Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

10 KB
1import { plain as f } from '../format.ts'
2import { list, num, record, records, text } from '../values.ts'
3import { toolResult, type ToolResult } from './result.ts'
4import { 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
11export const STATEMENTS = ['income', 'balance', 'cashflow'] as const
12export const FREQUENCIES = ['annual', 'quarterly', 'trailing'] as const
13export type Statement = (typeof STATEMENTS)[number]
14export type Frequency = (typeof FREQUENCIES)[number]
15
16export type LineKind = 'money' | 'perShare' | 'shares' | 'rate'
17
18export type Line = { key: string; label: string; kind: LineKind; highlight: boolean }
19
20export type LineItem = Line & {
21 /** By period, as in `periods`. */
22 values: Record<string, number | null>
23}
24
25export 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
35const line = (key: string, label: string, kind: LineKind = 'money', highlight = false): Line => ({ key, label, kind, highlight })
36
37export 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. */
134const LEAD: Record<Statement, string> = { income: 'TotalRevenue', balance: 'TotalAssets', cashflow: 'OperatingCashFlow' }
135
136const NAMES: Record<Statement, string> = { income: 'income statement', balance: 'balance sheet', cashflow: 'cash flow statement' }
137const EVERY: Record<Frequency, string> = { annual: 'annual', quarterly: 'quarterly', trailing: 'trailing twelve months' }
138
139/** Columns kept at most. */
140const PERIODS_MAX = 8
141/** Yahoo's own start for the series (1985). */
142const START = 493590046
143const DAY = 86_400
144
145export 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
155export 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
189export 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
195export 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}