phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance - Migrated 58 TypeScript files to domain-driven structure - Updated CLAUDE.md with new architecture documentation - Added .gitignore rules for .md files (except CLAUDE.md) - Removed unused CatalystAnalyst import from app.ts - Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions - Verified no sensitive data in git history - Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
committed by
saikiranvella
parent
c7e39c3e4e
commit
c388b6d83c
@@ -0,0 +1,53 @@
|
||||
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../../domains/shared';
|
||||
|
||||
export class PersonalFinanceAnalyzer {
|
||||
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
|
||||
|
||||
const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type));
|
||||
const investments = accounts.filter((a) => a.type === 'INVESTMENT');
|
||||
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
|
||||
const allTx = accounts.flatMap((a) => a.transactions);
|
||||
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
|
||||
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
|
||||
|
||||
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
|
||||
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const tx of spending) {
|
||||
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
|
||||
}
|
||||
|
||||
const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, amount]) => ({
|
||||
category,
|
||||
amount,
|
||||
pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0',
|
||||
}));
|
||||
|
||||
return {
|
||||
netWorth,
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
totalCash,
|
||||
totalInvestments: totalInvest,
|
||||
cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0',
|
||||
investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0',
|
||||
totalIncome,
|
||||
totalSpend,
|
||||
savingsRate:
|
||||
totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null,
|
||||
categoryBreakdown,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
YahooFinanceClient,
|
||||
BenchmarkProvider,
|
||||
chunkArray,
|
||||
Stock,
|
||||
Etf,
|
||||
Bond,
|
||||
SIGNAL,
|
||||
SIGNAL_ORDER,
|
||||
SCORE_MODE,
|
||||
ASSET_TYPE,
|
||||
} from '../../domains/shared';
|
||||
import { DataMapper } from './transform/DataMapper';
|
||||
import { RuleMerger } from './transform/RuleMerger';
|
||||
import { StockScorer } from './scorers/StockScorer';
|
||||
import { EtfScorer } from './scorers/EtfScorer';
|
||||
import { BondScorer } from './scorers/BondScorer';
|
||||
import type {
|
||||
Logger,
|
||||
MarketContext,
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreResult,
|
||||
ScreenerResult,
|
||||
ScreenerEngineOptions,
|
||||
ErrorResult,
|
||||
MappedData,
|
||||
StockData,
|
||||
EtfData,
|
||||
BondData,
|
||||
} from '../../domains/shared';
|
||||
|
||||
export class ScreenerEngine {
|
||||
private static readonly BATCH_SIZE = 5;
|
||||
private static readonly BATCH_DELAY_MS = 1000;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly client: YahooFinanceClient,
|
||||
private readonly benchmarkProvider: BenchmarkProvider,
|
||||
{ logger }: ScreenerEngineOptions = {},
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
this.logger = logger ?? {
|
||||
write: (msg: string) => process.stdout.write(msg),
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
};
|
||||
}
|
||||
|
||||
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this.screenInternal(tickers, false);
|
||||
}
|
||||
|
||||
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this.screenInternal(tickers, true);
|
||||
}
|
||||
|
||||
private async screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
||||
const marketContext = await this.fetchMarketContext(showProgress);
|
||||
const results = this.initializeResults();
|
||||
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
||||
let processed = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await this.processBatch(chunk, marketContext, results);
|
||||
processed += chunk.length;
|
||||
this.logProgress(showProgress, processed, tickers.length);
|
||||
await this.rateLimitDelay();
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
this.logger.write('\n');
|
||||
}
|
||||
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
private async fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
||||
if (showProgress) {
|
||||
this.logger.write('⏳ Fetching market context...');
|
||||
}
|
||||
const context = await this.benchmarkProvider.getMarketContext();
|
||||
if (showProgress) {
|
||||
this.logger.write(' done\n');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
||||
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
}
|
||||
|
||||
private async processBatch(
|
||||
tickers: string[],
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
): Promise<void> {
|
||||
const batch = await Promise.all(tickers.map((t) => this.fetch(t)));
|
||||
batch.forEach((data) => this.process(data, marketContext, results));
|
||||
}
|
||||
|
||||
private logProgress(showProgress: boolean, processed: number, total: number): void {
|
||||
if (showProgress) {
|
||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async rateLimitDelay(): Promise<void> {
|
||||
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
||||
}
|
||||
|
||||
private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||
return DataMapper.mapToStandardFormat(ticker, summary);
|
||||
} catch (err) {
|
||||
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
private process(
|
||||
data: MappedData | ErrorResult,
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
): void {
|
||||
if ('isError' in data && data.isError) {
|
||||
const e = data as ErrorResult;
|
||||
results.ERROR.push({ ticker: e.ticker, message: e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = this.buildAsset(data as MappedData);
|
||||
const fundamental = this.score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
||||
const inflated = this.score(asset, marketContext, SCORE_MODE.INFLATED);
|
||||
|
||||
(results[asset.type as AssetType] as unknown[]).push({
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this.signal(fundamental.label, inflated.label),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
|
||||
// its exact metrics type. No `as never` or unsafe casts required.
|
||||
private score(
|
||||
asset: Stock | Etf | Bond,
|
||||
marketContext: MarketContext,
|
||||
mode: string,
|
||||
): ScoreResult {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
asset.type as AssetType,
|
||||
asset.metrics as { sector?: string },
|
||||
marketContext,
|
||||
mode,
|
||||
);
|
||||
if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules);
|
||||
if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules);
|
||||
if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext);
|
||||
// TypeScript exhaustive check: all three branches are handled above.
|
||||
throw new Error('No scorer for unknown asset type');
|
||||
}
|
||||
|
||||
private buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
||||
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||
case ASSET_TYPE.BOND:
|
||||
return new Bond(data as BondData);
|
||||
case ASSET_TYPE.ETF:
|
||||
return new Etf(data as EtfData);
|
||||
default:
|
||||
return new Stock(data as StockData);
|
||||
}
|
||||
}
|
||||
|
||||
private signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
||||
const green = (l: string) => l.startsWith('🟢');
|
||||
const yellow = (l: string) => l.startsWith('🟡');
|
||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
|
||||
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
|
||||
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
|
||||
return SIGNAL.AVOID;
|
||||
}
|
||||
|
||||
signalOrder(signal: Signal): number {
|
||||
return SIGNAL_ORDER[signal] ?? 5;
|
||||
}
|
||||
|
||||
getMarketContext(): Promise<MarketContext> {
|
||||
return this.benchmarkProvider.getMarketContext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { LLMAnalyst } from '../../domains/shared';
|
||||
import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
||||
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class AnalyzeController {
|
||||
private readonly catalystAnalyst: CatalystAnalyst;
|
||||
|
||||
constructor(
|
||||
private readonly catalystCache: CatalystCache,
|
||||
private readonly llm: LLMAnalyst,
|
||||
) {
|
||||
// Create a fresh instance for per-ticker story fetching (not cached)
|
||||
this.catalystAnalyst = new CatalystAnalyst();
|
||||
}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
'/api/analyze',
|
||||
{ schema: analyzeSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
this.analyze.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private async analyze(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!this.llm.isAvailable) {
|
||||
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
|
||||
}
|
||||
|
||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||
|
||||
const stories = await this.catalystAnalyst.fetchStoriesForTickers(tickers);
|
||||
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
|
||||
|
||||
const { tickerFrequency } = CatalystAnalyst.rankTickers(stories);
|
||||
const analysis = await this.llm.analyze(stories, tickers, tickerFrequency);
|
||||
return { analysis };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Screener domain — stock/ETF/bond filtering and scoring
|
||||
|
||||
// Controllers
|
||||
export { ScreenerController } from './screener.controller';
|
||||
export { AnalyzeController } from './analyze.controller';
|
||||
|
||||
// Services
|
||||
export { ScreenerEngine } from './ScreenerEngine';
|
||||
export { PersonalFinanceAnalyzer } from './PersonalFinanceAnalyzer';
|
||||
|
||||
// Scorers
|
||||
export { StockScorer } from './scorers/StockScorer';
|
||||
export { EtfScorer } from './scorers/EtfScorer';
|
||||
export { BondScorer } from './scorers/BondScorer';
|
||||
|
||||
// Transform utilities
|
||||
export { DataMapper } from './transform/DataMapper';
|
||||
export { RuleMerger } from './transform/RuleMerger';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
BondMetrics,
|
||||
MarketContext,
|
||||
ScoreResult,
|
||||
SanitizedBondMetrics,
|
||||
} from '../../../domains/shared';
|
||||
|
||||
export class BondScorer {
|
||||
static score(
|
||||
m: BondMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
context?: MarketContext | null,
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = BondScorer.sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
}
|
||||
|
||||
private static sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||
const pct = (v: unknown): number =>
|
||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(String(m.duration)) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||
|
||||
export class EtfScorer {
|
||||
static score(
|
||||
m: EtfMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||
yield: parseFloat(String(m.yield)) || 0,
|
||||
volume: parseFloat(String(m.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'Gate failed: High Expense Ratio',
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
const breakdown: Record<string, number> = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
? (weights.fiveYearReturn ?? 1)
|
||||
: -1
|
||||
: 0,
|
||||
};
|
||||
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
||||
|
||||
export class StockScorer {
|
||||
private static n(v: unknown): NumVal {
|
||||
const f = parseFloat(String(v));
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
}
|
||||
|
||||
private static scoreValue(val: number, high: number, med: number, weight: number): number {
|
||||
return val >= high ? weight : val >= med ? 1 : -1;
|
||||
}
|
||||
|
||||
private static scorePeg(val: number, high: number, med: number, weight: number): number {
|
||||
return val <= high ? weight : val <= med ? 1 : -1;
|
||||
}
|
||||
static score(
|
||||
metrics: StockMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreResult {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = StockScorer.sanitize(metrics);
|
||||
|
||||
const failures = [
|
||||
m.debtToEquity != null &&
|
||||
m.debtToEquity > gates.maxDebtToEquity &&
|
||||
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
|
||||
m.quickRatio != null &&
|
||||
m.quickRatio < gates.minQuickRatio &&
|
||||
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
|
||||
m.peRatio != null &&
|
||||
m.peRatio > gates.maxPERatio &&
|
||||
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
|
||||
m.pegRatio != null &&
|
||||
m.pegRatio > gates.maxPegGate &&
|
||||
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
|
||||
m.priceToBook != null &&
|
||||
gates.maxPriceToBook &&
|
||||
m.priceToBook > gates.maxPriceToBook &&
|
||||
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
const factors = [
|
||||
{
|
||||
key: 'roe',
|
||||
active: weights.roe > 0 && m.returnOnEquity != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.returnOnEquity!,
|
||||
thresholds.roeHigh,
|
||||
thresholds.roeMed,
|
||||
weights.roe,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.operatingMargin!,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.netProfitMargin!,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () =>
|
||||
StockScorer.scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.revenueGrowth!,
|
||||
thresholds.revHigh,
|
||||
thresholds.revMed,
|
||||
weights.revenue,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
StockScorer.scoreValue(
|
||||
m.fcfYield!,
|
||||
thresholds.fcfHigh ?? 5,
|
||||
thresholds.fcfMed ?? 2,
|
||||
weights.fcf,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
|
||||
fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
},
|
||||
{
|
||||
key: 'pFFO',
|
||||
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
|
||||
fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
},
|
||||
{
|
||||
key: 'priceToBook',
|
||||
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
|
||||
fn: () => StockScorer.scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
// ── Expert features ────────────────────────────────────────────────
|
||||
{
|
||||
// Analyst consensus: Yahoo recommendationMean 1=Strong Buy → 5=Strong Sell.
|
||||
// We invert and score: ≤ analystBuy gets full weight, ≤ analystHold gets 1pt,
|
||||
// above Hold loses weight. Requires ≥ 3 analysts to avoid noise from thin coverage.
|
||||
key: 'analyst',
|
||||
active:
|
||||
(weights.analyst ?? 0) > 0 &&
|
||||
m.analystRating != null &&
|
||||
(metrics.numberOfAnalysts ?? 0) >= 3,
|
||||
fn: (): number => {
|
||||
const r = m.analystRating!;
|
||||
const buyThreshold = thresholds.analystBuy ?? 2.0;
|
||||
const holdThreshold = thresholds.analystHold ?? 3.0;
|
||||
if (r <= buyThreshold) return weights.analyst ?? 2;
|
||||
if (r <= holdThreshold) return 1;
|
||||
if (r <= 4.0) return -1;
|
||||
return -(weights.analyst ?? 2); // Strong Sell
|
||||
},
|
||||
},
|
||||
{
|
||||
// DCF margin of safety: how undervalued the stock is vs. 2-stage FCF model.
|
||||
// Positive = undervalued (good), negative = overvalued (bad).
|
||||
// Only fires when DCF could be computed (positive FCF required).
|
||||
key: 'dcf',
|
||||
active: (weights.dcf ?? 0) > 0 && m.dcfMarginOfSafety != null,
|
||||
fn: (): number => {
|
||||
const mos = m.dcfMarginOfSafety!;
|
||||
const undervalued = thresholds.dcfUndervalued ?? 20;
|
||||
const fairValue = thresholds.dcfFairValue ?? 0;
|
||||
if (mos >= undervalued) return weights.dcf ?? 2;
|
||||
if (mos >= fairValue) return 1;
|
||||
if (mos >= -20) return -1;
|
||||
return -(weights.dcf ?? 2); // significantly overvalued
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown: Record<string, number> = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn() as number;
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
|
||||
const riskFlags = [
|
||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
|
||||
// 52-week position flags
|
||||
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
|
||||
m.week52Position != null &&
|
||||
m.week52Position < 0.1 &&
|
||||
'Near 52-week low — potential opportunity',
|
||||
// 52-week momentum flags
|
||||
m.week52Change != null &&
|
||||
m.week52Change >= 50 &&
|
||||
`Strong uptrend: +${m.week52Change.toFixed(0)}% in 52 weeks`,
|
||||
m.week52Change != null &&
|
||||
m.week52Change <= -30 &&
|
||||
`Significant drawdown: ${m.week52Change.toFixed(0)}% in 52 weeks`,
|
||||
// Distance from 52-week high
|
||||
m.week52FromHigh != null &&
|
||||
m.week52FromHigh <= -20 &&
|
||||
`${Math.abs(m.week52FromHigh).toFixed(0)}% off 52-week high`,
|
||||
// Analyst/DCF divergence signal
|
||||
m.analystUpside != null &&
|
||||
m.analystUpside >= 25 &&
|
||||
`Analyst consensus: ${m.analystUpside.toFixed(0)}% upside to target`,
|
||||
m.analystUpside != null &&
|
||||
m.analystUpside <= -15 &&
|
||||
`Analyst consensus: target ${Math.abs(m.analystUpside).toFixed(0)}% below current price`,
|
||||
m.dcfMarginOfSafety != null &&
|
||||
m.dcfMarginOfSafety >= 30 &&
|
||||
`DCF: ${m.dcfMarginOfSafety.toFixed(0)}% margin of safety`,
|
||||
m.dcfMarginOfSafety != null &&
|
||||
m.dcfMarginOfSafety <= -30 &&
|
||||
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
label: StockScorer.label(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
};
|
||||
}
|
||||
|
||||
private static label(score: number): string {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
}
|
||||
|
||||
private static sanitize(m: StockMetrics): SanitizedMetrics {
|
||||
const w52 =
|
||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
debtToEquity: StockScorer.n(m.debtToEquity),
|
||||
quickRatio: StockScorer.n(m.quickRatio),
|
||||
peRatio: StockScorer.n(m.peRatio),
|
||||
pegRatio: StockScorer.n(m.pegRatio),
|
||||
priceToBook: StockScorer.n(m.priceToBook),
|
||||
netProfitMargin: StockScorer.n(m.netProfitMargin),
|
||||
operatingMargin: StockScorer.n(m.operatingMargin),
|
||||
returnOnEquity: StockScorer.n(m.returnOnEquity),
|
||||
revenueGrowth: StockScorer.n(m.revenueGrowth),
|
||||
fcfYield: StockScorer.n(m.fcfYield),
|
||||
dividendYield: StockScorer.n(m.dividendYield),
|
||||
pFFO: StockScorer.n(m.pFFO),
|
||||
beta: StockScorer.n(m.beta),
|
||||
week52Position: w52,
|
||||
week52Change: StockScorer.n(m.week52Change),
|
||||
week52FromHigh: StockScorer.n(m.week52FromHigh),
|
||||
analystRating: StockScorer.n(m.analystRating),
|
||||
analystUpside: StockScorer.n(m.analystUpside),
|
||||
dcfMarginOfSafety: StockScorer.n(m.dcfMarginOfSafety),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine } from './ScreenerEngine';
|
||||
import { CatalystCache } from '../../domains/shared';
|
||||
import type { LiveAssetResult } from '../../domains/shared';
|
||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class ScreenerController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly catalystCache: CatalystCache,
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
'/api/screen',
|
||||
{ schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
this.screen.bind(this),
|
||||
);
|
||||
app.get(
|
||||
'/api/screen/catalysts',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
this.catalysts.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private static serializeAssets(arr: LiveAssetResult[]) {
|
||||
return arr.map((r) => ({
|
||||
...r,
|
||||
asset: {
|
||||
ticker: r.asset.ticker,
|
||||
type: r.asset.type,
|
||||
currentPrice: r.asset.currentPrice,
|
||||
metrics: r.asset.metrics,
|
||||
displayMetrics: r.asset.getDisplayMetrics(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private async screen(req: FastifyRequest) {
|
||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||
const results = await this.engine.screenTickers(tickers);
|
||||
return {
|
||||
...results,
|
||||
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
||||
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
|
||||
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
|
||||
};
|
||||
}
|
||||
|
||||
private async catalysts() {
|
||||
const { tickers, stories } = await this.catalystCache.get();
|
||||
return { tickers, stories };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { MappedData } from '../../../domains/shared';
|
||||
|
||||
// Internal: Yahoo Finance API response shape
|
||||
type YahooSummary = Record<string, Record<string, unknown>>;
|
||||
|
||||
export class DataMapper {
|
||||
// ── Public entry point ────────────────────────────────────────────────────
|
||||
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
|
||||
const quoteType = summary.price?.quoteType as string | undefined;
|
||||
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
|
||||
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
|
||||
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
|
||||
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
? { type: 'BOND', ticker, ...DataMapper.mapBondData(summary) }
|
||||
: { type: 'ETF', ticker, ...DataMapper.mapEtfData(summary) };
|
||||
}
|
||||
|
||||
return { type: 'STOCK', ticker, ...DataMapper.mapStockData(summary) };
|
||||
}
|
||||
|
||||
// ── Stock ─────────────────────────────────────────────────────────────────
|
||||
private static mapStockData(summary: YahooSummary) {
|
||||
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
|
||||
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
|
||||
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
|
||||
const pr = (summary.price ?? {}) as Record<string, number | null>;
|
||||
|
||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||
const sharesOutstanding = ks.sharesOutstanding ?? 0;
|
||||
const operatingCashflow = fd.operatingCashflow ?? 0;
|
||||
const freeCashflow = fd.freeCashflow ?? 0;
|
||||
|
||||
// P/FFO proxy — used for REIT scoring
|
||||
const pFFO =
|
||||
operatingCashflow > 0 && sharesOutstanding > 0
|
||||
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
|
||||
: null;
|
||||
|
||||
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
|
||||
const fcfYield =
|
||||
freeCashflow !== 0 && sharesOutstanding > 0 && (currentPrice as number) > 0
|
||||
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) *
|
||||
100
|
||||
: null;
|
||||
|
||||
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
|
||||
const yahoosPEG = ks.pegRatio ?? null;
|
||||
const trailingPE = sd.trailingPE ?? null;
|
||||
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
|
||||
const computedPEG =
|
||||
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
|
||||
? +((trailingPE as number) / earningsGrowth).toFixed(2)
|
||||
: null;
|
||||
const pegRatio = yahoosPEG ?? computedPEG;
|
||||
|
||||
// Quick ratio — fall back to currentRatio when missing
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
// ── 52-week movement ──────────────────────────────────────────────────
|
||||
const week52High = sd.fiftyTwoWeekHigh ?? null;
|
||||
const week52Low = sd.fiftyTwoWeekLow ?? null;
|
||||
const week52Change =
|
||||
ks['52WeekChange'] != null ? +((ks['52WeekChange'] as number) * 100).toFixed(1) : null;
|
||||
const week52FromHigh =
|
||||
week52High != null && week52High > 0 && (currentPrice as number) > 0
|
||||
? +(((currentPrice - week52High) / week52High) * 100).toFixed(1)
|
||||
: null;
|
||||
const week52FromLow =
|
||||
week52Low != null && week52Low > 0 && (currentPrice as number) > 0
|
||||
? +(((currentPrice - week52Low) / week52Low) * 100).toFixed(1)
|
||||
: null;
|
||||
|
||||
// ── Analyst consensus ─────────────────────────────────────────────────
|
||||
const analystRating = fd.recommendationMean ?? null;
|
||||
const analystTargetPrice = fd.targetMeanPrice ?? null;
|
||||
const numberOfAnalysts =
|
||||
fd.numberOfAnalystOpinions != null ? Math.round(fd.numberOfAnalystOpinions as number) : null;
|
||||
const analystUpside =
|
||||
analystTargetPrice != null && (currentPrice as number) > 0
|
||||
? +(((analystTargetPrice - currentPrice) / currentPrice) * 100).toFixed(1)
|
||||
: null;
|
||||
|
||||
// ── Gross margin ──────────────────────────────────────────────────────
|
||||
const grossMargin =
|
||||
fd.grossMargins != null ? +((fd.grossMargins as number) * 100).toFixed(1) : null;
|
||||
|
||||
// ── DCF intrinsic value ───────────────────────────────────────────────
|
||||
const revenueGrowthDecimal = fd.revenueGrowth != null ? (fd.revenueGrowth as number) : null;
|
||||
const earningsGrowthDecimal = fd.earningsGrowth != null ? (fd.earningsGrowth as number) : null;
|
||||
const dcfGrowthRate =
|
||||
earningsGrowthDecimal ?? (revenueGrowthDecimal != null ? revenueGrowthDecimal * 0.7 : null);
|
||||
|
||||
const dcf = DataMapper.computeDCF(
|
||||
freeCashflow as number,
|
||||
sharesOutstanding as number,
|
||||
currentPrice as number,
|
||||
dcfGrowthRate,
|
||||
);
|
||||
|
||||
return {
|
||||
peRatio: trailingPE ?? ks.forwardPE,
|
||||
trailingPE,
|
||||
pegRatio,
|
||||
priceToBook: ks.priceToBook ?? null,
|
||||
evToEbitda: ks.enterpriseToEbitda ?? null,
|
||||
grossMargin,
|
||||
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
|
||||
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
|
||||
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
|
||||
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
|
||||
earningsGrowth,
|
||||
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
|
||||
quickRatio,
|
||||
fcfYield,
|
||||
pFFO,
|
||||
dividendYield:
|
||||
sd.trailingAnnualDividendYield != null
|
||||
? (sd.trailingAnnualDividendYield as number) * 100
|
||||
: null,
|
||||
beta: sd.beta ?? null,
|
||||
week52High,
|
||||
week52Low,
|
||||
week52Change,
|
||||
week52FromHigh,
|
||||
week52FromLow,
|
||||
marketCap: pr.marketCap ?? null,
|
||||
analystRating,
|
||||
analystTargetPrice,
|
||||
analystUpside,
|
||||
numberOfAnalysts,
|
||||
dcfIntrinsicValue: dcf?.intrinsicValue ?? null,
|
||||
dcfMarginOfSafety: dcf?.marginOfSafety ?? null,
|
||||
currentPrice,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────────
|
||||
private static mapEtfData(summary: YahooSummary) {
|
||||
return {
|
||||
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
|
||||
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
|
||||
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
|
||||
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
|
||||
volume:
|
||||
(summary.summaryDetail?.averageVolume as number) ??
|
||||
(summary.price?.averageVolume as number) ??
|
||||
0,
|
||||
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bond ──────────────────────────────────────────────────────────────────
|
||||
private static mapBondData(summary: YahooSummary) {
|
||||
return {
|
||||
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
|
||||
duration: DataMapper.inferDuration(summary.assetProfile?.category as string),
|
||||
creditRating: DataMapper.inferCreditRating(summary.assetProfile?.category as string),
|
||||
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static inferCreditRating(category: string | undefined): string {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
|
||||
if (cat.includes('muni')) return 'AA';
|
||||
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
|
||||
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
|
||||
return 'BBB';
|
||||
}
|
||||
|
||||
private static inferDuration(category: string | undefined): number {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
|
||||
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
|
||||
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
|
||||
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
|
||||
return 6;
|
||||
}
|
||||
|
||||
// ── DCF ───────────────────────────────────────────────────────────────────
|
||||
// Two-stage model:
|
||||
// Stage 1 — FCF/share grows at `growthRate` for 5 years, discounted at 9.5% WACC.
|
||||
// Stage 2 — Terminal value via Gordon Growth Model at 2.5% perpetuity rate.
|
||||
// Only fires when TTM FCF per share is positive.
|
||||
private static computeDCF(
|
||||
freeCashflow: number,
|
||||
sharesOutstanding: number,
|
||||
currentPrice: number,
|
||||
growthRate: number | null,
|
||||
riskFreeRate = 0.04,
|
||||
): { intrinsicValue: number; marginOfSafety: number } | null {
|
||||
if (!freeCashflow || freeCashflow <= 0) return null;
|
||||
if (!sharesOutstanding || sharesOutstanding <= 0) return null;
|
||||
if (!currentPrice || currentPrice <= 0) return null;
|
||||
|
||||
const fcfPerShare = freeCashflow / sharesOutstanding;
|
||||
if (fcfPerShare <= 0) return null;
|
||||
|
||||
const discountRate = riskFreeRate + 0.055; // WACC proxy
|
||||
const terminalGrowth = 0.025; // long-run GDP growth
|
||||
const years = 5;
|
||||
const g = Math.min(Math.max(growthRate ?? 0.08, -0.05), 0.3);
|
||||
|
||||
let pv = 0;
|
||||
let fcfT = fcfPerShare;
|
||||
for (let t = 1; t <= years; t++) {
|
||||
fcfT *= 1 + g;
|
||||
pv += fcfT / Math.pow(1 + discountRate, t);
|
||||
}
|
||||
|
||||
const terminalValue = (fcfT * (1 + terminalGrowth)) / (discountRate - terminalGrowth);
|
||||
pv += terminalValue / Math.pow(1 + discountRate, years);
|
||||
|
||||
const intrinsicValue = +pv.toFixed(2);
|
||||
const marginOfSafety = +(((intrinsicValue - currentPrice) / intrinsicValue) * 100).toFixed(1);
|
||||
|
||||
return { intrinsicValue, marginOfSafety };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ASSET_TYPE, REGIME, SECTOR } from '../../shared';
|
||||
import type { MarketContext, AssetType, InflatedOverrides } from '../../shared';
|
||||
|
||||
export class MarketRegime {
|
||||
private marketPE: number;
|
||||
private techPE: number;
|
||||
private reitYield: number;
|
||||
private igSpread: number;
|
||||
private rateRegime: string;
|
||||
private volatilityRegime: string;
|
||||
|
||||
constructor(marketContext: Partial<MarketContext>) {
|
||||
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
|
||||
this.marketPE = b.marketPE ?? 22;
|
||||
this.techPE = b.techPE ?? 30;
|
||||
this.reitYield = b.reitYield ?? 3.5;
|
||||
this.igSpread = b.igSpread ?? 1.0;
|
||||
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
|
||||
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
|
||||
}
|
||||
|
||||
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
||||
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this.etf();
|
||||
if (type === ASSET_TYPE.BOND) return this.bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
private stock(sector?: string): InflatedOverrides {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: {
|
||||
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
|
||||
maxPFFO: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sector === SECTOR.TECHNOLOGY) {
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.techPE * 1.3),
|
||||
maxPegGate: +(this.techPE / 15).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.marketPE * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
|
||||
private etf(): InflatedOverrides {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
private bond(): InflatedOverrides {
|
||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||
return {
|
||||
gates: {},
|
||||
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ScoringRules } from '../../../domains/shared/scoring/ScoringConfig';
|
||||
import { MarketRegime } from '../../../domains/shared/scoring/MarketRegime';
|
||||
import { SCORE_MODE } from '../../../domains/shared';
|
||||
import type { AssetType, MarketContext, RuleSet } from '../../../domains/shared';
|
||||
|
||||
export class RuleMerger {
|
||||
static getRulesForAsset(
|
||||
type: AssetType,
|
||||
metrics: { sector?: string },
|
||||
marketContext: Partial<MarketContext> = {},
|
||||
mode: string = SCORE_MODE.FUNDAMENTAL,
|
||||
): RuleSet {
|
||||
const base = ScoringRules[type as keyof typeof ScoringRules];
|
||||
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
|
||||
|
||||
// Deep clone to avoid mutating the source config
|
||||
const rules: RuleSet & { SECTOR_OVERRIDE?: unknown } = JSON.parse(JSON.stringify(base));
|
||||
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const stockBase = ScoringRules.STOCK;
|
||||
const override =
|
||||
stockBase.SECTOR_OVERRIDE?.[
|
||||
metrics.sector.toUpperCase() as keyof typeof stockBase.SECTOR_OVERRIDE
|
||||
];
|
||||
if (override) {
|
||||
rules.gates = { ...rules.gates, ...override.gates };
|
||||
rules.weights = { ...rules.weights, ...override.weights };
|
||||
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
|
||||
}
|
||||
}
|
||||
delete rules.SECTOR_OVERRIDE;
|
||||
|
||||
if (mode === SCORE_MODE.INFLATED) {
|
||||
const { gates, thresholds } = new MarketRegime(
|
||||
marketContext as MarketContext,
|
||||
).getInflatedOverrides(type, metrics.sector);
|
||||
rules.gates = { ...rules.gates, ...gates };
|
||||
rules.thresholds = { ...rules.thresholds, ...thresholds };
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user