248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
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;
|
|
// Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate
|
|
// Core Bond") — assetProfile.category is rarely populated for ETFs. A
|
|
// dividend-yield heuristic is deliberately NOT used: high-yield equity ETFs
|
|
// (SCHD, VYM) are not bonds.
|
|
const category = (
|
|
(summary.fundProfile?.categoryName as string) ||
|
|
(summary.assetProfile?.category as string) ||
|
|
''
|
|
).toLowerCase();
|
|
|
|
const isBond =
|
|
category.includes('bond') ||
|
|
category.includes('fixed income') ||
|
|
category.includes('treasury');
|
|
|
|
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;
|
|
|
|
// Today's % change — powers the sector drill-down "Today" sort
|
|
const prevClose = pr.regularMarketPreviousClose ?? null;
|
|
const dayChangePct =
|
|
prevClose != null && prevClose > 0 && (currentPrice as number) > 0
|
|
? +((((currentPrice as number) - prevClose) / prevClose) * 100).toFixed(2)
|
|
: null;
|
|
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,
|
|
dayChangePct,
|
|
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 ───────────────────────────────────────────────────────────────────
|
|
// Missing fields are preserved as null (not coerced to 0) so EtfScorer can
|
|
// skip the corresponding gate instead of auto-failing on absent Yahoo data.
|
|
private static mapEtfData(summary: YahooSummary) {
|
|
const num = (v: unknown): number | null =>
|
|
typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
|
|
const expenseRatio = num(summary.summaryDetail?.expenseRatio);
|
|
const dividendYield = num(summary.summaryDetail?.trailingAnnualDividendYield);
|
|
const fiveYearReturn = num(summary.defaultKeyStatistics?.fiveYearAverageReturn);
|
|
|
|
return {
|
|
expenseRatio: expenseRatio != null ? expenseRatio * 100 : null,
|
|
totalAssets: num(summary.summaryDetail?.totalAssets),
|
|
yield: dividendYield != null ? dividendYield * 100 : null,
|
|
fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null,
|
|
volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume),
|
|
currentPrice: num(summary.price?.regularMarketPrice) ?? 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 };
|
|
}
|
|
}
|