fix bruno collection
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user