Initial commit — options pricing dashboard
Full-stack options analytics app: IV surface, Greeks, skew metrics, vol term structure. Yahoo Finance data with Black-Scholes IV computation and historical vol fallback for after-hours data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
284
backend/src/lib/datafetch.ts
Normal file
284
backend/src/lib/datafetch.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user