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.
This commit is contained in:
Investor Flow Build
2026-06-30 13:03:30 -04:00
parent e1e028b418
commit cfc762e952
+248
View File
@@ -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:<SYMBOL>`.
// - options_chain(symbol, expiry): medium-lived (15 min) — matches the existing
// `options_snapshot` TtlClass in CacheRepository. Key: `options:chain:<SYMBOL>:<EXPIRY>`.
// 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:<SYMBOL>" → list of available expiry dates
* - "chain:<SYMBOL>:<EXPIRY>" → 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<YFinanceLike> {
if (!this._yf) {
const mod = await import('yahoo-finance2');
this._yf = new mod.default();
}
return this._yf as YFinanceLike;
}
async fetchOne(key: CacheKey): Promise<FetchResult> {
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<OptionExpiryDate[]> {
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<OptionChain> {
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<string, unknown>): OptionChain {
const calls = (raw.calls ?? []) as Array<Record<string, unknown>>;
const puts = (raw.puts ?? []) as Array<Record<string, unknown>>;
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<string, unknown>): 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<string, unknown>): '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 "SYMDATE<IV>RIGHT"
// 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<string, unknown>): 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<string[]>;
options(symbol: string, expiry?: string): Promise<Record<string, unknown>>;
}