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.

6.8 KB
1import { Fragment, type ReactElement } from 'react'
2import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk'
3import { display as d } from './format'
4import { Bar, Empty, Loading, Section, Signed, SymbolField, useEvery, usePanelStateValue, useStateWriter } from './shared'
5import { list, num, record, text, type Row } from './values'
6import { optionsOutput, readOptionsState, REFRESH_MS, STRIKE_CHOICES, type OptionsState } from './views'
7import './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
13const COLUMNS = ['Last', 'Bid', 'Ask', 'Volume', 'Open int.', 'IV'] as const
14
15export 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
53function 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
143function 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
153function 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}