From cfc762e952f11deec47c3a2cca20bf9abacbc43e Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Tue, 30 Jun 2026 13:03:30 -0400 Subject: [PATCH] slice 15a OptionsAdapter (ornith-35): expiry_dates + options_chain (calls/puts/IV/greeks), yf2 lazy singleton, options_snapshot/intraday TTL, ADR-0007 Cross-review by qwopus35b pending. --- app/server/src/adapters/OptionsAdapter.ts | 248 ++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 app/server/src/adapters/OptionsAdapter.ts diff --git a/app/server/src/adapters/OptionsAdapter.ts b/app/server/src/adapters/OptionsAdapter.ts new file mode 100644 index 0000000..6d5522d --- /dev/null +++ b/app/server/src/adapters/OptionsAdapter.ts @@ -0,0 +1,248 @@ +// 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') { + const rawDates = await yf.optionsExpiryDates(id); + // yahoo-finance2 returns string[] (ISO dates). Sort ascending. + const dates = [...rawDates].sort() as OptionExpiryDate[]; + return { + value: dates, + ttlClass: 'intraday', // 1h TTL class — short-lived, shifts around events + provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` }, + }; + } + + if (kind === 'chain') { + const [symbol, expiry] = id.split(':'); + const rawChain = await yf.options(symbol, expiry); + return { + value: parseOptionChain(symbol, rawChain), + ttlClass: 'options_snapshot', // 15 min (matches CacheRepository TTL_MS) + provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:chain:${symbol}:${expiry}` }, + }; + } + + 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?: string): Promise { + const key = expiry + ? `yfinance:chain:${symbol}:${expiry}` + : `yfinance:expiry_dates:${symbol}`; + const result = await this.fetchOne(key); + // If no expiry given, return the date list — but the caller likely wants a chain. + if (typeof result.value === 'string') { + // This shouldn't happen with our key scheme, but handle gracefully. + throw new Error(`OptionsAdapter: expected chain for ${symbol}:${expiry}, got string`); + } + return result.value as OptionChain; + } +} + +// ----- Pure parse helpers (tested with recorded fixtures; no network) ----- + +/** + * Parse the raw option chain from yahoo-finance2 into typed OptionChainRow[]. + * yfinance2 returns `{ calls: [...], puts: [...] }` — we flatten and tag each row. + */ +export function parseOptionChain(symbol: string, raw: Record): OptionChain { + const calls = (raw.calls ?? []) as Array>; + const puts = (raw.puts ?? []) as Array>; + + const rows: OptionChainRow[] = []; + 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; + }); + + // Derive expiration from the first row (all rows in a chain share it). + 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>; +}