diff --git a/apps/api/gammascope_api/contracts/generated/experimental_analytics.py b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py new file mode 100644 index 0000000..24e66cc --- /dev/null +++ b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py @@ -0,0 +1,243 @@ +# generated by datamodel-codegen: +# filename: experimental-analytics.schema.json + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, confloat, conint, constr + + +class Mode(Enum): + latest = 'latest' + replay = 'replay' + + +class Meta(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + generatedAt: AwareDatetime + mode: Mode + sourceSessionId: constr(min_length=1) + sourceSnapshotTime: AwareDatetime + symbol: Literal['SPX'] + expiry: constr(pattern=r'^\d{4}-\d{2}-\d{2}$') + + +class SourceSnapshot(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + spot: float + forward: float + rowCount: conint(ge=0) + strikeCount: conint(ge=0) + timeToExpiryYears: confloat(ge=0.0) + + +class ExpectedRange(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + lower: float + upper: float + + +class Level(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + strike: float + closeAbove: float | None + closeBelow: float | None + + +class Right(Enum): + call = 'call' + put = 'put' + pair = 'pair' + + +class Flag(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + strike: float + right: Right + code: constr(min_length=1) + message: constr(min_length=1) + + +class PanelStatus(Enum): + ok = 'ok' + preview = 'preview' + insufficient_data = 'insufficient_data' + error = 'error' + + +class Severity(Enum): + info = 'info' + warning = 'warning' + error = 'error' + + +class Diagnostic(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: constr(min_length=1) + message: constr(min_length=1) + severity: Severity + + +class Point(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + x: float + y: float | None + + +class StrikeValue(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + strike: float | None + value: float | None + label: str | None + + +class Row(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + strike: float + + +class PanelWithRows(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + rows: list[Row] + + +class ForwardSummary(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + parityForward: float | None + forwardMinusSpot: float | None + atmStrike: float | None + atmStraddle: float | None + expectedRange: ExpectedRange | None + expectedMovePercent: float | None + + +class Method(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + key: constr(min_length=1) + label: constr(min_length=1) + status: PanelStatus + points: list[Point] + + +class IvSmiles(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + methods: list[Method] + + +class SmileDiagnostics(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + ivValley: StrikeValue + atmForwardIv: float | None + skewSlope: float | None + curvature: float | None + methodDisagreement: float | None + + +class Probabilities(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + levels: list[Level] + + +class TerminalDistribution(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + density: list[Point] + highestDensityZone: str | None + range68: str | None + range95: str | None + leftTailProbability: float | None + rightTailProbability: float | None + + +class SkewTail(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + tailBias: str | None + leftTailRichness: float | None + rightTailRichness: float | None + + +class QuoteQuality(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + score: confloat(ge=0.0, le=1.0) + flags: list[Flag] + + +class ExperimentalAnalytics(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + schema_version: Literal['1.0.0'] + meta: Meta + sourceSnapshot: SourceSnapshot + forwardSummary: ForwardSummary + ivSmiles: IvSmiles + smileDiagnostics: SmileDiagnostics + probabilities: Probabilities + terminalDistribution: TerminalDistribution + skewTail: SkewTail + moveNeeded: PanelWithRows + decayPressure: PanelWithRows + richCheap: PanelWithRows + quoteQuality: QuoteQuality + historyPreview: PanelWithRows diff --git a/apps/api/gammascope_api/experimental/__init__.py b/apps/api/gammascope_api/experimental/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py new file mode 100644 index 0000000..1b0a5df --- /dev/null +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from collections.abc import Mapping +from math import exp, isfinite, log, sqrt +from typing import Any + +from gammascope_api.experimental.iv_methods import black76_price, normal_cdf +from gammascope_api.experimental.models import diagnostic, optional_float, panel + + +def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: + if not _positive_finite(forward) or not _positive_finite(tau): + return _empty_probability_panel("Forward and time to expiry must be positive.") + points = _fit_points(iv_panel) + if len(points) < 2: + return _empty_probability_panel("A fitted smile is required.") + levels = [] + for point in points: + strike = point["x"] + sigma = point["y"] + d2 = (log(forward / strike) - 0.5 * sigma * sigma * tau) / (sigma * sqrt(tau)) + close_above = normal_cdf(d2) + levels.append({"strike": strike, "closeAbove": close_above, "closeBelow": None if close_above is None else 1 - close_above}) + return panel( + "preview", + "Risk-neutral probabilities", + [diagnostic("risk_neutral", "Probabilities are risk-neutral, not real-world.", "info")], + levels=levels, + ) + + +def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: + if not _positive_finite(forward) or not _positive_finite(tau) or not isfinite(rate): + return _empty_terminal_distribution_panel("Forward and time to expiry must be positive.") + points = _fit_points(iv_panel) + if len(points) < 3: + return _empty_terminal_distribution_panel("A fitted smile with at least three points is required.") + calls = [ + black76_price(forward=forward, strike=point["x"], tau=tau, rate=rate, sigma=point["y"], right="call") + for point in points + ] + strikes = [point["x"] for point in points] + density = [] + for index in range(1, len(points) - 1): + left_width = strikes[index] - strikes[index - 1] + right_width = strikes[index + 1] - strikes[index] + if left_width <= 0 or right_width <= 0: + return _empty_terminal_distribution_panel("Fitted smile strikes must be strictly increasing.") + curvature = 2 / (left_width + right_width) * ( + (calls[index + 1] - calls[index]) / right_width - (calls[index] - calls[index - 1]) / left_width + ) + density.append({"x": strikes[index], "y": max(0.0, curvature * exp(rate * tau))}) + if not density: + return _empty_terminal_distribution_panel("Density could not be estimated.") + highest = max(density, key=lambda point: point["y"] or 0) + probabilities = probability_panel(iv_panel, forward=forward, tau=tau, rate=rate)["levels"] + lower68, upper68 = _range_from_probabilities(probabilities, 0.16, 0.84) + lower95, upper95 = _range_from_probabilities(probabilities, 0.025, 0.975) + left_tail = next((level["closeBelow"] for level in probabilities if level["strike"] == lower95), None) + right_tail = next((level["closeAbove"] for level in probabilities if level["strike"] == upper95), None) + return panel( + "preview", + "Terminal distribution", + [], + density=density, + highestDensityZone=f"{highest['x']:.0f}", + range68=_range_label(lower68, upper68), + range95=_range_label(lower95, upper95), + leftTailProbability=left_tail, + rightTailProbability=right_tail, + ) + + +def skew_tail_panel(iv_panel: dict[str, Any], *, forward: float) -> dict[str, Any]: + if not _positive_finite(forward): + return _empty_skew_tail_panel("Forward must be positive.") + points = _fit_points(iv_panel) + if len(points) < 3: + return _empty_skew_tail_panel("A fitted smile is required.") + atm = min(points, key=lambda point: abs(float(point["x"]) - forward)) + left = points[0] + right = points[-1] + atm_iv = max(float(atm["y"]), 1e-9) + left_richness = float(left["y"]) / atm_iv + right_richness = float(right["y"]) / atm_iv + if left_richness - right_richness > 0.05: + bias = "Left-tail rich" + elif right_richness - left_richness > 0.05: + bias = "Right-tail rich" + else: + bias = "Balanced tails" + return panel( + "preview", + "Skew and tail asymmetry", + [], + tailBias=bias, + leftTailRichness=left_richness, + rightTailRichness=right_richness, + ) + + +def _fit_points(iv_panel: dict[str, Any]) -> list[dict[str, float]]: + methods = iv_panel.get("methods", []) if isinstance(iv_panel, Mapping) else [] + if not isinstance(methods, list): + return [] + for method in methods: + if not isinstance(method, Mapping): + continue + if method.get("key") == "spline_fit": + return _clean_fit_points(method.get("points", [])) + return [] + + +def _range_from_probabilities(levels: list[dict[str, Any]], lower_tail: float, upper_tail: float) -> tuple[float | None, float | None]: + lower = min(levels, key=lambda level: abs((level.get("closeBelow") or 0) - lower_tail), default={}).get("strike") + upper = min(levels, key=lambda level: abs((level.get("closeBelow") or 0) - upper_tail), default={}).get("strike") + return lower, upper + + +def _range_label(lower: float | None, upper: float | None) -> str | None: + if lower is None or upper is None: + return None + return f"{lower:.0f}-{upper:.0f}" + + +def _clean_fit_points(points: list[dict[str, Any]]) -> list[dict[str, float]]: + if not isinstance(points, list): + return [] + by_strike: dict[float, float] = {} + for point in points: + if not isinstance(point, Mapping): + continue + strike = optional_float(point.get("x")) + sigma = optional_float(point.get("y")) + if strike is None or sigma is None or strike <= 0 or sigma <= 0: + continue + by_strike[strike] = sigma + return [{"x": strike, "y": by_strike[strike]} for strike in sorted(by_strike)] + + +def _empty_probability_panel(message: str) -> dict[str, Any]: + return panel("insufficient_data", "Risk-neutral probabilities", [diagnostic("missing_fit", message, "warning")], levels=[]) + + +def _empty_terminal_distribution_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Terminal distribution", + [diagnostic("missing_fit", message, "warning")], + density=[], + highestDensityZone=None, + range68=None, + range95=None, + leftTailProbability=None, + rightTailProbability=None, + ) + + +def _empty_skew_tail_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Skew and tail asymmetry", + [diagnostic("missing_fit", message, "warning")], + tailBias=None, + leftTailRichness=None, + rightTailRichness=None, + ) + + +def _positive_finite(value: float) -> bool: + return isfinite(value) and value > 0 diff --git a/apps/api/gammascope_api/experimental/forward.py b/apps/api/gammascope_api/experimental/forward.py new file mode 100644 index 0000000..0ab06e4 --- /dev/null +++ b/apps/api/gammascope_api/experimental/forward.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import UTC, datetime, time +from math import exp +from statistics import median +from typing import Any + +from gammascope_api.experimental.models import diagnostic, optional_float, panel +from gammascope_api.experimental.quality import grouped_pairs, quote_flags + +EXPIRY_CUTOFF_UTC = time(hour=20, minute=0, tzinfo=UTC) +MIN_TAU_YEARS = 1 / (365 * 24 * 60 * 60) + + +def time_to_expiry_years(snapshot_time: str, expiry: str) -> float: + try: + snapshot_dt = _parse_datetime(snapshot_time) + expiry_date = datetime.fromisoformat(expiry).date() + except ValueError: + return 0.0 + expiry_dt = datetime.combine(expiry_date, EXPIRY_CUTOFF_UTC) + seconds = max((expiry_dt - snapshot_dt).total_seconds(), 0) + return seconds / (365 * 24 * 60 * 60) + + +def forward_summary_panel(snapshot: dict[str, Any]) -> dict[str, Any]: + spot = float(snapshot["spot"]) + rate = float(snapshot.get("risk_free_rate") or 0.0) + tau = max(time_to_expiry_years(str(snapshot["snapshot_time"]), str(snapshot["expiry"])), MIN_TAU_YEARS) + rows = list(snapshot.get("rows", [])) + forward_estimates: list[tuple[float, float]] = [] + clean_pairs = [] + + for pair in grouped_pairs(rows): + if pair.call is None or pair.put is None: + continue + if quote_flags(pair.call) or quote_flags(pair.put): + continue + call_mid = optional_float(pair.call.get("mid")) + put_mid = optional_float(pair.put.get("mid")) + if call_mid is None or put_mid is None: + continue + clean_pairs.append(pair) + forward_estimates.append((pair.strike, pair.strike + exp(rate * tau) * (call_mid - put_mid))) + + if not forward_estimates: + return panel( + "insufficient_data", + "Forward and expected move", + [diagnostic("missing_pairs", "No clean call/put pairs are available.", "warning")], + parityForward=None, + forwardMinusSpot=None, + atmStrike=None, + atmStraddle=None, + expectedRange=None, + expectedMovePercent=None, + ) + + near_atm = sorted(forward_estimates, key=lambda item: abs(item[0] - spot))[:15] + parity_forward = median(value for _, value in near_atm) + atm_pair = min(clean_pairs, key=lambda pair: abs(pair.strike - parity_forward)) + atm_straddle = _pair_straddle(atm_pair.call, atm_pair.put) + expected_range = None + expected_move_percent = None + if atm_straddle is not None: + expected_range = {"lower": parity_forward - atm_straddle, "upper": parity_forward + atm_straddle} + expected_move_percent = atm_straddle / parity_forward if parity_forward > 0 else None + + return panel( + "ok", + "Forward and expected move", + [], + parityForward=parity_forward, + forwardMinusSpot=parity_forward - spot, + atmStrike=atm_pair.strike, + atmStraddle=atm_straddle, + expectedRange=expected_range, + expectedMovePercent=expected_move_percent, + ) + + +def _pair_straddle(call: dict[str, Any] | None, put: dict[str, Any] | None) -> float | None: + if call is None or put is None: + return None + call_mid = optional_float(call.get("mid")) + put_mid = optional_float(put.get("mid")) + if call_mid is None or put_mid is None: + return None + return call_mid + put_mid + + +def _parse_datetime(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) diff --git a/apps/api/gammascope_api/experimental/iv_methods.py b/apps/api/gammascope_api/experimental/iv_methods.py new file mode 100644 index 0000000..945743b --- /dev/null +++ b/apps/api/gammascope_api/experimental/iv_methods.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from math import erf, exp, isfinite, log, pi, sqrt +from typing import Any, Literal + +import numpy as np +from scipy.interpolate import UnivariateSpline +from scipy.optimize import brentq + +from gammascope_api.experimental.forward import time_to_expiry_years +from gammascope_api.experimental.models import diagnostic, optional_float, panel +from gammascope_api.experimental.quality import grouped_pairs, quote_flags + +Right = Literal["call", "put"] +SIGMA_MIN = 0.0001 +SIGMA_MAX = 8.5 + + +def normal_cdf(value: float) -> float: + return 0.5 * (1 + erf(value / sqrt(2))) + + +def black76_price(*, forward: float, strike: float, tau: float, rate: float, sigma: float, right: Right) -> float: + if forward <= 0 or strike <= 0 or tau <= 0 or sigma <= 0: + return 0.0 + df = exp(-rate * tau) + vol_sqrt_t = sigma * sqrt(tau) + if vol_sqrt_t <= 0: + intrinsic = max(forward - strike, 0) if right == "call" else max(strike - forward, 0) + return df * intrinsic + d1 = (log(forward / strike) + 0.5 * sigma * sigma * tau) / vol_sqrt_t + d2 = d1 - vol_sqrt_t + if right == "call": + return df * (forward * normal_cdf(d1) - strike * normal_cdf(d2)) + return df * (strike * normal_cdf(-d2) - forward * normal_cdf(-d1)) + + +def implied_vol_black76(*, price: float, forward: float, strike: float, tau: float, rate: float, right: Right) -> float | None: + if price <= 0 or forward <= 0 or strike <= 0 or tau <= 0: + return None + df = exp(-rate * tau) + intrinsic = df * (max(forward - strike, 0) if right == "call" else max(strike - forward, 0)) + if price < intrinsic - 1e-8: + return None + + def objective(sigma: float) -> float: + return black76_price(forward=forward, strike=strike, tau=tau, rate=rate, sigma=sigma, right=right) - price + + try: + return float(brentq(objective, SIGMA_MIN, SIGMA_MAX, xtol=1e-8, maxiter=100)) + except ValueError: + return None + + +def build_iv_smiles_panel(snapshot: dict[str, Any], forward_summary: dict[str, Any]) -> dict[str, Any]: + forward = optional_float(forward_summary.get("parityForward")) or float(snapshot["forward"]) + rate = float(snapshot.get("risk_free_rate") or 0) + tau = max(time_to_expiry_years(str(snapshot["snapshot_time"]), str(snapshot["expiry"])), 1 / (365 * 24 * 60 * 60)) + rows = list(snapshot.get("rows", [])) + raw_otm = _otm_midpoint_points(rows, forward, tau, rate) + custom_points = _row_points(rows, "custom_iv") + broker_points = _row_points(rows, "ibkr_iv") + atm_straddle_points = _atm_straddle_points(forward_summary, forward, tau) + fitted = _fit_methods(raw_otm, forward, tau) + methods = [ + {"key": "custom_iv", "label": "Current custom IV", "status": "ok" if custom_points else "insufficient_data", "points": custom_points}, + {"key": "broker_iv", "label": "Broker IV diagnostic", "status": "preview" if broker_points else "insufficient_data", "points": broker_points}, + {"key": "otm_midpoint_black76", "label": "OTM midpoint Black-76", "status": "ok" if raw_otm else "insufficient_data", "points": raw_otm}, + {"key": "atm_straddle_iv", "label": "ATM straddle IV", "status": "preview" if atm_straddle_points else "insufficient_data", "points": atm_straddle_points}, + *fitted, + {"key": "last_price", "label": "Last-price diagnostic", "status": "insufficient_data", "points": []}, + ] + status = "preview" if any(method["points"] for method in methods) else "insufficient_data" + return panel( + status, + "IV smile methods", + [diagnostic("research_methods", "Fitted smile methods are experimental.", "info")], + methods=methods, + ) + + +def smile_diagnostics_panel(iv_panel: dict[str, Any], forward: float) -> dict[str, Any]: + spline = next((method for method in iv_panel.get("methods", []) if method.get("key") == "spline_fit"), None) + points = _clean_points((spline or {}).get("points") or []) + if not points: + return _empty_smile_diagnostics_panel("No fitted smile is available.") + finite_points = points + if not finite_points: + return _empty_smile_diagnostics_panel("No finite fitted smile points are available.") + valley = min(finite_points, key=lambda point: float(point["y"])) + atm = min(finite_points, key=lambda point: abs(float(point["x"]) - forward)) + left = finite_points[0] + right = finite_points[-1] + width = max(float(right["x"]) - float(left["x"]), 1.0) + skew_slope = (float(right["y"]) - float(left["y"])) / width + curvature = float(left["y"]) + float(right["y"]) - 2 * float(atm["y"]) + disagreement = _method_disagreement(iv_panel, float(atm["x"])) + return panel( + "preview", + "Smile diagnostics", + [], + ivValley={"strike": float(valley["x"]), "value": float(valley["y"]), "label": "Spline valley"}, + atmForwardIv=float(atm["y"]), + skewSlope=skew_slope, + curvature=curvature, + methodDisagreement=disagreement, + ) + + +def _row_points(rows: list[dict[str, Any]], key: str) -> list[dict[str, float]]: + points = [] + for row in rows: + strike = optional_float(row.get("strike")) + value = optional_float(row.get(key)) + if strike is not None and strike > 0 and value is not None and value > 0: + points.append({"x": strike, "y": value}) + return sorted(points, key=lambda point: point["x"]) + + +def _otm_midpoint_points(rows: list[dict[str, Any]], forward: float, tau: float, rate: float) -> list[dict[str, float]]: + points = [] + for pair in grouped_pairs(rows): + if pair.strike < forward: + selected = pair.put + right: Right = "put" + else: + selected = pair.call + right = "call" + if selected is None or quote_flags(selected): + continue + price = optional_float(selected.get("mid")) + if price is None: + continue + iv = implied_vol_black76(price=price, forward=forward, strike=pair.strike, tau=tau, rate=rate, right=right) + if iv is not None and isfinite(iv): + points.append({"x": pair.strike, "y": iv}) + return sorted(points, key=lambda point: point["x"]) + + +def _atm_straddle_points(forward_summary: dict[str, Any], forward: float, tau: float) -> list[dict[str, float]]: + straddle = optional_float(forward_summary.get("atmStraddle")) + atm = optional_float(forward_summary.get("atmStrike")) + if straddle is None or straddle <= 0 or atm is None or atm <= 0 or forward <= 0 or tau <= 0: + return [] + iv = (straddle / forward) * sqrt(pi / (2 * tau)) + return [{"x": atm, "y": iv}] + + +def _fit_methods(points: list[dict[str, float]], forward: float, tau: float) -> list[dict[str, Any]]: + if len(points) < 4 or forward <= 0 or tau <= 0: + return [ + {"key": "spline_fit", "label": "Spline fit", "status": "insufficient_data", "points": []}, + {"key": "quadratic_fit", "label": "Quadratic fit", "status": "insufficient_data", "points": []}, + {"key": "wing_weighted_fit", "label": "Wing-weighted fit", "status": "insufficient_data", "points": []}, + ] + x = np.array([log(point["x"] / forward) for point in points], dtype=float) + strikes = np.array([point["x"] for point in points], dtype=float) + total_variance = np.array([(point["y"] ** 2) * tau for point in points], dtype=float) + order = np.argsort(x) + x = x[order] + strikes = strikes[order] + total_variance = total_variance[order] + grid = np.linspace(float(x.min()), float(x.max()), 80) + grid_strikes = forward * np.exp(grid) + + spline = UnivariateSpline(x, total_variance, k=min(3, len(x) - 1), s=len(x) * 1e-7) + spline_points = _fit_points(grid_strikes, spline(grid), tau) + + quadratic_coefficients = np.polyfit(x, total_variance, deg=2) + quadratic_points = _fit_points(grid_strikes, np.polyval(quadratic_coefficients, grid), tau) + + weights = 1 + np.abs(x) / max(float(np.max(np.abs(x))), 1e-9) + wing_coefficients = np.polyfit(x, total_variance, deg=2, w=weights) + wing_points = _fit_points(grid_strikes, np.polyval(wing_coefficients, grid), tau) + + return [ + {"key": "spline_fit", "label": "Spline fit", "status": "preview", "points": spline_points}, + {"key": "quadratic_fit", "label": "Quadratic fit", "status": "preview", "points": quadratic_points}, + {"key": "wing_weighted_fit", "label": "Wing-weighted fit", "status": "preview", "points": wing_points}, + ] + + +def _fit_points(strikes: np.ndarray, total_variance: np.ndarray, tau: float) -> list[dict[str, float]]: + clean = np.maximum(total_variance, 1e-12) + ivs = np.sqrt(clean / tau) + return [{"x": float(strike), "y": float(iv)} for strike, iv in zip(strikes, ivs)] + + +def _method_disagreement(iv_panel: dict[str, Any], reference_strike: float) -> float | None: + method_values = [] + for method in iv_panel.get("methods", []): + value = _nearest_value(method.get("points", []), reference_strike) + if value is not None: + method_values.append(value) + if len(method_values) < 2: + return None + return max(method_values) - min(method_values) + + +def _nearest_value(points: list[dict[str, Any]], reference_strike: float) -> float | None: + clean = _clean_points(points) + if not clean: + return None + return min(clean, key=lambda point: abs(point["x"] - reference_strike))["y"] + + +def _clean_points(points: list[dict[str, Any]]) -> list[dict[str, float]]: + clean = [] + for point in points: + strike = optional_float(point.get("x")) + value = optional_float(point.get("y")) + if strike is not None and strike > 0 and value is not None and value > 0: + clean.append({"x": strike, "y": value}) + return sorted(clean, key=lambda point: point["x"]) + + +def _empty_smile_diagnostics_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Smile diagnostics", + [diagnostic("missing_fit", message, "warning")], + ivValley={"strike": None, "value": None, "label": None}, + atmForwardIv=None, + skewSlope=None, + curvature=None, + methodDisagreement=None, + ) diff --git a/apps/api/gammascope_api/experimental/models.py b/apps/api/gammascope_api/experimental/models.py new file mode 100644 index 0000000..9c50254 --- /dev/null +++ b/apps/api/gammascope_api/experimental/models.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import isfinite +from typing import Any, Literal + + +PanelStatus = Literal["ok", "preview", "insufficient_data", "error"] + + +@dataclass(frozen=True) +class StrikePair: + strike: float + call: dict[str, Any] | None + put: dict[str, Any] | None + + +def diagnostic(code: str, message: str, severity: Literal["info", "warning", "error"] = "info") -> dict[str, str]: + return {"code": code, "message": message, "severity": severity} + + +def panel( + status: PanelStatus, + label: str, + diagnostics: list[dict[str, str]] | None = None, + **values: Any, +) -> dict[str, Any]: + return {"status": status, "label": label, "diagnostics": diagnostics or [], **values} + + +def optional_float(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if isfinite(result) else None diff --git a/apps/api/gammascope_api/experimental/quality.py b/apps/api/gammascope_api/experimental/quality.py new file mode 100644 index 0000000..0219257 --- /dev/null +++ b/apps/api/gammascope_api/experimental/quality.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any + +from gammascope_api.experimental.models import StrikePair, diagnostic, optional_float, panel + +MAX_RELATIVE_SPREAD = 0.40 + + +def grouped_pairs(rows: list[dict[str, Any]]) -> list[StrikePair]: + grouped: dict[float, dict[str, dict[str, Any] | None]] = {} + for row in rows: + strike = optional_float(row.get("strike")) + if strike is None: + continue + bucket = grouped.setdefault(strike, {"call": None, "put": None}) + if row.get("right") == "call": + bucket["call"] = row + elif row.get("right") == "put": + bucket["put"] = row + return [ + StrikePair(strike=strike, call=bucket["call"], put=bucket["put"]) + for strike, bucket in sorted(grouped.items()) + ] + + +def quote_quality_panel(rows: list[dict[str, Any]]) -> dict[str, Any]: + flags: list[dict[str, Any]] = [] + usable_rows = 0 + for row in rows: + row_flags = quote_flags(row) + flags.extend(row_flags) + if not row_flags: + usable_rows += 1 + + score = usable_rows / len(rows) if rows else 0.0 + status = "ok" if score >= 0.8 else "preview" if rows else "insufficient_data" + diagnostics = [] if rows else [diagnostic("empty_chain", "No option rows are available.", "warning")] + return panel(status, "Quote quality", diagnostics, score=round(score, 4), flags=flags) + + +def quote_flags(row: dict[str, Any]) -> list[dict[str, Any]]: + bid = optional_float(row.get("bid")) + ask = optional_float(row.get("ask")) + strike = optional_float(row.get("strike")) + right = str(row.get("right") or "pair") + flags: list[dict[str, Any]] = [] + + if strike is None: + return [_flag(0.0, right, "invalid_strike", "Strike is missing or invalid.")] + + if bid is None or ask is None: + return [_flag(strike, right, "missing_bid_ask", "Bid or ask is missing.")] + if ask < bid: + flags.append(_flag(strike, right, "crossed_market", "Bid is above ask.")) + if bid <= 0: + flags.append(_flag(strike, right, "zero_bid", "Bid is zero or negative.")) + + mid = (bid + ask) / 2 + if mid > 0 and (ask - bid) / mid > MAX_RELATIVE_SPREAD: + flags.append(_flag(strike, right, "wide_spread", "Spread is wider than 40% of midpoint.")) + + if row.get("calc_status") == "below_intrinsic": + flags.append(_flag(strike, right, "below_intrinsic", "Midpoint is below discounted intrinsic value.")) + if row.get("calc_status") in {"vol_out_of_bounds", "solver_failed"}: + flags.append(_flag(strike, right, str(row["calc_status"]), "IV solve is unusable.")) + + return flags + + +def _flag(strike: float, right: str, code: str, message: str) -> dict[str, Any]: + return {"strike": strike, "right": right, "code": code, "message": message} diff --git a/apps/api/gammascope_api/experimental/service.py b/apps/api/gammascope_api/experimental/service.py new file mode 100644 index 0000000..bcde39e --- /dev/null +++ b/apps/api/gammascope_api/experimental/service.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, date, datetime +from math import isfinite +from typing import Any, Callable, Literal + +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.experimental.distribution import probability_panel, skew_tail_panel, terminal_distribution_panel +from gammascope_api.experimental.forward import time_to_expiry_years, forward_summary_panel +from gammascope_api.experimental.iv_methods import build_iv_smiles_panel, smile_diagnostics_panel +from gammascope_api.experimental.models import diagnostic, optional_float, panel +from gammascope_api.experimental.quality import quote_quality_panel +from gammascope_api.experimental.trade_maps import decay_pressure_panel, move_needed_panel, rich_cheap_panel + +ExperimentalMode = Literal["latest", "replay"] +PanelBuilder = Callable[[], dict[str, Any]] + + +def build_experimental_payload(snapshot: Any, mode: ExperimentalMode) -> dict[str, Any]: + normalized = _normalize_snapshot(snapshot) + rows = normalized["rows"] + tau = normalized["time_to_expiry_years"] + rate = normalized["risk_free_rate"] + + forward_summary = _safe_panel( + lambda: forward_summary_panel(normalized), + lambda: _empty_forward_summary_panel("Forward summary could not be built from available inputs."), + ) + model_forward = _first_finite( + forward_summary.get("parityForward"), + normalized.get("forward"), + normalized.get("spot"), + default=0.0, + ) + normalized["forward"] = model_forward + + iv_smiles = _safe_panel( + lambda: build_iv_smiles_panel(normalized, forward_summary), + lambda: _empty_iv_smiles_panel("IV smile methods could not be built from available inputs."), + ) + smile_diagnostics = _safe_panel( + lambda: smile_diagnostics_panel(iv_smiles, model_forward), + lambda: _empty_smile_diagnostics_panel("Smile diagnostics could not be built from available inputs."), + ) + probabilities = _safe_panel( + lambda: probability_panel(iv_smiles, forward=model_forward, tau=tau, rate=rate), + lambda: _empty_probabilities_panel("Probabilities could not be built from available inputs."), + ) + terminal_distribution = _safe_panel( + lambda: terminal_distribution_panel(iv_smiles, forward=model_forward, tau=tau, rate=rate), + lambda: _empty_terminal_distribution_panel("Terminal distribution could not be built from available inputs."), + ) + skew_tail = _safe_panel( + lambda: skew_tail_panel(iv_smiles, forward=model_forward), + lambda: _empty_skew_tail_panel("Skew and tail asymmetry could not be built from available inputs."), + ) + move_needed = _safe_panel( + lambda: move_needed_panel(rows, spot=normalized["spot"], expected_move=optional_float(forward_summary.get("atmStraddle"))), + lambda: _empty_rows_panel("Move-needed map", "Move-needed map could not be built from available inputs."), + ) + decay_pressure = _safe_panel( + lambda: decay_pressure_panel(rows, minutes_to_expiry=tau * 365 * 24 * 60), + lambda: _empty_rows_panel("Time-decay pressure", "Time-decay pressure could not be built from available inputs."), + ) + rich_cheap = _safe_panel( + lambda: rich_cheap_panel(rows, iv_panel=iv_smiles, forward=model_forward, tau=tau, rate=rate), + lambda: _empty_rows_panel("Rich/cheap residuals", "Rich/cheap residuals could not be built from available inputs."), + ) + quote_quality = _safe_panel( + lambda: quote_quality_panel(rows), + lambda: _empty_quote_quality_panel("Quote quality could not be built from available inputs."), + ) + + payload = { + "schema_version": "1.0.0", + "meta": { + "generatedAt": _format_datetime(datetime.now(UTC)), + "mode": mode if mode in {"latest", "replay"} else "latest", + "sourceSessionId": normalized["source_session_id"], + "sourceSnapshotTime": _format_datetime(normalized["source_snapshot_time"]), + "symbol": "SPX", + "expiry": normalized["expiry"], + }, + "sourceSnapshot": { + "spot": normalized["spot"], + "forward": model_forward, + "rowCount": len(rows), + "strikeCount": _strike_count(rows), + "timeToExpiryYears": tau, + }, + "forwardSummary": forward_summary, + "ivSmiles": iv_smiles, + "smileDiagnostics": smile_diagnostics, + "probabilities": probabilities, + "terminalDistribution": terminal_distribution, + "skewTail": skew_tail, + "moveNeeded": move_needed, + "decayPressure": decay_pressure, + "richCheap": rich_cheap, + "quoteQuality": quote_quality, + "historyPreview": _empty_rows_panel("Range compression preview", "Select replay frames to compare history."), + } + return validate_experimental_payload(payload) + + +def validate_experimental_payload(payload: dict[str, Any]) -> dict[str, Any]: + safe_payload = _repair_payload_schema(_scrub_nonfinite(payload)) + return ExperimentalAnalytics.model_validate(safe_payload).model_dump(mode="json") + + +def _normalize_snapshot(snapshot: Any) -> dict[str, Any]: + source_snapshot_time = _coerce_datetime(_get(snapshot, "snapshot_time", "timestamp", "sourceSnapshotTime")) + expiry = _coerce_expiry(_get(snapshot, "expiry", "expiration", "expiryDate", "expirationDate"), source_snapshot_time) + spot = _finite_float(_get(snapshot, "spot"), default=0.0) + forward = _finite_float(_get(snapshot, "forward"), default=spot) + tau = _non_negative_float(_get(snapshot, "time_to_expiry_years", "timeToExpiryYears")) + if tau is None: + tau = time_to_expiry_years(_format_datetime(source_snapshot_time), expiry) + rate = _finite_float(_get(snapshot, "risk_free_rate", "riskFreeRate"), default=0.0) + + return { + "session_id": _source_session_id(snapshot), + "source_session_id": _source_session_id(snapshot), + "symbol": "SPX", + "expiry": expiry, + "snapshot_time": _format_datetime(source_snapshot_time), + "source_snapshot_time": source_snapshot_time, + "spot": spot, + "forward": forward, + "risk_free_rate": rate, + "time_to_expiry_years": tau, + "rows": _rows(snapshot), + } + + +def _safe_panel(builder: PanelBuilder, empty: Callable[[], dict[str, Any]]) -> dict[str, Any]: + try: + return builder() + except Exception: + fallback = empty() + fallback["diagnostics"] = [ + diagnostic("panel_unavailable", "Panel could not be built from available inputs.", "warning") + ] + return fallback + + +def _get(source: Any, *keys: str) -> Any: + for key in keys: + if isinstance(source, Mapping) and key in source: + return source[key] + if not isinstance(source, Mapping) and hasattr(source, key): + return getattr(source, key) + return None + + +def _source_session_id(snapshot: Any) -> str: + for key in ("session_id", "snapshot_id", "source_snapshot_id", "source", "sourceSessionId"): + value = _get(snapshot, key) + if value is not None and str(value).strip(): + return str(value) + return "unknown-session" + + +def _rows(snapshot: Any) -> list[dict[str, Any]]: + raw_rows = _get(snapshot, "rows") + if not isinstance(raw_rows, list): + return [] + rows = [] + for row in raw_rows: + if not isinstance(row, Mapping): + continue + clean = dict(row) + if clean.get("right") not in {"call", "put"}: + clean["right"] = None + rows.append(clean) + return rows + + +def _strike_count(rows: list[dict[str, Any]]) -> int: + strikes = set() + for row in rows: + strike = optional_float(row.get("strike")) + if strike is not None: + strikes.add(strike) + return len(strikes) + + +def _coerce_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return _aware_utc(value) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=UTC) + if isinstance(value, str): + try: + return _aware_utc(datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + pass + return datetime.now(UTC) + + +def _coerce_expiry(value: Any, fallback_time: datetime) -> str: + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str): + try: + return date.fromisoformat(value[:10]).isoformat() + except ValueError: + pass + return fallback_time.date().isoformat() + + +def _aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _format_datetime(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _finite_float(value: Any, *, default: float) -> float: + parsed = optional_float(value) + return parsed if parsed is not None else default + + +def _non_negative_float(value: Any) -> float | None: + parsed = optional_float(value) + if parsed is None or parsed < 0: + return None + return parsed + + +def _first_finite(*values: Any, default: float) -> float: + for value in values: + parsed = optional_float(value) + if parsed is not None: + return parsed + return default + + +def _scrub_nonfinite(value: Any) -> Any: + if isinstance(value, float): + return value if isfinite(value) else None + if isinstance(value, list): + return [_scrub_nonfinite(item) for item in value] + if isinstance(value, dict): + return {key: _scrub_nonfinite(item) for key, item in value.items()} + return value + + +def _repair_payload_schema(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + payload = {} + + source = _as_dict(payload.get("sourceSnapshot")) + source["spot"] = _finite_or_default(source.get("spot")) + source["forward"] = _finite_or_default(source.get("forward")) + source["rowCount"] = _non_negative_int(source.get("rowCount")) + source["strikeCount"] = _non_negative_int(source.get("strikeCount")) + source["timeToExpiryYears"] = _finite_or_default(source.get("timeToExpiryYears")) + payload["sourceSnapshot"] = source + + forward = _as_dict(payload.get("forwardSummary")) + for key in ("parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedMovePercent"): + forward[key] = _finite_or_none(forward.get(key)) + forward["expectedRange"] = _safe_expected_range(forward.get("expectedRange")) + payload["forwardSummary"] = forward + + iv_smiles = _as_dict(payload.get("ivSmiles")) + methods = iv_smiles.get("methods") + if isinstance(methods, list): + for method in methods: + if isinstance(method, dict): + method["points"] = _safe_points(method.get("points")) + payload["ivSmiles"] = iv_smiles + + diagnostics = _as_dict(payload.get("smileDiagnostics")) + valley = _as_dict(diagnostics.get("ivValley")) + valley["strike"] = _finite_or_none(valley.get("strike")) + valley["value"] = _finite_or_none(valley.get("value")) + diagnostics["ivValley"] = valley + for key in ("atmForwardIv", "skewSlope", "curvature", "methodDisagreement"): + diagnostics[key] = _finite_or_none(diagnostics.get(key)) + payload["smileDiagnostics"] = diagnostics + + probabilities = _as_dict(payload.get("probabilities")) + probabilities["levels"] = _safe_probability_levels(probabilities.get("levels")) + payload["probabilities"] = probabilities + + terminal = _as_dict(payload.get("terminalDistribution")) + terminal["density"] = _safe_points(terminal.get("density")) + for key in ("leftTailProbability", "rightTailProbability"): + terminal[key] = _finite_or_none(terminal.get(key)) + payload["terminalDistribution"] = terminal + + skew = _as_dict(payload.get("skewTail")) + for key in ("leftTailRichness", "rightTailRichness"): + skew[key] = _finite_or_none(skew.get(key)) + payload["skewTail"] = skew + + for key in ("moveNeeded", "decayPressure", "richCheap", "historyPreview"): + panel_payload = _as_dict(payload.get(key)) + panel_payload["rows"] = _safe_rows(panel_payload.get("rows")) + payload[key] = panel_payload + + quote_quality = _as_dict(payload.get("quoteQuality")) + quote_quality["score"] = min(max(_finite_or_default(quote_quality.get("score")), 0.0), 1.0) + quote_quality["flags"] = _safe_flags(quote_quality.get("flags")) + payload["quoteQuality"] = quote_quality + + return payload + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _finite_or_none(value: Any) -> float | None: + return optional_float(value) + + +def _finite_or_default(value: Any, default: float = 0.0) -> float: + parsed = optional_float(value) + return parsed if parsed is not None else default + + +def _non_negative_int(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return max(parsed, 0) + + +def _safe_expected_range(value: Any) -> dict[str, float] | None: + if not isinstance(value, Mapping): + return None + lower = optional_float(value.get("lower")) + upper = optional_float(value.get("upper")) + if lower is None or upper is None: + return None + return {"lower": lower, "upper": upper} + + +def _safe_points(value: Any) -> list[dict[str, float | None]]: + if not isinstance(value, list): + return [] + points = [] + for point in value: + if not isinstance(point, Mapping): + continue + x = optional_float(point.get("x")) + if x is None: + continue + points.append({"x": x, "y": _finite_or_none(point.get("y"))}) + return points + + +def _safe_probability_levels(value: Any) -> list[dict[str, float | None]]: + if not isinstance(value, list): + return [] + levels = [] + for level in value: + if not isinstance(level, Mapping): + continue + strike = optional_float(level.get("strike")) + if strike is None: + continue + levels.append( + { + "strike": strike, + "closeAbove": _finite_or_none(level.get("closeAbove")), + "closeBelow": _finite_or_none(level.get("closeBelow")), + } + ) + return levels + + +def _safe_rows(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + rows = [] + for row in value: + if not isinstance(row, Mapping): + continue + strike = optional_float(row.get("strike")) + if strike is None: + continue + clean = dict(row) + clean["strike"] = strike + rows.append(clean) + return rows + + +def _safe_flags(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + flags = [] + for flag in value: + if not isinstance(flag, Mapping): + continue + strike = optional_float(flag.get("strike")) + if strike is None: + continue + clean = dict(flag) + clean["strike"] = strike + flags.append(clean) + return flags + + +def _empty_forward_summary_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Forward and expected move", + [diagnostic("insufficient_data", message, "warning")], + parityForward=None, + forwardMinusSpot=None, + atmStrike=None, + atmStraddle=None, + expectedRange=None, + expectedMovePercent=None, + ) + + +def _empty_iv_smiles_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "IV smile methods", + [diagnostic("insufficient_data", message, "warning")], + methods=[], + ) + + +def _empty_smile_diagnostics_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Smile diagnostics", + [diagnostic("insufficient_data", message, "warning")], + ivValley={"strike": None, "value": None, "label": None}, + atmForwardIv=None, + skewSlope=None, + curvature=None, + methodDisagreement=None, + ) + + +def _empty_probabilities_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Risk-neutral probabilities", + [diagnostic("insufficient_data", message, "warning")], + levels=[], + ) + + +def _empty_terminal_distribution_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Terminal distribution", + [diagnostic("insufficient_data", message, "warning")], + density=[], + highestDensityZone=None, + range68=None, + range95=None, + leftTailProbability=None, + rightTailProbability=None, + ) + + +def _empty_skew_tail_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Skew and tail asymmetry", + [diagnostic("insufficient_data", message, "warning")], + tailBias=None, + leftTailRichness=None, + rightTailRichness=None, + ) + + +def _empty_rows_panel(label: str, message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + label, + [diagnostic("insufficient_data", message, "info")], + rows=[], + ) + + +def _empty_quote_quality_panel(message: str) -> dict[str, Any]: + return panel( + "insufficient_data", + "Quote quality", + [diagnostic("insufficient_data", message, "warning")], + score=0.0, + flags=[], + ) diff --git a/apps/api/gammascope_api/experimental/trade_maps.py b/apps/api/gammascope_api/experimental/trade_maps.py new file mode 100644 index 0000000..f4018ce --- /dev/null +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from bisect import bisect_left +from collections.abc import Mapping +from math import isfinite +from typing import Any + +from gammascope_api.experimental.iv_methods import black76_price +from gammascope_api.experimental.models import diagnostic, optional_float, panel + + +def move_needed_panel(rows: Any, *, spot: float, expected_move: float | None) -> dict[str, Any]: + out = [] + for row in _iter_rows(rows): + mid = optional_float(row.get("mid")) + strike = optional_float(row.get("strike")) + side = _option_side(row) + if mid is None or strike is None or strike <= 0 or side is None: + continue + if side == "call": + breakeven = strike + mid + move_needed = max(0.0, breakeven - spot) + else: + breakeven = strike - mid + move_needed = max(0.0, spot - breakeven) + ratio = move_needed / expected_move if expected_move and expected_move > 0 else None + out.append( + { + "strike": strike, + "side": side, + "breakeven": breakeven, + "moveNeeded": move_needed, + "expectedMoveRatio": ratio, + "label": _ratio_label(ratio), + } + ) + return panel("ok" if out else "insufficient_data", "Move-needed map", [], rows=out) + + +def decay_pressure_panel(rows: Any, *, minutes_to_expiry: float) -> dict[str, Any]: + if not _positive_finite(minutes_to_expiry): + return panel( + "insufficient_data", + "Time-decay pressure", + [diagnostic("invalid_time", "Minutes to expiry must be positive.", "warning")], + rows=[], + ) + out = [] + for row in _iter_rows(rows): + mid = optional_float(row.get("mid")) + strike = optional_float(row.get("strike")) + side = _option_side(row) + if mid is None or strike is None or strike <= 0 or side is None: + continue + out.append({"strike": strike, "side": side, "premium": mid, "pointsPerMinute": mid / minutes_to_expiry}) + return panel( + "preview" if out else "insufficient_data", + "Time-decay pressure", + [diagnostic("static_decay", "Static pressure assumes no spot or IV change.", "info")], + rows=out, + ) + + +def rich_cheap_panel(rows: Any, *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: + if not _positive_finite(forward) or not _positive_finite(tau) or not isfinite(rate): + return panel("insufficient_data", "Rich/cheap residuals", [diagnostic("invalid_model_inputs", "Forward and time to expiry must be positive.", "warning")], rows=[]) + fit_points = _fit_points(iv_panel) + out = [] + for row in _iter_rows(rows): + mid = optional_float(row.get("mid")) + strike = optional_float(row.get("strike")) + side = _option_side(row) + if mid is None or strike is None or strike <= 0 or side is None: + continue + sigma = _interpolated_sigma(fit_points, strike) + if sigma is None: + continue + fitted_fair = black76_price(forward=forward, strike=strike, tau=tau, rate=rate, sigma=sigma, right=side) + residual = mid - fitted_fair + out.append( + { + "strike": strike, + "side": side, + "actualMid": mid, + "fittedFair": fitted_fair, + "residual": residual, + "label": _residual_label(residual), + } + ) + return panel("preview" if out else "insufficient_data", "Rich/cheap residuals", [], rows=out) + + +def _ratio_label(ratio: float | None) -> str: + if ratio is None: + return "Expected move unavailable" + if ratio < 0.5: + return "Breakeven close" + if ratio <= 1.0: + return "Within expected move" + if ratio <= 1.5: + return "Needs above-normal move" + return "Lottery-like" + + +def _residual_label(residual: float) -> str: + if residual > 0.1: + return "Rich" + if residual < -0.1: + return "Cheap" + return "Inline" + + +def _iter_rows(rows: Any) -> list[Mapping[str, Any]]: + if not isinstance(rows, (list, tuple)): + return [] + return [row for row in rows if isinstance(row, Mapping)] + + +def _fit_points(iv_panel: dict[str, Any]) -> list[dict[str, float]]: + methods = iv_panel.get("methods", []) if isinstance(iv_panel, Mapping) else [] + if not isinstance(methods, list): + return [] + for method in methods: + if not isinstance(method, Mapping): + continue + if method.get("key") == "spline_fit": + by_strike = {} + points = method.get("points", []) + if not isinstance(points, list): + return [] + for point in points: + if not isinstance(point, Mapping): + continue + strike = optional_float(point.get("x")) + sigma = optional_float(point.get("y")) + if strike is not None and sigma is not None and strike > 0 and sigma > 0: + by_strike[strike] = sigma + return [{"x": strike, "y": by_strike[strike]} for strike in sorted(by_strike)] + return [] + + +def _interpolated_sigma(points: list[dict[str, float]], strike: float) -> float | None: + if not points: + return None + strikes = [point["x"] for point in points] + index = bisect_left(strikes, strike) + if index < len(points) and abs(points[index]["x"] - strike) <= 1e-9: + return points[index]["y"] + if index == 0 or index >= len(points): + return None + left = points[index - 1] + right = points[index] + width = right["x"] - left["x"] + if width <= 0: + return None + weight = (strike - left["x"]) / width + return left["y"] + weight * (right["y"] - left["y"]) + + +def _option_side(row: Mapping[str, Any]) -> str | None: + side = row.get("right") + if side == "call" or side == "put": + return side + return None + + +def _positive_finite(value: float) -> bool: + return isfinite(value) and value > 0 diff --git a/apps/api/gammascope_api/ingestion/live_snapshot.py b/apps/api/gammascope_api/ingestion/live_snapshot.py index ca44d29..9e37833 100644 --- a/apps/api/gammascope_api/ingestion/live_snapshot.py +++ b/apps/api/gammascope_api/ingestion/live_snapshot.py @@ -18,6 +18,7 @@ DEFAULT_DIVIDEND_YIELD = 0.01 DEFAULT_EXPIRY_CUTOFF_UTC = time(hour=20, minute=0, tzinfo=UTC) MIN_TAU_YEARS = 1 / (365 * 24 * 60 * 60) +SPX_DASHBOARD_SESSION_ID = "moomoo-spx-0dte-live" _ANALYTICS_MEMORY_FIELDS = ("custom_iv", "custom_gamma", "custom_vanna") _analytics_memory: dict[tuple[str, str], dict[str, float]] = {} @@ -99,6 +100,10 @@ def build_live_snapshot(state: CollectorState, *, session_id: str | None = None) } +def build_spx_dashboard_live_snapshot(state: CollectorState) -> dict[str, Any] | None: + return build_live_snapshot(state, session_id=SPX_DASHBOARD_SESSION_ID) or build_live_snapshot(state) + + def _active_expiry(*, contracts: list[dict[str, Any]], option_ticks: dict[str, dict[str, Any]]) -> str | None: ticked_contracts = [ contract for contract in contracts if str(contract["contract_id"]) in option_ticks and contract.get("expiry") is not None diff --git a/apps/api/gammascope_api/main.py b/apps/api/gammascope_api/main.py index af25a2b..1ec281b 100644 --- a/apps/api/gammascope_api/main.py +++ b/apps/api/gammascope_api/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError -from gammascope_api.routes import admin, collector, heatmap, replay, replay_imports, scenario, snapshot, status, stream, views +from gammascope_api.routes import admin, collector, experimental, heatmap, replay, replay_imports, scenario, snapshot, status, stream, views app = FastAPI(title="GammaScope API", version="0.1.0") @@ -14,6 +14,7 @@ app.include_router(admin.router) app.include_router(collector.router) app.include_router(snapshot.router) +app.include_router(experimental.router) app.include_router(heatmap.router) app.include_router(stream.router) app.include_router(replay.router) diff --git a/apps/api/gammascope_api/routes/experimental.py b/apps/api/gammascope_api/routes/experimental.py new file mode 100644 index 0000000..78c5cbb --- /dev/null +++ b/apps/api/gammascope_api/routes/experimental.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header + +from gammascope_api.auth import can_read_live_state +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.experimental.service import build_experimental_payload, validate_experimental_payload +from gammascope_api.fixtures import load_json_fixture +from gammascope_api.ingestion.latest_state_cache import cached_or_memory_collector_state +from gammascope_api.ingestion.live_snapshot import build_spx_dashboard_live_snapshot +from gammascope_api.routes import replay as replay_routes + + +router = APIRouter() + + +@router.get("/api/spx/0dte/experimental/latest", response_model=ExperimentalAnalytics) +def get_latest_experimental(x_gammascope_admin_token: str | None = Header(default=None)) -> dict: + if can_read_live_state(x_gammascope_admin_token): + live_snapshot = build_spx_dashboard_live_snapshot(cached_or_memory_collector_state()) + if live_snapshot is not None: + return build_experimental_payload(live_snapshot, "latest") + return validate_experimental_payload(load_json_fixture("experimental-analytics.seed.json")) + + +@router.get("/api/spx/0dte/experimental/replay/snapshot", response_model=ExperimentalAnalytics) +def get_replay_experimental_snapshot( + session_id: str, + at: str | None = None, + source_snapshot_id: str | None = None, +) -> dict: + snapshot = replay_routes.get_replay_snapshot( + session_id=session_id, + at=at, + source_snapshot_id=source_snapshot_id, + ) + return build_experimental_payload(snapshot, "replay") diff --git a/apps/api/gammascope_api/routes/scenario.py b/apps/api/gammascope_api/routes/scenario.py index 1f97722..1df6a11 100644 --- a/apps/api/gammascope_api/routes/scenario.py +++ b/apps/api/gammascope_api/routes/scenario.py @@ -6,7 +6,7 @@ from gammascope_api.analytics.scenario import create_scenario_snapshot from gammascope_api.fixtures import load_json_fixture from gammascope_api.ingestion.latest_state_cache import cached_or_memory_collector_state -from gammascope_api.ingestion.live_snapshot import build_live_snapshot +from gammascope_api.ingestion.live_snapshot import build_spx_dashboard_live_snapshot router = APIRouter() @@ -18,7 +18,7 @@ def create_scenario( x_gammascope_admin_token: str | None = Header(default=None), ) -> dict: if can_read_live_state(x_gammascope_admin_token): - live_snapshot = build_live_snapshot(cached_or_memory_collector_state()) + live_snapshot = build_spx_dashboard_live_snapshot(cached_or_memory_collector_state()) if live_snapshot is not None and live_snapshot["session_id"] == payload.get("session_id"): return create_scenario_snapshot(live_snapshot, payload) diff --git a/apps/api/gammascope_api/routes/snapshot.py b/apps/api/gammascope_api/routes/snapshot.py index 637a327..a43f3a2 100644 --- a/apps/api/gammascope_api/routes/snapshot.py +++ b/apps/api/gammascope_api/routes/snapshot.py @@ -3,7 +3,7 @@ from gammascope_api.auth import can_read_live_state from gammascope_api.fixtures import load_json_fixture from gammascope_api.ingestion.latest_state_cache import cached_or_memory_collector_state -from gammascope_api.ingestion.live_snapshot import build_live_snapshot +from gammascope_api.ingestion.live_snapshot import build_spx_dashboard_live_snapshot router = APIRouter() @@ -12,7 +12,7 @@ @router.get("/api/spx/0dte/snapshot/latest") def get_latest_snapshot(x_gammascope_admin_token: str | None = Header(default=None)) -> dict: if can_read_live_state(x_gammascope_admin_token): - live_snapshot = build_live_snapshot(cached_or_memory_collector_state()) + live_snapshot = build_spx_dashboard_live_snapshot(cached_or_memory_collector_state()) if live_snapshot is not None: return live_snapshot return load_json_fixture("analytics-snapshot.seed.json") diff --git a/apps/api/gammascope_api/routes/stream.py b/apps/api/gammascope_api/routes/stream.py index c1f9071..fe87648 100644 --- a/apps/api/gammascope_api/routes/stream.py +++ b/apps/api/gammascope_api/routes/stream.py @@ -6,7 +6,7 @@ from gammascope_api.auth import is_valid_admin_token, private_mode_enabled, websocket_admin_token from gammascope_api.fixtures import load_json_fixture from gammascope_api.ingestion.latest_state_cache import cached_or_memory_collector_state -from gammascope_api.ingestion.live_snapshot import build_live_snapshot +from gammascope_api.ingestion.live_snapshot import build_spx_dashboard_live_snapshot from gammascope_api.routes.replay import replay_stream_snapshots, seed_replay_snapshots @@ -66,7 +66,7 @@ async def stream_spx_0dte_replay( def _current_snapshot() -> dict[str, Any]: - live_snapshot = build_live_snapshot(cached_or_memory_collector_state()) + live_snapshot = build_spx_dashboard_live_snapshot(cached_or_memory_collector_state()) if live_snapshot is not None: return live_snapshot return load_json_fixture("analytics-snapshot.seed.json") diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 749578a..4d0241c 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -8,11 +8,13 @@ version = "0.1.0" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115", + "numpy>=2.0", "psycopg[binary]>=3.2", "pyarrow>=16", "pydantic>=2.8", "python-multipart>=0.0.22", "redis>=5.0", + "scipy>=1.13", "uvicorn[standard]>=0.30" ] diff --git a/apps/api/tests/test_contract_endpoints.py b/apps/api/tests/test_contract_endpoints.py index 060a19d..bf7e6b2 100644 --- a/apps/api/tests/test_contract_endpoints.py +++ b/apps/api/tests/test_contract_endpoints.py @@ -329,6 +329,38 @@ def test_latest_snapshot_prefers_ingested_live_snapshot() -> None: assert all(row["custom_vanna"] is not None for row in payload["rows"]) +def test_latest_snapshot_prefers_moomoo_spx_session_when_other_symbols_are_newer() -> None: + events = [ + _health_event("2026-04-24T15:30:00Z"), + _underlying_event("moomoo-spx-0dte-live", "2026-04-24T15:30:01Z", 5200.25), + _contract_event("moomoo-spx-0dte-live", "SPX-2026-04-24-C-5200", "call", "2026-04-24T15:30:01Z"), + _option_event("moomoo-spx-0dte-live", "SPX-2026-04-24-C-5200", "2026-04-24T15:30:02Z"), + _underlying_event("moomoo-ndx-0dte-live", "2026-04-24T15:30:03Z", 18300.0, symbol="NDX"), + _contract_event( + "moomoo-ndx-0dte-live", + "NDX-2026-04-24-C-18300", + "call", + "2026-04-24T15:30:03Z", + symbol="NDX", + strike=18300, + ), + _option_event("moomoo-ndx-0dte-live", "NDX-2026-04-24-C-18300", "2026-04-24T15:30:04Z"), + ] + + for event in events: + assert client.post("/api/spx/0dte/collector/events", json=event).status_code == 200 + + response = client.get("/api/spx/0dte/snapshot/latest") + + assert response.status_code == 200 + payload = response.json() + AnalyticsSnapshot.model_validate(payload) + assert payload["mode"] == "live" + assert payload["session_id"] == "moomoo-spx-0dte-live" + assert payload["symbol"] == "SPX" + assert payload["spot"] == 5200.25 + + def test_latest_snapshot_prefers_newer_ticked_expiry_when_state_contains_expired_contracts() -> None: session_id = "moomoo-spx-0dte-live" old_time = "2026-04-27T20:04:00Z" @@ -1036,12 +1068,12 @@ def _health_event(event_time: str) -> dict[str, object]: } -def _underlying_event(session_id: str, event_time: str, spot: float) -> dict[str, object]: +def _underlying_event(session_id: str, event_time: str, spot: float, *, symbol: str = "SPX") -> dict[str, object]: return { "schema_version": "1.0.0", "source": "ibkr", "session_id": session_id, - "symbol": "SPX", + "symbol": symbol, "spot": spot, "bid": spot - 0.5, "ask": spot + 0.5, @@ -1059,6 +1091,8 @@ def _contract_event( event_time: str, *, expiry: str = "2026-04-24", + symbol: str = "SPX", + strike: float = 5200, ) -> dict[str, object]: return { "schema_version": "1.0.0", @@ -1066,10 +1100,10 @@ def _contract_event( "session_id": session_id, "contract_id": contract_id, "ibkr_con_id": 900000, - "symbol": "SPX", + "symbol": symbol, "expiry": expiry, "right": right, - "strike": 5200, + "strike": strike, "multiplier": 100, "exchange": "CBOE", "currency": "USD", diff --git a/apps/api/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py new file mode 100644 index 0000000..795cf51 --- /dev/null +++ b/apps/api/tests/test_experimental_distribution.py @@ -0,0 +1,130 @@ +from math import exp + +import pytest + +from gammascope_api.experimental.distribution import probability_panel, terminal_distribution_panel, skew_tail_panel +from gammascope_api.experimental.iv_methods import black76_price + + +def fitted_iv_panel() -> dict: + points = [{"x": 95, "y": 0.22}, {"x": 100, "y": 0.18}, {"x": 105, "y": 0.21}] + return {"methods": [{"key": "spline_fit", "points": points}]} + + +def test_probability_panel_returns_risk_neutral_level_rows() -> None: + panel = probability_panel(fitted_iv_panel(), forward=100, tau=1 / 365, rate=0.0) + + assert panel["status"] == "preview" + assert panel["levels"][0]["strike"] == 95 + assert 0 <= panel["levels"][0]["closeAbove"] <= 1 + assert panel["diagnostics"][0]["code"] == "risk_neutral" + + +def test_terminal_distribution_panel_returns_density_and_ranges() -> None: + panel = terminal_distribution_panel(fitted_iv_panel(), forward=100, tau=1 / 365, rate=0.0) + + assert panel["status"] == "preview" + assert panel["density"] + assert panel["highestDensityZone"] is not None + assert panel["range68"] is not None + assert panel["range95"] is not None + + +def test_distribution_panels_report_insufficient_data_without_fit() -> None: + empty = {"methods": []} + + assert probability_panel(empty, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(empty, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + + +def test_skew_tail_panel_labels_left_tail_richness() -> None: + panel = skew_tail_panel(fitted_iv_panel(), forward=100) + + assert panel["status"] == "preview" + assert panel["tailBias"] in {"Left-tail rich", "Right-tail rich", "Balanced tails"} + + +def test_distribution_panels_sanitize_bad_fit_points_without_raising() -> None: + bad_panel = { + "methods": [ + { + "key": "spline_fit", + "points": [ + {"x": 95, "y": 0.0}, + {"x": "bad", "y": 0.2}, + {"x": 100, "y": None}, + {"x": 105, "y": 0.21}, + ], + } + ] + } + + assert probability_panel(bad_panel, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(bad_panel, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert skew_tail_panel(bad_panel, forward=100)["status"] == "insufficient_data" + + +def test_distribution_panels_sanitize_malformed_fit_containers_without_raising() -> None: + panels = [ + {"methods": None}, + {"methods": [None]}, + {"methods": [{"key": "spline_fit", "points": None}]}, + {"methods": [{"key": "spline_fit", "points": [None]}]}, + ] + + for panel in panels: + assert probability_panel(panel, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(panel, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert skew_tail_panel(panel, forward=100)["status"] == "insufficient_data" + + +def test_distribution_panels_degrade_on_invalid_model_inputs() -> None: + assert probability_panel(fitted_iv_panel(), forward=0, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert probability_panel(fitted_iv_panel(), forward=float("nan"), tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert skew_tail_panel(fitted_iv_panel(), forward=float("nan"))["status"] == "insufficient_data" + assert terminal_distribution_panel(fitted_iv_panel(), forward=100, tau=0, rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(fitted_iv_panel(), forward=100, tau=float("nan"), rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(fitted_iv_panel(), forward=100, tau=1 / 365, rate=float("nan"))["status"] == "insufficient_data" + + +def test_terminal_distribution_deduplicates_and_sorts_fit_points() -> None: + panel = { + "methods": [ + { + "key": "spline_fit", + "points": [ + {"x": 105, "y": 0.21}, + {"x": 95, "y": 0.22}, + {"x": 100, "y": 0.18}, + {"x": 100, "y": 0.19}, + ], + } + ] + } + + output = terminal_distribution_panel(panel, forward=100, tau=1 / 365, rate=0.0) + + assert output["status"] == "preview" + assert [point["x"] for point in output["density"]] == sorted(point["x"] for point in output["density"]) + + +def test_terminal_distribution_uses_nonuniform_strike_spacing() -> None: + rate = 0.01 + tau = 1 / 365 + points = [{"x": 95, "y": 0.24}, {"x": 100, "y": 0.18}, {"x": 112, "y": 0.23}] + panel = {"methods": [{"key": "spline_fit", "points": points}]} + + output = terminal_distribution_panel(panel, forward=100, tau=tau, rate=rate) + + calls = [ + black76_price(forward=100, strike=point["x"], tau=tau, rate=rate, sigma=point["y"], right="call") + for point in points + ] + left_width = points[1]["x"] - points[0]["x"] + right_width = points[2]["x"] - points[1]["x"] + expected_curvature = 2 / (left_width + right_width) * ( + (calls[2] - calls[1]) / right_width - (calls[1] - calls[0]) / left_width + ) + + assert output["status"] == "preview" + assert output["density"][0]["y"] == pytest.approx(max(0.0, expected_curvature * exp(rate * tau))) diff --git a/apps/api/tests/test_experimental_forward.py b/apps/api/tests/test_experimental_forward.py new file mode 100644 index 0000000..8e54df9 --- /dev/null +++ b/apps/api/tests/test_experimental_forward.py @@ -0,0 +1,82 @@ +import pytest + +from gammascope_api.experimental.forward import forward_summary_panel, time_to_expiry_years + + +def row(right: str, strike: float, mid: float, bid: float | None = None, ask: float | None = None) -> dict: + return { + "contract_id": f"{right}-{strike}", + "right": right, + "strike": strike, + "bid": bid if bid is not None else mid - 0.05, + "ask": ask if ask is not None else mid + 0.05, + "mid": mid, + "calc_status": "ok", + } + + +def test_time_to_expiry_years_uses_2000_utc_close() -> None: + assert time_to_expiry_years("2026-04-23T19:00:00Z", "2026-04-23") == pytest.approx(1 / (365 * 24)) + + +def test_forward_summary_uses_parity_median_and_forward_atm_straddle() -> None: + snapshot = { + "spot": 100.0, + "risk_free_rate": 0.0, + "snapshot_time": "2026-04-23T19:00:00Z", + "expiry": "2026-04-23", + "rows": [ + row("call", 95, 6.0), + row("put", 95, 1.0), + row("call", 100, 3.5), + row("put", 100, 3.4), + row("call", 105, 1.2), + row("put", 105, 6.0), + ], + } + + panel = forward_summary_panel(snapshot) + + assert panel["status"] == "ok" + assert panel["parityForward"] == pytest.approx(100.1) + assert panel["forwardMinusSpot"] == pytest.approx(0.1) + assert panel["atmStrike"] == 100 + assert panel["atmStraddle"] == pytest.approx(6.9) + assert panel["expectedRange"] == {"lower": pytest.approx(93.2), "upper": pytest.approx(107.0)} + assert panel["expectedMovePercent"] == pytest.approx(0.068931, rel=1e-4) + + +def test_forward_summary_reports_insufficient_data_without_pairs() -> None: + panel = forward_summary_panel( + { + "spot": 100.0, + "risk_free_rate": 0.0, + "snapshot_time": "2026-04-23T19:00:00Z", + "expiry": "2026-04-23", + "rows": [row("call", 100, 3.5)], + } + ) + + assert panel["status"] == "insufficient_data" + assert panel["parityForward"] is None + + +def test_forward_summary_uses_nearest_clean_pair_for_atm_straddle() -> None: + snapshot = { + "spot": 100.0, + "risk_free_rate": 0.0, + "snapshot_time": "2026-04-23T19:00:00Z", + "expiry": "2026-04-23", + "rows": [ + row("call", 95, 6.0), + row("put", 95, 1.0), + row("call", 100, 3.5), + ], + } + + panel = forward_summary_panel(snapshot) + + assert panel["status"] == "ok" + assert panel["atmStrike"] == 95 + assert panel["atmStraddle"] == pytest.approx(7.0) + assert panel["expectedRange"] == {"lower": pytest.approx(93.0), "upper": pytest.approx(107.0)} diff --git a/apps/api/tests/test_experimental_iv_methods.py b/apps/api/tests/test_experimental_iv_methods.py new file mode 100644 index 0000000..379d7d0 --- /dev/null +++ b/apps/api/tests/test_experimental_iv_methods.py @@ -0,0 +1,111 @@ +import pytest + +from gammascope_api.experimental.iv_methods import ( + black76_price, + build_iv_smiles_panel, + implied_vol_black76, + smile_diagnostics_panel, +) + + +def row(right: str, strike: float, mid: float, custom_iv: float = 0.2, ibkr_iv: float | None = 0.21) -> dict: + return { + "contract_id": f"{right}-{strike}", + "right": right, + "strike": strike, + "bid": max(0.0, mid - 0.05), + "ask": mid + 0.05, + "mid": mid, + "custom_iv": custom_iv, + "ibkr_iv": ibkr_iv, + "calc_status": "ok", + } + + +def test_black76_iv_solver_recovers_known_vol() -> None: + price = black76_price(forward=100, strike=100, tau=30 / 365, rate=0.05, sigma=0.25, right="call") + + assert implied_vol_black76(price=price, forward=100, strike=100, tau=30 / 365, rate=0.05, right="call") == pytest.approx(0.25, abs=1e-5) + + +def test_build_iv_smiles_panel_outputs_raw_and_fitted_methods() -> None: + snapshot = { + "spot": 100, + "risk_free_rate": 0.0, + "snapshot_time": "2026-04-23T19:00:00Z", + "expiry": "2026-04-23", + "rows": [ + row("put", 90, 0.25, 0.28), + row("put", 95, 0.75, 0.22), + row("call", 100, 3.0, 0.18), + row("put", 100, 2.9, 0.18), + row("call", 105, 0.9, 0.21), + row("call", 110, 0.3, 0.26), + ], + } + forward_summary = {"parityForward": 100.0, "atmStraddle": 5.9} + + panel = build_iv_smiles_panel(snapshot, forward_summary) + + assert panel["status"] == "preview" + assert {method["key"] for method in panel["methods"]} >= { + "custom_iv", + "broker_iv", + "otm_midpoint_black76", + "atm_straddle_iv", + "spline_fit", + "quadratic_fit", + "wing_weighted_fit", + "last_price", + } + assert next(method for method in panel["methods"] if method["key"] == "last_price")["status"] == "insufficient_data" + + +def test_build_iv_smiles_panel_skips_malformed_rows_and_nonpositive_straddle_iv() -> None: + snapshot = { + "spot": 100, + "forward": 100, + "risk_free_rate": 0.0, + "snapshot_time": "2026-04-23T19:00:00Z", + "expiry": "2026-04-23", + "rows": [ + row("call", 100, 3.0, 0.18), + {**row("put", 0, 2.9, 0.18), "strike": "bad"}, + ], + } + forward_summary = {"parityForward": 100.0, "atmStrike": 100.0, "atmStraddle": 0.0} + + panel = build_iv_smiles_panel(snapshot, forward_summary) + + custom = next(method for method in panel["methods"] if method["key"] == "custom_iv") + atm_straddle = next(method for method in panel["methods"] if method["key"] == "atm_straddle_iv") + assert custom["points"] == [{"x": 100.0, "y": 0.18}] + assert atm_straddle["status"] == "insufficient_data" + assert atm_straddle["points"] == [] + + +def test_smile_diagnostics_reports_valley_and_method_disagreement() -> None: + iv_panel = { + "methods": [ + {"key": "custom_iv", "points": [{"x": 95, "y": 0.22}, {"x": 100, "y": 0.18}, {"x": 105, "y": 0.21}]}, + {"key": "spline_fit", "points": [{"x": 95, "y": 0.215}, {"x": 100, "y": 0.175}, {"x": 105, "y": 0.205}]}, + {"key": "quadratic_fit", "points": [{"x": 95, "y": 0.216}, {"x": 100, "y": 0.176}, {"x": 105, "y": 0.206}]}, + ] + } + + panel = smile_diagnostics_panel(iv_panel, forward=100) + + assert panel["status"] == "preview" + assert panel["ivValley"] == {"strike": 100, "value": pytest.approx(0.175), "label": "Spline valley"} + assert panel["atmForwardIv"] == pytest.approx(0.175) + assert panel["methodDisagreement"] == pytest.approx(0.005) + + +def test_smile_diagnostics_reports_insufficient_data_for_malformed_spline_points() -> None: + panel = smile_diagnostics_panel( + {"methods": [{"key": "spline_fit", "points": [{"x": 100, "y": None}, {"x": "bad", "y": 0.2}]}]}, + forward=100, + ) + + assert panel["status"] == "insufficient_data" + assert panel["ivValley"] == {"strike": None, "value": None, "label": None} diff --git a/apps/api/tests/test_experimental_quality.py b/apps/api/tests/test_experimental_quality.py new file mode 100644 index 0000000..383af13 --- /dev/null +++ b/apps/api/tests/test_experimental_quality.py @@ -0,0 +1,78 @@ +import pytest + +from gammascope_api.experimental.quality import grouped_pairs, quote_quality_panel + + +def row( + contract_id: str, + right: str, + strike: float, + bid: float | None, + ask: float | None, + mid: float | None = None, +) -> dict: + return { + "contract_id": contract_id, + "right": right, + "strike": strike, + "bid": bid, + "ask": ask, + "mid": mid if mid is not None else ((bid + ask) / 2 if bid is not None and ask is not None else None), + "custom_iv": 0.2, + "ibkr_iv": 0.21, + "custom_gamma": 0.01, + "custom_vanna": 0.001, + "open_interest": 100, + "calc_status": "ok", + } + + +def test_grouped_pairs_keeps_call_and_put_by_strike() -> None: + pairs = grouped_pairs( + [ + row("c-100", "call", 100, 4.9, 5.1), + row("p-100", "put", 100, 4.8, 5.0), + row("c-105", "call", 105, 2.0, 2.2), + ] + ) + + assert [pair.strike for pair in pairs] == [100, 105] + assert pairs[0].call["contract_id"] == "c-100" + assert pairs[0].put["contract_id"] == "p-100" + assert pairs[1].call["contract_id"] == "c-105" + assert pairs[1].put is None + + +def test_quote_quality_flags_missing_crossed_zero_and_wide_quotes() -> None: + panel = quote_quality_panel( + [ + row("c-100", "call", 100, 4.9, 5.1), + row("p-100", "put", 100, None, 5.0), + row("c-105", "call", 105, 3.0, 2.9), + row("p-105", "put", 105, 0.0, 0.1), + row("c-110", "call", 110, 0.2, 1.2), + ] + ) + + assert panel["status"] == "preview" + assert panel["score"] == 0.2 + assert {flag["code"] for flag in panel["flags"]} == { + "missing_bid_ask", + "crossed_market", + "zero_bid", + "wide_spread", + } + + +def test_quote_quality_flags_malformed_strikes_without_raising() -> None: + panel = quote_quality_panel( + [ + row("c-100", "call", 100, 4.9, 5.1), + {**row("p-missing", "put", 0, 4.8, 5.0), "strike": None}, + {**row("c-bad", "call", 0, 2.0, 2.2), "strike": "bad"}, + ] + ) + + assert panel["status"] == "preview" + assert panel["score"] == pytest.approx(0.3333) + assert {flag["code"] for flag in panel["flags"]} == {"invalid_strike"} diff --git a/apps/api/tests/test_experimental_routes.py b/apps/api/tests/test_experimental_routes.py new file mode 100644 index 0000000..d38ca69 --- /dev/null +++ b/apps/api/tests/test_experimental_routes.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.fixtures import load_json_fixture +from gammascope_api.ingestion.collector_state import collector_state +from gammascope_api.ingestion.latest_state_cache import ( + InMemoryLatestStateCache, + reset_latest_state_cache_override, + set_latest_state_cache_override, +) +from gammascope_api.ingestion.live_snapshot import reset_live_snapshot_memory +from gammascope_api.main import app +from gammascope_api.routes import experimental as experimental_routes + + +client = TestClient(app) + + +def setup_function() -> None: + collector_state.clear() + reset_live_snapshot_memory() + set_latest_state_cache_override(InMemoryLatestStateCache()) + + +def teardown_function() -> None: + reset_latest_state_cache_override() + + +def test_experimental_routes_enforce_generated_response_model() -> None: + response_models = { + route.path: route.response_model + for route in app.routes + if isinstance(route, APIRoute) + } + + assert response_models["/api/spx/0dte/experimental/latest"] is ExperimentalAnalytics + assert response_models["/api/spx/0dte/experimental/replay/snapshot"] is ExperimentalAnalytics + + +def test_latest_experimental_route_falls_back_to_seed_payload() -> None: + response = client.get("/api/spx/0dte/experimental/latest") + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert payload["meta"]["mode"] == "latest" + assert payload["meta"]["sourceSessionId"] == "seed-spx-2026-04-23" + assert payload["forwardSummary"]["status"] == "ok" + + +def test_latest_experimental_route_prefers_moomoo_spx_session_when_other_symbols_are_newer() -> None: + for event in [ + _health_event("2026-04-24T15:30:00Z"), + _underlying_event("moomoo-spx-0dte-live", "SPX", 5200.25, "2026-04-24T15:30:01Z"), + _contract_event("moomoo-spx-0dte-live", "SPX-2026-04-24-C-5200", "SPX", "call", 5200), + _contract_event("moomoo-spx-0dte-live", "SPX-2026-04-24-P-5200", "SPX", "put", 5200), + _option_event("moomoo-spx-0dte-live", "SPX-2026-04-24-C-5200", 9.9, 10.1), + _option_event("moomoo-spx-0dte-live", "SPX-2026-04-24-P-5200", 9.7, 9.9), + _underlying_event("moomoo-ndx-0dte-live", "NDX", 18300.0, "2026-04-24T15:30:03Z"), + _contract_event("moomoo-ndx-0dte-live", "NDX-2026-04-24-C-18300", "NDX", "call", 18300), + _option_event("moomoo-ndx-0dte-live", "NDX-2026-04-24-C-18300", 30.0, 31.0), + ]: + assert client.post("/api/spx/0dte/collector/events", json=event).status_code == 200 + + response = client.get("/api/spx/0dte/experimental/latest") + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert payload["meta"]["sourceSessionId"] == "moomoo-spx-0dte-live" + assert payload["meta"]["symbol"] == "SPX" + assert payload["sourceSnapshot"]["spot"] == 5200.25 + + +def test_replay_experimental_route_delegates_to_replay_snapshot_helper(monkeypatch) -> None: + calls = [] + + def fake_replay_snapshot(session_id: str, at: str | None = None, source_snapshot_id: str | None = None) -> dict: + calls.append({"session_id": session_id, "at": at, "source_snapshot_id": source_snapshot_id}) + return load_json_fixture("analytics-snapshot.seed.json") + + monkeypatch.setattr(experimental_routes.replay_routes, "get_replay_snapshot", fake_replay_snapshot) + + response = client.get( + "/api/spx/0dte/experimental/replay/snapshot", + params={ + "session_id": "session-1", + "at": "2026-04-23T15:50:00Z", + "source_snapshot_id": "source-1", + }, + ) + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert calls == [ + { + "session_id": "session-1", + "at": "2026-04-23T15:50:00Z", + "source_snapshot_id": "source-1", + } + ] + assert payload["meta"]["mode"] == "replay" + + +def test_replay_experimental_route_degrades_malformed_replay_snapshot(monkeypatch) -> None: + def malformed_replay_snapshot(*_args, **_kwargs): # type: ignore[no-untyped-def] + return { + "session_id": "malformed", + "symbol": "SPX", + "snapshot_time": "bad-time", + "expiry": "bad-expiry", + "spot": "bad-spot", + "rows": [None, {"right": "call", "strike": "bad"}], + } + + monkeypatch.setattr(experimental_routes.replay_routes, "get_replay_snapshot", malformed_replay_snapshot) + + response = client.get("/api/spx/0dte/experimental/replay/snapshot", params={"session_id": "malformed"}) + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert payload["meta"]["mode"] == "replay" + assert payload["sourceSnapshot"]["spot"] == 0.0 + assert payload["forwardSummary"]["status"] == "insufficient_data" + + +def test_replay_experimental_route_serializes_extreme_numeric_snapshot(monkeypatch) -> None: + def extreme_replay_snapshot(*_args, **_kwargs): # type: ignore[no-untyped-def] + return { + "session_id": "extreme-session", + "symbol": "SPX", + "snapshot_time": "2026-04-23T15:50:00Z", + "expiry": "2026-04-23", + "spot": 5000, + "rows": [ + {"right": "call", "strike": 1e308, "bid": 1e308, "ask": 1e308, "mid": 1e308}, + {"right": "put", "strike": 1e308, "bid": 0.5, "ask": 0.6, "mid": 0.55}, + ], + } + + monkeypatch.setattr(experimental_routes.replay_routes, "get_replay_snapshot", extreme_replay_snapshot) + + response = client.get("/api/spx/0dte/experimental/replay/snapshot", params={"session_id": "extreme-session"}) + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert payload["forwardSummary"]["parityForward"] is None + assert payload["forwardSummary"]["expectedRange"] is None + + +def _health_event(event_time: str) -> dict: + return { + "schema_version": "1.0.0", + "source": "ibkr", + "collector_id": "local-dev", + "status": "connected", + "ibkr_account_mode": "paper", + "message": "Mock live cycle", + "event_time": event_time, + "received_time": event_time, + } + + +def _underlying_event(session_id: str, symbol: str, spot: float, event_time: str) -> dict: + return { + "schema_version": "1.0.0", + "source": "ibkr", + "session_id": session_id, + "symbol": symbol, + "spot": spot, + "bid": spot - 0.5, + "ask": spot + 0.5, + "last": spot, + "mark": spot, + "event_time": event_time, + "quote_status": "valid", + } + + +def _contract_event(session_id: str, contract_id: str, symbol: str, right: str, strike: float) -> dict: + return { + "schema_version": "1.0.0", + "source": "ibkr", + "session_id": session_id, + "contract_id": contract_id, + "ibkr_con_id": abs(hash(contract_id)) % 1_000_000, + "symbol": symbol, + "expiry": "2026-04-24", + "right": right, + "strike": strike, + "multiplier": 100, + "exchange": "CBOE", + "currency": "USD", + "event_time": "2026-04-24T15:30:01Z", + } + + +def _option_event(session_id: str, contract_id: str, bid: float, ask: float) -> dict: + return { + "schema_version": "1.0.0", + "source": "ibkr", + "session_id": session_id, + "contract_id": contract_id, + "bid": bid, + "ask": ask, + "last": (bid + ask) / 2, + "bid_size": 10, + "ask_size": 12, + "volume": 400, + "open_interest": 2400, + "ibkr_iv": 0.2, + "ibkr_delta": 0.51, + "ibkr_gamma": 0.017, + "ibkr_vega": 0.9, + "ibkr_theta": -1.0, + "event_time": "2026-04-24T15:30:02Z", + "quote_status": "valid", + } diff --git a/apps/api/tests/test_experimental_service.py b/apps/api/tests/test_experimental_service.py new file mode 100644 index 0000000..031e9ac --- /dev/null +++ b/apps/api/tests/test_experimental_service.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from copy import deepcopy +import json + +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.fixtures import load_json_fixture +from gammascope_api.experimental import service as experimental_service +from gammascope_api.experimental.service import build_experimental_payload + + +PANEL_KEYS = [ + "forwardSummary", + "ivSmiles", + "smileDiagnostics", + "probabilities", + "terminalDistribution", + "skewTail", + "moveNeeded", + "decayPressure", + "richCheap", + "quoteQuality", + "historyPreview", +] + + +def test_build_experimental_payload_validates_against_generated_contract() -> None: + payload = build_experimental_payload(load_json_fixture("analytics-snapshot.seed.json"), "latest") + + model = ExperimentalAnalytics.model_validate(payload) + + assert model.schema_version == "1.0.0" + assert payload["meta"]["mode"] == "latest" + assert payload["meta"]["symbol"] == "SPX" + assert payload["sourceSnapshot"]["rowCount"] == 34 + assert payload["sourceSnapshot"]["strikeCount"] == 17 + assert all(payload[key]["status"] in {"ok", "preview", "insufficient_data", "error"} for key in PANEL_KEYS) + + +def test_build_experimental_payload_populates_summary_fields_from_seed_snapshot() -> None: + payload = build_experimental_payload(load_json_fixture("analytics-snapshot.seed.json"), "replay") + + assert payload["meta"]["mode"] == "replay" + assert payload["forwardSummary"]["parityForward"] is not None + assert payload["forwardSummary"]["atmStraddle"] is not None + assert payload["forwardSummary"]["expectedMovePercent"] is not None + assert payload["smileDiagnostics"]["ivValley"]["strike"] is not None + assert payload["smileDiagnostics"]["atmForwardIv"] is not None + assert payload["smileDiagnostics"]["methodDisagreement"] is not None + + +def test_build_experimental_payload_degrades_malformed_rows_without_raising() -> None: + snapshot = { + "snapshot_id": "malformed-snapshot", + "symbol": "SPX", + "timestamp": "not-a-time", + "expiration": "not-a-date", + "spot": "not-a-number", + "time_to_expiry_years": "also-bad", + "rows": [None, 42, {"right": "call", "strike": object(), "bid": None, "ask": None, "mid": None}], + } + + payload = build_experimental_payload(snapshot, "latest") + + ExperimentalAnalytics.model_validate(payload) + assert payload["sourceSnapshot"]["spot"] == 0.0 + assert payload["forwardSummary"]["status"] == "insufficient_data" + assert payload["ivSmiles"]["status"] == "insufficient_data" + assert payload["moveNeeded"]["rows"] == [] + + +def test_build_experimental_payload_degrades_panel_builder_errors(monkeypatch) -> None: + snapshot = deepcopy(load_json_fixture("analytics-snapshot.seed.json")) + + def raise_panel_error(*_args, **_kwargs): # type: ignore[no-untyped-def] + raise RuntimeError("panel exploded") + + monkeypatch.setattr(experimental_service, "quote_quality_panel", raise_panel_error) + + payload = build_experimental_payload(snapshot, "latest") + + ExperimentalAnalytics.model_validate(payload) + assert payload["quoteQuality"]["status"] == "insufficient_data" + assert payload["quoteQuality"]["score"] == 0.0 + assert payload["quoteQuality"]["flags"] == [] + assert payload["quoteQuality"]["diagnostics"][0]["code"] == "panel_unavailable" + + +def test_build_experimental_payload_scrubs_nonfinite_computed_values() -> None: + snapshot = { + "session_id": "extreme-session", + "symbol": "SPX", + "snapshot_time": "2026-04-23T15:50:00Z", + "expiry": "2026-04-23", + "spot": 5000, + "rows": [ + {"right": "call", "strike": 1e308, "bid": 1e308, "ask": 1e308, "mid": 1e308}, + {"right": "put", "strike": 1e308, "bid": 0.5, "ask": 0.6, "mid": 0.55}, + ], + } + + payload = build_experimental_payload(snapshot, "latest") + + ExperimentalAnalytics.model_validate(payload) + json.dumps(payload, allow_nan=False) + assert payload["forwardSummary"]["parityForward"] is None + assert payload["forwardSummary"]["expectedRange"] is None diff --git a/apps/api/tests/test_experimental_trade_maps.py b/apps/api/tests/test_experimental_trade_maps.py new file mode 100644 index 0000000..2154d6e --- /dev/null +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -0,0 +1,103 @@ +import pytest + +from gammascope_api.experimental.trade_maps import decay_pressure_panel, move_needed_panel, rich_cheap_panel + + +def row(right: str, strike: float, mid: float) -> dict: + return { + "contract_id": f"{right}-{strike}", + "right": right, + "strike": strike, + "bid": max(0, mid - 0.05), + "ask": mid + 0.05, + "mid": mid, + "custom_iv": 0.2, + "calc_status": "ok", + } + + +def test_move_needed_panel_labels_expected_move_ratios() -> None: + panel = move_needed_panel([row("call", 105, 2), row("put", 95, 1.5)], spot=100, expected_move=10) + + assert panel["status"] == "ok" + assert panel["rows"][0]["breakeven"] == 107 + assert panel["rows"][0]["expectedMoveRatio"] == pytest.approx(0.7) + assert panel["rows"][0]["label"] == "Within expected move" + + +def test_decay_pressure_panel_reports_static_points_per_minute() -> None: + panel = decay_pressure_panel([row("call", 105, 2.0)], minutes_to_expiry=20) + + assert panel["status"] == "preview" + assert panel["rows"][0]["pointsPerMinute"] == pytest.approx(0.1) + + +def test_rich_cheap_panel_compares_actual_mid_to_fitted_fair() -> None: + iv_panel = {"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]} + panel = rich_cheap_panel([row("call", 105, 2.0)], iv_panel=iv_panel, forward=100, tau=1 / 365, rate=0.0) + + assert panel["status"] == "preview" + assert panel["rows"][0]["strike"] == 105 + assert panel["rows"][0]["label"] in {"Rich", "Cheap", "Inline"} + + +def test_rich_cheap_panel_interpolates_fit_between_grid_points() -> None: + iv_panel = {"methods": [{"key": "spline_fit", "points": [{"x": 100, "y": 0.18}, {"x": 110, "y": 0.22}]}]} + panel = rich_cheap_panel([row("call", 105, 2.0)], iv_panel=iv_panel, forward=100, tau=1 / 365, rate=0.0) + + assert panel["status"] == "preview" + assert panel["rows"][0]["strike"] == 105 + + +def test_trade_map_panels_skip_bad_rows_and_invalid_sides() -> None: + rows = [ + row("call", 105, 2.0), + {**row("call", 0, 2.0), "strike": "bad"}, + row("bad", 105, 2.0), + None, + 42, + ] + + move = move_needed_panel(rows, spot=100, expected_move=10) + decay = decay_pressure_panel(rows, minutes_to_expiry=20) + rich_cheap = rich_cheap_panel(rows, iv_panel={"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]}, forward=100, tau=1 / 365, rate=0.0) + + assert move["status"] == "ok" + assert [item["side"] for item in move["rows"]] == ["call"] + assert [item["side"] for item in decay["rows"]] == ["call"] + assert [item["side"] for item in rich_cheap["rows"]] == ["call"] + + +def test_trade_map_panels_degrade_on_malformed_row_containers() -> None: + iv_panel = {"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]} + + for bad_rows in (None, 42, {"strike": 105}): + assert move_needed_panel(bad_rows, spot=100, expected_move=10)["status"] == "insufficient_data" + assert decay_pressure_panel(bad_rows, minutes_to_expiry=20)["status"] == "insufficient_data" + assert rich_cheap_panel(bad_rows, iv_panel=iv_panel, forward=100, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + + +def test_trade_map_panels_degrade_on_invalid_model_inputs() -> None: + assert decay_pressure_panel([row("call", 105, 2.0)], minutes_to_expiry=0)["status"] == "insufficient_data" + assert decay_pressure_panel([row("call", 105, 2.0)], minutes_to_expiry=float("nan"))["status"] == "insufficient_data" + assert rich_cheap_panel( + [row("call", 105, 2.0)], + iv_panel={"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]}, + forward=0, + tau=1 / 365, + rate=0.0, + )["status"] == "insufficient_data" + assert rich_cheap_panel( + [row("call", 105, 2.0)], + iv_panel={"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]}, + forward=100, + tau=float("nan"), + rate=0.0, + )["status"] == "insufficient_data" + assert rich_cheap_panel( + [row("call", 105, 2.0)], + iv_panel={"methods": [{"key": "spline_fit", "points": [{"x": 105, "y": 0.2}]}]}, + forward=100, + tau=1 / 365, + rate=float("nan"), + )["status"] == "insufficient_data" diff --git a/apps/api/tests/test_generated_contracts.py b/apps/api/tests/test_generated_contracts.py index f302eaa..342e726 100644 --- a/apps/api/tests/test_generated_contracts.py +++ b/apps/api/tests/test_generated_contracts.py @@ -1,8 +1,12 @@ import json from pathlib import Path +import pytest +from pydantic import ValidationError + from gammascope_api.contracts.generated.analytics_snapshot import AnalyticsSnapshot from gammascope_api.contracts.generated.collector_events import CollectorHealth +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics from gammascope_api.contracts.generated.scenario import ScenarioRequest from gammascope_api.contracts.generated.saved_view import SavedView @@ -25,6 +29,53 @@ def test_seed_snapshot_loads_as_generated_model() -> None: assert snapshot.rows[0].open_interest is not None +def test_seed_experimental_analytics_loads_as_generated_model() -> None: + fixture_path = ( + Path(__file__).parents[3] + / "packages" + / "contracts" + / "fixtures" + / "experimental-analytics.seed.json" + ) + payload = json.loads(fixture_path.read_text()) + + experimental = ExperimentalAnalytics.model_validate(payload) + + assert experimental.schema_version == "1.0.0" + assert experimental.meta.symbol == "SPX" + assert experimental.forwardSummary.status.value == "ok" + + +def test_experimental_analytics_rejects_unexpected_panel_fields() -> None: + fixture_path = ( + Path(__file__).parents[3] + / "packages" + / "contracts" + / "fixtures" + / "experimental-analytics.seed.json" + ) + payload = json.loads(fixture_path.read_text()) + payload["forwardSummary"]["unexpected"] = 123 + + with pytest.raises(ValidationError): + ExperimentalAnalytics.model_validate(payload) + + +def test_experimental_analytics_rejects_empty_panel_labels() -> None: + fixture_path = ( + Path(__file__).parents[3] + / "packages" + / "contracts" + / "fixtures" + / "experimental-analytics.seed.json" + ) + payload = json.loads(fixture_path.read_text()) + payload["forwardSummary"]["label"] = "" + + with pytest.raises(ValidationError): + ExperimentalAnalytics.model_validate(payload) + + def test_seed_health_loads_as_generated_model() -> None: fixture_path = ( Path(__file__).parents[3] diff --git a/apps/web/app/api/spx/0dte/experimental/latest/route.ts b/apps/web/app/api/spx/0dte/experimental/latest/route.ts new file mode 100644 index 0000000..3b784b3 --- /dev/null +++ b/apps/web/app/api/spx/0dte/experimental/latest/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import seedExperimentalAnalytics from "../../../../../../../../packages/contracts/fixtures/experimental-analytics.seed.json"; +import { verifyAdminRequest } from "../../../../../../lib/adminSession"; + +const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"; +const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest"; +const ADMIN_TOKEN_HEADER = "X-GammaScope-Admin-Token"; + +function experimentalLatestUrl(apiBaseUrl: string): string { + return `${apiBaseUrl.replace(/\/+$/, "")}${EXPERIMENTAL_LATEST_PATH}`; +} + +function noStoreJson(payload: unknown, init?: ResponseInit) { + const response = NextResponse.json(payload, init); + response.headers.set("Cache-Control", "no-store"); + return response; +} + +function seedFallbackResponse(): Response { + return noStoreJson(seedExperimentalAnalytics); +} + +function upstreamHeaders(request: Request): HeadersInit { + const headers: Record = { + Accept: "application/json" + }; + const adminToken = process.env.GAMMASCOPE_ADMIN_TOKEN?.trim(); + + if (adminToken && verifyAdminRequest(request, { csrf: false }).ok) { + headers[ADMIN_TOKEN_HEADER] = adminToken; + } + + return headers; +} + +export async function GET(request: Request): Promise { + const apiBaseUrl = process.env.GAMMASCOPE_API_BASE_URL ?? DEFAULT_API_BASE_URL; + + try { + const upstreamResponse = await fetch(experimentalLatestUrl(apiBaseUrl), { + cache: "no-store", + headers: upstreamHeaders(request) + }); + + if (!upstreamResponse.ok) { + return seedFallbackResponse(); + } + + const response = new Response(await upstreamResponse.text(), { + status: upstreamResponse.status, + headers: { + "Content-Type": upstreamResponse.headers.get("Content-Type") ?? "application/json" + } + }); + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { + return seedFallbackResponse(); + } +} diff --git a/apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts b/apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts new file mode 100644 index 0000000..a0541be --- /dev/null +++ b/apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"; +const EXPERIMENTAL_REPLAY_PATH = "/api/spx/0dte/experimental/replay/snapshot"; + +function experimentalReplayUrl(apiBaseUrl: string, requestUrl: string): string { + const sourceUrl = new URL(requestUrl); + const params = new URLSearchParams(); + const sessionId = sourceUrl.searchParams.get("session_id"); + const at = sourceUrl.searchParams.get("at"); + const sourceSnapshotId = sourceUrl.searchParams.get("source_snapshot_id"); + + if (sessionId) { + params.set("session_id", sessionId); + } + + if (at) { + params.set("at", at); + } + + if (sourceSnapshotId) { + params.set("source_snapshot_id", sourceSnapshotId); + } + + return `${apiBaseUrl.replace(/\/+$/, "")}${EXPERIMENTAL_REPLAY_PATH}?${params.toString()}`; +} + +function noStoreJson(payload: unknown, init?: ResponseInit) { + const response = NextResponse.json(payload, init); + response.headers.set("Cache-Control", "no-store"); + return response; +} + +export async function GET(request: Request): Promise { + const apiBaseUrl = process.env.GAMMASCOPE_API_BASE_URL ?? DEFAULT_API_BASE_URL; + + try { + const upstreamResponse = await fetch(experimentalReplayUrl(apiBaseUrl, request.url), { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + + const response = new Response(await upstreamResponse.text(), { + status: upstreamResponse.status, + headers: { + "Content-Type": upstreamResponse.headers.get("Content-Type") ?? "application/json" + } + }); + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { + return noStoreJson({ error: "Experimental replay analytics unavailable" }, { status: 502 }); + } +} diff --git a/apps/web/app/experimental/page.tsx b/apps/web/app/experimental/page.tsx new file mode 100644 index 0000000..5c82fb4 --- /dev/null +++ b/apps/web/app/experimental/page.tsx @@ -0,0 +1,9 @@ +import { headers } from "next/headers"; +import { ExperimentalDashboard } from "../../components/ExperimentalDashboard"; +import { loadLatestExperimentalAnalytics } from "../../lib/serverExperimentalAnalyticsSource"; + +export default async function ExperimentalPage() { + const initialAnalytics = await loadLatestExperimentalAnalytics(fetch, await headers()); + + return ; +} diff --git a/apps/web/app/styles.css b/apps/web/app/styles.css index 5428508..451a986 100644 --- a/apps/web/app/styles.css +++ b/apps/web/app/styles.css @@ -181,6 +181,21 @@ select { .marketMapGrid, .marketIntelligencePanel, .marketIntelligenceGrid, +.experimentalShell, +.experimentalHeader, +.experimentalHeaderUtility, +.experimentalKpiGrid, +.experimentalSummaryGrid, +.experimentalChartsGrid, +.experimentalTablesGrid, +.experimentalPanel, +.experimentalPanelHeader, +.experimentalPanelBody, +.experimentalDetailGrid, +.experimentalChartFrame, +.experimentalLegend, +.experimentalDistributionStats, +.experimentalTableWrap, .chartGrid, .sharedInspectionBar, .sharedInspectionStrike, @@ -2254,6 +2269,602 @@ h1 { border-color: rgba(148, 163, 184, 0.14); } +.experimentalShell { + font-variant-numeric: tabular-nums; + width: min(1680px, calc(100% - 40px)); +} + +.experimentalHeader h1 { + font-size: clamp(26px, 3vw, 42px); +} + +.experimentalHeaderUtility { + align-items: center; + flex-wrap: wrap; +} + +.experimentalRefreshButton { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.36); + border-radius: 6px; + color: #dbeafe; + cursor: pointer; + font-size: 12px; + font-weight: 900; + min-height: 34px; + padding: 0 12px; + white-space: nowrap; +} + +.experimentalRefreshButton:disabled { + cursor: wait; + opacity: 0.6; +} + +.experimentalNotice, +.experimentalUnavailable { + background: rgba(16, 23, 34, 0.9); + border: 1px solid var(--line-soft); + border-radius: 8px; +} + +.experimentalNotice { + color: var(--warning-text); + font-size: 13px; + font-weight: 800; + margin-bottom: 16px; + padding: 12px 14px; +} + +.experimentalUnavailable { + display: grid; + gap: 8px; + margin-top: 16px; + min-height: 180px; + place-content: center; + text-align: center; +} + +.experimentalUnavailable h2 { + font-size: 20px; +} + +.experimentalUnavailable p { + color: var(--muted); + font-size: 14px; +} + +.experimentalKpiGrid { + background: var(--line-soft); + border: 1px solid var(--line-soft); + display: grid; + gap: 1px; + grid-template-columns: repeat(6, minmax(0, 1fr)); + margin: 0 0 16px; +} + +.experimentalMetric { + background: rgba(16, 23, 34, 0.92); + min-height: 90px; + min-width: 0; + padding: 15px; +} + +.experimentalMetric span, +.experimentalDetail span { + color: var(--muted); + display: block; + font-size: 10px; + font-weight: 850; + line-height: 1.15; + text-transform: uppercase; +} + +.experimentalMetric strong { + color: var(--text); + display: block; + font-size: 20px; + line-height: 1.05; + margin-top: 11px; + overflow-wrap: anywhere; +} + +.experimentalSummaryGrid, +.experimentalChartsGrid, +.experimentalTablesGrid { + display: grid; + gap: 16px; +} + +.experimentalSummaryGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.experimentalChartsGrid { + grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.88fr); + margin-bottom: 16px; +} + +.experimentalTablesGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.experimentalPanel { + background: + linear-gradient(180deg, rgba(148, 163, 184, 0.045), rgba(8, 13, 22, 0.04)), + rgba(12, 18, 28, 0.94); + border: 1px solid var(--line-soft); + border-radius: 8px; + min-width: 0; + overflow: hidden; +} + +.experimentalPanelHeader { + align-items: flex-start; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + display: flex; + gap: 12px; + justify-content: space-between; + padding: 14px 16px; +} + +.experimentalPanelHeader h2 { + color: var(--soft); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; + font-weight: 900; + letter-spacing: 0; + text-transform: uppercase; +} + +.experimentalPanelHeader p { + color: var(--muted); + font-size: 12px; + line-height: 1.3; + margin-top: 5px; +} + +.experimentalStatus { + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 999px; + color: var(--soft); + flex: 0 0 auto; + font-size: 10px; + font-weight: 900; + line-height: 1; + padding: 6px 8px; + text-transform: uppercase; +} + +.experimentalStatus-ok { + border-color: rgba(34, 197, 94, 0.36); + color: #86efac; +} + +.experimentalStatus-preview, +.experimentalStatus-insufficient_data { + border-color: rgba(245, 158, 11, 0.36); + color: #fcd34d; +} + +.experimentalStatus-error { + border-color: rgba(248, 113, 113, 0.38); + color: #fca5a5; +} + +.experimentalPanelBody { + padding: 14px 16px 16px; +} + +.experimentalDetailGrid { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); +} + +.experimentalDetail { + background: rgba(8, 13, 22, 0.58); + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + min-height: 72px; + min-width: 0; + padding: 11px 12px; +} + +.experimentalDetail strong { + color: var(--text); + display: block; + font-size: 15px; + line-height: 1.15; + margin-top: 8px; + overflow-wrap: anywhere; +} + +.experimentalDiagnostics { + border-top: 1px solid rgba(148, 163, 184, 0.14); + display: grid; + gap: 6px; + list-style: none; + margin: 0; + padding: 10px 16px 14px; +} + +.experimentalDiagnostics li { + align-items: baseline; + display: flex; + gap: 8px; + min-width: 0; +} + +.experimentalDiagnostics strong { + color: var(--muted); + flex: 0 0 auto; + font-size: 10px; + text-transform: uppercase; +} + +.experimentalDiagnostics span { + color: var(--soft); + font-size: 12px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.experimentalDiagnostic-warning span { + color: var(--warning-text); +} + +.experimentalDiagnostic-error span { + color: var(--error-text); +} + +.experimentalChartFrame { + background: rgba(5, 10, 19, 0.72); + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + height: 340px; + min-height: 340px; + overflow: hidden; + position: relative; +} + +.experimentalChartSvg { + display: block; + height: 100%; + width: 100%; +} + +.experimentalChartGrid line { + stroke: rgba(148, 163, 184, 0.16); + stroke-width: 1; +} + +.experimentalChartGrid .experimentalChartAxis { + stroke: rgba(203, 213, 225, 0.34); +} + +.experimentalChartTickLabels text, +.experimentalChartAxisLabel { + fill: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 10px; + font-weight: 800; +} + +.experimentalChartAxisLabel { + fill: var(--soft); + font-size: 11px; + text-transform: uppercase; +} + +.experimentalSeries { + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.experimentalSeriesMarker { + fill: currentColor; + stroke: rgba(5, 10, 19, 0.9); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.experimentalChartPoint { + fill: currentColor; + opacity: 0.92; + stroke: rgba(5, 10, 19, 0.92); + stroke-width: 1.2; + vector-effect: non-scaling-stroke; +} + +.experimentalChartPointHitTarget { + cursor: crosshair; + fill: transparent; + outline: none; + pointer-events: all; + stroke: transparent; + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} + +.experimentalChartPointHitTarget:hover, +.experimentalChartPointHitTarget:focus-visible { + fill: rgba(248, 250, 252, 0.07); + stroke: rgba(248, 250, 252, 0.58); +} + +.experimentalChartTooltip { + align-items: center; + background: rgba(15, 23, 42, 0.88); + border: 1px solid rgba(226, 232, 240, 0.2); + border-radius: 8px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24); + display: flex; + flex-wrap: wrap; + gap: 5px 9px; + left: 12px; + max-width: calc(100% - 24px); + overflow: hidden; + padding: 7px 9px; + pointer-events: none; + position: absolute; + top: 10px; + z-index: 2; +} + +.experimentalChartTooltip strong { + color: var(--text); + font-size: 11px; + font-weight: 900; + white-space: nowrap; +} + +.experimentalChartTooltip span { + color: var(--muted); + font-size: 11px; + font-weight: 820; + white-space: nowrap; +} + +.experimentalSeries-blue { + color: var(--blue); + stroke: var(--blue); +} + +.experimentalSeries-teal { + color: var(--teal); + stroke: var(--teal); +} + +.experimentalSeries-violet { + color: var(--violet); + stroke: var(--violet); +} + +.experimentalSeries-amber, +.experimentalSeries-distribution { + color: var(--amber); + stroke: var(--amber); +} + +.experimentalLegend { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 12px; +} + +.experimentalFocusedLegend { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 12px; +} + +.experimentalDistributionStats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.experimentalLegendItem, +.experimentalFocusedLegendItem, +.experimentalDistributionStats span { + align-items: center; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 6px; + color: var(--soft); + display: inline-flex; + font-size: 12px; + font-weight: 800; + gap: 7px; + min-height: 30px; + min-width: 0; + padding: 0 9px; +} + +.experimentalFocusedLegendItem { + align-items: center; + display: grid; + gap: 4px 8px; + grid-template-columns: 8px 1fr; + min-height: 62px; + padding: 8px 10px; +} + +.experimentalFocusedLegendItem i { + border-radius: 999px; + display: inline-block; + grid-row: 1 / span 3; + height: 8px; + width: 8px; +} + +.experimentalFocusedLegendItem strong { + color: var(--text); + font-size: 12px; + font-weight: 900; + line-height: 1.1; +} + +.experimentalFocusedLegendItem span { + color: var(--soft); + font-size: 11px; + font-weight: 850; + line-height: 1.1; +} + +.experimentalLegendItem { + align-items: flex-start; + appearance: none; + background: rgba(15, 23, 42, 0.2); + cursor: pointer; + flex-direction: column; + font-family: inherit; + justify-content: center; + min-height: 64px; + padding: 7px 9px; + text-align: left; + transition: border-color 160ms ease, opacity 160ms ease, background 160ms ease; + width: 100%; +} + +.experimentalLegendItem:hover, +.experimentalLegendItem:focus-visible { + background: rgba(30, 41, 59, 0.34); + border-color: rgba(148, 163, 184, 0.32); +} + +.experimentalLegendItem:focus-visible { + outline: 2px solid rgba(var(--spot-reference-rgb), 0.5); + outline-offset: 2px; +} + +.experimentalLegendItem[aria-pressed="false"] { + border-style: dashed; + opacity: 0.52; +} + +.experimentalLegendLabel { + align-items: center; + display: inline-flex; + gap: 7px; + line-height: 1.1; +} + +.experimentalLegendMetrics { + display: grid; + gap: 4px; + padding-left: 15px; +} + +.experimentalLegendMetrics strong { + color: var(--text); + font-size: 11px; + font-weight: 900; + line-height: 1.1; + white-space: nowrap; +} + +.experimentalLegend i { + border-radius: 999px; + display: inline-block; + height: 8px; + width: 8px; +} + +.experimentalLegend .experimentalSeries-blue { + background: var(--blue); +} + +.experimentalLegend .experimentalSeries-teal { + background: var(--teal); +} + +.experimentalLegend .experimentalSeries-violet { + background: var(--violet); +} + +.experimentalLegend .experimentalSeries-amber { + background: var(--amber); +} + +.experimentalFocusedLegend .experimentalSeries-violet { + background: var(--violet); +} + +.experimentalFocusedLegend .experimentalSeries-teal { + background: var(--teal); +} + +.experimentalDistributionStats span { + display: grid; + gap: 2px; + min-height: 46px; +} + +.experimentalDistributionStats strong { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; +} + +.experimentalTableWrap { + overflow-x: auto; +} + +.experimentalTable { + font-size: 13px; + min-width: 620px; +} + +.experimentalTable caption { + color: var(--soft); + font-size: 12px; + font-weight: 900; + padding: 0 0 10px; + text-align: left; + text-transform: uppercase; +} + +.experimentalTable th, +.experimentalTable td { + height: 42px; + padding: 0 12px; + text-align: right; +} + +.experimentalTable th:first-child, +.experimentalTable td:first-child { + text-align: left; +} + +.experimentalTable th { + background: rgba(5, 14, 22, 0.82); + font-size: 11px; + font-weight: 900; + letter-spacing: 0; +} + +.experimentalTable td { + background: rgba(13, 39, 48, 0.38); + color: var(--soft); + font-variant-numeric: tabular-nums; +} + +.experimentalTable tbody tr:nth-child(even) td { + background: rgba(9, 24, 34, 0.54); +} + +.experimentalTableEmpty { + color: var(--muted); + text-align: left; +} + .chartGrid { display: grid; grid-template-columns: repeat(3, minmax(320px, 1fr)); @@ -3222,6 +3833,10 @@ html[data-theme="light"] .replayImportPanel, html[data-theme="light"] .savedViewsPanel, html[data-theme="light"] .scenarioPanel, html[data-theme="light"] .metric, +html[data-theme="light"] .experimentalMetric, +html[data-theme="light"] .experimentalPanel, +html[data-theme="light"] .experimentalNotice, +html[data-theme="light"] .experimentalUnavailable, html[data-theme="light"] .marketIntelligenceItem, html[data-theme="light"] .levelMovementItem, html[data-theme="light"] .chartPanel, @@ -3277,6 +3892,7 @@ html[data-theme="light"] .adminPanel button, html[data-theme="light"] .adminPopover button, html[data-theme="light"] .replayImportPanel button, html[data-theme="light"] .replayPanel button, +html[data-theme="light"] .experimentalRefreshButton, html[data-theme="light"] .scenarioForm button { background: rgba(var(--spot-reference-rgb), 0.1); color: var(--text); @@ -3315,6 +3931,46 @@ html[data-theme="light"] .chartPanel { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); } +html[data-theme="light"] .experimentalChartFrame, +html[data-theme="light"] .experimentalDetail { + background: rgba(248, 250, 252, 0.86); +} + +html[data-theme="light"] .experimentalLegendItem { + background: rgba(255, 255, 255, 0.72); +} + +html[data-theme="light"] .experimentalLegendItem:hover, +html[data-theme="light"] .experimentalLegendItem:focus-visible { + background: rgba(var(--spot-reference-rgb), 0.08); + border-color: rgba(var(--spot-reference-rgb), 0.28); +} + +html[data-theme="light"] .experimentalChartTooltip { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(15, 23, 42, 0.12); + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.12); +} + +html[data-theme="light"] .experimentalChartPointHitTarget:hover, +html[data-theme="light"] .experimentalChartPointHitTarget:focus-visible { + fill: rgba(var(--spot-reference-rgb), 0.08); + stroke: rgba(var(--spot-reference-rgb), 0.48); +} + +html[data-theme="light"] .experimentalTable th { + background: var(--surface-strong); +} + +html[data-theme="light"] .experimentalTable td { + background: var(--table-row-bg); + color: var(--text); +} + +html[data-theme="light"] .experimentalTable tbody tr:nth-child(even) td { + background: var(--table-row-alt-bg); +} + html[data-theme="light"] .chartGridLines line { stroke: rgba(100, 116, 139, 0.24); } @@ -3453,11 +4109,18 @@ html[data-theme="light"] .operationalPill-muted { } .kpiGrid, + .experimentalKpiGrid, .marketMapGrid, .chartGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .experimentalSummaryGrid, + .experimentalChartsGrid, + .experimentalTablesGrid { + grid-template-columns: 1fr; + } + .scenarioForm { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -3559,6 +4222,17 @@ html[data-theme="light"] .operationalPill-muted { justify-content: flex-start; } + .experimentalHeaderUtility, + .experimentalHeaderUtility .statusRail { + justify-content: flex-start; + width: 100%; + } + + .experimentalHeaderUtility .statusRail span { + overflow-wrap: anywhere; + white-space: normal; + } + .chainLegend { align-items: flex-start; flex-wrap: wrap; @@ -3573,6 +4247,7 @@ html[data-theme="light"] .operationalPill-muted { .importFileGrid, .importReviewGrid, .kpiGrid, + .experimentalKpiGrid, .marketMapGrid, .chartGrid { grid-template-columns: 1fr; diff --git a/apps/web/components/DashboardView.tsx b/apps/web/components/DashboardView.tsx index ca9f742..a4f6551 100644 --- a/apps/web/components/DashboardView.tsx +++ b/apps/web/components/DashboardView.tsx @@ -136,6 +136,9 @@ export function DashboardView({ Heatmap + + Experimental +
diff --git a/apps/web/components/ExperimentalDashboard.tsx b/apps/web/components/ExperimentalDashboard.tsx new file mode 100644 index 0000000..35eb4e3 --- /dev/null +++ b/apps/web/components/ExperimentalDashboard.tsx @@ -0,0 +1,136 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { ExperimentalSmileChart } from "./experimental/ExperimentalSmileChart"; +import { ExperimentalSummaryPanels } from "./experimental/ExperimentalSummaryPanels"; +import { ExperimentalTables } from "./experimental/ExperimentalTables"; +import { ThemeToggle } from "./ThemeToggle"; +import { loadClientExperimentalAnalytics } from "../lib/clientExperimentalAnalyticsSource"; +import type { ExperimentalAnalytics } from "../lib/contracts"; +import { formatSnapshotTime, formatStatusLabel } from "../lib/dashboardMetrics"; +import { startSnapshotPolling } from "../lib/snapshotPolling"; + +interface ExperimentalDashboardProps { + initialAnalytics?: ExperimentalAnalytics | null; +} + +export function ExperimentalDashboard({ initialAnalytics = null }: ExperimentalDashboardProps) { + const [analytics, setAnalytics] = useState(initialAnalytics); + const [isRefreshing, setIsRefreshing] = useState(false); + const [refreshError, setRefreshError] = useState(null); + + useEffect(() => { + return startSnapshotPolling({ + loadSnapshot: loadClientExperimentalAnalytics, + applySnapshot: (nextAnalytics) => { + setAnalytics(nextAnalytics); + setRefreshError(null); + }, + intervalMs: 2000 + }); + }, []); + + const refreshAnalytics = async () => { + setIsRefreshing(true); + setRefreshError(null); + const nextAnalytics = await loadClientExperimentalAnalytics(); + setIsRefreshing(false); + + if (!nextAnalytics) { + setRefreshError("Latest experimental analytics unavailable."); + return; + } + + setAnalytics(nextAnalytics); + }; + + return ( +
+
+
+
+ + +
+
+ +
+ {analytics ? ( + <> + {formatStatusLabel(analytics.meta.mode)} + {analytics.meta.symbol} + Generated {formatSnapshotTime(analytics.meta.generatedAt)} + + ) : ( + Unavailable + )} +
+ +
+
+ + {refreshError ? ( +
+ {refreshError} +
+ ) : null} + + {analytics ? ( + <> +
+
+ Session + {analytics.meta.sourceSessionId} +
+
+ Snapshot + {formatSnapshotTime(analytics.meta.sourceSnapshotTime)} +
+
+ Coverage + {analytics.sourceSnapshot.rowCount} rows / {analytics.sourceSnapshot.strikeCount} strikes +
+
+ Expiry + {analytics.meta.expiry} +
+
+ + + + + + ) : ( +
+

Experimental analytics unavailable

+

No experimental analytics payload is available.

+
+ )} +
+ ); +} diff --git a/apps/web/components/ExposureHeatmap.tsx b/apps/web/components/ExposureHeatmap.tsx index 02a687a..b1a940a 100644 --- a/apps/web/components/ExposureHeatmap.tsx +++ b/apps/web/components/ExposureHeatmap.tsx @@ -83,6 +83,7 @@ export function ExposureHeatmap({ initialPayload, initialPayloads }: ExposureHea

SPX 0DTE heatmap

+
@@ -104,17 +105,7 @@ export function ExposureHeatmap({ initialPayload, initialPayloads }: ExposureHea

SPX 0DTE heatmap

- +
@@ -170,6 +161,25 @@ export function ExposureHeatmap({ initialPayload, initialPayloads }: ExposureHea ); } +function HeatmapNavTabs() { + return ( + + ); +} + function HeatmapTickerControls({ payloads, selectedSymbols, diff --git a/apps/web/components/experimental/ExperimentalPanel.tsx b/apps/web/components/experimental/ExperimentalPanel.tsx new file mode 100644 index 0000000..1dd48ad --- /dev/null +++ b/apps/web/components/experimental/ExperimentalPanel.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { formatStatusLabel } from "../../lib/dashboardMetrics"; + +type ExperimentalPanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; +type ExperimentalDiagnostic = ExperimentalAnalytics["forwardSummary"]["diagnostics"][number]; + +interface ExperimentalPanelProps { + title: string; + description?: string; + status?: ExperimentalPanelStatus; + diagnostics?: ExperimentalDiagnostic[]; + ariaLabel?: string; + className?: string; + children: React.ReactNode; +} + +export function ExperimentalPanel({ + title, + description, + status, + diagnostics = [], + ariaLabel, + className = "", + children +}: ExperimentalPanelProps) { + const classNames = ["experimentalPanel", className].filter(Boolean).join(" "); + + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {status ? {formatStatusLabel(status)} : null} +
+
{children}
+ {diagnostics.length > 0 ? ( +
    + {diagnostics.map((diagnostic) => ( +
  • + {formatStatusLabel(diagnostic.severity)} + {diagnostic.message} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/components/experimental/ExperimentalSmileChart.tsx b/apps/web/components/experimental/ExperimentalSmileChart.tsx new file mode 100644 index 0000000..6948334 --- /dev/null +++ b/apps/web/components/experimental/ExperimentalSmileChart.tsx @@ -0,0 +1,552 @@ +"use client"; + +import React from "react"; +import { ExperimentalPanel } from "./ExperimentalPanel"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { formatNumber, formatPercent } from "../../lib/dashboardMetrics"; + +interface ExperimentalSmileChartProps { + analytics: ExperimentalAnalytics; +} + +type ChartPoint = { + x: number; + y: number | null; +}; + +type ChartDomain = { + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +type ExperimentalChartKey = "iv_smile" | "focused_iv_smile" | "terminal_distribution"; + +type ActiveChartPoint = { + chart: ExperimentalChartKey; + seriesKey: string; + seriesLabel: string; + x: number; + y: number; + valueKind: "percent" | "decimal"; +}; + +const CHART_WIDTH = 640; +const CHART_HEIGHT = 340; +const PLOT = { + left: 58, + right: 18, + top: 20, + bottom: 58 +}; +const GRID_LINE_COUNT = 4; + +const SERIES_CLASSES = ["experimentalSeries-blue", "experimentalSeries-teal", "experimentalSeries-violet", "experimentalSeries-amber"]; +const FOCUSED_SMILE_METHOD_KEYS = ["otm_midpoint_black76", "spline_fit"] as const; +const FOCUSED_SERIES_CLASSES: Record = { + otm_midpoint_black76: "experimentalSeries-violet", + spline_fit: "experimentalSeries-teal" +}; + +export function ExperimentalSmileChart({ analytics }: ExperimentalSmileChartProps) { + const [activePoint, setActivePoint] = React.useState(null); + const [hiddenIvMethods, setHiddenIvMethods] = React.useState>(() => new Set()); + const ivDomain = domainForSeries(analytics.ivSmiles.methods.flatMap((entry) => entry.points)); + const focusedSmileMethods = focusedIvMethods(analytics.ivSmiles.methods); + const focusedIvDomain = domainForSeries(focusedSmileMethods.flatMap((entry) => entry.points)); + const distributionDomain = domainForSeries(analytics.terminalDistribution.density); + + const toggleIvMethod = (methodKey: string) => { + setActivePoint(null); + setHiddenIvMethods((current) => { + const next = new Set(current); + + if (next.has(methodKey)) { + next.delete(methodKey); + } else { + next.add(methodKey); + } + + return next; + }); + }; + + return ( +
+ +
setActivePoint(null)}> + + + {analytics.ivSmiles.methods.map((method, index) => { + if (hiddenIvMethods.has(method.key)) { + return null; + } + + const className = SERIES_CLASSES[index % SERIES_CLASSES.length]; + return ( + + {renderSeries(method.key, method.points, ivDomain, className)} + {renderPointTargets({ + chart: "iv_smile", + seriesKey: method.key, + seriesLabel: method.label, + points: method.points, + domain: ivDomain, + className, + valueKind: "percent", + onInspect: setActivePoint + })} + + ); + })} + + {activePoint?.chart === "iv_smile" ? : null} +
+
+ {analytics.ivSmiles.methods.map((method, index) => { + const nearestForwardIv = findNearestForwardValue(method.points, analytics.sourceSnapshot.forward); + const lowestIvPoint = findLowestValuePoint(method.points); + const isVisible = !hiddenIvMethods.has(method.key); + + return ( +