import type { MappedData } from '../../../domains/shared'; // Internal: Yahoo Finance API response shape type YahooSummary = Record>; 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; const ks = (summary.defaultKeyStatistics ?? {}) as Record; const sd = (summary.summaryDetail ?? {}) as Record; const pr = (summary.price ?? {}) as Record; 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 }; } }