Options Chain: Shows a calls/puts straddle table by strike (IV, delta, OI, volume, max pain) sourced from Yahoo, Alpaca, Massive, or Databento plugins.
| 1 | import { useBridge, usePanelState, usePublish, usePublishText, type PanelRef } from '@jaspers-ai/sdk' |
| 2 | import { useEffect, useMemo, useState } from 'react' |
| 3 | import '../styles.css' |
| 4 | import { calcDte, type ChainMeta, type ChainRow } from './math.ts' |
| 5 | import { |
| 6 | AlpacaProviderLoader, |
| 7 | DatabentoProviderLoader, |
| 8 | MassiveProviderLoader, |
| 9 | YahooProviderLoader, |
| 10 | } from './ProviderLoader.tsx' |
| 11 | import { StraddleTable } from './StraddleTable.tsx' |
| 12 | |
| 13 | const WATCHLIST = ['FLWS', 'BRCB', 'GO', 'CWH', 'JBSS', 'WEN', 'PLBY', 'SPY'] |
| 14 | const PROVIDERS = [ |
| 15 | { id: 'yahoo', label: 'Yahoo' }, |
| 16 | { id: 'alpaca', label: 'Alpaca' }, |
| 17 | { id: 'massive', label: 'Massive' }, |
| 18 | { id: 'databento', label: 'Databento' }, |
| 19 | ] as const |
| 20 | const 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 | |
| 27 | function fmtNum(v: number | null | undefined, d = 2): string { |
| 28 | if (v == null || !Number.isFinite(v)) return '-' |
| 29 | return v.toFixed(d) |
| 30 | } |
| 31 | |
| 32 | export 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 | |
| 191 | function 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 | } |