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:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
backend/data/*.db
|
||||||
|
backend/data/*.db-shm
|
||||||
|
backend/data/*.db-wal
|
||||||
|
backend/.env
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
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"]
|
||||||
|
}
|
||||||
5
frontend/assets/alpine.min.js
vendored
Normal file
5
frontend/assets/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
frontend/assets/apexcharts.min.js
vendored
Normal file
5
frontend/assets/apexcharts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
frontend/assets/tabler-vendors.min.css
vendored
Normal file
8
frontend/assets/tabler-vendors.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
frontend/assets/tabler.min.css
vendored
Normal file
9
frontend/assets/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/assets/tabler.min.js
vendored
Normal file
13
frontend/assets/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
557
frontend/chain.html
Normal file
557
frontend/chain.html
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Options Chain — Options Pricer</title>
|
||||||
|
<link rel="stylesheet" href="/assets/tabler.min.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
|
||||||
|
<style>
|
||||||
|
/* Sticky table header */
|
||||||
|
.table-sticky thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--tblr-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dense scrollable table wrappers */
|
||||||
|
.chain-scroll {
|
||||||
|
max-height: 75vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ATM highlight overrides */
|
||||||
|
.row-atm {
|
||||||
|
background-color: rgba(var(--tblr-info-rgb), 0.12) !important;
|
||||||
|
outline: 1px solid rgba(var(--tblr-info-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theta always red */
|
||||||
|
.text-theta {
|
||||||
|
color: var(--tblr-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IV color helpers already covered by Tabler text-yellow / text-orange */
|
||||||
|
|
||||||
|
/* Toolbar badge for spot price */
|
||||||
|
.spot-badge {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div class="wrapper" x-data="chain()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- ===== Sidebar ===== -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu"
|
||||||
|
aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 class="navbar-brand navbar-brand-autodark">
|
||||||
|
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chart-candle" width="28"
|
||||||
|
height="28" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<rect x="4" y="8" width="4" height="8" rx="1" />
|
||||||
|
<line x1="6" y1="4" x2="6" y2="8" />
|
||||||
|
<line x1="6" y1="16" x2="6" y2="20" />
|
||||||
|
<rect x="16" y="6" width="4" height="10" rx="1" />
|
||||||
|
<line x1="18" y1="2" x2="18" y2="6" />
|
||||||
|
<line x1="18" y1="16" x2="18" y2="22" />
|
||||||
|
</svg>
|
||||||
|
<span class="fw-bold">Options Pricer</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-dashboard" width="24"
|
||||||
|
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M5 12l-2 0l9 -9l9 9l-2 0" />
|
||||||
|
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" />
|
||||||
|
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item active">
|
||||||
|
<a class="nav-link" href="chain.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-table" width="24"
|
||||||
|
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
<path d="M3 10l18 0" />
|
||||||
|
<path d="M10 5v14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Options Chain</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="surface.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-wave-sine" width="24"
|
||||||
|
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 12c1.333 -4.667 2.667 -7 4 -7s2.667 2.333 4 7s2.667 7 4 7s2.667 -2.333 4 -7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Vol Surface</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="tracker.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-radar" width="24"
|
||||||
|
height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0" />
|
||||||
|
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||||
|
<path d="M15 12l-3 -3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Tracker</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ===== Main content ===== -->
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">Options Chain</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<!-- ===== Toolbar ===== -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
|
||||||
|
<!-- Symbol input + Lookup -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label visually-hidden" for="input-symbol">Symbol</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Symbol</span>
|
||||||
|
<input
|
||||||
|
id="input-symbol"
|
||||||
|
type="text"
|
||||||
|
class="form-control text-uppercase fw-bold"
|
||||||
|
style="width: 6rem;"
|
||||||
|
x-model="symbol"
|
||||||
|
placeholder="SPY"
|
||||||
|
@keydown.enter="fetchExpirations()"
|
||||||
|
aria-label="Underlying symbol"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
@click="fetchExpirations()"
|
||||||
|
:disabled="lookingUp || !symbol"
|
||||||
|
aria-label="Look up expirations for symbol"
|
||||||
|
>
|
||||||
|
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry select -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label visually-hidden" for="select-expiry">Expiry</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Expiry</span>
|
||||||
|
<select
|
||||||
|
id="select-expiry"
|
||||||
|
class="form-select"
|
||||||
|
x-model="expiry"
|
||||||
|
:disabled="expirations.length === 0"
|
||||||
|
aria-label="Expiration date"
|
||||||
|
>
|
||||||
|
<option value="" disabled>-- select --</option>
|
||||||
|
<template x-for="exp in expirations" :key="exp">
|
||||||
|
<option :value="exp" x-text="exp"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type selector -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label visually-hidden" for="select-type">Type</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Type</span>
|
||||||
|
<select
|
||||||
|
id="select-type"
|
||||||
|
class="form-select"
|
||||||
|
x-model="optionType"
|
||||||
|
aria-label="Option type filter"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="call">Calls</option>
|
||||||
|
<option value="put">Puts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load button -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
@click="loadChain()"
|
||||||
|
:disabled="loading || !expiry"
|
||||||
|
aria-label="Load options chain"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
x-show="loading"
|
||||||
|
class="spinner-border spinner-border-sm me-1"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<span x-text="loading ? 'Loading…' : 'Load'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spot price badge -->
|
||||||
|
<div class="col-auto ms-auto" x-show="spot > 0">
|
||||||
|
<span class="badge bg-blue-lt spot-badge fs-6 px-3 py-2">
|
||||||
|
<span class="text-muted me-1">Spot</span>
|
||||||
|
<strong x-text="'$' + spot.toFixed(2)"></strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div class="col-12" x-show="error">
|
||||||
|
<div class="alert alert-danger alert-dismissible py-1 mb-0" role="alert">
|
||||||
|
<span x-text="error"></span>
|
||||||
|
<button type="button" class="btn-close" @click="error = ''" aria-label="Dismiss"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Chain tables ===== -->
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
<!-- CALLS -->
|
||||||
|
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'call'">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-success me-1">C</span>
|
||||||
|
Calls
|
||||||
|
<span
|
||||||
|
class="badge bg-secondary ms-2"
|
||||||
|
x-show="calls.length > 0"
|
||||||
|
x-text="calls.length + ' strikes'"
|
||||||
|
></span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty / loading state -->
|
||||||
|
<div class="card-body text-center py-4" x-show="calls.length === 0 && !loading">
|
||||||
|
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center py-4" x-show="loading">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-scroll" x-show="calls.length > 0 && !loading">
|
||||||
|
<table
|
||||||
|
class="table table-vcenter table-hover table-sm table-sticky mb-0"
|
||||||
|
aria-label="Calls options chain"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Strike</th>
|
||||||
|
<th scope="col">Bid</th>
|
||||||
|
<th scope="col">Ask</th>
|
||||||
|
<th scope="col">Mid</th>
|
||||||
|
<th scope="col">IV%</th>
|
||||||
|
<th scope="col">Delta</th>
|
||||||
|
<th scope="col">Gamma</th>
|
||||||
|
<th scope="col">Theta/d</th>
|
||||||
|
<th scope="col">Vega/1%</th>
|
||||||
|
<th scope="col">Volume</th>
|
||||||
|
<th scope="col">OI</th>
|
||||||
|
<th scope="col">BS Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in calls" :key="row.strike">
|
||||||
|
<tr :class="rowClass(row, 'call')">
|
||||||
|
<td class="fw-semibold" x-text="row.strike"></td>
|
||||||
|
<td x-text="fmt2(row.bid)"></td>
|
||||||
|
<td x-text="fmt2(row.ask)"></td>
|
||||||
|
<td x-text="fmt2(row.midPrice)"></td>
|
||||||
|
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
|
||||||
|
<td x-text="fmt4(row.delta)"></td>
|
||||||
|
<td x-text="fmt4(row.gamma)"></td>
|
||||||
|
<td class="text-theta" x-text="fmt2(row.theta)"></td>
|
||||||
|
<td x-text="fmt2(row.vega)"></td>
|
||||||
|
<td x-text="fmtInt(row.volume)"></td>
|
||||||
|
<td x-text="fmtInt(row.openInterest)"></td>
|
||||||
|
<td x-text="fmt2(row.bsPrice)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PUTS -->
|
||||||
|
<div :class="optionType === 'all' ? 'col-12 col-xxl-6' : 'col-12'" x-show="optionType === 'all' || optionType === 'put'">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-danger me-1">P</span>
|
||||||
|
Puts
|
||||||
|
<span
|
||||||
|
class="badge bg-secondary ms-2"
|
||||||
|
x-show="puts.length > 0"
|
||||||
|
x-text="puts.length + ' strikes'"
|
||||||
|
></span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty / loading state -->
|
||||||
|
<div class="card-body text-center py-4" x-show="puts.length === 0 && !loading">
|
||||||
|
<div class="text-muted">No data — select a symbol and expiry, then click Load.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center py-4" x-show="loading">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chain-scroll" x-show="puts.length > 0 && !loading">
|
||||||
|
<table
|
||||||
|
class="table table-vcenter table-hover table-sm table-sticky mb-0"
|
||||||
|
aria-label="Puts options chain"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Strike</th>
|
||||||
|
<th scope="col">Bid</th>
|
||||||
|
<th scope="col">Ask</th>
|
||||||
|
<th scope="col">Mid</th>
|
||||||
|
<th scope="col">IV%</th>
|
||||||
|
<th scope="col">Delta</th>
|
||||||
|
<th scope="col">Gamma</th>
|
||||||
|
<th scope="col">Theta/d</th>
|
||||||
|
<th scope="col">Vega/1%</th>
|
||||||
|
<th scope="col">Volume</th>
|
||||||
|
<th scope="col">OI</th>
|
||||||
|
<th scope="col">BS Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in puts" :key="row.strike">
|
||||||
|
<tr :class="rowClass(row, 'put')">
|
||||||
|
<td class="fw-semibold" x-text="row.strike"></td>
|
||||||
|
<td x-text="fmt2(row.bid)"></td>
|
||||||
|
<td x-text="fmt2(row.ask)"></td>
|
||||||
|
<td x-text="fmt2(row.midPrice)"></td>
|
||||||
|
<td :class="ivClass(row.iv)" x-text="fmtIV(row.iv)"></td>
|
||||||
|
<td x-text="fmt4(row.delta)"></td>
|
||||||
|
<td x-text="fmt4(row.gamma)"></td>
|
||||||
|
<td class="text-theta" x-text="fmt2(row.theta)"></td>
|
||||||
|
<td x-text="fmt2(row.vega)"></td>
|
||||||
|
<td x-text="fmtInt(row.volume)"></td>
|
||||||
|
<td x-text="fmtInt(row.openInterest)"></td>
|
||||||
|
<td x-text="fmt2(row.bsPrice)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /row -->
|
||||||
|
</div><!-- /container-xl -->
|
||||||
|
</div><!-- /page-body -->
|
||||||
|
|
||||||
|
<footer class="footer footer-transparent d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row text-center align-items-center flex-row-reverse">
|
||||||
|
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||||
|
<ul class="list-inline list-inline-dots mb-0">
|
||||||
|
<li class="list-inline-item">
|
||||||
|
Options Pricer © <span x-text="new Date().getFullYear()"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div><!-- /page-wrapper -->
|
||||||
|
</div><!-- /wrapper -->
|
||||||
|
|
||||||
|
<script src="/assets/tabler.min.js" defer></script>
|
||||||
|
<script src="/assets/alpine.min.js" defer></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function chain() {
|
||||||
|
return {
|
||||||
|
// ── state ──────────────────────────────────────────────
|
||||||
|
symbol: 'SPY',
|
||||||
|
expiry: '',
|
||||||
|
optionType: 'all',
|
||||||
|
expirations: [],
|
||||||
|
calls: [],
|
||||||
|
puts: [],
|
||||||
|
spot: 0,
|
||||||
|
loading: false,
|
||||||
|
lookingUp: false,
|
||||||
|
error: '',
|
||||||
|
|
||||||
|
// ── lifecycle ──────────────────────────────────────────
|
||||||
|
async init() {
|
||||||
|
// no auto-load — user must click Lookup first
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── data fetching ──────────────────────────────────────
|
||||||
|
async fetchExpirations() {
|
||||||
|
if (!this.symbol) return;
|
||||||
|
this.error = '';
|
||||||
|
this.lookingUp = true;
|
||||||
|
this.expirations = [];
|
||||||
|
this.expiry = '';
|
||||||
|
this.calls = [];
|
||||||
|
this.puts = [];
|
||||||
|
try {
|
||||||
|
const sym = this.symbol.toUpperCase().trim();
|
||||||
|
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
const env = await res.json();
|
||||||
|
const data = env.data ?? env;
|
||||||
|
this.expirations = data.expirations ?? (Array.isArray(data) ? data : []);
|
||||||
|
if (this.expirations.length > 0) this.expiry = this.expirations[0];
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Failed to look up symbol: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.lookingUp = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadChain() {
|
||||||
|
if (!this.expiry) return;
|
||||||
|
this.error = '';
|
||||||
|
this.loading = true;
|
||||||
|
this.calls = [];
|
||||||
|
this.puts = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sym = (this.symbol || 'SPY').toUpperCase().trim();
|
||||||
|
const url = `/api/chain?symbol=${encodeURIComponent(sym)}&expiry=${encodeURIComponent(this.expiry)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
const env = await res.json();
|
||||||
|
// API wraps in { ok, data: { snapshots: [{ spot, chain }] } }
|
||||||
|
const snap = env.data?.snapshots?.[0] ?? env.data ?? {};
|
||||||
|
this.spot = snap.spot ?? 0;
|
||||||
|
const chain = snap.chain ?? [];
|
||||||
|
|
||||||
|
if (false) {
|
||||||
|
// placeholder to keep else structure
|
||||||
|
} else if (Array.isArray(chain)) {
|
||||||
|
this.calls = this._sortByStrike(
|
||||||
|
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'call')
|
||||||
|
);
|
||||||
|
this.puts = this._sortByStrike(
|
||||||
|
chain.filter(r => (r.optionType || r.type || '').toLowerCase() === 'put')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Unexpected API response shape.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = 'Failed to load chain: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────
|
||||||
|
_sortByStrike(arr) {
|
||||||
|
return [...arr].sort((a, b) => a.strike - b.strike);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ATM strike: the strike (across all rows) closest to spot
|
||||||
|
get atmStrike() {
|
||||||
|
const all = this.calls.concat(this.puts);
|
||||||
|
if (all.length === 0 || this.spot === 0) return null;
|
||||||
|
return all.reduce((best, row) => {
|
||||||
|
return Math.abs(row.strike - this.spot) < Math.abs(best.strike - this.spot)
|
||||||
|
? row
|
||||||
|
: best;
|
||||||
|
}).strike;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Row background class
|
||||||
|
rowClass(row, side) {
|
||||||
|
const s = row.strike;
|
||||||
|
if (s === this.atmStrike) return 'row-atm';
|
||||||
|
if (side === 'call' && s < this.spot) return 'bg-success-lt'; // ITM call
|
||||||
|
if (side === 'put' && s > this.spot) return 'bg-danger-lt'; // ITM put
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// IV colour class
|
||||||
|
ivClass(iv) {
|
||||||
|
const pct = (iv ?? 0) * 100;
|
||||||
|
if (pct < 20) return '';
|
||||||
|
if (pct < 40) return 'text-yellow fw-semibold';
|
||||||
|
return 'text-orange fw-semibold';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── formatters ─────────────────────────────────────────
|
||||||
|
fmt2(v) { return v == null ? '—' : Number(v).toFixed(2); },
|
||||||
|
fmt4(v) { return v == null ? '—' : Number(v).toFixed(4); },
|
||||||
|
fmtIV(iv) {
|
||||||
|
if (iv == null) return '—';
|
||||||
|
return (Number(iv) * 100).toFixed(1) + '%';
|
||||||
|
},
|
||||||
|
fmtInt(v) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return Number(v).toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
892
frontend/index.html
Normal file
892
frontend/index.html
Normal file
@@ -0,0 +1,892 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
|
||||||
|
<title>Options Pricer — Dashboard</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/assets/tabler.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/tabler-vendors.min.css">
|
||||||
|
<script src="/assets/apexcharts.min.js"></script>
|
||||||
|
<script src="/assets/tabler.min.js" defer></script>
|
||||||
|
<script src="/assets/alpine.min.js" defer></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--op-bg: #1e2030;
|
||||||
|
--op-surface: #252840;
|
||||||
|
--op-grid: #2d3045;
|
||||||
|
--op-cyan: #00adb5;
|
||||||
|
--op-amber: #f59f00;
|
||||||
|
--op-muted: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--op-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-vertical {
|
||||||
|
background-color: var(--op-surface) !important;
|
||||||
|
border-right: 1px solid var(--op-grid) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrapper {
|
||||||
|
background-color: var(--op-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--op-surface);
|
||||||
|
border: 1px solid var(--op-grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
border-bottom: 1px solid var(--op-grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat card value sizing */
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart container */
|
||||||
|
.chart-container {
|
||||||
|
min-height: 280px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(30, 32, 48, 0.75);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: inherit;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar bar */
|
||||||
|
.toolbar-card {
|
||||||
|
background-color: var(--op-surface);
|
||||||
|
border: 1px solid var(--op-grid);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar active state override */
|
||||||
|
.navbar-nav .nav-link.active,
|
||||||
|
.navbar-nav .nav-item.active > .nav-link {
|
||||||
|
color: var(--op-cyan) !important;
|
||||||
|
background-color: rgba(0, 173, 181, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active svg,
|
||||||
|
.navbar-nav .nav-item.active > .nav-link svg {
|
||||||
|
color: var(--op-cyan) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge last-updated */
|
||||||
|
.badge-updated {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.07);
|
||||||
|
color: #adb5bd;
|
||||||
|
border: 1px solid var(--op-grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color utilities */
|
||||||
|
.text-positive { color: #2fb344 !important; }
|
||||||
|
.text-negative { color: #d63939 !important; }
|
||||||
|
.text-amber { color: var(--op-amber) !important; }
|
||||||
|
.text-cyan { color: var(--op-cyan) !important; }
|
||||||
|
|
||||||
|
/* Stat subtitle */
|
||||||
|
.stat-subtitle {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner color override */
|
||||||
|
.spinner-border.text-cyan {
|
||||||
|
color: var(--op-cyan) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ApexCharts dark text override */
|
||||||
|
.apexcharts-text, .apexcharts-legend-text {
|
||||||
|
fill: #adb5bd !important;
|
||||||
|
color: #adb5bd !important;
|
||||||
|
}
|
||||||
|
.apexcharts-tooltip {
|
||||||
|
background: var(--op-surface) !important;
|
||||||
|
border: 1px solid var(--op-grid) !important;
|
||||||
|
color: #e9ecef !important;
|
||||||
|
}
|
||||||
|
.apexcharts-tooltip-title {
|
||||||
|
background: var(--op-grid) !important;
|
||||||
|
border-bottom: 1px solid var(--op-grid) !important;
|
||||||
|
color: #e9ecef !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div class="wrapper" x-data="dashboard">
|
||||||
|
|
||||||
|
<!-- ===================== SIDEBAR ===================== -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- Mobile toggle -->
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<h1 class="navbar-brand navbar-brand-autodark">
|
||||||
|
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-cyan" aria-hidden="true">
|
||||||
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
|
||||||
|
<polyline points="16 7 22 7 22 13"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-bold fs-5 text-white">Options Pricer</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Sidebar nav -->
|
||||||
|
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<li class="nav-item active">
|
||||||
|
<a class="nav-link active" href="index.html" aria-current="page">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Options Chain -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="chain.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Options Chain</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Vol Surface -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="surface.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 20h20M5 20V10l7-7 7 7v10"/>
|
||||||
|
<path d="M9 20v-5h6v5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Vol Surface</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Tracker -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="tracker.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Tracker</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ===================== MAIN CONTENT ===================== -->
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">Dashboard</h2>
|
||||||
|
<div class="text-secondary small mt-1">Implied volatility analytics & skew metrics</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<!-- ======== TOOLBAR ======== -->
|
||||||
|
<div class="toolbar-card mb-3" role="search" aria-label="Symbol controls">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<!-- Symbol input -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label visually-hidden" for="input-symbol">Symbol</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="input-symbol"
|
||||||
|
type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Symbol"
|
||||||
|
x-model="symbol"
|
||||||
|
@keydown.enter="fetchExpirations()"
|
||||||
|
style="width: 100px; text-transform: uppercase;"
|
||||||
|
aria-label="Ticker symbol"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry select -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label visually-hidden" for="select-expiry">Expiry</label>
|
||||||
|
<select
|
||||||
|
id="select-expiry"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
x-model="expiry"
|
||||||
|
@change="loadData()"
|
||||||
|
aria-label="Expiration date"
|
||||||
|
style="min-width: 140px;"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select expiry…</option>
|
||||||
|
<template x-for="exp in expirations" :key="exp">
|
||||||
|
<option :value="exp" x-text="exp"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh button -->
|
||||||
|
<div class="col-auto">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary d-inline-flex align-items-center gap-1"
|
||||||
|
type="button"
|
||||||
|
@click="refresh()"
|
||||||
|
:disabled="loading"
|
||||||
|
aria-label="Refresh data"
|
||||||
|
>
|
||||||
|
<span x-show="loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
<svg x-show="!loading" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last updated badge -->
|
||||||
|
<div class="col-auto ms-auto d-flex align-items-center gap-2">
|
||||||
|
<template x-if="lastUpdated">
|
||||||
|
<span class="badge badge-updated d-inline-flex align-items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
Updated <span x-text="lastUpdated"></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== ERROR ALERT ======== -->
|
||||||
|
<template x-if="error">
|
||||||
|
<div class="alert alert-danger alert-dismissible mb-3" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="alert-icon me-2" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="error"></span>
|
||||||
|
<button type="button" class="btn-close" @click="error = null" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ======== STAT CARDS ======== -->
|
||||||
|
<div class="row row-cards mb-3" aria-label="Key metrics">
|
||||||
|
|
||||||
|
<!-- ATM IV -->
|
||||||
|
<div class="col-sm-6 col-lg-3">
|
||||||
|
<div class="card h-100" style="position: relative;">
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
|
<div class="subheader text-secondary fw-medium small">ATM IV</div>
|
||||||
|
<span class="badge text-bg-secondary" style="font-size: 0.65rem;">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-baseline gap-2">
|
||||||
|
<div class="stat-value text-cyan" x-text="atmIv" aria-live="polite">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subtitle">At-the-money implied vol</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 25d Risk Reversal -->
|
||||||
|
<div class="col-sm-6 col-lg-3">
|
||||||
|
<div class="card h-100" style="position: relative;">
|
||||||
|
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
|
<div class="subheader text-secondary fw-medium small">25d Risk Reversal</div>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="rr25 >= 0 ? 'text-bg-success' : 'text-bg-danger'"
|
||||||
|
aria-hidden="true"
|
||||||
|
x-text="rr25 >= 0 ? 'CALL SKEW' : 'PUT SKEW'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-baseline gap-2">
|
||||||
|
<div
|
||||||
|
class="stat-value"
|
||||||
|
:class="rr25 >= 0 ? 'text-positive' : 'text-negative'"
|
||||||
|
x-text="formatPct(rr25)"
|
||||||
|
aria-live="polite"
|
||||||
|
>—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subtitle">Call IV − Put IV at 25Δ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 25d Butterfly -->
|
||||||
|
<div class="col-sm-6 col-lg-3">
|
||||||
|
<div class="card h-100" style="position: relative;">
|
||||||
|
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
|
<div class="subheader text-secondary fw-medium small">25d Butterfly</div>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="fly25 > 0 ? 'text-bg-warning' : 'text-bg-secondary'"
|
||||||
|
aria-hidden="true"
|
||||||
|
x-text="fly25 > 0 ? 'CONVEX' : 'FLAT'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-baseline gap-2">
|
||||||
|
<div
|
||||||
|
class="stat-value"
|
||||||
|
:class="fly25 > 0 ? 'text-amber' : 'text-secondary'"
|
||||||
|
x-text="formatPct(fly25)"
|
||||||
|
aria-live="polite"
|
||||||
|
>—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subtitle">Wing IV − ATM IV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Underlying Price -->
|
||||||
|
<div class="col-sm-6 col-lg-3">
|
||||||
|
<div class="card h-100" style="position: relative;">
|
||||||
|
<div class="loading-overlay" x-show="loading" aria-label="Loading" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
|
<div class="subheader text-secondary fw-medium small">Underlying Price</div>
|
||||||
|
<span class="badge text-bg-secondary" style="font-size: 0.65rem;" x-text="symbol.toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-baseline gap-2">
|
||||||
|
<div class="stat-value text-white" x-text="formatSpot(spot)" aria-live="polite">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subtitle" x-text="symbol.toUpperCase() + ' last price'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /stat cards -->
|
||||||
|
|
||||||
|
<!-- ======== CHARTS ROW ======== -->
|
||||||
|
<div class="row row-cards">
|
||||||
|
|
||||||
|
<!-- RR25 Trend Chart -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card" style="position: relative;">
|
||||||
|
<div class="loading-overlay" x-show="loading && snapshots.length === 0" aria-label="Loading chart" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#00adb5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
|
||||||
|
<polyline points="16 7 22 7 22 13"/>
|
||||||
|
</svg>
|
||||||
|
Risk Reversal Trend (25Δ)
|
||||||
|
</h3>
|
||||||
|
<div class="card-options">
|
||||||
|
<span class="text-secondary small">Last 30 snapshots</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body chart-container p-2">
|
||||||
|
<div id="chart-rr25" style="min-height: 260px;" aria-label="Risk Reversal 25 delta trend chart" role="img"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fly25 Trend Chart -->
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card" style="position: relative;">
|
||||||
|
<div class="loading-overlay" x-show="loading && snapshots.length === 0" aria-label="Loading chart" role="status">
|
||||||
|
<div class="spinner-border text-cyan" style="width: 1.6rem; height: 1.6rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="12" width="4" height="8" rx="1"/>
|
||||||
|
<rect x="10" y="7" width="4" height="13" rx="1"/>
|
||||||
|
<rect x="17" y="4" width="4" height="16" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
Butterfly Trend (25Δ Fly)
|
||||||
|
</h3>
|
||||||
|
<div class="card-options">
|
||||||
|
<span class="text-secondary small">Last 30 snapshots</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body chart-container p-2">
|
||||||
|
<div id="chart-fly25" style="min-height: 260px;" aria-label="Butterfly 25 delta trend chart" role="img"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /charts row -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /container-xl -->
|
||||||
|
</div>
|
||||||
|
<!-- /page-body -->
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer footer-transparent d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row text-center align-items-center flex-row-reverse">
|
||||||
|
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||||
|
<ul class="list-inline list-inline-dots mb-0">
|
||||||
|
<li class="list-inline-item text-secondary small">
|
||||||
|
Options Pricer © <span x-text="new Date().getFullYear()"></span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item text-secondary small">
|
||||||
|
Powered by Tabler & ApexCharts
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /page-wrapper -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /wrapper -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =========================================================
|
||||||
|
// Chart instances (module-level so we can destroy/re-render)
|
||||||
|
// =========================================================
|
||||||
|
let chartRR25 = null;
|
||||||
|
let chartFly25 = null;
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Utility: format a timestamp (ISO or epoch ms) → HH:mm MM/DD
|
||||||
|
// =========================================================
|
||||||
|
function fmtTimestamp(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return String(ts);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm} ${mo}/${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Base chart options (dark theme)
|
||||||
|
// =========================================================
|
||||||
|
function baseChartOptions(categories) {
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
background: '#252840',
|
||||||
|
foreColor: '#adb5bd',
|
||||||
|
toolbar: { show: false },
|
||||||
|
animations: { enabled: true, speed: 400 },
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
grid: {
|
||||||
|
borderColor: '#2d3045',
|
||||||
|
strokeDashArray: 3,
|
||||||
|
xaxis: { lines: { show: false } },
|
||||||
|
yaxis: { lines: { show: true } },
|
||||||
|
padding: { top: 0, right: 16, bottom: 0, left: 8 },
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: categories,
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#adb5bd', fontSize: '11px' },
|
||||||
|
rotate: -30,
|
||||||
|
maxHeight: 64,
|
||||||
|
},
|
||||||
|
axisBorder: { color: '#2d3045' },
|
||||||
|
axisTicks: { color: '#2d3045' },
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#adb5bd', fontSize: '11px' },
|
||||||
|
formatter: (val) => val.toFixed(4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
y: { formatter: (val) => val.toFixed(4) },
|
||||||
|
x: { formatter: (val) => val },
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
legend: { show: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Render / re-render RR25 line chart
|
||||||
|
// =========================================================
|
||||||
|
function renderRR25Chart(snapshots) {
|
||||||
|
const el = document.getElementById('chart-rr25');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const categories = snapshots.map(s => fmtTimestamp(s.timestamp ?? s.createdAt ?? s.ts));
|
||||||
|
const seriesData = snapshots.map(s => {
|
||||||
|
const val = s.rr25 ?? s.skewMetrics?.[0]?.rr25 ?? null;
|
||||||
|
return val !== null ? parseFloat(val) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chartRR25) {
|
||||||
|
chartRR25.destroy();
|
||||||
|
chartRR25 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
...baseChartOptions(categories),
|
||||||
|
chart: {
|
||||||
|
...baseChartOptions(categories).chart,
|
||||||
|
type: 'line',
|
||||||
|
height: 260,
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'RR25',
|
||||||
|
data: seriesData,
|
||||||
|
}],
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 2.5,
|
||||||
|
colors: ['#00adb5'],
|
||||||
|
},
|
||||||
|
markers: {
|
||||||
|
size: 3,
|
||||||
|
colors: ['#00adb5'],
|
||||||
|
strokeColors: '#252840',
|
||||||
|
strokeWidth: 2,
|
||||||
|
hover: { size: 5 },
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shade: 'dark',
|
||||||
|
type: 'vertical',
|
||||||
|
shadeIntensity: 0.4,
|
||||||
|
gradientToColors: ['#1e2030'],
|
||||||
|
stops: [0, 100],
|
||||||
|
opacityFrom: 0.3,
|
||||||
|
opacityTo: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#00adb5'],
|
||||||
|
yaxis: {
|
||||||
|
...baseChartOptions(categories).yaxis,
|
||||||
|
title: {
|
||||||
|
text: 'RR25 (decimal)',
|
||||||
|
style: { color: '#6c757d', fontSize: '11px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
yaxis: [{
|
||||||
|
y: 0,
|
||||||
|
borderColor: '#6c757d',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
label: {
|
||||||
|
text: '0',
|
||||||
|
style: { color: '#6c757d', background: 'transparent', fontSize: '10px' },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
chartRR25 = new ApexCharts(el, opts);
|
||||||
|
chartRR25.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Render / re-render Fly25 bar chart
|
||||||
|
// =========================================================
|
||||||
|
function renderFly25Chart(snapshots) {
|
||||||
|
const el = document.getElementById('chart-fly25');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const categories = snapshots.map(s => fmtTimestamp(s.timestamp ?? s.createdAt ?? s.ts));
|
||||||
|
const rawData = snapshots.map(s => {
|
||||||
|
const val = s.fly25 ?? s.skewMetrics?.[0]?.fly25 ?? null;
|
||||||
|
return val !== null ? parseFloat(val) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distributed colors: amber for positive, muted grey for non-positive
|
||||||
|
const barColors = rawData.map(v => v > 0 ? '#f59f00' : '#6c757d');
|
||||||
|
|
||||||
|
if (chartFly25) {
|
||||||
|
chartFly25.destroy();
|
||||||
|
chartFly25 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
...baseChartOptions(categories),
|
||||||
|
chart: {
|
||||||
|
...baseChartOptions(categories).chart,
|
||||||
|
type: 'bar',
|
||||||
|
height: 260,
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'Fly25',
|
||||||
|
data: rawData,
|
||||||
|
}],
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
distributed: true,
|
||||||
|
borderRadius: 3,
|
||||||
|
columnWidth: '60%',
|
||||||
|
dataLabels: { position: 'top' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: barColors,
|
||||||
|
yaxis: {
|
||||||
|
...baseChartOptions(categories).yaxis,
|
||||||
|
title: {
|
||||||
|
text: 'Fly25 (decimal)',
|
||||||
|
style: { color: '#6c757d', fontSize: '11px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
yaxis: [{
|
||||||
|
y: 0,
|
||||||
|
borderColor: '#6c757d',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
label: {
|
||||||
|
text: '0',
|
||||||
|
style: { color: '#6c757d', background: 'transparent', fontSize: '10px' },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
y: { formatter: (val) => val.toFixed(4) },
|
||||||
|
x: { formatter: (val) => val },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
chartFly25 = new ApexCharts(el, opts);
|
||||||
|
chartFly25.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Alpine.js component
|
||||||
|
// =========================================================
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('dashboard', () => ({
|
||||||
|
// ---- state ----
|
||||||
|
symbol: 'SPY',
|
||||||
|
expiry: '',
|
||||||
|
expirations: [],
|
||||||
|
analytics: {},
|
||||||
|
snapshots: [],
|
||||||
|
loading: false,
|
||||||
|
lastUpdated: '',
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// ---- lifecycle ----
|
||||||
|
async init() {
|
||||||
|
await this.fetchExpirations();
|
||||||
|
if (this.expiry) await this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- data fetching ----
|
||||||
|
async fetchExpirations() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(this.symbol)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const env = await res.json();
|
||||||
|
const data = env.data ?? env;
|
||||||
|
this.expirations = Array.isArray(data) ? data : (data.expirations ?? []);
|
||||||
|
if (this.expirations.length > 0 && !this.expiry) {
|
||||||
|
this.expiry = this.expirations[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = 'Failed to load expirations: ' + e.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const sym = encodeURIComponent(this.symbol);
|
||||||
|
const exp = encodeURIComponent(this.expiry);
|
||||||
|
|
||||||
|
const [analyticsRes, snapshotsRes] = await Promise.all([
|
||||||
|
fetch(`/api/analytics?symbol=${sym}&expiry=${exp}`),
|
||||||
|
fetch(`/api/snapshots?symbol=${sym}&limit=30`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!analyticsRes.ok) throw new Error(`Analytics API: HTTP ${analyticsRes.status}`);
|
||||||
|
if (!snapshotsRes.ok) throw new Error(`Snapshots API: HTTP ${snapshotsRes.status}`);
|
||||||
|
|
||||||
|
const analyticsEnv = await analyticsRes.json();
|
||||||
|
this.analytics = analyticsEnv.data ?? analyticsEnv;
|
||||||
|
|
||||||
|
const snapshotEnv = await snapshotsRes.json();
|
||||||
|
this.snapshots = snapshotEnv.data?.snapshots ?? snapshotEnv.snapshots ?? [];
|
||||||
|
|
||||||
|
this.lastUpdated = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// Wait a tick for DOM, then render charts
|
||||||
|
await this.$nextTick();
|
||||||
|
renderRR25Chart(this.snapshots);
|
||||||
|
renderFly25Chart(this.snapshots);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadData failed:', e);
|
||||||
|
this.error = `Failed to load data: ${e.message}`;
|
||||||
|
|
||||||
|
// In dev/demo, render placeholder charts with mock data
|
||||||
|
const mockSnaps = this._mockSnapshots();
|
||||||
|
this.snapshots = mockSnaps;
|
||||||
|
await this.$nextTick();
|
||||||
|
renderRR25Chart(mockSnaps);
|
||||||
|
renderFly25Chart(mockSnaps);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ symbol: this.symbol, expiry: this.expiry }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Refresh API: HTTP ${res.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('refresh failed:', e.message);
|
||||||
|
// Non-fatal — proceed to reload data anyway
|
||||||
|
}
|
||||||
|
await this.fetchExpirations();
|
||||||
|
await this.loadData();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- computed getters ----
|
||||||
|
get atmIv() {
|
||||||
|
const v = this.analytics.atmIv;
|
||||||
|
if (v == null || isNaN(Number(v))) return '—';
|
||||||
|
return (Number(v) * 100).toFixed(2) + '%';
|
||||||
|
},
|
||||||
|
|
||||||
|
get rr25() {
|
||||||
|
const sk = this.analytics.skewMetrics;
|
||||||
|
if (sk && !Array.isArray(sk)) return sk[this.expiry]?.rr25 ?? 0;
|
||||||
|
if (Array.isArray(sk) && sk.length > 0) return sk[0].rr25 ?? 0;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
get fly25() {
|
||||||
|
const sk = this.analytics.skewMetrics;
|
||||||
|
if (sk && !Array.isArray(sk)) return sk[this.expiry]?.fly25 ?? 0;
|
||||||
|
if (Array.isArray(sk) && sk.length > 0) return sk[0].fly25 ?? 0;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
get spot() {
|
||||||
|
return this.analytics.spot ?? this.analytics.underlyingPrice ?? 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- format helpers ----
|
||||||
|
formatSigned(val, dp = 4) {
|
||||||
|
if (val == null || isNaN(Number(val))) return '—';
|
||||||
|
const n = Number(val);
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return sign + n.toFixed(dp);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatPct(val) {
|
||||||
|
if (val == null || isNaN(Number(val))) return '—';
|
||||||
|
const n = Number(val) * 100;
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return sign + n.toFixed(2) + '%';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatSpot(val) {
|
||||||
|
if (val == null || isNaN(Number(val))) return '—';
|
||||||
|
return '$' + Number(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- dev/demo mock data ----
|
||||||
|
_mockSnapshots() {
|
||||||
|
const now = Date.now();
|
||||||
|
const interval = 5 * 60 * 1000; // 5 min
|
||||||
|
return Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const t = now - (29 - i) * interval;
|
||||||
|
const base = 0.002;
|
||||||
|
const rr = base + (Math.random() - 0.5) * 0.006;
|
||||||
|
const fly = Math.abs(Math.random() * 0.004) - 0.001;
|
||||||
|
return {
|
||||||
|
timestamp: new Date(t).toISOString(),
|
||||||
|
rr25: parseFloat(rr.toFixed(5)),
|
||||||
|
fly25: parseFloat(fly.toFixed(5)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
})); // end Alpine.data('dashboard')
|
||||||
|
}); // end alpine:init
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
894
frontend/surface.html
Normal file
894
frontend/surface.html
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vol Surface — Options Pricer</title>
|
||||||
|
<link rel="stylesheet" href="/assets/tabler.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/tabler-vendors.min.css">
|
||||||
|
<script src="/assets/apexcharts.min.js"></script>
|
||||||
|
<script src="/assets/alpine.min.js" defer></script>
|
||||||
|
<script src="/assets/tabler.min.js" defer></script>
|
||||||
|
<style>
|
||||||
|
.navbar-vertical {
|
||||||
|
width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-vertical .navbar-brand {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: #1e2030;
|
||||||
|
border: 1px solid #2d3045;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .card-header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid #2d3045;
|
||||||
|
}
|
||||||
|
|
||||||
|
#skewChart,
|
||||||
|
#termChart {
|
||||||
|
background: #1e2030;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-metric {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline .stat-label {
|
||||||
|
color: #8b95a7;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline .stat-value {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline .stat-value.positive {
|
||||||
|
color: #51cf66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline .stat-value.negative {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-inline .stat-value.amber {
|
||||||
|
color: #ffd43b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrapper {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apexcharts-tooltip {
|
||||||
|
background: #1e2030 !important;
|
||||||
|
border: 1px solid #2d3045 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apexcharts-tooltip-title {
|
||||||
|
background: #2d3045 !important;
|
||||||
|
border-bottom: 1px solid #3a3f5a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skew-table th {
|
||||||
|
background: #1a1c2e;
|
||||||
|
color: #8b95a7;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
border-bottom: 1px solid #2d3045;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skew-table td {
|
||||||
|
border-bottom: 1px solid #1e2030;
|
||||||
|
color: #d0d5e0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skew-table tbody tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skew-table .mono {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div class="wrapper" x-data="surfaceApp()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu"
|
||||||
|
aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
class="text-primary">
|
||||||
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline>
|
||||||
|
<polyline points="16 7 22 7 22 13"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-bold text-white fs-5">Options Pricer</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7"></rect>
|
||||||
|
<rect x="14" y="3" width="7" height="7"></rect>
|
||||||
|
<rect x="14" y="14" width="7" height="7"></rect>
|
||||||
|
<rect x="3" y="14" width="7" height="7"></rect>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="chain.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6"></line>
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6"></line>
|
||||||
|
<line x1="3" y1="12" x2="3.01" y2="12"></line>
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Options Chain</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="surface.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path d="M3 3v18h18"></path>
|
||||||
|
<path d="M7 16l4-8 4 4 4-4"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Vol Surface</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="tracker.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Tracker</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="page-header d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title">Volatility Surface</h2>
|
||||||
|
<div class="text-secondary mt-1">IV skew analysis and term structure</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-sm-auto">
|
||||||
|
<label class="form-label text-secondary" for="symbolInput">Symbol</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
id="symbolInput"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
style="background:#1e2030; border-color:#2d3045; color:#fff; width:100px; text-transform:uppercase;"
|
||||||
|
placeholder="SPY"
|
||||||
|
x-model="symbol"
|
||||||
|
@keydown.enter="fetchExpirations()"
|
||||||
|
@input="symbol = symbol.toUpperCase()"
|
||||||
|
:disabled="loading"
|
||||||
|
aria-label="Ticker symbol"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="fetchExpirations()"
|
||||||
|
:disabled="lookingUp || !symbol"
|
||||||
|
aria-label="Look up expirations for symbol"
|
||||||
|
>
|
||||||
|
<span x-show="lookingUp" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span x-text="lookingUp ? '…' : 'Lookup'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-auto">
|
||||||
|
<label class="form-label text-secondary" for="expirySelect">Expiry</label>
|
||||||
|
<select
|
||||||
|
id="expirySelect"
|
||||||
|
class="form-select"
|
||||||
|
style="background:#1e2030; border-color:#2d3045; color:#fff; min-width:160px;"
|
||||||
|
x-model="expiry"
|
||||||
|
:disabled="loading || expirations.length === 0"
|
||||||
|
aria-label="Select expiry date"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select expiry…</option>
|
||||||
|
<template x-for="exp in expirations" :key="exp">
|
||||||
|
<option :value="exp" x-text="exp"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-auto">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="loadSurface()"
|
||||||
|
:disabled="loading || !symbol || !expiry"
|
||||||
|
aria-label="Load volatility surface"
|
||||||
|
>
|
||||||
|
<span x-show="loading" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
<span x-text="loading ? 'Loading…' : 'Load Surface'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-auto ms-sm-auto" x-show="errorMsg" x-cloak>
|
||||||
|
<div class="alert alert-danger mb-0 py-2 px-3" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" class="me-1">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
|
</svg>
|
||||||
|
<span x-text="errorMsg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skew metric badges -->
|
||||||
|
<div class="mb-4" x-show="hasData" x-cloak>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
|
||||||
|
<div class="stat-inline">
|
||||||
|
<span class="stat-label">ATM IV</span>
|
||||||
|
<span class="stat-value" x-text="formatPct(currentMetrics.atmIV)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-inline">
|
||||||
|
<span class="stat-label">RR25</span>
|
||||||
|
<span
|
||||||
|
class="stat-value"
|
||||||
|
:class="{
|
||||||
|
'positive': currentMetrics.rr25 > 0.005,
|
||||||
|
'negative': currentMetrics.rr25 < -0.005
|
||||||
|
}"
|
||||||
|
x-text="formatPctSigned(currentMetrics.rr25)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-inline">
|
||||||
|
<span class="stat-label">Fly25</span>
|
||||||
|
<span
|
||||||
|
class="stat-value"
|
||||||
|
:class="{ 'amber': Math.abs(currentMetrics.fly25) > 0.002 }"
|
||||||
|
x-text="formatPctSigned(currentMetrics.fly25)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Greeks card -->
|
||||||
|
<div class="card mb-4" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
|
||||||
|
<div class="card-header" style="border-bottom:1px solid #2d3045;">
|
||||||
|
<h3 class="card-title text-white mb-0">Greeks</h3>
|
||||||
|
<div class="card-options">
|
||||||
|
<span class="text-secondary small">ATM & nearest ITM · <span x-text="expiry"></span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter skew-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col" class="text-end">Strike</th>
|
||||||
|
<th scope="col" class="text-end">IV</th>
|
||||||
|
<th scope="col" class="text-end">Mid</th>
|
||||||
|
<th scope="col" class="text-end">Delta</th>
|
||||||
|
<th scope="col" class="text-end">Gamma</th>
|
||||||
|
<th scope="col" class="text-end">Theta</th>
|
||||||
|
<th scope="col" class="text-end">Vega</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-if="greeks.atmCall">
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-blue-lt text-blue">ATM Call</span></td>
|
||||||
|
<td class="mono text-end" x-text="greeks.atmCall.strike"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPct(greeks.atmCall.iv)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPrice(greeks.atmCall.midPrice)"></td>
|
||||||
|
<td class="mono text-end text-success" x-text="fmtGreek(greeks.atmCall.delta)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtGreek4(greeks.atmCall.gamma)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmCall.theta)"></td>
|
||||||
|
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmCall.vega)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="greeks.atmPut">
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-orange-lt text-orange">ATM Put</span></td>
|
||||||
|
<td class="mono text-end" x-text="greeks.atmPut.strike"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPct(greeks.atmPut.iv)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPrice(greeks.atmPut.midPrice)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.delta)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtGreek4(greeks.atmPut.gamma)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.atmPut.theta)"></td>
|
||||||
|
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.atmPut.vega)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="greeks.itmCall">
|
||||||
|
<tr style="border-top:1px solid #2d3045;">
|
||||||
|
<td><span class="badge bg-blue-lt" style="color:#8ec8ff;">ITM Call</span></td>
|
||||||
|
<td class="mono text-end" x-text="greeks.itmCall.strike"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPct(greeks.itmCall.iv)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPrice(greeks.itmCall.midPrice)"></td>
|
||||||
|
<td class="mono text-end text-success" x-text="fmtGreek(greeks.itmCall.delta)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtGreek4(greeks.itmCall.gamma)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmCall.theta)"></td>
|
||||||
|
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmCall.vega)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="greeks.itmPut">
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-orange-lt" style="color:#ffb347;">ITM Put</span></td>
|
||||||
|
<td class="mono text-end" x-text="greeks.itmPut.strike"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPct(greeks.itmPut.iv)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtPrice(greeks.itmPut.midPrice)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.delta)"></td>
|
||||||
|
<td class="mono text-end" x-text="fmtGreek4(greeks.itmPut.gamma)"></td>
|
||||||
|
<td class="mono text-end text-danger" x-text="fmtGreek(greeks.itmPut.theta)"></td>
|
||||||
|
<td class="mono text-end text-warning" x-text="fmtGreek(greeks.itmPut.vega)"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row -->
|
||||||
|
<div class="row g-4 mb-4" x-show="hasData" x-cloak>
|
||||||
|
|
||||||
|
<!-- IV Skew chart -->
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title text-white mb-0">
|
||||||
|
IV Skew —
|
||||||
|
<span x-text="symbol"></span>
|
||||||
|
<span class="text-secondary ms-1" x-text="expiry"></span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="skewChart" style="min-height:320px;" role="img" aria-label="IV skew line chart showing calls and puts by strike price"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Term structure chart -->
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title text-white mb-0">ATM IV Term Structure</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="termChart" style="min-height:320px;" role="img" aria-label="ATM IV bar chart across expiry dates"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skew table -->
|
||||||
|
<div class="card" style="background:#161824; border:1px solid #2d3045;" x-show="hasData" x-cloak>
|
||||||
|
<div class="card-header" style="border-bottom:1px solid #2d3045;">
|
||||||
|
<h3 class="card-title text-white mb-0">Per-Expiry Skew Metrics</h3>
|
||||||
|
<div class="card-options">
|
||||||
|
<span class="text-secondary small" x-text="`${skewTable.length} expiries`"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter skew-table mb-0" aria-label="Per-expiry skew metrics table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Expiry</th>
|
||||||
|
<th scope="col" class="text-end">ATM IV</th>
|
||||||
|
<th scope="col" class="text-end">RR25</th>
|
||||||
|
<th scope="col" class="text-end">RR10</th>
|
||||||
|
<th scope="col" class="text-end">Fly25</th>
|
||||||
|
<th scope="col" class="text-center">Skew Direction</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in skewTable" :key="row.expiry">
|
||||||
|
<tr>
|
||||||
|
<td class="mono" x-text="row.expiry"></td>
|
||||||
|
<td class="mono text-end" x-text="formatPct(row.atmIV)"></td>
|
||||||
|
<td
|
||||||
|
class="mono text-end"
|
||||||
|
:class="{
|
||||||
|
'text-success': row.rr25 > 0.005,
|
||||||
|
'text-danger': row.rr25 < -0.005
|
||||||
|
}"
|
||||||
|
x-text="formatPctSigned(row.rr25)"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="mono text-end"
|
||||||
|
:class="{
|
||||||
|
'text-success': row.rr10 > 0.005,
|
||||||
|
'text-danger': row.rr10 < -0.005
|
||||||
|
}"
|
||||||
|
x-text="formatPctSigned(row.rr10)"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="mono text-end"
|
||||||
|
:class="{ 'text-warning': Math.abs(row.fly25) > 0.002 }"
|
||||||
|
x-text="formatPctSigned(row.fly25)"
|
||||||
|
></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="{
|
||||||
|
'bg-danger-lt text-danger': row.rr25 < -0.005,
|
||||||
|
'bg-success-lt text-success': row.rr25 > 0.005,
|
||||||
|
'bg-secondary-lt text-secondary': row.rr25 >= -0.005 && row.rr25 <= 0.005
|
||||||
|
}"
|
||||||
|
x-text="row.rr25 < -0.005 ? 'Put Skew' : row.rr25 > 0.005 ? 'Call Skew' : 'Flat'"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="card text-center py-5" style="background:#161824; border:1px solid #2d3045;" x-show="!hasData && !loading" x-cloak>
|
||||||
|
<div class="card-body">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" class="text-secondary mb-3">
|
||||||
|
<path d="M3 3v18h18"></path>
|
||||||
|
<path d="M7 16l4-8 4 4 4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-secondary">No surface loaded</h3>
|
||||||
|
<p class="text-muted">Enter a symbol and click Lookup, then select an expiry and click Load Surface.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div x-show="loading" x-cloak>
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading skew chart…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="card" style="background:#161824; border:1px solid #2d3045; min-height:340px;">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading term structure chart…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CHART_BG = '#1e2030';
|
||||||
|
const CHART_GRID = '#2d3045';
|
||||||
|
const CHART_LABEL = '#8b95a7';
|
||||||
|
const CHART_TOOLTIP_BG = '#1a1c2e';
|
||||||
|
const COLOR_CALL = '#4d9ef7'; // blue
|
||||||
|
const COLOR_PUT = '#ff8c42'; // orange
|
||||||
|
|
||||||
|
const BASE_CHART_OPTS = {
|
||||||
|
chart: {
|
||||||
|
background: CHART_BG,
|
||||||
|
foreColor: CHART_LABEL,
|
||||||
|
toolbar: { show: false },
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
animations: { enabled: true, speed: 400 }
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: CHART_GRID,
|
||||||
|
strokeDashArray: 3
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
style: { fontSize: '12px' }
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
legend: {
|
||||||
|
labels: { colors: '#d0d5e0' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function surfaceApp() {
|
||||||
|
return {
|
||||||
|
symbol: 'SPY',
|
||||||
|
expiry: '',
|
||||||
|
expirations: [],
|
||||||
|
analytics: {},
|
||||||
|
loading: false,
|
||||||
|
lookingUp: false,
|
||||||
|
hasData: false,
|
||||||
|
errorMsg: '',
|
||||||
|
skewTable: [],
|
||||||
|
currentMetrics: { atmIV: 0, rr25: 0, fly25: 0 },
|
||||||
|
greeks: { atmCall: null, atmPut: null, itmCall: null, itmPut: null },
|
||||||
|
skewChartInstance: null,
|
||||||
|
termChartInstance: null,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// no auto-load — user must click Lookup first
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchExpirations() {
|
||||||
|
if (!this.symbol) return;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.lookingUp = true;
|
||||||
|
this.expirations = [];
|
||||||
|
this.expiry = '';
|
||||||
|
this.hasData = false;
|
||||||
|
try {
|
||||||
|
const sym = this.symbol.toUpperCase().trim();
|
||||||
|
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const env = await res.json();
|
||||||
|
const data = env.data ?? env;
|
||||||
|
this.expirations = data.expirations || (Array.isArray(data) ? data : []);
|
||||||
|
if (this.expirations.length > 0) this.expiry = this.expirations[0];
|
||||||
|
} catch (err) {
|
||||||
|
this.errorMsg = 'Failed to look up symbol: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.lookingUp = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSurface() {
|
||||||
|
if (!this.symbol || !this.expiry) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/analytics?symbol=${encodeURIComponent(this.symbol)}&expiry=${encodeURIComponent(this.expiry)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
const env = await res.json();
|
||||||
|
const data = env.data ?? env;
|
||||||
|
this.analytics = data;
|
||||||
|
this._processAnalytics(data);
|
||||||
|
} catch (err) {
|
||||||
|
this.errorMsg = 'Failed to load surface: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_processAnalytics(data) {
|
||||||
|
const volSurface = data.volSurface || {};
|
||||||
|
// skewMetrics is an object keyed by expiry string
|
||||||
|
const skewMetrics = data.skewMetrics || {};
|
||||||
|
|
||||||
|
// Current expiry metrics (field name is atmIv, lowercase v)
|
||||||
|
const currentSkew = skewMetrics[this.expiry] || {};
|
||||||
|
this.currentMetrics = {
|
||||||
|
atmIV: currentSkew.atmIv ?? 0,
|
||||||
|
rr25: currentSkew.rr25 ?? 0,
|
||||||
|
fly25: currentSkew.fly25 ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build skew table from all expiries
|
||||||
|
const allExpiries = volSurface.expiries || Object.keys(skewMetrics);
|
||||||
|
this.skewTable = allExpiries.map(exp => {
|
||||||
|
const m = skewMetrics[exp] || {};
|
||||||
|
return {
|
||||||
|
expiry: exp,
|
||||||
|
atmIV: m.atmIv ?? 0,
|
||||||
|
rr25: m.rr25 ?? 0,
|
||||||
|
rr10: m.rr10 ?? 0,
|
||||||
|
fly25: m.fly25 ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.greeks = data.greeks || { atmCall: null, atmPut: null, itmCall: null, itmPut: null };
|
||||||
|
this.hasData = true;
|
||||||
|
|
||||||
|
// Wait a tick for x-show to render the divs
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this._renderSkewChart(data);
|
||||||
|
this._renderTermChart(data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderSkewChart(data) {
|
||||||
|
const strikes = data.strikes || [];
|
||||||
|
const callIVs = data.callIVs || [];
|
||||||
|
const putIVs = data.putIVs || [];
|
||||||
|
const spotPrice = data.spot ?? null;
|
||||||
|
|
||||||
|
const callSeries = callIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
|
||||||
|
const putSeries = putIVs.map(v => v != null ? parseFloat((v * 100).toFixed(2)) : null);
|
||||||
|
|
||||||
|
const annotations = spotPrice != null ? {
|
||||||
|
xaxis: [{
|
||||||
|
x: spotPrice,
|
||||||
|
borderColor: '#ffd43b',
|
||||||
|
strokeDashArray: 5,
|
||||||
|
label: {
|
||||||
|
text: `Spot ${spotPrice}`,
|
||||||
|
style: { color: '#ffd43b', background: '#1a1c2e', fontSize: '11px' }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
...BASE_CHART_OPTS,
|
||||||
|
chart: {
|
||||||
|
...BASE_CHART_OPTS.chart,
|
||||||
|
type: 'line',
|
||||||
|
height: 320
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: 'Calls', data: callSeries, color: COLOR_CALL },
|
||||||
|
{ name: 'Puts', data: putSeries, color: COLOR_PUT }
|
||||||
|
],
|
||||||
|
xaxis: {
|
||||||
|
categories: strikes,
|
||||||
|
title: { text: 'Strike', style: { color: CHART_LABEL } },
|
||||||
|
labels: {
|
||||||
|
style: { colors: CHART_LABEL },
|
||||||
|
rotate: -30,
|
||||||
|
formatter: v => Number(v).toFixed(0)
|
||||||
|
},
|
||||||
|
axisBorder: { color: CHART_GRID },
|
||||||
|
axisTicks: { color: CHART_GRID }
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: { text: 'Implied Volatility (%)', style: { color: CHART_LABEL } },
|
||||||
|
labels: {
|
||||||
|
style: { colors: CHART_LABEL },
|
||||||
|
formatter: v => v != null ? `${v.toFixed(1)}%` : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: { width: 2, curve: 'smooth' },
|
||||||
|
markers: { size: 0 },
|
||||||
|
annotations
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.skewChartInstance) {
|
||||||
|
this.skewChartInstance.updateOptions(opts, true, true);
|
||||||
|
} else {
|
||||||
|
this.skewChartInstance = new ApexCharts(document.getElementById('skewChart'), opts);
|
||||||
|
this.skewChartInstance.render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderTermChart(data) {
|
||||||
|
const skewMetrics = data.skewMetrics || {};
|
||||||
|
const volSurface = data.volSurface || {};
|
||||||
|
const allExpiries = volSurface.expiries || Object.keys(skewMetrics) || [];
|
||||||
|
|
||||||
|
const atmValues = allExpiries.map(exp => {
|
||||||
|
const m = skewMetrics[exp] || {};
|
||||||
|
return m.atmIv != null ? parseFloat((m.atmIv * 100).toFixed(2)) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color gradient: blue (near) to green (far)
|
||||||
|
const barColors = allExpiries.map((_, i) => {
|
||||||
|
const ratio = allExpiries.length > 1 ? i / (allExpiries.length - 1) : 0;
|
||||||
|
const r = Math.round((1 - ratio) * 0x4d + ratio * 0x40);
|
||||||
|
const g = Math.round((1 - ratio) * 0x9e + ratio * 0xc0);
|
||||||
|
const b = Math.round((1 - ratio) * 0xf7 + ratio * 0x60);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
...BASE_CHART_OPTS,
|
||||||
|
chart: {
|
||||||
|
...BASE_CHART_OPTS.chart,
|
||||||
|
type: 'bar',
|
||||||
|
height: 320
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'ATM IV',
|
||||||
|
data: atmValues
|
||||||
|
}],
|
||||||
|
colors: barColors,
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
distributed: true,
|
||||||
|
borderRadius: 4,
|
||||||
|
columnWidth: '60%'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: allExpiries,
|
||||||
|
title: { text: 'Expiry', style: { color: CHART_LABEL } },
|
||||||
|
labels: {
|
||||||
|
style: { colors: CHART_LABEL },
|
||||||
|
rotate: -40,
|
||||||
|
rotateAlways: allExpiries.length > 6
|
||||||
|
},
|
||||||
|
axisBorder: { color: CHART_GRID },
|
||||||
|
axisTicks: { color: CHART_GRID }
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: { text: 'ATM IV (%)', style: { color: CHART_LABEL } },
|
||||||
|
labels: {
|
||||||
|
style: { colors: CHART_LABEL },
|
||||||
|
formatter: v => `${v.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { show: false },
|
||||||
|
tooltip: {
|
||||||
|
...BASE_CHART_OPTS.tooltip,
|
||||||
|
y: {
|
||||||
|
formatter: v => `${v.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.termChartInstance) {
|
||||||
|
this.termChartInstance.updateOptions(opts, true, true);
|
||||||
|
} else {
|
||||||
|
this.termChartInstance = new ApexCharts(document.getElementById('termChart'), opts);
|
||||||
|
this.termChartInstance.render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Greeks formatting helpers
|
||||||
|
fmtPct(v) { return v == null || isNaN(v) ? '—' : `${(v*100).toFixed(2)}%`; },
|
||||||
|
fmtPrice(v) { return v == null || isNaN(v) ? '—' : `$${v.toFixed(2)}`; },
|
||||||
|
fmtGreek(v) { return v == null || isNaN(v) ? '—' : v.toFixed(4); },
|
||||||
|
fmtGreek4(v) { return v == null || isNaN(v) ? '—' : v.toFixed(5); },
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
formatPct(v) {
|
||||||
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
return `${(v * 100).toFixed(2)}%`;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatPctSigned(v) {
|
||||||
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
const pct = (v * 100).toFixed(2);
|
||||||
|
return v >= 0 ? `+${pct}%` : `${pct}%`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Demo data for development / when API is unavailable
|
||||||
|
_demoExpirations() {
|
||||||
|
return [
|
||||||
|
'2025-05-16', '2025-05-23', '2025-06-20',
|
||||||
|
'2025-07-18', '2025-09-19', '2025-12-19', '2026-01-16'
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
_demoAnalytics() {
|
||||||
|
const expiries = this._demoExpirations();
|
||||||
|
const strikes = [];
|
||||||
|
const callIVs = [];
|
||||||
|
const putIVs = [];
|
||||||
|
const spot = 540;
|
||||||
|
|
||||||
|
for (let k = spot - 60; k <= spot + 60; k += 5) {
|
||||||
|
strikes.push(k);
|
||||||
|
const moneyness = (k - spot) / spot;
|
||||||
|
// Put skew: OTM puts have higher IV
|
||||||
|
const callIV = 0.14 + moneyness * 0.05 + Math.random() * 0.005;
|
||||||
|
const putIV = 0.14 - moneyness * 0.08 + Math.random() * 0.005;
|
||||||
|
callIVs.push(Math.max(0.05, callIV));
|
||||||
|
putIVs.push(Math.max(0.05, putIV));
|
||||||
|
}
|
||||||
|
|
||||||
|
const skewMetrics = {};
|
||||||
|
expiries.forEach((exp, i) => {
|
||||||
|
const dte = (i + 1) * 30;
|
||||||
|
const baseVol = 0.12 + 0.04 * Math.sqrt(dte / 30);
|
||||||
|
skewMetrics[exp] = {
|
||||||
|
atmIv: baseVol + (Math.random() - 0.5) * 0.01,
|
||||||
|
rr25: -0.02 - i * 0.003 + (Math.random() - 0.5) * 0.004,
|
||||||
|
rr10: -0.035 - i * 0.004,
|
||||||
|
fly25: 0.005 + i * 0.001
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
spot,
|
||||||
|
strikes,
|
||||||
|
callIVs,
|
||||||
|
putIVs,
|
||||||
|
volSurface: { expiries },
|
||||||
|
skewMetrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
714
frontend/tracker.html
Normal file
714
frontend/tracker.html
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
|
||||||
|
<title>Options Metrics Tracker</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/assets/tabler.min.css"/>
|
||||||
|
<link rel="stylesheet" href="/assets/tabler-vendors.min.css"/>
|
||||||
|
<script src="/assets/apexcharts.min.js"></script>
|
||||||
|
<script src="/assets/alpine.min.js" defer></script>
|
||||||
|
<script src="/assets/tabler.min.js" defer></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--chart-bg: #1a1c23;
|
||||||
|
--chart-grid: rgba(255, 255, 255, 0.06);
|
||||||
|
--chart-label: #8b95a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #1a1c23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-vertical.navbar-dark {
|
||||||
|
background-color: #14161c;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background-color: var(--chart-bg);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apexcharts-canvas {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-snapshots th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--chart-label);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-bar {
|
||||||
|
background-color: #14161c;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active-page {
|
||||||
|
color: #4fc3f7 !important;
|
||||||
|
background-color: rgba(79, 195, 247, 0.1) !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-fat-tails {
|
||||||
|
background-color: #f59f00;
|
||||||
|
color: #1a1c23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-thin-tails {
|
||||||
|
background-color: #d63939;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-normal {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #8b95a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-count-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(79, 195, 247, 0.15);
|
||||||
|
color: #4fc3f7;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="antialiased" x-data="trackerApp()" x-init="init()">
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
<!-- Vertical Sidebar -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- Toggle for mobile -->
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<h1 class="navbar-brand navbar-brand-autodark">
|
||||||
|
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="#4fc3f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"></polyline>
|
||||||
|
<polyline points="16 7 22 7 22 13"></polyline>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-bold" style="color:#4fc3f7; font-size:1rem; letter-spacing:0.04em;">OPTIONS PRICER</span>
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Nav links -->
|
||||||
|
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="index.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect>
|
||||||
|
<rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="chain.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18"></line>
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line>
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Options Chain</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="surface.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 3l7 7 4-4 7 7"></path>
|
||||||
|
<path d="M21 17v4h-4"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Vol Surface</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active-page" href="tracker.html">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Tracker</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="page-wrapper">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar-bar py-2 px-3 mb-3">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
|
||||||
|
<!-- Symbol input -->
|
||||||
|
<div class="input-group input-group-sm" style="max-width:140px;">
|
||||||
|
<span class="input-group-text bg-dark border-secondary text-muted">Symbol</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control bg-dark border-secondary text-white"
|
||||||
|
placeholder="SPY"
|
||||||
|
x-model="symbol"
|
||||||
|
@keydown.enter="fetchExpirations()"
|
||||||
|
style="text-transform:uppercase;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry select -->
|
||||||
|
<div class="input-group input-group-sm" style="max-width:200px;">
|
||||||
|
<span class="input-group-text bg-dark border-secondary text-muted">Expiry</span>
|
||||||
|
<select
|
||||||
|
class="form-select bg-dark border-secondary text-white"
|
||||||
|
x-model="expiry"
|
||||||
|
@change="renderCharts()"
|
||||||
|
>
|
||||||
|
<option value="">All expirations</option>
|
||||||
|
<template x-for="exp in expirations" :key="exp">
|
||||||
|
<option :value="exp" x-text="exp"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
@click="loadHistory()"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span x-show="loading" class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
<span x-show="!loading">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
class="me-1" style="vertical-align:-2px;">
|
||||||
|
<polyline points="1 4 1 10 7 10"></polyline>
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 .49-3.51"></path>
|
||||||
|
</svg>
|
||||||
|
Load History
|
||||||
|
</span>
|
||||||
|
<span x-show="loading">Loading…</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Snapshot count badge -->
|
||||||
|
<span class="snapshot-count-badge" x-text="filteredSnapshots.length + ' snapshots'"></span>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<span x-show="error" x-cloak class="text-danger small ms-2" x-text="error"></span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page body -->
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-xl">
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div x-show="!loading && filteredSnapshots.length === 0" x-cloak class="text-center py-5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="#8b95a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mb-3">
|
||||||
|
<path d="M3 3l18 18"></path>
|
||||||
|
<path d="M21 21l-1.5-1.5M16.5 16.5L12 12M12 12L7.5 7.5M7.5 7.5L3 3"></path>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
</svg>
|
||||||
|
<p class="text-muted">No snapshot data. Enter a symbol and click Load History.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row -->
|
||||||
|
<div x-show="filteredSnapshots.length > 0" x-cloak>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
|
||||||
|
<!-- Chart 1: ATM IV -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="chart-card p-3">
|
||||||
|
<div class="d-flex align-items-center mb-2 gap-2">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#00d4ff;"></span>
|
||||||
|
<span class="fw-semibold text-white" style="font-size:0.9rem;">ATM Implied Volatility History</span>
|
||||||
|
</div>
|
||||||
|
<div id="chart-atm-iv" style="min-height:220px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart 2: RR25 + Chart 3: Fly25 side by side on wider screens -->
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="chart-card p-3">
|
||||||
|
<div class="d-flex align-items-center mb-2 gap-2">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#2fb344;"></span>
|
||||||
|
<span class="fw-semibold text-white" style="font-size:0.9rem;">25Δ Risk Reversal History</span>
|
||||||
|
</div>
|
||||||
|
<div id="chart-rr25" style="min-height:220px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="chart-card p-3">
|
||||||
|
<div class="d-flex align-items-center mb-2 gap-2">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59f00;"></span>
|
||||||
|
<span class="fw-semibold text-white" style="font-size:0.9rem;">25Δ Butterfly History</span>
|
||||||
|
</div>
|
||||||
|
<div id="chart-fly25" style="min-height:220px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History table -->
|
||||||
|
<div class="card" style="background-color:#14161c; border:1px solid rgba(255,255,255,0.07);">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between"
|
||||||
|
style="border-bottom:1px solid rgba(255,255,255,0.07);">
|
||||||
|
<h3 class="card-title text-white mb-0" style="font-size:0.9rem; font-weight:600;">
|
||||||
|
Snapshot History
|
||||||
|
<span class="ms-2 text-muted" style="font-size:0.75rem; font-weight:400;">
|
||||||
|
(most recent first, showing up to 20)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover table-snapshots mb-0"
|
||||||
|
style="color:#c8d3e0;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Expiry</th>
|
||||||
|
<th>Spot</th>
|
||||||
|
<th>ATM IV</th>
|
||||||
|
<th>RR25</th>
|
||||||
|
<th>RR10</th>
|
||||||
|
<th>Fly25</th>
|
||||||
|
<th>Butterfly Signal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="(snap, idx) in tableRows" :key="snap.timestamp + idx">
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="white-space:nowrap; font-size:0.8rem;"
|
||||||
|
x-text="formatTimestamp(snap.timestamp)"></td>
|
||||||
|
<td x-text="snap.expiry || '—'"></td>
|
||||||
|
<td x-text="snap.spot ? snap.spot.toFixed(2) : '—'"></td>
|
||||||
|
<td>
|
||||||
|
<span x-text="snap.atmIv ? (snap.atmIv * 100).toFixed(2) + '%' : '—'"
|
||||||
|
style="color:#4fc3f7; font-variant-numeric:tabular-nums;"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:style="{ color: snap.rr25 > 0 ? '#2fb344' : snap.rr25 < 0 ? '#d63939' : '#8b95a1' }"
|
||||||
|
style="font-variant-numeric:tabular-nums;"
|
||||||
|
x-text="snap.rr25 != null ? snap.rr25.toFixed(4) : '—'">
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:style="{ color: snap.rr10 > 0 ? '#2fb344' : snap.rr10 < 0 ? '#d63939' : '#8b95a1' }"
|
||||||
|
style="font-variant-numeric:tabular-nums;"
|
||||||
|
x-text="snap.rr10 != null ? snap.rr10.toFixed(4) : '—'">
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-variant-numeric:tabular-nums;"
|
||||||
|
x-text="snap.fly25 != null ? snap.fly25.toFixed(4) : '—'"></td>
|
||||||
|
<td>
|
||||||
|
<template x-if="snap.fly25 > 0.002">
|
||||||
|
<span class="badge badge-fat-tails">Fat Tails</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="snap.fly25 <= 0.002 && snap.fly25 < -0.002">
|
||||||
|
<span class="badge badge-thin-tails">Thin Tails</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="snap.fly25 != null && snap.fly25 >= -0.002 && snap.fly25 <= 0.002">
|
||||||
|
<span class="badge badge-normal">Normal</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="snap.fly25 == null">
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="tableRows.length === 0">
|
||||||
|
<td colspan="8" class="text-center text-muted py-3">No data available</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alpine + ApexCharts logic -->
|
||||||
|
<script>
|
||||||
|
const CHART_DEFAULTS = {
|
||||||
|
background: 'transparent',
|
||||||
|
foreColor: '#8b95a1',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AXIS_STYLE = {
|
||||||
|
borderColor: 'rgba(255,255,255,0.06)',
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#8b95a1', fontSize: '11px' }
|
||||||
|
},
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
theme: 'dark',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRID_STYLE = {
|
||||||
|
borderColor: 'rgba(255,255,255,0.06)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── ATM IV chart ──────────────────────────────────────────────────────────
|
||||||
|
function buildAtmIvChart(data) {
|
||||||
|
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||||
|
const series = sorted.map(s => ({
|
||||||
|
x: new Date(s.timestamp).getTime(),
|
||||||
|
y: s.atmIv != null ? parseFloat((s.atmIv * 100).toFixed(4)) : null,
|
||||||
|
})).filter(p => p.y !== null);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
height: 220,
|
||||||
|
toolbar: { show: false },
|
||||||
|
...CHART_DEFAULTS,
|
||||||
|
animations: { enabled: true, speed: 400 },
|
||||||
|
zoom: { enabled: false },
|
||||||
|
},
|
||||||
|
series: [{ name: 'ATM IV', data: series }],
|
||||||
|
stroke: { curve: 'smooth', width: 2, colors: ['#00d4ff'] },
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.2,
|
||||||
|
opacityTo: 0.0,
|
||||||
|
stops: [0, 100],
|
||||||
|
colorStops: [{
|
||||||
|
offset: 0,
|
||||||
|
color: '#00d4ff',
|
||||||
|
opacity: 0.2,
|
||||||
|
}, {
|
||||||
|
offset: 100,
|
||||||
|
color: '#00d4ff',
|
||||||
|
opacity: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: ['#00d4ff'],
|
||||||
|
xaxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
...AXIS_STYLE,
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...AXIS_STYLE,
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#8b95a1', fontSize: '11px' },
|
||||||
|
formatter: v => v != null ? v.toFixed(1) + '%' : '',
|
||||||
|
},
|
||||||
|
title: { text: 'IV (%)', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
|
||||||
|
},
|
||||||
|
grid: GRID_STYLE,
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_STYLE,
|
||||||
|
x: { format: 'dd MMM yy HH:mm' },
|
||||||
|
y: { formatter: v => v != null ? v.toFixed(2) + '%' : 'N/A' },
|
||||||
|
},
|
||||||
|
markers: { size: series.length <= 15 ? 4 : 0, colors: ['#00d4ff'], strokeColors: '#1a1c23', strokeWidth: 2 },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RR25 chart ────────────────────────────────────────────────────────────
|
||||||
|
function buildRr25Chart(data) {
|
||||||
|
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||||
|
const series = sorted.map(s => ({
|
||||||
|
x: new Date(s.timestamp).getTime(),
|
||||||
|
y: s.rr25 != null ? parseFloat(s.rr25.toFixed(6)) : null,
|
||||||
|
})).filter(p => p.y !== null);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
height: 220,
|
||||||
|
toolbar: { show: false },
|
||||||
|
...CHART_DEFAULTS,
|
||||||
|
animations: { enabled: true, speed: 400 },
|
||||||
|
zoom: { enabled: false },
|
||||||
|
},
|
||||||
|
series: [{ name: 'RR25', data: series }],
|
||||||
|
stroke: { curve: 'smooth', width: 2, colors: ['#2fb344'] },
|
||||||
|
colors: ['#2fb344'],
|
||||||
|
xaxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
...AXIS_STYLE,
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...AXIS_STYLE,
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#8b95a1', fontSize: '11px' },
|
||||||
|
formatter: v => v != null ? v.toFixed(4) : '',
|
||||||
|
},
|
||||||
|
title: { text: 'RR25', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
|
||||||
|
},
|
||||||
|
grid: GRID_STYLE,
|
||||||
|
annotations: {
|
||||||
|
yaxis: [{
|
||||||
|
y: 0,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||||
|
strokeDashArray: 5,
|
||||||
|
label: {
|
||||||
|
text: 'Zero',
|
||||||
|
style: {
|
||||||
|
color: '#8b95a1',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '10px',
|
||||||
|
},
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_STYLE,
|
||||||
|
x: { format: 'dd MMM yy HH:mm' },
|
||||||
|
y: { formatter: v => v != null ? v.toFixed(4) : 'N/A' },
|
||||||
|
},
|
||||||
|
markers: { size: series.length <= 15 ? 4 : 0, colors: ['#2fb344'], strokeColors: '#1a1c23', strokeWidth: 2 },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fly25 chart ───────────────────────────────────────────────────────────
|
||||||
|
function buildFly25Chart(data) {
|
||||||
|
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||||
|
const filtered = sorted.filter(s => s.fly25 != null);
|
||||||
|
|
||||||
|
const values = filtered.map(s => parseFloat(s.fly25.toFixed(6)));
|
||||||
|
const timestamps = filtered.map(s => new Date(s.timestamp).getTime());
|
||||||
|
const barColors = values.map(v => v > 0 ? '#f59f00' : '#6c757d');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
height: 220,
|
||||||
|
toolbar: { show: false },
|
||||||
|
...CHART_DEFAULTS,
|
||||||
|
animations: { enabled: true, speed: 400 },
|
||||||
|
zoom: { enabled: false },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: 'Fly25',
|
||||||
|
data: filtered.map((s, i) => ({
|
||||||
|
x: timestamps[i],
|
||||||
|
y: values[i],
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
distributed: true,
|
||||||
|
borderRadius: 2,
|
||||||
|
columnWidth: filtered.length > 30 ? '90%' : '60%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: barColors,
|
||||||
|
legend: { show: false },
|
||||||
|
xaxis: {
|
||||||
|
type: 'datetime',
|
||||||
|
...AXIS_STYLE,
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
...AXIS_STYLE,
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#8b95a1', fontSize: '11px' },
|
||||||
|
formatter: v => v != null ? v.toFixed(4) : '',
|
||||||
|
},
|
||||||
|
title: { text: 'Fly25', style: { color: '#8b95a1', fontSize: '11px', fontWeight: 400 } },
|
||||||
|
},
|
||||||
|
grid: GRID_STYLE,
|
||||||
|
annotations: {
|
||||||
|
yaxis: [{
|
||||||
|
y: 0,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_STYLE,
|
||||||
|
x: { format: 'dd MMM yy HH:mm' },
|
||||||
|
y: { formatter: v => v != null ? v.toFixed(4) : 'N/A' },
|
||||||
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alpine component ──────────────────────────────────────────────────────
|
||||||
|
function trackerApp() {
|
||||||
|
return {
|
||||||
|
symbol: 'SPY',
|
||||||
|
expiry: '',
|
||||||
|
expirations: [],
|
||||||
|
snapshots: [],
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
|
||||||
|
_charts: { atmIv: null, rr25: null, fly25: null },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// expirations are built from snapshot data after loadHistory()
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchExpirations() {
|
||||||
|
if (!this.symbol.trim()) return;
|
||||||
|
try {
|
||||||
|
const sym = this.symbol.trim().toUpperCase();
|
||||||
|
const res = await fetch(`/api/expirations?symbol=${encodeURIComponent(sym)}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch expirations');
|
||||||
|
const env = await res.json();
|
||||||
|
const json = env.data ?? env;
|
||||||
|
this.expirations = Array.isArray(json) ? json : (json.expirations || []);
|
||||||
|
} catch (e) {
|
||||||
|
this.expirations = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadHistory() {
|
||||||
|
if (!this.symbol.trim()) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const sym = this.symbol.trim().toUpperCase();
|
||||||
|
const url = `/api/snapshots?symbol=${encodeURIComponent(sym)}&limit=100`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||||
|
const env = await res.json();
|
||||||
|
const json = env.data ?? env;
|
||||||
|
// Accept array or { snapshots: [...] }
|
||||||
|
this.snapshots = json.snapshots ?? (Array.isArray(json) ? json : []);
|
||||||
|
// Sort descending (most recent first)
|
||||||
|
this.snapshots.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
// Rebuild expiration list from data if API didn't provide it
|
||||||
|
if (this.expirations.length === 0) {
|
||||||
|
const expSet = new Set(this.snapshots.map(s => s.expiry).filter(Boolean));
|
||||||
|
this.expirations = [...expSet].sort();
|
||||||
|
}
|
||||||
|
this.$nextTick(() => this.renderCharts());
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
this.snapshots = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredSnapshots() {
|
||||||
|
if (!this.expiry) return this.snapshots;
|
||||||
|
return this.snapshots.filter(s => s.expiry === this.expiry);
|
||||||
|
},
|
||||||
|
|
||||||
|
get tableRows() {
|
||||||
|
// Most recent first, limited to 20
|
||||||
|
return this.filteredSnapshots.slice(0, 20);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTimestamp(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString('en-US', {
|
||||||
|
month: 'short', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCharts() {
|
||||||
|
const data = this.filteredSnapshots;
|
||||||
|
|
||||||
|
// Destroy existing instances
|
||||||
|
Object.keys(this._charts).forEach(k => {
|
||||||
|
if (this._charts[k]) {
|
||||||
|
try { this._charts[k].destroy(); } catch (_) {}
|
||||||
|
this._charts[k] = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
this._charts.atmIv = new ApexCharts(
|
||||||
|
document.querySelector('#chart-atm-iv'),
|
||||||
|
buildAtmIvChart(data)
|
||||||
|
);
|
||||||
|
this._charts.atmIv.render();
|
||||||
|
|
||||||
|
this._charts.rr25 = new ApexCharts(
|
||||||
|
document.querySelector('#chart-rr25'),
|
||||||
|
buildRr25Chart(data)
|
||||||
|
);
|
||||||
|
this._charts.rr25.render();
|
||||||
|
|
||||||
|
this._charts.fly25 = new ApexCharts(
|
||||||
|
document.querySelector('#chart-fly25'),
|
||||||
|
buildFly25Chart(data)
|
||||||
|
);
|
||||||
|
this._charts.fly25.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Re-render charts when expiry filter changes
|
||||||
|
// (triggered via @change on the select, which calls renderCharts() directly)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user