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:
@@ -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