Files
options-pricer/backend/src/lib/datafetch.ts

285 lines
8.2 KiB
TypeScript
Raw Normal View History

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 {
fmpEnabled,
fmpExpirations,
fmpOptionsChain,
fmpQuote,
} from "./fmp.js";
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;
}