phase-7: code restructure
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BondScorer } from '../server/screener/scorers/BondScorer.js';
|
||||
import { BondScorer } from '../server/scorers/BondScorer';
|
||||
import type { MarketContext } from '../server/types';
|
||||
|
||||
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||
@@ -10,7 +11,8 @@ const rules = {
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
||||
};
|
||||
const ctx = { riskFreeRate: 4.5 };
|
||||
// BondScorer only uses riskFreeRate from context; cast the partial fixture to satisfy the type.
|
||||
const ctx = { riskFreeRate: 4.5 } as MarketContext;
|
||||
|
||||
test('rejects bond below investment-grade floor', () => {
|
||||
const result = BondScorer.score(
|
||||
@@ -38,7 +40,7 @@ test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread);
|
||||
assert.equal(result.audit.breakdown!.spread, rules.weights.yieldSpread);
|
||||
});
|
||||
|
||||
test('fails spread when yield barely above risk-free', () => {
|
||||
@@ -48,7 +50,7 @@ test('fails spread when yield barely above risk-free', () => {
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, -2);
|
||||
assert.equal(result.audit.breakdown!.spread, -2);
|
||||
});
|
||||
|
||||
test('penalises long duration', () => {
|
||||
@@ -57,5 +59,5 @@ test('penalises long duration', () => {
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.duration, -1);
|
||||
assert.equal(result.audit.breakdown!.duration, -1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapToStandardFormat } from '../server/screener/DataMapper.js';
|
||||
import { DataMapper } from '../server/services/DataMapper';
|
||||
|
||||
const base = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||
@@ -27,13 +27,13 @@ const base = {
|
||||
};
|
||||
|
||||
test('maps EQUITY quote type to STOCK', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.type, 'STOCK');
|
||||
assert.equal(result.ticker, 'AAPL');
|
||||
});
|
||||
|
||||
test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
||||
assert.equal(result.pegRatio, expected);
|
||||
});
|
||||
@@ -43,12 +43,12 @@ test('uses Yahoo pegRatio when available', () => {
|
||||
...base,
|
||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
||||
};
|
||||
const result = mapToStandardFormat('AAPL', summary);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', summary);
|
||||
assert.equal(result.pegRatio, 1.5);
|
||||
});
|
||||
|
||||
test('debtToEquity is divided by 100', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ test('maps ETF quoteType to ETF', () => {
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Large Blend' },
|
||||
};
|
||||
const result = mapToStandardFormat('VOO', etfSummary);
|
||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.type, 'ETF');
|
||||
});
|
||||
|
||||
@@ -68,19 +68,19 @@ test('classifies bond ETF from category keyword', () => {
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||
};
|
||||
const result = mapToStandardFormat('BND', bondSummary);
|
||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.type, 'BOND');
|
||||
});
|
||||
|
||||
test('FCF yield is computed when data available', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
assert(result.fcfYield > 0);
|
||||
assert((result.fcfYield as number) > 0);
|
||||
});
|
||||
|
||||
test('peRatio prefers trailingPE over forwardPE', () => {
|
||||
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.peRatio, 30); // trailing should win
|
||||
});
|
||||
|
||||
@@ -89,9 +89,9 @@ test('negative FCF yield is preserved, not nulled', () => {
|
||||
...base,
|
||||
financialData: { ...base.financialData, freeCashflow: -2e9 },
|
||||
};
|
||||
const result = mapToStandardFormat('AAPL', negativeFcf);
|
||||
const result = DataMapper.mapToStandardFormat('AAPL', negativeFcf);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
assert(result.fcfYield < 0, 'negative FCF should produce negative yield, not null');
|
||||
assert((result.fcfYield as number) < 0, 'negative FCF should produce negative yield, not null');
|
||||
});
|
||||
|
||||
test('ETF maps volume from summaryDetail', () => {
|
||||
@@ -107,7 +107,7 @@ test('ETF maps volume from summaryDetail', () => {
|
||||
},
|
||||
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
|
||||
};
|
||||
const result = mapToStandardFormat('VOO', etfSummary);
|
||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.volume, 5000000);
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ test('bond duration inferred from category — intermediate maps to 5y', () => {
|
||||
summaryDetail: { yield: 0.045 },
|
||||
defaultKeyStatistics: {},
|
||||
};
|
||||
const result = mapToStandardFormat('BND', bondSummary);
|
||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.duration, 5);
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ test('bond duration inferred from category — short-term maps to 2y', () => {
|
||||
summaryDetail: { yield: 0.05 },
|
||||
defaultKeyStatistics: {},
|
||||
};
|
||||
const result = mapToStandardFormat('SHY', bondSummary);
|
||||
const result = DataMapper.mapToStandardFormat('SHY', bondSummary);
|
||||
assert.equal(result.duration, 2);
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ test('metrics are null (not 0) when data missing', () => {
|
||||
summaryDetail: {},
|
||||
assetProfile: {},
|
||||
};
|
||||
const result = mapToStandardFormat('X', sparse);
|
||||
const result = DataMapper.mapToStandardFormat('X', sparse);
|
||||
assert.equal(result.pegRatio, null);
|
||||
assert.equal(result.quickRatio, null);
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../server/screener/scorers/EtfScorer.js';
|
||||
|
||||
const rules = {
|
||||
gates: { maxExpenseRatio: 0.5 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
|
||||
};
|
||||
|
||||
test('rejects ETF with expense ratio above gate', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.8, yield: 2.0 }, rules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('efficient label for low-cost, high-yield ETF', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules);
|
||||
assert.equal(result.label, '🟢 Efficient');
|
||||
});
|
||||
|
||||
test('neutral when yield is below threshold', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }, rules);
|
||||
assert.equal(result.label, '🟡 Neutral');
|
||||
});
|
||||
|
||||
test('audit breakdown includes cost, yield, vol keys', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules);
|
||||
assert(result.audit.breakdown.cost != null);
|
||||
assert(result.audit.breakdown.yield != null);
|
||||
assert(result.audit.breakdown.vol != null);
|
||||
});
|
||||
|
||||
test('penalises ETF with volume below liquidity floor', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }, rules);
|
||||
assert(result.audit.breakdown.vol < 0, 'low-volume ETF should receive negative vol score');
|
||||
});
|
||||
|
||||
test('scores 5Y return when threshold configured', () => {
|
||||
const rulesWithReturn = {
|
||||
...rules,
|
||||
weights: { ...rules.weights, fiveYearReturn: 2 },
|
||||
thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 },
|
||||
};
|
||||
const good = EtfScorer.score(
|
||||
{ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 },
|
||||
rulesWithReturn,
|
||||
);
|
||||
const poor = EtfScorer.score(
|
||||
{ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 },
|
||||
rulesWithReturn,
|
||||
);
|
||||
assert(good.audit.breakdown.fiveYearReturn > 0, 'strong 5Y return should score positively');
|
||||
assert(poor.audit.breakdown.fiveYearReturn < 0, 'weak 5Y return should score negatively');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../server/scorers/EtfScorer';
|
||||
import type { EtfMetrics } from '../server/types';
|
||||
|
||||
const rules = {
|
||||
gates: { maxExpenseRatio: 0.5 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
|
||||
};
|
||||
|
||||
// Helper to build minimal EtfMetrics fixtures (totalAssets/fiveYearReturn unused by scorer).
|
||||
const etf = (partial: Partial<EtfMetrics>): EtfMetrics => ({
|
||||
totalAssets: 0,
|
||||
fiveYearReturn: 0,
|
||||
volume: 0,
|
||||
yield: 0,
|
||||
expenseRatio: 0,
|
||||
...partial,
|
||||
});
|
||||
|
||||
test('rejects ETF with expense ratio above gate', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.8, yield: 2.0 }), rules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('efficient label for low-cost, high-yield ETF', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
||||
assert.equal(result.label, '🟢 Efficient');
|
||||
});
|
||||
|
||||
test('neutral when yield is below threshold', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }), rules);
|
||||
assert.equal(result.label, '🟡 Neutral');
|
||||
});
|
||||
|
||||
test('audit breakdown includes cost, yield, vol keys', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
||||
assert(result.audit.breakdown!.cost != null);
|
||||
assert(result.audit.breakdown!.yield != null);
|
||||
assert(result.audit.breakdown!.vol != null);
|
||||
});
|
||||
|
||||
test('penalises ETF with volume below liquidity floor', () => {
|
||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }), rules);
|
||||
assert(result.audit.breakdown!.vol < 0, 'low-volume ETF should receive negative vol score');
|
||||
});
|
||||
|
||||
test('scores 5Y return when threshold configured', () => {
|
||||
const rulesWithReturn = {
|
||||
...rules,
|
||||
weights: { ...rules.weights, fiveYearReturn: 2 },
|
||||
thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 },
|
||||
};
|
||||
const good = EtfScorer.score(
|
||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 }),
|
||||
rulesWithReturn,
|
||||
);
|
||||
const poor = EtfScorer.score(
|
||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 }),
|
||||
rulesWithReturn,
|
||||
);
|
||||
assert(good.audit.breakdown!.fiveYearReturn > 0, 'strong 5Y return should score positively');
|
||||
assert(poor.audit.breakdown!.fiveYearReturn < 0, 'weak 5Y return should score negatively');
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
|
||||
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
|
||||
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
|
||||
|
||||
function stripFences(raw) {
|
||||
function stripFences(raw: string): string {
|
||||
return raw
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/i, '')
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MarketRegime } from '../server/market/MarketRegime.js';
|
||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants.js';
|
||||
import { MarketRegime } from '../server/services/MarketRegime';
|
||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants';
|
||||
import type { Benchmarks, RateRegime } from '../server/types';
|
||||
|
||||
const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra });
|
||||
const regime = (benchmarks: Partial<Benchmarks>, extra: { rateRegime?: RateRegime } = {}) =>
|
||||
new MarketRegime({ benchmarks: benchmarks as Benchmarks, ...extra });
|
||||
|
||||
test('stock inflated P/E = marketPE × 1.5', () => {
|
||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
@@ -1,50 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
||||
import { SIGNAL } from '../server/config/constants.js';
|
||||
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
||||
import { SIGNAL } from '../server/config/constants';
|
||||
import type { PortfolioHolding } from '../server/types';
|
||||
|
||||
const advisor = new PortfolioAdvisor();
|
||||
// Cast to any to access private methods — tests exercise internal behaviour directly.
|
||||
const advisor = new PortfolioAdvisor() as any;
|
||||
|
||||
// Minimal holding shape used by _position and _advice (only costBasis/shares matter).
|
||||
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
||||
ticker: 'TEST',
|
||||
source: 'Test',
|
||||
type: 'stock',
|
||||
costBasis,
|
||||
shares,
|
||||
});
|
||||
|
||||
test('_position: computes gain/loss correctly', () => {
|
||||
const pos = advisor._position({ costBasis: 100, shares: 10 }, 150);
|
||||
const pos = advisor._position(holding(100, 10), 150);
|
||||
assert.equal(pos.gainLossPct, '50.0');
|
||||
assert.equal(pos.marketValue, '1500.00');
|
||||
assert.equal(pos.totalCost, '1000.00');
|
||||
});
|
||||
|
||||
test('_position: returns null gainLoss when price unavailable', () => {
|
||||
const pos = advisor._position({ costBasis: 100, shares: 10 }, null);
|
||||
const pos = advisor._position(holding(100, 10), null);
|
||||
assert.equal(pos.gainLossPct, null);
|
||||
assert.equal(pos.marketValue, null);
|
||||
});
|
||||
|
||||
test('_advice: Strong Buy → Hold & Add', () => {
|
||||
const { action } = advisor._advice(SIGNAL.STRONG_BUY, { costBasis: 100, shares: 10 }, 150);
|
||||
const { action } = advisor._advice(SIGNAL.STRONG_BUY, holding(100, 10), 150);
|
||||
assert.equal(action, '🟢 Hold & Add');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 150, shares: 10 }, 100);
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, holding(150, 10), 100);
|
||||
assert.equal(action, '🔴 Sell (Cut Loss)');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 100, shares: 10 }, 150);
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, holding(100, 10), 150);
|
||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||
});
|
||||
|
||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
||||
const { action } = advisor._advice(SIGNAL.SPECULATION, { costBasis: 100, shares: 10 }, 125);
|
||||
const { action } = advisor._advice(SIGNAL.SPECULATION, holding(100, 10), 125);
|
||||
assert.equal(action, '🟠 Reduce Position');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: no price → No price data', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null);
|
||||
const { action } = advisor._cryptoAdvice(holding(100, 1), null);
|
||||
assert.equal(action, '⚪ No price data');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000);
|
||||
const { action } = advisor._cryptoAdvice(holding(10000, 1), 25000);
|
||||
assert.equal(action, '🟠 Consider taking profits');
|
||||
});
|
||||
|
||||
@@ -58,7 +69,7 @@ test('advise: BRK-B screener result matches BRK.B holding', async () => {
|
||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||
};
|
||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||
const holding = {
|
||||
const holding: PortfolioHolding = {
|
||||
ticker: 'BRK.B',
|
||||
shares: 1,
|
||||
costBasis: 400,
|
||||
@@ -79,7 +90,7 @@ test('advise: BRK.B screener result matches BRK-B holding', async () => {
|
||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||
};
|
||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||
const holding = {
|
||||
const holding: PortfolioHolding = {
|
||||
ticker: 'BRK-B',
|
||||
shares: 1,
|
||||
costBasis: 400,
|
||||
@@ -1,17 +1,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RuleMerger } from '../server/screener/RuleMerger.js';
|
||||
import { SCORE_MODE } from '../server/config/constants.js';
|
||||
import { RuleMerger } from '../server/services/RuleMerger';
|
||||
import { SCORE_MODE } from '../server/config/constants';
|
||||
import type { MarketContext } from '../server/types';
|
||||
|
||||
const ctx = {
|
||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||
};
|
||||
} as Partial<MarketContext>;
|
||||
|
||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
||||
@@ -22,7 +23,7 @@ test('INFLATED mode loosens P/E gate from live SPY data', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
||||
@@ -33,7 +34,7 @@ test('INFLATED tech P/E gate uses XLK benchmark', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'TECHNOLOGY' },
|
||||
ctx,
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
||||
@@ -43,7 +44,7 @@ test('Sector override applied before inflated overrides', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'REIT' },
|
||||
ctx,
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 9999);
|
||||
@@ -55,12 +56,15 @@ test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
ctx as MarketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
) as unknown as Record<string, unknown>;
|
||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
||||
});
|
||||
|
||||
test('throws for unknown asset type', () => {
|
||||
assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/);
|
||||
assert.throws(
|
||||
() => RuleMerger.getRulesForAsset('CRYPTO' as never, {}, ctx as MarketContext),
|
||||
/No rules configured/,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig.js';
|
||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig';
|
||||
|
||||
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
||||
@@ -17,23 +17,23 @@ test('STOCK base gates are fundamental (Graham-style)', () => {
|
||||
});
|
||||
|
||||
test('REIT sector override zeroes out irrelevant weights', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.weights.margin, 0);
|
||||
assert.equal(reit.weights.peg, 0);
|
||||
assert.equal(reit.weights.revenue, 0);
|
||||
assert.equal(reit.weights.yield, 5);
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||
assert.equal(reit.weights!.margin, 0);
|
||||
assert.equal(reit.weights!.peg, 0);
|
||||
assert.equal(reit.weights!.revenue, 0);
|
||||
assert.equal(reit.weights!.yield, 5);
|
||||
});
|
||||
|
||||
test('REIT gates disable P/E and PEG', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.gates.maxPERatio, 9999);
|
||||
assert.equal(reit.gates.maxPegGate, 9999);
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||
assert.equal(reit.gates!.maxPERatio, 9999);
|
||||
assert.equal(reit.gates!.maxPegGate, 9999);
|
||||
});
|
||||
|
||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY;
|
||||
assert.equal(tech.gates.maxDebtToEquity, 2.0);
|
||||
assert.equal(tech.gates.minQuickRatio, 0.8);
|
||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY!;
|
||||
assert.equal(tech.gates!.maxDebtToEquity, 2.0);
|
||||
assert.equal(tech.gates!.minQuickRatio, 0.8);
|
||||
});
|
||||
|
||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StockScorer } from '../server/screener/scorers/StockScorer.js';
|
||||
import { StockScorer } from '../server/scorers/StockScorer';
|
||||
import type { StockMetrics } from '../server/types';
|
||||
|
||||
const baseRules = {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
||||
@@ -21,7 +22,49 @@ const baseRules = {
|
||||
},
|
||||
};
|
||||
|
||||
const pass = {
|
||||
// Minimal fixture — tests exercise specific fields; unused metrics are null.
|
||||
const nullMetrics: Omit<
|
||||
StockMetrics,
|
||||
| 'sector'
|
||||
| 'capCategory'
|
||||
| 'growthCategory'
|
||||
| 'currentPrice'
|
||||
| 'peRatio'
|
||||
| 'pegRatio'
|
||||
| 'debtToEquity'
|
||||
| 'quickRatio'
|
||||
| 'returnOnEquity'
|
||||
| 'operatingMargin'
|
||||
| 'netProfitMargin'
|
||||
| 'revenueGrowth'
|
||||
| 'fcfYield'
|
||||
> = {
|
||||
priceToBook: null,
|
||||
grossMargin: null,
|
||||
earningsGrowth: null,
|
||||
pFFO: null,
|
||||
dividendYield: null,
|
||||
beta: null,
|
||||
week52High: null,
|
||||
week52Low: null,
|
||||
week52Change: null,
|
||||
week52FromHigh: null,
|
||||
week52FromLow: null,
|
||||
marketCap: null,
|
||||
analystRating: null,
|
||||
analystTargetPrice: null,
|
||||
analystUpside: null,
|
||||
numberOfAnalysts: null,
|
||||
dcfIntrinsicValue: null,
|
||||
dcfMarginOfSafety: null,
|
||||
};
|
||||
|
||||
const pass: StockMetrics = {
|
||||
...nullMetrics,
|
||||
sector: 'GENERAL',
|
||||
capCategory: 'Large Cap',
|
||||
growthCategory: 'Growth',
|
||||
currentPrice: 150,
|
||||
peRatio: 15,
|
||||
pegRatio: 1.2,
|
||||
debtToEquity: 1.0,
|
||||
@@ -63,8 +106,8 @@ test('high-conviction BUY on strong metrics', () => {
|
||||
test('audit breakdown contains scored factors', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert(result.audit.passedGates);
|
||||
assert(result.audit.breakdown.roe != null);
|
||||
assert(result.audit.breakdown.margin != null);
|
||||
assert(result.audit.breakdown!.roe != null);
|
||||
assert(result.audit.breakdown!.margin != null);
|
||||
});
|
||||
|
||||
test('beta > 1.5 surfaces as risk flag', () => {
|
||||
Reference in New Issue
Block a user