refactor: restructure to clean architecture
fix: restore ScoringConfig improvements lost in refactor commit docs: rewrite README and CLAUDE.md to reflect current architecture code-format code fixes
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BondScorer } from '../src/screener/scorers/BondScorer.js';
|
||||
|
||||
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||
|
||||
const rules = {
|
||||
gates: { minCreditRating: 7 },
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
||||
};
|
||||
const ctx = { riskFreeRate: 4.5 };
|
||||
|
||||
test('rejects bond below investment-grade floor', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🔴 Avoid');
|
||||
assert(result.scoreSummary.includes('Gate failed'));
|
||||
});
|
||||
|
||||
test('attractive for wide spread and short duration', () => {
|
||||
// ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🟢 Attractive');
|
||||
});
|
||||
|
||||
test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread);
|
||||
});
|
||||
|
||||
test('fails spread when yield barely above risk-free', () => {
|
||||
// ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, -2);
|
||||
});
|
||||
|
||||
test('penalises long duration', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.duration, -1);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapToStandardFormat } from '../src/screener/DataMapper.js';
|
||||
|
||||
const base = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||
assetProfile: { sector: 'Technology', industry: 'Software', category: '' },
|
||||
financialData: {
|
||||
quickRatio: 1.2,
|
||||
debtToEquity: 150,
|
||||
freeCashflow: 5e9,
|
||||
revenueGrowth: 0.15,
|
||||
profitMargins: 0.25,
|
||||
operatingMargins: 0.3,
|
||||
returnOnEquity: 0.2,
|
||||
earningsGrowth: 0.12,
|
||||
operatingCashflow: 8e9,
|
||||
},
|
||||
defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 },
|
||||
summaryDetail: {
|
||||
trailingAnnualDividendYield: 0.005,
|
||||
trailingPE: 30,
|
||||
beta: 1.2,
|
||||
fiftyTwoWeekHigh: 200,
|
||||
fiftyTwoWeekLow: 120,
|
||||
},
|
||||
};
|
||||
|
||||
test('maps EQUITY quote type to STOCK', () => {
|
||||
const result = 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 expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
||||
assert.equal(result.pegRatio, expected);
|
||||
});
|
||||
|
||||
test('uses Yahoo pegRatio when available', () => {
|
||||
const summary = {
|
||||
...base,
|
||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
||||
};
|
||||
const result = mapToStandardFormat('AAPL', summary);
|
||||
assert.equal(result.pegRatio, 1.5);
|
||||
});
|
||||
|
||||
test('debtToEquity is divided by 100', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
||||
});
|
||||
|
||||
test('maps ETF quoteType to ETF', () => {
|
||||
const etfSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Large Blend' },
|
||||
};
|
||||
const result = mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.type, 'ETF');
|
||||
});
|
||||
|
||||
test('classifies bond ETF from category keyword', () => {
|
||||
const bondSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||
};
|
||||
const result = mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.type, 'BOND');
|
||||
});
|
||||
|
||||
test('FCF yield is computed when data available', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
assert(result.fcfYield > 0);
|
||||
});
|
||||
|
||||
test('metrics are null (not 0) when data missing', () => {
|
||||
const sparse = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 100 },
|
||||
financialData: {},
|
||||
defaultKeyStatistics: {},
|
||||
summaryDetail: {},
|
||||
assetProfile: {},
|
||||
};
|
||||
const result = mapToStandardFormat('X', sparse);
|
||||
assert.equal(result.pegRatio, null);
|
||||
assert.equal(result.quickRatio, null);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../src/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);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MarketRegime } from '../src/market/MarketRegime.js';
|
||||
import { SECTOR, ASSET_TYPE } from '../src/config/constants.js';
|
||||
|
||||
const regime = (benchmarks) => new MarketRegime({ benchmarks });
|
||||
|
||||
test('stock inflated P/E = marketPE × 1.5', () => {
|
||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36
|
||||
});
|
||||
|
||||
test('tech inflated P/E = techPE × 1.3', () => {
|
||||
const { gates } = regime({ techPE: 40 }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.TECHNOLOGY,
|
||||
);
|
||||
assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52
|
||||
});
|
||||
|
||||
test('REIT inflated minYield = reitYield × 0.85', () => {
|
||||
const { thresholds } = regime({ reitYield: 4.0 }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.REIT,
|
||||
);
|
||||
assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40
|
||||
});
|
||||
|
||||
test('bond inflated minSpread = igSpread × 0.80', () => {
|
||||
const { thresholds } = regime({ igSpread: 1.5 }).getInflatedOverrides(
|
||||
ASSET_TYPE.BOND,
|
||||
SECTOR.GENERAL,
|
||||
);
|
||||
assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20
|
||||
});
|
||||
|
||||
test('ETF inflated loosens expense gate to 0.75', () => {
|
||||
const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF);
|
||||
assert.equal(gates.maxExpenseRatio, 0.75);
|
||||
});
|
||||
|
||||
test('falls back to defaults when benchmarks missing', () => {
|
||||
const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
|
||||
import { SIGNAL } from '../src/config/constants.js';
|
||||
|
||||
const advisor = new PortfolioAdvisor();
|
||||
|
||||
test('_position: computes gain/loss correctly', () => {
|
||||
const pos = advisor._position({ costBasis: 100, shares: 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);
|
||||
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);
|
||||
assert.equal(action, '🟢 Hold & Add');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 150, shares: 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);
|
||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||
});
|
||||
|
||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
||||
const { action } = advisor._advice(SIGNAL.SPECULATION, { costBasis: 100, shares: 10 }, 125);
|
||||
assert.equal(action, '🟠 Reduce Position');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: no price → No price data', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null);
|
||||
assert.equal(action, '⚪ No price data');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000);
|
||||
assert.equal(action, '🟠 Consider taking profits');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RuleMerger } from '../src/screener/RuleMerger.js';
|
||||
import { SCORE_MODE } from '../src/config/constants.js';
|
||||
|
||||
const ctx = {
|
||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||
};
|
||||
|
||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
||||
assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard
|
||||
});
|
||||
|
||||
test('INFLATED mode loosens P/E gate from live SPY data', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
||||
assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x');
|
||||
});
|
||||
|
||||
test('INFLATED tech P/E gate uses XLK benchmark', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'TECHNOLOGY' },
|
||||
ctx,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
||||
});
|
||||
|
||||
test('Sector override applied before inflated overrides', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'REIT' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 9999);
|
||||
assert.equal(rules.weights.yield, 5);
|
||||
assert.equal(rules.weights.margin, 0);
|
||||
});
|
||||
|
||||
test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
||||
});
|
||||
|
||||
test('throws for unknown asset type', () => {
|
||||
assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../src/config/ScoringConfig.js';
|
||||
|
||||
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
||||
assert.equal(CREDIT_RATING_SCALE.BBB, 7);
|
||||
assert.equal(CREDIT_RATING_SCALE.BB, 6);
|
||||
assert.equal(CREDIT_RATING_SCALE.D, 1);
|
||||
});
|
||||
|
||||
test('STOCK base gates are fundamental (Graham-style)', () => {
|
||||
const { gates } = ScoringRules.STOCK;
|
||||
assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings
|
||||
assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price
|
||||
assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress
|
||||
});
|
||||
|
||||
test('REIT sector override zeroes out irrelevant weights', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.weights.margin, 0);
|
||||
assert.equal(reit.weights.peg, 0);
|
||||
assert.equal(reit.weights.revenue, 0);
|
||||
assert.equal(reit.weights.yield, 5);
|
||||
});
|
||||
|
||||
test('REIT gates disable P/E and PEG', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.gates.maxPERatio, 9999);
|
||||
assert.equal(reit.gates.maxPegGate, 9999);
|
||||
});
|
||||
|
||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY;
|
||||
assert.equal(tech.gates.maxDebtToEquity, 2.0);
|
||||
assert.equal(tech.gates.minQuickRatio, 0.8);
|
||||
});
|
||||
|
||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
||||
assert.equal(ScoringRules.BOND.gates.minCreditRating, 7);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StockScorer } from '../src/screener/scorers/StockScorer.js';
|
||||
|
||||
const baseRules = {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 10,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const pass = {
|
||||
peRatio: 15,
|
||||
pegRatio: 1.2,
|
||||
debtToEquity: 1.0,
|
||||
quickRatio: 1.0,
|
||||
returnOnEquity: 22,
|
||||
operatingMargin: 25,
|
||||
netProfitMargin: 18,
|
||||
revenueGrowth: 16,
|
||||
fcfYield: 6,
|
||||
};
|
||||
|
||||
test('rejects on high D/E', () => {
|
||||
const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('D/E'));
|
||||
});
|
||||
|
||||
test('rejects on high P/E', () => {
|
||||
const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('P/E'));
|
||||
});
|
||||
|
||||
test('rejects on high PEG', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('skips gate when metric is null (missing data)', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules);
|
||||
assert.notEqual(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('high-conviction BUY on strong metrics', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert.equal(result.label, '🟢 BUY (High Conviction)');
|
||||
});
|
||||
|
||||
test('audit breakdown contains scored factors', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert(result.audit.passedGates);
|
||||
assert(result.audit.breakdown.roe != null);
|
||||
assert(result.audit.breakdown.margin != null);
|
||||
});
|
||||
|
||||
test('beta > 1.5 surfaces as risk flag', () => {
|
||||
const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('High volatility')));
|
||||
});
|
||||
|
||||
test('near 52-week high surfaces as risk flag', () => {
|
||||
const result = StockScorer.score(
|
||||
{ ...pass, week52High: 200, week52Low: 100, currentPrice: 195 },
|
||||
baseRules,
|
||||
);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('52-week high')));
|
||||
});
|
||||
Reference in New Issue
Block a user