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:
ojy
2026-05-13 03:22:23 +00:00
commit d08c2230a8
20 changed files with 6112 additions and 0 deletions

1380
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backend/package.json Normal file
View 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"
}
}

View 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;
}

View 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 };
}

View 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;
}

View 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
View 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);
}

View 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
View 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
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"],
"paths": {}
},
"include": ["src/**/*.ts"]
}