phase-8:server code enhancements.
This commit is contained in:
committed by
saikiranvella
parent
93aac355cc
commit
ff1b99910b
@@ -657,6 +657,14 @@ Both `market-calls.json` and `portfolio.json` use `writeFileSync` with no concur
|
|||||||
|
|
||||||
Update `StockScorer.test.js` to cover the three new scoring factors: analyst consensus scoring (including the `numberOfAnalysts < 3` guard), DCF margin of safety scoring (positive/negative/null cases), and the new 52W risk flags.
|
Update `StockScorer.test.js` to cover the three new scoring factors: analyst consensus scoring (including the `numberOfAnalysts < 3` guard), DCF margin of safety scoring (positive/negative/null cases), and the new 52W risk flags.
|
||||||
|
|
||||||
|
#### 8l — Anthropic prompt caching for LLMAnalyst
|
||||||
|
|
||||||
|
`LLMAnalyst.analyze()` sends a large system prompt on every `/api/analyze` call. Enabling Anthropic prompt caching would cache the static system prompt across calls, reducing latency and token costs significantly.
|
||||||
|
|
||||||
|
Target: add `cache_control: { type: 'ephemeral' }` to the system prompt message block in `AnthropicClient.complete()` (or in `LLMAnalyst.analyze()` if the system prompt is built there). Use the `anthropic-beta: prompt-caching-2024-07-31` header. The cache has a 5-minute TTL and applies to the longest common prefix of consecutive requests — ideal for the static analysis instructions that never change between calls.
|
||||||
|
|
||||||
|
See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Adding a New Asset Type
|
## Adding a New Asset Type
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SimpleFINClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||||
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
||||||
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
|
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ export class SimpleFINClient {
|
|||||||
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
|
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
|
||||||
if (!this.accessUrl) await this.init();
|
if (!this.accessUrl) await this.init();
|
||||||
|
|
||||||
const startDate = options.startDate ?? this._daysAgo(30);
|
const startDate = options.startDate ?? this.daysAgo(30);
|
||||||
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
|
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const parsed = new URL(this.accessUrl!);
|
const parsed = new URL(this.accessUrl!);
|
||||||
@@ -59,13 +59,13 @@ export class SimpleFINClient {
|
|||||||
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._normalise(data as { accounts: unknown[]; errors: string[] });
|
return this.normalise(data as { accounts: unknown[]; errors: string[] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _claimAccessUrl(setupToken: string): Promise<string> {
|
private async claimAccessUrl(setupToken: string): Promise<string> {
|
||||||
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
||||||
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
|
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
|
||||||
const accessUrl = await this._post(claimUrl);
|
const accessUrl = await this.post(claimUrl);
|
||||||
if (!accessUrl || !accessUrl.startsWith('http')) {
|
if (!accessUrl || !accessUrl.startsWith('http')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
|
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
|
||||||
@@ -75,7 +75,7 @@ export class SimpleFINClient {
|
|||||||
return accessUrl.trim();
|
return accessUrl.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _post(url: string): Promise<string> {
|
private post(url: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const lib = parsed.protocol === 'https:' ? https : http;
|
const lib = parsed.protocol === 'https:' ? https : http;
|
||||||
@@ -101,7 +101,7 @@ export class SimpleFINClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
|
private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
|
||||||
const accounts = (data.accounts ?? []).map((acc: any) => ({
|
const accounts = (data.accounts ?? []).map((acc: any) => ({
|
||||||
id: acc.id,
|
id: acc.id,
|
||||||
name: acc.name,
|
name: acc.name,
|
||||||
@@ -109,19 +109,19 @@ export class SimpleFINClient {
|
|||||||
balance: parseFloat(acc.balance) ?? 0,
|
balance: parseFloat(acc.balance) ?? 0,
|
||||||
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
|
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
|
||||||
org: acc.org?.name ?? 'Unknown',
|
org: acc.org?.name ?? 'Unknown',
|
||||||
type: this._classifyAccount(acc.name),
|
type: this.classifyAccount(acc.name),
|
||||||
transactions: (acc.transactions ?? []).map((tx: any) => ({
|
transactions: (acc.transactions ?? []).map((tx: any) => ({
|
||||||
id: tx.id,
|
id: tx.id,
|
||||||
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
||||||
amount: parseFloat(tx.amount) ?? 0,
|
amount: parseFloat(tx.amount) ?? 0,
|
||||||
description: tx.description ?? '',
|
description: tx.description ?? '',
|
||||||
category: this._categorise(tx.description ?? ''),
|
category: this.categorise(tx.description ?? ''),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
return { accounts, errors: data.errors ?? [] };
|
return { accounts, errors: data.errors ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _classifyAccount(name: string): string {
|
private classifyAccount(name: string): string {
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
||||||
if (n.includes('saving')) return 'SAVINGS';
|
if (n.includes('saving')) return 'SAVINGS';
|
||||||
@@ -132,7 +132,7 @@ export class SimpleFINClient {
|
|||||||
return 'OTHER';
|
return 'OTHER';
|
||||||
}
|
}
|
||||||
|
|
||||||
private _categorise(description: string): string {
|
private categorise(description: string): string {
|
||||||
const d = description.toLowerCase();
|
const d = description.toLowerCase();
|
||||||
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
|
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
|
||||||
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
|
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
|
||||||
@@ -147,7 +147,7 @@ export class SimpleFINClient {
|
|||||||
return 'Other';
|
return 'Other';
|
||||||
}
|
}
|
||||||
|
|
||||||
private _daysAgo(n: number): number {
|
private daysAgo(n: number): number {
|
||||||
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
|
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export class Stock extends Asset {
|
|||||||
|
|
||||||
constructor(data: StockData) {
|
constructor(data: StockData) {
|
||||||
super(data);
|
super(data);
|
||||||
this.sector = this._mapToStandardSector(data);
|
this.sector = this.mapToStandardSector(data);
|
||||||
|
|
||||||
this.metrics = {
|
this.metrics = {
|
||||||
sector: this.sector,
|
sector: this.sector,
|
||||||
capCategory: this._classifyMarketCap(data.marketCap ?? null),
|
capCategory: this.classifyMarketCap(data.marketCap ?? null),
|
||||||
growthCategory: this._classifyGrowth(
|
growthCategory: this.classifyGrowth(
|
||||||
data.revenueGrowth ?? null,
|
data.revenueGrowth ?? null,
|
||||||
data.earningsGrowth ?? null,
|
data.earningsGrowth ?? null,
|
||||||
data.dividendYield ?? null,
|
data.dividendYield ?? null,
|
||||||
@@ -52,7 +52,7 @@ export class Stock extends Asset {
|
|||||||
|
|
||||||
// ── Market cap tier classification ──────────────────────────────────────
|
// ── Market cap tier classification ──────────────────────────────────────
|
||||||
// Thresholds follow MSCI/Russell institutional convention.
|
// Thresholds follow MSCI/Russell institutional convention.
|
||||||
_classifyMarketCap(marketCap: number | null): CapCategory {
|
classifyMarketCap(marketCap: number | null): CapCategory {
|
||||||
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
|
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
|
||||||
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
|
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
|
||||||
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
|
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
|
||||||
@@ -64,7 +64,7 @@ export class Stock extends Asset {
|
|||||||
// ── Growth / style classification ───────────────────────────────────────
|
// ── Growth / style classification ───────────────────────────────────────
|
||||||
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
|
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
|
||||||
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
|
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
|
||||||
_classifyGrowth(
|
classifyGrowth(
|
||||||
revenueGrowth: number | null,
|
revenueGrowth: number | null,
|
||||||
earningsGrowth: number | null,
|
earningsGrowth: number | null,
|
||||||
dividendYield: number | null,
|
dividendYield: number | null,
|
||||||
@@ -81,7 +81,7 @@ export class Stock extends Asset {
|
|||||||
return GROWTH_CATEGORY.STABLE;
|
return GROWTH_CATEGORY.STABLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapToStandardSector(data: StockData): Sector {
|
mapToStandardSector(data: StockData): Sector {
|
||||||
const profile = data.assetProfile ?? {};
|
const profile = data.assetProfile ?? {};
|
||||||
const industry = (profile.industry || '').toLowerCase();
|
const industry = (profile.industry || '').toLowerCase();
|
||||||
const sector = (profile.sector || '').toLowerCase();
|
const sector = (profile.sector || '').toLowerCase();
|
||||||
|
|||||||
@@ -3,29 +3,35 @@ import { randomUUID } from 'crypto';
|
|||||||
import type { MarketCall, CreateCallInput, StoreData } from '../types';
|
import type { MarketCall, CreateCallInput, StoreData } from '../types';
|
||||||
|
|
||||||
export class MarketCallRepository {
|
export class MarketCallRepository {
|
||||||
private static readonly STORE_PATH = './market-calls.json';
|
private static readonly DEFAULT_PATH = './market-calls.json';
|
||||||
|
|
||||||
private _load(): StoreData {
|
private readonly storePath: string;
|
||||||
if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] };
|
|
||||||
|
constructor(storePath?: string) {
|
||||||
|
this.storePath = storePath ?? MarketCallRepository.DEFAULT_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): StoreData {
|
||||||
|
if (!existsSync(this.storePath)) return { calls: [] };
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData;
|
return JSON.parse(readFileSync(this.storePath, 'utf8')) as StoreData;
|
||||||
} catch {
|
} catch {
|
||||||
return { calls: [] };
|
return { calls: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _save(data: StoreData): void {
|
private save(data: StoreData): void {
|
||||||
writeFileSync(MarketCallRepository.STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): (MarketCall & { createdAt: string })[] {
|
list(): (MarketCall & { createdAt: string })[] {
|
||||||
return this._load().calls.sort(
|
return this.load().calls.sort(
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): (MarketCall & { createdAt: string }) | null {
|
get(id: string): (MarketCall & { createdAt: string }) | null {
|
||||||
return this._load().calls.find((c) => c.id === id) ?? null;
|
return this.load().calls.find((c) => c.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create({
|
create({
|
||||||
@@ -36,7 +42,7 @@ export class MarketCallRepository {
|
|||||||
tickers,
|
tickers,
|
||||||
snapshot,
|
snapshot,
|
||||||
}: CreateCallInput): MarketCall & { createdAt: string } {
|
}: CreateCallInput): MarketCall & { createdAt: string } {
|
||||||
const data = this._load();
|
const data = this.load();
|
||||||
const call = {
|
const call = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
title,
|
title,
|
||||||
@@ -48,16 +54,16 @@ export class MarketCallRepository {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
data.calls.push(call);
|
data.calls.push(call);
|
||||||
this._save(data);
|
this.save(data);
|
||||||
return call;
|
return call;
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: string): boolean {
|
delete(id: string): boolean {
|
||||||
const data = this._load();
|
const data = this.load();
|
||||||
const before = data.calls.length;
|
const before = data.calls.length;
|
||||||
data.calls = data.calls.filter((c) => c.id !== id);
|
data.calls = data.calls.filter((c) => c.id !== id);
|
||||||
if (data.calls.length === before) return false;
|
if (data.calls.length === before) return false;
|
||||||
this._save(data);
|
this.save(data);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class BondScorer {
|
|||||||
context?: MarketContext | null,
|
context?: MarketContext | null,
|
||||||
): ScoreResult {
|
): ScoreResult {
|
||||||
const { gates, weights, thresholds } = rules;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = BondScorer._sanitize(m);
|
const metrics = BondScorer.sanitize(m);
|
||||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||||
|
|
||||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||||
@@ -37,7 +37,7 @@ export class BondScorer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
|
private static sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||||
const pct = (v: unknown): number =>
|
const pct = (v: unknown): number =>
|
||||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class StockScorer {
|
|||||||
},
|
},
|
||||||
): ScoreResult {
|
): ScoreResult {
|
||||||
const { gates, weights, thresholds } = rules;
|
const { gates, weights, thresholds } = rules;
|
||||||
const m = StockScorer._sanitize(metrics);
|
const m = StockScorer.sanitize(metrics);
|
||||||
|
|
||||||
const failures = [
|
const failures = [
|
||||||
m.debtToEquity != null &&
|
m.debtToEquity != null &&
|
||||||
@@ -208,20 +208,20 @@ export class StockScorer {
|
|||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: StockScorer._label(totalScore),
|
label: StockScorer.label(totalScore),
|
||||||
scoreSummary: `Score: ${totalScore}`,
|
scoreSummary: `Score: ${totalScore}`,
|
||||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static _label(score: number): string {
|
private static label(score: number): string {
|
||||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||||
if (score >= 0) return '🟡 HOLD';
|
if (score >= 0) return '🟡 HOLD';
|
||||||
return '🔴 REJECT';
|
return '🔴 REJECT';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static _sanitize(m: StockMetrics): SanitizedMetrics {
|
private static sanitize(m: StockMetrics): SanitizedMetrics {
|
||||||
const w52 =
|
const w52 =
|
||||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class CatalystAnalyst {
|
|||||||
|
|
||||||
async run(): Promise<CatalystResult> {
|
async run(): Promise<CatalystResult> {
|
||||||
this.logger.write('🔍 Fetching market news...');
|
this.logger.write('🔍 Fetching market news...');
|
||||||
const rawStories = await this._fetchNews();
|
const rawStories = await this.fetchNews();
|
||||||
|
|
||||||
if (!rawStories.length) {
|
if (!rawStories.length) {
|
||||||
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
|
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
|
||||||
@@ -67,7 +67,7 @@ export class CatalystAnalyst {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchNews(): Promise<YahooNewsItem[]> {
|
private async fetchNews(): Promise<YahooNewsItem[]> {
|
||||||
const seen = new Map<string, YahooNewsItem>();
|
const seen = new Map<string, YahooNewsItem>();
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
for (const query of CatalystAnalyst.NEWS_QUERIES) {
|
for (const query of CatalystAnalyst.NEWS_QUERIES) {
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export class MarketRegime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
||||||
if (type === ASSET_TYPE.STOCK) return this._stock(sector);
|
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
|
||||||
if (type === ASSET_TYPE.ETF) return this._etf();
|
if (type === ASSET_TYPE.ETF) return this.etf();
|
||||||
if (type === ASSET_TYPE.BOND) return this._bond();
|
if (type === ASSET_TYPE.BOND) return this.bond();
|
||||||
return { gates: {}, thresholds: {} };
|
return { gates: {}, thresholds: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _stock(sector?: string): InflatedOverrides {
|
private stock(sector?: string): InflatedOverrides {
|
||||||
if (sector === SECTOR.REIT) {
|
if (sector === SECTOR.REIT) {
|
||||||
return {
|
return {
|
||||||
gates: {},
|
gates: {},
|
||||||
@@ -55,11 +55,11 @@ export class MarketRegime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _etf(): InflatedOverrides {
|
private etf(): InflatedOverrides {
|
||||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _bond(): InflatedOverrides {
|
private bond(): InflatedOverrides {
|
||||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||||
return {
|
return {
|
||||||
gates: {},
|
gates: {},
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class PortfolioAdvisor {
|
|||||||
resultMap[t.replace(/\./g, '-')] = r;
|
resultMap[t.replace(/\./g, '-')] = r;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
||||||
|
|
||||||
return holdings.map((holding) => {
|
return holdings.map((holding) => {
|
||||||
const type = (holding.type ?? 'stock').toLowerCase();
|
const type = (holding.type ?? 'stock').toLowerCase();
|
||||||
@@ -36,35 +36,35 @@ export class PortfolioAdvisor {
|
|||||||
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
|
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
|
||||||
|
|
||||||
return type === 'crypto'
|
return type === 'crypto'
|
||||||
? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price))
|
? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price))
|
||||||
: this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
: this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _stockRow(
|
private stockRow(
|
||||||
holding: PortfolioHolding,
|
holding: PortfolioHolding,
|
||||||
price: number | null,
|
price: number | null,
|
||||||
source: string,
|
source: string,
|
||||||
result: AssetResult | undefined,
|
result: AssetResult | undefined,
|
||||||
): AdviceRow {
|
): AdviceRow {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return this._row(holding, price, source, '—', '—', '—', {
|
return this.row(holding, price, source, '—', '—', '—', {
|
||||||
action: '⚪ Not screened',
|
action: '⚪ Not screened',
|
||||||
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this._row(
|
return this.row(
|
||||||
holding,
|
holding,
|
||||||
price,
|
price,
|
||||||
source,
|
source,
|
||||||
result.signal,
|
result.signal,
|
||||||
result.inflated.label,
|
result.inflated.label,
|
||||||
result.fundamental.label,
|
result.fundamental.label,
|
||||||
this._advice(result.signal, holding, price),
|
this.advice(result.signal, holding, price),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _row(
|
private row(
|
||||||
holding: PortfolioHolding,
|
holding: PortfolioHolding,
|
||||||
currentPrice: number | null,
|
currentPrice: number | null,
|
||||||
source: string,
|
source: string,
|
||||||
@@ -73,7 +73,7 @@ export class PortfolioAdvisor {
|
|||||||
fundamental: string,
|
fundamental: string,
|
||||||
{ action, reason }: AdviceOutput,
|
{ action, reason }: AdviceOutput,
|
||||||
): AdviceRow {
|
): AdviceRow {
|
||||||
const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
|
const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice);
|
||||||
return {
|
return {
|
||||||
ticker: holding.ticker,
|
ticker: holding.ticker,
|
||||||
type: holding.type ?? 'stock',
|
type: holding.type ?? 'stock',
|
||||||
@@ -92,7 +92,7 @@ export class PortfolioAdvisor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
|
private position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
|
||||||
return {
|
return {
|
||||||
totalCost: (holding.costBasis * holding.shares).toFixed(2),
|
totalCost: (holding.costBasis * holding.shares).toFixed(2),
|
||||||
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
|
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
|
||||||
@@ -103,8 +103,8 @@ export class PortfolioAdvisor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
|
private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||||
const { gainLossPct } = this._position(holding, price);
|
const { gainLossPct } = this.position(holding, price);
|
||||||
const g = parseFloat(gainLossPct ?? 'NaN');
|
const g = parseFloat(gainLossPct ?? 'NaN');
|
||||||
if (gainLossPct == null)
|
if (gainLossPct == null)
|
||||||
return {
|
return {
|
||||||
@@ -127,8 +127,8 @@ export class PortfolioAdvisor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
|
private advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||||
const { gainLossPct } = this._position(holding, price);
|
const { gainLossPct } = this.position(holding, price);
|
||||||
const gain = parseFloat(gainLossPct ?? '0');
|
const gain = parseFloat(gainLossPct ?? '0');
|
||||||
switch (signal) {
|
switch (signal) {
|
||||||
case SIGNAL.STRONG_BUY:
|
case SIGNAL.STRONG_BUY:
|
||||||
@@ -164,9 +164,7 @@ export class PortfolioAdvisor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _cryptoPrices(
|
private async cryptoPrices(holdings: PortfolioHolding[]): Promise<Record<string, number | null>> {
|
||||||
holdings: PortfolioHolding[],
|
|
||||||
): Promise<Record<string, number | null>> {
|
|
||||||
const prices: Record<string, number | null> = {};
|
const prices: Record<string, number | null> = {};
|
||||||
for (const h of holdings) {
|
for (const h of holdings) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -44,24 +44,24 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||||
return this._screenInternal(tickers, false);
|
return this.screenInternal(tickers, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
||||||
return this._screenInternal(tickers, true);
|
return this.screenInternal(tickers, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
private async screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
||||||
const marketContext = await this._fetchMarketContext(showProgress);
|
const marketContext = await this.fetchMarketContext(showProgress);
|
||||||
const results = this._initializeResults();
|
const results = this.initializeResults();
|
||||||
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await this._processBatch(chunk, marketContext, results);
|
await this.processBatch(chunk, marketContext, results);
|
||||||
processed += chunk.length;
|
processed += chunk.length;
|
||||||
this._logProgress(showProgress, processed, tickers.length);
|
this.logProgress(showProgress, processed, tickers.length);
|
||||||
await this._rateLimitDelay();
|
await this.rateLimitDelay();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showProgress) {
|
if (showProgress) {
|
||||||
@@ -71,7 +71,7 @@ export class ScreenerEngine {
|
|||||||
return { ...results, marketContext };
|
return { ...results, marketContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
private async fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
||||||
if (showProgress) {
|
if (showProgress) {
|
||||||
this.logger.write('⏳ Fetching market context...');
|
this.logger.write('⏳ Fetching market context...');
|
||||||
}
|
}
|
||||||
@@ -82,30 +82,30 @@ export class ScreenerEngine {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
private initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
||||||
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _processBatch(
|
private async processBatch(
|
||||||
tickers: string[],
|
tickers: string[],
|
||||||
marketContext: MarketContext,
|
marketContext: MarketContext,
|
||||||
results: Omit<ScreenerResult, 'marketContext'>,
|
results: Omit<ScreenerResult, 'marketContext'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const batch = await Promise.all(tickers.map((t) => this._fetch(t)));
|
const batch = await Promise.all(tickers.map((t) => this.fetch(t)));
|
||||||
batch.forEach((data) => this._process(data, marketContext, results));
|
batch.forEach((data) => this.process(data, marketContext, results));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _logProgress(showProgress: boolean, processed: number, total: number): void {
|
private logProgress(showProgress: boolean, processed: number, total: number): void {
|
||||||
if (showProgress) {
|
if (showProgress) {
|
||||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
|
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _rateLimitDelay(): Promise<void> {
|
private async rateLimitDelay(): Promise<void> {
|
||||||
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
||||||
try {
|
try {
|
||||||
const summary = await this.client.fetchSummary(ticker);
|
const summary = await this.client.fetchSummary(ticker);
|
||||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||||
@@ -115,7 +115,7 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _process(
|
private process(
|
||||||
data: MappedData | ErrorResult,
|
data: MappedData | ErrorResult,
|
||||||
marketContext: MarketContext,
|
marketContext: MarketContext,
|
||||||
results: Omit<ScreenerResult, 'marketContext'>,
|
results: Omit<ScreenerResult, 'marketContext'>,
|
||||||
@@ -127,15 +127,15 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const asset = this._buildAsset(data as MappedData);
|
const asset = this.buildAsset(data as MappedData);
|
||||||
const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
const fundamental = this.score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
||||||
const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED);
|
const inflated = this.score(asset, marketContext, SCORE_MODE.INFLATED);
|
||||||
|
|
||||||
(results[asset.type as AssetType] as unknown[]).push({
|
(results[asset.type as AssetType] as unknown[]).push({
|
||||||
asset,
|
asset,
|
||||||
fundamental,
|
fundamental,
|
||||||
inflated,
|
inflated,
|
||||||
signal: this._signal(fundamental.label, inflated.label),
|
signal: this.signal(fundamental.label, inflated.label),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.ERROR.push({
|
results.ERROR.push({
|
||||||
@@ -147,7 +147,7 @@ export class ScreenerEngine {
|
|||||||
|
|
||||||
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
|
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
|
||||||
// its exact metrics type. No `as never` or unsafe casts required.
|
// its exact metrics type. No `as never` or unsafe casts required.
|
||||||
private _score(
|
private score(
|
||||||
asset: Stock | Etf | Bond,
|
asset: Stock | Etf | Bond,
|
||||||
marketContext: MarketContext,
|
marketContext: MarketContext,
|
||||||
mode: string,
|
mode: string,
|
||||||
@@ -165,7 +165,7 @@ export class ScreenerEngine {
|
|||||||
throw new Error('No scorer for unknown asset type');
|
throw new Error('No scorer for unknown asset type');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
private buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
||||||
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
|
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||||
case ASSET_TYPE.BOND:
|
case ASSET_TYPE.BOND:
|
||||||
return new Bond(data as BondData);
|
return new Bond(data as BondData);
|
||||||
@@ -176,7 +176,7 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
private signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
||||||
const green = (l: string) => l.startsWith('🟢');
|
const green = (l: string) => l.startsWith('🟢');
|
||||||
const yellow = (l: string) => l.startsWith('🟡');
|
const yellow = (l: string) => l.startsWith('🟡');
|
||||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for MarketCallRepository
|
||||||
|
* Each test gets its own temp file so tests are fully isolated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { MarketCallRepository } from '../server/repositories/MarketCallRepository';
|
||||||
|
|
||||||
|
// ── Temp-file helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tmpDirs: string[] = [];
|
||||||
|
|
||||||
|
function tempRepo(): MarketCallRepository {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'mkt-calls-test-'));
|
||||||
|
const path = join(dir, 'calls.json');
|
||||||
|
tmpDirs.push(dir);
|
||||||
|
return new MarketCallRepository(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
for (const dir of tmpDirs) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CALL_INPUT = {
|
||||||
|
title: 'Rate pivot play',
|
||||||
|
quarter: 'Q3 2025',
|
||||||
|
thesis: 'Fed cuts expected — rotate into duration and growth.',
|
||||||
|
tickers: ['TLT', 'QQQ'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('list() returns empty array when file does not exist', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
assert.deepEqual(repo.list(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create() returns call with id, createdAt, and correct fields', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const call = repo.create(CALL_INPUT);
|
||||||
|
assert.ok(call.id, 'id should be set');
|
||||||
|
assert.ok(call.createdAt, 'createdAt should be set');
|
||||||
|
assert.equal(call.title, CALL_INPUT.title);
|
||||||
|
assert.equal(call.quarter, CALL_INPUT.quarter);
|
||||||
|
assert.equal(call.thesis, CALL_INPUT.thesis);
|
||||||
|
assert.deepEqual(call.tickers, CALL_INPUT.tickers);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create() persists to disk — list() returns the created call', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
repo.create(CALL_INPUT);
|
||||||
|
assert.equal(repo.list().length, 1);
|
||||||
|
assert.equal(repo.list()[0].title, CALL_INPUT.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list() returns calls newest-first', () => {
|
||||||
|
// Write two calls directly with distinct timestamps to guarantee stable ordering.
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'mkt-order-'));
|
||||||
|
tmpDirs.push(dir);
|
||||||
|
const path = join(dir, 'calls.json');
|
||||||
|
|
||||||
|
const older = {
|
||||||
|
id: 'old-id',
|
||||||
|
title: 'First',
|
||||||
|
quarter: 'Q1',
|
||||||
|
date: '2025-01-01',
|
||||||
|
thesis: 'A',
|
||||||
|
tickers: [],
|
||||||
|
snapshot: {},
|
||||||
|
createdAt: '2025-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const newer = {
|
||||||
|
id: 'new-id',
|
||||||
|
title: 'Second',
|
||||||
|
quarter: 'Q1',
|
||||||
|
date: '2025-01-02',
|
||||||
|
thesis: 'B',
|
||||||
|
tickers: [],
|
||||||
|
snapshot: {},
|
||||||
|
createdAt: '2025-01-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
writeFileSync(path, JSON.stringify({ calls: [older, newer] }), 'utf8');
|
||||||
|
|
||||||
|
const repo = new MarketCallRepository(path);
|
||||||
|
const list = repo.list();
|
||||||
|
assert.equal(list[0].id, 'new-id', 'newer call should be first');
|
||||||
|
assert.equal(list[1].id, 'old-id', 'older call should be second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get() returns the call by id', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const call = repo.create(CALL_INPUT);
|
||||||
|
const found = repo.get(call.id);
|
||||||
|
assert.ok(found, 'should find by id');
|
||||||
|
assert.equal(found!.id, call.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get() returns null for unknown id', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
assert.equal(repo.get('nonexistent-id'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete() removes the call and returns true', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const call = repo.create(CALL_INPUT);
|
||||||
|
const ok = repo.delete(call.id);
|
||||||
|
assert.equal(ok, true);
|
||||||
|
assert.equal(repo.list().length, 0);
|
||||||
|
assert.equal(repo.get(call.id), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete() returns false for unknown id', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
assert.equal(repo.delete('no-such-id'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete() only removes the targeted call, leaves others intact', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const a = repo.create({ ...CALL_INPUT, title: 'Keep me' });
|
||||||
|
const b = repo.create({ ...CALL_INPUT, title: 'Delete me' });
|
||||||
|
repo.delete(b.id);
|
||||||
|
const list = repo.list();
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0].id, a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create() stores snapshot when provided', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const snapshot = { TLT: { price: 95.5, signal: '✅ Strong Buy' } };
|
||||||
|
const call = repo.create({ ...CALL_INPUT, snapshot } as any);
|
||||||
|
const found = repo.get(call.id)!;
|
||||||
|
assert.deepEqual(found.snapshot, snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create() sets default date when not provided', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const call = repo.create(CALL_INPUT);
|
||||||
|
assert.match(call.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create() uses provided date', () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
const call = repo.create({ ...CALL_INPUT, date: '2025-03-15' });
|
||||||
|
assert.equal(call.date, '2025-03-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent writes: two rapid creates do not lose data', async () => {
|
||||||
|
const repo = tempRepo();
|
||||||
|
// Both writes happen synchronously (writeFileSync), so the second
|
||||||
|
// always sees the first. This test documents the behaviour.
|
||||||
|
const a = repo.create({ ...CALL_INPUT, title: 'A' });
|
||||||
|
const b = repo.create({ ...CALL_INPUT, title: 'B' });
|
||||||
|
const list = repo.list();
|
||||||
|
assert.equal(list.length, 2, 'both calls should be persisted');
|
||||||
|
const ids = new Set(list.map((c) => c.id));
|
||||||
|
assert.ok(ids.has(a.id), 'call A should be present');
|
||||||
|
assert.ok(ids.has(b.id), 'call B should be present');
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ const stubClient = {} as unknown as YahooFinanceClient;
|
|||||||
// Cast to any to access private methods — tests exercise internal behaviour directly.
|
// Cast to any to access private methods — tests exercise internal behaviour directly.
|
||||||
const advisor = new PortfolioAdvisor(stubClient) as any;
|
const advisor = new PortfolioAdvisor(stubClient) as any;
|
||||||
|
|
||||||
// Minimal holding shape used by _position and _advice (only costBasis/shares matter).
|
// Minimal holding shape used by position and advice (only costBasis/shares matter).
|
||||||
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
||||||
ticker: 'TEST',
|
ticker: 'TEST',
|
||||||
source: 'Test',
|
source: 'Test',
|
||||||
@@ -22,45 +22,45 @@ const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('_position: computes gain/loss correctly', () => {
|
test('_position: computes gain/loss correctly', () => {
|
||||||
const pos = advisor._position(holding(100, 10), 150);
|
const pos = advisor.position(holding(100, 10), 150);
|
||||||
assert.equal(pos.gainLossPct, '50.0');
|
assert.equal(pos.gainLossPct, '50.0');
|
||||||
assert.equal(pos.marketValue, '1500.00');
|
assert.equal(pos.marketValue, '1500.00');
|
||||||
assert.equal(pos.totalCost, '1000.00');
|
assert.equal(pos.totalCost, '1000.00');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_position: returns null gainLoss when price unavailable', () => {
|
test('_position: returns null gainLoss when price unavailable', () => {
|
||||||
const pos = advisor._position(holding(100, 10), null);
|
const pos = advisor.position(holding(100, 10), null);
|
||||||
assert.equal(pos.gainLossPct, null);
|
assert.equal(pos.gainLossPct, null);
|
||||||
assert.equal(pos.marketValue, null);
|
assert.equal(pos.marketValue, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Strong Buy → Hold & Add', () => {
|
test('_advice: Strong Buy → Hold & Add', () => {
|
||||||
const { action } = advisor._advice(SIGNAL.STRONG_BUY, holding(100, 10), 150);
|
const { action } = advisor.advice(SIGNAL.STRONG_BUY, holding(100, 10), 150);
|
||||||
assert.equal(action, '🟢 Hold & Add');
|
assert.equal(action, '🟢 Hold & Add');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
||||||
const { action } = advisor._advice(SIGNAL.AVOID, holding(150, 10), 100);
|
const { action } = advisor.advice(SIGNAL.AVOID, holding(150, 10), 100);
|
||||||
assert.equal(action, '🔴 Sell (Cut Loss)');
|
assert.equal(action, '🔴 Sell (Cut Loss)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
||||||
const { action } = advisor._advice(SIGNAL.AVOID, holding(100, 10), 150);
|
const { action } = advisor.advice(SIGNAL.AVOID, holding(100, 10), 150);
|
||||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
||||||
const { action } = advisor._advice(SIGNAL.SPECULATION, holding(100, 10), 125);
|
const { action } = advisor.advice(SIGNAL.SPECULATION, holding(100, 10), 125);
|
||||||
assert.equal(action, '🟠 Reduce Position');
|
assert.equal(action, '🟠 Reduce Position');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_cryptoAdvice: no price → No price data', () => {
|
test('_cryptoAdvice: no price → No price data', () => {
|
||||||
const { action } = advisor._cryptoAdvice(holding(100, 1), null);
|
const { action } = advisor.cryptoAdvice(holding(100, 1), null);
|
||||||
assert.equal(action, '⚪ No price data');
|
assert.equal(action, '⚪ No price data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
||||||
const { action } = advisor._cryptoAdvice(holding(10000, 1), 25000);
|
const { action } = advisor.cryptoAdvice(holding(10000, 1), 25000);
|
||||||
assert.equal(action, '🟠 Consider taking profits');
|
assert.equal(action, '🟠 Consider taking profits');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for CallsController
|
||||||
|
* Uses Fastify inject() with an in-memory MarketCallRepository stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { CallsController } from '../server/controllers/calls.controller';
|
||||||
|
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
||||||
|
import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient';
|
||||||
|
import type { MarketCall, ScreenerResult, MarketContext, CreateCallInput } from '../server/types';
|
||||||
|
|
||||||
|
// ── Stubs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MARKET_CTX: MarketContext = {
|
||||||
|
sp500Price: 5000,
|
||||||
|
riskFreeRate: 4.5,
|
||||||
|
vixLevel: 18,
|
||||||
|
rateRegime: 'NORMAL',
|
||||||
|
volatilityRegime: 'NORMAL',
|
||||||
|
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RESULT: ScreenerResult = {
|
||||||
|
STOCK: [],
|
||||||
|
ETF: [],
|
||||||
|
BOND: [],
|
||||||
|
ERROR: [],
|
||||||
|
marketContext: MARKET_CTX,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubEngine = {
|
||||||
|
screenTickers: async () => EMPTY_RESULT,
|
||||||
|
} as unknown as ScreenerEngine;
|
||||||
|
|
||||||
|
const stubYahoo = {
|
||||||
|
fetchCalendarEvents: async () => null,
|
||||||
|
} as unknown as YahooFinanceClient;
|
||||||
|
|
||||||
|
// In-memory MarketCallRepository stub
|
||||||
|
function makeRepoStub() {
|
||||||
|
const calls: (MarketCall & { createdAt: string })[] = [];
|
||||||
|
return {
|
||||||
|
list: () =>
|
||||||
|
[...calls].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||||
|
get: (id: string) => calls.find((c) => c.id === id) ?? null,
|
||||||
|
create: ({
|
||||||
|
title,
|
||||||
|
quarter,
|
||||||
|
date,
|
||||||
|
thesis,
|
||||||
|
tickers,
|
||||||
|
snapshot,
|
||||||
|
}: CreateCallInput & { snapshot: any }) => {
|
||||||
|
const call = {
|
||||||
|
id: `call-${calls.length + 1}`,
|
||||||
|
title,
|
||||||
|
quarter,
|
||||||
|
date: date ?? new Date().toISOString().slice(0, 10),
|
||||||
|
thesis,
|
||||||
|
tickers,
|
||||||
|
snapshot,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
calls.push(call);
|
||||||
|
return call;
|
||||||
|
},
|
||||||
|
delete: (id: string) => {
|
||||||
|
const idx = calls.findIndex((c) => c.id === id);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
calls.splice(idx, 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App factory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(cors, { origin: '*' });
|
||||||
|
new CallsController(makeRepoStub() as any, stubEngine, stubYahoo).register(app);
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GET /api/calls → 200 with empty calls list', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/calls' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.deepEqual(res.json(), { calls: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/calls → 201 and returns the created call', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: {
|
||||||
|
title: 'Q3 rate pivot play',
|
||||||
|
quarter: 'Q3 2025',
|
||||||
|
thesis: 'Fed cuts incoming — rotate into duration and growth.',
|
||||||
|
tickers: ['TLT', 'QQQ'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 201);
|
||||||
|
const body = res.json();
|
||||||
|
assert.equal(body.title, 'Q3 rate pivot play');
|
||||||
|
assert.deepEqual(body.tickers, ['TLT', 'QQQ']);
|
||||||
|
assert.ok(body.id);
|
||||||
|
assert.ok(body.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/calls → created call appears in GET /api/calls', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: {
|
||||||
|
title: 'AI semiconductor cycle',
|
||||||
|
quarter: 'Q4 2025',
|
||||||
|
thesis: 'Capex cycle benefits chip designers more than hyperscalers.',
|
||||||
|
tickers: ['NVDA', 'AMD'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const listRes = await app.inject({ method: 'GET', url: '/api/calls' });
|
||||||
|
assert.equal(listRes.json().calls.length, 1);
|
||||||
|
assert.equal(listRes.json().calls[0].title, 'AI semiconductor cycle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/calls with missing required fields → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: { title: 'incomplete' }, // missing quarter, thesis, tickers
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/calls with thesis too short → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: { title: 'Test', quarter: 'Q1', thesis: 'short', tickers: ['AAPL'] },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/calls/:id on non-existent id → 404', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/calls/nonexistent' });
|
||||||
|
assert.equal(res.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/calls/:id removes the call', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: {
|
||||||
|
title: 'Call to delete',
|
||||||
|
quarter: 'Q1 2025',
|
||||||
|
thesis: 'This call will be deleted in the test.',
|
||||||
|
tickers: ['SPY'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { id } = created.json();
|
||||||
|
|
||||||
|
const del = await app.inject({ method: 'DELETE', url: `/api/calls/${id}` });
|
||||||
|
assert.equal(del.statusCode, 200);
|
||||||
|
assert.deepEqual(del.json(), { ok: true });
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/calls' });
|
||||||
|
assert.equal(list.json().calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/calls/:id on non-existent id → 404', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/calls/no-such-id' });
|
||||||
|
assert.equal(res.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/calls/:id returns call with current snapshot shape', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/calls',
|
||||||
|
payload: {
|
||||||
|
title: 'Rate trade',
|
||||||
|
quarter: 'Q2 2025',
|
||||||
|
thesis: 'Long duration bonds when yield curve inverts.',
|
||||||
|
tickers: ['TLT'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { id } = created.json();
|
||||||
|
const res = await app.inject({ method: 'GET', url: `/api/calls/${id}` });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
const body = res.json();
|
||||||
|
assert.equal(body.id, id);
|
||||||
|
assert.ok('current' in body, 'response should include current snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/calls/calendar with no calls → 200 empty events', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/calls/calendar' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.deepEqual(res.json(), { events: [] });
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for FinanceController
|
||||||
|
* Uses Fastify inject() with stub engine, advisor, and in-memory portfolio repo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { FinanceController } from '../server/controllers/finance.controller';
|
||||||
|
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
||||||
|
import type { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
||||||
|
import type { PortfolioHolding, MarketContext, ScreenerResult } from '../server/types';
|
||||||
|
|
||||||
|
// ── Stubs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MARKET_CTX: MarketContext = {
|
||||||
|
sp500Price: 5000,
|
||||||
|
riskFreeRate: 4.5,
|
||||||
|
vixLevel: 18,
|
||||||
|
rateRegime: 'NORMAL',
|
||||||
|
volatilityRegime: 'NORMAL',
|
||||||
|
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RESULT: ScreenerResult = {
|
||||||
|
STOCK: [],
|
||||||
|
ETF: [],
|
||||||
|
BOND: [],
|
||||||
|
ERROR: [],
|
||||||
|
marketContext: MARKET_CTX,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stubEngine = {
|
||||||
|
screenTickers: async () => EMPTY_RESULT,
|
||||||
|
getMarketContext: async () => MARKET_CTX,
|
||||||
|
} as unknown as ScreenerEngine;
|
||||||
|
|
||||||
|
const stubAdvisor = {
|
||||||
|
advise: async () => [],
|
||||||
|
} as unknown as PortfolioAdvisor;
|
||||||
|
|
||||||
|
// In-memory PortfolioRepository stub
|
||||||
|
function makePortfolioRepo(seed: PortfolioHolding[] = []) {
|
||||||
|
const holdings: PortfolioHolding[] = [...seed];
|
||||||
|
return {
|
||||||
|
exists: () => true,
|
||||||
|
read: () => ({ holdings: [...holdings] }),
|
||||||
|
upsert: (entry: PortfolioHolding) => {
|
||||||
|
const idx = holdings.findIndex((h) => h.ticker === entry.ticker);
|
||||||
|
if (idx >= 0) holdings[idx] = entry;
|
||||||
|
else holdings.push(entry);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
remove: (ticker: string) => {
|
||||||
|
const idx = holdings.findIndex((h) => h.ticker === ticker);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
holdings.splice(idx, 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyRepo() {
|
||||||
|
return {
|
||||||
|
exists: () => false,
|
||||||
|
read: () => ({ holdings: [] }),
|
||||||
|
upsert: () => {},
|
||||||
|
remove: () => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App factory ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildTestApp(repo = makePortfolioRepo()) {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(cors, { origin: '*' });
|
||||||
|
new FinanceController(stubEngine, repo as any, stubAdvisor).register(app);
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GET /api/finance/portfolio → 200 with advice and marketContext keys', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
const body = res.json();
|
||||||
|
assert.ok(Array.isArray(body.advice), 'advice should be array');
|
||||||
|
assert.ok(body.marketContext, 'marketContext should be present');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/finance/portfolio with no portfolio.json → 404', async () => {
|
||||||
|
const app = await buildTestApp(makeEmptyRepo() as any);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' });
|
||||||
|
assert.equal(res.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/finance/market-context → 200 with benchmark fields', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/finance/market-context' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
const body = res.json();
|
||||||
|
assert.ok(typeof body.riskFreeRate === 'number');
|
||||||
|
assert.ok(typeof body.sp500Price === 'number');
|
||||||
|
assert.ok(body.benchmarks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/finance/holdings → 201 and returns the holding', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/finance/holdings',
|
||||||
|
payload: { ticker: 'AAPL', shares: 10, costBasis: 150, type: 'stock', source: 'Robinhood' },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 201);
|
||||||
|
const body = res.json();
|
||||||
|
assert.equal(body.ticker, 'AAPL');
|
||||||
|
assert.equal(body.shares, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/finance/holdings with missing shares → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/finance/holdings',
|
||||||
|
payload: { ticker: 'AAPL' },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/finance/holdings with missing ticker → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/finance/holdings',
|
||||||
|
payload: { shares: 5 },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/finance/holdings with zero shares → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/finance/holdings',
|
||||||
|
payload: { ticker: 'AAPL', shares: 0 },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/finance/holdings with invalid type → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/finance/holdings',
|
||||||
|
payload: { ticker: 'AAPL', shares: 5, type: 'options' },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/finance/holdings/:ticker removes existing holding → 200', async () => {
|
||||||
|
const repo = makePortfolioRepo([
|
||||||
|
{ ticker: 'MSFT', shares: 5, costBasis: 300, type: 'stock', source: 'Manual' },
|
||||||
|
]);
|
||||||
|
const app = await buildTestApp(repo);
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/MSFT' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.deepEqual(res.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /api/finance/holdings/:ticker on missing ticker → 404', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/NOTHERE' });
|
||||||
|
assert.equal(res.statusCode, 404);
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for ScreenerController + /health
|
||||||
|
* Uses Fastify inject() — no real Yahoo calls, no live server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { ScreenerController } from '../server/controllers/screener.controller';
|
||||||
|
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
||||||
|
import type { ScreenerResult, MarketContext } from '../server/types';
|
||||||
|
|
||||||
|
// ── Fixture data ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MARKET_CTX: MarketContext = {
|
||||||
|
sp500Price: 5000,
|
||||||
|
riskFreeRate: 4.5,
|
||||||
|
vixLevel: 18,
|
||||||
|
rateRegime: 'NORMAL',
|
||||||
|
volatilityRegime: 'NORMAL',
|
||||||
|
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RESULT: ScreenerResult = {
|
||||||
|
STOCK: [],
|
||||||
|
ETF: [],
|
||||||
|
BOND: [],
|
||||||
|
ERROR: [],
|
||||||
|
marketContext: MARKET_CTX,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stub ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stubEngine = {
|
||||||
|
screenTickers: async (_tickers: string[]) => EMPTY_RESULT,
|
||||||
|
getMarketContext: async () => MARKET_CTX,
|
||||||
|
} as unknown as ScreenerEngine;
|
||||||
|
|
||||||
|
// ── App factory ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(cors, { origin: '*' });
|
||||||
|
new ScreenerController(stubEngine).register(app);
|
||||||
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GET /health → 200 { status: ok }', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.deepEqual(res.json(), { status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/screen → 200 with STOCK/ETF/BOND/ERROR/marketContext keys', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/screen',
|
||||||
|
payload: { tickers: ['AAPL'] },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
const body = res.json();
|
||||||
|
assert.ok(Array.isArray(body.STOCK), 'STOCK should be array');
|
||||||
|
assert.ok(Array.isArray(body.ETF), 'ETF should be array');
|
||||||
|
assert.ok(Array.isArray(body.BOND), 'BOND should be array');
|
||||||
|
assert.ok(Array.isArray(body.ERROR), 'ERROR should be array');
|
||||||
|
assert.ok(body.marketContext, 'marketContext should be present');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/screen → marketContext has expected shape', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/screen',
|
||||||
|
payload: { tickers: ['MSFT'] },
|
||||||
|
});
|
||||||
|
const { marketContext } = res.json();
|
||||||
|
assert.ok(typeof marketContext.riskFreeRate === 'number');
|
||||||
|
assert.ok(typeof marketContext.sp500Price === 'number');
|
||||||
|
assert.ok(typeof marketContext.vixLevel === 'number');
|
||||||
|
assert.ok(marketContext.benchmarks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/screen with missing tickers → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/screen',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/screen with empty tickers array → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/screen',
|
||||||
|
payload: { tickers: [] },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/screen with too many tickers (>50) → 400', async () => {
|
||||||
|
const app = await buildTestApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/screen',
|
||||||
|
payload: { tickers: Array.from({ length: 51 }, (_, i) => `T${i}`) },
|
||||||
|
});
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user