yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.
| 1 | import { display as d } from './format.ts' |
| 2 | |
| 3 | export { smallPercent } from './format.ts' |
| 4 | import { FREQUENCIES, LINES, lineValue, STATEMENTS, type Frequency, type Statement } from './server/financials.ts' |
| 5 | import { MOVER_LISTS, type MoverList } from './server/quotes.ts' |
| 6 | import { clamp, int, list, num, record, records, text, type Row } from './values.ts' |
| 7 | |
| 8 | // The views' pure half: each view's state read the forgiving way (main stores state as written), |
| 9 | // what each publishes (under the 4,096 bytes a publish may take) and says in the model's map, and |
| 10 | // the small reckonings the drawing needs. A run arrives as the app hands it over: the tool's rows as |
| 11 | // data, the rest of its structured answer as meta. No React and no bridge here. |
| 12 | |
| 13 | export const OUTPUT_MAX = 4096 |
| 14 | export const WATCHLIST_MAX = 30 |
| 15 | /** How often the quote views ask again. */ |
| 16 | export const REFRESH_MS = 60_000 |
| 17 | export const STRIKE_CHOICES = [5, 10, 15, 20, 30] as const |
| 18 | |
| 19 | const SYMBOL = /^[A-Z0-9.^=&-]{1,32}$/ |
| 20 | const DAY = /^\d{4}-\d{2}-\d{2}$/ |
| 21 | |
| 22 | /** A run as a view has it. */ |
| 23 | export interface Run { |
| 24 | data: Record<string, unknown>[] | undefined |
| 25 | meta: Record<string, unknown> |
| 26 | error: string | null |
| 27 | loading: boolean |
| 28 | } |
| 29 | |
| 30 | export function runOf(run: Run): { rows: Row[]; meta: Row; error: string | null; loading: boolean } { |
| 31 | return { rows: run.data ?? [], meta: run.meta, error: run.error, loading: run.loading } |
| 32 | } |
| 33 | |
| 34 | /** The message as the server or main wrote it, without the wrapping the IPC adds. */ |
| 35 | export function cleanError(message: string): string { |
| 36 | return message.replace(/^Error invoking remote method '[^']*': (?:Error: )?/, '') |
| 37 | } |
| 38 | |
| 39 | export function cleanSymbol(raw: unknown): string { |
| 40 | const symbol = typeof raw === 'string' ? raw.trim().toUpperCase() : '' |
| 41 | return SYMBOL.test(symbol) ? symbol : '' |
| 42 | } |
| 43 | |
| 44 | export function readSymbol(state: unknown): string { |
| 45 | return cleanSymbol(record(state)?.['symbol']) |
| 46 | } |
| 47 | |
| 48 | export function yahooPage(symbol: string): string { |
| 49 | return `https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}/` |
| 50 | } |
| 51 | |
| 52 | export function tone(value: number | null | undefined): 'up' | 'down' | '' { |
| 53 | return typeof value === 'number' && value > 0 ? 'up' : typeof value === 'number' && value < 0 ? 'down' : '' |
| 54 | } |
| 55 | |
| 56 | export function signedText(value: number | null | undefined, suffix = ''): string { |
| 57 | return typeof value === 'number' && Number.isFinite(value) ? `${d.signed(value)}${suffix}` : '—' |
| 58 | } |
| 59 | |
| 60 | /** Where a value sits between low and high, 0 to 1; null when the range says nothing. */ |
| 61 | export function position(low: number | null | undefined, high: number | null | undefined, value: number | null | undefined): number | null { |
| 62 | if (typeof low !== 'number' || typeof high !== 'number' || typeof value !== 'number' || !(high > low)) return null |
| 63 | return clamp((value - low) / (high - low), 0, 1) |
| 64 | } |
| 65 | |
| 66 | const FUNDS = new Set(['ETF', 'MUTUALFUND']) |
| 67 | |
| 68 | /** Whether a quote type is a fund, whose holdings the overview shows. */ |
| 69 | export function isFund(type: string | null | undefined): boolean { |
| 70 | return typeof type === 'string' && FUNDS.has(type) |
| 71 | } |
| 72 | |
| 73 | |
| 74 | /** Yahoo's display with a true minus. */ |
| 75 | function shown(value: unknown): string { |
| 76 | return (text(value) ?? '—').replace(/^-/, '−') |
| 77 | } |
| 78 | |
| 79 | function short(message: string | null): string | null { |
| 80 | if (!message) return null |
| 81 | const clean = cleanError(message) |
| 82 | return clean.length > 200 ? `${clean.slice(0, 199)}…` : clean |
| 83 | } |
| 84 | |
| 85 | // Company overview. |
| 86 | |
| 87 | export const OVERVIEW_STATS: [string, string][] = [ |
| 88 | ['marketCap', 'Market cap'], |
| 89 | ['enterpriseValue', 'Enterprise value'], |
| 90 | ['trailingPE', 'P/E'], |
| 91 | ['forwardPE', 'Forward P/E'], |
| 92 | ['pegRatio', 'PEG'], |
| 93 | ['priceToSales', 'Price/sales'], |
| 94 | ['priceToBook', 'Price/book'], |
| 95 | ['enterpriseToEbitda', 'EV/EBITDA'], |
| 96 | ['trailingEps', 'EPS'], |
| 97 | ['revenue', 'Revenue'], |
| 98 | ['profitMargin', 'Profit margin'], |
| 99 | ['operatingMargin', 'Operating margin'], |
| 100 | ['returnOnEquity', 'ROE'], |
| 101 | ['debtToEquity', 'Debt/equity'], |
| 102 | ['dividendYield', 'Dividend yield'], |
| 103 | ['payoutRatio', 'Payout ratio'], |
| 104 | ['beta', 'Beta'], |
| 105 | ['shortPercentOfFloat', 'Short % of float'], |
| 106 | ['averageVolume', 'Avg volume'], |
| 107 | ] |
| 108 | |
| 109 | const GRID = new Map(OVERVIEW_STATS) |
| 110 | |
| 111 | /** The statistics the overview's grid shows, in its order, from the statistics run's rows. */ |
| 112 | export function overviewStats(rows: Row[] | undefined): { key: string; label: string; value: string }[] { |
| 113 | const byKey = new Map((rows ?? []).map((row) => [text(row['key']) ?? '', row])) |
| 114 | return [...GRID].flatMap(([key, label]) => { |
| 115 | const row = byKey.get(key) |
| 116 | return row ? [{ key, label, value: shown(row['display']) }] : [] |
| 117 | }) |
| 118 | } |
| 119 | |
| 120 | /** A rating change in words: "Upgraded to Buy from Hold". */ |
| 121 | export function ratingAction(action: string | null, from: string | null, to: string | null): string { |
| 122 | const was = from && from !== to ? ` from ${from}` : '' |
| 123 | switch (action) { |
| 124 | case 'upgrade': |
| 125 | return `Upgraded to ${to ?? '?'}${was}` |
| 126 | case 'downgrade': |
| 127 | return `Downgraded to ${to ?? '?'}${was}` |
| 128 | case 'maintain': |
| 129 | return `Maintained ${to ?? '?'}` |
| 130 | case 'reiterate': |
| 131 | return `Reiterated ${to ?? '?'}` |
| 132 | case 'initiate': |
| 133 | return `Initiated at ${to ?? '?'}` |
| 134 | default: |
| 135 | return [action, to].filter(Boolean).join(' ') |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | const RATINGS: [string, string][] = [ |
| 140 | ['strongBuy', 'Strong buy'], |
| 141 | ['buy', 'Buy'], |
| 142 | ['hold', 'Hold'], |
| 143 | ['sell', 'Sell'], |
| 144 | ['strongSell', 'Strong sell'], |
| 145 | ] |
| 146 | |
| 147 | /** One month's ratings as shares of a bar; nothing when nobody rated. */ |
| 148 | export function ratingMix(month: Row | null | undefined): { key: string; label: string; count: number; share: number }[] { |
| 149 | const counts = RATINGS.map(([key, label]) => ({ key, label, count: num(month?.[key]) ?? 0 })) |
| 150 | const total = counts.reduce((sum, c) => sum + c.count, 0) |
| 151 | return total > 0 ? counts.map((c) => ({ ...c, share: c.count / total })) : [] |
| 152 | } |
| 153 | |
| 154 | export type OverviewRuns = { quote: Run; statistics: Run; analysts: Run; earnings: Run; profile: Run } |
| 155 | |
| 156 | export type OverviewOutput = { |
| 157 | symbol: string |
| 158 | name: string | null |
| 159 | type: string | null |
| 160 | currency: string | null |
| 161 | price: number | null |
| 162 | changePercent: number | null |
| 163 | marketState: string | null |
| 164 | marketCap: number | null |
| 165 | pe: number | null |
| 166 | forwardPe: number | null |
| 167 | dividendYieldPercent: number | null |
| 168 | fiftyTwoWeekLow: number | null |
| 169 | fiftyTwoWeekHigh: number | null |
| 170 | target: { low: number | null; mean: number | null; high: number | null; analysts: number | null } | null |
| 171 | recommendation: string | null |
| 172 | nextEarnings: string | null |
| 173 | sector: string | null |
| 174 | industry: string | null |
| 175 | errors: string[] |
| 176 | } |
| 177 | |
| 178 | export function overviewOutput(symbol: string, runs: OverviewRuns): OverviewOutput { |
| 179 | const quote = runs.quote.data?.[0] ?? {} |
| 180 | const stats = record(runs.statistics.meta['stats']) ?? {} |
| 181 | const target = record(runs.analysts.meta['target']) |
| 182 | const recommendation = record(runs.analysts.meta['recommendation']) |
| 183 | const next = record(runs.earnings.meta['next']) |
| 184 | const profile = runs.profile.meta |
| 185 | const pick = (a: unknown, b: unknown): number | null => num(a) ?? num(b) |
| 186 | const errors = (Object.entries(runs) as [string, Run][]).flatMap(([name, run]) => (run.error ? [`${name}: ${short(run.error)}`] : [])) |
| 187 | return { |
| 188 | symbol, |
| 189 | name: text(quote['name']) ?? text(runs.statistics.meta['name']) ?? text(profile['name']), |
| 190 | type: text(quote['type']) ?? text(profile['type']), |
| 191 | currency: text(quote['currency']) ?? text(runs.statistics.meta['currency']), |
| 192 | price: pick(quote['price'], stats['price']), |
| 193 | changePercent: num(quote['changePercent']), |
| 194 | marketState: text(quote['marketState']), |
| 195 | marketCap: pick(quote['marketCap'], stats['marketCap']), |
| 196 | pe: pick(quote['pe'], stats['trailingPE']), |
| 197 | forwardPe: pick(quote['forwardPe'], stats['forwardPE']), |
| 198 | dividendYieldPercent: pick(quote['dividendYield'], stats['dividendYield']), |
| 199 | fiftyTwoWeekLow: pick(quote['fiftyTwoWeekLow'], stats['fiftyTwoWeekLow']), |
| 200 | fiftyTwoWeekHigh: pick(quote['fiftyTwoWeekHigh'], stats['fiftyTwoWeekHigh']), |
| 201 | target: |
| 202 | target && num(target['mean']) !== null |
| 203 | ? { low: num(target['low']), mean: num(target['mean']), high: num(target['high']), analysts: num(target['analysts']) } |
| 204 | : null, |
| 205 | recommendation: text(recommendation?.['key']), |
| 206 | nextEarnings: text(next?.['date']), |
| 207 | sector: text(profile['sector']), |
| 208 | industry: text(profile['industry']), |
| 209 | errors, |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | export function overviewSummary(state: unknown, output: unknown): string { |
| 214 | const symbol = readSymbol(state) |
| 215 | if (!symbol) return 'Company: no symbol' |
| 216 | const out = record(output) |
| 217 | const price = num(out?.['price']) |
| 218 | if (!out || price === null) return `${symbol} company overview` |
| 219 | const target = record(out['target']) |
| 220 | const parts = [ |
| 221 | `${d.price(price)} ${d.signedPercent(num(out['changePercent']))}`, |
| 222 | num(out['marketCap']) !== null && `market cap ${d.big(num(out['marketCap']))}`, |
| 223 | num(out['pe']) !== null && `P/E ${d.fixed(num(out['pe']), 1)}`, |
| 224 | num(target?.['mean']) !== null && `target ${d.price(num(target?.['mean']))}${text(out['recommendation']) ? ` (${text(out['recommendation'])!.replace(/_/g, ' ')})` : ''}`, |
| 225 | text(out['nextEarnings']) && `earnings ${text(out['nextEarnings'])}`, |
| 226 | ].filter(Boolean) |
| 227 | const name = text(out['name']) |
| 228 | return `${symbol}${name ? ` ${name}` : ''}: ${parts.join(', ')}` |
| 229 | } |
| 230 | |
| 231 | // Watchlist. |
| 232 | |
| 233 | export function readWatchlist(state: unknown): string[] { |
| 234 | const symbols: string[] = [] |
| 235 | for (const raw of list(record(state)?.['symbols'])) { |
| 236 | const symbol = cleanSymbol(raw) |
| 237 | if (symbol && !symbols.includes(symbol)) symbols.push(symbol) |
| 238 | if (symbols.length === WATCHLIST_MAX) break |
| 239 | } |
| 240 | return symbols |
| 241 | } |
| 242 | |
| 243 | export function addSymbol(symbols: string[], raw: string): string[] { |
| 244 | const symbol = cleanSymbol(raw) |
| 245 | if (!symbol || symbols.includes(symbol) || symbols.length >= WATCHLIST_MAX) return symbols |
| 246 | return [...symbols, symbol] |
| 247 | } |
| 248 | |
| 249 | export function removeSymbol(symbols: string[], symbol: string): string[] { |
| 250 | return symbols.filter((s) => s !== symbol) |
| 251 | } |
| 252 | |
| 253 | export type WatchlistOutput = { |
| 254 | count: number |
| 255 | missing: string[] |
| 256 | rows: { symbol: string; price: number | null; changePercent: number | null }[] |
| 257 | } |
| 258 | |
| 259 | export function watchlistOutput(symbols: string[], quotes: Row[]): WatchlistOutput { |
| 260 | const bySymbol = new Map(quotes.map((q) => [text(q['symbol']) ?? '', q])) |
| 261 | const rows: WatchlistOutput['rows'] = [] |
| 262 | const missing: string[] = [] |
| 263 | for (const symbol of symbols) { |
| 264 | const q = bySymbol.get(symbol) |
| 265 | if (q) rows.push({ symbol, price: num(q['price']), changePercent: num(q['changePercent']) }) |
| 266 | else missing.push(symbol) |
| 267 | } |
| 268 | return { count: symbols.length, missing, rows } |
| 269 | } |
| 270 | |
| 271 | export function watchlistSummary(_state: unknown, output: unknown): string { |
| 272 | const out = record(output) |
| 273 | const rows = records(out?.['rows']) |
| 274 | const count = num(out?.['count']) ?? rows.length |
| 275 | if (!count) return 'Watchlist: empty' |
| 276 | const shown = rows.slice(0, 5).map((r) => `${text(r['symbol'])} ${d.price(num(r['price']))} ${d.signedPercent(num(r['changePercent']))}`) |
| 277 | return `Watchlist (${count}): ${shown.join(', ')}${rows.length > 5 ? ', …' : ''}` |
| 278 | } |
| 279 | |
| 280 | // Markets. |
| 281 | |
| 282 | export const MARKET_TABS: { list: MoverList; label: string }[] = [ |
| 283 | { list: 'gainers', label: 'Gainers' }, |
| 284 | { list: 'losers', label: 'Losers' }, |
| 285 | { list: 'most_active', label: 'Most active' }, |
| 286 | { list: 'trending', label: 'Trending' }, |
| 287 | ] |
| 288 | |
| 289 | export const MARKET_SCREENS: { list: MoverList; label: string }[] = [ |
| 290 | { list: 'undervalued_growth', label: 'Undervalued growth' }, |
| 291 | { list: 'growth_technology', label: 'Growth technology' }, |
| 292 | { list: 'aggressive_small_caps', label: 'Aggressive small caps' }, |
| 293 | { list: 'small_cap_gainers', label: 'Small cap gainers' }, |
| 294 | { list: 'undervalued_large_caps', label: 'Undervalued large caps' }, |
| 295 | ] |
| 296 | |
| 297 | export function readMarketList(state: unknown): MoverList { |
| 298 | const listed = record(state)?.['list'] |
| 299 | return typeof listed === 'string' && listed in MOVER_LISTS ? (listed as MoverList) : 'gainers' |
| 300 | } |
| 301 | |
| 302 | type Listing = { symbol: string; name: string | null; price: number | null; changePercent: number | null } |
| 303 | |
| 304 | function listing(q: Row): Listing { |
| 305 | return { symbol: text(q['symbol']) ?? '', name: text(q['name']), price: num(q['price']), changePercent: num(q['changePercent']) } |
| 306 | } |
| 307 | |
| 308 | export type MarketsOutput = { |
| 309 | list: MoverList |
| 310 | title: string | null |
| 311 | total: number | null |
| 312 | tiles: Listing[] |
| 313 | top: Listing[] |
| 314 | } |
| 315 | |
| 316 | export function marketsOutput(list: MoverList, tiles: Row[], screen: { title: unknown; total: unknown; rows: Row[] }): MarketsOutput { |
| 317 | return { |
| 318 | list, |
| 319 | title: text(screen.title), |
| 320 | total: num(screen.total), |
| 321 | tiles: tiles.slice(0, 16).map(listing), |
| 322 | top: screen.rows.slice(0, 10).map(listing), |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | export function marketsSummary(state: unknown, output: unknown): string { |
| 327 | const out = record(output) |
| 328 | const tiles = records(out?.['tiles']) |
| 329 | const top = records(out?.['top']) |
| 330 | if (!tiles.length && !top.length) return `Markets: ${readMarketList(state).replace(/_/g, ' ')}` |
| 331 | const move = (r: Row, name: unknown): string => `${text(name) ?? text(r['symbol'])} ${d.signedPercent(num(r['changePercent']))}` |
| 332 | const head = tiles.slice(0, 3).map((r) => move(r, r['name'])) |
| 333 | const list = top.slice(0, 3).map((r) => move(r, r['symbol'])) |
| 334 | const title = text(out?.['title']) ?? readMarketList(state) |
| 335 | return `Markets: ${[head.join(', '), list.length ? `${title}: ${list.join(', ')}` : ''].filter(Boolean).join('; ')}` |
| 336 | } |
| 337 | |
| 338 | // Financials. |
| 339 | |
| 340 | export type FinancialsState = { symbol: string; statement: Statement; frequency: Frequency } |
| 341 | |
| 342 | export function readFinancialsState(state: unknown): FinancialsState { |
| 343 | const raw = record(state) ?? {} |
| 344 | const statement = STATEMENTS.find((s) => s === raw['statement']) ?? 'income' |
| 345 | let frequency = FREQUENCIES.find((f) => f === raw['frequency']) ?? 'annual' |
| 346 | // A balance sheet has no trailing twelve months. |
| 347 | if (statement === 'balance' && frequency === 'trailing') frequency = 'annual' |
| 348 | return { symbol: readSymbol(raw), statement, frequency } |
| 349 | } |
| 350 | |
| 351 | export type FinancialsOutput = FinancialsState & { |
| 352 | currency: string | null |
| 353 | periods: string[] |
| 354 | lines: number |
| 355 | highlights: Record<string, (number | null)[]> |
| 356 | error: string | null |
| 357 | } |
| 358 | |
| 359 | export function financialsOutput(state: FinancialsState, run: Run): FinancialsOutput { |
| 360 | const periods = list(run.meta['periods']).map(text).filter((p): p is string => p !== null) |
| 361 | const rows = run.data ?? [] |
| 362 | const highlights: Record<string, (number | null)[]> = {} |
| 363 | for (const row of rows) { |
| 364 | if (row['highlight'] !== true) continue |
| 365 | const values = record(row['values']) ?? {} |
| 366 | highlights[text(row['label']) ?? ''] = periods.slice(0, 8).map((p) => num(values[p])) |
| 367 | } |
| 368 | return { |
| 369 | ...state, |
| 370 | currency: text(run.meta['currency']), |
| 371 | periods: periods.slice(0, 8), |
| 372 | lines: rows.length, |
| 373 | highlights, |
| 374 | error: short(run.error), |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | export function financialsSummary(state: unknown, output: unknown): string { |
| 379 | const read = readFinancialsState(state) |
| 380 | if (!read.symbol) return 'Financials: no symbol' |
| 381 | const out = record(output) |
| 382 | const head = `${read.symbol} ${read.statement}, ${read.frequency}` |
| 383 | const error = text(out?.['error']) |
| 384 | if (error) return `${head}: ${error}` |
| 385 | const periods = list(out?.['periods']).map(text) |
| 386 | const highlights = record(out?.['highlights']) |
| 387 | if (!periods[0] || !highlights) return `${head} statement` |
| 388 | const kinds = new Map(LINES[read.statement].map((l) => [l.label, l.kind])) |
| 389 | const values = Object.entries(highlights).map( |
| 390 | ([label, series]) => `${label} ${lineValue(kinds.get(label) ?? 'money', num(list(series)[0]), d)}`, |
| 391 | ) |
| 392 | const currency = text(out?.['currency']) |
| 393 | return `${head}${currency ? ` (${currency})` : ''}, ${periods[0]}: ${values.join(', ')}` |
| 394 | } |
| 395 | |
| 396 | // Options. |
| 397 | |
| 398 | export type OptionsState = { symbol: string; expiration: string | null; strikes: number } |
| 399 | |
| 400 | export function readOptionsState(state: unknown): OptionsState { |
| 401 | const raw = record(state) ?? {} |
| 402 | const expiration = text(raw['expiration']) |
| 403 | const strikes = int(raw['strikes']) |
| 404 | return { |
| 405 | symbol: readSymbol(raw), |
| 406 | expiration: expiration && DAY.test(expiration) ? expiration : null, |
| 407 | strikes: strikes === undefined ? 10 : clamp(strikes, 1, 50), |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | export type OptionsOutput = { |
| 412 | symbol: string |
| 413 | expiration: string | null |
| 414 | expirations: string[] |
| 415 | underlying: number | null |
| 416 | strikes: number |
| 417 | atm: { strike: number | null; callMid: number | null; putMid: number | null; callIvPercent: number | null; putIvPercent: number | null } | null |
| 418 | putCallVolumeRatio: number | null |
| 419 | putCallOpenInterestRatio: number | null |
| 420 | error: string | null |
| 421 | } |
| 422 | |
| 423 | export function optionsOutput(state: OptionsState, run: Run): OptionsOutput { |
| 424 | const meta = run.meta |
| 425 | const atm = record(meta['atm']) |
| 426 | const totals = record(meta['totals']) |
| 427 | return { |
| 428 | symbol: text(meta['symbol']) ?? state.symbol, |
| 429 | expiration: text(meta['expiration']) ?? state.expiration, |
| 430 | expirations: list(meta['expirations']) |
| 431 | .map(text) |
| 432 | .filter((e): e is string => e !== null) |
| 433 | .slice(0, 60), |
| 434 | underlying: num(record(meta['underlying'])?.['price']), |
| 435 | strikes: (run.data ?? []).length, |
| 436 | atm: atm |
| 437 | ? { |
| 438 | strike: num(atm['strike']), |
| 439 | callMid: num(atm['callMid']), |
| 440 | putMid: num(atm['putMid']), |
| 441 | callIvPercent: num(atm['callIvPercent']), |
| 442 | putIvPercent: num(atm['putIvPercent']), |
| 443 | } |
| 444 | : null, |
| 445 | putCallVolumeRatio: num(totals?.['putCallVolumeRatio']), |
| 446 | putCallOpenInterestRatio: num(totals?.['putCallOpenInterestRatio']), |
| 447 | error: short(run.error), |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | export function optionsSummary(state: unknown, output: unknown): string { |
| 452 | const read = readOptionsState(state) |
| 453 | if (!read.symbol) return 'Options: no symbol' |
| 454 | const out = record(output) |
| 455 | const expiration = text(out?.['expiration']) |
| 456 | const error = text(out?.['error']) |
| 457 | if (error) return `${read.symbol} options: ${error}` |
| 458 | const atm = record(out?.['atm']) |
| 459 | if (!expiration || !atm) return `${read.symbol} options` |
| 460 | const iv = num(atm['callIvPercent']) ?? num(atm['putIvPercent']) |
| 461 | const ratio = num(out?.['putCallOpenInterestRatio']) |
| 462 | return [ |
| 463 | `${read.symbol} options ${expiration}: at ${d.price(num(atm['strike']))} call ${d.price(num(atm['callMid']))} / put ${d.price(num(atm['putMid']))}`, |
| 464 | iv !== null && `IV ${d.percent(iv, 1)}`, |
| 465 | ratio !== null && `put/call OI ${d.fixed(ratio)}`, |
| 466 | ] |
| 467 | .filter(Boolean) |
| 468 | .join(', ') |
| 469 | } |