fix bruno collection

This commit is contained in:
Kazuma
2026-06-06 21:49:31 -04:00
parent 2e7860637e
commit 76c2a671f4
25 changed files with 4361 additions and 94 deletions
+279
View File
@@ -0,0 +1,279 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { StockScorer } from '../server/domains/screener/scorers/StockScorer.js';
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
import type { StockMetrics } from '../server/domains/shared/types/models.model.js';
const DEFAULT_RULES = {
gates: ScoringConfig.base.gates.STOCK,
weights: ScoringConfig.base.weights.STOCK,
thresholds: ScoringConfig.base.thresholds.STOCK,
};
test('StockScorer', async (t) => {
await t.test('rejects stock with high debt-to-equity ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: 2.5, // Exceeds 1.5 gate
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('D/E'));
});
await t.test('rejects stock with low quick ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 0.5, // Below 0.8 gate
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('Quick'));
});
await t.test('rejects stock with high P/E ratio', () => {
const metrics: StockMetrics = {
peRatio: 25, // Exceeds 15 gate
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('P/E'));
});
await t.test('rejects stock with high PEG ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.5, // Exceeds 1.0 gate
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('PEG'));
});
await t.test('scores high-quality stock positively', () => {
const metrics: StockMetrics = {
peRatio: 12, // Below gate
pegRatio: 0.8, // Below gate
debtToEquity: 0.5,
quickRatio: 1.2,
returnOnEquity: 25, // High ROE
operatingMargin: 20,
netProfitMargin: 15,
revenueGrowth: 10,
fcfYield: 5,
priceToBook: 2.0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
// Should have positive score
assert.ok(result.audit?.passedGates);
});
await t.test('handles null/undefined metrics gracefully', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: null,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: null,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
// Should not crash, null values are skipped in gate checks
assert.ok(result);
});
await t.test('passes all quality gates for strong stock', () => {
const metrics: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 30,
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit?.passedGates);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('includes audit trail of gate checks', () => {
const metrics: StockMetrics = {
peRatio: 25,
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit);
if (!result.audit?.passedGates) {
assert.ok(Array.isArray(result.audit?.failures));
}
});
await t.test('scores ROE as primary quality factor', () => {
const metricsLowRoe: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 5, // Low ROE
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const metricsHighRoe: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 40, // High ROE
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const resultLow = StockScorer.score(metricsLowRoe, DEFAULT_RULES);
const resultHigh = StockScorer.score(metricsHighRoe, DEFAULT_RULES);
// Both should pass gates, but high ROE should score better
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
await t.test('rejects stock with multiple gate failures', () => {
const metrics: StockMetrics = {
peRatio: 25, // High
pegRatio: 1.5, // High
debtToEquity: 2.5, // High
quickRatio: 0.5, // Low
returnOnEquity: 5,
operatingMargin: 5,
netProfitMargin: 2,
revenueGrowth: -5,
fcfYield: -1,
priceToBook: 0.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
// Should have multiple failures
if (!result.audit?.passedGates && result.audit?.failures) {
assert.ok(result.audit.failures.length > 1);
}
});
await t.test('handles edge case of zero metrics', () => {
const metrics: StockMetrics = {
peRatio: 0,
pegRatio: 0,
debtToEquity: 0,
quickRatio: 0,
returnOnEquity: 0,
operatingMargin: 0,
netProfitMargin: 0,
revenueGrowth: 0,
fcfYield: 0,
priceToBook: 0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
// Should handle gracefully (zero is falsy, treated as null)
assert.ok(result);
});
await t.test('scores based on configured thresholds', () => {
const metricsLowMargin: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 20,
operatingMargin: 5, // Below medium threshold
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const metricsHighMargin: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 20,
operatingMargin: 25, // Above high threshold
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const resultLow = StockScorer.score(metricsLowMargin, DEFAULT_RULES);
const resultHigh = StockScorer.score(metricsHighMargin, DEFAULT_RULES);
// Both should pass gates
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
});
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildApp } from '../server/app.js';
import { MockDatabaseConnection } from './helpers/mockDb.js';
// Inject mock DB so tests don't require the native better-sqlite3 binary
const mockDb = new MockDatabaseConnection() as never;
test('App Bootstrap', async (t) => {
await t.test('builds successfully without logger', async () => {
const app = await buildApp({ logger: false, db: mockDb });
assert.ok(app);
assert.ok(app.server);
});
await t.test('health check endpoint returns 200', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'GET',
url: '/health',
});
assert.equal(response.statusCode, 200);
const body = JSON.parse(response.body);
assert.equal(body.status, 'ok');
});
await t.test('POST /api/screen requires valid schema', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'POST',
url: '/api/screen',
payload: { tickers: [] }, // Empty array fails minItems: 1
});
// Empty array is invalid per schema (minItems: 1)
assert.equal(response.statusCode, 400);
});
await t.test('POST /api/screen rejects invalid payload', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'POST',
url: '/api/screen',
payload: { invalid: 'data' },
});
assert.equal(response.statusCode, 400);
});
await t.test('GET /api/screen/catalysts returns results', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'GET',
url: '/api/screen/catalysts',
});
assert.equal(response.statusCode, 200);
const body = JSON.parse(response.body);
assert.ok('tickers' in body);
assert.ok('stories' in body);
});
await t.test('CORS is enabled for configured origin', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'GET',
url: '/health',
headers: {
origin: 'http://localhost:5173',
},
});
assert.ok(response.headers['access-control-allow-origin']);
});
await t.test('API key auth is optional (disabled by default)', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'GET',
url: '/health',
});
// Should work without API key when not configured
assert.equal(response.statusCode, 200);
});
await t.test('OPTIONS requests bypass auth check', async () => {
const app = await buildApp({ logger: false, db: mockDb });
const response = await app.inject({
method: 'OPTIONS',
url: '/api/screen',
headers: { origin: 'http://localhost:5173' },
});
// OPTIONS preflight should not be blocked by auth (any non-401/403 is fine)
assert.ok(response.statusCode !== 401 && response.statusCode !== 403);
});
});
+247
View File
@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { BondScorer } from '../server/domains/screener/scorers/BondScorer.js';
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
import type { BondMetrics } from '../server/domains/shared/types/models.model.js';
const DEFAULT_RULES = {
gates: ScoringConfig.base.gates.BOND,
weights: ScoringConfig.base.weights.BOND,
thresholds: ScoringConfig.base.thresholds.BOND,
};
test('BondScorer', async (t) => {
await t.test('rejects bond with low credit rating', () => {
const metrics: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'BB', // Below BBB (gate is BBB = 7)
creditRatingNumeric: 5,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('Credit'));
});
await t.test('accepts bond with BBB credit rating', () => {
const metrics: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'BBB',
creditRatingNumeric: 7,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('accepts bond with A credit rating', () => {
const metrics: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'A',
creditRatingNumeric: 9,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('scores high-yield bond positively', () => {
const metrics: BondMetrics = {
ytm: 7.5, // High yield
duration: 5,
creditRating: 'A',
creditRatingNumeric: 9,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
assert.ok(result.audit?.passedGates);
});
await t.test('penalizes long-duration bond', () => {
const metricsShort: BondMetrics = {
ytm: 5.5,
duration: 3, // Short
creditRating: 'A',
creditRatingNumeric: 9,
};
const metricsLong: BondMetrics = {
ytm: 5.5,
duration: 10, // Long
creditRating: 'A',
creditRatingNumeric: 9,
};
const resultShort = BondScorer.score(metricsShort, DEFAULT_RULES);
const resultLong = BondScorer.score(metricsLong, DEFAULT_RULES);
// Both should pass gates
assert.ok(resultShort.audit?.passedGates);
assert.ok(resultLong.audit?.passedGates);
});
await t.test('handles null/undefined metrics gracefully', () => {
const metrics: BondMetrics = {
ytm: null,
duration: 5,
creditRating: null,
creditRatingNumeric: null,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
// Should not crash
assert.ok(result);
});
await t.test('includes audit trail of gate checks', () => {
const metrics: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'BB',
creditRatingNumeric: 5,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit);
if (!result.audit?.passedGates) {
assert.ok(Array.isArray(result.audit?.failures));
}
});
await t.test('accepts high-quality bond (AAA rated)', () => {
const metrics: BondMetrics = {
ytm: 4.0, // Lower yield (less risk)
duration: 7,
creditRating: 'AAA',
creditRatingNumeric: 10,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit?.passedGates);
});
await t.test('scores spread relative to risk-free rate', () => {
const metricsWideSpread: BondMetrics = {
ytm: 7.5, // Wide spread from ~4.5% risk-free
duration: 5,
creditRating: 'BBB',
creditRatingNumeric: 7,
};
const metricsTightSpread: BondMetrics = {
ytm: 4.8, // Tight spread
duration: 5,
creditRating: 'BBB',
creditRatingNumeric: 7,
};
const resultWide = BondScorer.score(metricsWideSpread, DEFAULT_RULES);
const resultTight = BondScorer.score(metricsTightSpread, DEFAULT_RULES);
// Both should pass gates
assert.ok(resultWide.audit?.passedGates);
assert.ok(resultTight.audit?.passedGates);
});
await t.test('rejects bond with very long duration', () => {
const metrics: BondMetrics = {
ytm: 5.5,
duration: 20, // Much longer than default gate
creditRating: 'A',
creditRatingNumeric: 9,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
// May fail duration gate if threshold is enforced
assert.ok(result);
});
await t.test('handles extreme yield scenarios', () => {
const metricsVeryHigh: BondMetrics = {
ytm: 15.0, // Very high (distressed)
duration: 5,
creditRating: 'B',
creditRatingNumeric: 4,
};
const metricsVeryLow: BondMetrics = {
ytm: 2.0, // Very low (low risk)
duration: 5,
creditRating: 'AAA',
creditRatingNumeric: 10,
};
const resultHigh = BondScorer.score(metricsVeryHigh, DEFAULT_RULES);
const resultLow = BondScorer.score(metricsVeryLow, DEFAULT_RULES);
// High yield bond likely fails credit gate
assert.ok(resultHigh);
// Low yield AAA bond should pass
assert.ok(resultLow.audit?.passedGates);
});
await t.test('scores based on credit rating thresholds', () => {
const metricsJustAboveGate: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'BBB',
creditRatingNumeric: 7, // Exactly at gate
};
const metricsWellAboveGate: BondMetrics = {
ytm: 5.5,
duration: 5,
creditRating: 'AA',
creditRatingNumeric: 9, // Well above gate
};
const resultAt = BondScorer.score(metricsJustAboveGate, DEFAULT_RULES);
const resultAbove = BondScorer.score(metricsWellAboveGate, DEFAULT_RULES);
// Both should pass
assert.ok(resultAt.audit?.passedGates);
assert.ok(resultAbove.audit?.passedGates);
});
await t.test('handles negative YTM (unlikely but possible)', () => {
const metrics: BondMetrics = {
ytm: -0.5, // Negative yield (Swiss bonds in past)
duration: 2,
creditRating: 'AAA',
creditRatingNumeric: 10,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
// Should handle gracefully
assert.ok(result);
});
await t.test('investment-grade bond scores well', () => {
const metrics: BondMetrics = {
ytm: 5.0,
duration: 5,
creditRating: 'A',
creditRatingNumeric: 8,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
assert.ok(result.audit?.passedGates);
});
await t.test('speculative-grade bond rejected', () => {
const metrics: BondMetrics = {
ytm: 8.5,
duration: 5,
creditRating: 'CCC',
creditRatingNumeric: 3,
};
const result = BondScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
});
});
+300
View File
@@ -0,0 +1,300 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CallsController } from '../server/domains/calls/calls.controller.js';
import { CalendarService } from '../server/domains/calls/CalendarService.js';
import type { MarketCall } from '../server/domains/shared/types/calls.model.js';
class MockMarketCallRepository {
private calls: (MarketCall & { id: string })[] = [
{
id: '1',
title: 'AAPL Post-Earnings',
quarter: 'Q2 2024',
thesis: 'Strong iPhone sales cycle',
tickers: ['AAPL'],
date: new Date('2024-05-01'),
snapshots: [{ ticker: 'AAPL', price: 180, date: new Date('2024-05-01') }],
},
];
async list(): Promise<(MarketCall & { id: string })[]> {
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime());
}
async get(id: string): Promise<(MarketCall & { id: string }) | null> {
return this.calls.find((c) => c.id === id) || null;
}
async create(call: MarketCall): Promise<MarketCall & { id: string }> {
const id = String(this.calls.length + 1);
const newCall = { id, ...call };
this.calls.push(newCall);
return newCall;
}
async delete(id: string) {
const index = this.calls.findIndex((c) => c.id === id);
if (index >= 0) {
this.calls.splice(index, 1);
return true;
}
return false;
}
}
class MockScreenerEngine {
async screenTickers(tickers: string[]) {
return {
STOCK: tickers.map((ticker) => ({
asset: {
ticker,
type: 'STOCK',
currentPrice: 100,
metrics: {},
},
signal: 'STRONG_BUY',
})),
ETF: [],
BOND: [],
ERROR: [],
marketContext: {
sp500Price: 5500,
riskFreeRate: 4.5,
vixLevel: 12.5,
rateRegime: 'NORMAL' as const,
volatilityRegime: 'LOW' as const,
benchmarks: {
marketPE: 20,
techPE: 28,
reitYield: 3.5,
igSpread: 1.2,
},
},
};
}
}
class MockYahooClient {
async fetchCalendarEvents() {
return [
{
ticker: 'AAPL',
date: new Date('2024-05-15'),
type: 'earnings',
epsEstimate: 1.52,
epsActual: null,
},
];
}
}
test('CallsController', async (t) => {
await t.test('registers GET /api/calls endpoint', async () => {
const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any;
const calendar = new CalendarService(new MockYahooClient() as any);
const controller = new CallsController(repository, engine, calendar);
let callsEndpointRegistered = false;
const mockApp = {
get: (path: string) => {
if (path === '/api/calls') callsEndpointRegistered = true;
},
post: () => {},
delete: () => {},
};
controller.register(mockApp as any);
assert.ok(callsEndpointRegistered);
});
await t.test('registers POST /api/calls endpoint', async () => {
const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any;
const calendar = new CalendarService(new MockYahooClient() as any);
const controller = new CallsController(repository, engine, calendar);
let createEndpointRegistered = false;
const mockApp = {
get: () => {},
post: (path: string) => {
if (path === '/api/calls') createEndpointRegistered = true;
},
delete: () => {},
};
controller.register(mockApp as any);
assert.ok(createEndpointRegistered);
});
await t.test('registers DELETE /api/calls/:id endpoint', async () => {
const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any;
const calendar = new CalendarService(new MockYahooClient() as any);
const controller = new CallsController(repository, engine, calendar);
let deleteEndpointRegistered = false;
const mockApp = {
get: () => {},
post: () => {},
delete: (path: string) => {
if (path === '/api/calls/:id') deleteEndpointRegistered = true;
},
};
controller.register(mockApp as any);
assert.ok(deleteEndpointRegistered);
});
await t.test('lists all market calls', async () => {
const repository = new MockMarketCallRepository() as any;
const calls = await repository.list();
assert.ok(Array.isArray(calls));
assert.equal(calls.length, 1);
assert.equal(calls[0].ticker || calls[0].title, 'AAPL Post-Earnings' || 'AAPL');
});
await t.test('returns calls sorted by date (newest first)', async () => {
class MultiCallRepository {
private calls = [
{
id: '1',
title: 'Old Call',
quarter: 'Q1 2024',
thesis: 'Old thesis',
tickers: ['AAPL'],
date: new Date('2024-01-01'),
snapshots: [],
},
{
id: '2',
title: 'New Call',
quarter: 'Q2 2024',
thesis: 'New thesis',
tickers: ['MSFT'],
date: new Date('2024-05-01'),
snapshots: [],
},
];
async list() {
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime());
}
async get(id: string) {
return this.calls.find((c) => c.id === id) || null;
}
async create(call: any) {
return call;
}
async delete(_id: string) {
return true;
}
}
const repository = new MultiCallRepository() as any;
const calls = await repository.list();
assert.equal(calls[0].title, 'New Call');
assert.equal(calls[1].title, 'Old Call');
});
await t.test('creates new market call', async () => {
const repository = new MockMarketCallRepository() as any;
const newCall: MarketCall = {
title: 'MSFT Q3 2024',
quarter: 'Q3 2024',
thesis: 'Cloud growth acceleration',
tickers: ['MSFT'],
date: new Date('2024-07-01'),
snapshots: [],
};
const created = await repository.create(newCall);
assert.ok(created.id);
assert.equal(created.title, 'MSFT Q3 2024');
});
await t.test('retrieves single market call by id', async () => {
const repository = new MockMarketCallRepository() as any;
const call = await repository.get('1');
assert.ok(call);
assert.equal(call.id, '1');
assert.equal(call.title, 'AAPL Post-Earnings');
});
await t.test('deletes market call', async () => {
const repository = new MockMarketCallRepository() as any;
const deleted = await repository.delete('1');
assert.ok(deleted);
const call = await repository.get('1');
assert.equal(call, null);
});
await t.test('returns 404 for non-existent call', async () => {
const repository = new MockMarketCallRepository() as any;
const call = await repository.get('999');
assert.equal(call, null);
});
await t.test('screens tickers in call', async () => {
const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any;
const call = await repository.get('1');
if (call) {
const results = await engine.screenTickers(call.tickers);
assert.ok(results);
assert.ok(results.STOCK || results.ERROR);
}
});
await t.test('handles multiple tickers in call', async () => {
const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any;
const newCall: MarketCall = {
title: 'Tech Quartet',
quarter: 'Q3 2024',
thesis: 'All tech leaders',
tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'],
date: new Date('2024-07-01'),
snapshots: [],
};
const created = await repository.create(newCall);
const results = await engine.screenTickers(created.tickers);
assert.ok(created.tickers.length === 4);
assert.ok(results);
// Should have screened all 4 tickers
});
await t.test('gets calendar events for call tickers', async () => {
const repository = new MockMarketCallRepository() as any;
const calendar = new CalendarService(new MockYahooClient() as any);
const call = await repository.get('1');
if (call) {
const result = await calendar.getEvents(call.tickers);
assert.ok(Array.isArray(result.events));
assert.ok(Array.isArray(result.tickers));
}
});
await t.test('call includes snapshots of entry prices', async () => {
const repository = new MockMarketCallRepository() as any;
const call = await repository.get('1');
assert.ok(call);
assert.ok(Array.isArray(call.snapshots));
});
});
+270
View File
@@ -0,0 +1,270 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EtfScorer } from '../server/domains/screener/scorers/EtfScorer.js';
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
import type { EtfMetrics } from '../server/domains/shared/types/models.model.js';
const DEFAULT_RULES = {
gates: ScoringConfig.base.gates.ETF,
weights: ScoringConfig.base.weights.ETF,
thresholds: ScoringConfig.base.thresholds.ETF,
};
test('EtfScorer', async (t) => {
await t.test('rejects ETF with high expense ratio', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.8, // Exceeds 0.2% gate
yield: 2.5,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('Expense ratio'));
});
await t.test('accepts ETF with low expense ratio', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.05, // Well below 0.2% gate
yield: 2.5,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('rejects ETF with zero expense ratio (data issue)', () => {
const metrics: EtfMetrics = {
expenseRatio: 0, // Zero treated as missing/invalid
yield: 2.5,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
// Zero is treated as null/missing data
assert.ok(result);
});
await t.test('rejects ETF with low 5-year return', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 5000000,
fiveYearReturn: 5, // Below 8% gate
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('5-year return'));
});
await t.test('accepts ETF with strong 5-year return', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 5000000,
fiveYearReturn: 12, // Above 8% gate
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('rejects ETF with insufficient volume', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 500000, // Below 1M gate
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('Volume'));
});
await t.test('accepts ETF with strong volume', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 10000000, // Well above 1M gate
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('scores high-quality ETF positively', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.05, // Low
yield: 3.0, // Decent
volume: 20000000, // High
fiveYearReturn: 15, // Strong
totalAssets: 50e9, // Large
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
assert.ok(result.audit?.passedGates);
});
await t.test('handles null/undefined metrics gracefully', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: null,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: null,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
// Should not crash, null values are skipped
assert.ok(result);
});
await t.test('includes audit trail of gate checks', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.8,
yield: 2.5,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit);
if (!result.audit?.passedGates) {
assert.ok(Array.isArray(result.audit?.failures));
}
});
await t.test('scores yield as secondary factor', () => {
const metricsLowYield: EtfMetrics = {
expenseRatio: 0.1,
yield: 0.5, // Low yield
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const metricsHighYield: EtfMetrics = {
expenseRatio: 0.1,
yield: 4.0, // High yield
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const resultLow = EtfScorer.score(metricsLowYield, DEFAULT_RULES);
const resultHigh = EtfScorer.score(metricsHighYield, DEFAULT_RULES);
// Both should pass gates
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
await t.test('rejects ETF with multiple gate failures', () => {
const metrics: EtfMetrics = {
expenseRatio: 1.0, // High
yield: 0.5,
volume: 100000, // Low
fiveYearReturn: 3, // Low
totalAssets: 100e6, // Small
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
// Should have multiple failures
if (!result.audit?.passedGates && result.audit?.failures) {
assert.ok(result.audit.failures.length > 1);
}
});
await t.test('calculates score based on thresholds', () => {
const metricsLowReturn: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 5000000,
fiveYearReturn: 8.5, // Just above gate
totalAssets: 5e9,
};
const metricsHighReturn: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 5000000,
fiveYearReturn: 15, // Well above gate
totalAssets: 5e9,
};
const resultLow = EtfScorer.score(metricsLowReturn, DEFAULT_RULES);
const resultHigh = EtfScorer.score(metricsHighReturn, DEFAULT_RULES);
// Both should pass
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
await t.test('penalizes low-volume ETF', () => {
const metricsLowVolume: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 2000000, // Low but above gate
fiveYearReturn: 10,
totalAssets: 5e9,
};
const metricsHighVolume: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 50000000, // High volume
fiveYearReturn: 10,
totalAssets: 5e9,
};
const resultLow = EtfScorer.score(metricsLowVolume, DEFAULT_RULES);
const resultHigh = EtfScorer.score(metricsHighVolume, DEFAULT_RULES);
// Both pass gates, but high volume should score higher
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
await t.test('handles extremely high expense ratio', () => {
const metrics: EtfMetrics = {
expenseRatio: 5.0, // 5%!
yield: 2.5,
volume: 5000000,
fiveYearReturn: 10,
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
});
await t.test('handles negative 5-year return', () => {
const metrics: EtfMetrics = {
expenseRatio: 0.1,
yield: 2.5,
volume: 5000000,
fiveYearReturn: -5, // Negative return
totalAssets: 5e9,
};
const result = EtfScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* MockDatabaseConnection — in-memory stub for tests.
*
* Substitutes for DatabaseConnection when better-sqlite3 is unavailable
* (e.g. native binary built for wrong platform).
* All mutation methods are no-ops; read methods return empty results.
*/
import { QueryBuilder } from '../../server/domains/shared/utils/QueryBuilder.js';
import { QueryAudit } from '../../server/domains/shared/db/QueryAudit.js';
export class MockDatabaseConnection {
private audit = new QueryAudit();
all<T = Record<string, unknown>>(_qb: QueryBuilder): T[] {
return [];
}
get<T = Record<string, unknown>>(_qb: QueryBuilder): T | null {
return null;
}
run(_qb: QueryBuilder): number {
return 0;
}
transaction<T>(fn: () => T): T {
return fn();
}
raw(): never {
throw new Error('MockDatabaseConnection: raw() not available in tests');
}
getAudit(): QueryAudit {
return this.audit;
}
clearStatementCache(): void {}
printAudit(): void {}
}
+278
View File
@@ -0,0 +1,278 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PortfolioAdvisor } from '../server/domains/portfolio/PortfolioAdvisor.js';
import { SIGNAL } from '../server/domains/shared/config/constants.js';
import type {
PortfolioHolding,
AdviceRow,
} from '../server/domains/shared/types/portfolio.model.js';
import type { ScreenerResult } from '../server/domains/shared/types/asset.model.js';
class MockYahooClient {
async fetchSummary(ticker: string) {
const prices: Record<string, number> = {
AAPL: 189.5,
MSFT: 425.3,
'BRK-B': 385.0,
};
return {
price: {
regularMarketPrice: prices[ticker] || 100,
marketCap: 1e12,
},
summaryDetail: {
fiftyTwoWeekHigh: (prices[ticker] || 100) * 1.1,
fiftyTwoWeekLow: (prices[ticker] || 100) * 0.9,
},
quoteType: { quoteType: 'EQUITY' },
defaultKeyStatistics: { trailingPE: 20 },
financialData: {
returnOnEquity: 0.2,
operatingMargins: 0.15,
grossMargins: 0.4,
freeCashflow: 50e9,
totalRevenue: 200e9,
debtToEquity: 50,
currentRatio: 1.2,
},
incomeStatementHistoryQuarterly: {
incomeStatementHistory: [{ commonStockSharesOutstanding: 2.6e9, netIncome: 25e9 }],
},
};
}
normalise(ticker: string): string {
return ticker.replace(/\./g, '-');
}
}
// Helper: build a minimal ScreenerResult with one STOCK result
function makeScreenerResult(ticker: string, signal: string, price: number): ScreenerResult {
return {
STOCK: [
{
signal,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: {
ticker,
currentPrice: price,
type: 'STOCK',
getDisplayMetrics: () => ({}),
} as any,
displayMetrics: {},
} as any,
],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
};
}
test('PortfolioAdvisor', async (t) => {
await t.test('analyzes portfolio with single holding', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 1);
});
await t.test('calculates position gain/loss correctly', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
const row = advice[0] as AdviceRow;
// gain = 10 * (189.5 - 150) = $395
assert.ok(row.gainLossPct !== null);
});
await t.test('handles empty portfolio', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const screened: ScreenerResult = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
};
const advice = await advisor.advise([], screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 0);
});
await t.test('normalizes BRK.B to BRK-B', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'BRK.B', shares: 5, costBasis: 350, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('BRK-B', SIGNAL.NEUTRAL, 385.0);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 1);
});
await t.test('analyzes multiple holdings', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
{ ticker: 'MSFT', shares: 5, costBasis: 400, source: 'manual', type: 'stock' },
];
const screened: ScreenerResult = {
STOCK: [
{
signal: SIGNAL.STRONG_BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: {
ticker: 'AAPL',
currentPrice: 189.5,
type: 'STOCK',
getDisplayMetrics: () => ({}),
} as any,
displayMetrics: {},
} as any,
{
signal: SIGNAL.BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: {
ticker: 'MSFT',
currentPrice: 425.3,
type: 'STOCK',
getDisplayMetrics: () => ({}),
} as any,
displayMetrics: {},
} as any,
],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
};
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 2);
});
await t.test('maps signal to advice recommendation', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
for (const signal of [SIGNAL.STRONG_BUY, SIGNAL.NEUTRAL, SIGNAL.AVOID]) {
const screened = makeScreenerResult('AAPL', signal, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
}
});
await t.test('handles fractional shares', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10.5, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 1);
});
await t.test('returns advice rows with ticker information', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
const row = advice[0] as AdviceRow;
assert.ok(row.ticker);
});
await t.test('handles missing current price gracefully', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'UNKNOWN', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened: ScreenerResult = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
};
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 1);
assert.equal(advice[0].currentPrice, null);
});
await t.test('calculates total portfolio value', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
{ ticker: 'MSFT', shares: 5, costBasis: 400, source: 'manual', type: 'stock' },
];
const screened: ScreenerResult = {
STOCK: [
{
signal: SIGNAL.STRONG_BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: {
ticker: 'AAPL',
currentPrice: 189.5,
type: 'STOCK',
getDisplayMetrics: () => ({}),
} as any,
displayMetrics: {},
} as any,
{
signal: SIGNAL.BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: {
ticker: 'MSFT',
currentPrice: 425.3,
type: 'STOCK',
getDisplayMetrics: () => ({}),
} as any,
displayMetrics: {},
} as any,
],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
};
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
assert.equal(advice.length, 2);
// AAPL: 10 * 189.5 = 1895, MSFT: 5 * 425.3 = 2126.5
const totalValue = advice.reduce((sum, r) => sum + parseFloat(r.marketValue ?? '0'), 0);
assert.ok(totalValue > 0);
});
await t.test('signals match in advice rows', async () => {
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const holdings: PortfolioHolding[] = [
{ ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' },
];
const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5);
const advice = await advisor.advise(holdings, screened);
assert.ok(Array.isArray(advice));
const row = advice[0] as AdviceRow;
assert.equal(row.signal, SIGNAL.STRONG_BUY);
});
});
+305
View File
@@ -0,0 +1,305 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FinanceController } from '../server/domains/finance/finance.controller.js';
import { PortfolioAdvisor } from '../server/domains/portfolio/PortfolioAdvisor.js';
import type { PortfolioHolding } from '../server/domains/shared/types/portfolio.model.js';
class MockScreenerEngine {
async screenTickers() {
return {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: {
sp500Price: 5500,
riskFreeRate: 4.5,
vixLevel: 12.5,
rateRegime: 'NORMAL' as const,
volatilityRegime: 'LOW' as const,
benchmarks: {
marketPE: 20,
techPE: 28,
reitYield: 3.5,
igSpread: 1.2,
},
},
};
}
}
class MockPortfolioRepository {
async getHoldings() {
return [
{
id: '1',
ticker: 'AAPL',
shares: 10,
costBasis: 150,
type: 'stock' as const,
},
];
}
async addHolding(holding: PortfolioHolding) {
return { id: '1', ...holding };
}
async deleteHolding(_id: string) {
return true;
}
}
class MockYahooClient {
async fetchSummary() {
return {
price: { regularMarketPrice: 189.5 },
summaryDetail: { fiftyTwoWeekHigh: 199, fiftyTwoWeekLow: 164 },
quoteType: { quoteType: 'EQUITY' },
defaultKeyStatistics: { trailingPE: 28.5 },
financialData: {
returnOnEquity: 95.2 / 100,
operatingMargins: 30.7 / 100,
grossMargins: 46.2 / 100,
freeCashflow: 110e9,
totalRevenue: 383.3e9,
debtToEquity: 75.56,
currentRatio: 0.95,
},
incomeStatementHistoryQuarterly: {
incomeStatementHistory: [
{
commonStockSharesOutstanding: 15.6e9,
netIncome: 25e9,
},
],
},
};
}
}
test('FinanceController', async (t) => {
await t.test('registers portfolio endpoint', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const controller = new FinanceController(engine, repository, advisor);
let portfolioEndpointRegistered = false;
const mockApp = {
get: (path: string) => {
if (path === '/api/finance/portfolio') portfolioEndpointRegistered = true;
},
post: () => {},
delete: () => {},
};
controller.register(mockApp as any);
assert.ok(portfolioEndpointRegistered);
});
await t.test('registers market context endpoint', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const controller = new FinanceController(engine, repository, advisor);
let contextEndpointRegistered = false;
const mockApp = {
get: (path: string) => {
if (path === '/api/finance/market-context') contextEndpointRegistered = true;
},
post: () => {},
delete: () => {},
};
controller.register(mockApp as any);
assert.ok(contextEndpointRegistered);
});
await t.test('registers holdings endpoints', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const controller = new FinanceController(engine, repository, advisor);
let addHoldingRegistered = false;
let deleteHoldingRegistered = false;
const mockApp = {
get: () => {},
post: (path: string) => {
if (path === '/api/finance/holdings') addHoldingRegistered = true;
},
delete: (path: string) => {
if (path === '/api/finance/holdings/:ticker') deleteHoldingRegistered = true;
},
};
controller.register(mockApp as any);
assert.ok(addHoldingRegistered);
assert.ok(deleteHoldingRegistered);
});
await t.test('returns portfolio with holdings', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const holdings = await repository.getHoldings();
assert.ok(Array.isArray(holdings));
assert.equal(holdings.length, 1);
assert.equal(holdings[0].ticker, 'AAPL');
});
await t.test('returns market context data', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const results = await engine.screenTickers([]);
assert.ok(results.marketContext);
assert.ok(results.marketContext.sp500Price);
assert.ok(results.marketContext.benchmarks);
});
await t.test('adds holding to portfolio', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const newHolding: PortfolioHolding = {
ticker: 'MSFT',
shares: 5,
costBasis: 400,
source: 'manual',
type: 'stock',
};
const added = await repository.addHolding(newHolding);
assert.ok(added);
assert.equal(added.ticker, 'MSFT');
});
await t.test('deletes holding from portfolio', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const deleted = await repository.deleteHolding('1');
assert.ok(deleted);
});
await t.test('handles empty portfolio gracefully', async () => {
class EmptyRepository {
async getHoldings() {
return [];
}
async addHolding(holding: PortfolioHolding) {
return { id: '1', ...holding };
}
async deleteHolding(_id: string) {
return true;
}
}
const engine = new MockScreenerEngine() as any;
const repository = new EmptyRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const holdings = await repository.getHoldings();
assert.equal(holdings.length, 0);
});
await t.test('analyzes portfolio advice', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const holdings = await repository.getHoldings();
const advice = await advisor.advise(holdings, {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
});
assert.ok(advice);
});
await t.test('includes holdings in portfolio response', async () => {
const engine = new MockScreenerEngine() as any;
const repository = new MockPortfolioRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const holdings = await repository.getHoldings();
const advice = await advisor.advise(holdings, {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: null as any,
});
assert.ok(holdings);
assert.ok(advice);
});
await t.test('handles multiple holdings in portfolio', async () => {
class MultiHoldingRepository {
async getHoldings() {
return [
{
id: '1',
ticker: 'AAPL',
shares: 10,
costBasis: 150,
type: 'stock' as const,
},
{
id: '2',
ticker: 'MSFT',
shares: 5,
costBasis: 400,
type: 'stock' as const,
},
{
id: '3',
ticker: 'VOO',
shares: 20,
costBasis: 350,
type: 'etf' as const,
},
];
}
async addHolding(holding: PortfolioHolding) {
return { id: '4', ...holding };
}
async deleteHolding(_id: string) {
return true;
}
}
const engine = new MockScreenerEngine() as any;
const repository = new MultiHoldingRepository() as any;
const advisor = new PortfolioAdvisor(new MockYahooClient() as any);
const _controller = new FinanceController(engine, repository, advisor);
const holdings = await repository.getHoldings();
assert.equal(holdings.length, 3);
assert.ok(holdings.some((h: any) => h.ticker === 'AAPL'));
assert.ok(holdings.some((h: any) => h.ticker === 'MSFT'));
assert.ok(holdings.some((h: any) => h.type === 'etf'));
});
});
+196
View File
@@ -0,0 +1,196 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ScreenerController } from '../server/domains/screener/screener.controller.js';
import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js';
import type {
LiveAssetResult,
MarketContext,
Stock,
} from '../server/domains/shared/types/index.js';
import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js';
// Mock implementations
class MockScreenerEngine extends ScreenerEngine {
async screenTickers(tickers: string[]) {
const mockContext: MarketContext = {
sp500Price: 5500,
riskFreeRate: 4.5,
vixLevel: 12.5,
rateRegime: 'NORMAL',
volatilityRegime: 'LOW',
benchmarks: {
marketPE: 20,
techPE: 28,
reitYield: 3.5,
igSpread: 1.2,
},
};
// Return mock results for tested tickers
const mockStock = {
ticker: 'AAPL',
type: ASSET_TYPE.STOCK,
currentPrice: 189.5,
metrics: {
peRatio: 28.5,
returnOnEquity: 95.2,
freeCashFlow: 100000000,
debtToEquity: 0.75,
},
getDisplayMetrics: () => ({
peRatio: 28.5,
returnOnEquity: 95.2,
freeCashFlow: 100000000,
}),
} as unknown as Stock;
const mockResult: LiveAssetResult = {
asset: mockStock,
fundamentalScore: { label: '✓ BUY', scoreSummary: 'Quality gate PASS' },
inflatedScore: { label: '✓ BUY', scoreSummary: 'Market adjusted gate PASS' },
signal: SIGNAL.STRONG_BUY,
};
return {
STOCK: tickers.length > 0 ? [mockResult] : [],
ETF: [],
BOND: [],
ERROR: [],
marketContext: mockContext,
};
}
}
class MockCatalystCache {
async get() {
return {
tickers: ['AAPL', 'MSFT', 'NVDA'],
stories: [
{
headline: 'Apple beats Q2 earnings',
summary: 'Strong iPhone sales boost revenue',
sentiment: 'positive',
relatedTickers: ['AAPL'],
},
],
};
}
}
test('ScreenerController', async (t) => {
await t.test('screen() processes valid ticker list', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const controller = new ScreenerController(engine, cache);
// Create a mock FastifyInstance with only the methods we need
const mockApp = {
post: () => {},
get: () => {},
};
// Register should succeed
controller.register(mockApp as any);
assert.ok(true);
});
await t.test('screen() serializes assets correctly', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const _controller = new ScreenerController(engine, cache);
// Test private serialization method indirectly through results
const results = await engine.screenTickers(['AAPL']);
assert.equal(results.STOCK.length, 1);
assert.ok(results.STOCK[0].asset.ticker === 'AAPL');
});
await t.test('screen() handles empty ticker list', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const _controller = new ScreenerController(engine, cache);
const results = await engine.screenTickers([]);
assert.equal(results.STOCK.length, 0);
assert.equal(results.ETF.length, 0);
assert.equal(results.BOND.length, 0);
});
await t.test('catalysts() returns cached results', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const _controller = new ScreenerController(engine, cache);
const catalysts = await cache.get();
assert.ok(Array.isArray(catalysts.tickers));
assert.ok(Array.isArray(catalysts.stories));
assert.equal(catalysts.tickers.length, 3);
});
await t.test('catalysts() includes headlines and sentiment', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const _controller = new ScreenerController(engine, cache);
const catalysts = await cache.get();
const story = catalysts.stories[0];
assert.ok(story.headline);
assert.ok(story.sentiment);
assert.ok(story.relatedTickers);
});
await t.test('controller registers POST /api/screen endpoint', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const controller = new ScreenerController(engine, cache);
let screenEndpointRegistered = false;
const mockApp = {
post: (path: string) => {
if (path === '/api/screen') screenEndpointRegistered = true;
},
get: () => {},
};
controller.register(mockApp as any);
assert.ok(screenEndpointRegistered);
});
await t.test('controller registers GET /api/screen/catalysts endpoint', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const cache = new MockCatalystCache() as any;
const controller = new ScreenerController(engine, cache);
let catalystEndpointRegistered = false;
const mockApp = {
post: () => {},
get: (path: string) => {
if (path === '/api/screen/catalysts') catalystEndpointRegistered = true;
},
};
controller.register(mockApp as any);
assert.ok(catalystEndpointRegistered);
});
await t.test('screen() includes market context in response', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const results = await engine.screenTickers(['AAPL']);
assert.ok(results.marketContext);
assert.ok(results.marketContext.sp500Price);
assert.ok(results.marketContext.benchmarks);
});
await t.test('screen() includes verdict signal in results', async () => {
const engine = new MockScreenerEngine(null as any, null as any);
const results = await engine.screenTickers(['AAPL']);
assert.equal(results.STOCK.length, 1);
const result = results.STOCK[0];
assert.ok(result.signal);
assert.ok(result.fundamentalScore);
assert.ok(result.inflatedScore);
});
});
+196
View File
@@ -0,0 +1,196 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js';
import { BenchmarkProvider } from '../server/domains/shared/services/BenchmarkProvider.js';
import { noopLogger } from '../server/domains/shared/utils/logger.js';
import type { MarketContext } from '../server/domains/shared/types/market.model.js';
// Mock Yahoo Finance Client
class MockYahooClient {
async fetchSummary(ticker: string) {
if (ticker === 'INVALID') {
throw new Error('Not found');
}
return {
price: {
regularMarketPrice: ticker === 'AAPL' ? 189.5 : 425.3,
marketCap: ticker === 'AAPL' ? 2.8e12 : 1.6e12,
},
summaryDetail: {
fiftyTwoWeekHigh: ticker === 'AAPL' ? 199.62 : 468.5,
fiftyTwoWeekLow: ticker === 'AAPL' ? 164.08 : 380.2,
},
quoteType: { quoteType: 'EQUITY' },
defaultKeyStatistics: {
trailingPE: ticker === 'AAPL' ? 28.5 : 32.1,
},
financialData: {
returnOnEquity: (ticker === 'AAPL' ? 95.2 : 48.5) / 100,
operatingMargins: (ticker === 'AAPL' ? 30.7 : 27.8) / 100,
grossMargins: (ticker === 'AAPL' ? 46.2 : 45.5) / 100,
freeCashflow: ticker === 'AAPL' ? 110e9 : 60e9,
totalRevenue: ticker === 'AAPL' ? 383.3e9 : 215.1e9,
debtToEquity: ticker === 'AAPL' ? 75.56 : 55.2,
currentRatio: ticker === 'AAPL' ? 0.95 : 1.2,
},
incomeStatementHistoryQuarterly: {
incomeStatementHistory: [
{
commonStockSharesOutstanding: ticker === 'AAPL' ? 15.6e9 : 9.3e9,
netIncome: ticker === 'AAPL' ? 25e9 : 20e9,
},
],
},
};
}
async fetchCalendarEvents() {
return [];
}
async search() {
return { quotes: [] };
}
}
class MockBenchmarkProvider extends BenchmarkProvider {
async getMarketContext(): Promise<MarketContext> {
return {
sp500Price: 5500,
riskFreeRate: 4.5,
vixLevel: 12.5,
rateRegime: 'NORMAL',
volatilityRegime: 'LOW',
benchmarks: {
marketPE: 20,
techPE: 28,
reitYield: 3.5,
igSpread: 1.2,
},
};
}
}
test('ScreenerEngine', async (t) => {
await t.test('screenTickers() processes valid ticker', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['AAPL']);
assert.ok(results);
assert.ok('STOCK' in results);
assert.ok('ETF' in results);
assert.ok('BOND' in results);
assert.ok('ERROR' in results);
});
await t.test('screenTickers() handles error gracefully', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['INVALID']);
assert.equal(results.ERROR.length, 1);
assert.equal(results.ERROR[0].ticker, 'INVALID');
assert.ok(results.ERROR[0].message);
});
await t.test('screenTickers() normalizes ticker to uppercase', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['aapl']);
// Should process without error
assert.ok(results);
});
await t.test('screenTickers() batches requests with delay', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const startTime = Date.now();
await engine.screenTickers(['AAPL', 'MSFT']);
const endTime = Date.now();
// Should have delay between batches (1000ms per batch)
assert.ok(endTime - startTime >= 0); // At minimum, some time should pass
});
await t.test('screenTickers() returns market context', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['AAPL']);
assert.ok(results.marketContext);
assert.equal(results.marketContext.sp500Price, 5500);
assert.equal(results.marketContext.rateRegime, 'NORMAL');
});
await t.test('screenTickers() handles empty list', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers([]);
assert.equal(results.STOCK.length, 0);
assert.equal(results.ETF.length, 0);
assert.equal(results.BOND.length, 0);
assert.equal(results.ERROR.length, 0);
});
await t.test('screenTickers() processes multiple tickers', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['AAPL', 'MSFT']);
const totalResults =
results.STOCK.length + results.ETF.length + results.BOND.length + results.ERROR.length;
assert.equal(totalResults, 2);
});
await t.test('screenWithProgress() works without logger', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenWithProgress(['AAPL']);
assert.ok(results);
assert.ok('marketContext' in results);
});
await t.test('screenTickers() processes large ticker list correctly', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
// Create array of 10 tickers (batch size is 5, so should need 2 batches)
const tickers = Array(10)
.fill(0)
.map((_, i) => (i % 2 === 0 ? 'AAPL' : 'MSFT'));
const results = await engine.screenTickers(tickers);
const totalResults =
results.STOCK.length + results.ETF.length + results.BOND.length + results.ERROR.length;
assert.equal(totalResults, 10);
});
await t.test('screenTickers() includes scoring details', async () => {
const client = new MockYahooClient();
const benchmark = new MockBenchmarkProvider(null as any);
const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger });
const results = await engine.screenTickers(['AAPL']);
if (results.STOCK.length > 0) {
const result = results.STOCK[0];
assert.ok(result.signal);
assert.ok(result.fundamental);
assert.ok(result.inflated);
}
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Test Setup — Handle platform-specific issues gracefully
*
* This file runs before tests to handle:
* - Platform mismatches (macOS binaries in Linux environment)
* - Native module loading failures (better-sqlite3, esbuild)
* - Environment-specific test skipping
*/
const canLoadNativeModules = () => {
try {
require('better-sqlite3');
return true;
} catch (err) {
if (err.code === 'ERR_MODULE_NOT_FOUND' || err.message.includes('wrong platform')) {
return false;
}
throw err;
}
};
export const canRunDatabaseTests = canLoadNativeModules();
// Set environment variable for test suite
if (!canRunDatabaseTests) {
process.env.SKIP_DATABASE_TESTS = 'true';
console.warn('⚠️ Native modules not available (platform mismatch detected)');
console.warn(' Skipping database-dependent tests');
console.warn(' Run tests on macOS or rebuild with: npm ci');
}
+279
View File
@@ -0,0 +1,279 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { StockScorer } from '../server/domains/screener/scorers/StockScorer.js';
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
import type { StockMetrics } from '../server/domains/shared/types/models.model.js';
const DEFAULT_RULES = {
gates: ScoringConfig.base.gates.STOCK,
weights: ScoringConfig.base.weights.STOCK,
thresholds: ScoringConfig.base.thresholds.STOCK,
};
test('StockScorer', async (t) => {
await t.test('rejects stock with high debt-to-equity ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: 2.5, // Exceeds 1.5 gate
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('D/E'));
});
await t.test('rejects stock with low quick ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 0.5, // Below 0.8 gate
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('Quick'));
});
await t.test('rejects stock with high P/E ratio', () => {
const metrics: StockMetrics = {
peRatio: 25, // Exceeds 15 gate
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('P/E'));
});
await t.test('rejects stock with high PEG ratio', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.5, // Exceeds 1.0 gate
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
assert.ok(result.scoreSummary.includes('PEG'));
});
await t.test('scores high-quality stock positively', () => {
const metrics: StockMetrics = {
peRatio: 12, // Below gate
pegRatio: 0.8, // Below gate
debtToEquity: 0.5,
quickRatio: 1.2,
returnOnEquity: 25, // High ROE
operatingMargin: 20,
netProfitMargin: 15,
revenueGrowth: 10,
fcfYield: 5,
priceToBook: 2.0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.notEqual(result.label, '🔴 REJECT');
// Should have positive score
assert.ok(result.audit?.passedGates);
});
await t.test('handles null/undefined metrics gracefully', () => {
const metrics: StockMetrics = {
peRatio: 15,
pegRatio: 1.0,
debtToEquity: null,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: null,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
// Should not crash, null values are skipped in gate checks
assert.ok(result);
});
await t.test('passes all quality gates for strong stock', () => {
const metrics: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 30,
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit?.passedGates);
assert.notEqual(result.label, '🔴 REJECT');
});
await t.test('includes audit trail of gate checks', () => {
const metrics: StockMetrics = {
peRatio: 25,
pegRatio: 1.0,
debtToEquity: 1.0,
quickRatio: 1.0,
returnOnEquity: 20,
operatingMargin: 15,
netProfitMargin: 10,
revenueGrowth: 5,
fcfYield: 3,
priceToBook: 1.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.ok(result.audit);
if (!result.audit?.passedGates) {
assert.ok(Array.isArray(result.audit?.failures));
}
});
await t.test('scores ROE as primary quality factor', () => {
const metricsLowRoe: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 5, // Low ROE
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const metricsHighRoe: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 40, // High ROE
operatingMargin: 25,
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const resultLow = StockScorer.score(metricsLowRoe, DEFAULT_RULES);
const resultHigh = StockScorer.score(metricsHighRoe, DEFAULT_RULES);
// Both should pass gates, but high ROE should score better
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
await t.test('rejects stock with multiple gate failures', () => {
const metrics: StockMetrics = {
peRatio: 25, // High
pegRatio: 1.5, // High
debtToEquity: 2.5, // High
quickRatio: 0.5, // Low
returnOnEquity: 5,
operatingMargin: 5,
netProfitMargin: 2,
revenueGrowth: -5,
fcfYield: -1,
priceToBook: 0.5,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
assert.equal(result.label, '🔴 REJECT');
// Should have multiple failures
if (!result.audit?.passedGates && result.audit?.failures) {
assert.ok(result.audit.failures.length > 1);
}
});
await t.test('handles edge case of zero metrics', () => {
const metrics: StockMetrics = {
peRatio: 0,
pegRatio: 0,
debtToEquity: 0,
quickRatio: 0,
returnOnEquity: 0,
operatingMargin: 0,
netProfitMargin: 0,
revenueGrowth: 0,
fcfYield: 0,
priceToBook: 0,
} as any;
const result = StockScorer.score(metrics, DEFAULT_RULES);
// Should handle gracefully (zero is falsy, treated as null)
assert.ok(result);
});
await t.test('scores based on configured thresholds', () => {
const metricsLowMargin: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 20,
operatingMargin: 5, // Below medium threshold
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const metricsHighMargin: StockMetrics = {
peRatio: 10,
pegRatio: 0.9,
debtToEquity: 0.3,
quickRatio: 1.5,
returnOnEquity: 20,
operatingMargin: 25, // Above high threshold
netProfitMargin: 18,
revenueGrowth: 8,
fcfYield: 6,
priceToBook: 3.0,
} as any;
const resultLow = StockScorer.score(metricsLowMargin, DEFAULT_RULES);
const resultHigh = StockScorer.score(metricsHighMargin, DEFAULT_RULES);
// Both should pass gates
assert.ok(resultLow.audit?.passedGates);
assert.ok(resultHigh.audit?.passedGates);
});
});