import { expect, test } from "bun:test"; import type { OptionContract, OptionsChain, PricePoint } from "../../../types/financials"; import { valueOption } from "../options-calculator/model"; import { calculateOptionGreeks, calculateOptionsSummary, historicalVolatility30d, solveChainVolatilities, type ChainVolatilities, } from "./analytics"; const NO_VOLS: ChainVolatilities = { valuationTime: 0, byStrike: new Map() }; function contract( strike: number, impliedVolatility: number, volume: number, openInterest: number, ): OptionContract { return { contractSymbol: String(strike), strike, currency: "USD", lastPrice: 1, change: 0, percentChange: 0, volume, openInterest, bid: 0.95, ask: 1.05, impliedVolatility, inTheMoney: false, expiration: Date.UTC(2027, 0, 15) / 1000, lastTradeDate: 0, }; } function priceHistory(): PricePoint[] { const points: PricePoint[] = [{ date: new Date(Date.UTC(2026, 0, 1)), close: 100 }]; for (let index = 0; index < 30; index += 1) { points.push({ date: new Date(Date.UTC(2026, 0, index + 2)), close: points.at(-1)!.close * Math.exp(index % 2 === 0 ? 0.01 : -0.01), }); } return points; } test("annualizes the latest 30 daily log returns, including persisted dates", () => { const expected = Math.sqrt((30 * 0.01 ** 2 / 29) * 252); expect(historicalVolatility30d(priceHistory())).toBeCloseTo(expected, 10); expect(historicalVolatility30d(priceHistory().map((point) => ({ ...point, date: point.date.toISOString() as unknown as Date, })))).toBeCloseTo(expected, 10); expect(historicalVolatility30d(priceHistory().slice(1))).toBeNull(); }); test("summarizes the selected expiration without presenting it as whole-chain volume", () => { const chain: OptionsChain = { underlyingSymbol: "AAPL", expirationDates: [Date.UTC(2027, 0, 15) / 1000], calls: [contract(100, 0.2, 100, 200), contract(105, 0.3, 200, 400)], puts: [contract(100, 0.22, 150, 180), contract(105, 0.4, 300, 420)], }; const summary = calculateOptionsSummary(chain, 101, priceHistory(), { valuationTime: 0, byStrike: new Map([[100, 0.21], [105, 0.3]]) }); expect(summary.atmImpliedVolatility).toBeCloseTo(0.21, 10); expect(summary.expirationVolume).toBe(750); expect(summary.putCallVolumeRatio).toBe(1.5); expect(summary.putCallOpenInterestRatio).toBe(1); expect(summary.impliedHistoricalRatio).toBeCloseTo( summary.atmImpliedVolatility! / summary.historicalVolatility30d!, 10, ); }); // A same-day expiry priced on calendar time, as OVDV and the pricer value it. // The vendor IVs (trading-time basis, and a 1e-5 placeholder) must not leak in. test("solves one calendar-time IV per strike from midpoints and ignores vendor IVs", () => { const expiration = Date.UTC(2026, 8, 23) / 1000; const now = Date.UTC(2026, 8, 23, 16, 0); // 12:00 ET, four hours to the close const days = 4 / 24; const spot = 769; const price = (side: "call" | "put", strike: number) => valueOption({ symbol: "SPY", side, spot, strike, daysToExpiry: days, rate: 0.04, volatility: 0.14, dividendYield: 0.04, marketPrice: 0 }).price; const quoted = (side: "call" | "put", strike: number, vendorIv: number): OptionContract => { const mid = price(side, strike); return { ...contract(strike, vendorIv, 10, 10), expiration, bid: mid - 0.005, ask: mid + 0.005 }; }; const strikes = [760, 767, 768, 769, 770, 771, 772]; const chain: OptionsChain = { underlyingSymbol: "SPY", expirationDates: [expiration], asOf: new Date(now).toISOString(), calls: strikes.map((strike) => quoted("call", strike, 0.06)), puts: [...strikes.map((strike) => quoted("put", strike, strike === 772 ? 1e-5 : 0.06)), // Deep in the money, quoted under intrinsic, with no bid on the call: no time value left. { ...contract(785, 1e-5, 10, 10), expiration, bid: 15.9, ask: 15.95 }], }; chain.calls.push({ ...contract(785, 0, 10, 10), expiration, bid: 0, ask: 0.01 }); const volatilities = solveChainVolatilities(chain, spot, 0, now); for (const strike of strikes.slice(1)) expect(volatilities.byStrike.get(strike)!).toBeCloseTo(0.14, 2); // Out-of-the-money quotes inside the tick: the wing reads off its solved neighbour, // not a zero volatility forced by the in-the-money spread. expect(volatilities.byStrike.get(760)).toBe(volatilities.byStrike.get(767)); expect(volatilities.byStrike.get(785)).toBe(volatilities.byStrike.get(772)); expect(calculateOptionsSummary(chain, spot, [], volatilities).atmImpliedVolatility).toBeCloseTo(0.14, 2); // Parity: equal gamma, and put delta = call delta - 1 at the same strike. const greeks = (strike: number, side: "call" | "put") => calculateOptionGreeks( (side === "call" ? chain.calls : chain.puts).find((c) => c.strike === strike), side, spot, 0, volatilities); const [call772, put772] = [greeks(772, "call")!, greeks(772, "put")!]; expect(put772.gamma).toBeCloseTo(call772.gamma, 10); expect(put772.delta).toBeCloseTo(call772.delta - 1, 10); expect(put772.delta).toBeGreaterThan(-0.95); // The two-sided put keeps its Greeks; the call nobody bids shows none. expect(greeks(785, "put")!.delta).toBeLessThan(greeks(772, "put")!.delta); expect(greeks(785, "put")!.delta).toBeCloseTo(-1, 4); expect(greeks(785, "call")).toBeUndefined(); }); // SPY 749 on 2026-09-23: a two-sided call next to a 0/0.01 put read delta 1.000 and // gamma .000 between 748 (.994) and 750 (.993), whose puts bid a cent. test("a strike without an out-of-the-money bid keeps delta monotonic across the chain", () => { const expiration = Date.UTC(2026, 8, 25) / 1000; const now = Date.UTC(2026, 8, 23, 16, 0); const spot = 767; const price = (side: "call" | "put", strike: number) => valueOption({ symbol: "SPY", side, spot, strike, daysToExpiry: 2.2, rate: 0.04, volatility: 0.22, dividendYield: 0.04, marketPrice: 0 }).price; const quoted = (side: "call" | "put", strike: number): OptionContract => { const mid = price(side, strike); const bid = Math.max(0, Math.round((mid - 0.07) * 100) / 100); return { ...contract(strike, 0, 10, 10), expiration, bid, ask: Math.round((mid + 0.07) * 100) / 100 }; }; const strikes = [746, 747, 748, 749, 750, 765, 766, 767, 768, 769]; const chain: OptionsChain = { underlyingSymbol: "SPY", expirationDates: [expiration], asOf: new Date(now).toISOString(), calls: strikes.map((strike) => quoted("call", strike)), puts: strikes.map((strike) => strike === 749 ? { ...quoted("put", strike), bid: 0, ask: 0.01 } : quoted("put", strike)) }; const volatilities = solveChainVolatilities(chain, spot, 0, now); const deltas = strikes.map((strike) => calculateOptionGreeks(chain.calls.find((c) => c.strike === strike), "call", spot, 0, volatilities)!.delta); for (let index = 1; index < deltas.length; index += 1) expect(deltas[index]!).toBeLessThan(deltas[index - 1]!); expect(deltas[3]!).toBeLessThan(1); expect(calculateOptionGreeks(chain.puts.find((p) => p.strike === 749), "put", spot, 0, volatilities)).toBeUndefined(); }); test("activity totals require each reported contract's input without discarding independent metrics", () => { const chain: OptionsChain = { underlyingSymbol: "AAPL", expirationDates: [], calls: [contract(100, .25, 10, 20)], puts: [contract(100, .25, 0, 0)], }; for (const invalid of [undefined, Number.NaN, Infinity, -1]) { const partial = { ...chain, calls: [{ ...chain.calls[0]!, openInterest: invalid }] }; const summary = calculateOptionsSummary(partial, 100, [], { valuationTime: 0, byStrike: new Map([[100, .25]]) }); expect(summary.expirationVolume).toBe(10); expect(summary.putCallVolumeRatio).toBe(0); expect(summary.putCallOpenInterestRatio).toBeNull(); expect(summary.atmImpliedVolatility).toBe(.25); } const missingVolume = calculateOptionsSummary({ ...chain, puts: [{ ...chain.puts[0]!, volume: undefined }] }, 100, [], NO_VOLS); expect(missingVolume.expirationVolume).toBeNull(); expect(missingVolume.putCallVolumeRatio).toBeNull(); expect(missingVolume.putCallOpenInterestRatio).toBe(0); const zero = calculateOptionsSummary({ ...chain, calls: [{ ...chain.calls[0]!, volume: 0, openInterest: 0 }] }, 100, [], NO_VOLS); expect(zero.expirationVolume).toBe(0); expect(zero.putCallVolumeRatio).toBeNull(); expect(zero.putCallOpenInterestRatio).toBeNull(); }); test("rejects bad observations inside the selected HV window instead of bridging them", () => { const good = priceHistory(); const extended = [{ date: new Date(Date.UTC(2025, 11, 31)), close: 100 }, ...good]; for (const bad of [{ high: 90, low: 110 }, { high: 99 }, { close: 0 }, { close: Number.NaN }]) { const points = extended.map((point, i) => i === 15 ? { ...point, ...bad } : point); expect(historicalVolatility30d(points)).toBeNull(); const summary = calculateOptionsSummary({ underlyingSymbol: "AAPL", expirationDates: [], calls: [contract(100, .2, 100, 200)], puts: [] }, 100, points, { valuationTime: 0, byStrike: new Map([[100, .2]]) }); expect(summary.historicalVolatilityUnavailableReason).toBeTruthy(); expect(summary.impliedHistoricalRatio).toBeNull(); expect(summary.atmImpliedVolatility).toBe(.2); } // An excluded earlier bad bar cannot contaminate a complete later window. expect(historicalVolatility30d([{ ...extended[0]!, high: 90, low: 110 }, ...good])).toBeCloseTo(historicalVolatility30d(good)!, 12); }); test("deduplicates corrections before selecting 31 observations and retains immutable rejected source data", () => { const good = priceHistory(); expect(historicalVolatility30d(good.slice(1).flatMap((point) => [point, point]))).toBeNull(); const broken = { ...good[15]!, high: 90, low: 110 }; const corrected = [...good.slice(0, 15), broken, ...good.slice(16), good[15]!]; expect(historicalVolatility30d(corrected)).toBeCloseTo(historicalVolatility30d(good)!, 12); const chain = { underlyingSymbol: "AAPL", expirationDates: [], calls: [], puts: [] }; const summary = calculateOptionsSummary(chain, 100, [...good, broken], NO_VOLS); expect(summary.historicalVolatility30d).toBeNull(); const diagnostic = summary.historicalVolatilityIntegrity!; expect(diagnostic.sourcePoints).toHaveLength(1); expect(diagnostic.sourcePoints[0]!.date).toBe(good[15]!.date.toISOString()); broken.high = 120; expect(diagnostic.sourcePoints[0]!.high).toBe(90); expect(Object.isFrozen(diagnostic.sourcePoints)).toBe(true); expect(Object.isFrozen(diagnostic.sourcePoints[0])).toBe(true); }); test("validates chronology without requiring full OHLC and handles finite extreme prices", () => { const good = priceHistory(); expect(historicalVolatility30d([...good].reverse())).toBeCloseTo(historicalVolatility30d(good)!, 12); expect(historicalVolatility30d([...good, { date: new Date(Number.NaN), close: 100 }])).toBeNull(); expect(historicalVolatility30d(good.map((point, i) => ({ ...point, close: i % 2 ? 1e300 : 1e-300 })))).toBeFinite(); expect(historicalVolatility30d(good.map((point) => ({ ...point, close: 100 })))).toBe(0); }); test("retains rejected-source diagnostics before enough history exists for HV30", () => { const chain = { underlyingSymbol: "AAPL", expirationDates: [], calls: [], puts: [] }; const bad = { date: new Date("2026-09-22"), close: 100, high: 90, low: 110 }; const summary = calculateOptionsSummary(chain, 100, [bad], NO_VOLS); expect(summary.historicalVolatility30d).toBeNull(); expect(summary.historicalVolatilityIntegrity!.sourcePoints).toHaveLength(1); expect(summary.historicalVolatilityUnavailableReason).toContain("inconsistent OHLC"); bad.high = 120; expect(summary.historicalVolatilityIntegrity!.sourcePoints[0]!.high).toBe(90); const missing = calculateOptionsSummary(chain, 100, [{ date: bad.date, close: 0 }], NO_VOLS); expect(missing.historicalVolatilityUnavailableReason).toContain("nonpositive close"); }); // Range-only history requests can return weekly observations even for a daily metric. test("HV30 rejects weekly and intraday bars instead of applying daily annualization", () => { const daily = priceHistory(); for (const step of [7 * 86_400_000, 3_600_000]) { const points = daily.map((point, index) => ({ ...point, date: new Date(Date.UTC(2026, 0, 1) + index * step) })); const summary = calculateOptionsSummary({ underlyingSymbol: "TEST", expirationDates: [], calls: [], puts: [] }, 100, points, NO_VOLS); expect(summary.historicalVolatility30d).toBeNull(); expect(summary.impliedHistoricalRatio).toBeNull(); expect(summary.historicalVolatilityUnavailableReason).toContain(step > 86_400_000 ? "weekly" : "intraday"); } });