138 lines
5.5 KiB
TypeScript
138 lines
5.5 KiB
TypeScript
import type { AssetType } from '../types.js';
|
|
|
|
// Shape of the raw Yahoo Finance summary payload (loosely typed — fields vary by asset)
|
|
type YahooSummary = Record<string, Record<string, unknown>>;
|
|
|
|
interface MappedData {
|
|
type: AssetType;
|
|
ticker: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export const 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, ...mapBondData(summary) }
|
|
: { type: 'ETF', ticker, ...mapEtfData(summary) };
|
|
}
|
|
|
|
return { type: 'STOCK', ticker, ...mapStockData(summary) };
|
|
};
|
|
|
|
const 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 != null &&
|
|
operatingCashflow > 0 &&
|
|
sharesOutstanding != null &&
|
|
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 != null &&
|
|
sharesOutstanding > 0 &&
|
|
currentPrice != null &&
|
|
currentPrice > 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;
|
|
|
|
return {
|
|
peRatio: trailingPE ?? ks.forwardPE,
|
|
trailingPE,
|
|
pegRatio,
|
|
priceToBook: ks.priceToBook ?? null,
|
|
evToEbitda: ks.enterpriseToEbitda ?? null,
|
|
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: sd.fiftyTwoWeekHigh ?? null,
|
|
week52Low: sd.fiftyTwoWeekLow ?? null,
|
|
currentPrice,
|
|
assetProfile: summary.assetProfile || {},
|
|
};
|
|
};
|
|
|
|
const mapEtfData = (summary: YahooSummary) => ({
|
|
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,
|
|
});
|
|
|
|
const 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';
|
|
};
|
|
|
|
const 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;
|
|
};
|
|
|
|
const mapBondData = (summary: YahooSummary) => ({
|
|
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
|
|
duration: inferDuration(summary.assetProfile?.category as string),
|
|
creditRating: inferCreditRating(summary.assetProfile?.category as string),
|
|
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
|
});
|