yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { Fragment, type ReactElement } from 'react' |
| 2 | import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { display as d } from './format' |
| 4 | import { Bar, Empty, Loading, Section, Signed, SymbolField, useEvery, usePanelStateValue, useStateWriter } from './shared' |
| 5 | import { list, num, record, text, type Row } from './values' |
| 6 | import { optionsOutput, readOptionsState, REFRESH_MS, STRIKE_CHOICES, type OptionsState } from './views' |
| 7 | import './styles.css' |
| 8 | |
| 9 | // One symbol's option chain for one expiration: calls on the left, puts on the right, the strike |
| 10 | // between them, the in-the-money side shaded, and a rule where the price falls. The chain is asked |
| 11 | // again every minute. A new symbol clears the expiration, since its dates are the old symbol's. |
| 12 | |
| 13 | const COLUMNS = ['Last', 'Bid', 'Ask', 'Volume', 'Open int.', 'IV'] as const |
| 14 | |
| 15 | export function OptionsView({ panel }: { panel: PanelRef }): ReactElement { |
| 16 | const state = usePanelStateValue(panel) |
| 17 | const write = useStateWriter(panel) |
| 18 | if (state === undefined) return <Loading /> |
| 19 | const read = readOptionsState(state) |
| 20 | return ( |
| 21 | <div className="yf-root"> |
| 22 | <Bar> |
| 23 | <SymbolField |
| 24 | symbol={read.symbol} |
| 25 | onSymbol={(symbol) => symbol !== read.symbol && write([['expiration', null], ['symbol', symbol]])} |
| 26 | /> |
| 27 | <label className="yf-muted yf-label"> |
| 28 | Strikes each side{' '} |
| 29 | <select className="yf-select" value={read.strikes} onChange={(event) => write([['strikes', Number(event.target.value)]])}> |
| 30 | {[...new Set([...STRIKE_CHOICES, read.strikes])] |
| 31 | .sort((a, b) => a - b) |
| 32 | .map((n) => ( |
| 33 | <option key={n} value={n}> |
| 34 | {n} |
| 35 | </option> |
| 36 | ))} |
| 37 | </select> |
| 38 | </label> |
| 39 | </Bar> |
| 40 | <div className="yf-body"> |
| 41 | {read.symbol ? ( |
| 42 | <Chain panel={panel} state={read} onExpiration={(expiration) => write([['expiration', expiration]])} /> |
| 43 | ) : ( |
| 44 | <Empty panel={panel} output={optionsOutput(read, { data: [], meta: {}, error: null, loading: false })}> |
| 45 | Enter a symbol with listed options: AAPL, SPY, TSLA. |
| 46 | </Empty> |
| 47 | )} |
| 48 | </div> |
| 49 | </div> |
| 50 | ) |
| 51 | } |
| 52 | |
| 53 | function Chain({ panel, state, onExpiration }: { panel: PanelRef; state: OptionsState; onExpiration: (expiration: string) => void }): ReactElement { |
| 54 | const args: Record<string, unknown> = { symbol: state.symbol, strikes: state.strikes } |
| 55 | if (state.expiration) args['expiration'] = state.expiration |
| 56 | const run = useData('yfinance/options', args) |
| 57 | useEvery(run.refetch, REFRESH_MS) |
| 58 | usePublish(panel, optionsOutput(state, run)) |
| 59 | const meta = run.meta |
| 60 | const hint = num(meta['priceHint']) |
| 61 | const underlying = record(meta['underlying']) ?? {} |
| 62 | const price = num(underlying['price']) |
| 63 | const totals = record(meta['totals']) ?? {} |
| 64 | const atm = record(meta['atm']) |
| 65 | const expirations = list(meta['expirations']).map(text).filter((e): e is string => e !== null) |
| 66 | const expiration = text(meta['expiration']) |
| 67 | const rows = run.data ?? [] |
| 68 | // The price rule goes before the first strike above the price. |
| 69 | const above = price === null ? -1 : rows.findIndex((row) => (num(row['strike']) ?? 0) >= price) |
| 70 | return ( |
| 71 | <Section |
| 72 | title={`${state.symbol} options`} |
| 73 | run={run} |
| 74 | aside={ |
| 75 | expirations.length ? ( |
| 76 | <label> |
| 77 | Expires{' '} |
| 78 | <select className="yf-select" value={expiration ?? ''} onChange={(event) => onExpiration(event.target.value)}> |
| 79 | {expirations.map((e) => ( |
| 80 | <option key={e} value={e}> |
| 81 | {e} |
| 82 | </option> |
| 83 | ))} |
| 84 | </select> |
| 85 | </label> |
| 86 | ) : undefined |
| 87 | } |
| 88 | empty={rows.length === 0} |
| 89 | > |
| 90 | <p className="yf-line"> |
| 91 | <span className="yf-num yf-strong">{d.price(price, hint)}</span> <Signed value={num(underlying['change'])} hint={hint} />{' '} |
| 92 | <Signed value={num(underlying['changePercent'])} percent /> |
| 93 | <span className="yf-muted"> |
| 94 | {' · '} |
| 95 | {num(totals['calls'])} calls, {num(totals['puts'])} puts · put/call {d.fixed(num(totals['putCallVolumeRatio']))} by volume,{' '} |
| 96 | {d.fixed(num(totals['putCallOpenInterestRatio']))} by open interest |
| 97 | {atm && ` · at ${d.price(num(atm['strike']), hint)}: call IV ${d.percent(num(atm['callIvPercent']), 1)}, put IV ${d.percent(num(atm['putIvPercent']), 1)}`} |
| 98 | </span> |
| 99 | </p> |
| 100 | <table className="yf-table yf-chain"> |
| 101 | <thead> |
| 102 | <tr> |
| 103 | <th colSpan={COLUMNS.length} className="yf-side"> |
| 104 | Calls |
| 105 | </th> |
| 106 | <th className="yf-strike-head" /> |
| 107 | <th colSpan={COLUMNS.length} className="yf-side"> |
| 108 | Puts |
| 109 | </th> |
| 110 | </tr> |
| 111 | <tr> |
| 112 | {COLUMNS.map((c) => ( |
| 113 | <th key={`c${c}`} className="yf-num"> |
| 114 | {c} |
| 115 | </th> |
| 116 | ))} |
| 117 | <th className="yf-num yf-strike-head">Strike</th> |
| 118 | {COLUMNS.map((c) => ( |
| 119 | <th key={`p${c}`} className="yf-num"> |
| 120 | {c} |
| 121 | </th> |
| 122 | ))} |
| 123 | </tr> |
| 124 | </thead> |
| 125 | <tbody> |
| 126 | {rows.map((row, index) => ( |
| 127 | <Fragment key={num(row['strike'])}> |
| 128 | {index === above && <PriceRule price={price} hint={hint} />} |
| 129 | <tr> |
| 130 | <Side contract={record(row['call'])} hint={hint} /> |
| 131 | <td className="yf-num yf-strike">{d.price(num(row['strike']), hint)}</td> |
| 132 | <Side contract={record(row['put'])} hint={hint} /> |
| 133 | </tr> |
| 134 | </Fragment> |
| 135 | ))} |
| 136 | {above === -1 && rows.length > 0 && price !== null && <PriceRule price={price} hint={hint} />} |
| 137 | </tbody> |
| 138 | </table> |
| 139 | </Section> |
| 140 | ) |
| 141 | } |
| 142 | |
| 143 | function PriceRule({ price, hint }: { price: number | null; hint: number | null }): ReactElement { |
| 144 | return ( |
| 145 | <tr className="yf-price-rule"> |
| 146 | <td colSpan={COLUMNS.length * 2 + 1}> |
| 147 | <span>price {d.price(price, hint)}</span> |
| 148 | </td> |
| 149 | </tr> |
| 150 | ) |
| 151 | } |
| 152 | |
| 153 | function Side({ contract, hint }: { contract: Row | null; hint: number | null }): ReactElement { |
| 154 | if (!contract) { |
| 155 | return ( |
| 156 | <> |
| 157 | {COLUMNS.map((c) => ( |
| 158 | <td key={c} className="yf-num yf-muted"> |
| 159 | — |
| 160 | </td> |
| 161 | ))} |
| 162 | </> |
| 163 | ) |
| 164 | } |
| 165 | const itm = contract['inTheMoney'] === true ? ' yf-itm' : '' |
| 166 | const cells = [ |
| 167 | d.price(num(contract['last']), hint), |
| 168 | d.price(num(contract['bid']), hint), |
| 169 | d.price(num(contract['ask']), hint), |
| 170 | d.fixed(num(contract['volume']), 0), |
| 171 | d.fixed(num(contract['openInterest']), 0), |
| 172 | d.percent(num(contract['ivPercent']), 1), |
| 173 | ] |
| 174 | return ( |
| 175 | <> |
| 176 | {cells.map((cell, i) => ( |
| 177 | <td key={COLUMNS[i]} className={`yf-num${itm}`} title={i === 0 ? (text(contract['contract']) ?? undefined) : undefined}> |
| 178 | {cell} |
| 179 | </td> |
| 180 | ))} |
| 181 | </> |
| 182 | ) |
| 183 | } |