Files
options-pricer/backend/src/lib/datafetch.ts
ojy 3ea5fd5209 Scanner: spike = current ATM IV >= +30% above recent baseline
Replaces the IV/HV >= 1.5 heuristic with a baseline-relative jump:
a symbol is flagged SPIKE when current ATM IV is at least 30%
above its recent baseline (e.g., 10% -> 13%+). Baseline prefers
the 30-day average of saved snapshots; falls back to HV30 when
no scan history exists. Each scan persists a snapshot so the
baseline self-improves over time. BIG MOVE (|chg%| >= 3%) is now
shown as a separate badge instead of a spike requirement.

- snapshots.ts: add getAverageAtmIv(symbol, days)
- datafetch.ts: save snapshot per scan; compute baselineIv,
  baselineSrc, baselineN, ivJumpPct; spike from jump threshold
- options.ts: /api/scan returns new baseline + jump fields
- scanner.html: header copy, table columns (Baseline IV, IV Δ%),
  default sort by ivJumpPct desc, separate SPIKE/BIG MOVE badges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 07:39:24 +00:00

405 lines
13 KiB
TypeScript

import YahooFinance from "yahoo-finance2";
import type { OptionsResult, CallOrPut } from "yahoo-finance2/modules/options";
import { bsPrice, bsGreeks, impliedVol } from "./blackscholes.js";
import type { OptionQuote, ChainSnapshot } from "./analytics.js";
import { computeSkewMetrics } from "./analytics.js";
import { saveSnapshot, getAverageAtmIv } from "../db/snapshots.js";
import {
fmpEnabled,
fmpExpirations,
fmpOptionsChain,
fmpQuote,
} from "./fmp.js";
/** Spike threshold — current ATM IV must be at least this much above the baseline. */
const SPIKE_JUMP_PCT = 0.30; // +30%
const SPIKE_BIG_MOVE_PCT = 3; // |today Δ| ≥ 3%
const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] });
const RISK_FREE_RATE = 0.05;
/**
* Compute 30-day annualized realized volatility from daily closing prices.
* Used as the ATM IV baseline when options markets are closed / bid-ask are stale.
*/
async function fetchHistoricalVol(symbol: string): Promise<number> {
try {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 45); // fetch 45 days to ensure 30 trading days
const rows = await yf.historical(symbol, {
period1: start,
period2: end,
interval: "1d",
});
const closes = rows
.map((r) => r.adjClose ?? r.close)
.filter((v): v is number => v != null && v > 0);
if (closes.length < 5) return 0;
const logReturns: number[] = [];
for (let i = 1; i < closes.length; i++) {
logReturns.push(Math.log(closes[i] / closes[i - 1]));
}
const mean = logReturns.reduce((a, b) => a + b, 0) / logReturns.length;
const variance =
logReturns.reduce((sum, r) => sum + (r - mean) ** 2, 0) /
(logReturns.length - 1);
return Math.sqrt(variance * 252); // annualize
} catch {
return 0;
}
}
function timeToExpiry(expiryDateStr: string): number {
const daysRemaining = (new Date(expiryDateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return Math.max(daysRemaining, 0) / 365;
}
function toExpiryString(val: Date | number | string): string {
if (typeof val === "string") return val;
const d = val instanceof Date ? val : new Date((val as number) * 1000);
return d.toISOString().split("T")[0];
}
function enrichOption(
raw: CallOrPut,
type: "call" | "put",
expiry: string,
spot: number
): OptionQuote | null {
const strike = raw.strike ?? 0;
const bid = raw.bid ?? 0;
const ask = raw.ask ?? 0;
const lastPrice = parseFloat(String(raw.lastPrice ?? 0));
const volume = raw.volume ?? 0;
const openInterest = raw.openInterest ?? 0;
if (strike <= 0) return null;
// During market hours: use mid/ask. After hours: fall back to lastPrice (last traded price).
const marketPrice =
bid > 0 && ask > 0 ? (bid + ask) / 2 :
ask > 0 ? ask :
lastPrice;
if (marketPrice <= 0) return null;
const midPrice = ask > 0 ? (bid > 0 ? (bid + ask) / 2 : ask) : lastPrice;
const T = timeToExpiry(expiry);
const r = RISK_FREE_RATE;
let iv: number;
if (marketPrice > 0 && T > 0 && spot > 0) {
const calculatedIV = impliedVol(spot, strike, T, r, marketPrice, type) ?? null;
if (calculatedIV !== null && calculatedIV > 0.01 && calculatedIV < 5 && isFinite(calculatedIV)) {
iv = calculatedIV;
} else {
// Newton-Raphson failed — try Yahoo's per-option IV as fallback, but only if plausible
const yahooIV = raw.impliedVolatility;
if (yahooIV > 0.01 && yahooIV < 5 && isFinite(yahooIV)) {
iv = yahooIV;
} else {
return null;
}
}
} else {
return null;
}
const theoreticalPrice = T > 0 && spot > 0 ? bsPrice(spot, strike, T, r, iv, type) : 0;
const greeks =
T > 0 && spot > 0
? bsGreeks(spot, strike, T, r, iv, type)
: { delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0 };
return {
strike,
expiry,
type,
bid,
ask,
iv,
delta: greeks.delta,
gamma: greeks.gamma,
theta: greeks.theta,
vega: greeks.vega,
volume,
openInterest,
bsPrice: theoreticalPrice,
midPrice,
};
}
// ---------------------------------------------------------------------------
// FMP path — converts FmpOption[] into ChainSnapshot
// ---------------------------------------------------------------------------
async function fetchViaFmp(symbol: string, expiry: string): Promise<ChainSnapshot | null> {
try {
const [fmpOptions, quote] = await Promise.all([
fmpOptionsChain(symbol, expiry),
fmpQuote(symbol),
]);
if (fmpOptions.length === 0) return null;
const spot = quote?.price ?? 0;
const T = timeToExpiry(expiry);
const r = RISK_FREE_RATE;
const chain: OptionQuote[] = fmpOptions.map((o) => {
const theoreticalPrice =
T > 0 && spot > 0
? bsPrice(spot, o.strike, T, r, o.impliedVolatility, o.type)
: 0;
return {
strike: o.strike,
expiry,
type: o.type,
bid: o.bid,
ask: o.ask,
iv: o.impliedVolatility,
delta: o.delta,
gamma: o.gamma,
theta: o.theta,
vega: o.vega,
volume: o.volume,
openInterest: o.openInterest,
bsPrice: theoreticalPrice,
midPrice: o.mid,
};
});
const spotIv = quote?.impliedVolatility ?? 0;
return {
symbol,
expiry,
spot,
spotIv,
timestamp: new Date().toISOString(),
chain,
};
} catch (err) {
console.warn(`[datafetch] FMP failed for ${symbol} ${expiry}:`, (err as Error).message);
return null;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function fetchExpirations(symbol: string): Promise<string[]> {
if (fmpEnabled()) {
try {
const dates = await fmpExpirations(symbol);
if (dates.length > 0) {
console.log(`[datafetch] FMP expirations for ${symbol}: ${dates.length} dates`);
return dates;
}
} catch (err) {
console.warn(`[datafetch] FMP expirations failed, falling back to Yahoo:`, (err as Error).message);
}
}
const result: OptionsResult = await yf.options(symbol);
const dates = result.expirationDates ?? [];
return dates.map((d) => toExpiryString(d)).sort();
}
export async function fetchOptionsChain(
symbol: string,
expiry?: string
): Promise<ChainSnapshot[]> {
// --- Determine which expiries to fetch ---
let expiriesToFetch: string[];
if (expiry) {
expiriesToFetch = [expiry];
} else {
const all = await fetchExpirations(symbol);
const now = Date.now();
expiriesToFetch = all.filter((e) => new Date(e).getTime() > now).slice(0, 3);
}
if (expiriesToFetch.length === 0) {
throw new Error(`No valid expiration dates found for ${symbol}`);
}
// --- FMP path ---
if (fmpEnabled()) {
const snapshots: ChainSnapshot[] = [];
for (const exp of expiriesToFetch) {
const snap = await fetchViaFmp(symbol, exp);
if (snap) {
snapshots.push(snap);
} else {
console.warn(`[datafetch] FMP returned no data for ${symbol} ${exp}`);
}
}
if (snapshots.length > 0) {
console.log(`[datafetch] FMP: fetched ${snapshots.length} snapshots for ${symbol}`);
return snapshots;
}
console.warn(`[datafetch] FMP returned nothing for ${symbol}, falling back to Yahoo`);
}
// --- Yahoo Finance fallback ---
const historicalVol = await fetchHistoricalVol(symbol);
const snapshots: ChainSnapshot[] = [];
for (const expiryDate of expiriesToFetch) {
try {
const result: OptionsResult = await yf.options(symbol, {
date: new Date(expiryDate),
});
const spot: number = result.quote?.regularMarketPrice ?? 0;
const optionExpiry = result.options?.[0];
const rawCalls: CallOrPut[] = optionExpiry?.calls ?? [];
const rawPuts: CallOrPut[] = optionExpiry?.puts ?? [];
const chain: OptionQuote[] = [
...rawCalls.map((r) => enrichOption(r, "call", expiryDate, spot)),
...rawPuts.map((r) => enrichOption(r, "put", expiryDate, spot)),
]
.filter((q): q is OptionQuote => q !== null)
.sort((a, b) => a.strike - b.strike);
snapshots.push({ symbol, expiry: expiryDate, spot, spotIv: historicalVol, timestamp: new Date().toISOString(), chain });
} catch (err) {
console.error(`[datafetch] Failed for ${symbol} expiry ${expiryDate}: ${err instanceof Error ? err.message : err}`);
}
}
if (snapshots.length === 0) {
throw new Error(`Failed to fetch any options data for ${symbol}`);
}
return snapshots;
}
// ---------------------------------------------------------------------------
// Lightweight per-symbol scan — for the IV-spike scanner
// ---------------------------------------------------------------------------
export type ScanResult = {
symbol: string;
spot: number;
change: number;
changePct: number;
atmIv: number;
hv30: number;
baselineIv: number; // recent-30d avg ATM IV from snapshots, else HV30
baselineSrc: "history" | "hv30" | "none";
baselineN: number; // # of snapshot rows feeding the baseline (0 = HV30 fallback)
ivJumpPct: number; // (atmIv - baselineIv) / baselineIv
ivHv: number; // atmIv / hv30 (informational; pre-baseline metric)
spike: boolean; // ivJumpPct ≥ 30%
bigMove: boolean; // |today Δ| ≥ 3%
expiry: string;
error?: string;
};
/** Fetch just the front-expiry chain + HV30 for a single symbol — cheap enough to parallelize. */
export async function scanSymbol(symbol: string): Promise<ScanResult> {
// 1) list of expirations
const exps: string[] = await fetchExpirations(symbol);
const now = Date.now();
const upcoming = exps.filter((e) => new Date(e).getTime() > now);
if (upcoming.length === 0) throw new Error("No upcoming expirations");
const expiry = upcoming[0];
// 2) the front-expiry chain + spot
let spot = 0, prevClose = 0;
let chain: OptionQuote[] = [];
if (fmpEnabled()) {
try {
const [fmpChain, q] = await Promise.all([fmpOptionsChain(symbol, expiry), fmpQuote(symbol)]);
if (q?.price) spot = q.price;
const T = Math.max(0, (new Date(expiry).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) / 365;
chain = fmpChain.map((o) => ({
strike: o.strike, expiry, type: o.type,
bid: o.bid, ask: o.ask,
iv: o.impliedVolatility, delta: o.delta, gamma: o.gamma, theta: o.theta, vega: o.vega,
volume: o.volume, openInterest: o.openInterest,
bsPrice: spot > 0 ? bsPrice(spot, o.strike, T, RISK_FREE_RATE, o.impliedVolatility, o.type) : 0,
midPrice: o.mid,
}));
} catch { /* fall through to yahoo */ }
}
if (chain.length === 0) {
const result: OptionsResult = await yf.options(symbol, { date: new Date(expiry) });
spot = result.quote?.regularMarketPrice ?? 0;
prevClose = result.quote?.regularMarketPreviousClose ?? spot;
const optExp = result.options?.[0];
const rawCalls = optExp?.calls ?? [];
const rawPuts = optExp?.puts ?? [];
chain = [
...rawCalls.map((r) => enrichOption(r, "call", expiry, spot)),
...rawPuts.map((r) => enrichOption(r, "put", expiry, spot)),
].filter((q): q is OptionQuote => q !== null);
} else if (!prevClose) {
prevClose = spot;
}
const change = spot - prevClose;
const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0;
// ATM IV — closest-strike option's IV (pick the lower-IV side to avoid wing inflation)
let atmIv = 0;
if (chain.length > 0 && spot > 0) {
const closest = chain.reduce((b, q) =>
Math.abs(q.strike - spot) < Math.abs(b.strike - spot) ? q : b
);
const sameStrike = chain.filter((q) => q.strike === closest.strike && q.iv > 0);
atmIv = sameStrike.length > 1
? sameStrike.reduce((s, q) => s + q.iv, 0) / sameStrike.length
: (closest.iv || 0);
}
const hv30 = await fetchHistoricalVol(symbol);
const ivHv = hv30 > 0 ? atmIv / hv30 : 0;
// Save this scan as a snapshot so the baseline grows over time.
// Use the snapshot's chain so per-strike IV is recorded too.
try {
if (atmIv > 0 && spot > 0) {
const snap = {
symbol, expiry, spot, spotIv: hv30,
timestamp: new Date().toISOString(),
chain,
};
saveSnapshot(symbol, expiry, spot, computeSkewMetrics(snap), chain);
}
} catch { /* non-fatal */ }
// Baseline IV: prefer 30-day avg from snapshots; fall back to HV30.
const hist = getAverageAtmIv(symbol, 30);
let baselineIv = 0, baselineSrc: ScanResult["baselineSrc"] = "none", baselineN = 0;
if (hist && hist.avg > 0) { baselineIv = hist.avg; baselineSrc = "history"; baselineN = hist.n; }
else if (hv30 > 0) { baselineIv = hv30; baselineSrc = "hv30"; baselineN = 0; }
const ivJumpPct = baselineIv > 0 ? (atmIv - baselineIv) / baselineIv : 0;
const spike = ivJumpPct >= SPIKE_JUMP_PCT;
const bigMove = Math.abs(changePct) >= SPIKE_BIG_MOVE_PCT;
return {
symbol, spot, change, changePct, atmIv, hv30,
baselineIv, baselineSrc, baselineN,
ivJumpPct, ivHv, spike, bigMove, expiry,
};
}