// Investor Flow — OptionsAdapter (DESIGN.md §3a Module 2 + options slice). // // Extends SourceFetch (SourceAdapter.ts) to serve the 'options' sourceKind. // Wraps yahoo-finance2's `YahooFinance.options(symbol)` chain + `optionsExpiryDates()`. // // Caching policy: // - expiry_dates(symbol): short-lived (5 min) — expiration dates change infrequently // but can shift around earnings/dividends. Key: `options:expiry_dates:`. // - options_chain(symbol, expiry): medium-lived (15 min) — matches the existing // `options_snapshot` TtlClass in CacheRepository. Key: `options:chain::`. // Both cache keys use 'yfinance' as sourceKind since they route through the same yf client. // // Rate-limiting: reuses YFinanceAdapter's lazy-loaded yf instance via a shared singleton // to avoid per-request dynamic imports. User-Agent is set once via the yfinance2 constructor. // // ADR-0007: no trade verbs — this adapter only reads (fetch), never writes. // P6: typed greeks + IV included where present; missing greeks gracefully omitted. import type { CacheKey, TtlClass, Provenance, SourceKind } from '../cache/CacheRepository.ts'; import { parseCacheKey } from '../cache/CacheRepository.ts'; import type { SourceFetch, FetchResult } from './SourceAdapter.ts'; // ----- Public types (options domain) ----- /** One expiry date string returned by yfinance (e.g. "2026-07-17"). */ export type OptionExpiryDate = string; /** One leg of the chain — call OR put, identified by strike + type. */ export interface OptionChainRow { /** Contract symbol, e.g. "NVDA250717C00100000" */ contractSymbol: string; /** Strike price (number). */ strike: number; /** "call" | "put" */ right: 'call' | 'put'; /** Expiry date string (ISO). */ expiration: OptionExpiryDate; /** Last trade price. */ lastPrice?: number | null; /** Bid price (if quoted). */ bid?: number | null; /** Ask price (if quoted). */ ask?: number | null; /** Volume (open interest). */ volume?: number | null; /** Open interest count. */ openInterest?: number | null; /** Implied volatility (decimal, e.g. 0.45 = 45%). */ impliedVolatility?: number | null; /** In-the-money probability (0-1). */ inTheMoney?: boolean | null; /** Greeks — all optional (not all endpoints return them). */ greeks?: OptionGreeks | null; } /** Delta / Gamma / Theta / Vega — all optional per ADR-0007 (best-effort). */ export interface OptionGreeks { /** Rate of change of option price w.r.t. underlying (−1..1 for puts, 0..1 for calls). */ delta?: number | null; /** Rate of change of delta w.r.t. underlying. */ gamma?: number | null; /** Rate of change of option price w.r.t. time decay (per day). */ theta?: number | null; /** Rate of change of option price w.r.t. IV (per 1% change). */ vega?: number | null; } /** Full chain for one expiry — calls and puts interleaved by strike. */ export interface OptionChain { symbol: string; expiration: OptionExpiryDate; rows: OptionChainRow[]; } // ----- SourceFetch implementation ----- /** * SourceFetch for options data. Implements `fetchOne` for two kinds: * - "expiry_dates:" → list of available expiry dates * - "chain::" → full chain (calls + puts) for one expiry */ export class OptionsAdapter implements SourceFetch { readonly sourceKind = 'yfinance' as SourceKind; /** Lazy-loaded yfinance2 instance (singleton per adapter). */ private _yf: unknown = null; private async yf(): Promise { if (!this._yf) { const mod = await import('yahoo-finance2'); this._yf = new mod.default(); } return this._yf as YFinanceLike; } async fetchOne(key: CacheKey): Promise { const { kind, id } = parseCacheKey(key); const fetchedAt = new Date().toISOString(); const yf = await this.yf(); if (kind === 'expiry_dates') { // Use optionsExpiryDates() which returns string[] directly. const dates = await yf.optionsExpiryDates(id); const sorted = [...dates].sort() as OptionExpiryDate[]; return { value: sorted, ttlClass: 'intraday', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` }, }; } if (kind === 'chain') { const [symbol, expiry] = id.split(':'); const rawResult = await yf.options(symbol, expiry); // Handle multiple response shapes: // v3: { options: { '1234567890': { calls: [...], puts: [...] } } } // flat: { calls: [...], puts: [...] } let expiryData: Record | null = null; if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) { const optObj = rawResult.options as Record; const dateKeys = Object.keys(optObj).filter((k) => /^\d+$/.test(k) || /\d{4}-\d{2}-\d{2}/.test(k)); if (dateKeys.length > 0) { expiryData = optObj[dateKeys[0]] as Record; } else { expiryData = optObj; } } else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) { // Flat format: { calls: [...], puts: [...] } expiryData = rawResult as Record; } const allRows = [ ...((expiryData?.calls as Array> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })), ...((expiryData?.puts as Array> | undefined) ?? []).map((p) => ({ ...p, right: 'put' as const })), ]; const rows = parseOptionChainRows(symbol, allRows); return { value: rows, ttlClass: 'options_snapshot', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:chain:${symbol}:${expiry}` }, }; } if (kind === 'greeks') { const parts = id.split(':'); const symbol = parts[0]; const expiry = parts[1]; const strike = parseFloat(parts[2] ?? '0'); const rawResult = await yf.options(symbol, expiry); // Handle multiple response shapes (same logic as chain kind). let expiryData: Record | null = null; if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) { const optObj = rawResult.options as Record; const dateKeys = Object.keys(optObj).filter((k) => /^\d+$/.test(k) || /\d{4}-\d{2}-\d{2}/.test(k)); if (dateKeys.length > 0) { expiryData = optObj[dateKeys[0]] as Record; } else { expiryData = optObj; } } else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) { expiryData = rawResult as Record; } const allRows = [ ...((expiryData?.calls as Array> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })), ...((expiryData?.puts as Array> | undefined) ?? []).map((p) => ({ ...p, right: 'put' as const })), ]; const rows = parseOptionChainRows(symbol, allRows); const target = strike > 0 ? rows.find((r) => r.strike === strike) : rows[0]; return { value: target ?? null, ttlClass: 'options_snapshot', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:greeks:${symbol}:${expiry}:${strike}` }, }; } throw new Error(`OptionsAdapter: unknown kind '${kind}'`); } /** Convenience: fetch expiry dates for a symbol (bypasses CacheRepository). */ async expiryDates(symbol: string): Promise { const result = await this.fetchOne(`yfinance:expiry_dates:${symbol}`); return result.value as OptionExpiryDate[]; } /** Convenience: fetch full chain for a symbol + expiry (bypasses CacheRepository). */ async chain(symbol: string, expiry: OptionExpiryDate): Promise { const key = `yfinance:chain:${symbol}:${expiry}`; const result = await this.fetchOne(key); // fetchOne returns OptionChainRow[] (the cached shape). Re-wrap into OptionChain. const rows = result.value as OptionChainRow[]; return { symbol, expiration: expiry, rows }; } } // ----- Pure parse helpers (tested with recorded fixtures; no network) ----- /** * Parse the raw option chain from yahoo-finance2 into typed OptionChainRow[]. * Handles both formats: * - v2: `{ calls: [...], puts: [...] }` object * - v3: array of row objects with `right` field already set */ export function parseOptionChainRows(symbol: string, raw: Record | Array>): OptionChainRow[] { let rows: OptionChainRow[] = []; if (Array.isArray(raw)) { // Already flattened array with right field for (const r of raw) { rows.push(parseOneRow(r)); } } else { // Legacy format: { calls: [...], puts: [...] } const calls = (raw.calls ?? []) as Array>; const puts = (raw.puts ?? []) as Array>; for (const r of [...calls, ...puts]) { rows.push(parseOneRow(r)); } } // Sort by strike ascending, calls first then puts at same strike (standard convention). rows.sort((a, b) => { const strikeDiff = a.strike - b.strike; if (strikeDiff !== 0) return strikeDiff; // calls before puts at same strike if (a.right === 'call' && b.right === 'put') return -1; if (a.right === 'put' && b.right === 'call') return 1; return 0; }); return rows; } /** * Parse the raw option chain and return a full OptionChain wrapper. * Used when callers need the OptionChain shape (e.g. direct API responses). */ export function parseOptionChain(symbol: string, raw: Record): OptionChain { const rows = parseOptionChainRows(symbol, raw); const expiration = rows[0]?.expiration ?? ''; return { symbol, expiration, rows }; } /** Parse a single raw option row from yfinance2. */ function parseOneRow(raw: Record): OptionChainRow { const strike = num(raw.strike) ?? 0; const contractSymbol = str(raw.contractSymbol ?? raw.contractSymbol2 ?? '') ?? ''; // Determine right from symbol suffix or explicit field. const right = inferRight(contractSymbol, raw); return { contractSymbol, strike, right, expiration: str(raw.expiration) ?? '', lastPrice: num(raw.lastPrice ?? raw.lastPrice2), bid: num(raw.bid), ask: num(raw.ask), volume: num(raw.volume), openInterest: num(raw.openInterest ?? raw.openInterest2), impliedVolatility: num(raw.impliedVolatility), inTheMoney: raw.inTheMoney === true, greeks: parseGreeks(raw), }; } /** Infer call/put from contract symbol suffix or explicit field. */ function inferRight(contractSymbol: string, raw: Record): 'call' | 'put' { // yfinance2 sometimes includes 'C' or 'P' suffix in contractSymbol. if (typeof raw.right === 'string') { const r = raw.right.toLowerCase(); if (r === 'call' || r === 'c') return 'call'; if (r === 'put' || r === 'p') return 'put'; } // Fallback: inspect contractSymbol — yfinance2 formats as "SYMDATERIGHT" // where RIGHT is "C" or "P". The last char of the numeric section encodes it. const match = contractSymbol.match(/([CP])$/i); if (match) return match[1].toUpperCase() === 'P' ? 'put' : 'call'; return 'call'; // default — most common } /** Parse greeks from a raw option row. All fields are optional. */ function parseGreeks(raw: Record): OptionGreeks | null { const delta = num(raw.delta); const gamma = num(raw.gamma); const theta = num(raw.theta); const vega = num(raw.vega); // Only return greeks object if at least one field is present. if (delta === null && gamma === null && theta === null && vega === null) { return null; } return { delta, gamma, theta, vega }; } // ----- Helpers ----- function num(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null; } function str(v: unknown): string | null { return typeof v === 'string' && v.length > 0 ? v : null; } // ----- Type shim for yahoo-finance2 ----- /** Minimal yfinance2 surface we use. Keeps the dynamic import decoupled from the type system. */ interface YFinanceLike { optionsExpiryDates(symbol: string): Promise; options(symbol: string, expiry?: string): Promise>; }