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:
@@ -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>>;
|
||||
}
|
||||
Reference in New Issue
Block a user