Files
investor-flow/app/server/src/adapters/OptionsAdapter.ts
T
Investor Flow Build e262187c3c fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix
- 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
2026-07-23 18:02:24 -04:00

316 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>>;
}