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:
1380
backend/package-lock.json
generated
Normal file
1380
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/package.json
Normal file
22
backend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "options-pricer-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --import tsx/esm --watch src/server.ts",
|
||||
"start": "node --import tsx/esm src/server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"hono": "^4.6.0",
|
||||
"yahoo-finance2": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
99
backend/src/db/snapshots.ts
Normal file
99
backend/src/db/snapshots.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdirSync, existsSync } from "fs";
|
||||
import type { OptionQuote, SkewMetrics } from "../lib/analytics.js";
|
||||
|
||||
const DB_DIR = "./data";
|
||||
const DB_PATH = `${DB_DIR}/snapshots.db`;
|
||||
|
||||
if (!existsSync(DB_DIR)) {
|
||||
mkdirSync(DB_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
expiry TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
spot REAL,
|
||||
atm_iv REAL,
|
||||
rr25 REAL,
|
||||
rr10 REAL,
|
||||
fly25 REAL,
|
||||
chain_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_symbol_time
|
||||
ON snapshots(symbol, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_symbol_expiry_time
|
||||
ON snapshots(symbol, expiry, timestamp);
|
||||
`);
|
||||
|
||||
export type SnapshotRow = {
|
||||
id: number;
|
||||
symbol: string;
|
||||
expiry: string;
|
||||
timestamp: string;
|
||||
spot: number | null;
|
||||
atm_iv: number | null;
|
||||
rr25: number | null;
|
||||
rr10: number | null;
|
||||
fly25: number | null;
|
||||
chain_json: string | null;
|
||||
};
|
||||
|
||||
const stmtInsert = db.prepare(`
|
||||
INSERT INTO snapshots
|
||||
(symbol, expiry, timestamp, spot, atm_iv, rr25, rr10, fly25, chain_json)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const stmtGetBySymbol = db.prepare(`
|
||||
SELECT * FROM snapshots
|
||||
WHERE symbol = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const stmtGetLatest = db.prepare(`
|
||||
SELECT * FROM snapshots
|
||||
WHERE symbol = ? AND expiry = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
export function saveSnapshot(
|
||||
symbol: string,
|
||||
expiry: string,
|
||||
spot: number,
|
||||
metrics: SkewMetrics,
|
||||
chain: OptionQuote[]
|
||||
): void {
|
||||
stmtInsert.run(
|
||||
symbol,
|
||||
expiry,
|
||||
new Date().toISOString(),
|
||||
spot,
|
||||
metrics.atmIv ?? null,
|
||||
metrics.rr25 ?? null,
|
||||
metrics.rr10 ?? null,
|
||||
metrics.fly25 ?? null,
|
||||
JSON.stringify(chain)
|
||||
);
|
||||
}
|
||||
|
||||
export function getSnapshots(symbol: string, limit = 100): SnapshotRow[] {
|
||||
return stmtGetBySymbol.all(symbol, limit) as SnapshotRow[];
|
||||
}
|
||||
|
||||
export function getLatestSnapshot(symbol: string, expiry: string): SnapshotRow | null {
|
||||
return (stmtGetLatest.get(symbol, expiry) as SnapshotRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
return db;
|
||||
}
|
||||
270
backend/src/lib/analytics.ts
Normal file
270
backend/src/lib/analytics.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Options chain analytics: ATM IV, risk reversals, butterflies, vol surface.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OptionQuote = {
|
||||
strike: number;
|
||||
expiry: string;
|
||||
type: "call" | "put";
|
||||
bid: number;
|
||||
ask: number;
|
||||
iv: number; // implied volatility (from yahoo or calculated)
|
||||
delta: number;
|
||||
gamma: number;
|
||||
theta: number;
|
||||
vega: number;
|
||||
volume: number;
|
||||
openInterest: number;
|
||||
bsPrice: number; // our own BS theoretical price
|
||||
midPrice: number; // (bid + ask) / 2
|
||||
};
|
||||
|
||||
export type ChainSnapshot = {
|
||||
symbol: string;
|
||||
expiry: string;
|
||||
spot: number;
|
||||
spotIv: number; // stock-level 30-day IV from Yahoo (quote.impliedVolatility)
|
||||
timestamp: string;
|
||||
chain: OptionQuote[];
|
||||
};
|
||||
|
||||
export type VolSurface = {
|
||||
expiries: string[];
|
||||
strikes: number[];
|
||||
matrix: Record<string, Record<number, number>>; // expiry → strike → IV
|
||||
};
|
||||
|
||||
export type SkewMetrics = {
|
||||
expiry: string;
|
||||
atmIv: number;
|
||||
rr25: number;
|
||||
rr10: number;
|
||||
fly25: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filter a chain to a single option type with non-zero liquidity.
|
||||
*/
|
||||
function filterByType(
|
||||
chain: OptionQuote[],
|
||||
type: "call" | "put"
|
||||
): OptionQuote[] {
|
||||
return chain.filter((q) => q.type === type && q.iv > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the option in the list whose strike is closest to the target.
|
||||
*/
|
||||
function closestByStrike(
|
||||
quotes: OptionQuote[],
|
||||
targetStrike: number
|
||||
): OptionQuote | null {
|
||||
if (quotes.length === 0) return null;
|
||||
return quotes.reduce((best, q) =>
|
||||
Math.abs(q.strike - targetStrike) < Math.abs(best.strike - targetStrike)
|
||||
? q
|
||||
: best
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported analytics functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the ATM (at-the-money) strike closest to the spot price.
|
||||
* Uses calls preferentially; falls back to all strikes if no calls.
|
||||
*/
|
||||
export function findATMStrike(chain: OptionQuote[], spot: number): number {
|
||||
const calls = filterByType(chain, "call");
|
||||
const pool = calls.length > 0 ? calls : chain;
|
||||
if (pool.length === 0) return spot;
|
||||
|
||||
const atm = closestByStrike(pool, spot);
|
||||
return atm ? atm.strike : spot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the ATM implied vol for the given option type.
|
||||
* Looks up the option at the ATM strike.
|
||||
*/
|
||||
export function getATMIV(
|
||||
chain: OptionQuote[],
|
||||
spot: number,
|
||||
type: "call" | "put"
|
||||
): number {
|
||||
const filtered = filterByType(chain, type);
|
||||
const atm = closestByStrike(filtered, spot);
|
||||
return atm ? atm.iv : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the option in the chain closest to the target delta.
|
||||
*
|
||||
* For calls, delta is positive [0, 1].
|
||||
* For puts, targetDelta should be expressed as a negative value (e.g. -0.25 for 25-delta put),
|
||||
* or as an absolute value — the function handles both conventions by comparing |delta|.
|
||||
*/
|
||||
export function findDeltaStrike(
|
||||
chain: OptionQuote[],
|
||||
targetDelta: number,
|
||||
type: "call" | "put"
|
||||
): OptionQuote | null {
|
||||
const filtered = filterByType(chain, type);
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
const absDelta = Math.abs(targetDelta);
|
||||
|
||||
return filtered.reduce((best, q) => {
|
||||
const qDiff = Math.abs(Math.abs(q.delta) - absDelta);
|
||||
const bestDiff = Math.abs(Math.abs(best.delta) - absDelta);
|
||||
return qDiff < bestDiff ? q : best;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk Reversal = IV(call @ delta) - IV(put @ |delta|)
|
||||
*
|
||||
* A positive risk reversal means calls are more expensive than puts (bullish skew).
|
||||
*
|
||||
* @param chain - Full options chain (calls + puts) for a single expiry
|
||||
* @param expiry - Expiry string (used for filtering; chain may be pre-filtered)
|
||||
* @param targetDelta - Delta level, e.g. 0.25 for 25-delta RR
|
||||
*/
|
||||
export function calcRiskReversal(
|
||||
chain: OptionQuote[],
|
||||
expiry: string,
|
||||
targetDelta: number
|
||||
): number {
|
||||
const expiryChain = chain.filter((q) => q.expiry === expiry);
|
||||
|
||||
const callOpt = findDeltaStrike(expiryChain, targetDelta, "call");
|
||||
const putOpt = findDeltaStrike(expiryChain, Math.abs(targetDelta), "put");
|
||||
|
||||
if (!callOpt || !putOpt) return 0;
|
||||
|
||||
return callOpt.iv - putOpt.iv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Butterfly spread metric = 0.5 * (IV_25d_call + IV_25d_put) - IV_ATM
|
||||
*
|
||||
* Measures the curvature (kurtosis) of the vol smile.
|
||||
* A positive butterfly means wings are more expensive than the ATM (vol smile).
|
||||
*
|
||||
* @param chain - Full options chain for a single expiry (call + put)
|
||||
* @param expiry - Target expiry string
|
||||
*/
|
||||
export function calcButterfly(chain: OptionQuote[], expiry: string): number {
|
||||
const expiryChain = chain.filter((q) => q.expiry === expiry);
|
||||
|
||||
// Get spot from the chain (use the midpoint strike as proxy if no spot available)
|
||||
// We use the ATM call and put average for the ATM IV
|
||||
const calls = filterByType(expiryChain, "call");
|
||||
const puts = filterByType(expiryChain, "put");
|
||||
|
||||
if (calls.length === 0 || puts.length === 0) return 0;
|
||||
|
||||
// Approximate spot from the chain: use the midpoint of strike range
|
||||
const allStrikes = expiryChain.map((q) => q.strike);
|
||||
const spotProxy = (Math.max(...allStrikes) + Math.min(...allStrikes)) / 2;
|
||||
|
||||
// ATM IV: average of ATM call and put IV
|
||||
const atmCall = closestByStrike(calls, spotProxy);
|
||||
const atmPut = closestByStrike(puts, spotProxy);
|
||||
if (!atmCall || !atmPut) return 0;
|
||||
const atmIv = (atmCall.iv + atmPut.iv) / 2;
|
||||
|
||||
// 25-delta wings
|
||||
const call25 = findDeltaStrike(expiryChain, 0.25, "call");
|
||||
const put25 = findDeltaStrike(expiryChain, 0.25, "put");
|
||||
if (!call25 || !put25) return 0;
|
||||
|
||||
return 0.5 * (call25.iv + put25.iv) - atmIv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a vol surface from an array of chain snapshots.
|
||||
*
|
||||
* The surface is organized as: expiry → strike → IV.
|
||||
* Only liquid options (non-zero IV, bid, ask) are included.
|
||||
* IV values are averaged if both call and put exist at the same strike.
|
||||
*/
|
||||
export function buildVolSurface(snapshots: ChainSnapshot[]): VolSurface {
|
||||
// Use a map to accumulate IVs per (expiry, strike)
|
||||
const ivAccum: Record<string, Record<number, { sum: number; count: number }>> = {};
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
const expiry = snapshot.expiry;
|
||||
if (!ivAccum[expiry]) {
|
||||
ivAccum[expiry] = {};
|
||||
}
|
||||
|
||||
for (const quote of snapshot.chain) {
|
||||
if (quote.iv <= 0) continue;
|
||||
|
||||
const { strike, iv } = quote;
|
||||
if (!ivAccum[expiry][strike]) {
|
||||
ivAccum[expiry][strike] = { sum: 0, count: 0 };
|
||||
}
|
||||
ivAccum[expiry][strike].sum += iv;
|
||||
ivAccum[expiry][strike].count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort expiries and strikes
|
||||
const expiries = Object.keys(ivAccum).sort();
|
||||
|
||||
const strikeSet = new Set<number>();
|
||||
for (const expiry of expiries) {
|
||||
for (const strikeStr of Object.keys(ivAccum[expiry])) {
|
||||
strikeSet.add(Number(strikeStr));
|
||||
}
|
||||
}
|
||||
const strikes = Array.from(strikeSet).sort((a, b) => a - b);
|
||||
|
||||
// Build the matrix
|
||||
const matrix: Record<string, Record<number, number>> = {};
|
||||
for (const expiry of expiries) {
|
||||
matrix[expiry] = {};
|
||||
for (const strike of strikes) {
|
||||
const entry = ivAccum[expiry][strike];
|
||||
if (entry && entry.count > 0) {
|
||||
matrix[expiry][strike] = entry.sum / entry.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { expiries, strikes, matrix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a full set of skew metrics for a given expiry from a snapshot.
|
||||
* Returns SkewMetrics with ATM IV, 25-delta and 10-delta risk reversals, and 25-delta butterfly.
|
||||
*/
|
||||
export function computeSkewMetrics(
|
||||
snapshot: ChainSnapshot
|
||||
): SkewMetrics {
|
||||
const { expiry, chain, spot, spotIv } = snapshot;
|
||||
|
||||
// Prefer per-option IV computed from real bid/ask; fall back to stock-level 30-day IV
|
||||
const atmIv =
|
||||
getATMIV(chain, spot, "call") ||
|
||||
getATMIV(chain, spot, "put") ||
|
||||
spotIv ||
|
||||
0;
|
||||
|
||||
const rr25 = calcRiskReversal(chain, expiry, 0.25);
|
||||
const rr10 = calcRiskReversal(chain, expiry, 0.10);
|
||||
const fly25 = calcButterfly(chain, expiry);
|
||||
|
||||
return { expiry, atmIv, rr25, rr10, fly25 };
|
||||
}
|
||||
305
backend/src/lib/blackscholes.ts
Normal file
305
backend/src/lib/blackscholes.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Black-Scholes pricing, Greeks, and Implied Volatility
|
||||
* Implemented from scratch — no external math libraries.
|
||||
*
|
||||
* Abramowitz & Stegun 26.2.17 approximation for normalCDF,
|
||||
* max error 7.5e-8.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard normal CDF using Horner's method (A&S 26.2.17).
|
||||
* Accurate to within 7.5e-8.
|
||||
*/
|
||||
export function normalCDF(x: number): number {
|
||||
// Use symmetry: CDF(-x) = 1 - CDF(x)
|
||||
const sign = x >= 0 ? 1 : -1;
|
||||
const absX = Math.abs(x);
|
||||
|
||||
// A&S constants for polynomial approximation
|
||||
const a1 = 0.319381530;
|
||||
const a2 = -0.356563782;
|
||||
const a3 = 1.781477937;
|
||||
const a4 = -1.821255978;
|
||||
const a5 = 1.330274429;
|
||||
const p = 0.2316419;
|
||||
|
||||
const t = 1.0 / (1.0 + p * absX);
|
||||
|
||||
// Horner's method: ((((a5*t + a4)*t + a3)*t + a2)*t + a1)*t
|
||||
const poly = t * (a1 + t * (a2 + t * (a3 + t * (a4 + t * a5))));
|
||||
const pdf = normalPDF(absX);
|
||||
const approx = 1.0 - pdf * poly;
|
||||
|
||||
// For negative x, CDF(x) = 1 - CDF(-x)
|
||||
if (sign === 1) {
|
||||
return approx;
|
||||
} else {
|
||||
return 1.0 - approx;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard normal PDF.
|
||||
*/
|
||||
export function normalPDF(x: number): number {
|
||||
return Math.exp(-0.5 * x * x) / Math.sqrt(2.0 * Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute d1 and d2 for Black-Scholes.
|
||||
*/
|
||||
function computeD1D2(
|
||||
S: number,
|
||||
K: number,
|
||||
T: number,
|
||||
r: number,
|
||||
sigma: number
|
||||
): { d1: number; d2: number } {
|
||||
const sqrtT = Math.sqrt(T);
|
||||
const d1 =
|
||||
(Math.log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrtT);
|
||||
const d2 = d1 - sigma * sqrtT;
|
||||
return { d1, d2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes theoretical price.
|
||||
*
|
||||
* @param S - Current spot price
|
||||
* @param K - Strike price
|
||||
* @param T - Time to expiry in years
|
||||
* @param r - Risk-free rate (e.g. 0.05 for 5%)
|
||||
* @param sigma - Implied / historical vol (e.g. 0.20 for 20%)
|
||||
* @param type - 'call' or 'put'
|
||||
* @returns Theoretical option price
|
||||
*/
|
||||
export function bsPrice(
|
||||
S: number,
|
||||
K: number,
|
||||
T: number,
|
||||
r: number,
|
||||
sigma: number,
|
||||
type: "call" | "put"
|
||||
): number {
|
||||
// Edge case: expired option
|
||||
if (T <= 0) {
|
||||
if (type === "call") return Math.max(S - K, 0);
|
||||
return Math.max(K - S, 0);
|
||||
}
|
||||
|
||||
const { d1, d2 } = computeD1D2(S, K, T, r, sigma);
|
||||
const discountFactor = Math.exp(-r * T);
|
||||
|
||||
if (type === "call") {
|
||||
return S * normalCDF(d1) - K * discountFactor * normalCDF(d2);
|
||||
} else {
|
||||
return K * discountFactor * normalCDF(-d2) - S * normalCDF(-d1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Black-Scholes Greeks.
|
||||
*
|
||||
* Theta is returned per calendar day (divided by 365).
|
||||
* Vega is returned per 1% move in implied vol (divided by 100).
|
||||
*
|
||||
* @param S - Current spot price
|
||||
* @param K - Strike price
|
||||
* @param T - Time to expiry in years
|
||||
* @param r - Risk-free rate
|
||||
* @param sigma - Implied vol
|
||||
* @param type - 'call' or 'put'
|
||||
* @returns Object with { delta, gamma, theta, vega, rho }
|
||||
*/
|
||||
export function bsGreeks(
|
||||
S: number,
|
||||
K: number,
|
||||
T: number,
|
||||
r: number,
|
||||
sigma: number,
|
||||
type: "call" | "put"
|
||||
): { delta: number; gamma: number; theta: number; vega: number; rho: number } {
|
||||
// Edge case: expired option
|
||||
if (T <= 0) {
|
||||
const intrinsic = type === "call" ? S - K : K - S;
|
||||
const itm = intrinsic > 0;
|
||||
return {
|
||||
delta: type === "call" ? (itm ? 1 : 0) : (itm ? -1 : 0),
|
||||
gamma: 0,
|
||||
theta: 0,
|
||||
vega: 0,
|
||||
rho: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const { d1, d2 } = computeD1D2(S, K, T, r, sigma);
|
||||
const sqrtT = Math.sqrt(T);
|
||||
const nd1 = normalPDF(d1);
|
||||
const discountFactor = Math.exp(-r * T);
|
||||
|
||||
// Delta
|
||||
let delta: number;
|
||||
if (type === "call") {
|
||||
delta = normalCDF(d1);
|
||||
} else {
|
||||
delta = normalCDF(d1) - 1; // equivalently: -normalCDF(-d1)
|
||||
}
|
||||
|
||||
// Gamma (same for call and put)
|
||||
const gamma = nd1 / (S * sigma * sqrtT);
|
||||
|
||||
// Theta (per year) — we divide by 365 for per-day
|
||||
let thetaAnnual: number;
|
||||
if (type === "call") {
|
||||
thetaAnnual =
|
||||
-(S * nd1 * sigma) / (2 * sqrtT) -
|
||||
r * K * discountFactor * normalCDF(d2);
|
||||
} else {
|
||||
thetaAnnual =
|
||||
-(S * nd1 * sigma) / (2 * sqrtT) +
|
||||
r * K * discountFactor * normalCDF(-d2);
|
||||
}
|
||||
const theta = thetaAnnual / 365;
|
||||
|
||||
// Vega (per 1% vol move) — raw vega is per unit, divide by 100
|
||||
const vegaRaw = S * nd1 * sqrtT;
|
||||
const vega = vegaRaw / 100;
|
||||
|
||||
// Rho (per 1% rate move) — raw rho, divide by 100
|
||||
let rhoRaw: number;
|
||||
if (type === "call") {
|
||||
rhoRaw = K * T * discountFactor * normalCDF(d2);
|
||||
} else {
|
||||
rhoRaw = -K * T * discountFactor * normalCDF(-d2);
|
||||
}
|
||||
const rho = rhoRaw / 100;
|
||||
|
||||
return { delta, gamma, theta, vega, rho };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate implied volatility using Newton-Raphson with bisection fallback.
|
||||
*
|
||||
* First attempts Newton-Raphson (fast convergence near the solution).
|
||||
* Falls back to bisection over [0.0001, 5.0] if vega is too small or
|
||||
* Newton-Raphson diverges / oscillates.
|
||||
*
|
||||
* @param S - Spot price
|
||||
* @param K - Strike
|
||||
* @param T - Time to expiry in years
|
||||
* @param r - Risk-free rate
|
||||
* @param marketPrice - Observed market price (mid)
|
||||
* @param type - 'call' or 'put'
|
||||
* @param maxIter - Maximum iterations (default 100, NR uses 50)
|
||||
* @param tol - Convergence tolerance (default 1e-7)
|
||||
* @returns Implied volatility or null if no convergence
|
||||
*/
|
||||
export function impliedVol(
|
||||
S: number,
|
||||
K: number,
|
||||
T: number,
|
||||
r: number,
|
||||
marketPrice: number,
|
||||
type: "call" | "put",
|
||||
maxIter = 100,
|
||||
tol = 1e-7
|
||||
): number | null {
|
||||
// Basic sanity check
|
||||
if (T <= 0) return null;
|
||||
if (marketPrice <= 0) return null;
|
||||
|
||||
// Intrinsic value bounds check
|
||||
const intrinsic =
|
||||
type === "call" ? Math.max(S - K * Math.exp(-r * T), 0) : Math.max(K * Math.exp(-r * T) - S, 0);
|
||||
if (marketPrice < intrinsic - 1e-6) return null;
|
||||
|
||||
// --- Newton-Raphson phase (up to 50 iterations) ---
|
||||
const nrMaxIter = 50;
|
||||
// Initial guess using Brenner-Subrahmanyam approximation
|
||||
let sigma =
|
||||
Math.sqrt((2 * Math.PI) / T) * (marketPrice / S);
|
||||
// Clamp initial guess to reasonable bounds
|
||||
sigma = Math.max(0.01, Math.min(sigma, 5.0));
|
||||
|
||||
let useBisection = false;
|
||||
|
||||
for (let i = 0; i < nrMaxIter; i++) {
|
||||
const price = bsPrice(S, K, T, r, sigma, type);
|
||||
const diff = price - marketPrice;
|
||||
|
||||
if (Math.abs(diff) < tol) {
|
||||
return sigma;
|
||||
}
|
||||
|
||||
// Vega: raw vega (not per 1%) for NR step
|
||||
const { d1 } = computeD1D2(S, K, T, r, sigma);
|
||||
const vegaRaw = S * normalPDF(d1) * Math.sqrt(T);
|
||||
|
||||
// If vega is effectively zero, switch to bisection
|
||||
if (Math.abs(vegaRaw) < 1e-10) {
|
||||
useBisection = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const sigmaNext = sigma - diff / vegaRaw;
|
||||
|
||||
// If the step produces an out-of-range sigma, fall back to bisection
|
||||
if (sigmaNext <= 0 || sigmaNext > 10.0 || !isFinite(sigmaNext)) {
|
||||
useBisection = true;
|
||||
break;
|
||||
}
|
||||
|
||||
sigma = sigmaNext;
|
||||
}
|
||||
|
||||
// Check if NR converged
|
||||
if (!useBisection) {
|
||||
const price = bsPrice(S, K, T, r, sigma, type);
|
||||
if (Math.abs(price - marketPrice) < tol) {
|
||||
return sigma;
|
||||
}
|
||||
// NR stalled but not converged — try bisection
|
||||
useBisection = true;
|
||||
}
|
||||
|
||||
// --- Bisection fallback ---
|
||||
const bisectMaxIter = maxIter;
|
||||
let lo = 0.0001;
|
||||
let hi = 5.0;
|
||||
|
||||
// Check that the bracket is valid
|
||||
const priceLo = bsPrice(S, K, T, r, lo, type);
|
||||
const priceHi = bsPrice(S, K, T, r, hi, type);
|
||||
|
||||
if (
|
||||
(priceLo - marketPrice) * (priceHi - marketPrice) > 0
|
||||
) {
|
||||
// Market price out of model range — cannot bracket
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < bisectMaxIter; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const priceMid = bsPrice(S, K, T, r, mid, type);
|
||||
const diff = priceMid - marketPrice;
|
||||
|
||||
if (Math.abs(diff) < tol || (hi - lo) / 2 < tol) {
|
||||
return mid;
|
||||
}
|
||||
|
||||
if ((priceLo - marketPrice) * diff < 0) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
|
||||
// Final best estimate from bisection midpoint
|
||||
const finalSigma = (lo + hi) / 2;
|
||||
const finalPrice = bsPrice(S, K, T, r, finalSigma, type);
|
||||
if (Math.abs(finalPrice - marketPrice) < 1e-4) {
|
||||
return finalSigma;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
119
backend/src/lib/fmp.ts
Normal file
119
backend/src/lib/fmp.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Financial Modeling Prep (FMP) options data client.
|
||||
* Free tier: 250 calls/day. https://financialmodelingprep.com
|
||||
*
|
||||
* Provides real options chains with IV and Greeks already computed.
|
||||
*/
|
||||
|
||||
const BASE = "https://financialmodelingprep.com/api";
|
||||
|
||||
function key(): string {
|
||||
return process.env.FMP_API_KEY ?? "";
|
||||
}
|
||||
|
||||
export function fmpEnabled(): boolean {
|
||||
const k = key();
|
||||
return k.length > 0 && k !== "demo";
|
||||
}
|
||||
|
||||
async function fmpGet(path: string, params: Record<string, string> = {}): Promise<any> {
|
||||
const qs = new URLSearchParams({ ...params, apikey: key() }).toString();
|
||||
const res = await fetch(`${BASE}${path}?${qs}`);
|
||||
if (!res.ok) throw new Error(`FMP ${path} → ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
if (data?.["Error Message"]) throw new Error(`FMP: ${data["Error Message"]}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export type FmpOption = {
|
||||
symbol: string;
|
||||
expiration: string;
|
||||
type: "call" | "put";
|
||||
strike: number;
|
||||
bid: number;
|
||||
ask: number;
|
||||
mid: number;
|
||||
volume: number;
|
||||
openInterest: number;
|
||||
impliedVolatility: number;
|
||||
delta: number;
|
||||
gamma: number;
|
||||
theta: number;
|
||||
vega: number;
|
||||
lastPrice: number;
|
||||
};
|
||||
|
||||
export type FmpQuote = {
|
||||
symbol: string;
|
||||
price: number;
|
||||
impliedVolatility?: number;
|
||||
};
|
||||
|
||||
/** Get current stock quote (price + 30d IV if available). */
|
||||
export async function fmpQuote(symbol: string): Promise<FmpQuote | null> {
|
||||
try {
|
||||
const data = await fmpGet(`/v3/quote/${symbol}`);
|
||||
const q = Array.isArray(data) ? data[0] : null;
|
||||
if (!q) return null;
|
||||
return {
|
||||
symbol,
|
||||
price: q.price ?? 0,
|
||||
impliedVolatility: q.impliedVolatility ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[fmp] quote ${symbol}:`, (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all available option expiration dates for a symbol. */
|
||||
export async function fmpExpirations(symbol: string): Promise<string[]> {
|
||||
const data = await fmpGet(`/v4/options/${symbol}`);
|
||||
if (!Array.isArray(data) || data.length === 0) return [];
|
||||
const dates = [...new Set(data.map((o: any) => o.date ?? o.expiration).filter(Boolean))];
|
||||
return (dates as string[]).sort();
|
||||
}
|
||||
|
||||
/** Get full options chain for a symbol and specific expiry. */
|
||||
export async function fmpOptionsChain(
|
||||
symbol: string,
|
||||
expiry: string
|
||||
): Promise<FmpOption[]> {
|
||||
const data = await fmpGet(`/v4/options/${symbol}`, { expiration: expiry });
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data
|
||||
.map((o: any): FmpOption | null => {
|
||||
const strike = parseFloat(o.strike ?? o.strikePrice ?? "0");
|
||||
if (!strike || strike <= 0) return null;
|
||||
|
||||
const type = (o.type ?? o.putCall ?? "").toLowerCase();
|
||||
if (type !== "call" && type !== "put") return null;
|
||||
|
||||
const bid = parseFloat(o.bid ?? "0");
|
||||
const ask = parseFloat(o.ask ?? "0");
|
||||
const lastPrice = parseFloat(o.lastPrice ?? o.last ?? "0");
|
||||
const mid = bid > 0 && ask > 0 ? (bid + ask) / 2 : ask > 0 ? ask : lastPrice;
|
||||
|
||||
const iv = parseFloat(o.impliedVolatility ?? o.iv ?? "0");
|
||||
|
||||
return {
|
||||
symbol,
|
||||
expiration: expiry,
|
||||
type: type as "call" | "put",
|
||||
strike,
|
||||
bid,
|
||||
ask,
|
||||
mid,
|
||||
volume: parseInt(o.volume ?? "0", 10),
|
||||
openInterest: parseInt(o.openInterest ?? o.open_interest ?? "0", 10),
|
||||
impliedVolatility: iv,
|
||||
delta: parseFloat(o.delta ?? "0"),
|
||||
gamma: parseFloat(o.gamma ?? "0"),
|
||||
theta: parseFloat(o.theta ?? "0"),
|
||||
vega: parseFloat(o.vega ?? "0"),
|
||||
lastPrice,
|
||||
};
|
||||
})
|
||||
.filter((o): o is FmpOption => o !== null && o.impliedVolatility > 0.001);
|
||||
}
|
||||
427
backend/src/routes/options.ts
Normal file
427
backend/src/routes/options.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Hono route handlers for the options pricing API.
|
||||
*
|
||||
* All routes return:
|
||||
* { ok: true, data: ..., timestamp: "..." } on success
|
||||
* { ok: false, error: "...", timestamp: "..." } on failure
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { fetchOptionsChain, fetchExpirations } from "../lib/datafetch.js";
|
||||
|
||||
/** How old a snapshot can be and still count for the term structure (more lenient than primary TTL). */
|
||||
const TERM_STRUCTURE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
import {
|
||||
buildVolSurface,
|
||||
computeSkewMetrics,
|
||||
getATMIV,
|
||||
} from "../lib/analytics.js";
|
||||
import type { ChainSnapshot } from "../lib/analytics.js";
|
||||
import {
|
||||
saveSnapshot,
|
||||
getSnapshots,
|
||||
getLatestSnapshot,
|
||||
} from "../db/snapshots.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How old a cached snapshot can be before we re-fetch (milliseconds). */
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ok<T>(data: T): { ok: true; data: T; timestamp: string } {
|
||||
return { ok: true, data, timestamp: now() };
|
||||
}
|
||||
|
||||
function fail(error: string): { ok: false; error: string; timestamp: string } {
|
||||
return { ok: false, error, timestamp: now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot row is fresh enough (within CACHE_TTL_MS of now).
|
||||
*/
|
||||
function isFresh(timestampIso: string): boolean {
|
||||
const snapshotTime = new Date(timestampIso).getTime();
|
||||
return Date.now() - snapshotTime < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `chain_json` field from a DB row and return a ChainSnapshot.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
function rowToSnapshot(
|
||||
row: {
|
||||
symbol: string;
|
||||
expiry: string;
|
||||
spot: number | null;
|
||||
timestamp: string;
|
||||
chain_json: string | null;
|
||||
atm_iv: number | null;
|
||||
rr25: number | null;
|
||||
rr10: number | null;
|
||||
fly25: number | null;
|
||||
}
|
||||
): ChainSnapshot | null {
|
||||
try {
|
||||
const chain = row.chain_json ? JSON.parse(row.chain_json) : [];
|
||||
return {
|
||||
symbol: row.symbol,
|
||||
expiry: row.expiry,
|
||||
spot: row.spot ?? 0,
|
||||
spotIv: row.atm_iv ?? 0,
|
||||
timestamp: row.timestamp,
|
||||
chain,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const optionsRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/chain?symbol=AAPL&expiry=2025-01-17
|
||||
// ---------------------------------------------------------------------------
|
||||
optionsRouter.get("/chain", async (c) => {
|
||||
const symbol = c.req.query("symbol")?.toUpperCase();
|
||||
const expiry = c.req.query("expiry");
|
||||
|
||||
if (!symbol) {
|
||||
return c.json(fail("Missing required query parameter: symbol"), 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// If an expiry was specified, check the cache first
|
||||
if (expiry) {
|
||||
const cached = getLatestSnapshot(symbol, expiry);
|
||||
if (cached && isFresh(cached.timestamp)) {
|
||||
const snapshot = rowToSnapshot(cached);
|
||||
if (snapshot) {
|
||||
return c.json(ok({ cached: true, snapshots: [snapshot] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — fetch fresh data
|
||||
const snapshots = await fetchOptionsChain(symbol, expiry);
|
||||
|
||||
// Persist each fetched snapshot
|
||||
for (const snapshot of snapshots) {
|
||||
const metrics = computeSkewMetrics(snapshot);
|
||||
saveSnapshot(
|
||||
snapshot.symbol,
|
||||
snapshot.expiry,
|
||||
snapshot.spot,
|
||||
metrics,
|
||||
snapshot.chain
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(ok({ cached: false, snapshots }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GET /api/chain] ${msg}`);
|
||||
return c.json(fail(msg), 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/expirations?symbol=AAPL
|
||||
// ---------------------------------------------------------------------------
|
||||
optionsRouter.get("/expirations", async (c) => {
|
||||
const symbol = c.req.query("symbol")?.toUpperCase();
|
||||
|
||||
if (!symbol) {
|
||||
return c.json(fail("Missing required query parameter: symbol"), 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const expirations = await fetchExpirations(symbol);
|
||||
return c.json(ok({ symbol, expirations }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GET /api/expirations] ${msg}`);
|
||||
return c.json(fail(msg), 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/analytics?symbol=AAPL&expiry=2025-01-17
|
||||
// ---------------------------------------------------------------------------
|
||||
optionsRouter.get("/analytics", async (c) => {
|
||||
const symbol = c.req.query("symbol")?.toUpperCase();
|
||||
const expiry = c.req.query("expiry");
|
||||
|
||||
if (!symbol) {
|
||||
return c.json(fail("Missing required query parameter: symbol"), 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// -----------------------------------------------------------------------
|
||||
// Step 1: Fetch the primary expiry (fresh data, used for Greeks + skew)
|
||||
// -----------------------------------------------------------------------
|
||||
let primarySnapshots: ChainSnapshot[];
|
||||
|
||||
if (expiry) {
|
||||
const cached = getLatestSnapshot(symbol, expiry);
|
||||
if (cached && isFresh(cached.timestamp)) {
|
||||
const snap = rowToSnapshot(cached);
|
||||
primarySnapshots = snap ? [snap] : [];
|
||||
} else {
|
||||
primarySnapshots = await fetchOptionsChain(symbol, expiry);
|
||||
for (const snap of primarySnapshots) {
|
||||
saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No expiry specified — fetch nearest 3 as primary
|
||||
primarySnapshots = await fetchOptionsChain(symbol);
|
||||
for (const snap of primarySnapshots) {
|
||||
saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain);
|
||||
}
|
||||
}
|
||||
|
||||
if (primarySnapshots.length === 0) {
|
||||
return c.json(fail("No options data available for the specified parameters"), 404);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Step 2: Build term structure — fetch nearest 5 expiries, using cache
|
||||
// aggressively (30-min TTL) to avoid extra Yahoo calls
|
||||
// -----------------------------------------------------------------------
|
||||
const allExpiries = await fetchExpirations(symbol);
|
||||
const now = Date.now();
|
||||
const nearestFive = allExpiries
|
||||
.filter((e) => new Date(e).getTime() > now)
|
||||
.slice(0, 5);
|
||||
|
||||
// Ensure the primary expiry is included even if far out
|
||||
if (expiry && !nearestFive.includes(expiry)) nearestFive.unshift(expiry);
|
||||
|
||||
const seenExpiries = new Set(primarySnapshots.map((s) => s.expiry));
|
||||
const termSnapshots: ChainSnapshot[] = [...primarySnapshots];
|
||||
|
||||
for (const exp of nearestFive) {
|
||||
if (seenExpiries.has(exp)) continue;
|
||||
const cached = getLatestSnapshot(symbol, exp);
|
||||
if (cached && Date.now() - new Date(cached.timestamp).getTime() < TERM_STRUCTURE_TTL_MS) {
|
||||
const snap = rowToSnapshot(cached);
|
||||
if (snap) { termSnapshots.push(snap); seenExpiries.add(exp); }
|
||||
} else {
|
||||
// Not cached — fetch fresh and save
|
||||
try {
|
||||
const fetched = await fetchOptionsChain(symbol, exp);
|
||||
for (const snap of fetched) {
|
||||
saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain);
|
||||
termSnapshots.push(snap);
|
||||
seenExpiries.add(snap.expiry);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — term structure just has fewer bars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by expiry date
|
||||
termSnapshots.sort((a, b) => a.expiry.localeCompare(b.expiry));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Step 3: Build metrics from all term-structure snapshots
|
||||
// -----------------------------------------------------------------------
|
||||
const skewMetrics: Record<string, ReturnType<typeof computeSkewMetrics>> = {};
|
||||
for (const s of termSnapshots) {
|
||||
skewMetrics[s.expiry] = computeSkewMetrics(s);
|
||||
}
|
||||
|
||||
// Build vol surface from all snapshots
|
||||
const volSurface = buildVolSurface(termSnapshots);
|
||||
|
||||
// Primary snapshot (the requested expiry, or the first)
|
||||
const primarySnapshot = expiry
|
||||
? primarySnapshots.find((s) => s.expiry === expiry) ?? primarySnapshots[0]
|
||||
: primarySnapshots[0];
|
||||
|
||||
const atmIv = primarySnapshot
|
||||
? getATMIV(primarySnapshot.chain, primarySnapshot.spot, "call") ||
|
||||
getATMIV(primarySnapshot.chain, primarySnapshot.spot, "put")
|
||||
: 0;
|
||||
|
||||
// Build per-strike IV arrays for the skew chart (primary expiry only)
|
||||
const allStrikes = primarySnapshot
|
||||
? [...new Set(primarySnapshot.chain.map((q) => q.strike))].sort((a, b) => a - b)
|
||||
: [];
|
||||
|
||||
const callIVs = allStrikes.map((k) => {
|
||||
const q = primarySnapshot?.chain.find(
|
||||
(o) => o.type === "call" && o.strike === k && o.iv > 0
|
||||
);
|
||||
return q ? q.iv : null;
|
||||
});
|
||||
|
||||
const putIVs = allStrikes.map((k) => {
|
||||
const q = primarySnapshot?.chain.find(
|
||||
(o) => o.type === "put" && o.strike === k && o.iv > 0
|
||||
);
|
||||
return q ? q.iv : null;
|
||||
});
|
||||
|
||||
// Greeks for ATM and nearest ITM options (primary expiry)
|
||||
const greeks = (() => {
|
||||
if (!primarySnapshot || primarySnapshot.chain.length === 0) return null;
|
||||
const { chain, spot } = primarySnapshot;
|
||||
|
||||
const calls = chain.filter((o) => o.type === "call" && o.iv > 0);
|
||||
const puts = chain.filter((o) => o.type === "put" && o.iv > 0);
|
||||
|
||||
const closest = (arr: typeof chain, target: number) =>
|
||||
arr.length === 0 ? null :
|
||||
arr.reduce((b, o) => Math.abs(o.strike - target) < Math.abs(b.strike - target) ? o : b);
|
||||
|
||||
// ATM: closest strike to spot for each type
|
||||
const atmCall = closest(calls, spot);
|
||||
const atmPut = closest(puts, spot);
|
||||
|
||||
// Nearest ITM: one strike deeper in-the-money than ATM (distinct from ATM row)
|
||||
const atmCallStrike = atmCall?.strike ?? spot;
|
||||
const atmPutStrike = atmPut?.strike ?? spot;
|
||||
const itmCallCandidates = calls.filter((o) => o.strike < atmCallStrike);
|
||||
const itmPutCandidates = puts.filter((o) => o.strike > atmPutStrike);
|
||||
const itmCall = itmCallCandidates.length > 0
|
||||
? itmCallCandidates.reduce((b, o) => o.strike > b.strike ? o : b)
|
||||
: null;
|
||||
const itmPut = itmPutCandidates.length > 0
|
||||
? itmPutCandidates.reduce((b, o) => o.strike < b.strike ? o : b)
|
||||
: null;
|
||||
|
||||
const pick = (o: typeof chain[number] | null) => o ? {
|
||||
strike: o.strike, iv: o.iv,
|
||||
delta: o.delta, gamma: o.gamma, theta: o.theta, vega: o.vega,
|
||||
bid: o.bid, ask: o.ask, midPrice: o.midPrice,
|
||||
} : null;
|
||||
|
||||
return {
|
||||
atmCall: pick(atmCall),
|
||||
atmPut: pick(atmPut),
|
||||
itmCall: pick(itmCall),
|
||||
itmPut: pick(itmPut),
|
||||
};
|
||||
})();
|
||||
|
||||
return c.json(
|
||||
ok({
|
||||
symbol,
|
||||
expiry: expiry ?? primarySnapshots.map((s) => s.expiry),
|
||||
spot: primarySnapshot?.spot ?? null,
|
||||
atmIv,
|
||||
skewMetrics,
|
||||
volSurface,
|
||||
strikes: allStrikes,
|
||||
callIVs,
|
||||
putIVs,
|
||||
greeks,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GET /api/analytics] ${msg}`);
|
||||
return c.json(fail(msg), 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/snapshots?symbol=AAPL&limit=50
|
||||
// ---------------------------------------------------------------------------
|
||||
optionsRouter.get("/snapshots", (c) => {
|
||||
const symbol = c.req.query("symbol")?.toUpperCase();
|
||||
const limitStr = c.req.query("limit") ?? "50";
|
||||
|
||||
if (!symbol) {
|
||||
return c.json(fail("Missing required query parameter: symbol"), 400);
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(limitStr, 10) || 50, 500);
|
||||
|
||||
try {
|
||||
const rows = getSnapshots(symbol, limit);
|
||||
|
||||
// Shape the rows for the chart consumer — omit chain_json to keep payload small
|
||||
const data = rows.map((row) => ({
|
||||
id: row.id,
|
||||
symbol: row.symbol,
|
||||
expiry: row.expiry,
|
||||
timestamp: row.timestamp,
|
||||
spot: row.spot,
|
||||
atmIv: row.atm_iv,
|
||||
rr25: row.rr25,
|
||||
rr10: row.rr10,
|
||||
fly25: row.fly25,
|
||||
}));
|
||||
|
||||
return c.json(ok({ symbol, count: data.length, snapshots: data }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[GET /api/snapshots] ${msg}`);
|
||||
return c.json(fail(msg), 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/refresh?symbol=AAPL
|
||||
// ---------------------------------------------------------------------------
|
||||
optionsRouter.post("/refresh", async (c) => {
|
||||
const symbol = c.req.query("symbol")?.toUpperCase();
|
||||
|
||||
if (!symbol) {
|
||||
return c.json(fail("Missing required query parameter: symbol"), 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Force fetch fresh data (no cache check)
|
||||
const snapshots = await fetchOptionsChain(symbol);
|
||||
|
||||
const savedMetrics = [];
|
||||
for (const snapshot of snapshots) {
|
||||
const metrics = computeSkewMetrics(snapshot);
|
||||
saveSnapshot(
|
||||
snapshot.symbol,
|
||||
snapshot.expiry,
|
||||
snapshot.spot,
|
||||
metrics,
|
||||
snapshot.chain
|
||||
);
|
||||
savedMetrics.push(metrics);
|
||||
}
|
||||
|
||||
const volSurface = buildVolSurface(snapshots);
|
||||
const primarySnapshot = snapshots[0];
|
||||
const atmIv = primarySnapshot
|
||||
? getATMIV(primarySnapshot.chain, primarySnapshot.spot, "call") ||
|
||||
getATMIV(primarySnapshot.chain, primarySnapshot.spot, "put")
|
||||
: 0;
|
||||
|
||||
return c.json(
|
||||
ok({
|
||||
symbol,
|
||||
refreshed: snapshots.map((s) => s.expiry),
|
||||
spot: primarySnapshot?.spot ?? null,
|
||||
atmIv,
|
||||
skewMetrics: savedMetrics,
|
||||
volSurface,
|
||||
snapshots,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[POST /api/refresh] ${msg}`);
|
||||
return c.json(fail(msg), 502);
|
||||
}
|
||||
});
|
||||
88
backend/src/server.ts
Normal file
88
backend/src/server.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load .env manually (no dotenv dependency needed)
|
||||
const envPath = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Initialize DB (creates tables on first run)
|
||||
import "./db/snapshots.js";
|
||||
|
||||
import { optionsRouter } from "./routes/options.js";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const FRONTEND_DIST = resolve(__dirname, "../../frontend");
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: "*",
|
||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization", "Accept"],
|
||||
maxAge: 86400,
|
||||
})
|
||||
);
|
||||
|
||||
app.use("*", logger());
|
||||
|
||||
app.get("/health", (c) =>
|
||||
c.json({ ok: true, service: "options-pricer-backend", timestamp: new Date().toISOString() })
|
||||
);
|
||||
|
||||
app.route("/api", optionsRouter);
|
||||
|
||||
if (existsSync(FRONTEND_DIST)) {
|
||||
console.log(`[server] Serving frontend SPA from ${FRONTEND_DIST}`);
|
||||
app.use("/*", serveStatic({ root: FRONTEND_DIST }));
|
||||
app.get("*", (c) => {
|
||||
const indexPath = resolve(FRONTEND_DIST, "index.html");
|
||||
if (existsSync(indexPath)) {
|
||||
return c.html(readFileSync(indexPath, "utf-8"));
|
||||
}
|
||||
return c.text("Frontend not found", 404);
|
||||
});
|
||||
} else {
|
||||
app.get("/", (c) =>
|
||||
c.json({
|
||||
service: "options-pricer-backend",
|
||||
version: "1.0.0",
|
||||
docs: "API at /api/*",
|
||||
routes: [
|
||||
"GET /api/chain?symbol=SPY&expiry=YYYY-MM-DD",
|
||||
"GET /api/expirations?symbol=SPY",
|
||||
"GET /api/analytics?symbol=SPY&expiry=YYYY-MM-DD",
|
||||
"GET /api/snapshots?symbol=SPY&limit=50",
|
||||
"POST /api/refresh?symbol=SPY",
|
||||
"GET /health",
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
app.onError((err, c) =>
|
||||
c.json({ ok: false, error: err.message ?? "Internal server error", timestamp: new Date().toISOString() }, 500)
|
||||
);
|
||||
|
||||
app.notFound((c) =>
|
||||
c.json({ ok: false, error: `Not found: ${c.req.method} ${c.req.path}`, timestamp: new Date().toISOString() }, 404)
|
||||
);
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? "3001", 10);
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, () => {
|
||||
console.log(`Options Pricer backend running on http://localhost:${PORT}`);
|
||||
});
|
||||
13
backend/tsconfig.json
Normal file
13
backend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user