yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { plain as f } from '../format.ts' |
| 2 | import { list, num, record, records, text } from '../values.ts' |
| 3 | import { toolResult, type ToolResult } from './result.ts' |
| 4 | import { YahooError } from './yahoo.ts' |
| 5 | |
| 6 | // Financial statements from Yahoo's fundamentals time series, the source its own Financials page |
| 7 | // reads (quoteSummary's statement modules have been mostly empty since 2023). Each line is a series |
| 8 | // named by the frequency and the item, annualTotalRevenue; Yahoo gives about four years and five |
| 9 | // quarters. Values are as reported, in the statement's currency; a tax rate is a fraction. |
| 10 | |
| 11 | export const STATEMENTS = ['income', 'balance', 'cashflow'] as const |
| 12 | export const FREQUENCIES = ['annual', 'quarterly', 'trailing'] as const |
| 13 | export type Statement = (typeof STATEMENTS)[number] |
| 14 | export type Frequency = (typeof FREQUENCIES)[number] |
| 15 | |
| 16 | export type LineKind = 'money' | 'perShare' | 'shares' | 'rate' |
| 17 | |
| 18 | export type Line = { key: string; label: string; kind: LineKind; highlight: boolean } |
| 19 | |
| 20 | export type LineItem = Line & { |
| 21 | /** By period, as in `periods`. */ |
| 22 | values: Record<string, number | null> |
| 23 | } |
| 24 | |
| 25 | export type Financials = { |
| 26 | symbol: string |
| 27 | statement: Statement |
| 28 | frequency: Frequency |
| 29 | currency: string | null |
| 30 | /** When each column's period ended, latest first. */ |
| 31 | periods: string[] |
| 32 | rows: LineItem[] |
| 33 | } |
| 34 | |
| 35 | const line = (key: string, label: string, kind: LineKind = 'money', highlight = false): Line => ({ key, label, kind, highlight }) |
| 36 | |
| 37 | export const LINES: Record<Statement, Line[]> = { |
| 38 | income: [ |
| 39 | line('TotalRevenue', 'Total revenue', 'money', true), |
| 40 | line('OperatingRevenue', 'Operating revenue'), |
| 41 | line('CostOfRevenue', 'Cost of revenue'), |
| 42 | line('GrossProfit', 'Gross profit', 'money', true), |
| 43 | line('OperatingExpense', 'Operating expense'), |
| 44 | line('SellingGeneralAndAdministration', 'Selling, general and administrative'), |
| 45 | line('ResearchAndDevelopment', 'Research and development'), |
| 46 | line('OperatingIncome', 'Operating income', 'money', true), |
| 47 | line('NetNonOperatingInterestIncomeExpense', 'Net non-operating interest'), |
| 48 | line('InterestIncome', 'Interest income'), |
| 49 | line('InterestExpense', 'Interest expense'), |
| 50 | line('OtherIncomeExpense', 'Other income and expense'), |
| 51 | line('PretaxIncome', 'Pretax income'), |
| 52 | line('TaxProvision', 'Tax provision'), |
| 53 | line('NetIncomeContinuousOperations', 'Net income from continuing operations'), |
| 54 | line('MinorityInterests', 'Minority interests'), |
| 55 | line('NetIncome', 'Net income', 'money', true), |
| 56 | line('NetIncomeCommonStockholders', 'Net income to common stockholders'), |
| 57 | line('BasicEPS', 'Basic EPS', 'perShare'), |
| 58 | line('DilutedEPS', 'Diluted EPS', 'perShare', true), |
| 59 | line('BasicAverageShares', 'Basic average shares', 'shares'), |
| 60 | line('DilutedAverageShares', 'Diluted average shares', 'shares'), |
| 61 | line('TotalExpenses', 'Total expenses'), |
| 62 | line('EBIT', 'EBIT'), |
| 63 | line('EBITDA', 'EBITDA', 'money', true), |
| 64 | line('NormalizedEBITDA', 'Normalized EBITDA'), |
| 65 | line('NormalizedIncome', 'Normalized income'), |
| 66 | line('ReconciledDepreciation', 'Depreciation and amortization'), |
| 67 | line('TotalUnusualItems', 'Unusual items'), |
| 68 | line('TaxRateForCalcs', 'Tax rate', 'rate'), |
| 69 | ], |
| 70 | balance: [ |
| 71 | line('TotalAssets', 'Total assets', 'money', true), |
| 72 | line('CurrentAssets', 'Current assets'), |
| 73 | line('CashCashEquivalentsAndShortTermInvestments', 'Cash and short-term investments'), |
| 74 | line('CashAndCashEquivalents', 'Cash and cash equivalents', 'money', true), |
| 75 | line('OtherShortTermInvestments', 'Short-term investments'), |
| 76 | line('Receivables', 'Receivables'), |
| 77 | line('AccountsReceivable', 'Accounts receivable'), |
| 78 | line('Inventory', 'Inventory'), |
| 79 | line('OtherCurrentAssets', 'Other current assets'), |
| 80 | line('TotalNonCurrentAssets', 'Non-current assets'), |
| 81 | line('NetPPE', 'Property, plant and equipment, net'), |
| 82 | line('GoodwillAndOtherIntangibleAssets', 'Goodwill and intangibles'), |
| 83 | line('Goodwill', 'Goodwill'), |
| 84 | line('InvestmentsAndAdvances', 'Investments and advances'), |
| 85 | line('OtherNonCurrentAssets', 'Other non-current assets'), |
| 86 | line('TotalLiabilitiesNetMinorityInterest', 'Total liabilities', 'money', true), |
| 87 | line('CurrentLiabilities', 'Current liabilities'), |
| 88 | line('AccountsPayable', 'Accounts payable'), |
| 89 | line('CurrentDebt', 'Current debt'), |
| 90 | line('CurrentDeferredRevenue', 'Current deferred revenue'), |
| 91 | line('OtherCurrentLiabilities', 'Other current liabilities'), |
| 92 | line('TotalNonCurrentLiabilitiesNetMinorityInterest', 'Non-current liabilities'), |
| 93 | line('LongTermDebt', 'Long-term debt'), |
| 94 | line('OtherNonCurrentLiabilities', 'Other non-current liabilities'), |
| 95 | line('StockholdersEquity', "Stockholders' equity", 'money', true), |
| 96 | line('CommonStockEquity', 'Common stock equity'), |
| 97 | line('RetainedEarnings', 'Retained earnings'), |
| 98 | line('TotalEquityGrossMinorityInterest', 'Total equity with minority interest'), |
| 99 | line('TotalDebt', 'Total debt', 'money', true), |
| 100 | line('NetDebt', 'Net debt'), |
| 101 | line('WorkingCapital', 'Working capital'), |
| 102 | line('InvestedCapital', 'Invested capital'), |
| 103 | line('TangibleBookValue', 'Tangible book value'), |
| 104 | line('ShareIssued', 'Shares issued', 'shares'), |
| 105 | line('OrdinarySharesNumber', 'Ordinary shares', 'shares'), |
| 106 | line('TreasurySharesNumber', 'Treasury shares', 'shares'), |
| 107 | ], |
| 108 | cashflow: [ |
| 109 | line('OperatingCashFlow', 'Operating cash flow', 'money', true), |
| 110 | line('NetIncomeFromContinuingOperations', 'Net income from continuing operations'), |
| 111 | line('DepreciationAndAmortization', 'Depreciation and amortization'), |
| 112 | line('StockBasedCompensation', 'Stock-based compensation'), |
| 113 | line('DeferredIncomeTax', 'Deferred income tax'), |
| 114 | line('ChangeInWorkingCapital', 'Change in working capital'), |
| 115 | line('InvestingCashFlow', 'Investing cash flow', 'money', true), |
| 116 | line('CapitalExpenditure', 'Capital expenditure'), |
| 117 | line('NetBusinessPurchaseAndSale', 'Acquisitions and divestitures'), |
| 118 | line('NetInvestmentPurchaseAndSale', 'Investments bought and sold'), |
| 119 | line('FinancingCashFlow', 'Financing cash flow', 'money', true), |
| 120 | line('NetIssuancePaymentsOfDebt', 'Debt issued and repaid'), |
| 121 | line('RepurchaseOfCapitalStock', 'Share repurchases'), |
| 122 | line('IssuanceOfCapitalStock', 'Shares issued'), |
| 123 | line('CashDividendsPaid', 'Dividends paid'), |
| 124 | line('ChangesInCash', 'Change in cash'), |
| 125 | line('BeginningCashPosition', 'Cash at the start'), |
| 126 | line('EndCashPosition', 'Cash at the end'), |
| 127 | line('FreeCashFlow', 'Free cash flow', 'money', true), |
| 128 | line('IncomeTaxPaidSupplementalData', 'Income taxes paid'), |
| 129 | line('InterestPaidSupplementalData', 'Interest paid'), |
| 130 | ], |
| 131 | } |
| 132 | |
| 133 | /** The line whose periods are the statement's columns. */ |
| 134 | const LEAD: Record<Statement, string> = { income: 'TotalRevenue', balance: 'TotalAssets', cashflow: 'OperatingCashFlow' } |
| 135 | |
| 136 | const NAMES: Record<Statement, string> = { income: 'income statement', balance: 'balance sheet', cashflow: 'cash flow statement' } |
| 137 | const EVERY: Record<Frequency, string> = { annual: 'annual', quarterly: 'quarterly', trailing: 'trailing twelve months' } |
| 138 | |
| 139 | /** Columns kept at most. */ |
| 140 | const PERIODS_MAX = 8 |
| 141 | /** Yahoo's own start for the series (1985). */ |
| 142 | const START = 493590046 |
| 143 | const DAY = 86_400 |
| 144 | |
| 145 | export function timeseriesQuery(symbol: string, statement: Statement, frequency: Frequency, now: number): Record<string, string | number> { |
| 146 | return { |
| 147 | symbol, |
| 148 | type: LINES[statement].map((l) => `${frequency}${l.key}`).join(','), |
| 149 | period1: START, |
| 150 | // The next midnight, UTC: the same address all day, so the answer can be kept. |
| 151 | period2: Math.ceil(now / 1000 / DAY) * DAY, |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | export function readFinancials(symbol: string, statement: Statement, frequency: Frequency, json: unknown): Financials { |
| 156 | if (statement === 'balance' && frequency === 'trailing') { |
| 157 | throw new YahooError('A balance sheet has no trailing twelve months: it is a point in time. Ask for annual or quarterly.') |
| 158 | } |
| 159 | const wanted = frequency === 'trailing' ? 'TTM' : null |
| 160 | const series = new Map<string, Map<string, number>>() |
| 161 | let currency: string | null = null |
| 162 | for (const result of records(record(record(json)?.['timeseries'])?.['result'])) { |
| 163 | const type = text(list(record(result['meta'])?.['type'])[0]) |
| 164 | if (!type?.startsWith(frequency)) continue |
| 165 | const values = new Map<string, number>() |
| 166 | for (const entry of records(result[type])) { |
| 167 | const date = text(entry['asOfDate']) |
| 168 | const value = num(record(entry['reportedValue'])?.['raw']) |
| 169 | if (!date || value === null) continue |
| 170 | if (wanted && text(entry['periodType']) !== wanted) continue |
| 171 | values.set(date, value) |
| 172 | currency ??= text(entry['currencyCode']) |
| 173 | } |
| 174 | if (values.size) series.set(type.slice(frequency.length), values) |
| 175 | } |
| 176 | if (!series.size) throw new YahooError(`No ${EVERY[frequency]} ${NAMES[statement]} for ${symbol}.`) |
| 177 | const lead = series.get(LEAD[statement]) |
| 178 | const dates = lead ? [...lead.keys()] : [...series.values()].flatMap((values) => [...values.keys()]) |
| 179 | const periods = [...new Set(dates)].sort((a, b) => b.localeCompare(a)).slice(0, PERIODS_MAX) |
| 180 | const rows: LineItem[] = [] |
| 181 | for (const l of LINES[statement]) { |
| 182 | const values = series.get(l.key) |
| 183 | if (!values || !periods.some((p) => values.has(p))) continue |
| 184 | rows.push({ ...l, values: Object.fromEntries(periods.map((p) => [p, values.get(p) ?? null])) }) |
| 185 | } |
| 186 | return { symbol, statement, frequency, currency, periods, rows } |
| 187 | } |
| 188 | |
| 189 | export function lineValue(kind: LineKind, value: number | null, format: { big(v: number | null): string; fixed(v: number | null): string; fraction(v: number | null): string }): string { |
| 190 | if (kind === 'perShare') return format.fixed(value) |
| 191 | if (kind === 'rate') return format.fraction(value) |
| 192 | return format.big(value) |
| 193 | } |
| 194 | |
| 195 | export function financialsResult(financials: Financials): ToolResult { |
| 196 | const { symbol, statement, frequency, currency, periods, rows } = financials |
| 197 | const head = `${symbol} ${NAMES[statement]}, ${EVERY[frequency]}${currency ? `, in ${currency}` : ''}.` |
| 198 | const lines = rows.map((row) => `${row.label}: ${periods.map((p) => lineValue(row.kind, row.values[p] ?? null, f)).join(' | ')}`) |
| 199 | return toolResult([head, `Period ended: ${periods.join(' | ')}`, ...lines].join('\n'), financials) |
| 200 | } |