phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance - Migrated 58 TypeScript files to domain-driven structure - Updated CLAUDE.md with new architecture documentation - Added .gitignore rules for .md files (except CLAUDE.md) - Removed unused CatalystAnalyst import from app.ts - Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions - Verified no sensitive data in git history - Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
committed by
saikiranvella
parent
c7e39c3e4e
commit
c388b6d83c
@@ -1,63 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BondScorer } from '../server/scorers/BondScorer';
|
||||
import type { MarketContext } from '../server/types';
|
||||
|
||||
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||
|
||||
const rules = {
|
||||
gates: { minCreditRating: 7 },
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
||||
};
|
||||
// BondScorer only uses riskFreeRate from context; cast the partial fixture to satisfy the type.
|
||||
const ctx = { riskFreeRate: 4.5 } as MarketContext;
|
||||
|
||||
test('rejects bond below investment-grade floor', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🔴 Avoid');
|
||||
assert(result.scoreSummary.includes('Gate failed'));
|
||||
});
|
||||
|
||||
test('attractive for wide spread and short duration', () => {
|
||||
// ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🟢 Attractive');
|
||||
});
|
||||
|
||||
test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown!.spread, rules.weights.yieldSpread);
|
||||
});
|
||||
|
||||
test('fails spread when yield barely above risk-free', () => {
|
||||
// ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown!.spread, -2);
|
||||
});
|
||||
|
||||
test('penalises long duration', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown!.duration, -1);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DataMapper } from '../server/services/DataMapper';
|
||||
|
||||
const base = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||
assetProfile: { sector: 'Technology', industry: 'Software', category: '' },
|
||||
financialData: {
|
||||
quickRatio: 1.2,
|
||||
debtToEquity: 150,
|
||||
freeCashflow: 5e9,
|
||||
revenueGrowth: 0.15,
|
||||
profitMargins: 0.25,
|
||||
operatingMargins: 0.3,
|
||||
returnOnEquity: 0.2,
|
||||
earningsGrowth: 0.12,
|
||||
operatingCashflow: 8e9,
|
||||
},
|
||||
defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 },
|
||||
summaryDetail: {
|
||||
trailingAnnualDividendYield: 0.005,
|
||||
trailingPE: 30,
|
||||
beta: 1.2,
|
||||
fiftyTwoWeekHigh: 200,
|
||||
fiftyTwoWeekLow: 120,
|
||||
},
|
||||
};
|
||||
|
||||
test('maps EQUITY quote type to STOCK', () => {
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.type, 'STOCK');
|
||||
assert.equal(result.ticker, 'AAPL');
|
||||
});
|
||||
|
||||
test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => {
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
||||
assert.equal(result.pegRatio, expected);
|
||||
});
|
||||
|
||||
test('uses Yahoo pegRatio when available', () => {
|
||||
const summary = {
|
||||
...base,
|
||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', summary);
|
||||
assert.equal(result.pegRatio, 1.5);
|
||||
});
|
||||
|
||||
test('debtToEquity is divided by 100', () => {
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
||||
});
|
||||
|
||||
test('maps ETF quoteType to ETF', () => {
|
||||
const etfSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Large Blend' },
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.type, 'ETF');
|
||||
});
|
||||
|
||||
test('classifies bond ETF from category keyword', () => {
|
||||
const bondSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.type, 'BOND');
|
||||
});
|
||||
|
||||
test('FCF yield is computed when data available', () => {
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
assert((result.fcfYield as number) > 0);
|
||||
});
|
||||
|
||||
test('peRatio prefers trailingPE over forwardPE', () => {
|
||||
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.peRatio, 30); // trailing should win
|
||||
});
|
||||
|
||||
test('negative FCF yield is preserved, not nulled', () => {
|
||||
const negativeFcf = {
|
||||
...base,
|
||||
financialData: { ...base.financialData, freeCashflow: -2e9 },
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', negativeFcf);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
assert((result.fcfYield as number) < 0, 'negative FCF should produce negative yield, not null');
|
||||
});
|
||||
|
||||
test('ETF maps volume from summaryDetail', () => {
|
||||
const etfSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Large Blend' },
|
||||
summaryDetail: {
|
||||
...base.summaryDetail,
|
||||
averageVolume: 5000000,
|
||||
expenseRatio: 0.0003,
|
||||
trailingAnnualDividendYield: 0.013,
|
||||
},
|
||||
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.volume, 5000000);
|
||||
});
|
||||
|
||||
test('bond duration inferred from category — intermediate maps to 5y', () => {
|
||||
const bondSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||
summaryDetail: { yield: 0.045 },
|
||||
defaultKeyStatistics: {},
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.duration, 5);
|
||||
});
|
||||
|
||||
test('bond duration inferred from category — short-term maps to 2y', () => {
|
||||
const bondSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Short-Term Bond' },
|
||||
summaryDetail: { yield: 0.05 },
|
||||
defaultKeyStatistics: {},
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('SHY', bondSummary);
|
||||
assert.equal(result.duration, 2);
|
||||
});
|
||||
|
||||
test('metrics are null (not 0) when data missing', () => {
|
||||
const sparse = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 100 },
|
||||
financialData: {},
|
||||
defaultKeyStatistics: {},
|
||||
summaryDetail: {},
|
||||
assetProfile: {},
|
||||
};
|
||||
const result = DataMapper.mapToStandardFormat('X', sparse);
|
||||
assert.equal(result.pegRatio, null);
|
||||
assert.equal(result.quickRatio, null);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../server/scorers/EtfScorer';
|
||||
import type { EtfMetrics } from '../server/types';
|
||||
|
||||
const rules = {
|
||||
gates: { maxExpenseRatio: 0.5 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
|
||||
};
|
||||
|
||||
// Helper to build minimal EtfMetrics fixtures (totalAssets/fiveYearReturn unused by scorer).
|
||||
const etf = (partial: Partial<EtfMetrics>): EtfMetrics => ({
|
||||
totalAssets: 0,
|
||||
fiveYearReturn: 0,
|
||||
volume: 0,
|
||||
yield: 0,
|
||||
expenseRatio: 0,
|
||||
...partial,
|
||||
});
|
||||
|
||||
test('rejects ETF with expense ratio above gate', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.8, yield: 2.0 }), rules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('efficient label for low-cost, high-yield ETF', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
||||
assert.equal(result.label, '🟢 Efficient');
|
||||
});
|
||||
|
||||
test('neutral when yield is below threshold', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }), rules);
|
||||
assert.equal(result.label, '🟡 Neutral');
|
||||
});
|
||||
|
||||
test('audit breakdown includes cost, yield, vol keys', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
||||
assert(result.audit.breakdown!.cost != null);
|
||||
assert(result.audit.breakdown!.yield != null);
|
||||
assert(result.audit.breakdown!.vol != null);
|
||||
});
|
||||
|
||||
test('penalises ETF with volume below liquidity floor', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }), rules);
|
||||
assert(result.audit.breakdown!.vol < 0, 'low-volume ETF should receive negative vol score');
|
||||
});
|
||||
|
||||
test('scores 5Y return when threshold configured', () => {
|
||||
const rulesWithReturn = {
|
||||
...rules,
|
||||
weights: { ...rules.weights, fiveYearReturn: 2 },
|
||||
thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 },
|
||||
};
|
||||
const good = EtfScorer.score(
|
||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 }),
|
||||
rulesWithReturn,
|
||||
);
|
||||
const poor = EtfScorer.score(
|
||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 }),
|
||||
rulesWithReturn,
|
||||
);
|
||||
assert(good.audit.breakdown!.fiveYearReturn > 0, 'strong 5Y return should score positively');
|
||||
assert(poor.audit.breakdown!.fiveYearReturn < 0, 'weak 5Y return should score negatively');
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Test the markdown fence stripping logic in isolation —
|
||||
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
|
||||
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
|
||||
|
||||
function stripFences(raw: string): string {
|
||||
return raw
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const VALID_JSON =
|
||||
'{"summary":"test","sentiment":"BULLISH","affectedIndustries":[],"relatedTickers":[]}';
|
||||
|
||||
test('stripFences: passes clean JSON through unchanged', () => {
|
||||
assert.equal(stripFences(VALID_JSON), VALID_JSON);
|
||||
});
|
||||
|
||||
test('stripFences: strips ```json ... ``` fences', () => {
|
||||
const wrapped = '```json\n' + VALID_JSON + '\n```';
|
||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
||||
});
|
||||
|
||||
test('stripFences: strips ``` ... ``` fences (no language tag)', () => {
|
||||
const wrapped = '```\n' + VALID_JSON + '\n```';
|
||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
||||
});
|
||||
|
||||
test('stripFences: result is valid parseable JSON', () => {
|
||||
const wrapped = '```json\n' + VALID_JSON + '\n```';
|
||||
const parsed = JSON.parse(stripFences(wrapped));
|
||||
assert.equal(parsed.sentiment, 'BULLISH');
|
||||
assert.equal(parsed.summary, 'test');
|
||||
});
|
||||
|
||||
test('stripFences: handles no trailing newline before closing fence', () => {
|
||||
const wrapped = '```json\n' + VALID_JSON + '```';
|
||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
||||
});
|
||||
|
||||
test('stripFences: case-insensitive fence tag', () => {
|
||||
const wrapped = '```JSON\n' + VALID_JSON + '\n```';
|
||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Unit tests for MarketCallRepository (SQLite-backed).
|
||||
* Each test gets its own in-memory database so tests are fully isolated.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
import { MarketCallRepository } from '../server/repositories/MarketCallRepository';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS market_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL, quarter TEXT NOT NULL, date TEXT NOT NULL,
|
||||
thesis TEXT NOT NULL, tickers TEXT NOT NULL, snapshot TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
function makeRepo(): MarketCallRepository {
|
||||
const db = new BetterSqlite3(':memory:');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.exec(DDL);
|
||||
return new MarketCallRepository(db);
|
||||
}
|
||||
|
||||
// ── 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 on fresh db', () => {
|
||||
assert.deepEqual(makeRepo().list(), []);
|
||||
});
|
||||
|
||||
test('create() returns call with id, createdAt, and correct fields', () => {
|
||||
const call = makeRepo().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 — list() returns the created call', () => {
|
||||
const repo = makeRepo();
|
||||
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', () => {
|
||||
const repo = makeRepo();
|
||||
const db = (repo as any).db as BetterSqlite3.Database;
|
||||
|
||||
// Insert two rows with distinct created_at values directly
|
||||
db.prepare(
|
||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
).run('old-id', 'First', 'Q1', '2025-01-01', 'A', '[]', '{}', '2025-01-01T00:00:00.000Z');
|
||||
db.prepare(
|
||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
).run('new-id', 'Second', 'Q1', '2025-01-02', 'B', '[]', '{}', '2025-01-02T00:00:00.000Z');
|
||||
|
||||
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 = makeRepo();
|
||||
const call = repo.create(CALL_INPUT);
|
||||
const found = repo.get(call.id);
|
||||
assert.ok(found);
|
||||
assert.equal(found!.id, call.id);
|
||||
});
|
||||
|
||||
test('get() returns null for unknown id', () => {
|
||||
assert.equal(makeRepo().get('no-such-id'), null);
|
||||
});
|
||||
|
||||
test('delete() removes the call and returns true', () => {
|
||||
const repo = makeRepo();
|
||||
const call = repo.create(CALL_INPUT);
|
||||
assert.equal(repo.delete(call.id), true);
|
||||
assert.equal(repo.list().length, 0);
|
||||
assert.equal(repo.get(call.id), null);
|
||||
});
|
||||
|
||||
test('delete() returns false for unknown id', () => {
|
||||
assert.equal(makeRepo().delete('no-such-id'), false);
|
||||
});
|
||||
|
||||
test('delete() only removes the targeted call', () => {
|
||||
const repo = makeRepo();
|
||||
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 = makeRepo();
|
||||
const snapshot = { TLT: { price: 95.5, signal: '✅ Strong Buy' } };
|
||||
const call = repo.create({ ...CALL_INPUT, snapshot } as any);
|
||||
assert.deepEqual(repo.get(call.id)!.snapshot, snapshot);
|
||||
});
|
||||
|
||||
test('create() sets default date when not provided', () => {
|
||||
const call = makeRepo().create(CALL_INPUT);
|
||||
assert.match(call.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
test('create() uses provided date', () => {
|
||||
const call = makeRepo().create({ ...CALL_INPUT, date: '2025-03-15' });
|
||||
assert.equal(call.date, '2025-03-15');
|
||||
});
|
||||
|
||||
test('concurrent writes: two rapid creates both persist (SQLite WAL is concurrency-safe)', () => {
|
||||
const repo = makeRepo();
|
||||
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);
|
||||
const ids = new Set(list.map((c) => c.id));
|
||||
assert.ok(ids.has(a.id));
|
||||
assert.ok(ids.has(b.id));
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MarketRegime } from '../server/services/MarketRegime';
|
||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants';
|
||||
import type { Benchmarks, RateRegime } from '../server/types';
|
||||
|
||||
const regime = (benchmarks: Partial<Benchmarks>, extra: { rateRegime?: RateRegime } = {}) =>
|
||||
new MarketRegime({ benchmarks: benchmarks as Benchmarks, ...extra });
|
||||
|
||||
test('stock inflated P/E = marketPE × 1.5', () => {
|
||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36
|
||||
});
|
||||
|
||||
test('tech inflated P/E = techPE × 1.3', () => {
|
||||
const { gates } = regime({ techPE: 40 }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.TECHNOLOGY,
|
||||
);
|
||||
assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52
|
||||
});
|
||||
|
||||
test('REIT inflated minYield = reitYield × 0.85 in NORMAL rate regime', () => {
|
||||
const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.REIT,
|
||||
);
|
||||
assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40
|
||||
});
|
||||
|
||||
test('REIT inflated minYield = reitYield × 0.95 in HIGH rate regime', () => {
|
||||
const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.REIT,
|
||||
);
|
||||
assert.equal(thresholds.minYield, +(4.0 * 0.95).toFixed(2)); // 3.80
|
||||
});
|
||||
|
||||
test('bond inflated minSpread = igSpread × 0.80 in NORMAL rate regime', () => {
|
||||
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
|
||||
ASSET_TYPE.BOND,
|
||||
SECTOR.GENERAL,
|
||||
);
|
||||
assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20
|
||||
});
|
||||
|
||||
test('bond inflated minSpread = igSpread × 0.90 in HIGH rate regime', () => {
|
||||
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
||||
ASSET_TYPE.BOND,
|
||||
SECTOR.GENERAL,
|
||||
);
|
||||
assert.equal(thresholds.minSpread, +(1.5 * 0.9).toFixed(2)); // 1.35
|
||||
});
|
||||
|
||||
test('GENERAL stock P/E multiplier compresses to 1.2× in HIGH rate regime', () => {
|
||||
const { gates } = regime({ marketPE: 25 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.GENERAL,
|
||||
);
|
||||
assert.equal(gates.maxPERatio, Math.round(25 * 1.2)); // 30
|
||||
});
|
||||
|
||||
test('ETF inflated loosens expense gate to 0.75', () => {
|
||||
const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF);
|
||||
assert.equal(gates.maxExpenseRatio, 0.75);
|
||||
});
|
||||
|
||||
test('falls back to defaults when benchmarks missing', () => {
|
||||
const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
||||
import { SIGNAL } from '../server/config/constants';
|
||||
import type { PortfolioHolding } from '../server/types';
|
||||
import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient';
|
||||
|
||||
// _cryptoPrices is the only method that uses the client; all other private
|
||||
// methods under test are pure calculations that never touch it.
|
||||
const stubClient = {} as unknown as YahooFinanceClient;
|
||||
|
||||
// Cast to any to access private methods — tests exercise internal behaviour directly.
|
||||
const advisor = new PortfolioAdvisor(stubClient) as any;
|
||||
|
||||
// Minimal holding shape used by position and advice (only costBasis/shares matter).
|
||||
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
||||
ticker: 'TEST',
|
||||
source: 'Test',
|
||||
type: 'stock',
|
||||
costBasis,
|
||||
shares,
|
||||
});
|
||||
|
||||
test('_position: computes gain/loss correctly', () => {
|
||||
const pos = advisor.position(holding(100, 10), 150);
|
||||
assert.equal(pos.gainLossPct, '50.0');
|
||||
assert.equal(pos.marketValue, '1500.00');
|
||||
assert.equal(pos.totalCost, '1000.00');
|
||||
});
|
||||
|
||||
test('_position: returns null gainLoss when price unavailable', () => {
|
||||
const pos = advisor.position(holding(100, 10), null);
|
||||
assert.equal(pos.gainLossPct, null);
|
||||
assert.equal(pos.marketValue, null);
|
||||
});
|
||||
|
||||
test('_advice: Strong Buy → Hold & Add', () => {
|
||||
const { action } = advisor.advice(SIGNAL.STRONG_BUY, holding(100, 10), 150);
|
||||
assert.equal(action, '🟢 Hold & Add');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
||||
const { action } = advisor.advice(SIGNAL.AVOID, holding(150, 10), 100);
|
||||
assert.equal(action, '🔴 Sell (Cut Loss)');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
||||
const { action } = advisor.advice(SIGNAL.AVOID, holding(100, 10), 150);
|
||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||
});
|
||||
|
||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
||||
const { action } = advisor.advice(SIGNAL.SPECULATION, holding(100, 10), 125);
|
||||
assert.equal(action, '🟠 Reduce Position');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: no price → No price data', () => {
|
||||
const { action } = advisor.cryptoAdvice(holding(100, 1), null);
|
||||
assert.equal(action, '⚪ No price data');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
||||
const { action } = advisor.cryptoAdvice(holding(10000, 1), 25000);
|
||||
assert.equal(action, '🟠 Consider taking profits');
|
||||
});
|
||||
|
||||
// ── Result map dot-notation normalisation (BRK.B / BRK-B) ───────────────────
|
||||
|
||||
test('advise: BRK-B screener result matches BRK.B holding', async () => {
|
||||
const mockResult = {
|
||||
asset: { ticker: 'BRK-B', currentPrice: 500 },
|
||||
signal: SIGNAL.STRONG_BUY,
|
||||
inflated: { label: '🟢 BUY (High Conviction)' },
|
||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||
};
|
||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||
const holding: PortfolioHolding = {
|
||||
ticker: 'BRK.B',
|
||||
shares: 1,
|
||||
costBasis: 400,
|
||||
type: 'stock',
|
||||
source: 'Robinhood',
|
||||
};
|
||||
|
||||
const advice = await advisor.advise([holding], screenedResults);
|
||||
// Should match and return a real signal, not "Not screened"
|
||||
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
|
||||
});
|
||||
|
||||
test('advise: BRK.B screener result matches BRK-B holding', async () => {
|
||||
const mockResult = {
|
||||
asset: { ticker: 'BRK.B', currentPrice: 500 },
|
||||
signal: SIGNAL.STRONG_BUY,
|
||||
inflated: { label: '🟢 BUY (High Conviction)' },
|
||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||
};
|
||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||
const holding: PortfolioHolding = {
|
||||
ticker: 'BRK-B',
|
||||
shares: 1,
|
||||
costBasis: 400,
|
||||
type: 'stock',
|
||||
source: 'Robinhood',
|
||||
};
|
||||
|
||||
const advice = await advisor.advise([holding], screenedResults);
|
||||
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RuleMerger } from '../server/services/RuleMerger';
|
||||
import { SCORE_MODE } from '../server/config/constants';
|
||||
import type { MarketContext } from '../server/types';
|
||||
|
||||
const ctx = {
|
||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||
} as Partial<MarketContext>;
|
||||
|
||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
||||
assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard
|
||||
});
|
||||
|
||||
test('INFLATED mode loosens P/E gate from live SPY data', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
||||
assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x');
|
||||
});
|
||||
|
||||
test('INFLATED tech P/E gate uses XLK benchmark', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'TECHNOLOGY' },
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
||||
});
|
||||
|
||||
test('Sector override applied before inflated overrides', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'REIT' },
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 9999);
|
||||
assert.equal(rules.weights.yield, 5);
|
||||
assert.equal(rules.weights.margin, 0);
|
||||
});
|
||||
|
||||
test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
) as unknown as Record<string, unknown>;
|
||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
||||
});
|
||||
|
||||
test('throws for unknown asset type', () => {
|
||||
assert.throws(
|
||||
() => RuleMerger.getRulesForAsset('CRYPTO' as never, {}, ctx as MarketContext),
|
||||
/No rules configured/,
|
||||
);
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig';
|
||||
|
||||
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
||||
assert.equal(CREDIT_RATING_SCALE.BBB, 7);
|
||||
assert.equal(CREDIT_RATING_SCALE.BB, 6);
|
||||
assert.equal(CREDIT_RATING_SCALE.D, 1);
|
||||
});
|
||||
|
||||
test('STOCK base gates are fundamental (Graham-style)', () => {
|
||||
const { gates } = ScoringRules.STOCK;
|
||||
assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings
|
||||
assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price
|
||||
assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress
|
||||
});
|
||||
|
||||
test('REIT sector override zeroes out irrelevant weights', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||
assert.equal(reit.weights!.margin, 0);
|
||||
assert.equal(reit.weights!.peg, 0);
|
||||
assert.equal(reit.weights!.revenue, 0);
|
||||
assert.equal(reit.weights!.yield, 5);
|
||||
});
|
||||
|
||||
test('REIT gates disable P/E and PEG', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||
assert.equal(reit.gates!.maxPERatio, 9999);
|
||||
assert.equal(reit.gates!.maxPegGate, 9999);
|
||||
});
|
||||
|
||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY!;
|
||||
assert.equal(tech.gates!.maxDebtToEquity, 2.0);
|
||||
assert.equal(tech.gates!.minQuickRatio, 0.8);
|
||||
});
|
||||
|
||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
||||
assert.equal(ScoringRules.BOND.gates.minCreditRating, 7);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StockScorer } from '../server/scorers/StockScorer';
|
||||
import type { StockMetrics } from '../server/types';
|
||||
|
||||
const baseRules = {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 10,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
};
|
||||
|
||||
// Minimal fixture — tests exercise specific fields; unused metrics are null.
|
||||
const nullMetrics: Omit<
|
||||
StockMetrics,
|
||||
| 'sector'
|
||||
| 'capCategory'
|
||||
| 'growthCategory'
|
||||
| 'currentPrice'
|
||||
| 'peRatio'
|
||||
| 'pegRatio'
|
||||
| 'debtToEquity'
|
||||
| 'quickRatio'
|
||||
| 'returnOnEquity'
|
||||
| 'operatingMargin'
|
||||
| 'netProfitMargin'
|
||||
| 'revenueGrowth'
|
||||
| 'fcfYield'
|
||||
> = {
|
||||
priceToBook: null,
|
||||
grossMargin: null,
|
||||
earningsGrowth: null,
|
||||
pFFO: null,
|
||||
dividendYield: null,
|
||||
beta: null,
|
||||
week52High: null,
|
||||
week52Low: null,
|
||||
week52Change: null,
|
||||
week52FromHigh: null,
|
||||
week52FromLow: null,
|
||||
marketCap: null,
|
||||
analystRating: null,
|
||||
analystTargetPrice: null,
|
||||
analystUpside: null,
|
||||
numberOfAnalysts: null,
|
||||
dcfIntrinsicValue: null,
|
||||
dcfMarginOfSafety: null,
|
||||
};
|
||||
|
||||
const pass: StockMetrics = {
|
||||
...nullMetrics,
|
||||
sector: 'GENERAL',
|
||||
capCategory: 'Large Cap',
|
||||
growthCategory: 'Growth',
|
||||
currentPrice: 150,
|
||||
peRatio: 15,
|
||||
pegRatio: 1.2,
|
||||
debtToEquity: 1.0,
|
||||
quickRatio: 1.0,
|
||||
returnOnEquity: 22,
|
||||
operatingMargin: 25,
|
||||
netProfitMargin: 18,
|
||||
revenueGrowth: 16,
|
||||
fcfYield: 6,
|
||||
};
|
||||
|
||||
test('rejects on high D/E', () => {
|
||||
const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('D/E'));
|
||||
});
|
||||
|
||||
test('rejects on high P/E', () => {
|
||||
const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('P/E'));
|
||||
});
|
||||
|
||||
test('rejects on high PEG', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('skips gate when metric is null (missing data)', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules);
|
||||
assert.notEqual(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('high-conviction BUY on strong metrics', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert.equal(result.label, '🟢 BUY (High Conviction)');
|
||||
});
|
||||
|
||||
test('audit breakdown contains scored factors', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert(result.audit.passedGates);
|
||||
assert(result.audit.breakdown!.roe != null);
|
||||
assert(result.audit.breakdown!.margin != null);
|
||||
});
|
||||
|
||||
test('beta > 1.5 surfaces as risk flag', () => {
|
||||
const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('High volatility')));
|
||||
});
|
||||
|
||||
test('near 52-week high surfaces as risk flag', () => {
|
||||
const result = StockScorer.score(
|
||||
{ ...pass, week52High: 200, week52Low: 100, currentPrice: 195 },
|
||||
baseRules,
|
||||
);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('52-week high')));
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* 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 { CalendarService } from '../server/services/CalendarService';
|
||||
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 stubCalendar = {
|
||||
getEvents: async () => ({ events: [], tickers: [] }),
|
||||
} as unknown as CalendarService;
|
||||
|
||||
// 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, stubCalendar).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: [], tickers: [] });
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 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