- Add await ctx.cache.subscribe() to addSymbol mutation so symbols added via the sidebar get registered in symbol_demand and yfinance jobs are queued immediately - Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue - Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false to quoteSummary() to handle Yahoo schema drift - Add error detail logging for analyst ratings schema failures - Update .gitignore with common ignores
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
// 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') {
|
||
// 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<string, unknown> | null = null;
|
||
if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) {
|
||
const optObj = rawResult.options as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
} else {
|
||
expiryData = optObj;
|
||
}
|
||
} else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) {
|
||
// Flat format: { calls: [...], puts: [...] }
|
||
expiryData = rawResult as Record<string, unknown>;
|
||
}
|
||
const allRows = [
|
||
...((expiryData?.calls as Array<Record<string, unknown>> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })),
|
||
...((expiryData?.puts as Array<Record<string, unknown>> | 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<string, unknown> | null = null;
|
||
if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) {
|
||
const optObj = rawResult.options as Record<string, unknown>;
|
||
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<string, unknown>;
|
||
} else {
|
||
expiryData = optObj;
|
||
}
|
||
} else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) {
|
||
expiryData = rawResult as Record<string, unknown>;
|
||
}
|
||
const allRows = [
|
||
...((expiryData?.calls as Array<Record<string, unknown>> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })),
|
||
...((expiryData?.puts as Array<Record<string, unknown>> | 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<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: OptionExpiryDate): Promise<OptionChain> {
|
||
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<string, unknown> | Array<Record<string, unknown>>): 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<Record<string, unknown>>;
|
||
const puts = (raw.puts ?? []) as Array<Record<string, unknown>>;
|
||
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<string, unknown>): 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<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>>;
|
||
}
|