From d643fad1cdaa1c08fcf1e66c1b8a522c166a489c Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:26:26 -0700 Subject: [PATCH 01/23] docs: design experimental analytics tab --- ...04-29-experimental-analytics-tab-design.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-experimental-analytics-tab-design.md diff --git a/docs/superpowers/specs/2026-04-29-experimental-analytics-tab-design.md b/docs/superpowers/specs/2026-04-29-experimental-analytics-tab-design.md new file mode 100644 index 0000000..ca48d3d --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-experimental-analytics-tab-design.md @@ -0,0 +1,500 @@ +# Experimental Analytics Tab Design + +Date: 2026-04-29 +Status: User-approved design, pending implementation plan +Repository: gamma-scope + +## Summary + +GammaScope will add a new `Experimental` tab for testing price-only SPX 0DTE option-chain analytics before promoting the useful parts into the main trading dashboard. The tab should behave like a dense research cockpit: many panels visible at once, compact controls, explicit formula labels, and clear preview/error states. + +The selected approach is a dedicated experimental backend API plus a modular frontend page. Heavy math and fitted curves live in Python so the calculations are centralized, testable, and able to use numeric libraries such as `numpy` and `scipy`. The frontend focuses on comparison, layout, toggles, replay frame selection, and rendering partial outputs without hiding the rest of the workbench. + +## Goals + +- Add `/experimental` as a fourth top-level tab beside Realtime, Replay, and Heatmap. +- Present many experimental outputs at once instead of a step-by-step workflow. +- Compare multiple IV smile construction methods and fitted curves side by side. +- Infer price-only metrics: parity forward, ATM straddle expected move, risk-neutral probabilities, terminal distribution preview, skew/tail asymmetry, move-needed maps, time-decay pressure, rich/cheap residuals, and no-arbitrage flags. +- Support latest snapshot analysis and lightweight replay-frame selection. +- Keep experimental formulas isolated from the existing realtime, replay, and heatmap dashboard contracts. +- Make immature outputs acceptable in v1 when they are explicitly labeled as `Experimental`, `Preview`, `Insufficient data`, or `Error`. +- Keep every formula explicit enough to test and later decide whether it belongs in the real dashboard. + +## Non-Goals + +- Do not replace the existing Realtime, Replay, or Heatmap pages. +- Do not change the existing `AnalyticsSnapshot` schema for this slice. +- Do not add order execution, alerts, brokerage actions, or account-risk workflows. +- Do not infer dealer positioning, gamma exposure from actual inventory, hidden liquidity, stop locations, or real-world probabilities from price-only data. +- Do not treat broker IV or last-price IV as production signals; they are diagnostic overlays. +- Do not take terminal-density derivatives from raw quote dots without smoothing or fitted prices. + +## Current Project Context + +GammaScope is a pnpm monorepo with: + +- Next.js web app under `apps/web`. +- FastAPI backend under `apps/api`. +- Shared contracts under `packages/contracts`. +- Existing pages for `/`, `/replay`, and `/heatmap`. +- Existing `AnalyticsSnapshot` fields for spot, forward, expiry, discount factor, rate, dividend yield, bid, ask, mid, open interest, custom IV, custom gamma, custom vanna, broker IV/gamma comparisons, status, and coverage. +- Existing frontend dashboard primitives in `DashboardView`, `DashboardChart`, `dashboardMetrics`, `chartGeometry`, and shared theme/navigation CSS. +- Existing backend analytics in `analytics/black_scholes.py`, live snapshot assembly, replay repositories, heatmap service patterns, and route tests. + +The new work should reuse the current snapshot and replay sources, route proxy patterns, top navigation styling, chart/table conventions, status tones, and test style. + +## Selected Approach + +Use a dedicated experimental API and modular tab. + +Data flow: + +```text +latest/replay AnalyticsSnapshot + -> backend experimental analytics service + -> typed ExperimentalAnalytics payload + -> /experimental dense workbench grid +``` + +Backend responsibilities: + +- Estimate parity-implied forward from paired call/put mids. +- Compute ATM straddle expected move and expected range. +- Build raw and fitted IV method curves. +- Use `numpy` and `scipy` for robust fitting and distribution work. +- Produce risk-neutral probability, terminal distribution, move-needed, decay, residual, and quote-quality panel outputs. +- Return partial results when a panel fails instead of failing the whole response. + +Frontend responsibilities: + +- Render `/experimental` as a dense research cockpit. +- Provide latest/replay frame selection. +- Provide compact quote-filter and method-visibility controls. +- Show panel statuses and diagnostics clearly. +- Keep many outputs visible at once so the user can decide which experiments are useful. + +Alternatives considered: + +- Generic research payload: faster to iterate but weakens type safety and makes panel contracts easier to break. +- Frontend-orchestrated hybrid: smaller backend pieces but spreads formulas across TypeScript and Python, making repeatable research harder. +- Split research console: useful for many controls, but it hides some simultaneous comparison value. The selected UI direction is a dense workbench grid with a compact control strip. + +## UI Direction + +The page should prioritize simultaneous visibility over guided flow. + +Layout: + +- Top navigation includes `Experimental`. +- Page header matches the existing dashboard shell. +- Compact control strip: + - mode: latest or replay + - replay session/frame selector when replay mode is active + - quote-filter preset + - IV method toggles + - refresh/load controls +- KPI strip: + - parity forward + - forward minus spot + - ATM straddle + - expected range + - expected move percent + - quote-quality score +- Main workbench grid: + - IV smile method comparison + - terminal distribution preview + - close-above/close-below probability table + - skew and tail asymmetry + - move-needed map + - time-decay pressure + - rich/cheap residuals + - quote-quality and no-arbitrage flags + - range compression/expansion preview when replay/history is available + +The layout should be compact and operational, not a marketing or explanatory page. Cards are acceptable for individual repeated panels, but the page should avoid nested cards and should keep text small enough for dense scanning. + +## Panel Scope + +### Forward And Expected Move Summary + +Inputs: + +- paired call/put mids by strike +- risk-free rate +- time to expiry +- spot hint when available + +Outputs: + +- parity forward +- forward minus spot +- ATM strike by forward +- ATM straddle +- expected range +- expected move percent +- diagnostics for missing pairs, crossed quotes, wide quotes, and skipped strikes + +Formula: + +```text +F_i = K + exp(rT) * (call_mid - put_mid) +forward = robust median of near-ATM F_i values +ATM straddle = ATM call mid + ATM put mid +expected range = forward +/- ATM straddle +``` + +### IV Smile Method Comparison + +Visible methods: + +- existing custom IV from `AnalyticsSnapshot` +- OTM midpoint Black-76 IV +- broker IV diagnostic overlay +- last-price diagnostic overlay when the input payload contains last prices; against current `AnalyticsSnapshot` inputs, this method returns `insufficient_data` +- ATM straddle IV +- fitted total-variance spline +- fitted total-variance quadratic +- fitted total-variance wing-weighted fit + +Primary raw method: + +```text +If K < F, use put midpoint. +If K > F, use call midpoint. +Near ATM, blend both sides or use the straddle-derived anchor. +``` + +Fitted curves should use log-moneyness and total variance: + +```text +x = ln(K / F) +w = IV^2 * T +``` + +The UI should show raw points and fitted curves together, with method toggles. It should not present the lowest raw point as the final IV valley. + +### Smile Diagnostics + +Outputs: + +- IV valley by fit +- ATM-forward IV +- put-wing IV +- call-wing IV +- skew slope +- curvature +- left/right wing richness +- method disagreement summary +- fit-quality status + +Diagnostics must state which fitted method produced the headline value. + +### Risk-Neutral Probabilities + +Outputs: + +- close-above probability by strike +- close-below probability by strike +- range probability approximation +- probability shelves where probabilities drop sharply + +Approximation: + +```text +P(S_T > K) ~= -dC/dK +P(S_T < K) ~= dP/dK +``` + +For adjacent strikes, use spread slopes and smoothed/fitted prices. Label these as risk-neutral probabilities, not real-world probabilities. + +### Terminal Distribution Preview + +Outputs: + +- density buckets by strike zone +- highest-density close zone +- 68 percent implied range +- 95 percent implied range +- left-tail probability +- right-tail probability + +This panel must use smoothed or fitted prices before estimating curvature: + +```text +risk-neutral density ~= second derivative of call price with respect to strike +``` + +If smoothing/fitting is unavailable, the panel status should be `insufficient_data`. + +### Skew And Tail Asymmetry + +Outputs: + +- downside versus upside tail richness +- OTM put premium slope versus OTM call premium slope +- 25-delta and 10-delta approximations when enough data exists +- labels such as left-tail rich, right-tail cheap, skew steepening, skew flattening, crash premium elevated, or squeeze premium elevated + +Labels must be backed by named formulas and thresholds in code. + +### Move-Needed Map + +Outputs by strike and side: + +- call breakeven +- put breakeven +- distance to breakeven +- move-needed / expected-move ratio +- ratio labels: + - below `0.5`: breakeven close + - `0.5` to `1.0`: within expected move + - `1.0` to `1.5`: needs above-normal move + - above `1.5`: lottery-like + +Formulas: + +```text +call breakeven = K + call_mid +put breakeven = K - put_mid +call move needed = call breakeven - spot +put move needed = spot - put breakeven +ratio = move_needed / expected_move +``` + +### Time-Decay Pressure + +Outputs: + +- static premium per minute to expiry +- highest pressure strikes +- 15/30/60 minute preview when a fitted IV method is available; otherwise this sub-output returns `insufficient_data` + +Static formula: + +```text +static decay pressure = remaining premium / minutes to expiry +``` + +This is a crude diagnostic and must be labeled as such. + +### Rich/Cheap Residuals + +Outputs: + +- actual mid +- fitted fair value +- residual in points +- rich/cheap/inline label +- local dislocation diagnostics + +The first version can compute residuals against the selected fitted IV curve. It should not present residuals as guaranteed edge. + +### Quote Quality And No-Arbitrage Flags + +Flags: + +- missing bid/ask +- crossed market +- bid above ask +- zero or negative bid when the method requires bid support +- spread too wide relative to mid +- below intrinsic +- impossible IV +- solver failed +- monotonicity violation +- convexity violation +- unusable for IV +- unusable for probability +- unusable for distribution + +No-arbitrage checks are data-quality warnings, not trading signals. + +### Range Compression/Expansion Preview + +When replay/history is available, track over selected frames: + +- ATM straddle +- expected move +- expected range +- tail price +- skew slope +- probability of selected key levels + +If only the latest snapshot is loaded, this panel can show `insufficient_data` with a prompt to select replay frames. + +## API And Contract + +Add backend routes: + +```http +GET /api/spx/0dte/experimental/latest +GET /api/spx/0dte/experimental/replay/snapshot?session_id=...&at=...&source_snapshot_id=... +``` + +Add matching Next.js proxy routes: + +```text +apps/web/app/api/spx/0dte/experimental/latest/route.ts +apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts +``` + +The backend response should be typed and grouped: + +```text +meta +sourceSnapshot +forwardSummary +ivSmiles +smileDiagnostics +probabilities +terminalDistribution +skewTail +moveNeeded +decayPressure +richCheap +quoteQuality +historyPreview +``` + +Each group should include: + +```text +status: "ok" | "preview" | "insufficient_data" | "error" +label: short display label +diagnostics: short machine-readable and display-readable notes +``` + +Contract rules: + +- Existing `AnalyticsSnapshot`, heatmap, replay, scenario, and saved-view contracts remain untouched. +- Experimental payload gets schema/type tests even though it is allowed to evolve. +- Partial results are valid responses. +- Formula metadata should include method name, input counts, skipped row counts, and fallback reason when relevant. +- Errors inside one panel should not hide other panels. + +## Backend Boundaries + +Create focused experimental modules under `apps/api/gammascope_api/experimental/`: + +- `forward.py`: paired quotes, parity forward, ATM straddle, expected range. +- `iv_methods.py`: Black-76 pricing, OTM midpoint IV, broker/last diagnostics, fitted smiles. +- `distribution.py`: close probabilities and terminal-density preview. +- `trade_maps.py`: move-needed, decay pressure, rich/cheap residuals. +- `quality.py`: quote filters and no-arbitrage checks. +- `service.py`: orchestration, panel statuses, partial-result handling. +- `routes.py` or route integration under the existing routes package. + +The service should accept a normalized snapshot dictionary, produce an experimental payload, and be directly testable without HTTP. + +`numpy` and `scipy` are acceptable backend dependencies for this feature. + +## Frontend Boundaries + +Create focused frontend units: + +- `apps/web/app/experimental/page.tsx`: server page loading initial experimental payload. +- `apps/web/components/ExperimentalDashboard.tsx`: route shell, latest/replay mode state, control strip, panel grid. +- `apps/web/components/experimental/*`: modular panel components. +- `apps/web/lib/clientExperimentalSource.ts`: client fetchers and payload validation. +- `apps/web/lib/experimentalFormat.ts`: formatting, status display, ratio labels, diagnostics text. +- Optional `apps/web/lib/experimentalChartGeometry.ts` if current chart helpers are too narrow. + +Reuse: + +- top nav style from `DashboardView` +- theme toggle and source/status conventions where practical +- chart geometry where practical +- replay session/timestamp client helpers where practical +- existing formatter conventions for prices, percentages, and statuses + +## Replay Behavior + +The experimental page should support: + +- latest snapshot mode by default +- replay session selection +- replay timestamp/frame selection +- loading experimental analytics for a selected replay frame + +It should not duplicate the full replay workstation. It only needs enough replay support to compare experimental formulas across historical frames. + +## Error Handling And Data Hygiene + +The experimental API should be tolerant of noisy 0DTE quotes while being explicit about discarded data. + +Rules: + +- A bad formula or failed fit marks that panel as `error` or `insufficient_data`; it does not break the whole tab. +- Quote filters track skipped rows by reason. +- IV methods report convergence failures and bounds failures separately. +- Distribution panels require smoothed or fitted prices. +- Probability and distribution panels are labeled risk-neutral. +- Broker IV and last-price IV are diagnostic overlays. +- No-arbitrage flags are data-quality warnings. + +## Testing + +Backend tests: + +- parity forward from paired quotes +- robust forward median behavior around ATM +- ATM straddle expected move +- Black-76 pricing and IV solving +- quote filtering and skipped-reason counts +- fitted smile success and failure states +- probability approximations +- terminal-density preview states +- move-needed ratios and labels +- time-decay pressure +- rich/cheap residuals +- no-arbitrage flags +- partial payload generation when one panel fails +- latest and replay experimental routes + +Contract tests: + +- experimental response schema validation +- generated or typed payload compatibility if a shared schema is added + +Frontend tests: + +- experimental fetchers +- formatter/status helpers +- `/experimental` route rendering +- top nav active state +- dense grid panel presence +- latest mode +- lightweight replay frame selection +- panel partial failure states +- method toggles and quote-filter controls + +Regression checks: + +- existing API tests +- existing web tests +- web typecheck +- browser verification at desktop width showing many panels without overlap + +## Acceptance Criteria + +- `/experimental` appears in top navigation. +- The page renders a dense, multi-panel experimental analytics workbench. +- Latest snapshot mode loads and displays partial or complete experimental payloads. +- Replay frame mode can load experimental analytics for a selected frame. +- IV smile methods and fitted curves are visible at the same time. +- Forward, expected move, probabilities, distribution preview, move-needed, decay, rich/cheap, and quote-quality panels are visible at once on desktop. +- Every panel shows a clear status and diagnostics when data is missing or a method fails. +- Existing Realtime, Replay, and Heatmap behavior remains unchanged. +- Tests cover core formulas, panel status behavior, routes, proxies, and frontend rendering. + +## Decisions Made + +- Use a dedicated experimental backend API. +- Use a modular `/experimental` tab. +- Use a dense workbench grid with compact controls. +- Include broad price-only analytics in v1, with preview labels where needed. +- Support latest snapshot plus lightweight replay frame selection. +- Expose a research set of IV methods and fits. +- Allow `numpy` and `scipy` backend dependencies. +- Keep existing production dashboard contracts untouched. From 73daf89974a8f513da57c0f73234f2875469be03 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:57:44 -0700 Subject: [PATCH 02/23] docs: add experimental analytics implementation plan --- .../2026-04-29-experimental-analytics-tab.md | 3450 +++++++++++++++++ 1 file changed, 3450 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-experimental-analytics-tab.md diff --git a/docs/superpowers/plans/2026-04-29-experimental-analytics-tab.md b/docs/superpowers/plans/2026-04-29-experimental-analytics-tab.md new file mode 100644 index 0000000..f4a79c9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-experimental-analytics-tab.md @@ -0,0 +1,3450 @@ +# Experimental Analytics Tab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a dense `/experimental` SPX 0DTE research tab backed by a dedicated experimental FastAPI payload for IV smile comparison, price-implied distribution, probabilities, trade maps, and quote-quality diagnostics. + +**Architecture:** Keep the existing `AnalyticsSnapshot` contract untouched. Add a new experimental contract, backend analytics package, FastAPI routes, Next.js proxy/client layer, and modular React workbench page. Heavy numeric work lives in Python with `numpy` and `scipy`; frontend code renders many panel outputs at once and treats each panel status independently. + +**Tech Stack:** Python 3.11+, FastAPI, pytest, Pydantic-generated models, numpy, scipy, Next.js App Router, React 19, TypeScript, Vitest, JSON Schema contracts. + +--- + +## Scope Check + +The approved spec is broad, but it is one cohesive feature: one new experimental surface powered by one dedicated payload. The work is split into independently verifiable slices so each task produces working, testable software before the next layer depends on it. + +## File Structure + +### Contracts + +- Create `packages/contracts/schemas/experimental-analytics.schema.json`: canonical response schema. +- Create `packages/contracts/fixtures/experimental-analytics.seed.json`: fixture for schema and UI tests. +- Modify `packages/contracts/package.json`: add TypeScript generation and package export for experimental analytics. +- Modify `packages/contracts/tests/schema.test.mjs`: compile schema and validate seed fixture. +- Modify `packages/contracts/tests/generated-types.ts`: typecheck generated type. +- Create generated `packages/contracts/src/generated/experimental-analytics.ts`. +- Create generated `apps/api/gammascope_api/contracts/generated/experimental_analytics.py`. +- Modify `apps/api/tests/test_generated_contracts.py`: validate seed fixture through generated Pydantic model. + +### Backend Analytics + +- Create `apps/api/gammascope_api/experimental/__init__.py`: package marker. +- Create `apps/api/gammascope_api/experimental/models.py`: internal dataclasses and panel helpers. +- Create `apps/api/gammascope_api/experimental/quality.py`: quote pairing, quote filters, no-arbitrage checks. +- Create `apps/api/gammascope_api/experimental/forward.py`: parity forward and ATM straddle. +- Create `apps/api/gammascope_api/experimental/iv_methods.py`: Black-76 pricing, IV solve, raw curves, fits. +- Create `apps/api/gammascope_api/experimental/distribution.py`: probabilities and density preview. +- Create `apps/api/gammascope_api/experimental/trade_maps.py`: move-needed, decay, residual maps. +- Create `apps/api/gammascope_api/experimental/service.py`: orchestration and partial panel error handling. +- Create `apps/api/gammascope_api/routes/experimental.py`: latest and replay experimental API routes. +- Modify `apps/api/gammascope_api/main.py`: include experimental router. +- Modify `apps/api/pyproject.toml`: add `numpy` and `scipy`. + +### Backend Tests + +- Create `apps/api/tests/test_experimental_quality.py`. +- Create `apps/api/tests/test_experimental_forward.py`. +- Create `apps/api/tests/test_experimental_iv_methods.py`. +- Create `apps/api/tests/test_experimental_distribution.py`. +- Create `apps/api/tests/test_experimental_trade_maps.py`. +- Create `apps/api/tests/test_experimental_service.py`. +- Create `apps/api/tests/test_experimental_routes.py`. + +### Frontend Data And UI + +- Modify `apps/web/lib/contracts.ts`: export `ExperimentalAnalytics`. +- Create `apps/web/lib/clientExperimentalSource.ts`: validation and client fetchers. +- Create `apps/web/lib/experimentalFormat.ts`: status, number, ratio, and diagnostics formatting. +- Create `apps/web/components/ExperimentalDashboard.tsx`: page shell and replay/latest state. +- Create `apps/web/components/experimental/ExperimentalPanel.tsx`: reusable panel wrapper. +- Create `apps/web/components/experimental/ExperimentalSmileChart.tsx`: compact SVG chart for IV curves and distribution. +- Create `apps/web/components/experimental/ExperimentalSummaryPanels.tsx`: KPI, forward, smile diagnostics, skew/tail panels. +- Create `apps/web/components/experimental/ExperimentalTables.tsx`: probability, move-needed, decay, residual, quality tables. +- Create `apps/web/app/experimental/page.tsx`: server route. +- Create `apps/web/app/api/spx/0dte/experimental/latest/route.ts`: Next proxy for latest. +- Create `apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts`: Next proxy for replay frame. +- Modify `apps/web/components/DashboardView.tsx`: add Experimental nav tab. +- Modify `apps/web/components/ExposureHeatmap.tsx`: add Experimental nav tab to its independent heatmap header. +- Modify `apps/web/app/styles.css`: experimental workbench layout and responsive styling. + +### Frontend Tests + +- Create `apps/web/tests/clientExperimentalSource.test.ts`. +- Create `apps/web/tests/experimentalFormat.test.ts`. +- Create `apps/web/tests/experimentalRoute.test.ts`. +- Create `apps/web/tests/experimentalReplayRoute.test.ts`. +- Create `apps/web/tests/ExperimentalPage.test.tsx`. +- Create `apps/web/tests/ExperimentalDashboard.test.tsx`. +- Modify `apps/web/tests/DashboardView.test.tsx`: top nav includes Experimental. +- Modify `apps/web/tests/ExposureHeatmap.test.tsx`: Heatmap nav includes Experimental. + +--- + +## Task 1: Add Experimental Contract And Numeric Dependencies + +**Files:** +- Create: `packages/contracts/schemas/experimental-analytics.schema.json` +- Create: `packages/contracts/fixtures/experimental-analytics.seed.json` +- Modify: `packages/contracts/package.json` +- Modify: `packages/contracts/tests/schema.test.mjs` +- Modify: `packages/contracts/tests/generated-types.ts` +- Create generated: `packages/contracts/src/generated/experimental-analytics.ts` +- Create generated: `apps/api/gammascope_api/contracts/generated/experimental_analytics.py` +- Modify: `apps/api/tests/test_generated_contracts.py` +- Modify: `apps/api/pyproject.toml` + +- [ ] **Step 1: Add failing schema fixture validation** + +In `packages/contracts/tests/schema.test.mjs`, add `experimental-analytics.schema.json` to the schema compile list: + +```js + for (const schemaPath of [ + "schemas/common.schema.json", + "schemas/collector-events.schema.json", + "schemas/analytics-snapshot.schema.json", + "schemas/experimental-analytics.schema.json", + "schemas/scenario.schema.json", + "schemas/saved-view.schema.json" + ]) { +``` + +Then add this test after `seed analytics snapshot matches schema`: + +```js +test("seed experimental analytics payload matches schema", async () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + + const schema = await readJson("schemas/experimental-analytics.schema.json"); + const fixture = await readJson("fixtures/experimental-analytics.seed.json"); + const validate = ajv.compile(schema); + + assert.equal(validate(fixture), true, JSON.stringify(validate.errors, null, 2)); +}); +``` + +- [ ] **Step 2: Run contract tests to verify failure** + +Run: + +```bash +pnpm test:contracts +``` + +Expected: FAIL because `packages/contracts/schemas/experimental-analytics.schema.json` does not exist. + +- [ ] **Step 3: Create experimental schema** + +Create `packages/contracts/schemas/experimental-analytics.schema.json` with this schema: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gammascope.local/schemas/experimental-analytics.schema.json", + "title": "ExperimentalAnalytics", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "meta", + "sourceSnapshot", + "forwardSummary", + "ivSmiles", + "smileDiagnostics", + "probabilities", + "terminalDistribution", + "skewTail", + "moveNeeded", + "decayPressure", + "richCheap", + "quoteQuality", + "historyPreview" + ], + "properties": { + "schema_version": { "type": "string", "const": "1.0.0" }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["generatedAt", "mode", "sourceSessionId", "sourceSnapshotTime", "symbol", "expiry"], + "properties": { + "generatedAt": { "type": "string", "format": "date-time" }, + "mode": { "type": "string", "enum": ["latest", "replay"] }, + "sourceSessionId": { "type": "string", "minLength": 1 }, + "sourceSnapshotTime": { "type": "string", "format": "date-time" }, + "symbol": { "type": "string", "const": "SPX" }, + "expiry": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } + } + }, + "sourceSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["spot", "forward", "rowCount", "strikeCount", "timeToExpiryYears"], + "properties": { + "spot": { "type": "number" }, + "forward": { "type": "number" }, + "rowCount": { "type": "integer", "minimum": 0 }, + "strikeCount": { "type": "integer", "minimum": 0 }, + "timeToExpiryYears": { "type": "number", "minimum": 0 } + } + }, + "forwardSummary": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedRange", "expectedMovePercent"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "parityForward": { "type": ["number", "null"] }, + "forwardMinusSpot": { "type": ["number", "null"] }, + "atmStrike": { "type": ["number", "null"] }, + "atmStraddle": { "type": ["number", "null"] }, + "expectedRange": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["lower", "upper"], + "properties": { + "lower": { "type": "number" }, + "upper": { "type": "number" } + } + }, + "expectedMovePercent": { "type": ["number", "null"] } + } + } + ] + }, + "ivSmiles": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "methods"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "methods": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "label", "status", "points"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/PanelStatus" }, + "points": { "type": "array", "items": { "$ref": "#/$defs/Point" } } + } + } + } + } + } + ] + }, + "smileDiagnostics": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "ivValley", "atmForwardIv", "skewSlope", "curvature", "methodDisagreement"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "ivValley": { "$ref": "#/$defs/StrikeValue" }, + "atmForwardIv": { "type": ["number", "null"] }, + "skewSlope": { "type": ["number", "null"] }, + "curvature": { "type": ["number", "null"] }, + "methodDisagreement": { "type": ["number", "null"] } + } + } + ] + }, + "probabilities": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "levels"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "closeAbove", "closeBelow"], + "properties": { + "strike": { "type": "number" }, + "closeAbove": { "type": ["number", "null"] }, + "closeBelow": { "type": ["number", "null"] } + } + } + } + } + } + ] + }, + "terminalDistribution": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "density", "highestDensityZone", "range68", "range95", "leftTailProbability", "rightTailProbability"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "density": { "type": "array", "items": { "$ref": "#/$defs/Point" } }, + "highestDensityZone": { "type": ["string", "null"] }, + "range68": { "type": ["string", "null"] }, + "range95": { "type": ["string", "null"] }, + "leftTailProbability": { "type": ["number", "null"] }, + "rightTailProbability": { "type": ["number", "null"] } + } + } + ] + }, + "skewTail": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "tailBias", "leftTailRichness", "rightTailRichness"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "tailBias": { "type": ["string", "null"] }, + "leftTailRichness": { "type": ["number", "null"] }, + "rightTailRichness": { "type": ["number", "null"] } + } + } + ] + }, + "moveNeeded": { "$ref": "#/$defs/PanelWithRows" }, + "decayPressure": { "$ref": "#/$defs/PanelWithRows" }, + "richCheap": { "$ref": "#/$defs/PanelWithRows" }, + "quoteQuality": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "score", "flags"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "flags": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "right", "code", "message"], + "properties": { + "strike": { "type": "number" }, + "right": { "type": "string", "enum": ["call", "put", "pair"] }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + } + } + } + } + ] + }, + "historyPreview": { "$ref": "#/$defs/PanelWithRows" } + }, + "$defs": { + "PanelStatus": { + "type": "string", + "enum": ["ok", "preview", "insufficient_data", "error"] + }, + "Diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "severity"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 }, + "severity": { "type": "string", "enum": ["info", "warning", "error"] } + } + }, + "PanelBase": { + "type": "object", + "required": ["status", "label", "diagnostics"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string", "minLength": 1 }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } } + } + }, + "Point": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y"], + "properties": { + "x": { "type": "number" }, + "y": { "type": ["number", "null"] } + } + }, + "StrikeValue": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "value", "label"], + "properties": { + "strike": { "type": ["number", "null"] }, + "value": { "type": ["number", "null"] }, + "label": { "type": ["string", "null"] } + } + }, + "PanelWithRows": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "rows"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["strike"], + "properties": { + "strike": { "type": "number" } + } + } + } + } + } + ] + } + } +} +``` + +- [ ] **Step 4: Create seed experimental fixture** + +Create `packages/contracts/fixtures/experimental-analytics.seed.json`: + +```json +{ + "schema_version": "1.0.0", + "meta": { + "generatedAt": "2026-04-23T16:00:00Z", + "mode": "latest", + "sourceSessionId": "seed-spx-2026-04-23", + "sourceSnapshotTime": "2026-04-23T16:00:00Z", + "symbol": "SPX", + "expiry": "2026-04-23" + }, + "sourceSnapshot": { + "spot": 5200.25, + "forward": 5200.36, + "rowCount": 34, + "strikeCount": 17, + "timeToExpiryYears": 0.000456621 + }, + "forwardSummary": { + "status": "ok", + "label": "Forward and expected move", + "diagnostics": [], + "parityForward": 5200.36, + "forwardMinusSpot": 0.11, + "atmStrike": 5200, + "atmStraddle": 18.75, + "expectedRange": { "lower": 5181.61, "upper": 5219.11 }, + "expectedMovePercent": 0.003605 + }, + "ivSmiles": { + "status": "preview", + "label": "IV smile methods", + "diagnostics": [{ "code": "preview_method", "message": "Experimental fitted methods are diagnostic.", "severity": "info" }], + "methods": [ + { "key": "custom_iv", "label": "Current custom IV", "status": "ok", "points": [{ "x": 5190, "y": 0.18 }, { "x": 5200, "y": 0.17 }, { "x": 5210, "y": 0.19 }] }, + { "key": "spline_fit", "label": "Spline fit", "status": "preview", "points": [{ "x": 5190, "y": 0.181 }, { "x": 5200, "y": 0.171 }, { "x": 5210, "y": 0.191 }] } + ] + }, + "smileDiagnostics": { + "status": "preview", + "label": "Smile diagnostics", + "diagnostics": [], + "ivValley": { "strike": 5200, "value": 0.171, "label": "Spline valley" }, + "atmForwardIv": 0.172, + "skewSlope": -0.08, + "curvature": 0.12, + "methodDisagreement": 0.011 + }, + "probabilities": { + "status": "preview", + "label": "Risk-neutral probabilities", + "diagnostics": [{ "code": "risk_neutral", "message": "Probabilities are risk-neutral, not real-world.", "severity": "info" }], + "levels": [ + { "strike": 5190, "closeAbove": 0.67, "closeBelow": 0.33 }, + { "strike": 5200, "closeAbove": 0.52, "closeBelow": 0.48 }, + { "strike": 5210, "closeAbove": 0.35, "closeBelow": 0.65 } + ] + }, + "terminalDistribution": { + "status": "preview", + "label": "Terminal distribution", + "diagnostics": [], + "density": [{ "x": 5190, "y": 0.02 }, { "x": 5200, "y": 0.04 }, { "x": 5210, "y": 0.025 }], + "highestDensityZone": "5195-5205", + "range68": "5182-5219", + "range95": "5164-5237", + "leftTailProbability": 0.08, + "rightTailProbability": 0.06 + }, + "skewTail": { + "status": "preview", + "label": "Skew and tail asymmetry", + "diagnostics": [], + "tailBias": "Left-tail rich", + "leftTailRichness": 1.18, + "rightTailRichness": 0.94 + }, + "moveNeeded": { + "status": "ok", + "label": "Move-needed map", + "diagnostics": [], + "rows": [{ "strike": 5210, "side": "call", "breakeven": 5213.2, "moveNeeded": 12.95, "expectedMoveRatio": 0.69, "label": "Within expected move" }] + }, + "decayPressure": { + "status": "preview", + "label": "Time-decay pressure", + "diagnostics": [{ "code": "static_decay", "message": "Static pressure assumes no spot or IV change.", "severity": "info" }], + "rows": [{ "strike": 5210, "side": "call", "premium": 3.2, "pointsPerMinute": 0.08 }] + }, + "richCheap": { + "status": "preview", + "label": "Rich/cheap residuals", + "diagnostics": [], + "rows": [{ "strike": 5210, "side": "call", "actualMid": 3.2, "fittedFair": 3.05, "residual": 0.15, "label": "Rich" }] + }, + "quoteQuality": { + "status": "ok", + "label": "Quote quality", + "diagnostics": [], + "score": 0.94, + "flags": [{ "strike": 5120, "right": "put", "code": "zero_bid", "message": "Bid is zero." }] + }, + "historyPreview": { + "status": "insufficient_data", + "label": "Range compression preview", + "diagnostics": [{ "code": "needs_replay", "message": "Select replay frames to compare history.", "severity": "info" }], + "rows": [] + } +} +``` + +- [ ] **Step 5: Update contract package exports and generation** + +Modify `packages/contracts/package.json`: + +```json +{ + "scripts": { + "validate": "node --test tests/schema.test.mjs", + "test": "node --test tests/schema.test.mjs", + "generate": "mkdir -p src/generated && json2ts -i schemas/analytics-snapshot.schema.json -o src/generated/analytics-snapshot.ts && json2ts -i schemas/collector-events.schema.json -o src/generated/collector-events.ts && json2ts -i schemas/experimental-analytics.schema.json -o src/generated/experimental-analytics.ts && json2ts -i schemas/scenario.schema.json -o src/generated/scenario.ts && json2ts -i schemas/saved-view.schema.json -o src/generated/saved-view.ts", + "typecheck:generated": "tsc --project tsconfig.generated.json --noEmit" + }, + "exports": { + "./analytics-snapshot": "./src/generated/analytics-snapshot.ts", + "./collector-events": "./src/generated/collector-events.ts", + "./experimental-analytics": "./src/generated/experimental-analytics.ts", + "./scenario": "./src/generated/scenario.ts", + "./saved-view": "./src/generated/saved-view.ts" + } +} +``` + +Preserve unchanged fields such as `name`, `version`, `private`, `type`, and `devDependencies`. + +- [ ] **Step 6: Update TypeScript generated type smoke** + +Modify `packages/contracts/tests/generated-types.ts`: + +```ts +import type { AnalyticsSnapshot } from "../src/generated/analytics-snapshot"; +import type { CollectorEvents } from "../src/generated/collector-events"; +import type { ExperimentalAnalytics } from "../src/generated/experimental-analytics"; +import type { SavedView } from "../src/generated/saved-view"; +import type { ScenarioRequest } from "../src/generated/scenario"; + +type _SnapshotSchemaVersion = AnalyticsSnapshot["schema_version"]; +type _CollectorEvent = CollectorEvents; +type _ExperimentalPanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; +type _ScenarioShift = ScenarioRequest["vol_shift_points"]; +type _SavedViewMode = SavedView["mode"]; +``` + +- [ ] **Step 7: Add Python generated model test** + +Modify `apps/api/tests/test_generated_contracts.py`: + +```python +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +``` + +Add: + +```python +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" +``` + +- [ ] **Step 8: Add numeric dependencies** + +Modify `apps/api/pyproject.toml` dependencies: + +```toml +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" +] +``` + +- [ ] **Step 9: Generate TypeScript and Python contract models** + +Run: + +```bash +pnpm contracts:generate +.venv/bin/python -m datamodel_code_generator --input packages/contracts/schemas/experimental-analytics.schema.json --input-file-type jsonschema --output apps/api/gammascope_api/contracts/generated/experimental_analytics.py --output-model-type pydantic_v2.BaseModel --disable-timestamp +``` + +Expected: creates `packages/contracts/src/generated/experimental-analytics.ts` and `apps/api/gammascope_api/contracts/generated/experimental_analytics.py`. + +- [ ] **Step 10: Run contract and generated-model tests** + +Run: + +```bash +pnpm test:contracts +pnpm --filter @gammascope/contracts typecheck:generated +.venv/bin/pytest apps/api/tests/test_generated_contracts.py -q +``` + +Expected: all pass. + +- [ ] **Step 11: Commit contracts** + +Run: + +```bash +git add packages/contracts apps/api/pyproject.toml apps/api/gammascope_api/contracts/generated/experimental_analytics.py apps/api/tests/test_generated_contracts.py +git commit -m "feat: add experimental analytics contract" +``` + +--- + +## Task 2: Add Quote Quality And Forward Summary Core + +**Files:** +- Create: `apps/api/gammascope_api/experimental/__init__.py` +- Create: `apps/api/gammascope_api/experimental/models.py` +- Create: `apps/api/gammascope_api/experimental/quality.py` +- Create: `apps/api/gammascope_api/experimental/forward.py` +- Test: `apps/api/tests/test_experimental_quality.py` +- Test: `apps/api/tests/test_experimental_forward.py` + +- [ ] **Step 1: Write failing quote-quality tests** + +Create `apps/api/tests/test_experimental_quality.py`: + +```python +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"} +``` + +- [ ] **Step 2: Run quality tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'gammascope_api.experimental'`. + +- [ ] **Step 3: Implement experimental models** + +Create `apps/api/gammascope_api/experimental/__init__.py` as an empty file. + +Create `apps/api/gammascope_api/experimental/models.py`: + +```python +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 +``` + +- [ ] **Step 4: Implement quote quality** + +Create `apps/api/gammascope_api/experimental/quality.py`: + +```python +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 = float(row["strike"]) + 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 = float(row["strike"]) + right = str(row.get("right") or "pair") + flags: list[dict[str, Any]] = [] + + 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} +``` + +- [ ] **Step 5: Run quality tests to verify pass** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Write failing forward tests** + +Create `apps/api/tests/test_experimental_forward.py`: + +```python +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 +``` + +- [ ] **Step 7: Run forward tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_forward.py -q +``` + +Expected: FAIL because `gammascope_api.experimental.forward` does not exist. + +- [ ] **Step 8: Implement forward summary** + +Create `apps/api/gammascope_api/experimental/forward.py`: + +```python +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) + forward_estimates: list[tuple[float, float]] = [] + + for pair in grouped_pairs(list(snapshot.get("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 + 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(grouped_pairs(list(snapshot.get("rows", []))), 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) +``` + +- [ ] **Step 9: Run focused backend core tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py -q +``` + +Expected: PASS. + +- [ ] **Step 10: Commit quality and forward core** + +Run: + +```bash +git add apps/api/gammascope_api/experimental apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py +git commit -m "feat: add experimental quote quality and forward summary" +``` + +--- + +## Task 3: Add IV Method Curves And Fitted Smile Diagnostics + +**Files:** +- Create: `apps/api/gammascope_api/experimental/iv_methods.py` +- Test: `apps/api/tests/test_experimental_iv_methods.py` + +- [ ] **Step 1: Write failing IV method tests** + +Create `apps/api/tests/test_experimental_iv_methods.py`: + +```python +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_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"] is not None +``` + +- [ ] **Step 2: Run IV tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_iv_methods.py -q +``` + +Expected: FAIL because `iv_methods.py` does not exist. + +- [ ] **Step 3: Implement Black-76, IV solve, and smile methods** + +Create `apps/api/gammascope_api/experimental/iv_methods.py`: + +```python +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 = list((spline or {}).get("points") or []) + if not points: + return panel("insufficient_data", "Smile diagnostics", [diagnostic("missing_fit", "No fitted smile is available.", "warning")], ivValley={"strike": None, "value": None, "label": None}, atmForwardIv=None, skewSlope=None, curvature=None, methodDisagreement=None) + finite_points = [point for point in points if point.get("y") is not None] + 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) + 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: + value = optional_float(row.get(key)) + if value is not None: + points.append({"x": float(row["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 atm is None 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]) -> float | None: + method_values = [] + for method in iv_panel.get("methods", []): + points = [point for point in method.get("points", []) if point.get("y") is not None] + if points: + method_values.append(float(points[len(points) // 2]["y"])) + if len(method_values) < 2: + return None + return max(method_values) - min(method_values) +``` + +- [ ] **Step 4: Run IV tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_iv_methods.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Run backend core regression** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py apps/api/tests/test_experimental_iv_methods.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Commit IV methods** + +Run: + +```bash +git add apps/api/gammascope_api/experimental/iv_methods.py apps/api/tests/test_experimental_iv_methods.py +git commit -m "feat: add experimental iv smile methods" +``` + +--- + +## Task 4: Add Distribution, Probability, Trade Map, And Residual Panels + +**Files:** +- Create: `apps/api/gammascope_api/experimental/distribution.py` +- Create: `apps/api/gammascope_api/experimental/trade_maps.py` +- Test: `apps/api/tests/test_experimental_distribution.py` +- Test: `apps/api/tests/test_experimental_trade_maps.py` + +- [ ] **Step 1: Write failing distribution tests** + +Create `apps/api/tests/test_experimental_distribution.py`: + +```python +from gammascope_api.experimental.distribution import probability_panel, terminal_distribution_panel, skew_tail_panel + + +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"} +``` + +- [ ] **Step 2: Run distribution tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_distribution.py -q +``` + +Expected: FAIL because `distribution.py` does not exist. + +- [ ] **Step 3: Implement distribution panels** + +Create `apps/api/gammascope_api/experimental/distribution.py`: + +```python +from __future__ import annotations + +from math import exp, log, sqrt +from typing import Any + +from gammascope_api.experimental.iv_methods import black76_price, normal_cdf +from gammascope_api.experimental.models import diagnostic, panel + + +def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: + points = _fit_points(iv_panel) + if len(points) < 2: + return panel("insufficient_data", "Risk-neutral probabilities", [diagnostic("missing_fit", "A fitted smile is required.", "warning")], levels=[]) + levels = [] + for point in points: + strike = float(point["x"]) + sigma = float(point["y"]) + if forward <= 0 or strike <= 0 or tau <= 0: + close_above = None + else: + 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]: + points = _fit_points(iv_panel) + if len(points) < 3: + return panel("insufficient_data", "Terminal distribution", [diagnostic("missing_fit", "A fitted smile with at least three points is required.", "warning")], density=[], highestDensityZone=None, range68=None, range95=None, leftTailProbability=None, rightTailProbability=None) + calls = [ + black76_price(forward=forward, strike=float(point["x"]), tau=tau, rate=rate, sigma=float(point["y"]), right="call") + for point in points + ] + strikes = [float(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] + width = max((left_width + right_width) / 2, 1e-9) + curvature = (calls[index - 1] - 2 * calls[index] + calls[index + 1]) / (width * width) + density.append({"x": strikes[index], "y": max(0.0, curvature * exp(rate * tau))}) + if not density: + return panel("insufficient_data", "Terminal distribution", [diagnostic("empty_density", "Density could not be estimated.", "warning")], density=[], highestDensityZone=None, range68=None, range95=None, leftTailProbability=None, rightTailProbability=None) + 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]: + points = _fit_points(iv_panel) + if len(points) < 3: + return panel("insufficient_data", "Skew and tail asymmetry", [diagnostic("missing_fit", "A fitted smile is required.", "warning")], tailBias=None, leftTailRichness=None, rightTailRichness=None) + 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]]: + for method in iv_panel.get("methods", []): + if method.get("key") == "spline_fit": + return [point for point in method.get("points", []) if point.get("y") is not None] + 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}" +``` + +- [ ] **Step 4: Run distribution tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_distribution.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Write failing trade map tests** + +Create `apps/api/tests/test_experimental_trade_maps.py`: + +```python +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"} +``` + +- [ ] **Step 6: Run trade map tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_trade_maps.py -q +``` + +Expected: FAIL because `trade_maps.py` does not exist. + +- [ ] **Step 7: Implement trade maps** + +Create `apps/api/gammascope_api/experimental/trade_maps.py`: + +```python +from __future__ import annotations + +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: list[dict[str, Any]], *, spot: float, expected_move: float | None) -> dict[str, Any]: + out = [] + for row in rows: + mid = optional_float(row.get("mid")) + if mid is None: + continue + strike = float(row["strike"]) + side = str(row["right"]) + 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: list[dict[str, Any]], *, minutes_to_expiry: float) -> dict[str, Any]: + out = [] + minutes = max(minutes_to_expiry, 1e-9) + for row in rows: + mid = optional_float(row.get("mid")) + if mid is None: + continue + out.append({"strike": float(row["strike"]), "side": row["right"], "premium": mid, "pointsPerMinute": mid / minutes}) + 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: list[dict[str, Any]], *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: + fit_by_strike = _fit_by_strike(iv_panel) + out = [] + for row in rows: + mid = optional_float(row.get("mid")) + sigma = fit_by_strike.get(float(row["strike"])) + if mid is None or sigma is None: + continue + side = row["right"] + fitted_fair = black76_price(forward=forward, strike=float(row["strike"]), tau=tau, rate=rate, sigma=sigma, right=side) + residual = mid - fitted_fair + out.append({"strike": float(row["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 _fit_by_strike(iv_panel: dict[str, Any]) -> dict[float, float]: + for method in iv_panel.get("methods", []): + if method.get("key") == "spline_fit": + return {float(point["x"]): float(point["y"]) for point in method.get("points", []) if point.get("y") is not None} + return {} +``` + +- [ ] **Step 8: Run distribution and trade map tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_distribution.py apps/api/tests/test_experimental_trade_maps.py -q +``` + +Expected: PASS. + +- [ ] **Step 9: Run all experimental backend unit tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py apps/api/tests/test_experimental_iv_methods.py apps/api/tests/test_experimental_distribution.py apps/api/tests/test_experimental_trade_maps.py -q +``` + +Expected: PASS. + +- [ ] **Step 10: Commit distribution and trade maps** + +Run: + +```bash +git add apps/api/gammascope_api/experimental/distribution.py apps/api/gammascope_api/experimental/trade_maps.py apps/api/tests/test_experimental_distribution.py apps/api/tests/test_experimental_trade_maps.py +git commit -m "feat: add experimental distribution and trade maps" +``` + +--- + +## Task 5: Add Experimental Service And FastAPI Routes + +**Files:** +- Create: `apps/api/gammascope_api/experimental/service.py` +- Create: `apps/api/gammascope_api/routes/experimental.py` +- Modify: `apps/api/gammascope_api/main.py` +- Test: `apps/api/tests/test_experimental_service.py` +- Test: `apps/api/tests/test_experimental_routes.py` + +- [ ] **Step 1: Write failing service tests** + +Create `apps/api/tests/test_experimental_service.py`: + +```python +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.experimental.service import build_experimental_payload +from gammascope_api.fixtures import load_json_fixture + + +def test_build_experimental_payload_validates_against_generated_model() -> None: + snapshot = load_json_fixture("analytics-snapshot.seed.json") + + payload = build_experimental_payload(snapshot, mode="latest") + validated = ExperimentalAnalytics.model_validate(payload) + + assert validated.schema_version == "1.0.0" + assert validated.meta.mode.value == "latest" + assert validated.meta.sourceSessionId == snapshot["session_id"] + assert validated.forwardSummary.label == "Forward and expected move" + assert validated.ivSmiles.methods + assert validated.quoteQuality.score >= 0 + + +def test_build_experimental_payload_returns_partial_panel_when_iv_builder_fails(monkeypatch) -> None: + snapshot = load_json_fixture("analytics-snapshot.seed.json") + + def explode(*_args, **_kwargs): + raise RuntimeError("fit failed") + + monkeypatch.setattr("gammascope_api.experimental.service.build_iv_smiles_panel", explode) + + payload = build_experimental_payload(snapshot, mode="latest") + + assert payload["ivSmiles"]["status"] == "error" + assert payload["forwardSummary"]["status"] in {"ok", "preview", "insufficient_data"} + ExperimentalAnalytics.model_validate(payload) +``` + +- [ ] **Step 2: Run service tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_service.py -q +``` + +Expected: FAIL because `service.py` does not exist. + +- [ ] **Step 3: Implement experimental service** + +Create `apps/api/gammascope_api/experimental/service.py`: + +```python +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Literal + +from gammascope_api.experimental.distribution import probability_panel, skew_tail_panel, terminal_distribution_panel +from gammascope_api.experimental.forward import forward_summary_panel, time_to_expiry_years +from gammascope_api.experimental.iv_methods import build_iv_smiles_panel, smile_diagnostics_panel +from gammascope_api.experimental.models import diagnostic, 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"] + + +def build_experimental_payload(snapshot: dict[str, Any], *, mode: ExperimentalMode) -> dict[str, Any]: + tau = time_to_expiry_years(str(snapshot["snapshot_time"]), str(snapshot["expiry"])) + rows = list(snapshot.get("rows", [])) + source = { + "spot": float(snapshot["spot"]), + "forward": float(snapshot["forward"]), + "rowCount": len(rows), + "strikeCount": len({float(row["strike"]) for row in rows}), + "timeToExpiryYears": tau, + } + forward = _safe_panel("forwardSummary", lambda: forward_summary_panel(snapshot)) + iv_smiles = _safe_panel("ivSmiles", lambda: build_iv_smiles_panel(snapshot, forward)) + smile_diagnostics = _safe_panel("smileDiagnostics", lambda: smile_diagnostics_panel(iv_smiles, forward=float(forward.get("parityForward") or snapshot["forward"]))) + rate = float(snapshot.get("risk_free_rate") or 0) + model_forward = float(forward.get("parityForward") or snapshot["forward"]) + expected_move = forward.get("atmStraddle") + minutes = max(tau * 365 * 24 * 60, 0) + + return { + "schema_version": "1.0.0", + "meta": { + "generatedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "mode": mode, + "sourceSessionId": str(snapshot["session_id"]), + "sourceSnapshotTime": str(snapshot["snapshot_time"]), + "symbol": str(snapshot["symbol"]), + "expiry": str(snapshot["expiry"]), + }, + "sourceSnapshot": source, + "forwardSummary": forward, + "ivSmiles": iv_smiles, + "smileDiagnostics": smile_diagnostics, + "probabilities": _safe_panel("probabilities", lambda: probability_panel(iv_smiles, forward=model_forward, tau=max(tau, 1e-9), rate=rate)), + "terminalDistribution": _safe_panel("terminalDistribution", lambda: terminal_distribution_panel(iv_smiles, forward=model_forward, tau=max(tau, 1e-9), rate=rate)), + "skewTail": _safe_panel("skewTail", lambda: skew_tail_panel(iv_smiles, forward=model_forward)), + "moveNeeded": _safe_panel("moveNeeded", lambda: move_needed_panel(rows, spot=float(snapshot["spot"]), expected_move=expected_move)), + "decayPressure": _safe_panel("decayPressure", lambda: decay_pressure_panel(rows, minutes_to_expiry=minutes)), + "richCheap": _safe_panel("richCheap", lambda: rich_cheap_panel(rows, iv_panel=iv_smiles, forward=model_forward, tau=max(tau, 1e-9), rate=rate)), + "quoteQuality": _safe_panel("quoteQuality", lambda: quote_quality_panel(rows)), + "historyPreview": panel("insufficient_data", "Range compression preview", [diagnostic("needs_replay_frames", "Select replay frames to compare history.", "info")], rows=[]), + } + + +def _safe_panel(name: str, builder): # type: ignore[no-untyped-def] + try: + return builder() + except Exception as exc: + return panel( + "error", + _label_for(name), + [diagnostic("panel_error", f"{_label_for(name)} failed: {exc}", "error")], + **EMPTY_VALUES_BY_PANEL.get(name, {}), + ) + + +def _label_for(name: str) -> str: + return { + "forwardSummary": "Forward and expected move", + "ivSmiles": "IV smile methods", + "smileDiagnostics": "Smile diagnostics", + "probabilities": "Risk-neutral probabilities", + "terminalDistribution": "Terminal distribution", + "skewTail": "Skew and tail asymmetry", + "moveNeeded": "Move-needed map", + "decayPressure": "Time-decay pressure", + "richCheap": "Rich/cheap residuals", + "quoteQuality": "Quote quality", + }.get(name, name) + + +EMPTY_VALUES_BY_PANEL = { + "forwardSummary": {"parityForward": None, "forwardMinusSpot": None, "atmStrike": None, "atmStraddle": None, "expectedRange": None, "expectedMovePercent": None}, + "ivSmiles": {"methods": []}, + "smileDiagnostics": {"ivValley": {"strike": None, "value": None, "label": None}, "atmForwardIv": None, "skewSlope": None, "curvature": None, "methodDisagreement": None}, + "probabilities": {"levels": []}, + "terminalDistribution": {"density": [], "highestDensityZone": None, "range68": None, "range95": None, "leftTailProbability": None, "rightTailProbability": None}, + "skewTail": {"tailBias": None, "leftTailRichness": None, "rightTailRichness": None}, + "moveNeeded": {"rows": []}, + "decayPressure": {"rows": []}, + "richCheap": {"rows": []}, + "quoteQuality": {"score": 0, "flags": []}, +} +``` + +- [ ] **Step 4: Run service tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_service.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Write failing route tests** + +Create `apps/api/tests/test_experimental_routes.py`: + +```python +from fastapi.testclient import TestClient + +from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics +from gammascope_api.main import app + + +client = TestClient(app) + + +def test_latest_experimental_route_returns_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" + + +def test_replay_experimental_route_returns_requested_replay_payload() -> None: + response = client.get("/api/spx/0dte/experimental/replay/snapshot?session_id=seed-spx-2026-04-23&at=2026-04-23T15:40:00Z") + + assert response.status_code == 200 + payload = response.json() + ExperimentalAnalytics.model_validate(payload) + assert payload["meta"]["mode"] == "replay" + assert payload["meta"]["sourceSnapshotTime"] == "2026-04-23T15:40:00Z" +``` + +- [ ] **Step 6: Run route tests to verify failure** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_routes.py -q +``` + +Expected: FAIL with 404 responses because routes are not registered. + +- [ ] **Step 7: Implement FastAPI routes** + +Create `apps/api/gammascope_api/routes/experimental.py`: + +```python +from __future__ import annotations + +from fastapi import APIRouter, Header + +from gammascope_api.auth import can_read_live_state +from gammascope_api.experimental.service import build_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_live_snapshot +from gammascope_api.routes.replay import get_replay_snapshot + + +router = APIRouter() + + +@router.get("/api/spx/0dte/experimental/latest") +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_live_snapshot(cached_or_memory_collector_state()) + if live_snapshot is not None: + return build_experimental_payload(live_snapshot, mode="latest") + return build_experimental_payload(load_json_fixture("analytics-snapshot.seed.json"), mode="latest") + + +@router.get("/api/spx/0dte/experimental/replay/snapshot") +def get_replay_experimental( + session_id: str, + at: str | None = None, + source_snapshot_id: str | None = None, +) -> dict: + snapshot = get_replay_snapshot(session_id=session_id, at=at, source_snapshot_id=source_snapshot_id) + return build_experimental_payload(snapshot, mode="replay") +``` + +Modify `apps/api/gammascope_api/main.py`: + +```python +from gammascope_api.routes import admin, collector, experimental, heatmap, replay, replay_imports, scenario, snapshot, status, stream, views +``` + +Add after `app.include_router(snapshot.router)`: + +```python +app.include_router(experimental.router) +``` + +- [ ] **Step 8: Run service and route tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_service.py apps/api/tests/test_experimental_routes.py -q +``` + +Expected: PASS. + +- [ ] **Step 9: Run focused backend experimental suite** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py apps/api/tests/test_experimental_iv_methods.py apps/api/tests/test_experimental_distribution.py apps/api/tests/test_experimental_trade_maps.py apps/api/tests/test_experimental_service.py apps/api/tests/test_experimental_routes.py -q +``` + +Expected: PASS. + +- [ ] **Step 10: Commit service and routes** + +Run: + +```bash +git add apps/api/gammascope_api/experimental/service.py apps/api/gammascope_api/routes/experimental.py apps/api/gammascope_api/main.py apps/api/tests/test_experimental_service.py apps/api/tests/test_experimental_routes.py +git commit -m "feat: add experimental analytics api" +``` + +--- + +## Task 6: Add Next.js Experimental Proxy And Client Source + +**Files:** +- Modify: `apps/web/lib/contracts.ts` +- Create: `apps/web/lib/clientExperimentalSource.ts` +- Create: `apps/web/lib/experimentalFormat.ts` +- Create: `apps/web/app/api/spx/0dte/experimental/latest/route.ts` +- Create: `apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts` +- Test: `apps/web/tests/clientExperimentalSource.test.ts` +- Test: `apps/web/tests/experimentalFormat.test.ts` +- Test: `apps/web/tests/experimentalRoute.test.ts` +- Test: `apps/web/tests/experimentalReplayRoute.test.ts` + +- [ ] **Step 1: Export experimental contract type** + +Modify `apps/web/lib/contracts.ts`: + +```ts +export type { AnalyticsSnapshot } from "@gammascope/contracts/analytics-snapshot"; +export type { ExperimentalAnalytics } from "@gammascope/contracts/experimental-analytics"; +``` + +- [ ] **Step 2: Write failing client source tests** + +Create `apps/web/tests/clientExperimentalSource.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { loadClientExperimentalLatest, loadClientExperimentalReplaySnapshot, isExperimentalAnalytics } from "../lib/clientExperimentalSource"; + +const payload = { + schema_version: "1.0.0", + meta: { + generatedAt: "2026-04-23T16:00:00Z", + mode: "latest", + sourceSessionId: "seed-spx-2026-04-23", + sourceSnapshotTime: "2026-04-23T16:00:00Z", + symbol: "SPX", + expiry: "2026-04-23" + }, + sourceSnapshot: { spot: 5200.25, forward: 5200.36, rowCount: 34, strikeCount: 17, timeToExpiryYears: 0.01 }, + forwardSummary: { status: "ok", label: "Forward", diagnostics: [], parityForward: 5200.36, forwardMinusSpot: 0.11, atmStrike: 5200, atmStraddle: 18.75, expectedRange: { lower: 5181.61, upper: 5219.11 }, expectedMovePercent: 0.0036 }, + ivSmiles: { status: "preview", label: "IV", diagnostics: [], methods: [] }, + smileDiagnostics: { status: "preview", label: "Smile", diagnostics: [], ivValley: { strike: null, value: null, label: null }, atmForwardIv: null, skewSlope: null, curvature: null, methodDisagreement: null }, + probabilities: { status: "preview", label: "Probabilities", diagnostics: [], levels: [] }, + terminalDistribution: { status: "preview", label: "Distribution", diagnostics: [], density: [], highestDensityZone: null, range68: null, range95: null, leftTailProbability: null, rightTailProbability: null }, + skewTail: { status: "preview", label: "Skew", diagnostics: [], tailBias: null, leftTailRichness: null, rightTailRichness: null }, + moveNeeded: { status: "ok", label: "Move", diagnostics: [], rows: [] }, + decayPressure: { status: "preview", label: "Decay", diagnostics: [], rows: [] }, + richCheap: { status: "preview", label: "Residuals", diagnostics: [], rows: [] }, + quoteQuality: { status: "ok", label: "Quality", diagnostics: [], score: 1, flags: [] }, + historyPreview: { status: "insufficient_data", label: "History", diagnostics: [], rows: [] } +}; + +function jsonResponse(body: unknown, ok = true): Response { + return { ok, json: async () => body } as Response; +} + +describe("client experimental source", () => { + it("validates an experimental payload", () => { + expect(isExperimentalAnalytics(payload)).toBe(true); + expect(isExperimentalAnalytics({ ...payload, quoteQuality: { score: 1 } })).toBe(false); + }); + + it("loads latest experimental analytics without caching", async () => { + const fetcher = vi.fn(async () => jsonResponse(payload)); + + await expect(loadClientExperimentalLatest({ fetcher })).resolves.toEqual(payload); + expect(fetcher).toHaveBeenCalledWith("/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { Accept: "application/json" } + }); + }); + + it("loads replay experimental analytics with source snapshot id", async () => { + const fetcher = vi.fn(async () => jsonResponse({ ...payload, meta: { ...payload.meta, mode: "replay" } })); + + await loadClientExperimentalReplaySnapshot({ session_id: "session/a", at: "2026-04-23T15:40:00Z", source_snapshot_id: "snap-1" }, { fetcher }); + + expect(fetcher).toHaveBeenCalledWith("/api/spx/0dte/experimental/replay/snapshot?session_id=session%2Fa&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snap-1", { + cache: "no-store", + headers: { Accept: "application/json" } + }); + }); + + it("returns null for invalid payloads", async () => { + const fetcher = vi.fn(async () => jsonResponse({ ...payload, schema_version: "0.0.0" })); + + await expect(loadClientExperimentalLatest({ fetcher })).resolves.toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run client source tests to verify failure** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/clientExperimentalSource.test.ts +``` + +Expected: FAIL because `clientExperimentalSource.ts` does not exist. + +- [ ] **Step 4: Implement client experimental source** + +Create `apps/web/lib/clientExperimentalSource.ts`: + +```ts +import type { ExperimentalAnalytics } from "./contracts"; + +const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest"; +const EXPERIMENTAL_REPLAY_PATH = "/api/spx/0dte/experimental/replay/snapshot"; + +type ExperimentalFetcher = (input: string, init: RequestInit) => Promise; + +interface LoadOptions { + fetcher?: ExperimentalFetcher; +} + +export interface ExperimentalReplayRequest { + session_id: string; + at?: string; + source_snapshot_id?: string; +} + +export async function loadClientExperimentalLatest(options: LoadOptions = {}): Promise { + return loadExperimental(EXPERIMENTAL_LATEST_PATH, options); +} + +export async function loadClientExperimentalReplaySnapshot( + request: ExperimentalReplayRequest, + options: LoadOptions = {} +): Promise { + const params = new URLSearchParams({ session_id: request.session_id }); + if (request.at) { + params.set("at", request.at); + } + if (request.source_snapshot_id) { + params.set("source_snapshot_id", request.source_snapshot_id); + } + return loadExperimental(`${EXPERIMENTAL_REPLAY_PATH}?${params.toString()}`, options); +} + +async function loadExperimental(path: string, options: LoadOptions): Promise { + const fetcher = options.fetcher ?? fetch; + try { + const response = await fetcher(path, { + cache: "no-store", + headers: { Accept: "application/json" } + }); + if (!response.ok) { + return null; + } + const payload = await response.json(); + return isExperimentalAnalytics(payload) ? payload : null; + } catch { + return null; + } +} + +export function isExperimentalAnalytics(payload: unknown): payload is ExperimentalAnalytics { + if (!isRecord(payload)) { + return false; + } + return ( + payload.schema_version === "1.0.0" && + isRecord(payload.meta) && + (payload.meta.mode === "latest" || payload.meta.mode === "replay") && + payload.meta.symbol === "SPX" && + isRecord(payload.sourceSnapshot) && + isPanel(payload.forwardSummary) && + isIvSmiles(payload.ivSmiles) && + isPanel(payload.smileDiagnostics) && + isPanel(payload.probabilities) && + isPanel(payload.terminalDistribution) && + isPanel(payload.skewTail) && + isPanel(payload.moveNeeded) && + isPanel(payload.decayPressure) && + isPanel(payload.richCheap) && + isQuoteQuality(payload.quoteQuality) && + isPanel(payload.historyPreview) + ); +} + +function isPanel(value: unknown): value is { status: string; label: string; diagnostics: unknown[] } { + return isRecord(value) && isStatus(value.status) && typeof value.label === "string" && Array.isArray(value.diagnostics); +} + +function isIvSmiles(value: unknown): boolean { + return isPanel(value) && Array.isArray(value.methods); +} + +function isQuoteQuality(value: unknown): boolean { + return isPanel(value) && typeof value.score === "number" && Number.isFinite(value.score) && Array.isArray(value.flags); +} + +function isStatus(value: unknown): boolean { + return value === "ok" || value === "preview" || value === "insufficient_data" || value === "error"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} +``` + +- [ ] **Step 5: Add formatter tests** + +Create `apps/web/tests/experimentalFormat.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { formatExperimentalNumber, formatExperimentalPercent, panelStatusDisplay } from "../lib/experimentalFormat"; + +describe("experimental format helpers", () => { + it("formats numbers and percentages with null fallbacks", () => { + expect(formatExperimentalNumber(12.3456, 2)).toBe("12.35"); + expect(formatExperimentalNumber(null)).toBe("—"); + expect(formatExperimentalPercent(0.1234)).toBe("12.34%"); + }); + + it("maps panel statuses to display tones", () => { + expect(panelStatusDisplay("ok")).toEqual({ label: "OK", tone: "ok" }); + expect(panelStatusDisplay("preview")).toEqual({ label: "Preview", tone: "warning" }); + expect(panelStatusDisplay("insufficient_data")).toEqual({ label: "Insufficient data", tone: "muted" }); + expect(panelStatusDisplay("error")).toEqual({ label: "Error", tone: "error" }); + }); +}); +``` + +- [ ] **Step 6: Implement format helpers** + +Create `apps/web/lib/experimentalFormat.ts`: + +```ts +import type { ExperimentalAnalytics } from "./contracts"; + +export type PanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; +export type PanelTone = "ok" | "warning" | "muted" | "error"; + +export function panelStatusDisplay(status: PanelStatus): { label: string; tone: PanelTone } { + if (status === "ok") { + return { label: "OK", tone: "ok" }; + } + if (status === "preview") { + return { label: "Preview", tone: "warning" }; + } + if (status === "insufficient_data") { + return { label: "Insufficient data", tone: "muted" }; + } + return { label: "Error", tone: "error" }; +} + +export function formatExperimentalNumber(value: number | null | undefined, digits = 2): string { + if (value == null || !Number.isFinite(value)) { + return "—"; + } + return value.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} + +export function formatExperimentalPercent(value: number | null | undefined, digits = 2): string { + if (value == null || !Number.isFinite(value)) { + return "—"; + } + return `${(value * 100).toFixed(digits)}%`; +} + +export function formatExperimentalRange(range: { lower: number; upper: number } | null | undefined): string { + if (!range) { + return "—"; + } + return `${formatExperimentalNumber(range.lower)} - ${formatExperimentalNumber(range.upper)}`; +} +``` + +- [ ] **Step 7: Run client and formatter tests** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/clientExperimentalSource.test.ts tests/experimentalFormat.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Write failing Next proxy route tests** + +Create `apps/web/tests/experimentalRoute.test.ts`: + +```ts +import { describe, expect, it, vi, afterEach } from "vitest"; + +describe("GET /api/spx/0dte/experimental/latest", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("forwards latest experimental analytics without caching", async () => { + vi.stubEnv("GAMMASCOPE_API_BASE_URL", "http://fastapi.local:9000/"); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + const response = await GET(new Request("http://web.local/api/spx/0dte/experimental/latest")); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(fetch).toHaveBeenCalledWith("http://fastapi.local:9000/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { Accept: "application/json" } + }); + }); +}); +``` + +Create `apps/web/tests/experimentalReplayRoute.test.ts`: + +```ts +import { describe, expect, it, vi, afterEach } from "vitest"; + +describe("GET /api/spx/0dte/experimental/replay/snapshot", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("forwards replay experimental query params without caching", async () => { + vi.stubEnv("GAMMASCOPE_API_BASE_URL", "http://fastapi.local:9000/"); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }))); + + const { GET } = await import("../app/api/spx/0dte/experimental/replay/snapshot/route"); + const response = await GET(new Request("http://web.local/api/spx/0dte/experimental/replay/snapshot?session_id=seed&at=2026-04-23T15:40:00Z&source_snapshot_id=snap-a")); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(fetch).toHaveBeenCalledWith("http://fastapi.local:9000/api/spx/0dte/experimental/replay/snapshot?session_id=seed&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snap-a", { + cache: "no-store", + headers: { Accept: "application/json" } + }); + }); +}); +``` + +- [ ] **Step 9: Run proxy route tests to verify failure** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/experimentalRoute.test.ts tests/experimentalReplayRoute.test.ts +``` + +Expected: FAIL because the route files do not exist. + +- [ ] **Step 10: Implement Next proxy routes** + +Create `apps/web/app/api/spx/0dte/experimental/latest/route.ts`: + +```ts +const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"; +const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest"; + +function noStoreJson(payload: unknown, init?: ResponseInit) { + const response = Response.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 upstream = await fetch(`${apiBaseUrl.replace(/\/+$/, "")}${EXPERIMENTAL_LATEST_PATH}`, { + cache: "no-store", + headers: { Accept: "application/json" } + }); + const response = new Response(await upstream.text(), { + status: upstream.status, + headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" } + }); + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { + return noStoreJson({ error: "Experimental analytics unavailable" }, { status: 502 }); + } +} +``` + +Create `apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts`: + +```ts +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(); + for (const key of ["session_id", "at", "source_snapshot_id"]) { + const value = sourceUrl.searchParams.get(key); + if (value) { + params.set(key, value); + } + } + return `${apiBaseUrl.replace(/\/+$/, "")}${EXPERIMENTAL_REPLAY_PATH}?${params.toString()}`; +} + +function noStoreJson(payload: unknown, init?: ResponseInit) { + const response = Response.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 upstream = await fetch(experimentalReplayUrl(apiBaseUrl, request.url), { + cache: "no-store", + headers: { Accept: "application/json" } + }); + const response = new Response(await upstream.text(), { + status: upstream.status, + headers: { "Content-Type": upstream.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 }); + } +} +``` + +- [ ] **Step 11: Run web data-layer tests** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/clientExperimentalSource.test.ts tests/experimentalFormat.test.ts tests/experimentalRoute.test.ts tests/experimentalReplayRoute.test.ts +``` + +Expected: PASS. + +- [ ] **Step 12: Commit web data layer** + +Run: + +```bash +git add apps/web/lib/contracts.ts apps/web/lib/clientExperimentalSource.ts apps/web/lib/experimentalFormat.ts apps/web/app/api/spx/0dte/experimental apps/web/tests/clientExperimentalSource.test.ts apps/web/tests/experimentalFormat.test.ts apps/web/tests/experimentalRoute.test.ts apps/web/tests/experimentalReplayRoute.test.ts +git commit -m "feat: add experimental web data layer" +``` + +--- + +## Task 7: Add Experimental Page, Modular Panels, And Navigation + +**Files:** +- Create: `apps/web/app/experimental/page.tsx` +- Create: `apps/web/components/ExperimentalDashboard.tsx` +- Create: `apps/web/components/experimental/ExperimentalPanel.tsx` +- Create: `apps/web/components/experimental/ExperimentalSmileChart.tsx` +- Create: `apps/web/components/experimental/ExperimentalSummaryPanels.tsx` +- Create: `apps/web/components/experimental/ExperimentalTables.tsx` +- Modify: `apps/web/components/DashboardView.tsx` +- Modify: `apps/web/components/ExposureHeatmap.tsx` +- Test: `apps/web/tests/ExperimentalPage.test.tsx` +- Test: `apps/web/tests/ExperimentalDashboard.test.tsx` +- Modify: `apps/web/tests/DashboardView.test.tsx` +- Modify: `apps/web/tests/ExposureHeatmap.test.tsx` + +- [ ] **Step 1: Write failing page test** + +Create `apps/web/tests/ExperimentalPage.test.tsx`: + +```tsx +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mocks = vi.hoisted(() => ({ + dashboardProps: vi.fn(), + initialPayload: { + schema_version: "1.0.0", + meta: { mode: "latest", sourceSessionId: "seed", sourceSnapshotTime: "2026-04-23T16:00:00Z", generatedAt: "2026-04-23T16:00:01Z", symbol: "SPX", expiry: "2026-04-23" }, + sourceSnapshot: { spot: 5200, forward: 5201, rowCount: 2, strikeCount: 1, timeToExpiryYears: 0.01 }, + forwardSummary: { status: "ok", label: "Forward", diagnostics: [], parityForward: 5201, forwardMinusSpot: 1, atmStrike: 5200, atmStraddle: 20, expectedRange: { lower: 5181, upper: 5221 }, expectedMovePercent: 0.0038 }, + ivSmiles: { status: "preview", label: "IV", diagnostics: [], methods: [] }, + smileDiagnostics: { status: "preview", label: "Smile", diagnostics: [], ivValley: { strike: null, value: null, label: null }, atmForwardIv: null, skewSlope: null, curvature: null, methodDisagreement: null }, + probabilities: { status: "preview", label: "Probabilities", diagnostics: [], levels: [] }, + terminalDistribution: { status: "preview", label: "Distribution", diagnostics: [], density: [], highestDensityZone: null, range68: null, range95: null, leftTailProbability: null, rightTailProbability: null }, + skewTail: { status: "preview", label: "Skew", diagnostics: [], tailBias: null, leftTailRichness: null, rightTailRichness: null }, + moveNeeded: { status: "ok", label: "Move", diagnostics: [], rows: [] }, + decayPressure: { status: "preview", label: "Decay", diagnostics: [], rows: [] }, + richCheap: { status: "preview", label: "Residuals", diagnostics: [], rows: [] }, + quoteQuality: { status: "ok", label: "Quality", diagnostics: [], score: 1, flags: [] }, + historyPreview: { status: "insufficient_data", label: "History", diagnostics: [], rows: [] } + } +})); + +vi.mock("../components/ExperimentalDashboard", () => ({ + ExperimentalDashboard: (props: unknown) => { + mocks.dashboardProps(props); + return
Experimental dashboard shell
; + } +})); + +describe("ExperimentalPage", () => { + it("loads and renders the experimental dashboard shell", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(mocks.initialPayload), { status: 200, headers: { "Content-Type": "application/json" } }))); + vi.stubGlobal("React", React); + + const { default: ExperimentalPage } = await import("../app/experimental/page"); + const page = await ExperimentalPage(); + + expect(renderToStaticMarkup(page)).toContain("Experimental dashboard shell"); + expect(mocks.dashboardProps).toHaveBeenCalledWith({ initialPayload: mocks.initialPayload }); + }); +}); +``` + +- [ ] **Step 2: Run page test to verify failure** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/ExperimentalPage.test.tsx +``` + +Expected: FAIL because `apps/web/app/experimental/page.tsx` does not exist. + +- [ ] **Step 3: Implement page route** + +Create `apps/web/app/experimental/page.tsx`: + +```tsx +import { ExperimentalDashboard } from "../../components/ExperimentalDashboard"; +import { loadClientExperimentalLatest } from "../../lib/clientExperimentalSource"; + +export default async function ExperimentalPage() { + const initialPayload = await loadClientExperimentalLatest({ + fetcher: (input, init) => fetch(input, init) + }); + + return ; +} +``` + +- [ ] **Step 4: Write failing dashboard render test** + +Create `apps/web/tests/ExperimentalDashboard.test.tsx`: + +```tsx +import React from "react"; +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ExperimentalDashboard } from "../components/ExperimentalDashboard"; +import type { ExperimentalAnalytics } from "../lib/contracts"; + +const payload = { + schema_version: "1.0.0", + meta: { mode: "latest", sourceSessionId: "seed", sourceSnapshotTime: "2026-04-23T16:00:00Z", generatedAt: "2026-04-23T16:00:01Z", symbol: "SPX", expiry: "2026-04-23" }, + sourceSnapshot: { spot: 5200, forward: 5201, rowCount: 2, strikeCount: 1, timeToExpiryYears: 0.01 }, + forwardSummary: { status: "ok", label: "Forward and expected move", diagnostics: [], parityForward: 5201, forwardMinusSpot: 1, atmStrike: 5200, atmStraddle: 20, expectedRange: { lower: 5181, upper: 5221 }, expectedMovePercent: 0.0038 }, + ivSmiles: { status: "preview", label: "IV smile methods", diagnostics: [], methods: [{ key: "custom_iv", label: "Current custom IV", status: "ok", points: [{ x: 5200, y: 0.2 }] }] }, + smileDiagnostics: { status: "preview", label: "Smile diagnostics", diagnostics: [], ivValley: { strike: 5200, value: 0.18, label: "Spline valley" }, atmForwardIv: 0.18, skewSlope: -0.01, curvature: 0.02, methodDisagreement: 0.01 }, + probabilities: { status: "preview", label: "Risk-neutral probabilities", diagnostics: [], levels: [{ strike: 5200, closeAbove: 0.52, closeBelow: 0.48 }] }, + terminalDistribution: { status: "preview", label: "Terminal distribution", diagnostics: [], density: [{ x: 5200, y: 0.04 }], highestDensityZone: "5195-5205", range68: "5181-5221", range95: "5160-5240", leftTailProbability: 0.08, rightTailProbability: 0.06 }, + skewTail: { status: "preview", label: "Skew and tail asymmetry", diagnostics: [], tailBias: "Left-tail rich", leftTailRichness: 1.2, rightTailRichness: 0.9 }, + moveNeeded: { status: "ok", label: "Move-needed map", diagnostics: [], rows: [{ strike: 5210, side: "call", breakeven: 5214, moveNeeded: 14, expectedMoveRatio: 0.7, label: "Within expected move" }] }, + decayPressure: { status: "preview", label: "Time-decay pressure", diagnostics: [], rows: [{ strike: 5210, side: "call", premium: 4, pointsPerMinute: 0.2 }] }, + richCheap: { status: "preview", label: "Rich/cheap residuals", diagnostics: [], rows: [{ strike: 5210, side: "call", actualMid: 4, fittedFair: 3.8, residual: 0.2, label: "Rich" }] }, + quoteQuality: { status: "ok", label: "Quote quality", diagnostics: [], score: 0.95, flags: [{ strike: 5120, right: "put", code: "zero_bid", message: "Bid is zero." }] }, + historyPreview: { status: "insufficient_data", label: "Range compression preview", diagnostics: [{ code: "needs_replay", message: "Select replay frames.", severity: "info" }], rows: [] } +} satisfies ExperimentalAnalytics; + +describe("ExperimentalDashboard", () => { + it("renders dense experimental panels at once", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("GammaScope"); + expect(markup).toContain("Experimental"); + expect(markup).toContain("Forward and expected move"); + expect(markup).toContain("IV smile methods"); + expect(markup).toContain("Smile diagnostics"); + expect(markup).toContain("Risk-neutral probabilities"); + expect(markup).toContain("Terminal distribution"); + expect(markup).toContain("Skew and tail asymmetry"); + expect(markup).toContain("Move-needed map"); + expect(markup).toContain("Time-decay pressure"); + expect(markup).toContain("Rich/cheap residuals"); + expect(markup).toContain("Quote quality"); + expect(markup).toContain("Range compression preview"); + }); + + it("renders an empty state when the initial payload is unavailable", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Experimental analytics unavailable"); + }); +}); +``` + +- [ ] **Step 5: Run dashboard test to verify failure** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/ExperimentalDashboard.test.tsx +``` + +Expected: FAIL because `ExperimentalDashboard` does not exist. + +- [ ] **Step 6: Implement reusable panel wrapper** + +Create `apps/web/components/experimental/ExperimentalPanel.tsx`: + +```tsx +import React from "react"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { panelStatusDisplay } from "../../lib/experimentalFormat"; + +type PanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; + +interface ExperimentalPanelProps { + title: string; + status: PanelStatus; + children: React.ReactNode; + diagnostics?: Array<{ code: string; message: string; severity?: string }>; +} + +export function ExperimentalPanel({ title, status, diagnostics = [], children }: ExperimentalPanelProps) { + const display = panelStatusDisplay(status); + return ( +
+
+

{title}

+ {display.label} +
+
{children}
+ {diagnostics.length > 0 ? ( +
    + {diagnostics.slice(0, 3).map((diagnostic) => ( +
  • {diagnostic.message}
  • + ))} +
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 7: Implement chart and panel components** + +Create `apps/web/components/experimental/ExperimentalSmileChart.tsx`: + +```tsx +import React from "react"; + +interface Point { + x: number; + y: number | null; +} + +interface Series { + key: string; + label: string; + points: Point[]; +} + +const FRAME = { width: 520, height: 220, padding: 32 }; + +export function ExperimentalSmileChart({ series }: { series: Series[] }) { + const points = series.flatMap((item) => item.points).filter((point): point is { x: number; y: number } => point.y != null); + if (points.length === 0) { + return
No chart points available
; + } + return ( + + {series.map((item, index) => ( + + ))} + + ); +} + +function pathFor(seriesPoints: Point[], domainPoints: Array<{ x: number; y: number }>): string { + return seriesPoints + .filter((point): point is { x: number; y: number } => point.y != null) + .map((point, index) => { + const projected = project(point, domainPoints); + return `${index === 0 ? "M" : "L"} ${projected.x.toFixed(2)} ${projected.y.toFixed(2)}`; + }) + .join(" "); +} + +function project(point: { x: number; y: number }, domainPoints: Array<{ x: number; y: number }>) { + const minX = Math.min(...domainPoints.map((item) => item.x)); + const maxX = Math.max(...domainPoints.map((item) => item.x)); + const minY = Math.min(...domainPoints.map((item) => item.y)); + const maxY = Math.max(...domainPoints.map((item) => item.y)); + return { + x: FRAME.padding + ratio(point.x, minX, maxX) * (FRAME.width - FRAME.padding * 2), + y: FRAME.height - FRAME.padding - ratio(point.y, minY, maxY) * (FRAME.height - FRAME.padding * 2) + }; +} + +function ratio(value: number, min: number, max: number): number { + return min === max ? 0.5 : (value - min) / (max - min); +} +``` + +Create `apps/web/components/experimental/ExperimentalSummaryPanels.tsx`: + +```tsx +import React from "react"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { formatExperimentalNumber, formatExperimentalPercent, formatExperimentalRange } from "../../lib/experimentalFormat"; +import { ExperimentalPanel } from "./ExperimentalPanel"; +import { ExperimentalSmileChart } from "./ExperimentalSmileChart"; + +export function ExperimentalKpis({ payload }: { payload: ExperimentalAnalytics }) { + return ( +
+ + + + + + +
+ ); +} + +export function ForwardPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.forwardSummary; + return ( + +
+
ATM strike
{formatExperimentalNumber(panel.atmStrike, 0)}
+
Expected range
{formatExperimentalRange(panel.expectedRange)}
+
Expected move
{formatExperimentalPercent(panel.expectedMovePercent)}
+
+
+ ); +} + +export function SmilePanel({ payload }: { payload: ExperimentalAnalytics }) { + return ( + + +
+ {payload.ivSmiles.methods.map((method) => {method.label})} +
+
+ ); +} + +export function DiagnosticsPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.smileDiagnostics; + return ( + +
+
IV valley
{formatExperimentalNumber(panel.ivValley.value ? panel.ivValley.strike : null, 0)}
+
ATM-forward IV
{formatExperimentalPercent(panel.atmForwardIv)}
+
Skew slope
{formatExperimentalNumber(panel.skewSlope, 4)}
+
Curvature
{formatExperimentalNumber(panel.curvature, 4)}
+
+
+ ); +} + +export function DistributionPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.terminalDistribution; + return ( + + +
+
Highest density
{panel.highestDensityZone ?? "—"}
+
68% range
{panel.range68 ?? "—"}
+
95% range
{panel.range95 ?? "—"}
+
+
+ ); +} + +export function SkewTailPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.skewTail; + return ( + + {panel.tailBias ?? "—"} +
+
Left richness
{formatExperimentalNumber(panel.leftTailRichness)}
+
Right richness
{formatExperimentalNumber(panel.rightTailRichness)}
+
+
+ ); +} + +function Kpi({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} +``` + +Create `apps/web/components/experimental/ExperimentalTables.tsx`: + +```tsx +import React from "react"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { formatExperimentalNumber, formatExperimentalPercent } from "../../lib/experimentalFormat"; +import { ExperimentalPanel } from "./ExperimentalPanel"; + +export function ProbabilityPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.probabilities; + return ( + + [formatExperimentalNumber(row.strike, 0), formatExperimentalPercent(row.closeAbove), formatExperimentalPercent(row.closeBelow)])} /> + + ); +} + +export function MoveNeededPanel({ payload }: { payload: ExperimentalAnalytics }) { + return ; +} + +export function DecayPanel({ payload }: { payload: ExperimentalAnalytics }) { + return ; +} + +export function ResidualPanel({ payload }: { payload: ExperimentalAnalytics }) { + return ; +} + +export function QualityPanel({ payload }: { payload: ExperimentalAnalytics }) { + const panel = payload.quoteQuality; + return ( + +

{formatExperimentalPercent(panel.score, 0)}

+ [formatExperimentalNumber(flag.strike, 0), flag.right, flag.message])} /> +
+ ); +} + +export function HistoryPanel({ payload }: { payload: ExperimentalAnalytics }) { + return ; +} + +function RowsPanel({ title, status, diagnostics, rows }: { title: string; status: ExperimentalAnalytics["forwardSummary"]["status"]; diagnostics: ExperimentalAnalytics["forwardSummary"]["diagnostics"]; rows: Array> }) { + return ( + + [formatExperimentalNumber(row.strike as number, 0), String(row.side ?? row.code ?? row.residual ?? "—"), String(row.label ?? row.message ?? "—")])} /> + + ); +} + +function SimpleTable({ headers, rows }: { headers: string[]; rows: string[][] }) { + if (rows.length === 0) { + return
No rows available
; + } + return ( + + {headers.map((header) => )} + {rows.map((row, index) => {row.map((cell, cellIndex) => )})} +
{header}
{cell}
+ ); +} +``` + +- [ ] **Step 8: Implement dashboard shell** + +Create `apps/web/components/ExperimentalDashboard.tsx`: + +```tsx +"use client"; + +import React, { useState } from "react"; +import type { ExperimentalAnalytics } from "../lib/contracts"; +import { ThemeToggle } from "./ThemeToggle"; +import { + DiagnosticsPanel, + DistributionPanel, + ExperimentalKpis, + ForwardPanel, + SkewTailPanel, + SmilePanel +} from "./experimental/ExperimentalSummaryPanels"; +import { + DecayPanel, + HistoryPanel, + MoveNeededPanel, + ProbabilityPanel, + QualityPanel, + ResidualPanel +} from "./experimental/ExperimentalTables"; + +interface ExperimentalDashboardProps { + initialPayload: ExperimentalAnalytics | null; +} + +export function ExperimentalDashboard({ initialPayload }: ExperimentalDashboardProps) { + const [payload] = useState(initialPayload); + + return ( +
+
+
+
+ + +
+
+
+ + {payload ? ( + <> +
+ {payload.meta.mode === "replay" ? "Replay frame" : "Latest snapshot"} + {payload.meta.sourceSessionId} + {payload.meta.sourceSnapshotTime} + {payload.sourceSnapshot.strikeCount} strikes +
+ +
+ + + + + + + + + + + +
+ + ) : ( +
+

Experimental analytics unavailable

+

Latest experimental analytics could not be loaded.

+
+ )} +
+ ); +} +``` + +- [ ] **Step 9: Run page and dashboard tests** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/ExperimentalPage.test.tsx tests/ExperimentalDashboard.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 10: Add Experimental nav to existing pages** + +Modify the top nav in `apps/web/components/DashboardView.tsx` to add: + +```tsx + + Experimental + +``` + +Place it after Heatmap. + +Modify the `topNavTabs` block in `apps/web/components/ExposureHeatmap.tsx` to add: + +```tsx + + Experimental + +``` + +Place it after the active Heatmap link. + +- [ ] **Step 11: Update existing nav tests** + +In `apps/web/tests/DashboardView.test.tsx`, add to the first render test: + +```ts + expect(markup).toContain("Experimental"); + expect(markup).toContain("href=\"/experimental\""); +``` + +Add matching assertions to `apps/web/tests/ExposureHeatmap.test.tsx` in its basic render test: + +```ts + expect(container.innerHTML).toContain("Experimental"); + expect(container.innerHTML).toContain("href=\"/experimental\""); +``` + +- [ ] **Step 12: Run nav and page tests** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/DashboardView.test.tsx tests/ExposureHeatmap.test.tsx tests/ExperimentalPage.test.tsx tests/ExperimentalDashboard.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 13: Commit experimental page** + +Run: + +```bash +git add apps/web/app/experimental apps/web/components/ExperimentalDashboard.tsx apps/web/components/experimental apps/web/components/DashboardView.tsx apps/web/components/ExposureHeatmap.tsx apps/web/tests/ExperimentalPage.test.tsx apps/web/tests/ExperimentalDashboard.test.tsx apps/web/tests/DashboardView.test.tsx apps/web/tests/ExposureHeatmap.test.tsx +git commit -m "feat: add experimental analytics workbench" +``` + +--- + +## Task 8: Add Experimental Styling And Final Verification + +**Files:** +- Modify: `apps/web/app/styles.css` +- Test: `apps/web/tests/ExperimentalDashboard.test.tsx` + +- [ ] **Step 1: Add CSS assertions** + +In `apps/web/tests/ExperimentalDashboard.test.tsx`, import CSS: + +```ts +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +``` + +Add after imports: + +```ts +const styles = readFileSync(join(__dirname, "../app/styles.css"), "utf8"); +``` + +Add this test: + +```tsx + it("defines dense experimental workbench styles", () => { + expect(styles).toContain(".experimentalGrid"); + expect(styles).toContain("grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"); + expect(styles).toContain(".experimentalControlStrip"); + expect(styles).toContain(".experimentalPanel"); + }); +``` + +- [ ] **Step 2: Run style assertion to verify failure** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/ExperimentalDashboard.test.tsx +``` + +Expected: FAIL because styles are not defined. + +- [ ] **Step 3: Add experimental CSS** + +Append to `apps/web/app/styles.css` before light-theme overrides near the end: + +```css +.experimentalShell { + width: min(1520px, calc(100% - 32px)); +} + +.experimentalControlStrip { + align-items: center; + background: var(--control-bg); + border: 1px solid var(--line); + border-radius: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + padding: 10px; +} + +.experimentalControlStrip span, +.experimentalStatus { + border: 1px solid var(--line-soft); + border-radius: 999px; + color: var(--soft); + font-size: 12px; + line-height: 1; + padding: 7px 9px; +} + +.experimentalKpiGrid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + margin-bottom: 14px; +} + +.experimentalGrid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.experimentalPanel { + background: var(--panel-bg); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 16px 34px var(--shadow-strong); + min-width: 0; + padding: 14px; +} + +.experimentalPanel:first-child, +.experimentalPanel:nth-child(2) { + grid-column: span 2; +} + +.experimentalPanelHeader { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + margin-bottom: 10px; +} + +.experimentalPanelHeader h2 { + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.experimentalStatus-ok { + color: var(--good-text); +} + +.experimentalStatus-warning { + color: var(--warning-text); +} + +.experimentalStatus-muted { + color: var(--muted); +} + +.experimentalStatus-error { + color: var(--error-text); +} + +.experimentalPanelBody { + min-width: 0; +} + +.experimentalDefinitionGrid { + display: grid; + gap: 8px; + grid-template-columns: minmax(90px, 1fr) minmax(90px, 1fr); +} + +.experimentalDefinitionGrid dt, +.experimentalTable th { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.experimentalDefinitionGrid dd { + color: var(--text); + font-variant-numeric: tabular-nums; + margin: 0; + text-align: right; +} + +.experimentalChart { + display: block; + height: auto; + max-height: 240px; + width: 100%; +} + +.experimentalChartSeries { + fill: none; + stroke: var(--blue); + stroke-width: 2; +} + +.experimentalChartSeries-1 { + stroke: var(--teal); +} + +.experimentalChartSeries-2 { + stroke: var(--violet); +} + +.experimentalChartSeries-3 { + stroke: var(--amber); +} + +.experimentalChartSeries-4 { + stroke: var(--green); +} + +.experimentalChartSeries-5 { + stroke: var(--red); +} + +.experimentalLegend { + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-size: 11px; + gap: 6px; + margin-top: 8px; +} + +.experimentalLegend span { + border: 1px solid var(--line-soft); + border-radius: 999px; + padding: 4px 7px; +} + +.experimentalTable { + border-collapse: collapse; + font-size: 12px; + width: 100%; +} + +.experimentalTable th, +.experimentalTable td { + border-bottom: 1px solid var(--line-soft); + padding: 6px 4px; + text-align: left; +} + +.experimentalTable td { + color: var(--soft); + font-variant-numeric: tabular-nums; +} + +.experimentalDiagnostics { + color: var(--muted); + font-size: 11px; + margin: 10px 0 0; + padding-left: 18px; +} + +.experimentalLargeValue { + font-size: 28px; + font-weight: 760; +} + +.experimentalEmpty { + align-items: center; + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + display: flex; + min-height: 80px; + justify-content: center; + padding: 12px; +} + +@media (max-width: 760px) { + .experimentalPanel:first-child, + .experimentalPanel:nth-child(2) { + grid-column: span 1; + } +} +``` + +- [ ] **Step 4: Run web focused tests** + +Run: + +```bash +pnpm --filter @gammascope/web test -- --run tests/ExperimentalDashboard.test.tsx tests/ExperimentalPage.test.tsx tests/clientExperimentalSource.test.ts tests/experimentalFormat.test.ts tests/experimentalRoute.test.ts tests/experimentalReplayRoute.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run backend focused tests** + +Run: + +```bash +.venv/bin/pytest apps/api/tests/test_experimental_quality.py apps/api/tests/test_experimental_forward.py apps/api/tests/test_experimental_iv_methods.py apps/api/tests/test_experimental_distribution.py apps/api/tests/test_experimental_trade_maps.py apps/api/tests/test_experimental_service.py apps/api/tests/test_experimental_routes.py apps/api/tests/test_generated_contracts.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Run contract generation and validation** + +Run: + +```bash +pnpm test:contracts +pnpm contracts:generate +.venv/bin/python -m datamodel_code_generator --input packages/contracts/schemas/experimental-analytics.schema.json --input-file-type jsonschema --output apps/api/gammascope_api/contracts/generated/experimental_analytics.py --output-model-type pydantic_v2.BaseModel --disable-timestamp +pnpm --filter @gammascope/contracts typecheck:generated +``` + +Expected: PASS and no unintended generated diff beyond experimental files. + +- [ ] **Step 7: Run full web checks** + +Run: + +```bash +pnpm typecheck:web +pnpm test:web +``` + +Expected: PASS. + +- [ ] **Step 8: Run full API tests** + +Run: + +```bash +docker compose up -d postgres +.venv/bin/pytest apps/api/tests -q +``` + +Expected: PASS. + +- [ ] **Step 9: Browser verification** + +Run the app: + +```bash +pnpm dev:api +pnpm dev:web +``` + +Open: + +```text +http://localhost:3000/experimental +``` + +Verify: + +- Top nav shows `Experimental` active. +- KPI row is visible. +- IV smile and distribution panels span wider than small panels on desktop. +- Probability, skew/tail, move-needed, decay, rich/cheap, quote-quality, and history panels are visible without overlapping. +- Panel statuses are visible. +- The page remains usable at a 1440px desktop viewport and a 390px mobile viewport. + +- [ ] **Step 10: Commit styling and verification changes** + +Run: + +```bash +git add apps/web/app/styles.css apps/web/tests/ExperimentalDashboard.test.tsx +git commit -m "style: add experimental workbench layout" +``` + +--- + +## Final Verification + +Run: + +```bash +pnpm contracts:validate +pnpm --filter @gammascope/contracts typecheck:generated +pnpm typecheck:web +pnpm test:web +docker compose up -d postgres +.venv/bin/pytest apps/api/tests -q +``` + +Expected: + +- Contracts compile and validate. +- Generated TypeScript types typecheck. +- Web typecheck passes. +- Web tests pass. +- API tests pass. +- Existing Realtime, Replay, and Heatmap pages keep their current behavior. + +## Manual Smoke + +Run: + +```bash +pnpm dev:api +pnpm dev:web +``` + +Open: + +```text +http://localhost:3000/experimental +``` + +Expected: + +- Dense workbench grid loads from seeded fallback when live API data is unavailable. +- Experimental tab is active. +- Many panels are visible at once. +- Preview and insufficient-data statuses are visible where relevant. +- No browser console errors appear during initial render. + +## Notes For Executors + +- Preserve the unrelated existing modification in `apps/web/next-env.d.ts`; do not revert it. +- Use `apply_patch` for manual edits. +- Keep each task commit focused. +- If scipy fitting behaves differently by version, adjust assertions to test shape/status/ranges rather than exact spline point values. From 0e14e3b2a872d7028259c28e902fff0f872bc071 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:35:17 -0700 Subject: [PATCH 03/23] feat: add experimental analytics contract --- .../generated/experimental_analytics.py | 225 +++++++++++++ apps/api/pyproject.toml | 2 + apps/api/tests/test_generated_contracts.py | 18 ++ .../fixtures/experimental-analytics.seed.json | 108 +++++++ packages/contracts/package.json | 3 +- .../experimental-analytics.schema.json | 300 ++++++++++++++++++ .../src/generated/experimental-analytics.ts | 136 ++++++++ packages/contracts/tests/generated-types.ts | 2 + packages/contracts/tests/schema.test.mjs | 12 + 9 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 apps/api/gammascope_api/contracts/generated/experimental_analytics.py create mode 100644 packages/contracts/fixtures/experimental-analytics.seed.json create mode 100644 packages/contracts/schemas/experimental-analytics.schema.json create mode 100644 packages/contracts/src/generated/experimental-analytics.ts 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..0c696a0 --- /dev/null +++ b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py @@ -0,0 +1,225 @@ +# 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 PanelBase(BaseModel): + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + + +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(PanelBase): + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + rows: list[Row] + + +class ForwardSummary(PanelBase): + 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(PanelBase): + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + methods: list[Method] + + +class SmileDiagnostics(PanelBase): + 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(PanelBase): + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + levels: list[Level] + + +class TerminalDistribution(PanelBase): + 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(PanelBase): + status: PanelStatus + label: constr(min_length=1) + diagnostics: list[Diagnostic] + tailBias: str | None + leftTailRichness: float | None + rightTailRichness: float | None + + +class QuoteQuality(PanelBase): + 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/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_generated_contracts.py b/apps/api/tests/test_generated_contracts.py index f302eaa..d5aa0c6 100644 --- a/apps/api/tests/test_generated_contracts.py +++ b/apps/api/tests/test_generated_contracts.py @@ -3,6 +3,7 @@ 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 +26,23 @@ 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_seed_health_loads_as_generated_model() -> None: fixture_path = ( Path(__file__).parents[3] diff --git a/packages/contracts/fixtures/experimental-analytics.seed.json b/packages/contracts/fixtures/experimental-analytics.seed.json new file mode 100644 index 0000000..c87b0b7 --- /dev/null +++ b/packages/contracts/fixtures/experimental-analytics.seed.json @@ -0,0 +1,108 @@ +{ + "schema_version": "1.0.0", + "meta": { + "generatedAt": "2026-04-23T16:00:00Z", + "mode": "latest", + "sourceSessionId": "seed-spx-2026-04-23", + "sourceSnapshotTime": "2026-04-23T16:00:00Z", + "symbol": "SPX", + "expiry": "2026-04-23" + }, + "sourceSnapshot": { + "spot": 5200.25, + "forward": 5200.36, + "rowCount": 34, + "strikeCount": 17, + "timeToExpiryYears": 0.000456621 + }, + "forwardSummary": { + "status": "ok", + "label": "Forward and expected move", + "diagnostics": [], + "parityForward": 5200.36, + "forwardMinusSpot": 0.11, + "atmStrike": 5200, + "atmStraddle": 18.75, + "expectedRange": { "lower": 5181.61, "upper": 5219.11 }, + "expectedMovePercent": 0.003605 + }, + "ivSmiles": { + "status": "preview", + "label": "IV smile methods", + "diagnostics": [{ "code": "preview_method", "message": "Experimental fitted methods are diagnostic.", "severity": "info" }], + "methods": [ + { "key": "custom_iv", "label": "Current custom IV", "status": "ok", "points": [{ "x": 5190, "y": 0.18 }, { "x": 5200, "y": 0.17 }, { "x": 5210, "y": 0.19 }] }, + { "key": "spline_fit", "label": "Spline fit", "status": "preview", "points": [{ "x": 5190, "y": 0.181 }, { "x": 5200, "y": 0.171 }, { "x": 5210, "y": 0.191 }] } + ] + }, + "smileDiagnostics": { + "status": "preview", + "label": "Smile diagnostics", + "diagnostics": [], + "ivValley": { "strike": 5200, "value": 0.171, "label": "Spline valley" }, + "atmForwardIv": 0.172, + "skewSlope": -0.08, + "curvature": 0.12, + "methodDisagreement": 0.011 + }, + "probabilities": { + "status": "preview", + "label": "Risk-neutral probabilities", + "diagnostics": [{ "code": "risk_neutral", "message": "Probabilities are risk-neutral, not real-world.", "severity": "info" }], + "levels": [ + { "strike": 5190, "closeAbove": 0.67, "closeBelow": 0.33 }, + { "strike": 5200, "closeAbove": 0.52, "closeBelow": 0.48 }, + { "strike": 5210, "closeAbove": 0.35, "closeBelow": 0.65 } + ] + }, + "terminalDistribution": { + "status": "preview", + "label": "Terminal distribution", + "diagnostics": [], + "density": [{ "x": 5190, "y": 0.02 }, { "x": 5200, "y": 0.04 }, { "x": 5210, "y": 0.025 }], + "highestDensityZone": "5195-5205", + "range68": "5182-5219", + "range95": "5164-5237", + "leftTailProbability": 0.08, + "rightTailProbability": 0.06 + }, + "skewTail": { + "status": "preview", + "label": "Skew and tail asymmetry", + "diagnostics": [], + "tailBias": "Left-tail rich", + "leftTailRichness": 1.18, + "rightTailRichness": 0.94 + }, + "moveNeeded": { + "status": "ok", + "label": "Move-needed map", + "diagnostics": [], + "rows": [{ "strike": 5210, "side": "call", "breakeven": 5213.2, "moveNeeded": 12.95, "expectedMoveRatio": 0.69, "label": "Within expected move" }] + }, + "decayPressure": { + "status": "preview", + "label": "Time-decay pressure", + "diagnostics": [{ "code": "static_decay", "message": "Static pressure assumes no spot or IV change.", "severity": "info" }], + "rows": [{ "strike": 5210, "side": "call", "premium": 3.2, "pointsPerMinute": 0.08 }] + }, + "richCheap": { + "status": "preview", + "label": "Rich/cheap residuals", + "diagnostics": [], + "rows": [{ "strike": 5210, "side": "call", "actualMid": 3.2, "fittedFair": 3.05, "residual": 0.15, "label": "Rich" }] + }, + "quoteQuality": { + "status": "ok", + "label": "Quote quality", + "diagnostics": [], + "score": 0.94, + "flags": [{ "strike": 5120, "right": "put", "code": "zero_bid", "message": "Bid is zero." }] + }, + "historyPreview": { + "status": "insufficient_data", + "label": "Range compression preview", + "diagnostics": [{ "code": "needs_replay", "message": "Select replay frames to compare history.", "severity": "info" }], + "rows": [] + } +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index b30a8cb..023383f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -6,12 +6,13 @@ "scripts": { "validate": "node --test tests/schema.test.mjs", "test": "node --test tests/schema.test.mjs", - "generate": "mkdir -p src/generated && json2ts -i schemas/analytics-snapshot.schema.json -o src/generated/analytics-snapshot.ts && json2ts -i schemas/collector-events.schema.json -o src/generated/collector-events.ts && json2ts -i schemas/scenario.schema.json -o src/generated/scenario.ts && json2ts -i schemas/saved-view.schema.json -o src/generated/saved-view.ts", + "generate": "mkdir -p src/generated && json2ts -i schemas/analytics-snapshot.schema.json -o src/generated/analytics-snapshot.ts && json2ts -i schemas/collector-events.schema.json -o src/generated/collector-events.ts && json2ts -i schemas/experimental-analytics.schema.json -o src/generated/experimental-analytics.ts && json2ts -i schemas/scenario.schema.json -o src/generated/scenario.ts && json2ts -i schemas/saved-view.schema.json -o src/generated/saved-view.ts", "typecheck:generated": "tsc --project tsconfig.generated.json --noEmit" }, "exports": { "./analytics-snapshot": "./src/generated/analytics-snapshot.ts", "./collector-events": "./src/generated/collector-events.ts", + "./experimental-analytics": "./src/generated/experimental-analytics.ts", "./scenario": "./src/generated/scenario.ts", "./saved-view": "./src/generated/saved-view.ts" }, diff --git a/packages/contracts/schemas/experimental-analytics.schema.json b/packages/contracts/schemas/experimental-analytics.schema.json new file mode 100644 index 0000000..af2c2c4 --- /dev/null +++ b/packages/contracts/schemas/experimental-analytics.schema.json @@ -0,0 +1,300 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gammascope.local/schemas/experimental-analytics.schema.json", + "title": "ExperimentalAnalytics", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "meta", + "sourceSnapshot", + "forwardSummary", + "ivSmiles", + "smileDiagnostics", + "probabilities", + "terminalDistribution", + "skewTail", + "moveNeeded", + "decayPressure", + "richCheap", + "quoteQuality", + "historyPreview" + ], + "properties": { + "schema_version": { "type": "string", "const": "1.0.0" }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["generatedAt", "mode", "sourceSessionId", "sourceSnapshotTime", "symbol", "expiry"], + "properties": { + "generatedAt": { "type": "string", "format": "date-time" }, + "mode": { "type": "string", "enum": ["latest", "replay"] }, + "sourceSessionId": { "type": "string", "minLength": 1 }, + "sourceSnapshotTime": { "type": "string", "format": "date-time" }, + "symbol": { "type": "string", "const": "SPX" }, + "expiry": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } + } + }, + "sourceSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["spot", "forward", "rowCount", "strikeCount", "timeToExpiryYears"], + "properties": { + "spot": { "type": "number" }, + "forward": { "type": "number" }, + "rowCount": { "type": "integer", "minimum": 0 }, + "strikeCount": { "type": "integer", "minimum": 0 }, + "timeToExpiryYears": { "type": "number", "minimum": 0 } + } + }, + "forwardSummary": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedRange", "expectedMovePercent"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "parityForward": { "type": ["number", "null"] }, + "forwardMinusSpot": { "type": ["number", "null"] }, + "atmStrike": { "type": ["number", "null"] }, + "atmStraddle": { "type": ["number", "null"] }, + "expectedRange": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["lower", "upper"], + "properties": { + "lower": { "type": "number" }, + "upper": { "type": "number" } + } + }, + "expectedMovePercent": { "type": ["number", "null"] } + } + } + ] + }, + "ivSmiles": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "methods"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "methods": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "label", "status", "points"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/PanelStatus" }, + "points": { "type": "array", "items": { "$ref": "#/$defs/Point" } } + } + } + } + } + } + ] + }, + "smileDiagnostics": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "ivValley", "atmForwardIv", "skewSlope", "curvature", "methodDisagreement"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "ivValley": { "$ref": "#/$defs/StrikeValue" }, + "atmForwardIv": { "type": ["number", "null"] }, + "skewSlope": { "type": ["number", "null"] }, + "curvature": { "type": ["number", "null"] }, + "methodDisagreement": { "type": ["number", "null"] } + } + } + ] + }, + "probabilities": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "levels"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "closeAbove", "closeBelow"], + "properties": { + "strike": { "type": "number" }, + "closeAbove": { "type": ["number", "null"] }, + "closeBelow": { "type": ["number", "null"] } + } + } + } + } + } + ] + }, + "terminalDistribution": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "density", "highestDensityZone", "range68", "range95", "leftTailProbability", "rightTailProbability"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "density": { "type": "array", "items": { "$ref": "#/$defs/Point" } }, + "highestDensityZone": { "type": ["string", "null"] }, + "range68": { "type": ["string", "null"] }, + "range95": { "type": ["string", "null"] }, + "leftTailProbability": { "type": ["number", "null"] }, + "rightTailProbability": { "type": ["number", "null"] } + } + } + ] + }, + "skewTail": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "tailBias", "leftTailRichness", "rightTailRichness"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "tailBias": { "type": ["string", "null"] }, + "leftTailRichness": { "type": ["number", "null"] }, + "rightTailRichness": { "type": ["number", "null"] } + } + } + ] + }, + "moveNeeded": { "$ref": "#/$defs/PanelWithRows" }, + "decayPressure": { "$ref": "#/$defs/PanelWithRows" }, + "richCheap": { "$ref": "#/$defs/PanelWithRows" }, + "quoteQuality": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "score", "flags"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "flags": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "right", "code", "message"], + "properties": { + "strike": { "type": "number" }, + "right": { "type": "string", "enum": ["call", "put", "pair"] }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + } + } + } + } + ] + }, + "historyPreview": { "$ref": "#/$defs/PanelWithRows" } + }, + "$defs": { + "PanelStatus": { + "type": "string", + "enum": ["ok", "preview", "insufficient_data", "error"] + }, + "Diagnostic": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "severity"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 }, + "severity": { "type": "string", "enum": ["info", "warning", "error"] } + } + }, + "PanelBase": { + "type": "object", + "required": ["status", "label", "diagnostics"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string", "minLength": 1 }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } } + } + }, + "Point": { + "type": "object", + "additionalProperties": false, + "required": ["x", "y"], + "properties": { + "x": { "type": "number" }, + "y": { "type": ["number", "null"] } + } + }, + "StrikeValue": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "value", "label"], + "properties": { + "strike": { "type": ["number", "null"] }, + "value": { "type": ["number", "null"] }, + "label": { "type": ["string", "null"] } + } + }, + "PanelWithRows": { + "allOf": [ + { "$ref": "#/$defs/PanelBase" }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "rows"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["strike"], + "properties": { + "strike": { "type": "number" } + } + } + } + } + } + ] + } + } +} diff --git a/packages/contracts/src/generated/experimental-analytics.ts b/packages/contracts/src/generated/experimental-analytics.ts new file mode 100644 index 0000000..23453ea --- /dev/null +++ b/packages/contracts/src/generated/experimental-analytics.ts @@ -0,0 +1,136 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type PanelStatus = "ok" | "preview" | "insufficient_data" | "error"; +export type PanelWithRows = PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + rows: { + strike: number; + [k: string]: unknown; + }[]; +}; + +export interface ExperimentalAnalytics { + schema_version: "1.0.0"; + meta: { + generatedAt: string; + mode: "latest" | "replay"; + sourceSessionId: string; + sourceSnapshotTime: string; + symbol: "SPX"; + expiry: string; + }; + sourceSnapshot: { + spot: number; + forward: number; + rowCount: number; + strikeCount: number; + timeToExpiryYears: number; + }; + forwardSummary: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + parityForward: number | null; + forwardMinusSpot: number | null; + atmStrike: number | null; + atmStraddle: number | null; + expectedRange: { + lower: number; + upper: number; + } | null; + expectedMovePercent: number | null; + }; + ivSmiles: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + methods: { + key: string; + label: string; + status: PanelStatus; + points: Point[]; + }[]; + }; + smileDiagnostics: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + ivValley: StrikeValue; + atmForwardIv: number | null; + skewSlope: number | null; + curvature: number | null; + methodDisagreement: number | null; + }; + probabilities: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + levels: { + strike: number; + closeAbove: number | null; + closeBelow: number | null; + }[]; + }; + terminalDistribution: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + density: Point[]; + highestDensityZone: string | null; + range68: string | null; + range95: string | null; + leftTailProbability: number | null; + rightTailProbability: number | null; + }; + skewTail: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + tailBias: string | null; + leftTailRichness: number | null; + rightTailRichness: number | null; + }; + moveNeeded: PanelWithRows; + decayPressure: PanelWithRows; + richCheap: PanelWithRows; + quoteQuality: PanelBase & { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + score: number; + flags: { + strike: number; + right: "call" | "put" | "pair"; + code: string; + message: string; + }[]; + }; + historyPreview: PanelWithRows; +} +export interface PanelBase { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + [k: string]: unknown; +} +export interface Diagnostic { + code: string; + message: string; + severity: "info" | "warning" | "error"; +} +export interface Point { + x: number; + y: number | null; +} +export interface StrikeValue { + strike: number | null; + value: number | null; + label: string | null; +} diff --git a/packages/contracts/tests/generated-types.ts b/packages/contracts/tests/generated-types.ts index c26e500..ee7182a 100644 --- a/packages/contracts/tests/generated-types.ts +++ b/packages/contracts/tests/generated-types.ts @@ -1,9 +1,11 @@ import type { AnalyticsSnapshot } from "../src/generated/analytics-snapshot"; import type { CollectorEvents } from "../src/generated/collector-events"; +import type { ExperimentalAnalytics } from "../src/generated/experimental-analytics"; import type { SavedView } from "../src/generated/saved-view"; import type { ScenarioRequest } from "../src/generated/scenario"; type _SnapshotSchemaVersion = AnalyticsSnapshot["schema_version"]; type _CollectorEvent = CollectorEvents; +type _ExperimentalPanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; type _ScenarioShift = ScenarioRequest["vol_shift_points"]; type _SavedViewMode = SavedView["mode"]; diff --git a/packages/contracts/tests/schema.test.mjs b/packages/contracts/tests/schema.test.mjs index ed70010..fa153ff 100644 --- a/packages/contracts/tests/schema.test.mjs +++ b/packages/contracts/tests/schema.test.mjs @@ -19,6 +19,7 @@ test("all schemas compile", async () => { "schemas/common.schema.json", "schemas/collector-events.schema.json", "schemas/analytics-snapshot.schema.json", + "schemas/experimental-analytics.schema.json", "schemas/scenario.schema.json", "schemas/saved-view.schema.json" ]) { @@ -38,6 +39,17 @@ test("seed analytics snapshot matches schema", async () => { assert.equal(validate(fixture), true, JSON.stringify(validate.errors, null, 2)); }); +test("seed experimental analytics payload matches schema", async () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + + const schema = await readJson("schemas/experimental-analytics.schema.json"); + const fixture = await readJson("fixtures/experimental-analytics.seed.json"); + const validate = ajv.compile(schema); + + assert.equal(validate(fixture), true, JSON.stringify(validate.errors, null, 2)); +}); + test("seed collector health matches schema", async () => { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); From 33e34298731a7da34ffab1fe62373bce927b718e Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:47:10 -0700 Subject: [PATCH 04/23] fix: close experimental generated panel contracts --- .../generated/experimental_analytics.py | 62 ++-- apps/api/tests/test_generated_contracts.py | 18 + .../experimental-analytics.schema.json | 309 ++++++++---------- .../src/generated/experimental-analytics.ts | 38 +-- packages/contracts/tests/generated-types.ts | 14 + 5 files changed, 218 insertions(+), 223 deletions(-) diff --git a/apps/api/gammascope_api/contracts/generated/experimental_analytics.py b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py index 0c696a0..9f129c7 100644 --- a/apps/api/gammascope_api/contracts/generated/experimental_analytics.py +++ b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py @@ -92,12 +92,6 @@ class Diagnostic(BaseModel): severity: Severity -class PanelBase(BaseModel): - status: PanelStatus - label: constr(min_length=1) - diagnostics: list[Diagnostic] - - class Point(BaseModel): model_config = ConfigDict( extra='forbid', @@ -122,16 +116,22 @@ class Row(BaseModel): strike: float -class PanelWithRows(PanelBase): +class PanelWithRows(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] rows: list[Row] -class ForwardSummary(PanelBase): +class ForwardSummary(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] parityForward: float | None forwardMinusSpot: float | None @@ -151,16 +151,22 @@ class Method(BaseModel): points: list[Point] -class IvSmiles(PanelBase): +class IvSmiles(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] methods: list[Method] -class SmileDiagnostics(PanelBase): +class SmileDiagnostics(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] ivValley: StrikeValue atmForwardIv: float | None @@ -169,16 +175,22 @@ class SmileDiagnostics(PanelBase): methodDisagreement: float | None -class Probabilities(PanelBase): +class Probabilities(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] levels: list[Level] -class TerminalDistribution(PanelBase): +class TerminalDistribution(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] density: list[Point] highestDensityZone: str | None @@ -188,18 +200,24 @@ class TerminalDistribution(PanelBase): rightTailProbability: float | None -class SkewTail(PanelBase): +class SkewTail(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] tailBias: str | None leftTailRichness: float | None rightTailRichness: float | None -class QuoteQuality(PanelBase): +class QuoteQuality(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) status: PanelStatus - label: constr(min_length=1) + label: str diagnostics: list[Diagnostic] score: confloat(ge=0.0, le=1.0) flags: list[Flag] diff --git a/apps/api/tests/test_generated_contracts.py b/apps/api/tests/test_generated_contracts.py index d5aa0c6..1802f0b 100644 --- a/apps/api/tests/test_generated_contracts.py +++ b/apps/api/tests/test_generated_contracts.py @@ -1,6 +1,9 @@ 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 @@ -43,6 +46,21 @@ def test_seed_experimental_analytics_loads_as_generated_model() -> None: 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_seed_health_loads_as_generated_model() -> None: fixture_path = ( Path(__file__).parents[3] diff --git a/packages/contracts/schemas/experimental-analytics.schema.json b/packages/contracts/schemas/experimental-analytics.schema.json index af2c2c4..ef6915d 100644 --- a/packages/contracts/schemas/experimental-analytics.schema.json +++ b/packages/contracts/schemas/experimental-analytics.schema.json @@ -48,182 +48,147 @@ } }, "forwardSummary": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedRange", "expectedMovePercent"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "parityForward": { "type": ["number", "null"] }, + "forwardMinusSpot": { "type": ["number", "null"] }, + "atmStrike": { "type": ["number", "null"] }, + "atmStraddle": { "type": ["number", "null"] }, + "expectedRange": { + "type": ["object", "null"], "additionalProperties": false, - "required": ["status", "label", "diagnostics", "parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedRange", "expectedMovePercent"], + "required": ["lower", "upper"], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "parityForward": { "type": ["number", "null"] }, - "forwardMinusSpot": { "type": ["number", "null"] }, - "atmStrike": { "type": ["number", "null"] }, - "atmStraddle": { "type": ["number", "null"] }, - "expectedRange": { - "type": ["object", "null"], - "additionalProperties": false, - "required": ["lower", "upper"], - "properties": { - "lower": { "type": "number" }, - "upper": { "type": "number" } - } - }, - "expectedMovePercent": { "type": ["number", "null"] } + "lower": { "type": "number" }, + "upper": { "type": "number" } } - } - ] + }, + "expectedMovePercent": { "type": ["number", "null"] } + } }, "ivSmiles": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "methods"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "methods": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["key", "label", "status", "points"], - "properties": { - "key": { "type": "string", "minLength": 1 }, - "label": { "type": "string", "minLength": 1 }, - "status": { "$ref": "#/$defs/PanelStatus" }, - "points": { "type": "array", "items": { "$ref": "#/$defs/Point" } } - } - } + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "methods"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "methods": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "label", "status", "points"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "status": { "$ref": "#/$defs/PanelStatus" }, + "points": { "type": "array", "items": { "$ref": "#/$defs/Point" } } } } } - ] + } }, "smileDiagnostics": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "ivValley", "atmForwardIv", "skewSlope", "curvature", "methodDisagreement"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "ivValley": { "$ref": "#/$defs/StrikeValue" }, - "atmForwardIv": { "type": ["number", "null"] }, - "skewSlope": { "type": ["number", "null"] }, - "curvature": { "type": ["number", "null"] }, - "methodDisagreement": { "type": ["number", "null"] } - } - } - ] + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "ivValley", "atmForwardIv", "skewSlope", "curvature", "methodDisagreement"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "ivValley": { "$ref": "#/$defs/StrikeValue" }, + "atmForwardIv": { "type": ["number", "null"] }, + "skewSlope": { "type": ["number", "null"] }, + "curvature": { "type": ["number", "null"] }, + "methodDisagreement": { "type": ["number", "null"] } + } }, "probabilities": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "levels"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "levels": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["strike", "closeAbove", "closeBelow"], - "properties": { - "strike": { "type": "number" }, - "closeAbove": { "type": ["number", "null"] }, - "closeBelow": { "type": ["number", "null"] } - } - } + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "levels"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "closeAbove", "closeBelow"], + "properties": { + "strike": { "type": "number" }, + "closeAbove": { "type": ["number", "null"] }, + "closeBelow": { "type": ["number", "null"] } } } } - ] + } }, "terminalDistribution": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "density", "highestDensityZone", "range68", "range95", "leftTailProbability", "rightTailProbability"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "density": { "type": "array", "items": { "$ref": "#/$defs/Point" } }, - "highestDensityZone": { "type": ["string", "null"] }, - "range68": { "type": ["string", "null"] }, - "range95": { "type": ["string", "null"] }, - "leftTailProbability": { "type": ["number", "null"] }, - "rightTailProbability": { "type": ["number", "null"] } - } - } - ] + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "density", "highestDensityZone", "range68", "range95", "leftTailProbability", "rightTailProbability"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "density": { "type": "array", "items": { "$ref": "#/$defs/Point" } }, + "highestDensityZone": { "type": ["string", "null"] }, + "range68": { "type": ["string", "null"] }, + "range95": { "type": ["string", "null"] }, + "leftTailProbability": { "type": ["number", "null"] }, + "rightTailProbability": { "type": ["number", "null"] } + } }, "skewTail": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "tailBias", "leftTailRichness", "rightTailRichness"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "tailBias": { "type": ["string", "null"] }, - "leftTailRichness": { "type": ["number", "null"] }, - "rightTailRichness": { "type": ["number", "null"] } - } - } - ] + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "tailBias", "leftTailRichness", "rightTailRichness"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "tailBias": { "type": ["string", "null"] }, + "leftTailRichness": { "type": ["number", "null"] }, + "rightTailRichness": { "type": ["number", "null"] } + } }, "moveNeeded": { "$ref": "#/$defs/PanelWithRows" }, "decayPressure": { "$ref": "#/$defs/PanelWithRows" }, "richCheap": { "$ref": "#/$defs/PanelWithRows" }, "quoteQuality": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "score", "flags"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "score": { "type": "number", "minimum": 0, "maximum": 1 }, - "flags": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["strike", "right", "code", "message"], - "properties": { - "strike": { "type": "number" }, - "right": { "type": "string", "enum": ["call", "put", "pair"] }, - "code": { "type": "string", "minLength": 1 }, - "message": { "type": "string", "minLength": 1 } - } - } + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "score", "flags"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "flags": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["strike", "right", "code", "message"], + "properties": { + "strike": { "type": "number" }, + "right": { "type": "string", "enum": ["call", "put", "pair"] }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } } } } - ] + } }, "historyPreview": { "$ref": "#/$defs/PanelWithRows" } }, @@ -242,15 +207,6 @@ "severity": { "type": "string", "enum": ["info", "warning", "error"] } } }, - "PanelBase": { - "type": "object", - "required": ["status", "label", "diagnostics"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string", "minLength": 1 }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } } - } - }, "Point": { "type": "object", "additionalProperties": false, @@ -271,30 +227,25 @@ } }, "PanelWithRows": { - "allOf": [ - { "$ref": "#/$defs/PanelBase" }, - { - "type": "object", - "additionalProperties": false, - "required": ["status", "label", "diagnostics", "rows"], - "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "rows": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "required": ["strike"], - "properties": { - "strike": { "type": "number" } - } - } + "type": "object", + "additionalProperties": false, + "required": ["status", "label", "diagnostics", "rows"], + "properties": { + "status": { "$ref": "#/$defs/PanelStatus" }, + "label": { "type": "string" }, + "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["strike"], + "properties": { + "strike": { "type": "number" } } } } - ] + } } } } diff --git a/packages/contracts/src/generated/experimental-analytics.ts b/packages/contracts/src/generated/experimental-analytics.ts index 23453ea..873cdd2 100644 --- a/packages/contracts/src/generated/experimental-analytics.ts +++ b/packages/contracts/src/generated/experimental-analytics.ts @@ -6,15 +6,6 @@ */ export type PanelStatus = "ok" | "preview" | "insufficient_data" | "error"; -export type PanelWithRows = PanelBase & { - status: PanelStatus; - label: string; - diagnostics: Diagnostic[]; - rows: { - strike: number; - [k: string]: unknown; - }[]; -}; export interface ExperimentalAnalytics { schema_version: "1.0.0"; @@ -33,7 +24,7 @@ export interface ExperimentalAnalytics { strikeCount: number; timeToExpiryYears: number; }; - forwardSummary: PanelBase & { + forwardSummary: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -47,7 +38,7 @@ export interface ExperimentalAnalytics { } | null; expectedMovePercent: number | null; }; - ivSmiles: PanelBase & { + ivSmiles: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -58,7 +49,7 @@ export interface ExperimentalAnalytics { points: Point[]; }[]; }; - smileDiagnostics: PanelBase & { + smileDiagnostics: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -68,7 +59,7 @@ export interface ExperimentalAnalytics { curvature: number | null; methodDisagreement: number | null; }; - probabilities: PanelBase & { + probabilities: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -78,7 +69,7 @@ export interface ExperimentalAnalytics { closeBelow: number | null; }[]; }; - terminalDistribution: PanelBase & { + terminalDistribution: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -89,7 +80,7 @@ export interface ExperimentalAnalytics { leftTailProbability: number | null; rightTailProbability: number | null; }; - skewTail: PanelBase & { + skewTail: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -100,7 +91,7 @@ export interface ExperimentalAnalytics { moveNeeded: PanelWithRows; decayPressure: PanelWithRows; richCheap: PanelWithRows; - quoteQuality: PanelBase & { + quoteQuality: { status: PanelStatus; label: string; diagnostics: Diagnostic[]; @@ -114,12 +105,6 @@ export interface ExperimentalAnalytics { }; historyPreview: PanelWithRows; } -export interface PanelBase { - status: PanelStatus; - label: string; - diagnostics: Diagnostic[]; - [k: string]: unknown; -} export interface Diagnostic { code: string; message: string; @@ -134,3 +119,12 @@ export interface StrikeValue { value: number | null; label: string | null; } +export interface PanelWithRows { + status: PanelStatus; + label: string; + diagnostics: Diagnostic[]; + rows: { + strike: number; + [k: string]: unknown; + }[]; +} diff --git a/packages/contracts/tests/generated-types.ts b/packages/contracts/tests/generated-types.ts index ee7182a..1ab3156 100644 --- a/packages/contracts/tests/generated-types.ts +++ b/packages/contracts/tests/generated-types.ts @@ -9,3 +9,17 @@ type _CollectorEvent = CollectorEvents; type _ExperimentalPanelStatus = ExperimentalAnalytics["forwardSummary"]["status"]; type _ScenarioShift = ScenarioRequest["vol_shift_points"]; type _SavedViewMode = SavedView["mode"]; + +const _ExperimentalRejectsExtraForwardField: ExperimentalAnalytics["forwardSummary"] = { + status: "ok", + label: "Forward and expected move", + diagnostics: [], + parityForward: 5200, + forwardMinusSpot: 0, + atmStrike: 5200, + atmStraddle: 18, + expectedRange: { lower: 5182, upper: 5218 }, + expectedMovePercent: 0.0035, + // @ts-expect-error Experimental panels are closed by the shared JSON Schema. + unexpected: 123 +}; From 438d082471b2ab49c389505ac09e70c277349ec0 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:51:50 -0700 Subject: [PATCH 05/23] fix: restore experimental panel label constraints --- .../generated/experimental_analytics.py | 16 +- apps/api/tests/test_generated_contracts.py | 15 + .../experimental-analytics.schema.json | 614 +++++++++++++++--- packages/contracts/tests/schema.test.mjs | 13 + 4 files changed, 548 insertions(+), 110 deletions(-) diff --git a/apps/api/gammascope_api/contracts/generated/experimental_analytics.py b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py index 9f129c7..24e66cc 100644 --- a/apps/api/gammascope_api/contracts/generated/experimental_analytics.py +++ b/apps/api/gammascope_api/contracts/generated/experimental_analytics.py @@ -121,7 +121,7 @@ class PanelWithRows(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] rows: list[Row] @@ -131,7 +131,7 @@ class ForwardSummary(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] parityForward: float | None forwardMinusSpot: float | None @@ -156,7 +156,7 @@ class IvSmiles(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] methods: list[Method] @@ -166,7 +166,7 @@ class SmileDiagnostics(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] ivValley: StrikeValue atmForwardIv: float | None @@ -180,7 +180,7 @@ class Probabilities(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] levels: list[Level] @@ -190,7 +190,7 @@ class TerminalDistribution(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] density: list[Point] highestDensityZone: str | None @@ -205,7 +205,7 @@ class SkewTail(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] tailBias: str | None leftTailRichness: float | None @@ -217,7 +217,7 @@ class QuoteQuality(BaseModel): extra='forbid', ) status: PanelStatus - label: str + label: constr(min_length=1) diagnostics: list[Diagnostic] score: confloat(ge=0.0, le=1.0) flags: list[Flag] diff --git a/apps/api/tests/test_generated_contracts.py b/apps/api/tests/test_generated_contracts.py index 1802f0b..342e726 100644 --- a/apps/api/tests/test_generated_contracts.py +++ b/apps/api/tests/test_generated_contracts.py @@ -61,6 +61,21 @@ def test_experimental_analytics_rejects_unexpected_panel_fields() -> None: 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/packages/contracts/schemas/experimental-analytics.schema.json b/packages/contracts/schemas/experimental-analytics.schema.json index ef6915d..11c84cb 100644 --- a/packages/contracts/schemas/experimental-analytics.schema.json +++ b/packages/contracts/schemas/experimental-analytics.schema.json @@ -21,75 +21,213 @@ "historyPreview" ], "properties": { - "schema_version": { "type": "string", "const": "1.0.0" }, + "schema_version": { + "type": "string", + "const": "1.0.0" + }, "meta": { "type": "object", "additionalProperties": false, - "required": ["generatedAt", "mode", "sourceSessionId", "sourceSnapshotTime", "symbol", "expiry"], + "required": [ + "generatedAt", + "mode", + "sourceSessionId", + "sourceSnapshotTime", + "symbol", + "expiry" + ], "properties": { - "generatedAt": { "type": "string", "format": "date-time" }, - "mode": { "type": "string", "enum": ["latest", "replay"] }, - "sourceSessionId": { "type": "string", "minLength": 1 }, - "sourceSnapshotTime": { "type": "string", "format": "date-time" }, - "symbol": { "type": "string", "const": "SPX" }, - "expiry": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "mode": { + "type": "string", + "enum": [ + "latest", + "replay" + ] + }, + "sourceSessionId": { + "type": "string", + "minLength": 1 + }, + "sourceSnapshotTime": { + "type": "string", + "format": "date-time" + }, + "symbol": { + "type": "string", + "const": "SPX" + }, + "expiry": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } } }, "sourceSnapshot": { "type": "object", "additionalProperties": false, - "required": ["spot", "forward", "rowCount", "strikeCount", "timeToExpiryYears"], + "required": [ + "spot", + "forward", + "rowCount", + "strikeCount", + "timeToExpiryYears" + ], "properties": { - "spot": { "type": "number" }, - "forward": { "type": "number" }, - "rowCount": { "type": "integer", "minimum": 0 }, - "strikeCount": { "type": "integer", "minimum": 0 }, - "timeToExpiryYears": { "type": "number", "minimum": 0 } + "spot": { + "type": "number" + }, + "forward": { + "type": "number" + }, + "rowCount": { + "type": "integer", + "minimum": 0 + }, + "strikeCount": { + "type": "integer", + "minimum": 0 + }, + "timeToExpiryYears": { + "type": "number", + "minimum": 0 + } } }, "forwardSummary": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "parityForward", "forwardMinusSpot", "atmStrike", "atmStraddle", "expectedRange", "expectedMovePercent"], + "required": [ + "status", + "label", + "diagnostics", + "parityForward", + "forwardMinusSpot", + "atmStrike", + "atmStraddle", + "expectedRange", + "expectedMovePercent" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "parityForward": { "type": ["number", "null"] }, - "forwardMinusSpot": { "type": ["number", "null"] }, - "atmStrike": { "type": ["number", "null"] }, - "atmStraddle": { "type": ["number", "null"] }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, + "parityForward": { + "type": [ + "number", + "null" + ] + }, + "forwardMinusSpot": { + "type": [ + "number", + "null" + ] + }, + "atmStrike": { + "type": [ + "number", + "null" + ] + }, + "atmStraddle": { + "type": [ + "number", + "null" + ] + }, "expectedRange": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "additionalProperties": false, - "required": ["lower", "upper"], + "required": [ + "lower", + "upper" + ], "properties": { - "lower": { "type": "number" }, - "upper": { "type": "number" } + "lower": { + "type": "number" + }, + "upper": { + "type": "number" + } } }, - "expectedMovePercent": { "type": ["number", "null"] } + "expectedMovePercent": { + "type": [ + "number", + "null" + ] + } } }, "ivSmiles": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "methods"], + "required": [ + "status", + "label", + "diagnostics", + "methods" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, "methods": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["key", "label", "status", "points"], + "required": [ + "key", + "label", + "status", + "points" + ], "properties": { - "key": { "type": "string", "minLength": 1 }, - "label": { "type": "string", "minLength": 1 }, - "status": { "$ref": "#/$defs/PanelStatus" }, - "points": { "type": "array", "items": { "$ref": "#/$defs/Point" } } + "key": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/$defs/Point" + } + } } } } @@ -98,36 +236,108 @@ "smileDiagnostics": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "ivValley", "atmForwardIv", "skewSlope", "curvature", "methodDisagreement"], + "required": [ + "status", + "label", + "diagnostics", + "ivValley", + "atmForwardIv", + "skewSlope", + "curvature", + "methodDisagreement" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "ivValley": { "$ref": "#/$defs/StrikeValue" }, - "atmForwardIv": { "type": ["number", "null"] }, - "skewSlope": { "type": ["number", "null"] }, - "curvature": { "type": ["number", "null"] }, - "methodDisagreement": { "type": ["number", "null"] } + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, + "ivValley": { + "$ref": "#/$defs/StrikeValue" + }, + "atmForwardIv": { + "type": [ + "number", + "null" + ] + }, + "skewSlope": { + "type": [ + "number", + "null" + ] + }, + "curvature": { + "type": [ + "number", + "null" + ] + }, + "methodDisagreement": { + "type": [ + "number", + "null" + ] + } } }, "probabilities": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "levels"], + "required": [ + "status", + "label", + "diagnostics", + "levels" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, "levels": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["strike", "closeAbove", "closeBelow"], + "required": [ + "strike", + "closeAbove", + "closeBelow" + ], "properties": { - "strike": { "type": "number" }, - "closeAbove": { "type": ["number", "null"] }, - "closeBelow": { "type": ["number", "null"] } + "strike": { + "type": "number" + }, + "closeAbove": { + "type": [ + "number", + "null" + ] + }, + "closeBelow": { + "type": [ + "number", + "null" + ] + } } } } @@ -136,112 +346,312 @@ "terminalDistribution": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "density", "highestDensityZone", "range68", "range95", "leftTailProbability", "rightTailProbability"], + "required": [ + "status", + "label", + "diagnostics", + "density", + "highestDensityZone", + "range68", + "range95", + "leftTailProbability", + "rightTailProbability" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "density": { "type": "array", "items": { "$ref": "#/$defs/Point" } }, - "highestDensityZone": { "type": ["string", "null"] }, - "range68": { "type": ["string", "null"] }, - "range95": { "type": ["string", "null"] }, - "leftTailProbability": { "type": ["number", "null"] }, - "rightTailProbability": { "type": ["number", "null"] } + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, + "density": { + "type": "array", + "items": { + "$ref": "#/$defs/Point" + } + }, + "highestDensityZone": { + "type": [ + "string", + "null" + ] + }, + "range68": { + "type": [ + "string", + "null" + ] + }, + "range95": { + "type": [ + "string", + "null" + ] + }, + "leftTailProbability": { + "type": [ + "number", + "null" + ] + }, + "rightTailProbability": { + "type": [ + "number", + "null" + ] + } } }, "skewTail": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "tailBias", "leftTailRichness", "rightTailRichness"], + "required": [ + "status", + "label", + "diagnostics", + "tailBias", + "leftTailRichness", + "rightTailRichness" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "tailBias": { "type": ["string", "null"] }, - "leftTailRichness": { "type": ["number", "null"] }, - "rightTailRichness": { "type": ["number", "null"] } + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, + "tailBias": { + "type": [ + "string", + "null" + ] + }, + "leftTailRichness": { + "type": [ + "number", + "null" + ] + }, + "rightTailRichness": { + "type": [ + "number", + "null" + ] + } } }, - "moveNeeded": { "$ref": "#/$defs/PanelWithRows" }, - "decayPressure": { "$ref": "#/$defs/PanelWithRows" }, - "richCheap": { "$ref": "#/$defs/PanelWithRows" }, + "moveNeeded": { + "$ref": "#/$defs/PanelWithRows" + }, + "decayPressure": { + "$ref": "#/$defs/PanelWithRows" + }, + "richCheap": { + "$ref": "#/$defs/PanelWithRows" + }, "quoteQuality": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "score", "flags"], + "required": [ + "status", + "label", + "diagnostics", + "score", + "flags" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, - "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, "flags": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["strike", "right", "code", "message"], + "required": [ + "strike", + "right", + "code", + "message" + ], "properties": { - "strike": { "type": "number" }, - "right": { "type": "string", "enum": ["call", "put", "pair"] }, - "code": { "type": "string", "minLength": 1 }, - "message": { "type": "string", "minLength": 1 } + "strike": { + "type": "number" + }, + "right": { + "type": "string", + "enum": [ + "call", + "put", + "pair" + ] + }, + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } } } } } }, - "historyPreview": { "$ref": "#/$defs/PanelWithRows" } + "historyPreview": { + "$ref": "#/$defs/PanelWithRows" + } }, "$defs": { "PanelStatus": { "type": "string", - "enum": ["ok", "preview", "insufficient_data", "error"] + "enum": [ + "ok", + "preview", + "insufficient_data", + "error" + ] }, "Diagnostic": { "type": "object", "additionalProperties": false, - "required": ["code", "message", "severity"], + "required": [ + "code", + "message", + "severity" + ], "properties": { - "code": { "type": "string", "minLength": 1 }, - "message": { "type": "string", "minLength": 1 }, - "severity": { "type": "string", "enum": ["info", "warning", "error"] } + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error" + ] + } } }, "Point": { "type": "object", "additionalProperties": false, - "required": ["x", "y"], + "required": [ + "x", + "y" + ], "properties": { - "x": { "type": "number" }, - "y": { "type": ["number", "null"] } + "x": { + "type": "number" + }, + "y": { + "type": [ + "number", + "null" + ] + } } }, "StrikeValue": { "type": "object", "additionalProperties": false, - "required": ["strike", "value", "label"], + "required": [ + "strike", + "value", + "label" + ], "properties": { - "strike": { "type": ["number", "null"] }, - "value": { "type": ["number", "null"] }, - "label": { "type": ["string", "null"] } + "strike": { + "type": [ + "number", + "null" + ] + }, + "value": { + "type": [ + "number", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + } } }, "PanelWithRows": { "type": "object", "additionalProperties": false, - "required": ["status", "label", "diagnostics", "rows"], + "required": [ + "status", + "label", + "diagnostics", + "rows" + ], "properties": { - "status": { "$ref": "#/$defs/PanelStatus" }, - "label": { "type": "string" }, - "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/Diagnostic" } }, + "status": { + "$ref": "#/$defs/PanelStatus" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + } + }, "rows": { "type": "array", "items": { "type": "object", "additionalProperties": true, - "required": ["strike"], + "required": [ + "strike" + ], "properties": { - "strike": { "type": "number" } + "strike": { + "type": "number" + } } } } diff --git a/packages/contracts/tests/schema.test.mjs b/packages/contracts/tests/schema.test.mjs index fa153ff..be92709 100644 --- a/packages/contracts/tests/schema.test.mjs +++ b/packages/contracts/tests/schema.test.mjs @@ -50,6 +50,19 @@ test("seed experimental analytics payload matches schema", async () => { assert.equal(validate(fixture), true, JSON.stringify(validate.errors, null, 2)); }); +test("experimental analytics rejects empty panel labels", async () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + + const schema = await readJson("schemas/experimental-analytics.schema.json"); + const fixture = await readJson("fixtures/experimental-analytics.seed.json"); + fixture.forwardSummary.label = ""; + const validate = ajv.compile(schema); + + assert.equal(validate(fixture), false); + assert.equal(validate.errors?.some((error) => error.instancePath === "/forwardSummary/label"), true); +}); + test("seed collector health matches schema", async () => { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); From f8be2100e677aa17b82661d9cf4f85337a17c38a Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:00:40 -0700 Subject: [PATCH 06/23] feat: add experimental quote quality and forward summary --- .../gammascope_api/experimental/__init__.py | 0 .../gammascope_api/experimental/forward.py | 94 +++++++++++++++++++ .../api/gammascope_api/experimental/models.py | 36 +++++++ .../gammascope_api/experimental/quality.py | 67 +++++++++++++ apps/api/tests/test_experimental_forward.py | 61 ++++++++++++ apps/api/tests/test_experimental_quality.py | 62 ++++++++++++ 6 files changed, 320 insertions(+) create mode 100644 apps/api/gammascope_api/experimental/__init__.py create mode 100644 apps/api/gammascope_api/experimental/forward.py create mode 100644 apps/api/gammascope_api/experimental/models.py create mode 100644 apps/api/gammascope_api/experimental/quality.py create mode 100644 apps/api/tests/test_experimental_forward.py create mode 100644 apps/api/tests/test_experimental_quality.py 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/forward.py b/apps/api/gammascope_api/experimental/forward.py new file mode 100644 index 0000000..6f749a8 --- /dev/null +++ b/apps/api/gammascope_api/experimental/forward.py @@ -0,0 +1,94 @@ +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]] = [] + + 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 + 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(grouped_pairs(rows), 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/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..ce4dca0 --- /dev/null +++ b/apps/api/gammascope_api/experimental/quality.py @@ -0,0 +1,67 @@ +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 = float(row["strike"]) + 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 = float(row["strike"]) + right = str(row.get("right") or "pair") + flags: list[dict[str, Any]] = [] + + 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/tests/test_experimental_forward.py b/apps/api/tests/test_experimental_forward.py new file mode 100644 index 0000000..4493071 --- /dev/null +++ b/apps/api/tests/test_experimental_forward.py @@ -0,0 +1,61 @@ +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 diff --git a/apps/api/tests/test_experimental_quality.py b/apps/api/tests/test_experimental_quality.py new file mode 100644 index 0000000..62b90c3 --- /dev/null +++ b/apps/api/tests/test_experimental_quality.py @@ -0,0 +1,62 @@ +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", + } From 70d53aa7bc08728ef034f7cb9a5b8ad839289a25 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:08:29 -0700 Subject: [PATCH 07/23] fix: harden experimental forward quality core --- .../gammascope_api/experimental/forward.py | 4 +++- .../gammascope_api/experimental/quality.py | 9 ++++++-- apps/api/tests/test_experimental_forward.py | 21 +++++++++++++++++++ apps/api/tests/test_experimental_quality.py | 16 ++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/api/gammascope_api/experimental/forward.py b/apps/api/gammascope_api/experimental/forward.py index 6f749a8..0ab06e4 100644 --- a/apps/api/gammascope_api/experimental/forward.py +++ b/apps/api/gammascope_api/experimental/forward.py @@ -29,6 +29,7 @@ def forward_summary_panel(snapshot: dict[str, Any]) -> dict[str, Any]: 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: @@ -39,6 +40,7 @@ def forward_summary_panel(snapshot: dict[str, Any]) -> dict[str, Any]: 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: @@ -56,7 +58,7 @@ def forward_summary_panel(snapshot: dict[str, Any]) -> dict[str, Any]: 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(grouped_pairs(rows), key=lambda pair: abs(pair.strike - parity_forward)) + 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 diff --git a/apps/api/gammascope_api/experimental/quality.py b/apps/api/gammascope_api/experimental/quality.py index ce4dca0..0219257 100644 --- a/apps/api/gammascope_api/experimental/quality.py +++ b/apps/api/gammascope_api/experimental/quality.py @@ -10,7 +10,9 @@ def grouped_pairs(rows: list[dict[str, Any]]) -> list[StrikePair]: grouped: dict[float, dict[str, dict[str, Any] | None]] = {} for row in rows: - strike = float(row["strike"]) + 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 @@ -40,10 +42,13 @@ def quote_quality_panel(rows: list[dict[str, Any]]) -> dict[str, Any]: def quote_flags(row: dict[str, Any]) -> list[dict[str, Any]]: bid = optional_float(row.get("bid")) ask = optional_float(row.get("ask")) - strike = float(row["strike"]) + 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: diff --git a/apps/api/tests/test_experimental_forward.py b/apps/api/tests/test_experimental_forward.py index 4493071..8e54df9 100644 --- a/apps/api/tests/test_experimental_forward.py +++ b/apps/api/tests/test_experimental_forward.py @@ -59,3 +59,24 @@ def test_forward_summary_reports_insufficient_data_without_pairs() -> None: 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_quality.py b/apps/api/tests/test_experimental_quality.py index 62b90c3..383af13 100644 --- a/apps/api/tests/test_experimental_quality.py +++ b/apps/api/tests/test_experimental_quality.py @@ -1,3 +1,5 @@ +import pytest + from gammascope_api.experimental.quality import grouped_pairs, quote_quality_panel @@ -60,3 +62,17 @@ def test_quote_quality_flags_missing_crossed_zero_and_wide_quotes() -> None: "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"} From 6c1e6d197bb13b2e1ff9e8dd99a94b9ce0e19470 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:16:11 -0700 Subject: [PATCH 08/23] feat: add experimental iv smile methods --- .../gammascope_api/experimental/iv_methods.py | 203 ++++++++++++++++++ .../api/tests/test_experimental_iv_methods.py | 78 +++++++ 2 files changed, 281 insertions(+) create mode 100644 apps/api/gammascope_api/experimental/iv_methods.py create mode 100644 apps/api/tests/test_experimental_iv_methods.py 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..90668c3 --- /dev/null +++ b/apps/api/gammascope_api/experimental/iv_methods.py @@ -0,0 +1,203 @@ +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 = list((spline or {}).get("points") or []) + if not points: + return panel( + "insufficient_data", + "Smile diagnostics", + [diagnostic("missing_fit", "No fitted smile is available.", "warning")], + ivValley={"strike": None, "value": None, "label": None}, + atmForwardIv=None, + skewSlope=None, + curvature=None, + methodDisagreement=None, + ) + finite_points = [point for point in points if point.get("y") is not None] + 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) + 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: + value = optional_float(row.get(key)) + if value is not None: + points.append({"x": float(row["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 atm is None 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]) -> float | None: + method_values = [] + for method in iv_panel.get("methods", []): + points = [point for point in method.get("points", []) if point.get("y") is not None] + if points: + method_values.append(float(points[len(points) // 2]["y"])) + if len(method_values) < 2: + return None + return max(method_values) - min(method_values) 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..bf323fc --- /dev/null +++ b/apps/api/tests/test_experimental_iv_methods.py @@ -0,0 +1,78 @@ +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_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"] is not None From 99973f4073ac6e0feda46a851eff8cc7b7285219 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:22:48 -0700 Subject: [PATCH 09/23] fix: harden experimental iv diagnostics --- .../gammascope_api/experimental/iv_methods.py | 64 +++++++++++++------ .../api/tests/test_experimental_iv_methods.py | 35 +++++++++- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/apps/api/gammascope_api/experimental/iv_methods.py b/apps/api/gammascope_api/experimental/iv_methods.py index 90668c3..945743b 100644 --- a/apps/api/gammascope_api/experimental/iv_methods.py +++ b/apps/api/gammascope_api/experimental/iv_methods.py @@ -81,19 +81,12 @@ def build_iv_smiles_panel(snapshot: dict[str, Any], forward_summary: dict[str, A 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 = list((spline or {}).get("points") or []) + points = _clean_points((spline or {}).get("points") or []) if not points: - return panel( - "insufficient_data", - "Smile diagnostics", - [diagnostic("missing_fit", "No fitted smile is available.", "warning")], - ivValley={"strike": None, "value": None, "label": None}, - atmForwardIv=None, - skewSlope=None, - curvature=None, - methodDisagreement=None, - ) - finite_points = [point for point in points if point.get("y") is not None] + 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] @@ -101,7 +94,7 @@ def smile_diagnostics_panel(iv_panel: dict[str, Any], forward: float) -> dict[st 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) + disagreement = _method_disagreement(iv_panel, float(atm["x"])) return panel( "preview", "Smile diagnostics", @@ -117,9 +110,10 @@ def smile_diagnostics_panel(iv_panel: dict[str, Any], forward: float) -> dict[st 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 value is not None: - points.append({"x": float(row["strike"]), "y": value}) + 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"]) @@ -146,7 +140,7 @@ def _otm_midpoint_points(rows: list[dict[str, Any]], forward: float, tau: float, 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 atm is None or forward <= 0 or tau <= 0: + 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}] @@ -192,12 +186,42 @@ def _fit_points(strikes: np.ndarray, total_variance: np.ndarray, tau: float) -> return [{"x": float(strike), "y": float(iv)} for strike, iv in zip(strikes, ivs)] -def _method_disagreement(iv_panel: dict[str, Any]) -> float | None: +def _method_disagreement(iv_panel: dict[str, Any], reference_strike: float) -> float | None: method_values = [] for method in iv_panel.get("methods", []): - points = [point for point in method.get("points", []) if point.get("y") is not None] - if points: - method_values.append(float(points[len(points) // 2]["y"])) + 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/tests/test_experimental_iv_methods.py b/apps/api/tests/test_experimental_iv_methods.py index bf323fc..379d7d0 100644 --- a/apps/api/tests/test_experimental_iv_methods.py +++ b/apps/api/tests/test_experimental_iv_methods.py @@ -61,6 +61,29 @@ def test_build_iv_smiles_panel_outputs_raw_and_fitted_methods() -> None: 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": [ @@ -75,4 +98,14 @@ def test_smile_diagnostics_reports_valley_and_method_disagreement() -> None: 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"] is not None + 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} From 6efe437cdb06987ea124331586b8a2d98553a69f Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:31:57 -0700 Subject: [PATCH 10/23] feat: add experimental distribution and trade maps --- .../experimental/distribution.py | 143 ++++++++++++++++++ .../gammascope_api/experimental/trade_maps.py | 101 +++++++++++++ .../tests/test_experimental_distribution.py | 39 +++++ .../api/tests/test_experimental_trade_maps.py | 41 +++++ 4 files changed, 324 insertions(+) create mode 100644 apps/api/gammascope_api/experimental/distribution.py create mode 100644 apps/api/gammascope_api/experimental/trade_maps.py create mode 100644 apps/api/tests/test_experimental_distribution.py create mode 100644 apps/api/tests/test_experimental_trade_maps.py diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py new file mode 100644 index 0000000..1a16fc1 --- /dev/null +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from math import exp, log, sqrt +from typing import Any + +from gammascope_api.experimental.iv_methods import black76_price, normal_cdf +from gammascope_api.experimental.models import diagnostic, panel + + +def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: + points = _fit_points(iv_panel) + if len(points) < 2: + return panel( + "insufficient_data", + "Risk-neutral probabilities", + [diagnostic("missing_fit", "A fitted smile is required.", "warning")], + levels=[], + ) + levels = [] + for point in points: + strike = float(point["x"]) + sigma = float(point["y"]) + if forward <= 0 or strike <= 0 or tau <= 0: + close_above = None + else: + 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]: + points = _fit_points(iv_panel) + if len(points) < 3: + return panel( + "insufficient_data", + "Terminal distribution", + [diagnostic("missing_fit", "A fitted smile with at least three points is required.", "warning")], + density=[], + highestDensityZone=None, + range68=None, + range95=None, + leftTailProbability=None, + rightTailProbability=None, + ) + calls = [ + black76_price(forward=forward, strike=float(point["x"]), tau=tau, rate=rate, sigma=float(point["y"]), right="call") + for point in points + ] + strikes = [float(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] + width = max((left_width + right_width) / 2, 1e-9) + curvature = (calls[index - 1] - 2 * calls[index] + calls[index + 1]) / (width * width) + density.append({"x": strikes[index], "y": max(0.0, curvature * exp(rate * tau))}) + if not density: + return panel( + "insufficient_data", + "Terminal distribution", + [diagnostic("empty_density", "Density could not be estimated.", "warning")], + density=[], + highestDensityZone=None, + range68=None, + range95=None, + leftTailProbability=None, + rightTailProbability=None, + ) + 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]: + points = _fit_points(iv_panel) + if len(points) < 3: + return panel( + "insufficient_data", + "Skew and tail asymmetry", + [diagnostic("missing_fit", "A fitted smile is required.", "warning")], + tailBias=None, + leftTailRichness=None, + rightTailRichness=None, + ) + 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]]: + for method in iv_panel.get("methods", []): + if method.get("key") == "spline_fit": + return [point for point in method.get("points", []) if point.get("y") is not None] + 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}" 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..bd81946 --- /dev/null +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +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: list[dict[str, Any]], *, spot: float, expected_move: float | None) -> dict[str, Any]: + out = [] + for row in rows: + mid = optional_float(row.get("mid")) + if mid is None: + continue + strike = float(row["strike"]) + side = str(row["right"]) + 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: list[dict[str, Any]], *, minutes_to_expiry: float) -> dict[str, Any]: + out = [] + minutes = max(minutes_to_expiry, 1e-9) + for row in rows: + mid = optional_float(row.get("mid")) + if mid is None: + continue + out.append({"strike": float(row["strike"]), "side": row["right"], "premium": mid, "pointsPerMinute": mid / minutes}) + 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: list[dict[str, Any]], *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: + fit_by_strike = _fit_by_strike(iv_panel) + out = [] + for row in rows: + mid = optional_float(row.get("mid")) + sigma = fit_by_strike.get(float(row["strike"])) + if mid is None or sigma is None: + continue + side = row["right"] + fitted_fair = black76_price(forward=forward, strike=float(row["strike"]), tau=tau, rate=rate, sigma=sigma, right=side) + residual = mid - fitted_fair + out.append( + { + "strike": float(row["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 _fit_by_strike(iv_panel: dict[str, Any]) -> dict[float, float]: + for method in iv_panel.get("methods", []): + if method.get("key") == "spline_fit": + return {float(point["x"]): float(point["y"]) for point in method.get("points", []) if point.get("y") is not None} + return {} diff --git a/apps/api/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py new file mode 100644 index 0000000..6084c1c --- /dev/null +++ b/apps/api/tests/test_experimental_distribution.py @@ -0,0 +1,39 @@ +from gammascope_api.experimental.distribution import probability_panel, terminal_distribution_panel, skew_tail_panel + + +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"} 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..f141889 --- /dev/null +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -0,0 +1,41 @@ +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"} From 30494561037780e31097e2d85083df560f1a5d40 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:38:54 -0700 Subject: [PATCH 11/23] fix: harden experimental distribution trade maps --- .../experimental/distribution.py | 109 ++++++++++-------- .../gammascope_api/experimental/trade_maps.py | 50 ++++++-- .../tests/test_experimental_distribution.py | 46 ++++++++ .../api/tests/test_experimental_trade_maps.py | 28 +++++ 4 files changed, 173 insertions(+), 60 deletions(-) diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py index 1a16fc1..bbb7665 100644 --- a/apps/api/gammascope_api/experimental/distribution.py +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -4,27 +4,21 @@ from typing import Any from gammascope_api.experimental.iv_methods import black76_price, normal_cdf -from gammascope_api.experimental.models import diagnostic, panel +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 forward <= 0 or tau <= 0: + return _empty_probability_panel("Forward and time to expiry must be positive.") points = _fit_points(iv_panel) if len(points) < 2: - return panel( - "insufficient_data", - "Risk-neutral probabilities", - [diagnostic("missing_fit", "A fitted smile is required.", "warning")], - levels=[], - ) + return _empty_probability_panel("A fitted smile is required.") levels = [] for point in points: - strike = float(point["x"]) - sigma = float(point["y"]) - if forward <= 0 or strike <= 0 or tau <= 0: - close_above = None - else: - d2 = (log(forward / strike) - 0.5 * sigma * sigma * tau) / (sigma * sqrt(tau)) - close_above = normal_cdf(d2) + 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", @@ -35,43 +29,27 @@ def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, r def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: + if forward <= 0 or tau <= 0: + return _empty_terminal_distribution_panel("Forward and time to expiry must be positive.") points = _fit_points(iv_panel) if len(points) < 3: - return panel( - "insufficient_data", - "Terminal distribution", - [diagnostic("missing_fit", "A fitted smile with at least three points is required.", "warning")], - density=[], - highestDensityZone=None, - range68=None, - range95=None, - leftTailProbability=None, - rightTailProbability=None, - ) + return _empty_terminal_distribution_panel("A fitted smile with at least three points is required.") calls = [ - black76_price(forward=forward, strike=float(point["x"]), tau=tau, rate=rate, sigma=float(point["y"]), right="call") + black76_price(forward=forward, strike=point["x"], tau=tau, rate=rate, sigma=point["y"], right="call") for point in points ] - strikes = [float(point["x"]) 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] - width = max((left_width + right_width) / 2, 1e-9) + if left_width <= 0 or right_width <= 0: + return _empty_terminal_distribution_panel("Fitted smile strikes must be strictly increasing.") + width = (left_width + right_width) / 2 curvature = (calls[index - 1] - 2 * calls[index] + calls[index + 1]) / (width * width) density.append({"x": strikes[index], "y": max(0.0, curvature * exp(rate * tau))}) if not density: - return panel( - "insufficient_data", - "Terminal distribution", - [diagnostic("empty_density", "Density could not be estimated.", "warning")], - density=[], - highestDensityZone=None, - range68=None, - range95=None, - leftTailProbability=None, - rightTailProbability=None, - ) + 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) @@ -92,16 +70,11 @@ def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau def skew_tail_panel(iv_panel: dict[str, Any], *, forward: float) -> dict[str, Any]: + if forward <= 0: + return _empty_skew_tail_panel("Forward must be positive.") points = _fit_points(iv_panel) if len(points) < 3: - return panel( - "insufficient_data", - "Skew and tail asymmetry", - [diagnostic("missing_fit", "A fitted smile is required.", "warning")], - tailBias=None, - leftTailRichness=None, - rightTailRichness=None, - ) + 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] @@ -127,7 +100,7 @@ def skew_tail_panel(iv_panel: dict[str, Any], *, forward: float) -> dict[str, An def _fit_points(iv_panel: dict[str, Any]) -> list[dict[str, float]]: for method in iv_panel.get("methods", []): if method.get("key") == "spline_fit": - return [point for point in method.get("points", []) if point.get("y") is not None] + return _clean_fit_points(method.get("points", [])) return [] @@ -141,3 +114,43 @@ 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]]: + by_strike: dict[float, float] = {} + for point in points: + 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, + ) diff --git a/apps/api/gammascope_api/experimental/trade_maps.py b/apps/api/gammascope_api/experimental/trade_maps.py index bd81946..29ad9a7 100644 --- a/apps/api/gammascope_api/experimental/trade_maps.py +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -10,10 +10,10 @@ def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: out = [] for row in rows: mid = optional_float(row.get("mid")) - if mid is None: + 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 - strike = float(row["strike"]) - side = str(row["right"]) if side == "call": breakeven = strike + mid move_needed = max(0.0, breakeven - spot) @@ -35,13 +35,21 @@ def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float) -> dict[str, Any]: + if minutes_to_expiry <= 0: + return panel( + "insufficient_data", + "Time-decay pressure", + [diagnostic("invalid_time", "Minutes to expiry must be positive.", "warning")], + rows=[], + ) out = [] - minutes = max(minutes_to_expiry, 1e-9) for row in rows: mid = optional_float(row.get("mid")) - if mid is None: + 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": float(row["strike"]), "side": row["right"], "premium": mid, "pointsPerMinute": mid / minutes}) + out.append({"strike": strike, "side": side, "premium": mid, "pointsPerMinute": mid / minutes_to_expiry}) return panel( "preview" if out else "insufficient_data", "Time-decay pressure", @@ -51,19 +59,24 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float def rich_cheap_panel(rows: list[dict[str, Any]], *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: + if forward <= 0 or tau <= 0: + return panel("insufficient_data", "Rich/cheap residuals", [diagnostic("invalid_model_inputs", "Forward and time to expiry must be positive.", "warning")], rows=[]) fit_by_strike = _fit_by_strike(iv_panel) out = [] for row in rows: mid = optional_float(row.get("mid")) - sigma = fit_by_strike.get(float(row["strike"])) - if mid is None or sigma is None: + 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 = fit_by_strike.get(strike) + if sigma is None: continue - side = row["right"] - fitted_fair = black76_price(forward=forward, strike=float(row["strike"]), tau=tau, rate=rate, sigma=sigma, right=side) + fitted_fair = black76_price(forward=forward, strike=strike, tau=tau, rate=rate, sigma=sigma, right=side) residual = mid - fitted_fair out.append( { - "strike": float(row["strike"]), + "strike": strike, "side": side, "actualMid": mid, "fittedFair": fitted_fair, @@ -97,5 +110,18 @@ def _residual_label(residual: float) -> str: def _fit_by_strike(iv_panel: dict[str, Any]) -> dict[float, float]: for method in iv_panel.get("methods", []): if method.get("key") == "spline_fit": - return {float(point["x"]): float(point["y"]) for point in method.get("points", []) if point.get("y") is not None} + out = {} + for point in method.get("points", []): + 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: + out[strike] = sigma + return out return {} + + +def _option_side(row: dict[str, Any]) -> str | None: + side = row.get("right") + if side == "call" or side == "put": + return side + return None diff --git a/apps/api/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py index 6084c1c..9f57545 100644 --- a/apps/api/tests/test_experimental_distribution.py +++ b/apps/api/tests/test_experimental_distribution.py @@ -37,3 +37,49 @@ def test_skew_tail_panel_labels_left_tail_richness() -> None: 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_degrade_on_invalid_model_inputs() -> None: + assert probability_panel(fitted_iv_panel(), forward=0, tau=1 / 365, rate=0.0)["status"] == "insufficient_data" + assert terminal_distribution_panel(fitted_iv_panel(), forward=100, tau=0, rate=0.0)["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"]) diff --git a/apps/api/tests/test_experimental_trade_maps.py b/apps/api/tests/test_experimental_trade_maps.py index f141889..df24077 100644 --- a/apps/api/tests/test_experimental_trade_maps.py +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -39,3 +39,31 @@ def test_rich_cheap_panel_compares_actual_mid_to_fitted_fair() -> None: assert panel["status"] == "preview" assert panel["rows"][0]["strike"] == 105 assert panel["rows"][0]["label"] in {"Rich", "Cheap", "Inline"} + + +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), + ] + + 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_invalid_model_inputs() -> None: + assert decay_pressure_panel([row("call", 105, 2.0)], minutes_to_expiry=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=0, + tau=1 / 365, + rate=0.0, + )["status"] == "insufficient_data" From 8464995594703636a23cbe5b9e163cdd28dd1724 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:42:54 -0700 Subject: [PATCH 12/23] fix: reject nonfinite experimental panel inputs --- apps/api/gammascope_api/experimental/distribution.py | 12 ++++++++---- apps/api/gammascope_api/experimental/trade_maps.py | 9 +++++++-- apps/api/tests/test_experimental_distribution.py | 3 +++ apps/api/tests/test_experimental_trade_maps.py | 8 ++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py index bbb7665..eb73789 100644 --- a/apps/api/gammascope_api/experimental/distribution.py +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -1,6 +1,6 @@ from __future__ import annotations -from math import exp, log, sqrt +from math import exp, isfinite, log, sqrt from typing import Any from gammascope_api.experimental.iv_methods import black76_price, normal_cdf @@ -8,7 +8,7 @@ def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: - if forward <= 0 or tau <= 0: + 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: @@ -29,7 +29,7 @@ def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, r def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, rate: float) -> dict[str, Any]: - if forward <= 0 or tau <= 0: + if not _positive_finite(forward) or not _positive_finite(tau): return _empty_terminal_distribution_panel("Forward and time to expiry must be positive.") points = _fit_points(iv_panel) if len(points) < 3: @@ -70,7 +70,7 @@ def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau def skew_tail_panel(iv_panel: dict[str, Any], *, forward: float) -> dict[str, Any]: - if forward <= 0: + if not _positive_finite(forward): return _empty_skew_tail_panel("Forward must be positive.") points = _fit_points(iv_panel) if len(points) < 3: @@ -154,3 +154,7 @@ def _empty_skew_tail_panel(message: str) -> dict[str, Any]: leftTailRichness=None, rightTailRichness=None, ) + + +def _positive_finite(value: float) -> bool: + return isfinite(value) and value > 0 diff --git a/apps/api/gammascope_api/experimental/trade_maps.py b/apps/api/gammascope_api/experimental/trade_maps.py index 29ad9a7..6b43d85 100644 --- a/apps/api/gammascope_api/experimental/trade_maps.py +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -1,5 +1,6 @@ from __future__ import annotations +from math import isfinite from typing import Any from gammascope_api.experimental.iv_methods import black76_price @@ -35,7 +36,7 @@ def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float) -> dict[str, Any]: - if minutes_to_expiry <= 0: + if not _positive_finite(minutes_to_expiry): return panel( "insufficient_data", "Time-decay pressure", @@ -59,7 +60,7 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float def rich_cheap_panel(rows: list[dict[str, Any]], *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: - if forward <= 0 or tau <= 0: + if not _positive_finite(forward) or not _positive_finite(tau): return panel("insufficient_data", "Rich/cheap residuals", [diagnostic("invalid_model_inputs", "Forward and time to expiry must be positive.", "warning")], rows=[]) fit_by_strike = _fit_by_strike(iv_panel) out = [] @@ -125,3 +126,7 @@ def _option_side(row: dict[str, Any]) -> str | None: 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/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py index 9f57545..784e071 100644 --- a/apps/api/tests/test_experimental_distribution.py +++ b/apps/api/tests/test_experimental_distribution.py @@ -61,7 +61,10 @@ def test_distribution_panels_sanitize_bad_fit_points_without_raising() -> None: 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" def test_terminal_distribution_deduplicates_and_sorts_fit_points() -> None: diff --git a/apps/api/tests/test_experimental_trade_maps.py b/apps/api/tests/test_experimental_trade_maps.py index df24077..982dd6d 100644 --- a/apps/api/tests/test_experimental_trade_maps.py +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -60,6 +60,7 @@ def test_trade_map_panels_skip_bad_rows_and_invalid_sides() -> None: 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}]}]}, @@ -67,3 +68,10 @@ def test_trade_map_panels_degrade_on_invalid_model_inputs() -> None: 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" From 38333ff0915b97986666e175bfb31b885ac525fd Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:49:25 -0700 Subject: [PATCH 13/23] fix: guard experimental panel container inputs --- .../experimental/distribution.py | 14 +++++++++-- .../gammascope_api/experimental/trade_maps.py | 23 ++++++++++++++++--- .../tests/test_experimental_distribution.py | 15 ++++++++++++ .../api/tests/test_experimental_trade_maps.py | 9 ++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py index eb73789..47d6e17 100644 --- a/apps/api/gammascope_api/experimental/distribution.py +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from math import exp, isfinite, log, sqrt from typing import Any @@ -29,7 +30,7 @@ def probability_panel(iv_panel: dict[str, Any], *, forward: float, tau: float, r 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): + 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: @@ -98,7 +99,12 @@ def skew_tail_panel(iv_panel: dict[str, Any], *, forward: float) -> dict[str, An def _fit_points(iv_panel: dict[str, Any]) -> list[dict[str, float]]: - for method in iv_panel.get("methods", []): + 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 [] @@ -117,8 +123,12 @@ def _range_label(lower: float | None, upper: float | None) -> str | None: 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: diff --git a/apps/api/gammascope_api/experimental/trade_maps.py b/apps/api/gammascope_api/experimental/trade_maps.py index 6b43d85..269cf6b 100644 --- a/apps/api/gammascope_api/experimental/trade_maps.py +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from math import isfinite from typing import Any @@ -10,6 +11,8 @@ def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: float | None) -> dict[str, Any]: out = [] for row in rows: + if not isinstance(row, Mapping): + continue mid = optional_float(row.get("mid")) strike = optional_float(row.get("strike")) side = _option_side(row) @@ -45,6 +48,8 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float ) out = [] for row in rows: + if not isinstance(row, Mapping): + continue mid = optional_float(row.get("mid")) strike = optional_float(row.get("strike")) side = _option_side(row) @@ -60,11 +65,13 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float def rich_cheap_panel(rows: list[dict[str, 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): + 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_by_strike = _fit_by_strike(iv_panel) out = [] for row in rows: + if not isinstance(row, Mapping): + continue mid = optional_float(row.get("mid")) strike = optional_float(row.get("strike")) side = _option_side(row) @@ -109,10 +116,20 @@ def _residual_label(residual: float) -> str: def _fit_by_strike(iv_panel: dict[str, Any]) -> dict[float, float]: - for method in iv_panel.get("methods", []): + 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": out = {} - for point in method.get("points", []): + points = method.get("points", []) + if not isinstance(points, list): + return out + 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: diff --git a/apps/api/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py index 784e071..77e7018 100644 --- a/apps/api/tests/test_experimental_distribution.py +++ b/apps/api/tests/test_experimental_distribution.py @@ -59,12 +59,27 @@ def test_distribution_panels_sanitize_bad_fit_points_without_raising() -> None: 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: diff --git a/apps/api/tests/test_experimental_trade_maps.py b/apps/api/tests/test_experimental_trade_maps.py index 982dd6d..bb93910 100644 --- a/apps/api/tests/test_experimental_trade_maps.py +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -46,6 +46,8 @@ def test_trade_map_panels_skip_bad_rows_and_invalid_sides() -> None: 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) @@ -75,3 +77,10 @@ def test_trade_map_panels_degrade_on_invalid_model_inputs() -> None: 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" From 1365eef54c0154c3ccb2526b9349f994d36ebd49 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:59:33 -0700 Subject: [PATCH 14/23] fix: harden experimental trade map density math --- .../experimental/distribution.py | 5 +- .../gammascope_api/experimental/trade_maps.py | 67 ++++++++++++------- .../tests/test_experimental_distribution.py | 27 ++++++++ .../api/tests/test_experimental_trade_maps.py | 17 +++++ 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/apps/api/gammascope_api/experimental/distribution.py b/apps/api/gammascope_api/experimental/distribution.py index 47d6e17..1b0a5df 100644 --- a/apps/api/gammascope_api/experimental/distribution.py +++ b/apps/api/gammascope_api/experimental/distribution.py @@ -46,8 +46,9 @@ def terminal_distribution_panel(iv_panel: dict[str, Any], *, forward: float, tau 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.") - width = (left_width + right_width) / 2 - curvature = (calls[index - 1] - 2 * calls[index] + calls[index + 1]) / (width * width) + 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.") diff --git a/apps/api/gammascope_api/experimental/trade_maps.py b/apps/api/gammascope_api/experimental/trade_maps.py index 269cf6b..f4018ce 100644 --- a/apps/api/gammascope_api/experimental/trade_maps.py +++ b/apps/api/gammascope_api/experimental/trade_maps.py @@ -1,5 +1,6 @@ from __future__ import annotations +from bisect import bisect_left from collections.abc import Mapping from math import isfinite from typing import Any @@ -8,11 +9,9 @@ from gammascope_api.experimental.models import diagnostic, optional_float, panel -def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: float | None) -> dict[str, Any]: +def move_needed_panel(rows: Any, *, spot: float, expected_move: float | None) -> dict[str, Any]: out = [] - for row in rows: - if not isinstance(row, Mapping): - continue + for row in _iter_rows(rows): mid = optional_float(row.get("mid")) strike = optional_float(row.get("strike")) side = _option_side(row) @@ -38,7 +37,7 @@ def move_needed_panel(rows: list[dict[str, Any]], *, spot: float, expected_move: return panel("ok" if out else "insufficient_data", "Move-needed map", [], rows=out) -def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float) -> dict[str, Any]: +def decay_pressure_panel(rows: Any, *, minutes_to_expiry: float) -> dict[str, Any]: if not _positive_finite(minutes_to_expiry): return panel( "insufficient_data", @@ -47,9 +46,7 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float rows=[], ) out = [] - for row in rows: - if not isinstance(row, Mapping): - continue + for row in _iter_rows(rows): mid = optional_float(row.get("mid")) strike = optional_float(row.get("strike")) side = _option_side(row) @@ -64,20 +61,18 @@ def decay_pressure_panel(rows: list[dict[str, Any]], *, minutes_to_expiry: float ) -def rich_cheap_panel(rows: list[dict[str, Any]], *, iv_panel: dict[str, Any], forward: float, tau: float, rate: float) -> dict[str, Any]: +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_by_strike = _fit_by_strike(iv_panel) + fit_points = _fit_points(iv_panel) out = [] - for row in rows: - if not isinstance(row, Mapping): - continue + 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 = fit_by_strike.get(strike) + 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) @@ -115,30 +110,54 @@ def _residual_label(residual: float) -> str: return "Inline" -def _fit_by_strike(iv_panel: dict[str, Any]) -> dict[float, float]: +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 {} + return [] for method in methods: if not isinstance(method, Mapping): continue if method.get("key") == "spline_fit": - out = {} + by_strike = {} points = method.get("points", []) if not isinstance(points, list): - return out + 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: - out[strike] = sigma - return out - return {} - - -def _option_side(row: dict[str, Any]) -> str | None: + 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 diff --git a/apps/api/tests/test_experimental_distribution.py b/apps/api/tests/test_experimental_distribution.py index 77e7018..795cf51 100644 --- a/apps/api/tests/test_experimental_distribution.py +++ b/apps/api/tests/test_experimental_distribution.py @@ -1,4 +1,9 @@ +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: @@ -101,3 +106,25 @@ def test_terminal_distribution_deduplicates_and_sorts_fit_points() -> None: 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_trade_maps.py b/apps/api/tests/test_experimental_trade_maps.py index bb93910..2154d6e 100644 --- a/apps/api/tests/test_experimental_trade_maps.py +++ b/apps/api/tests/test_experimental_trade_maps.py @@ -41,6 +41,14 @@ def test_rich_cheap_panel_compares_actual_mid_to_fitted_fair() -> None: 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), @@ -60,6 +68,15 @@ def test_trade_map_panels_skip_bad_rows_and_invalid_sides() -> None: 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" From 22177978b808ec02092de5013cdc15d30ea6395a Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:01:42 -0700 Subject: [PATCH 15/23] Add experimental analytics API routes --- .../gammascope_api/experimental/service.py | 323 ++++++++++++++++++ apps/api/gammascope_api/main.py | 3 +- .../api/gammascope_api/routes/experimental.py | 36 ++ apps/api/tests/test_experimental_routes.py | 93 +++++ apps/api/tests/test_experimental_service.py | 85 +++++ 5 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 apps/api/gammascope_api/experimental/service.py create mode 100644 apps/api/gammascope_api/routes/experimental.py create mode 100644 apps/api/tests/test_experimental_routes.py create mode 100644 apps/api/tests/test_experimental_service.py diff --git a/apps/api/gammascope_api/experimental/service.py b/apps/api/gammascope_api/experimental/service.py new file mode 100644 index 0000000..d67bc0b --- /dev/null +++ b/apps/api/gammascope_api/experimental/service.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, date, datetime +from typing import Any, Callable, Literal + +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."), + ) + + return { + "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."), + } + + +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 _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/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..9e24a62 --- /dev/null +++ b/apps/api/gammascope_api/routes/experimental.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header + +from gammascope_api.auth import can_read_live_state +from gammascope_api.experimental.service import build_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_live_snapshot +from gammascope_api.routes import replay as replay_routes + + +router = APIRouter() + + +@router.get("/api/spx/0dte/experimental/latest") +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_live_snapshot(cached_or_memory_collector_state()) + if live_snapshot is not None: + return build_experimental_payload(live_snapshot, "latest") + return load_json_fixture("experimental-analytics.seed.json") + + +@router.get("/api/spx/0dte/experimental/replay/snapshot") +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/tests/test_experimental_routes.py b/apps/api/tests/test_experimental_routes.py new file mode 100644 index 0000000..a2204e6 --- /dev/null +++ b/apps/api/tests/test_experimental_routes.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +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_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_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" diff --git a/apps/api/tests/test_experimental_service.py b/apps/api/tests/test_experimental_service.py new file mode 100644 index 0000000..659fefd --- /dev/null +++ b/apps/api/tests/test_experimental_service.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from copy import deepcopy + +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" From 7ac88fb9e68b07714fe0cd395bd2220726a7ee68 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:05:15 -0700 Subject: [PATCH 16/23] feat: add experimental analytics web loaders --- .../api/spx/0dte/experimental/latest/route.ts | 38 ++ .../experimental/replay/snapshot/route.ts | 56 +++ .../lib/clientExperimentalAnalyticsSource.ts | 336 ++++++++++++++++++ apps/web/lib/contracts.ts | 1 + .../lib/serverExperimentalAnalyticsSource.ts | 48 +++ .../clientExperimentalAnalyticsSource.test.ts | 149 ++++++++ apps/web/tests/experimentalRoute.test.ts | 142 ++++++++ .../serverExperimentalAnalyticsSource.test.ts | 78 ++++ 8 files changed, 848 insertions(+) create mode 100644 apps/web/app/api/spx/0dte/experimental/latest/route.ts create mode 100644 apps/web/app/api/spx/0dte/experimental/replay/snapshot/route.ts create mode 100644 apps/web/lib/clientExperimentalAnalyticsSource.ts create mode 100644 apps/web/lib/serverExperimentalAnalyticsSource.ts create mode 100644 apps/web/tests/clientExperimentalAnalyticsSource.test.ts create mode 100644 apps/web/tests/experimentalRoute.test.ts create mode 100644 apps/web/tests/serverExperimentalAnalyticsSource.test.ts 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..e798a78 --- /dev/null +++ b/apps/web/app/api/spx/0dte/experimental/latest/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"; +const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest"; + +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; +} + +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: { + 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 analytics unavailable" }, { status: 502 }); + } +} 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/lib/clientExperimentalAnalyticsSource.ts b/apps/web/lib/clientExperimentalAnalyticsSource.ts new file mode 100644 index 0000000..38fe6ec --- /dev/null +++ b/apps/web/lib/clientExperimentalAnalyticsSource.ts @@ -0,0 +1,336 @@ +import type { ExperimentalAnalytics } from "./contracts"; + +const EXPERIMENTAL_LATEST_PATH = "/api/spx/0dte/experimental/latest"; +const EXPERIMENTAL_REPLAY_PATH = "/api/spx/0dte/experimental/replay/snapshot"; + +type ExperimentalAnalyticsFetcher = (input: string, init: RequestInit) => Promise; + +type LoadClientExperimentalAnalyticsOptions = { + fetcher?: ExperimentalAnalyticsFetcher; +}; + +export type ExperimentalReplayRequest = { + session_id: string; + at?: string; + source_snapshot_id?: string; +}; + +type Validator = (value: unknown) => boolean; + +const PANEL_STATUSES = ["ok", "preview", "insufficient_data", "error"] as const; + +const TOP_LEVEL_FIELDS: Record = { + schema_version: (value) => value === "1.0.0", + meta: isMeta, + sourceSnapshot: isSourceSnapshot, + forwardSummary: isForwardSummary, + ivSmiles: isIvSmiles, + smileDiagnostics: isSmileDiagnostics, + probabilities: isProbabilities, + terminalDistribution: isTerminalDistribution, + skewTail: isSkewTail, + moveNeeded: isPanelWithRows, + decayPressure: isPanelWithRows, + richCheap: isPanelWithRows, + quoteQuality: isQuoteQuality, + historyPreview: isPanelWithRows +}; + +export function isExperimentalAnalytics(payload: unknown): payload is ExperimentalAnalytics { + if (!isRecord(payload)) { + return false; + } + + return Object.entries(TOP_LEVEL_FIELDS).every(([field, isValid]) => hasValidField(payload, field, isValid)); +} + +export async function loadClientExperimentalAnalytics( + options: LoadClientExperimentalAnalyticsOptions = {} +): Promise { + return loadExperimentalAnalytics(EXPERIMENTAL_LATEST_PATH, options); +} + +export async function loadClientReplayExperimentalAnalytics( + request: ExperimentalReplayRequest, + options: LoadClientExperimentalAnalyticsOptions = {} +): Promise { + const params = new URLSearchParams({ session_id: request.session_id }); + + if (request.at) { + params.set("at", request.at); + } + + if (request.source_snapshot_id) { + params.set("source_snapshot_id", request.source_snapshot_id); + } + + return loadExperimentalAnalytics(`${EXPERIMENTAL_REPLAY_PATH}?${params.toString()}`, options); +} + +async function loadExperimentalAnalytics( + path: string, + options: LoadClientExperimentalAnalyticsOptions +): Promise { + const fetcher = options.fetcher ?? fetch; + + try { + const response = await fetcher(path, { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + return isExperimentalAnalytics(payload) ? payload : null; + } catch { + return null; + } +} + +function isMeta(value: unknown): value is ExperimentalAnalytics["meta"] { + if (!isRecord(value)) { + return false; + } + + return ( + hasValidField(value, "generatedAt", isString) && + hasValidField(value, "mode", (fieldValue) => isOneOf(fieldValue, ["latest", "replay"])) && + hasValidField(value, "sourceSessionId", isNonEmptyString) && + hasValidField(value, "sourceSnapshotTime", isString) && + hasValidField(value, "symbol", (fieldValue) => fieldValue === "SPX") && + hasValidField(value, "expiry", isString) + ); +} + +function isSourceSnapshot(value: unknown): value is ExperimentalAnalytics["sourceSnapshot"] { + if (!isRecord(value)) { + return false; + } + + return ( + hasValidField(value, "spot", isNumber) && + hasValidField(value, "forward", isNumber) && + hasValidField(value, "rowCount", isNonNegativeInteger) && + hasValidField(value, "strikeCount", isNonNegativeInteger) && + hasValidField(value, "timeToExpiryYears", isNonNegativeNumber) + ); +} + +function isForwardSummary(value: unknown): value is ExperimentalAnalytics["forwardSummary"] { + return ( + isPanel(value) && + hasValidField(value, "parityForward", isNullableNumber) && + hasValidField(value, "forwardMinusSpot", isNullableNumber) && + hasValidField(value, "atmStrike", isNullableNumber) && + hasValidField(value, "atmStraddle", isNullableNumber) && + hasValidField(value, "expectedRange", isNullableExpectedRange) && + hasValidField(value, "expectedMovePercent", isNullableNumber) + ); +} + +function isIvSmiles(value: unknown): value is ExperimentalAnalytics["ivSmiles"] { + return isPanel(value) && hasValidField(value, "methods", isIvSmileMethods); +} + +function isIvSmileMethods(value: unknown): value is ExperimentalAnalytics["ivSmiles"]["methods"] { + return Array.isArray(value) && value.every((method) => { + return ( + isRecord(method) && + hasValidField(method, "key", isNonEmptyString) && + hasValidField(method, "label", isNonEmptyString) && + hasValidField(method, "status", isPanelStatus) && + hasValidField(method, "points", isPoints) + ); + }); +} + +function isSmileDiagnostics(value: unknown): value is ExperimentalAnalytics["smileDiagnostics"] { + return ( + isPanel(value) && + hasValidField(value, "ivValley", isStrikeValue) && + hasValidField(value, "atmForwardIv", isNullableNumber) && + hasValidField(value, "skewSlope", isNullableNumber) && + hasValidField(value, "curvature", isNullableNumber) && + hasValidField(value, "methodDisagreement", isNullableNumber) + ); +} + +function isProbabilities(value: unknown): value is ExperimentalAnalytics["probabilities"] { + return isPanel(value) && hasValidField(value, "levels", isProbabilityLevels); +} + +function isProbabilityLevels(value: unknown): value is ExperimentalAnalytics["probabilities"]["levels"] { + return Array.isArray(value) && value.every((level) => { + return ( + isRecord(level) && + hasValidField(level, "strike", isNumber) && + hasValidField(level, "closeAbove", isNullableNumber) && + hasValidField(level, "closeBelow", isNullableNumber) + ); + }); +} + +function isTerminalDistribution(value: unknown): value is ExperimentalAnalytics["terminalDistribution"] { + return ( + isPanel(value) && + hasValidField(value, "density", isPoints) && + hasValidField(value, "highestDensityZone", isNullableString) && + hasValidField(value, "range68", isNullableString) && + hasValidField(value, "range95", isNullableString) && + hasValidField(value, "leftTailProbability", isNullableNumber) && + hasValidField(value, "rightTailProbability", isNullableNumber) + ); +} + +function isSkewTail(value: unknown): value is ExperimentalAnalytics["skewTail"] { + return ( + isPanel(value) && + hasValidField(value, "tailBias", isNullableString) && + hasValidField(value, "leftTailRichness", isNullableNumber) && + hasValidField(value, "rightTailRichness", isNullableNumber) + ); +} + +function isPanelWithRows(value: unknown): value is ExperimentalAnalytics["moveNeeded"] { + return isPanel(value) && hasValidField(value, "rows", isPanelRows); +} + +function isQuoteQuality(value: unknown): value is ExperimentalAnalytics["quoteQuality"] { + return ( + isPanel(value) && + hasValidField(value, "score", isNormalizedNumber) && + hasValidField(value, "flags", isQuoteQualityFlags) + ); +} + +function isQuoteQualityFlags(value: unknown): value is ExperimentalAnalytics["quoteQuality"]["flags"] { + return Array.isArray(value) && value.every((flag) => { + return ( + isRecord(flag) && + hasValidField(flag, "strike", isNumber) && + hasValidField(flag, "right", (fieldValue) => isOneOf(fieldValue, ["call", "put", "pair"])) && + hasValidField(flag, "code", isNonEmptyString) && + hasValidField(flag, "message", isNonEmptyString) + ); + }); +} + +function isPanel(value: unknown): value is { + status: ExperimentalAnalytics["forwardSummary"]["status"]; + label: string; + diagnostics: ExperimentalAnalytics["forwardSummary"]["diagnostics"]; +} & Record { + if (!isRecord(value)) { + return false; + } + + return ( + hasValidField(value, "status", isPanelStatus) && + hasValidField(value, "label", isNonEmptyString) && + hasValidField(value, "diagnostics", isDiagnostics) + ); +} + +function isDiagnostics(value: unknown): value is ExperimentalAnalytics["forwardSummary"]["diagnostics"] { + return Array.isArray(value) && value.every((diagnostic) => { + return ( + isRecord(diagnostic) && + hasValidField(diagnostic, "code", isNonEmptyString) && + hasValidField(diagnostic, "message", isNonEmptyString) && + hasValidField(diagnostic, "severity", (fieldValue) => isOneOf(fieldValue, ["info", "warning", "error"])) + ); + }); +} + +function isPoints(value: unknown): value is ExperimentalAnalytics["ivSmiles"]["methods"][number]["points"] { + return Array.isArray(value) && value.every((point) => { + return ( + isRecord(point) && + hasValidField(point, "x", isNumber) && + hasValidField(point, "y", isNullableNumber) + ); + }); +} + +function isStrikeValue(value: unknown): value is ExperimentalAnalytics["smileDiagnostics"]["ivValley"] { + if (!isRecord(value)) { + return false; + } + + return ( + hasValidField(value, "strike", isNullableNumber) && + hasValidField(value, "value", isNullableNumber) && + hasValidField(value, "label", isNullableString) + ); +} + +function isPanelRows(value: unknown): value is ExperimentalAnalytics["moveNeeded"]["rows"] { + return Array.isArray(value) && value.every((row) => { + return isRecord(row) && hasValidField(row, "strike", isNumber); + }); +} + +function isNullableExpectedRange(value: unknown): value is ExperimentalAnalytics["forwardSummary"]["expectedRange"] { + if (value === null) { + return true; + } + + return ( + isRecord(value) && + hasValidField(value, "lower", isNumber) && + hasValidField(value, "upper", isNumber) + ); +} + +function hasValidField(record: Record, field: string, isValid: Validator): boolean { + return Object.hasOwn(record, field) && isValid(record[field]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isNonEmptyString(value: unknown): value is string { + return isString(value) && value.length > 0; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || isString(value); +} + +function isNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || isNumber(value); +} + +function isNonNegativeNumber(value: unknown): value is number { + return isNumber(value) && value >= 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return isNumber(value) && Number.isInteger(value) && value >= 0; +} + +function isNormalizedNumber(value: unknown): value is number { + return isNumber(value) && value >= 0 && value <= 1; +} + +function isPanelStatus(value: unknown): value is ExperimentalAnalytics["forwardSummary"]["status"] { + return isOneOf(value, PANEL_STATUSES); +} + +function isOneOf(value: unknown, allowedValues: readonly T[]): value is T { + return typeof value === "string" && allowedValues.includes(value as T); +} diff --git a/apps/web/lib/contracts.ts b/apps/web/lib/contracts.ts index 9fa9e23..b6c4a9b 100644 --- a/apps/web/lib/contracts.ts +++ b/apps/web/lib/contracts.ts @@ -1 +1,2 @@ export type { AnalyticsSnapshot } from "@gammascope/contracts/analytics-snapshot"; +export type { ExperimentalAnalytics } from "@gammascope/contracts/experimental-analytics"; diff --git a/apps/web/lib/serverExperimentalAnalyticsSource.ts b/apps/web/lib/serverExperimentalAnalyticsSource.ts new file mode 100644 index 0000000..3e2c2e6 --- /dev/null +++ b/apps/web/lib/serverExperimentalAnalyticsSource.ts @@ -0,0 +1,48 @@ +import seed from "../../../packages/contracts/fixtures/experimental-analytics.seed.json"; +import { isExperimentalAnalytics } from "./clientExperimentalAnalyticsSource"; +import type { ExperimentalAnalytics } from "./contracts"; + +const EXPERIMENTAL_LATEST_PROXY_PATH = "/api/spx/0dte/experimental/latest"; + +const seedExperimentalAnalytics = seed as ExperimentalAnalytics; + +export async function loadLatestExperimentalAnalytics( + fetcher: typeof fetch = fetch, + requestHeaders?: Pick +): Promise { + try { + const response = await fetcher(sameOriginProxyUrl(requestHeaders), { + cache: "no-store", + headers: proxyRequestHeaders(requestHeaders) + }); + + if (!response.ok) { + return seedExperimentalAnalytics; + } + + const payload = await response.json(); + return isExperimentalAnalytics(payload) ? payload : seedExperimentalAnalytics; + } catch { + return seedExperimentalAnalytics; + } +} + +function sameOriginProxyUrl(requestHeaders?: Pick): string { + const host = requestHeaders?.get("x-forwarded-host") ?? requestHeaders?.get("host") ?? "localhost:3000"; + const protocol = requestHeaders?.get("x-forwarded-proto") ?? "http"; + + return `${protocol}://${host}${EXPERIMENTAL_LATEST_PROXY_PATH}`; +} + +function proxyRequestHeaders(requestHeaders?: Pick): HeadersInit { + const headers: Record = { + Accept: "application/json" + }; + const cookie = requestHeaders?.get("cookie"); + + if (cookie) { + headers.Cookie = cookie; + } + + return headers; +} diff --git a/apps/web/tests/clientExperimentalAnalyticsSource.test.ts b/apps/web/tests/clientExperimentalAnalyticsSource.test.ts new file mode 100644 index 0000000..74b2189 --- /dev/null +++ b/apps/web/tests/clientExperimentalAnalyticsSource.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import seed from "../../../packages/contracts/fixtures/experimental-analytics.seed.json"; +import { + isExperimentalAnalytics, + loadClientExperimentalAnalytics, + loadClientReplayExperimentalAnalytics, + type ExperimentalReplayRequest +} from "../lib/clientExperimentalAnalyticsSource"; +import type { ExperimentalAnalytics } from "../lib/contracts"; + +const seedPayload = seed as ExperimentalAnalytics; + +function jsonResponse(payload: unknown, ok = true): Response { + return { + ok, + json: async () => payload + } as Response; +} + +describe("isExperimentalAnalytics", () => { + it("accepts the seeded experimental analytics fixture", () => { + expect(isExperimentalAnalytics(seedPayload)).toBe(true); + }); + + it("rejects malformed panel shapes", () => { + expect(isExperimentalAnalytics({ + ...seedPayload, + forwardSummary: { + ...seedPayload.forwardSummary, + diagnostics: "not-an-array" + } + })).toBe(false); + + expect(isExperimentalAnalytics({ + ...seedPayload, + ivSmiles: { + ...seedPayload.ivSmiles, + methods: [{ ...seedPayload.ivSmiles.methods[0]!, points: "not-an-array" }] + } + })).toBe(false); + + expect(isExperimentalAnalytics({ + ...seedPayload, + quoteQuality: { + ...seedPayload.quoteQuality, + score: Number.NaN + } + })).toBe(false); + }); + + it("rejects nonfinite source snapshot fields", () => { + expect(isExperimentalAnalytics({ + ...seedPayload, + sourceSnapshot: { + ...seedPayload.sourceSnapshot, + spot: Number.POSITIVE_INFINITY + } + })).toBe(false); + }); +}); + +describe("loadClientExperimentalAnalytics", () => { + it("loads latest experimental analytics from the relative API route without caching", async () => { + const fetcher = vi.fn(async () => jsonResponse(seedPayload)); + + await expect(loadClientExperimentalAnalytics({ fetcher })).resolves.toBe(seedPayload); + + expect(fetcher).toHaveBeenCalledWith("/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + + it("returns null for fetch exceptions, non-OK responses, and invalid payloads", async () => { + await expect(loadClientExperimentalAnalytics({ + fetcher: vi.fn(async () => { + throw new Error("offline"); + }) + })).resolves.toBeNull(); + + await expect(loadClientExperimentalAnalytics({ + fetcher: vi.fn(async () => jsonResponse({ error: "unavailable" }, false)) + })).resolves.toBeNull(); + + await expect(loadClientExperimentalAnalytics({ + fetcher: vi.fn(async () => jsonResponse({ ...seedPayload, schema_version: "0.0.0" })) + })).resolves.toBeNull(); + }); +}); + +describe("loadClientReplayExperimentalAnalytics", () => { + it("loads replay experimental analytics with session id and optional selectors", async () => { + const fetcher = vi.fn(async () => jsonResponse({ + ...seedPayload, + meta: { + ...seedPayload.meta, + mode: "replay" + } + })); + const request: ExperimentalReplayRequest = { + session_id: "session/a", + at: "2026-04-23T15:40:00Z", + source_snapshot_id: "snapshot-a" + }; + + await loadClientReplayExperimentalAnalytics(request, { fetcher }); + + expect(fetcher).toHaveBeenCalledWith( + "/api/spx/0dte/experimental/replay/snapshot?session_id=session%2Fa&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snapshot-a", + { + cache: "no-store", + headers: { + Accept: "application/json" + } + } + ); + }); + + it("omits absent replay query selectors", async () => { + const fetcher = vi.fn(async () => jsonResponse(seedPayload)); + + await loadClientReplayExperimentalAnalytics({ session_id: "session-a" }, { fetcher }); + + expect(fetcher).toHaveBeenCalledWith("/api/spx/0dte/experimental/replay/snapshot?session_id=session-a", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + + it("returns null for fetch exceptions, non-OK responses, and invalid payloads", async () => { + await expect(loadClientReplayExperimentalAnalytics({ session_id: "session-a" }, { + fetcher: vi.fn(async () => { + throw new Error("offline"); + }) + })).resolves.toBeNull(); + + await expect(loadClientReplayExperimentalAnalytics({ session_id: "session-a" }, { + fetcher: vi.fn(async () => jsonResponse({ error: "unavailable" }, false)) + })).resolves.toBeNull(); + + await expect(loadClientReplayExperimentalAnalytics({ session_id: "session-a" }, { + fetcher: vi.fn(async () => jsonResponse({ ...seedPayload, sourceSnapshot: { ...seedPayload.sourceSnapshot, rowCount: -1 } })) + })).resolves.toBeNull(); + }); +}); diff --git a/apps/web/tests/experimentalRoute.test.ts b/apps/web/tests/experimentalRoute.test.ts new file mode 100644 index 0000000..214d3fe --- /dev/null +++ b/apps/web/tests/experimentalRoute.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +function textResponse(body: string, init: ResponseInit = {}): Response { + return new Response(body, { + status: init.status ?? 200, + headers: { + "Content-Type": "application/json", + ...init.headers + } + }); +} + +describe("GET /api/spx/0dte/experimental/latest", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("proxies latest experimental analytics and preserves upstream body, status, and content type", async () => { + const fetcher = vi.fn(async () => textResponse(JSON.stringify({ ok: true }), { + status: 202, + headers: { + "Content-Type": "application/vnd.gammascope.experimental+json" + } + })); + vi.stubGlobal("fetch", fetcher); + vi.stubEnv("GAMMASCOPE_API_BASE_URL", "http://fastapi.test/"); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + const response = await GET(new Request("http://localhost/api/spx/0dte/experimental/latest")); + + expect(response.status).toBe(202); + await expect(response.text()).resolves.toBe(JSON.stringify({ ok: true })); + expect(response.headers.get("Content-Type")).toBe("application/vnd.gammascope.experimental+json"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(fetcher).toHaveBeenCalledWith("http://fastapi.test/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + + it("uses the default FastAPI base URL for latest experimental analytics", async () => { + const fetcher = vi.fn(async () => textResponse("{}")); + vi.stubGlobal("fetch", fetcher); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + await GET(new Request("http://localhost/api/spx/0dte/experimental/latest")); + + expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + + it("returns no-store 502 JSON when the latest upstream fetch fails", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new Error("offline"); + })); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + const response = await GET(new Request("http://localhost/api/spx/0dte/experimental/latest")); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ error: "Experimental analytics unavailable" }); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("GET /api/spx/0dte/experimental/replay/snapshot", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("forwards replay experimental query parameters and preserves upstream text response", async () => { + const fetcher = vi.fn(async () => textResponse("accepted", { + status: 202, + headers: { + "Content-Type": "text/plain" + } + })); + vi.stubGlobal("fetch", fetcher); + + const { GET } = await import("../app/api/spx/0dte/experimental/replay/snapshot/route"); + const response = await GET(new Request( + "http://localhost/api/spx/0dte/experimental/replay/snapshot?session_id=session%2Fa&at=2026-04-23T15:40:00Z&source_snapshot_id=snapshot-a&ignored=1" + )); + + expect(response.status).toBe(202); + await expect(response.text()).resolves.toBe("accepted"); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(fetcher).toHaveBeenCalledWith( + "http://127.0.0.1:8000/api/spx/0dte/experimental/replay/snapshot?session_id=session%2Fa&at=2026-04-23T15%3A40%3A00Z&source_snapshot_id=snapshot-a", + { + cache: "no-store", + headers: { + Accept: "application/json" + } + } + ); + }); + + it("omits absent replay experimental query parameters", async () => { + const fetcher = vi.fn(async () => textResponse("{}")); + vi.stubGlobal("fetch", fetcher); + + const { GET } = await import("../app/api/spx/0dte/experimental/replay/snapshot/route"); + await GET(new Request("http://localhost/api/spx/0dte/experimental/replay/snapshot?session_id=session-a")); + + expect(fetcher).toHaveBeenCalledWith( + "http://127.0.0.1:8000/api/spx/0dte/experimental/replay/snapshot?session_id=session-a", + { + cache: "no-store", + headers: { + Accept: "application/json" + } + } + ); + }); + + it("returns no-store 502 JSON when the replay upstream fetch fails", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new Error("offline"); + })); + + const { GET } = await import("../app/api/spx/0dte/experimental/replay/snapshot/route"); + const response = await GET(new Request( + "http://localhost/api/spx/0dte/experimental/replay/snapshot?session_id=session-a" + )); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ error: "Experimental replay analytics unavailable" }); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); +}); diff --git a/apps/web/tests/serverExperimentalAnalyticsSource.test.ts b/apps/web/tests/serverExperimentalAnalyticsSource.test.ts new file mode 100644 index 0000000..d99df31 --- /dev/null +++ b/apps/web/tests/serverExperimentalAnalyticsSource.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import seed from "../../../packages/contracts/fixtures/experimental-analytics.seed.json"; +import { loadLatestExperimentalAnalytics } from "../lib/serverExperimentalAnalyticsSource"; +import type { ExperimentalAnalytics } from "../lib/contracts"; + +const seedPayload = seed as ExperimentalAnalytics; + +describe("loadLatestExperimentalAnalytics", () => { + it("loads latest experimental analytics from the same-origin proxy URL", async () => { + const payload: ExperimentalAnalytics = { + ...seedPayload, + meta: { + ...seedPayload.meta, + sourceSessionId: "api-session" + } + }; + const fetcher = vi.fn(async () => new Response(JSON.stringify(payload), { + status: 200, + headers: { + "Content-Type": "application/json" + } + })); + + await expect(loadLatestExperimentalAnalytics(fetcher as typeof fetch, new Headers({ + "x-forwarded-host": "gamma.example", + "x-forwarded-proto": "https" + }))).resolves.toEqual(payload); + + expect(fetcher).toHaveBeenCalledWith("https://gamma.example/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + + it("forwards the request cookie when present", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify(seedPayload), { + status: 200, + headers: { + "Content-Type": "application/json" + } + })); + + await loadLatestExperimentalAnalytics(fetcher as typeof fetch, new Headers({ + host: "gamma.local", + cookie: "session=abc" + })); + + expect(fetcher).toHaveBeenCalledWith("http://gamma.local/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json", + Cookie: "session=abc" + } + }); + }); + + it("falls back to the seed payload for fetch failures, non-OK responses, and invalid payloads", async () => { + await expect(loadLatestExperimentalAnalytics(vi.fn(async () => { + throw new Error("offline"); + }) as typeof fetch)).resolves.toEqual(seedPayload); + + await expect(loadLatestExperimentalAnalytics(vi.fn(async () => new Response(JSON.stringify({ error: "unavailable" }), { + status: 503 + })) as typeof fetch)).resolves.toEqual(seedPayload); + + await expect(loadLatestExperimentalAnalytics(vi.fn(async () => new Response(JSON.stringify({ + ...seedPayload, + quoteQuality: { + ...seedPayload.quoteQuality, + flags: "not-an-array" + } + }), { + status: 200 + })) as typeof fetch)).resolves.toEqual(seedPayload); + }); +}); From 6fedbcf85db88db90b80c9056a2a5407456aea70 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:11:58 -0700 Subject: [PATCH 17/23] fix: enforce experimental api response safety --- .../gammascope_api/experimental/service.py | 180 +++++++++++++++++- .../api/gammascope_api/routes/experimental.py | 9 +- apps/api/tests/test_experimental_routes.py | 37 ++++ apps/api/tests/test_experimental_service.py | 22 +++ 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/apps/api/gammascope_api/experimental/service.py b/apps/api/gammascope_api/experimental/service.py index d67bc0b..bcde39e 100644 --- a/apps/api/gammascope_api/experimental/service.py +++ b/apps/api/gammascope_api/experimental/service.py @@ -2,8 +2,10 @@ 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 @@ -70,7 +72,7 @@ def build_experimental_payload(snapshot: Any, mode: ExperimentalMode) -> dict[st lambda: _empty_quote_quality_panel("Quote quality could not be built from available inputs."), ) - return { + payload = { "schema_version": "1.0.0", "meta": { "generatedAt": _format_datetime(datetime.now(UTC)), @@ -99,6 +101,12 @@ def build_experimental_payload(snapshot: Any, mode: ExperimentalMode) -> dict[st "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]: @@ -234,6 +242,176 @@ def _first_finite(*values: Any, default: float) -> float: 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", diff --git a/apps/api/gammascope_api/routes/experimental.py b/apps/api/gammascope_api/routes/experimental.py index 9e24a62..d2293a7 100644 --- a/apps/api/gammascope_api/routes/experimental.py +++ b/apps/api/gammascope_api/routes/experimental.py @@ -3,7 +3,8 @@ from fastapi import APIRouter, Header from gammascope_api.auth import can_read_live_state -from gammascope_api.experimental.service import build_experimental_payload +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_live_snapshot @@ -13,16 +14,16 @@ router = APIRouter() -@router.get("/api/spx/0dte/experimental/latest") +@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_live_snapshot(cached_or_memory_collector_state()) if live_snapshot is not None: return build_experimental_payload(live_snapshot, "latest") - return load_json_fixture("experimental-analytics.seed.json") + return validate_experimental_payload(load_json_fixture("experimental-analytics.seed.json")) -@router.get("/api/spx/0dte/experimental/replay/snapshot") +@router.get("/api/spx/0dte/experimental/replay/snapshot", response_model=ExperimentalAnalytics) def get_replay_experimental_snapshot( session_id: str, at: str | None = None, diff --git a/apps/api/tests/test_experimental_routes.py b/apps/api/tests/test_experimental_routes.py index a2204e6..382ce36 100644 --- a/apps/api/tests/test_experimental_routes.py +++ b/apps/api/tests/test_experimental_routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +from fastapi.routing import APIRoute from fastapi.testclient import TestClient from gammascope_api.contracts.generated.experimental_analytics import ExperimentalAnalytics @@ -28,6 +29,17 @@ 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") @@ -91,3 +103,28 @@ def malformed_replay_snapshot(*_args, **_kwargs): # type: ignore[no-untyped-def 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 diff --git a/apps/api/tests/test_experimental_service.py b/apps/api/tests/test_experimental_service.py index 659fefd..031e9ac 100644 --- a/apps/api/tests/test_experimental_service.py +++ b/apps/api/tests/test_experimental_service.py @@ -1,6 +1,7 @@ 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 @@ -83,3 +84,24 @@ def raise_panel_error(*_args, **_kwargs): # type: ignore[no-untyped-def] 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 From ecb9f390b91370e1e35c39829819e56bb1f46d23 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:19:22 -0700 Subject: [PATCH 18/23] fix: forward admin token for experimental latest proxy --- .../api/spx/0dte/experimental/latest/route.ts | 21 ++++++-- apps/web/tests/experimentalRoute.test.ts | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/app/api/spx/0dte/experimental/latest/route.ts b/apps/web/app/api/spx/0dte/experimental/latest/route.ts index e798a78..659e3d0 100644 --- a/apps/web/app/api/spx/0dte/experimental/latest/route.ts +++ b/apps/web/app/api/spx/0dte/experimental/latest/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; +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}`; @@ -13,15 +15,26 @@ function noStoreJson(payload: unknown, init?: ResponseInit) { return response; } -export async function GET(_request: Request): Promise { +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: { - Accept: "application/json" - } + headers: upstreamHeaders(request) }); const response = new Response(await upstreamResponse.text(), { diff --git a/apps/web/tests/experimentalRoute.test.ts b/apps/web/tests/experimentalRoute.test.ts index 214d3fe..faa8e30 100644 --- a/apps/web/tests/experimentalRoute.test.ts +++ b/apps/web/tests/experimentalRoute.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +const ADMIN_ENV = { + GAMMASCOPE_WEB_ADMIN_USERNAME: "admin", + GAMMASCOPE_WEB_ADMIN_PASSWORD: "correct-horse-battery-staple", + GAMMASCOPE_WEB_ADMIN_SESSION_SECRET: "test-session-secret-with-enough-entropy", + GAMMASCOPE_ADMIN_TOKEN: "upstream-admin-token" +} as const; + function textResponse(body: string, init: ResponseInit = {}): Response { return new Response(body, { status: init.status ?? 200, @@ -10,6 +17,13 @@ function textResponse(body: string, init: ResponseInit = {}): Response { }); } +function setAdminEnv() { + vi.stubEnv("GAMMASCOPE_WEB_ADMIN_USERNAME", ADMIN_ENV.GAMMASCOPE_WEB_ADMIN_USERNAME); + vi.stubEnv("GAMMASCOPE_WEB_ADMIN_PASSWORD", ADMIN_ENV.GAMMASCOPE_WEB_ADMIN_PASSWORD); + vi.stubEnv("GAMMASCOPE_WEB_ADMIN_SESSION_SECRET", ADMIN_ENV.GAMMASCOPE_WEB_ADMIN_SESSION_SECRET); + vi.stubEnv("GAMMASCOPE_ADMIN_TOKEN", ADMIN_ENV.GAMMASCOPE_ADMIN_TOKEN); +} + describe("GET /api/spx/0dte/experimental/latest", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -57,6 +71,45 @@ describe("GET /api/spx/0dte/experimental/latest", () => { }); }); + it("forwards the upstream admin token when the web admin session is valid", async () => { + setAdminEnv(); + const { ADMIN_COOKIE_NAME, createAdminSessionValue } = await import("../lib/adminSession"); + const sessionValue = createAdminSessionValue(); + const fetcher = vi.fn(async () => textResponse("{}")); + vi.stubGlobal("fetch", fetcher); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + await GET(new Request("http://localhost/api/spx/0dte/experimental/latest", { + headers: { + Cookie: `${ADMIN_COOKIE_NAME}=${encodeURIComponent(sessionValue)}` + } + })); + + expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json", + "X-GammaScope-Admin-Token": ADMIN_ENV.GAMMASCOPE_ADMIN_TOKEN + } + }); + }); + + it("does not forward the upstream admin token when the request is unauthenticated", async () => { + setAdminEnv(); + const fetcher = vi.fn(async () => textResponse("{}")); + vi.stubGlobal("fetch", fetcher); + + const { GET } = await import("../app/api/spx/0dte/experimental/latest/route"); + await GET(new Request("http://localhost/api/spx/0dte/experimental/latest")); + + expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:8000/api/spx/0dte/experimental/latest", { + cache: "no-store", + headers: { + Accept: "application/json" + } + }); + }); + it("returns no-store 502 JSON when the latest upstream fetch fails", async () => { vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("offline"); From ff7364676603e5174b3c67d1b59d33a7586944d0 Mon Sep 17 00:00:00 2001 From: Sakura <32687351+zifanzhou1024@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:26:58 -0700 Subject: [PATCH 19/23] feat: add experimental analytics dashboard --- apps/web/app/experimental/page.tsx | 9 + apps/web/app/styles.css | 454 ++++++++++++++++++ apps/web/components/DashboardView.tsx | 3 + apps/web/components/ExperimentalDashboard.tsx | 124 +++++ apps/web/components/ExposureHeatmap.tsx | 32 +- .../experimental/ExperimentalPanel.tsx | 51 ++ .../experimental/ExperimentalSmileChart.tsx | 162 +++++++ .../ExperimentalSummaryPanels.tsx | 115 +++++ .../experimental/ExperimentalTables.tsx | 219 +++++++++ apps/web/tests/DashboardView.test.tsx | 8 + apps/web/tests/ExperimentalDashboard.test.tsx | 84 ++++ apps/web/tests/ExperimentalPage.test.tsx | 61 +++ apps/web/tests/ExposureHeatmap.test.tsx | 2 + 13 files changed, 1313 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/experimental/page.tsx create mode 100644 apps/web/components/ExperimentalDashboard.tsx create mode 100644 apps/web/components/experimental/ExperimentalPanel.tsx create mode 100644 apps/web/components/experimental/ExperimentalSmileChart.tsx create mode 100644 apps/web/components/experimental/ExperimentalSummaryPanels.tsx create mode 100644 apps/web/components/experimental/ExperimentalTables.tsx create mode 100644 apps/web/tests/ExperimentalDashboard.test.tsx create mode 100644 apps/web/tests/ExperimentalPage.test.tsx 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..13be32b 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,414 @@ 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: 260px; + min-height: 260px; + overflow: hidden; +} + +.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); +} + +.experimentalSeries { + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.experimentalSeries-blue { + stroke: var(--blue); +} + +.experimentalSeries-teal { + stroke: var(--teal); +} + +.experimentalSeries-violet { + stroke: var(--violet); +} + +.experimentalSeries-amber, +.experimentalSeries-distribution { + stroke: var(--amber); +} + +.experimentalLegend, +.experimentalDistributionStats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.experimentalLegend span, +.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; +} + +.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); +} + +.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 +3645,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 +3704,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 +3743,24 @@ 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"] .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 +3899,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)); } @@ -3573,6 +4026,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..fc66aec --- /dev/null +++ b/apps/web/components/ExperimentalDashboard.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { 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"; + +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); + + 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 + )} +
+ +
+
+ + {analytics ? ( + <> +
+
+ Session + {analytics.meta.sourceSessionId} +
+
+ Snapshot + {formatSnapshotTime(analytics.meta.sourceSnapshotTime)} +
+
+ Coverage + {analytics.sourceSnapshot.rowCount} rows / {analytics.sourceSnapshot.strikeCount} strikes +
+
+ Expiry + {analytics.meta.expiry} +
+
+ + {refreshError ? ( +
+ {refreshError} +
+ ) : null} + + + + + + ) : ( +
+

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..76f2326 --- /dev/null +++ b/apps/web/components/experimental/ExperimentalSmileChart.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { ExperimentalPanel } from "./ExperimentalPanel"; +import type { ExperimentalAnalytics } from "../../lib/contracts"; +import { 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; +}; + +const CHART_WIDTH = 640; +const CHART_HEIGHT = 260; +const PLOT = { + left: 44, + right: 18, + top: 18, + bottom: 38 +}; + +const SERIES_CLASSES = ["experimentalSeries-blue", "experimentalSeries-teal", "experimentalSeries-violet", "experimentalSeries-amber"]; + +export function ExperimentalSmileChart({ analytics }: ExperimentalSmileChartProps) { + return ( +
+ +
+ + + {analytics.ivSmiles.methods.map((method, index) => ( + entry.points)))} + fill="none" + /> + ))} + +
+
+ {analytics.ivSmiles.methods.map((method, index) => ( + +