yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import type { ReactElement } from 'react' |
| 2 | import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { display as d } from './format' |
| 4 | import { lineValue, type Frequency, type LineKind, type Statement } from './server/financials' |
| 5 | import { Bar, Empty, Loading, Section, SymbolField, usePanelStateValue, useStateWriter } from './shared' |
| 6 | import { list, record, text } from './values' |
| 7 | import { financialsOutput, readFinancialsState, type FinancialsState } from './views' |
| 8 | import './styles.css' |
| 9 | |
| 10 | // One company's statement: line items down, periods across, latest first, the lines that matter in |
| 11 | // bold. The toggles write `statement` and `frequency`; a balance sheet has no trailing twelve months, |
| 12 | // so choosing one while TTM is on moves to annual. |
| 13 | |
| 14 | const STATEMENTS: [Statement, string][] = [ |
| 15 | ['income', 'Income'], |
| 16 | ['balance', 'Balance sheet'], |
| 17 | ['cashflow', 'Cash flow'], |
| 18 | ] |
| 19 | |
| 20 | const FREQUENCIES: [Frequency, string][] = [ |
| 21 | ['annual', 'Annual'], |
| 22 | ['quarterly', 'Quarterly'], |
| 23 | ['trailing', 'TTM'], |
| 24 | ] |
| 25 | |
| 26 | export function FinancialsView({ panel }: { panel: PanelRef }): ReactElement { |
| 27 | const state = usePanelStateValue(panel) |
| 28 | const write = useStateWriter(panel) |
| 29 | if (state === undefined) return <Loading /> |
| 30 | const read = readFinancialsState(state) |
| 31 | return ( |
| 32 | <div className="yf-root"> |
| 33 | <Bar> |
| 34 | <SymbolField symbol={read.symbol} onSymbol={(symbol) => write([['symbol', symbol]])} /> |
| 35 | <Toggle |
| 36 | label="Statement" |
| 37 | options={STATEMENTS} |
| 38 | value={read.statement} |
| 39 | onChange={(statement) => write(statement === 'balance' && read.frequency === 'trailing' ? [['frequency', 'annual'], ['statement', statement]] : [['statement', statement]])} |
| 40 | /> |
| 41 | <Toggle |
| 42 | label="Period" |
| 43 | options={FREQUENCIES} |
| 44 | value={read.frequency} |
| 45 | disabled={read.statement === 'balance' ? ['trailing'] : []} |
| 46 | onChange={(frequency) => write([['frequency', frequency]])} |
| 47 | /> |
| 48 | </Bar> |
| 49 | <div className="yf-body"> |
| 50 | {read.symbol ? ( |
| 51 | <Statement panel={panel} state={read} /> |
| 52 | ) : ( |
| 53 | <Empty panel={panel} output={financialsOutput(read, { data: [], meta: {}, error: null, loading: false })}> |
| 54 | Enter a symbol: AAPL, MSFT, 7203.T. |
| 55 | </Empty> |
| 56 | )} |
| 57 | </div> |
| 58 | </div> |
| 59 | ) |
| 60 | } |
| 61 | |
| 62 | function Toggle<T extends string>({ |
| 63 | label, |
| 64 | options, |
| 65 | value, |
| 66 | disabled = [], |
| 67 | onChange, |
| 68 | }: { |
| 69 | label: string |
| 70 | options: [T, string][] |
| 71 | value: T |
| 72 | disabled?: T[] |
| 73 | onChange: (value: T) => void |
| 74 | }): ReactElement { |
| 75 | return ( |
| 76 | <div className="yf-toggle" role="group" aria-label={label}> |
| 77 | {options.map(([key, name]) => ( |
| 78 | <button |
| 79 | key={key} |
| 80 | type="button" |
| 81 | className={key === value ? 'yf-tab yf-tab-on' : 'yf-tab'} |
| 82 | aria-pressed={key === value} |
| 83 | disabled={disabled.includes(key)} |
| 84 | title={disabled.includes(key) ? 'A balance sheet is a point in time: there is no trailing twelve months.' : undefined} |
| 85 | onClick={() => onChange(key)} |
| 86 | > |
| 87 | {name} |
| 88 | </button> |
| 89 | ))} |
| 90 | </div> |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | function Statement({ panel, state }: { panel: PanelRef; state: FinancialsState }): ReactElement { |
| 95 | const run = useData('yfinance/financials', { symbol: state.symbol, statement: state.statement, frequency: state.frequency }) |
| 96 | usePublish(panel, financialsOutput(state, run)) |
| 97 | const periods = list(run.meta['periods']).map(text).filter((p): p is string => p !== null) |
| 98 | const rows = run.data ?? [] |
| 99 | const currency = text(run.meta['currency']) |
| 100 | const name = STATEMENTS.find(([key]) => key === state.statement)![1] |
| 101 | const every = FREQUENCIES.find(([key]) => key === state.frequency)![1] |
| 102 | return ( |
| 103 | <Section |
| 104 | title={`${state.symbol} ${name.toLowerCase()}`} |
| 105 | run={run} |
| 106 | aside={[every, currency && `in ${currency}`].filter(Boolean).join(' · ')} |
| 107 | empty={rows.length === 0} |
| 108 | > |
| 109 | <table className="yf-table yf-statement"> |
| 110 | <thead> |
| 111 | <tr> |
| 112 | <th>{state.frequency === 'trailing' ? 'Twelve months to' : 'Period ended'}</th> |
| 113 | {periods.map((p) => ( |
| 114 | <th key={p} className="yf-num"> |
| 115 | {p} |
| 116 | </th> |
| 117 | ))} |
| 118 | </tr> |
| 119 | </thead> |
| 120 | <tbody> |
| 121 | {rows.map((row) => { |
| 122 | const values = record(row['values']) ?? {} |
| 123 | const kind = (text(row['kind']) ?? 'money') as LineKind |
| 124 | return ( |
| 125 | <tr key={text(row['key']) ?? ''} className={row['highlight'] === true ? 'yf-strong' : undefined}> |
| 126 | <td>{text(row['label'])}</td> |
| 127 | {periods.map((p) => { |
| 128 | const value = typeof values[p] === 'number' ? (values[p] as number) : null |
| 129 | return ( |
| 130 | <td key={p} className={`yf-num${value !== null && value < 0 ? ' yf-negative' : ''}`}> |
| 131 | {lineValue(kind, value, d)} |
| 132 | </td> |
| 133 | ) |
| 134 | })} |
| 135 | </tr> |
| 136 | ) |
| 137 | })} |
| 138 | </tbody> |
| 139 | </table> |
| 140 | </Section> |
| 141 | ) |
| 142 | } |