Jaspers Hub

biseroff / option-chain

pluginReviewed★ 0↓ 0

Options Chain: Shows a multi-vendor options chain straddle table with IV, delta, OI, volume, and max pain for a ticker.

11 KB
1import { useBridge, usePanelState, usePublish, usePublishText, type PanelRef } from '@jaspers-ai/sdk'
2import { useEffect, useMemo, useState } from 'react'
3import '../styles.css'
4import { calcDte, type ChainMeta, type ChainRow } from './math.ts'
5import {
6 AlpacaProviderLoader,
7 DatabentoProviderLoader,
8 MassiveProviderLoader,
9 YahooProviderLoader,
10} from './ProviderLoader.tsx'
11import { StraddleTable } from './StraddleTable.tsx'
12
13const WATCHLIST = ['FLWS', 'BRCB', 'GO', 'CWH', 'JBSS', 'WEN', 'PLBY', 'SPY']
14const PROVIDERS = [
15 { id: 'yahoo', label: 'Yahoo' },
16 { id: 'alpaca', label: 'Alpaca' },
17 { id: 'massive', label: 'Massive' },
18 { id: 'databento', label: 'Databento' },
19] as const
20const STRIKE_CHOICES = [
21 { value: 10, label: '10 Strikes' },
22 { value: 20, label: '20 Strikes' },
23 { value: 40, label: '40 Strikes' },
24 { value: 0, label: 'All Strikes' },
25]
26
27function fmtNum(v: number | null | undefined, d = 2): string {
28 if (v == null || !Number.isFinite(v)) return '-'
29 return v.toFixed(d)
30}
31
32export function TableView({ panel }: { panel: PanelRef }) {
33 const [symbolState, setSymbol] = usePanelState<string>(panel, 'symbol', 'FLWS')
34 const [providerState, setProvider] = usePanelState<'yahoo' | 'alpaca' | 'massive' | 'databento'>(
35 panel,
36 'provider',
37 'yahoo',
38 )
39 const [alpacaFeedState] = usePanelState<'indicative' | 'opra'>(panel, 'alpacaFeed', 'indicative')
40 const [expirationState, setExpiration] = usePanelState<string | null>(panel, 'expiration', null)
41 const [strikesState, setStrikes] = usePanelState<number>(panel, 'strikes', 20)
42
43 const symbol = (symbolState || 'FLWS').toUpperCase()
44 const provider = providerState || 'yahoo'
45 const alpacaFeed = alpacaFeedState || 'indicative'
46 const expiration = expirationState ?? null
47 const strikes = strikesState ?? 20
48
49 const [tickerInput, setTickerInput] = useState(symbol)
50 useEffect(() => {
51 setTickerInput(symbol)
52 }, [symbol])
53
54 const commitTicker = (raw: string) => {
55 const clean = raw.trim().toUpperCase()
56 if (!clean) return
57 setTickerInput(clean)
58 if (clean !== symbol) {
59 setExpiration(null)
60 setSymbol(clean)
61 }
62 }
63
64 const tickerChoices = useMemo(
65 () => (WATCHLIST.includes(symbol) ? WATCHLIST : [symbol, ...WATCHLIST]),
66 [symbol],
67 )
68
69 const Loader =
70 provider === 'alpaca'
71 ? AlpacaProviderLoader
72 : provider === 'massive'
73 ? MassiveProviderLoader
74 : provider === 'databento'
75 ? DatabentoProviderLoader
76 : YahooProviderLoader
77
78 return (
79 <div className="oc-root">
80 <Loader symbol={symbol} expiration={expiration} strikes={strikes} alpacaFeed={alpacaFeed}>
81 {(res) => {
82 const expirations = res.meta?.expirations ?? []
83 const activeExp = res.meta?.selectedExpiration ?? expiration ?? expirations[0] ?? ''
84
85 return (
86 <>
87 <div className="oc-toolbar">
88 <div className="oc-field">
89 <span className="oc-label">Ticker</span>
90 <select
91 className="oc-select oc-select-mono"
92 value={symbol}
93 onChange={(e) => commitTicker(e.target.value)}
94 >
95 {tickerChoices.map((t) => (
96 <option key={t} value={t}>
97 {t}
98 </option>
99 ))}
100 </select>
101 <input
102 className="oc-input"
103 value={tickerInput}
104 placeholder="Custom..."
105 onChange={(e) => setTickerInput(e.target.value.toUpperCase())}
106 onKeyDown={(e) => {
107 if (e.key === 'Enter') commitTicker(tickerInput)
108 }}
109 />
110 <button type="button" className="oc-btn" onClick={() => commitTicker(tickerInput)}>
111 Go
112 </button>
113 </div>
114
115 <div className="oc-field">
116 <span className="oc-label">Vendor</span>
117 <select
118 className="oc-select"
119 value={provider}
120 onChange={(e) => {
121 const next = e.target.value as 'yahoo' | 'alpaca' | 'massive' | 'databento'
122 setExpiration(null)
123 setProvider(next)
124 }}
125 >
126 {PROVIDERS.map((p) => (
127 <option key={p.id} value={p.id}>
128 {p.label}
129 </option>
130 ))}
131 </select>
132 </div>
133
134 <div className="oc-field">
135 <span className="oc-label">Expiration</span>
136 <select
137 className="oc-select oc-select-mono"
138 value={activeExp}
139 disabled={expirations.length === 0}
140 onChange={(e) => setExpiration(e.target.value || null)}
141 >
142 {expirations.length === 0 ? (
143 <option value="">{res.loading ? 'Loading expirations...' : 'No expirations'}</option>
144 ) : (
145 expirations.map((exp) => {
146 const dte = calcDte(exp)
147 return (
148 <option key={exp} value={exp}>
149 {exp} ({dte} DTE)
150 </option>
151 )
152 })
153 )}
154 </select>
155 </div>
156
157 <div className="oc-field">
158 <span className="oc-label">Strikes</span>
159 <select
160 className="oc-select"
161 value={strikes <= 0 || strikes >= 200 ? 0 : strikes}
162 onChange={(e) => setStrikes(Number(e.target.value))}
163 >
164 {STRIKE_CHOICES.map((c) => (
165 <option key={c.value} value={c.value}>
166 {c.label}
167 </option>
168 ))}
169 </select>
170 </div>
171 </div>
172
173 <ChainBody
174 panel={panel}
175 symbol={symbol}
176 provider={provider}
177 expiration={expiration}
178 rows={res.rows}
179 meta={res.meta}
180 loading={res.loading}
181 error={res.error}
182 />
183 </>
184 )
185 }}
186 </Loader>
187 </div>
188 )
189}
190
191function ChainBody({
192 panel,
193 symbol,
194 provider,
195 expiration,
196 rows,
197 meta,
198 loading,
199 error,
200}: {
201 panel: PanelRef
202 symbol: string
203 provider: string
204 expiration: string | null
205 rows: ChainRow[]
206 meta: ChainMeta | null
207 loading: boolean
208 error: string | null
209}) {
210 const bridge = useBridge()
211 const [copiedOcc, setCopiedOcc] = useState<string | null>(null)
212 const activeExp = meta?.selectedExpiration ?? expiration ?? meta?.expirations?.[0] ?? null
213
214 usePublish(panel, {
215 symbol,
216 provider,
217 expiration: activeExp,
218 spot: meta?.spot ?? null,
219 strikeCount: rows.length,
220 expirationsCount: meta?.expirations?.length ?? 0,
221 atmIv: meta?.atmIv ?? null,
222 atmStraddle: meta?.straddlePrice ?? null,
223 pcRatioOi: meta?.pcOiRatio ?? null,
224 maxPain: meta?.maxPain ?? null,
225 })
226
227 const textSummary = useMemo(() => {
228 if (!meta && rows.length === 0) {
229 return `Option Chain: ${symbol} (${provider}) - ${loading ? 'Loading' : error || 'No data'}`
230 }
231 const header = `Option Chain ${symbol} (${meta?.feedLabel ?? provider}) | Spot: $${fmtNum(meta?.spot)} | Exp: ${activeExp ?? '-'} (${meta?.dte ?? '-'}d) | ATM Strike: ${fmtNum(meta?.atmStrike)} | ATM IV: ${fmtNum(meta?.atmIv)}% | Straddle: $${fmtNum(meta?.straddlePrice)} | P/C OI: ${fmtNum(meta?.pcOiRatio)} | P/C Vol: ${fmtNum(meta?.pcVolRatio)} | Max Pain: $${fmtNum(meta?.maxPain)}`
232 const cols =
233 'CallBid | CallAsk | CallLast | CallVol | CallOI | CallIV% | CallDelta | Strike | PutDelta | PutIV% | PutOI | PutVol | PutLast | PutBid | PutAsk'
234 const lines = rows.map(
235 (r) =>
236 `${fmtNum(r.callBid)}${r.callBidSize ? 'x' + r.callBidSize : ''} | ${fmtNum(r.callAsk)}${r.callAskSize ? 'x' + r.callAskSize : ''} | ${fmtNum(r.callLast)} | ${r.callVol ?? 0} | ${r.callOi ?? 0} | ${fmtNum(r.callIv)} | ${fmtNum(r.callDelta, 3)} | [${fmtNum(r.strike)}] | ${fmtNum(r.putDelta, 3)} | ${fmtNum(r.putIv)} | ${r.putOi ?? 0} | ${r.putVol ?? 0} | ${fmtNum(r.putLast)} | ${fmtNum(r.putBid)}${r.putBidSize ? 'x' + r.putBidSize : ''} | ${fmtNum(r.putAsk)}${r.putAskSize ? 'x' + r.putAskSize : ''}`,
237 )
238 return [header, cols, ...lines].join('\n')
239 }, [symbol, provider, meta, activeExp, rows, loading, error])
240
241 usePublishText(panel, textSummary)
242
243 const handlePickContract = (occ: string) => {
244 bridge.copyText(occ)
245 setCopiedOcc(occ)
246 }
247
248 return (
249 <>
250 {meta && (
251 <div className="oc-summary-bar">
252 <div className="oc-stat">
253 <span className="oc-stat-label">Spot</span>
254 <span className="oc-stat-val">${fmtNum(meta.spot)}</span>
255 </div>
256 <div className="oc-stat">
257 <span className="oc-stat-label">Exp / DTE</span>
258 <span className="oc-stat-val">
259 {activeExp ?? '-'} ({meta.dte ?? '-'}d)
260 </span>
261 </div>
262 <div className="oc-stat">
263 <span className="oc-stat-label">ATM IV</span>
264 <span className="oc-stat-val">{meta.atmIv != null ? `${fmtNum(meta.atmIv)}%` : '-'}</span>
265 </div>
266 <div className="oc-stat">
267 <span className="oc-stat-label">ATM Straddle</span>
268 <span className="oc-stat-val">
269 {meta.straddlePrice != null ? `$${fmtNum(meta.straddlePrice)}` : '-'}
270 {meta.straddlePct != null ? ` (${fmtNum(meta.straddlePct)}%)` : ''}
271 </span>
272 </div>
273 <div className="oc-stat">
274 <span className="oc-stat-label">P/C OI</span>
275 <span className="oc-stat-val">{fmtNum(meta.pcOiRatio)}</span>
276 </div>
277 <div className="oc-stat">
278 <span className="oc-stat-label">P/C Vol</span>
279 <span className="oc-stat-val">{fmtNum(meta.pcVolRatio)}</span>
280 </div>
281 <div className="oc-stat">
282 <span className="oc-stat-label">Max Pain</span>
283 <span className="oc-stat-val">{meta.maxPain != null ? `$${fmtNum(meta.maxPain)}` : '-'}</span>
284 </div>
285 <div className="oc-stat">
286 <span className="oc-stat-label">Feed</span>
287 <span className="oc-stat-val">
288 {meta.feedLabel}
289 {copiedOcc ? ` | Copied ${copiedOcc}` : ''}
290 </span>
291 </div>
292 </div>
293 )}
294
295 {meta?.note && <div className="oc-note">{meta.note}</div>}
296 {error && <div className="oc-error">{error}</div>}
297 {loading && rows.length === 0 && <div className="oc-note">Loading {symbol} option chain...</div>}
298 {!loading && !error && rows.length === 0 && (
299 <div className="oc-note">No option contracts found for {symbol}.</div>
300 )}
301
302 {rows.length > 0 && (
303 <StraddleTable
304 rows={rows}
305 spot={meta?.spot ?? null}
306 atmStrike={meta?.atmStrike ?? null}
307 onPickContract={handlePickContract}
308 />
309 )}
310 </>
311 )
312}