diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7a44caa..eb80781 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,8 @@ +{"_type":"issue","id":"islandflow-sh1","title":"Fix live websocket stale lag and reconnect loop","description":"Investigate and fix API live consumer lag causing stale timestamps, feed-behind status, and reconnect loops. Optimize live cache persistence path, add lag telemetry/alerts, and validate in runtime.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T17:04:34Z","created_by":"dirtydishes","updated_at":"2026-05-04T17:09:44Z","started_at":"2026-05-04T17:04:38Z","closed_at":"2026-05-04T17:09:44Z","close_reason":"Completed: optimized live cache persistence path, added lag telemetry, deployed api via docker compose on di, verified ws freshness and low hotFeedLagMs","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-b3o","title":"Implement options tape table with execution spot","description":"Redesign OptionsPane into a dense classifier-colored table and preserve execution-time underlying spot on option prints from equity quote mid.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:41:59Z","created_by":"dirtydishes","updated_at":"2026-05-04T05:14:26Z","started_at":"2026-05-04T04:42:08Z","closed_at":"2026-05-04T05:14:26Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ug1","title":"Fix false NBBO-missing badges in live Options tape","description":"Investigate and fix client-side cases where Options rows show NBBO missing/stale even when a fresh NBBO quote exists in the live nbbo map. Update rendering logic to prefer fresh quote-derived status and add regression tests.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-29T15:58:31Z","created_by":"dirtydishes","updated_at":"2026-04-29T16:01:28Z","started_at":"2026-04-29T15:58:35Z","closed_at":"2026-04-29T16:01:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"dirtydishes","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.env.example b/.env.example index f86691a..8a9ead7 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,7 @@ REPLAY_LOG_EVERY=1000 LIVE_LIMIT_OPTIONS=10000 LIVE_LIMIT_NBBO=10000 LIVE_LIMIT_EQUITIES=10000 +LIVE_LIMIT_EQUITY_QUOTES=10000 LIVE_LIMIT_EQUITY_JOINS=10000 LIVE_LIMIT_FLOW=10000 LIVE_LIMIT_CLASSIFIER_HITS=10000 diff --git a/AGENTS.md b/AGENTS.md index 2899947..ecf3a15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,27 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + +## Minimal Repo Operating Instructions + +This is a Bun + TypeScript monorepo for an event-sourced market-data pipeline: +- Flow: ingest services publish to NATS/JetStream, compute/candles derive events, API serves REST/WS, web consumes live/replay streams. +- Main folders: `services/*` (runtime services), `packages/*` (shared libs/types/storage), `apps/web` (Next.js UI). +- Infra dependency: local dev assumes Docker services (NATS, ClickHouse, Redis) are available. + +Use these repo-specific commands: +- Install deps: `bun install` +- Start full stack: `bun run dev` +- Start infra only: `bun run dev:infra` +- Start backend services only: `bun run dev:services` +- Start web only: `bun run dev:web` + +Testing and validation in this repo are Bun-first: +- Run tests: `bun test` +- Run scoped tests: `bun test services/compute/tests` (or another package/service path) +- Validate web production build when UI code changes: `bun --cwd=apps/web run build` + +Working style that avoids common problems here: +- Prefer editing in the touched workspace (`services/`, `packages/`, `apps/web`) and keep shared contract changes in `packages/types`. +- Keep `.env` aligned with `.env.example`; adapters default to synthetic modes for local development. +- Dev runners persist child PID state in `.tmp/`; if a previous run crashed, restart via the standard `bun run dev*` commands so stale processes are cleaned up. diff --git a/README.md b/README.md index f07916b..b5720fa 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ Default `smart-money` policy rejects lower-information prints and keeps high-con | `LIVE_LIMIT_OPTIONS` | `10000` | In-memory/Redis live cache depth for options channel (clamped `1..100000`). | | `LIVE_LIMIT_NBBO` | `10000` | Live cache depth for options NBBO channel (clamped `1..100000`). | | `LIVE_LIMIT_EQUITIES` | `10000` | Live cache depth for equities channel (clamped `1..100000`). | +| `LIVE_LIMIT_EQUITY_QUOTES` | `10000` | Live cache depth for equity quotes channel (clamped `1..100000`). | | `LIVE_LIMIT_EQUITY_JOINS` | `10000` | Live cache depth for equity join channel (clamped `1..100000`). | | `LIVE_LIMIT_FLOW` | `10000` | Live cache depth for flow packet channel (clamped `1..100000`). | | `LIVE_LIMIT_CLASSIFIER_HITS` | `10000` | Live cache depth for classifier hits channel (clamped `1..100000`). | @@ -303,7 +304,10 @@ Default `smart-money` policy rejects lower-information prints and keeps high-con - `view=raw` — audit/debug path that preserves every stored print. - The default Tape page options/packets posture is now stock-only, hides `B` / `BB`, keeps calls and puts visible, and applies in-memory min-notional controls immediately. - Live retention uses a two-tier model: - - API/Redis maintain a bounded hot cache per live generic channel. + - ClickHouse is durable server history; Redis is a bounded hot cache per live generic channel. + - `LIVE_LIMIT_*` controls initial snapshot/hot-cache depth, not total persisted history. + - Browser state is only a rendering window and UI preferences, not a market-data database. + - Devices connected to the same API hydrate from the same server-seen history. - UI keeps a bounded hot window for rendering performance around the signal view rather than raw noise. - Options prints can use a deeper dedicated cap via `NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS` without raising every other feed. - Alert/drawer evidence is pinned and hydrated by id/trace so details remain inspectable after hot-window eviction. diff --git a/apps/web/app/charts/page.tsx b/apps/web/app/charts/page.tsx index b1b8870..9d82bba 100644 --- a/apps/web/app/charts/page.tsx +++ b/apps/web/app/charts/page.tsx @@ -1,5 +1,7 @@ -import { ChartsRoute } from "../terminal"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/frontend-cooker/frontend-cooker.module.css b/apps/web/app/frontend-cooker/frontend-cooker.module.css new file mode 100644 index 0000000..34df997 --- /dev/null +++ b/apps/web/app/frontend-cooker/frontend-cooker.module.css @@ -0,0 +1,2 @@ +.cookerShell{min-height:100vh;display:grid;grid-template-columns:280px 1fr;background:#080806;color:#f4efe3}.chrome{position:sticky;top:0;height:100vh;padding:18px;display:flex;flex-direction:column;gap:22px;background:#111;border-right:1px solid #333;z-index:5}.chrome p{margin:0 0 8px;color:#d6a84f;text-transform:uppercase;letter-spacing:.18em;font-size:12px}.chrome h2{margin:0 0 8px;font-family:Georgia,serif;font-size:28px;line-height:1}.chrome small,.chrome footer{color:#aaa;line-height:1.45}.chrome footer{margin-top:auto;font-size:12px}.switcher{display:grid;gap:9px}.switcher button{display:grid;grid-template-columns:28px 1fr;gap:10px;align-items:center;text-align:left;padding:10px;border:1px solid #333;border-radius:14px;background:#191919;color:#ddd;cursor:pointer;transition:.18s}.switcher button:hover,.switcher .active{transform:translateX(3px);border-color:#d6a84f;background:#272111}.switcher b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:#333;color:#fff}.mock{min-height:100vh;padding:28px;font-family:var(--body,serif);transition:background .25s,color .25s}.productNav{display:flex;align-items:center;gap:18px;margin-bottom:28px}.productNav strong{margin-right:auto;letter-spacing:.16em}.productNav span{opacity:.75}.productNav button,.panelHead button{border:0;border-radius:999px;padding:10px 14px;cursor:pointer;background:var(--accent);color:var(--accentText)}.hero{display:grid;grid-template-columns:minmax(0,1.25fr)360px;gap:24px;align-items:stretch}.kicker{margin:0 0 10px;color:var(--accent);letter-spacing:.18em;text-transform:uppercase;font-size:12px}.hero h1{margin:0;font-family:var(--display,Georgia,serif);font-size:clamp(42px,6vw,92px);line-height:.9;letter-spacing:-.05em;text-transform:none}.copy{max-width:680px;font-size:18px;line-height:1.5;opacity:.78}.statusCard,.metrics article,.primaryPanel,.sidePanel,.tableWrap{border:1px solid var(--line);background:var(--panel);box-shadow:var(--shadow);border-radius:var(--radius)}.statusCard{padding:24px;font-size:15px}.statusCard b{display:block;margin:28px 0 4px;font-size:48px;font-family:var(--display)}.liveDot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#28d77f;box-shadow:0 0 18px #28d77f;margin-right:8px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.metrics article{padding:18px;font-weight:700}.workspace{display:grid;grid-template-columns:1.45fr .75fr;gap:18px}.primaryPanel,.sidePanel{padding:18px}.panelHead{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.panelHead h2,.sidePanel h2{margin:0;font-family:var(--display);font-size:24px}.chart{height:330px;position:relative;display:flex;align-items:flex-end;gap:1.8%;padding:24px;overflow:hidden;background:var(--chart);border-radius:calc(var(--radius) - 6px)}.chart i{flex:1;background:var(--bar);border-radius:99px 99px 0 0;animation:rise .7s both}.chart b{position:absolute;left:5%;right:5%;top:45%;height:3px;background:var(--accent);transform:rotate(-8deg);box-shadow:0 0 24px var(--accent)}.alert,.empty,.loading,.error{padding:14px;margin-top:12px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.06)}.loading{background:repeating-linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.08) 12px,transparent 12px,transparent 24px)}.error{color:#ffb1a8}.tableWrap{margin-top:18px;overflow:auto}.tableWrap table{width:100%;border-collapse:collapse}.tableWrap th,.tableWrap td{padding:14px 16px;border-bottom:1px solid var(--line);text-align:left}.tableWrap tr:hover td{background:rgba(255,255,255,.08)}@keyframes rise{from{transform:scaleY(.25);opacity:.2}to{transform:scaleY(1);opacity:1}} +.pit{--display:Impact,Haettenschweiler,'Arial Narrow Bold',sans-serif;--body:'Trebuchet MS',sans-serif;--accent:#ffb000;--accentText:#1a0c00;--line:#3e321b;--panel:#16120b;--chart:#080602;--bar:linear-gradient(#ffcf52,#b35b00);--radius:4px;--shadow:inset 0 0 0 1px #000,0 18px 0 rgba(0,0,0,.25);background:radial-gradient(circle at 70% -10%,#5d2500,transparent 35%),#0b0905;color:#fff0c9}.pit .productNav{border-bottom:6px solid #ffb000;padding-bottom:12px}.atlas{--display:'Didot','Bodoni 72',serif;--body:'Avenir Next',Verdana,sans-serif;--accent:#00b894;--accentText:#001b15;--line:rgba(16,80,70,.28);--panel:rgba(235,255,250,.68);--chart:linear-gradient(135deg,#dff9ef,#b8d6e5);--bar:#0b8874;--radius:28px;--shadow:0 30px 80px rgba(30,90,90,.16);background:linear-gradient(120deg,#eef8f3,#cbdde1);color:#17322f}.ledger{--display:'Iowan Old Style',Georgia,serif;--body:Georgia,serif;--accent:#8b3f1f;--accentText:#fff8ee;--line:#d8c7a9;--panel:#fffaf0;--chart:#f7ecd8;--bar:#1f3f35;--radius:0;--shadow:8px 8px 0 #d8c7a9;background:#f4ead8;color:#24190f}.ledger.mock,.ledger .tableWrap table{font-size:17px}.neon{--display:'Courier New',monospace;--body:'Courier New',monospace;--accent:#39ff14;--accentText:#001400;--line:#263cff;--panel:rgba(4,8,28,.82);--chart:#03040f;--bar:linear-gradient(#ff2bd6,#263cff);--radius:18px;--shadow:0 0 32px rgba(57,255,20,.2),inset 0 0 24px rgba(38,60,255,.18);background:linear-gradient(180deg,#050718,#110014);color:#d6fff4}.neon .hero h1{text-shadow:0 0 20px #ff2bd6}.paper{--display:'Franklin Gothic Medium','Arial Narrow',sans-serif;--body:'Times New Roman',serif;--accent:#c5281c;--accentText:#fff;--line:#111;--panel:#f8f1df;--chart:repeating-linear-gradient(0deg,#efe4cc,#efe4cc 14px,#e2d3b8 15px);--bar:#111;--radius:0;--shadow:none;background:#eee2c8;color:#111}.paper .productNav,.paper .hero,.paper .metrics{border-bottom:3px double #111;padding-bottom:14px}@media(max-width:900px){.cookerShell{grid-template-columns:1fr}.chrome{height:auto;position:relative}.switcher{grid-template-columns:repeat(2,1fr)}.hero,.workspace,.metrics{grid-template-columns:1fr}.productNav{flex-wrap:wrap}.mock{padding:18px}} \ No newline at end of file diff --git a/apps/web/app/frontend-cooker/page.tsx b/apps/web/app/frontend-cooker/page.tsx new file mode 100644 index 0000000..c985524 --- /dev/null +++ b/apps/web/app/frontend-cooker/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useMemo, useState } from "react"; +import styles from "./frontend-cooker.module.css"; + +const variations = [ + { id: "pit", name: "Open-Outcry Pit", rationale: "A loud exchange-floor command center optimized for immediate threat recognition and dense scan paths." }, + { id: "atlas", name: "Glass Atlas", rationale: "A calm geospatial intelligence room that makes flow feel mapped, layered, and explorable." }, + { id: "ledger", name: "Ivory Ledger", rationale: "A refined analyst notebook with editorial hierarchy for slower, higher-confidence review." }, + { id: "neon", name: "Neon Underpass", rationale: "A kinetic cyberpunk tape for traders who want momentum, heat, and speed above all." }, + { id: "paper", name: "Signal Gazette", rationale: "A newspaper-like briefing that turns raw options activity into a morning intelligence digest." } +]; + +const flowRows = [ + ["NVDA", "910C", "05-17", "$4.8M", "AA", "+92%", "Sweep"], + ["TSLA", "175P", "05-10", "$2.1M", "BB", "−68%", "ISO"], + ["AAPL", "205C", "06-21", "$1.4M", "A", "+41%", "Block"], + ["SPY", "520P", "05-03", "$8.7M", "B", "−53%", "Split"], + ["AMD", "162C", "05-24", "$910K", "AA", "+77%", "Sweep"] +]; + +function MiniChart({ variant }: { variant: string }) { + return
+ {Array.from({ length: 22 }).map((_, i) => )} + +
; +} + +function AppMock({ id }: { id: string }) { + return
+ +
+

Live Options Intelligence

Unusual flow surfaced before the crowd.

Representative redesign of the IslandFlow terminal: live status, option sweeps, inferred dark activity, classifier hits, and replay controls.

+
Connected · 1,284 msgs/min
$42.6M premium tracked in active window
+
+
{["Alert score 87", "Bullish 62%", "Dark pool 14", "Stale feeds 0"].map(x =>
{x}
)}
+
+

Flow Radar

+

Classifier Hits

High conviction: NVDA call sweep above ask with confirming equity print.
Empty state: no stale NBBO quotes in the last 15s.
Loading replay baseline…
Error state: dark inference source delayed.
+
+
{["Ticker", "Contract", "Expiry", "Notional", "Side", "Delta", "Condition"].map(h => )}{flowRows.map((r) => {r.map((c, i) => )})}
{h}
{c}
+
; +} + +export default function FrontendCooker() { + const [active, setActive] = useState(0); + const current = variations[active]; + const nav = useMemo(() => variations.slice(0, 5), []); + return
+ + +
; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3399a97..5af91c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -19,7 +19,7 @@ --blue: #4da3ff; --blue-soft: rgba(77, 163, 255, 0.14); --rail-width: 236px; - --topbar-height: 76px; + --topbar-height: 64px; } * { @@ -166,38 +166,16 @@ input { position: sticky; top: 0; z-index: 20; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 18px 24px; - padding: 16px 24px 14px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 10px 20px; background: rgba(7, 10, 14, 0.92); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); } -.feed-status-bar { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - min-width: 0; -} - -.feed-status { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 999px; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.02); - color: var(--text-dim); - font-family: var(--font-mono), monospace; - font-size: 0.75rem; -} - -.feed-status-dot, .status-dot, .chart-dot { width: 8px; @@ -206,28 +184,24 @@ input { background: var(--text-faint); } -.feed-status-connected .feed-status-dot, .status-connected .status-dot, .chart-status-connected .chart-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(37, 193, 122, 0.14); } -.feed-status-connecting .feed-status-dot, .chart-status-connecting .chart-dot { background: var(--accent); box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.12); animation: pulse 1.3s ease-in-out infinite; } -.feed-status-stale .feed-status-dot, .status-stale .status-dot, .chart-status-stale .chart-dot { background: var(--accent); box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.18); } -.feed-status-disconnected .feed-status-dot, .status-disconnected .status-dot, .chart-status-disconnected .chart-dot { background: var(--red); @@ -236,33 +210,37 @@ input { .terminal-topbar-actions { display: flex; - align-items: flex-end; + align-items: center; justify-content: flex-end; - gap: 20px; + gap: 12px; min-width: 0; + width: auto; + margin-left: auto; } .terminal-topbar-controls { display: flex; - align-items: flex-end; + align-items: center; justify-content: flex-end; - gap: 12px; + gap: 10px; min-width: 0; + flex: 0 1 auto; } .terminal-topbar-mode { display: flex; - align-items: flex-end; + align-items: center; justify-content: flex-end; flex: 0 0 auto; + margin-left: auto; } .terminal-filter { display: flex; flex-direction: column; - gap: 6px; - min-width: clamp(280px, 26vw, 420px); - flex: 0 1 clamp(280px, 26vw, 420px); + gap: 4px; + min-width: clamp(220px, 24vw, 360px); + flex: 0 1 clamp(220px, 24vw, 360px); } .terminal-filter-label { @@ -274,7 +252,7 @@ input { position: relative; display: flex; align-items: center; - min-height: 36px; + min-height: 32px; } .terminal-filter-field::before, @@ -308,7 +286,7 @@ input { .terminal-input { min-width: 0; width: 100%; - padding: 0 0 8px; + padding: 0 0 6px; border: none; border-radius: 0; background: transparent; @@ -358,8 +336,8 @@ input { .overlay-toggle, .drawer-close { border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 12px; + border-radius: 8px; + padding: 8px 10px; background: rgba(255, 255, 255, 0.03); color: var(--text); cursor: pointer; @@ -384,6 +362,59 @@ input { color: #ffd89a; } +.instrument-focus-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + max-width: min(360px, 32vw); + padding: 5px 8px 5px 10px; + border: 1px solid rgba(255, 216, 154, 0.34); + border-radius: 8px; + background: rgba(245, 166, 35, 0.08); + color: #ffe2aa; + font-family: var(--font-mono), monospace; + font-size: 0.72rem; + font-weight: 700; +} + +.instrument-focus-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.instrument-focus-chip button, +.instrument-cell-button { + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.instrument-focus-chip button { + padding: 4px 6px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.62rem; +} + +.instrument-cell-button { + padding: 0; + text-align: inherit; + text-decoration: underline; + text-decoration-color: rgba(255, 216, 154, 0.36); + text-underline-offset: 3px; +} + +.instrument-cell-button:hover, +.instrument-cell-button:focus-visible { + color: #ffd89a; + outline: none; +} + .pause-button { padding: 7px 10px; font-size: 0.66rem; @@ -391,7 +422,7 @@ input { .terminal-content { min-width: 0; - padding: 34px 24px 28px; + padding: 24px 24px 24px; } .page-shell { @@ -427,6 +458,27 @@ h3 { position: relative; } +.contract-filter-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + min-width: 0; + max-width: min(440px, 42vw); +} + +.contract-filter-button-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contract-filter-button.is-active { + border-color: rgba(245, 166, 35, 0.55); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.18), rgba(245, 166, 35, 0.07)); + color: #ffe2aa; +} + .flow-filter-popover { position: relative; } @@ -564,11 +616,10 @@ h3 { color: #ffe4b3; } -.overview-strip, .replay-matrix { display: grid; gap: 12px; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .overview-cell { @@ -585,8 +636,8 @@ h3 { align-items: stretch; } -.page-grid-overview { - grid-template-columns: repeat(3, minmax(0, 1fr)); +.page-grid-home { + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); } .page-grid-tape { @@ -608,7 +659,7 @@ h3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.page-grid-overview > :nth-child(1), +.page-grid-home > :nth-child(3), .page-grid-tape > :nth-child(1), .page-grid-replay > :nth-child(1) { grid-column: 1 / -1; @@ -784,11 +835,26 @@ h3 { } .missed-count { - width: 86px; + display: inline-flex; + align-items: center; + justify-content: flex-start; + max-width: 0; + overflow: hidden; + white-space: nowrap; font-size: 0.72rem; color: var(--accent); - text-align: right; - white-space: nowrap; + opacity: 0; + transform: translateX(6px); + transition: + max-width 180ms ease, + opacity 140ms ease, + transform 180ms ease; +} + +.missed-count-visible { + max-width: 92px; + opacity: 1; + transform: translateX(0); } .list { @@ -811,7 +877,12 @@ h3 { max-height: 260px; } -.page-grid-overview > :not(:first-child), +.page-grid-home > :nth-child(1), +.page-grid-home > :nth-child(2) { + height: clamp(430px, 56vh, 760px); +} + +.page-grid-home > :nth-child(3), .page-grid-replay > :not(:first-child) { height: clamp(430px, 58vh, 760px); } @@ -873,6 +944,287 @@ h3 { background: linear-gradient(180deg, rgba(245, 166, 35, 0.07), rgba(255, 255, 255, 0.018)); } +.data-table-shell, +.options-table-wrap { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.data-table-wrap { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: rgba(5, 8, 12, 0.42); +} + +.data-table { + display: block; + min-width: 980px; +} + +.data-table-options { + min-width: 1280px; +} + +.data-table-equities { + min-width: 660px; +} + +.data-table-flow { + min-width: 1260px; +} + +.data-table-alerts { + min-width: 900px; +} + +.data-table-classifier { + min-width: 760px; +} + +.data-table-dark { + min-width: 820px; +} + +.data-table-head, +.data-table-row { + display: grid; + align-items: center; + column-gap: 8px; +} + +.data-table-head { + position: sticky; + top: 0; + z-index: 2; + min-height: 30px; + padding: 0 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.095); + background: rgba(8, 11, 16, 0.98); + color: var(--text-faint); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.data-table-row { + width: 100%; + min-height: 40px; + padding: 0 10px; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); + background: rgba(255, 255, 255, 0.008); + color: inherit; + font: inherit; + text-align: left; +} + +.data-table-row:nth-child(even) { + background: rgba(255, 255, 255, 0.022); +} + +.data-table-row:hover, +.data-table-row:focus-visible { + outline: none; + background: rgba(245, 166, 35, 0.055); +} + +.data-table-row-button { + cursor: pointer; +} + +.data-table-row-options { + min-height: 36px; +} + +.data-table-row-equities { + min-height: 34px; +} + +.data-table-row-flow, +.data-table-row-alerts, +.data-table-row-classifier, +.data-table-row-dark { + min-height: 44px; +} + +.data-table-row-classified { + background: + linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.12)), transparent 62%), + rgba(255, 255, 255, 0.008); +} + +.data-table-row-classified:hover, +.data-table-row-classified:focus-visible { + background: + linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.18)), transparent 68%), + rgba(245, 166, 35, 0.04); +} + +.data-table-row-classified.is-classified { + border-left: 3px solid rgba(var(--classifier-rgb), calc(0.35 + var(--classifier-intensity) * 0.45)); + padding-left: 7px; +} + +.data-table-row-warn, +.data-table-row-severity-high, +.data-table-row-direction-bearish { + border-left: 3px solid rgba(255, 107, 95, 0.58); + padding-left: 7px; +} + +.data-table-row-severity-medium, +.data-table-row-direction-neutral { + border-left: 3px solid rgba(77, 163, 255, 0.46); + padding-left: 7px; +} + +.data-table-row-severity-low, +.data-table-row-direction-bullish { + border-left: 3px solid rgba(37, 193, 122, 0.5); + padding-left: 7px; +} + +.data-table-options .data-table-head, +.data-table-options .data-table-row { + grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr); +} + +.data-table-equities .data-table-head, +.data-table-equities .data-table-row { + grid-template-columns: minmax(76px, 0.9fr) minmax(70px, 0.8fr) minmax(76px, 0.8fr) minmax(70px, 0.75fr) minmax(80px, 0.8fr) minmax(76px, 0.75fr); +} + +.data-table-flow .data-table-head, +.data-table-flow .data-table-row { + grid-template-columns: minmax(148px, 1.1fr) minmax(180px, 1.4fr) minmax(62px, 0.45fr) minmax(70px, 0.5fr) minmax(88px, 0.7fr) minmax(74px, 0.55fr) minmax(132px, 1fr) minmax(110px, 0.8fr) minmax(210px, 1.6fr); +} + +.data-table-alerts .data-table-head, +.data-table-alerts .data-table-row { + grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.4fr) minmax(52px, 0.45fr) minmax(58px, 0.45fr) minmax(52px, 0.4fr) minmax(66px, 0.55fr) minmax(260px, 2fr); +} + +.data-table-classifier .data-table-head, +.data-table-classifier .data-table-row { + grid-template-columns: minmax(76px, 0.75fr) minmax(180px, 1.45fr) minmax(70px, 0.6fr) minmax(74px, 0.65fr) minmax(300px, 2.2fr); +} + +.data-table-dark .data-table-head, +.data-table-dark .data-table-row { + grid-template-columns: minmax(76px, 0.75fr) minmax(170px, 1.35fr) minmax(76px, 0.65fr) minmax(74px, 0.65fr) minmax(74px, 0.65fr) minmax(260px, 2fr); +} + +.data-table-cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.72rem; +} + +.data-table-cell-number { + font-family: var(--font-mono), monospace; + font-variant-numeric: tabular-nums; +} + +.data-table-spacer { + min-width: 100%; + pointer-events: none; +} + +.options-table { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; +} + +.options-table-head, +.options-table-row { + display: grid; + grid-template-columns: minmax(72px, 0.8fr) minmax(50px, 0.55fr) minmax(64px, 0.7fr) minmax(58px, 0.6fr) minmax(34px, 0.35fr) minmax(62px, 0.65fr) minmax(104px, 1fr) minmax(54px, 0.55fr) minmax(66px, 0.7fr) minmax(48px, 0.5fr) minmax(42px, 0.45fr) minmax(92px, 0.9fr); + align-items: center; + column-gap: 8px; +} + +.options-table-head { + flex: 0 0 auto; + height: 30px; + padding: 0 8px; + border-bottom: 1px solid var(--border); + background: rgba(8, 11, 16, 0.98); + color: var(--muted); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.options-table-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.options-table-row { + width: 100%; + min-height: 34px; + padding: 0 8px; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); + background: + linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.12)), transparent 62%), + rgba(255, 255, 255, 0.012); + color: inherit; + font: inherit; + text-align: left; +} + +.options-table-row:hover, +.options-table-row:focus-visible { + outline: none; + background: + linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.18)), transparent 68%), + rgba(255, 255, 255, 0.03); +} + +.options-table-row.is-classified { + cursor: pointer; + border-left: 3px solid rgba(var(--classifier-rgb), calc(0.35 + var(--classifier-intensity) * 0.45)); + padding-left: 7px; +} + +.options-table-row > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.72rem; +} + +.mono { + font-variant-numeric: tabular-nums; +} + +.classifier-green { --classifier-rgb: 37, 193, 122; } +.classifier-red { --classifier-rgb: 255, 107, 95; } +.classifier-amber { --classifier-rgb: 245, 166, 35; } +.classifier-copper { --classifier-rgb: 198, 122, 75; } +.classifier-blue { --classifier-rgb: 77, 163, 255; } +.classifier-teal { --classifier-rgb: 64, 210, 190; } +.classifier-yellowgreen { --classifier-rgb: 174, 210, 78; } +.classifier-violet { --classifier-rgb: 170, 130, 255; } +.classifier-cyan { --classifier-rgb: 94, 214, 255; } +.classifier-magenta { --classifier-rgb: 255, 92, 205; } +.classifier-neutral { --classifier-rgb: 192, 200, 210; } + .contract, .drawer-row-title { margin-bottom: 6px; @@ -1212,25 +1564,26 @@ h3 { } @media (max-width: 980px) { - .page-grid-overview, + .page-grid-home, .page-grid-tape, .page-grid-signals, .page-grid-charts, .page-grid-replay, - .overview-strip, .replay-matrix, .shell-metrics { grid-template-columns: minmax(0, 1fr); } - .page-grid-overview > :nth-child(1), + .page-grid-home > :nth-child(3), .page-grid-tape > :nth-child(1), .page-grid-replay > :nth-child(1) { grid-column: auto; grid-row: auto; } - .page-grid-overview > :not(:first-child), + .page-grid-home > :nth-child(1), + .page-grid-home > :nth-child(2), + .page-grid-home > :nth-child(3), .page-grid-signals > .terminal-pane, .page-grid-replay > :not(:first-child), .page-grid-tape > :first-child, @@ -1246,16 +1599,19 @@ h3 { .terminal-topbar { position: static; - grid-template-columns: minmax(0, 1fr); - align-items: stretch; + align-items: center; + justify-content: flex-end; + padding: 10px 16px; } .terminal-topbar-actions { - justify-content: space-between; + justify-content: flex-end; + margin-left: auto; + width: auto; } .terminal-topbar-controls { - flex: 1 1 auto; + flex: 0 1 auto; } .flow-filter-popover-panel { diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index a6807b8..326a63d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,7 @@ import { OverviewRoute } from "./terminal"; +export const dynamic = "force-dynamic"; + export default function Page() { return ; } diff --git a/apps/web/app/replay/page.tsx b/apps/web/app/replay/page.tsx index 2044bee..9d82bba 100644 --- a/apps/web/app/replay/page.tsx +++ b/apps/web/app/replay/page.tsx @@ -1,5 +1,7 @@ -import { ReplayRoute } from "../terminal"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/routes.test.ts b/apps/web/app/routes.test.ts new file mode 100644 index 0000000..55b29e0 --- /dev/null +++ b/apps/web/app/routes.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const redirect = mock((path: string) => { + throw new Error(`NEXT_REDIRECT:${path}`); +}); + +mock.module("next/navigation", () => ({ redirect })); + +describe("legacy page redirects", () => { + beforeEach(() => { + redirect.mockClear(); + }); + + it("redirects /signals to home", async () => { + const mod = await import("./signals/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + it("redirects /charts to home", async () => { + const mod = await import("./charts/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + it("redirects /replay to home", async () => { + const mod = await import("./replay/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/signals/page.tsx index e510e26..9d82bba 100644 --- a/apps/web/app/signals/page.tsx +++ b/apps/web/app/signals/page.tsx @@ -1,5 +1,7 @@ -import { SignalsRoute } from "../terminal"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/tape/page.tsx b/apps/web/app/tape/page.tsx index 344ada0..a692698 100644 --- a/apps/web/app/tape/page.tsx +++ b/apps/web/app/tape/page.tsx @@ -1,5 +1,7 @@ import { TapeRoute } from "../terminal"; +export const dynamic = "force-dynamic"; + export default function Page() { return ; } diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index f3f10be..0c65741 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "bun:test"; import { + NAV_ITEMS, buildDefaultFlowFilters, + classifierToneForFamily, deriveAlertDirection, countActiveFlowFilterGroups, formatCompactUsd, formatOptionContractLabel, flushPausableTapeData, getAlertWindowAnchorTs, + getOptionTableSnapshot, getLiveFeedStatus, + getLiveManifest, normalizeAlertSeverity, nextFlowFilterPopoverState, projectPausableTapeState, reducePausableTapeData, shouldRetainLiveSnapshotHistory, shouldShowEquitiesSilentFeedWarning, + selectPrimaryClassifierHit, statusLabel, toggleFilterValue } from "./terminal"; @@ -35,6 +40,98 @@ const makeAlert = (overrides: Record = {}) => ...overrides }) as any; +describe("live manifest", () => { + it("includes options on home and tape", () => { + const filters = buildDefaultFlowFilters(); + for (const pathname of ["/", "/tape"]) { + expect( + getLiveManifest(pathname, "SPY", 60000, filters).some( + (subscription) => subscription.channel === "options" + ) + ).toBe(true); + } + }); + + it("dedupes tape options subscription", () => { + const tapeOptionsSubscriptions = getLiveManifest( + "/tape", + "SPY", + 60000, + buildDefaultFlowFilters() + ).filter((subscription) => subscription.channel === "options"); + expect(tapeOptionsSubscriptions).toHaveLength(1); + }); + + it("keeps option filters on baseline subscription across page changes", () => { + const filters = { + ...buildDefaultFlowFilters(), + minNotional: 125_000 + }; + + const homeOptionsSubscription = getLiveManifest("/", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "options" + ); + const tapeOptionsSubscription = getLiveManifest("/tape", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "options" + ); + + expect(homeOptionsSubscription?.filters).toBe(filters); + expect(tapeOptionsSubscription?.filters).toBe(filters); + }); + + it("applies global flow filters to flow subscriptions on home and tape", () => { + const filters = { + ...buildDefaultFlowFilters(), + minNotional: 50_000 + }; + + const homeFlowSubscription = getLiveManifest("/", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "flow" + ); + const tapeFlowSubscription = getLiveManifest("/tape", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "flow" + ); + + expect(homeFlowSubscription?.filters).toBe(filters); + expect(tapeFlowSubscription?.filters).toBe(filters); + }); + + it("includes scoped option and equity subscriptions", () => { + const manifest = getLiveManifest( + "/tape", + "AAPL", + 60000, + buildDefaultFlowFilters(), + { + underlying_ids: ["AAPL"], + option_contract_id: "AAPL-2025-01-17-200-C" + }, + { underlying_ids: ["AAPL"] } + ); + const optionsSubscription = manifest.find( + (subscription): subscription is Extract<(typeof manifest)[number], { channel: "options" }> => + subscription.channel === "options" + ); + const equitiesSubscription = manifest.find( + (subscription): subscription is Extract<(typeof manifest)[number], { channel: "equities" }> => + subscription.channel === "equities" + ); + + expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]); + expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C"); + expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]); + }); +}); + +describe("terminal navigation", () => { + it("exposes only Home and Tape as top-level destinations", () => { + expect(NAV_ITEMS).toEqual([ + { href: "/", label: "Home" }, + { href: "/tape", label: "Tape" } + ]); + }); +}); + describe("live tape pausable helpers", () => { it("queues new items while paused and flushes them on resume", () => { let state = reducePausableTapeData( @@ -171,6 +268,54 @@ describe("options display formatters", () => { expect(formatCompactUsd(1_250_000)).toBe("1.3M"); expect(formatCompactUsd(Number.NaN)).toBe("0.00"); }); + + it("renders options table snapshot values from preserved spot and IV", () => { + expect( + getOptionTableSnapshot({ + price: 1.25, + size: 10, + notional: 12_500, + execution_nbbo_side: "A", + execution_underlying_spot: 450.05, + execution_iv: 0.42 + }) + ).toEqual({ + spot: "450.05", + iv: "42%", + side: "A", + details: "10@1.25_A", + value: "12.5K" + }); + }); + + it("renders legacy options table snapshot spot and IV as dashes", () => { + const snapshot = getOptionTableSnapshot({ + price: 1, + size: 2 + }); + + expect(snapshot.spot).toBe("--"); + expect(snapshot.iv).toBe("--"); + }); +}); + +describe("classifier row decoration helpers", () => { + it("maps classifier families to row tones", () => { + expect(classifierToneForFamily("large_bullish_call_sweep")).toBe("green"); + expect(classifierToneForFamily("large_bearish_put_sweep")).toBe("red"); + expect(classifierToneForFamily("straddle")).toBe("blue"); + expect(classifierToneForFamily("unknown_family")).toBe("neutral"); + }); + + it("selects primary hits by confidence, source timestamp, then seq", () => { + const hit = selectPrimaryClassifierHit([ + { ...makeAlert({ classifier_id: "old", confidence: 0.9, source_ts: 1_000, seq: 1 }), direction: "bullish", explanations: [] }, + { ...makeAlert({ classifier_id: "new", confidence: 0.9, source_ts: 2_000, seq: 1 }), direction: "bullish", explanations: [] }, + { ...makeAlert({ classifier_id: "low", confidence: 0.5, source_ts: 3_000, seq: 9 }), direction: "bullish", explanations: [] } + ]); + + expect(hit?.classifier_id).toBe("new"); + }); }); describe("flow filter popup helpers", () => { diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index e2a0d9a..24a0e5d 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -11,7 +11,9 @@ import { useMemo, useRef, useState, + type CSSProperties, type Dispatch, + type MouseEvent as ReactMouseEvent, type ReactNode, type SetStateAction } from "react"; @@ -22,6 +24,7 @@ import type { EquityCandle, EquityPrint, EquityPrintJoin, + EquityQuote, FlowPacket, InferredDarkEvent, LiveServerMessage, @@ -95,6 +98,15 @@ const CANDLE_INTERVALS = [ { label: "1m", ms: 60000 }, { label: "5m", ms: 300000 } ]; +const LIVE_SESSION_IDLE_RECONNECT_MS = 12_000; +const LIVE_SESSION_IDLE_CHECK_MS = 3_000; +const LIVE_SESSION_HOT_CHANNELS = new Set([ + "options", + "nbbo", + "equities", + "flow", + "equity-overlay" +]); type CandlestickSeries = ReturnType; @@ -113,6 +125,11 @@ type ChartCandle = { close: number; }; +type SelectedInstrument = + | null + | { kind: "equity"; underlyingId: string } + | { kind: "option-contract"; contractId: string; underlyingId: string }; + const formatIntervalLabel = (intervalMs: number): string => { const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs); if (match) { @@ -398,13 +415,19 @@ export const reducePausableTapeData = ( return current; } - const nextSeenKeys = new Set(current.seenKeys); + const seenKeys = current.seenKeys; + let nextSeenKeys: Set | null = null; const unseen: T[] = []; + // Incoming items are maintained newest-first by mergeNewest. + // Once we hit a previously seen key, the remainder is older history. for (const item of incoming) { const key = getTapeItemKey(item); - if (nextSeenKeys.has(key)) { - continue; + if (seenKeys.has(key)) { + break; + } + if (!nextSeenKeys) { + nextSeenKeys = new Set(seenKeys); } nextSeenKeys.add(key); unseen.push(item); @@ -420,7 +443,7 @@ export const reducePausableTapeData = ( queued: mergeNewest(unseen, current.queued, retentionLimit, (evicted) => incrementRetentionMetric("hotWindowEvictions", evicted) ), - seenKeys: nextSeenKeys, + seenKeys: nextSeenKeys ?? seenKeys, dropped: current.dropped + unseen.length }; } @@ -431,7 +454,7 @@ export const reducePausableTapeData = ( incrementRetentionMetric("hotWindowEvictions", evicted) ), queued: [], - seenKeys: nextSeenKeys, + seenKeys: nextSeenKeys ?? seenKeys, dropped: 0 }; }; @@ -982,7 +1005,8 @@ const LIVE_SNAPSHOT_HISTORY_CHANNELS = new Set([ "options", "nbbo", "equities", - "flow" + "flow", + "classifier-hits" ]); export const shouldRetainLiveSnapshotHistory = ( @@ -1027,8 +1051,83 @@ const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): return price >= mid ? "A" : "B"; }; +type ClassifierDecor = { + hit: ClassifierHitEvent; + family: string; + tone: string; + intensity: number; +}; + +const CLASSIFIER_FAMILY_TONES: Record = { + large_bullish_call_sweep: "green", + large_bearish_put_sweep: "red", + unusual_contract_spike: "amber", + large_call_sell_overwrite: "copper", + large_put_sell_write: "copper", + straddle: "blue", + strangle: "blue", + vertical_spread: "teal", + ladder_accumulation: "yellowgreen", + roll_up_down_out: "violet", + far_dated_conviction: "cyan", + zero_dte_gamma_punch: "magenta" +}; + +export const selectPrimaryClassifierHit = ( + hits: readonly ClassifierHitEvent[] +): ClassifierHitEvent | null => { + if (hits.length === 0) { + return null; + } + return [...hits].sort((a, b) => { + const confidenceDelta = b.confidence - a.confidence; + if (confidenceDelta !== 0) { + return confidenceDelta; + } + const tsDelta = b.source_ts - a.source_ts; + if (tsDelta !== 0) { + return tsDelta; + } + return b.seq - a.seq; + })[0]; +}; + +export const classifierToneForFamily = (classifierId: string): string => + CLASSIFIER_FAMILY_TONES[classifierId] ?? "neutral"; + +const buildClassifierDecor = (hit: ClassifierHitEvent): ClassifierDecor => ({ + hit, + family: hit.classifier_id, + tone: classifierToneForFamily(hit.classifier_id), + intensity: clamp(hit.confidence, 0.25, 1) +}); + +export const getOptionTableSnapshot = ( + print: Pick< + OptionPrint, + | "price" + | "size" + | "notional" + | "nbbo_side" + | "execution_nbbo_side" + | "execution_underlying_spot" + | "execution_iv" + >, + fallbackSide: OptionNbboSide | null = null +): { spot: string; iv: string; side: string; details: string; value: string } => { + const side = print.execution_nbbo_side ?? print.nbbo_side ?? fallbackSide ?? "--"; + return { + spot: typeof print.execution_underlying_spot === "number" ? formatPrice(print.execution_underlying_spot) : "--", + iv: typeof print.execution_iv === "number" ? formatPct(print.execution_iv) : "--", + side, + details: `${formatSize(print.size)}@${formatPrice(print.price)}_${side}`, + value: formatCompactUsd(print.notional ?? print.price * print.size * 100) + }; +}; + type ListScrollState = { listRef: React.RefObject; + setListRef: (node: HTMLDivElement | null) => void; isAtTop: boolean; isAtTopRef: React.MutableRefObject; missed: number; @@ -1039,12 +1138,18 @@ type ListScrollState = { const useListScroll = (): ListScrollState => { const listRef = useRef(null); + const [listNode, setListNode] = useState(null); const [isAtTop, setIsAtTop] = useState(true); const [missed, setMissed] = useState(0); const [resumeTick, setResumeTick] = useState(0); const isAtTopRef = useRef(true); const prevAtTopRef = useRef(true); + const setListRef = useCallback((node: HTMLDivElement | null) => { + listRef.current = node; + setListNode(node); + }, []); + useEffect(() => { isAtTopRef.current = isAtTop; }, [isAtTop]); @@ -1071,8 +1176,7 @@ const useListScroll = (): ListScrollState => { }, [isAtTopRef]); useEffect(() => { - const el = listRef.current; - if (!el) { + if (!listNode) { return; } @@ -1081,12 +1185,12 @@ const useListScroll = (): ListScrollState => { }; updateScrollState(); - el.addEventListener("scroll", onScroll); + listNode.addEventListener("scroll", onScroll); return () => { - el.removeEventListener("scroll", onScroll); + listNode.removeEventListener("scroll", onScroll); }; - }, [updateScrollState]); + }, [listNode, updateScrollState]); const onNewItems = useCallback((count: number) => { if (count <= 0) { @@ -1114,6 +1218,7 @@ const useListScroll = (): ListScrollState => { return { listRef, + setListRef, isAtTop, isAtTopRef, missed, @@ -1748,6 +1853,8 @@ type PausableTapeViewConfig = { captureScroll?: () => void; getItemTs?: (item: T) => number; retentionLimit?: number; + shouldHold?: () => boolean; + resumeSignal?: number; }; const usePausableTapeView = ( @@ -1774,11 +1881,12 @@ const usePausableTapeView = ( return; } + const holdForScroll = config.shouldHold ? config.shouldHold() : false; setData((current) => { const next = reducePausableTapeData( current, config.sourceItems, - paused, + paused || holdForScroll, config.retentionLimit ?? LIVE_HOT_WINDOW ); if (next === current) { @@ -1799,6 +1907,7 @@ const usePausableTapeView = ( config.onNewItems, config.captureScroll, config.retentionLimit, + config.shouldHold, paused ]); @@ -1807,6 +1916,11 @@ const usePausableTapeView = ( return; } + const holdForScroll = config.shouldHold ? config.shouldHold() : false; + if (holdForScroll) { + return; + } + setData((current) => { const next = flushPausableTapeData(current, config.retentionLimit ?? LIVE_HOT_WINDOW); if (next === current) { @@ -1820,7 +1934,15 @@ const usePausableTapeView = ( return next; }); - }, [config.captureScroll, config.enabled, config.onNewItems, config.retentionLimit, paused]); + }, [ + config.captureScroll, + config.enabled, + config.onNewItems, + config.retentionLimit, + config.resumeSignal, + config.shouldHold, + paused + ]); const togglePause = useCallback(() => { setPaused((current) => !current); @@ -2097,9 +2219,15 @@ type LiveSessionState = { connectedAt: number | null; lastUpdate: number | null; lastEventByChannel: Partial>; + manifest: LiveSubscription[]; + historyCursors: Partial>; + historyLoading: Partial>; + historyErrors: Partial>; + loadOlder: (channel: LiveSubscription["channel"]) => Promise; options: OptionPrint[]; nbbo: OptionNBBO[]; equities: EquityPrint[]; + equityQuotes: EquityQuote[]; equityJoins: EquityPrintJoin[]; flow: FlowPacket[]; classifierHits: ClassifierHitEvent[]; @@ -2109,46 +2237,100 @@ type LiveSessionState = { chartOverlay: EquityPrint[]; }; -const getLiveManifest = ( +type LiveHistoryResponse = { + data: T[]; + next_before: Cursor | null; +}; + +const LIVE_HISTORY_ENDPOINTS: Partial> = { + options: "/history/options", + nbbo: "/history/nbbo", + equities: "/history/equities", + "equity-quotes": "/history/equity-quotes", + "equity-joins": "/history/equity-joins", + flow: "/history/flow", + "classifier-hits": "/history/classifier-hits", + alerts: "/history/alerts", + "inferred-dark": "/history/inferred-dark" +}; + +const appendOptionFlowFilters = (params: URLSearchParams, filters: OptionFlowFilters | undefined): void => { + if (!filters) { + return; + } + if (filters.view) { + params.set("view", filters.view); + } + if (filters.securityTypes?.length === 1) { + params.set("security", filters.securityTypes[0]); + } else if (filters.securityTypes && filters.securityTypes.length > 1) { + params.set("security", "all"); + } + if (filters.nbboSides?.length) { + params.set("side", filters.nbboSides.join(",")); + } + if (filters.optionTypes?.length) { + params.set("type", filters.optionTypes.join(",")); + } + if (typeof filters.minNotional === "number") { + params.set("min_notional", String(filters.minNotional)); + } +}; + +const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => { + if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) { + params.set("underlying_ids", subscription.underlying_ids.join(",")); + } + if (subscription.channel === "options" && subscription.option_contract_id) { + params.set("option_contract_id", subscription.option_contract_id); + } +}; + +const dedupeLiveSubscriptions = (subscriptions: LiveSubscription[]): LiveSubscription[] => { + const seen = new Set(); + return subscriptions.filter((subscription) => { + const key = getLiveSubscriptionKey(subscription); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +export const getLiveManifest = ( pathname: string, chartTicker: string, chartIntervalMs: number, - flowFilters: OptionFlowFilters + flowFilters: OptionFlowFilters, + optionScope?: Pick, "underlying_ids" | "option_contract_id">, + equityScope?: Pick, "underlying_ids"> ): LiveSubscription[] => { + const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters, ...optionScope }]; const chartSubs: LiveSubscription[] = [ { channel: "equity-candles", underlying_id: chartTicker, interval_ms: chartIntervalMs }, { channel: "equity-overlay", underlying_id: chartTicker } ]; if (pathname === "/tape") { - return [ - { channel: "options", filters: flowFilters }, + return dedupeLiveSubscriptions([ + ...baselineSubs, { channel: "nbbo" }, - { channel: "equities" }, - { channel: "flow", filters: flowFilters } - ]; + { channel: "equities", ...equityScope }, + { channel: "flow", filters: flowFilters }, + { channel: "classifier-hits" } + ]); } - if (pathname === "/signals") { - return [{ channel: "alerts" }, { channel: "classifier-hits" }, { channel: "inferred-dark" }]; - } - - if (pathname === "/charts") { - return [...chartSubs, { channel: "classifier-hits" }, { channel: "inferred-dark" }]; - } - - if (pathname === "/replay") { - return []; - } - - return [ - { channel: "equities" }, - { channel: "flow" }, + return dedupeLiveSubscriptions([ + ...baselineSubs, + { channel: "equities", ...equityScope }, + { channel: "flow", filters: flowFilters }, { channel: "alerts" }, { channel: "classifier-hits" }, { channel: "inferred-dark" }, ...chartSubs - ]; + ]); }; const useLiveSession = ( @@ -2156,7 +2338,9 @@ const useLiveSession = ( pathname: string, chartTicker: string, chartIntervalMs: number, - flowFilters: OptionFlowFilters + flowFilters: OptionFlowFilters, + optionScope?: Pick, "underlying_ids" | "option_contract_id">, + equityScope?: Pick, "underlying_ids"> ): LiveSessionState => { const [status, setStatus] = useState(enabled ? "connecting" : "disconnected"); const [connectedAt, setConnectedAt] = useState(null); @@ -2164,9 +2348,13 @@ const useLiveSession = ( const [lastEventByChannel, setLastEventByChannel] = useState< Partial> >({}); + const [historyCursors, setHistoryCursors] = useState>>({}); + const [historyLoading, setHistoryLoading] = useState>>({}); + const [historyErrors, setHistoryErrors] = useState>>({}); const [options, setOptions] = useState([]); const [nbbo, setNbbo] = useState([]); const [equities, setEquities] = useState([]); + const [equityQuotes, setEquityQuotes] = useState([]); const [equityJoins, setEquityJoins] = useState([]); const [flow, setFlow] = useState([]); const [classifierHits, setClassifierHits] = useState([]); @@ -2176,11 +2364,14 @@ const useLiveSession = ( const [chartOverlay, setChartOverlay] = useState([]); const socketRef = useRef(null); const reconnectRef = useRef(null); + const idleWatchdogRef = useRef(null); + const connectedAtRef = useRef(null); + const lastEventAtRef = useRef(null); const subscribedKeysRef = useRef>(new Set()); const subscribedMapRef = useRef>(new Map()); const manifest = useMemo( - () => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters), - [pathname, chartTicker, chartIntervalMs, flowFilters] + () => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope), + [pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope] ); useEffect(() => { @@ -2189,9 +2380,13 @@ const useLiveSession = ( setConnectedAt(null); setLastUpdate(null); setLastEventByChannel({}); + setHistoryCursors({}); + setHistoryLoading({}); + setHistoryErrors({}); setOptions([]); setNbbo([]); setEquities([]); + setEquityQuotes([]); setEquityJoins([]); setFlow([]); setClassifierHits([]); @@ -2209,6 +2404,12 @@ const useLiveSession = ( window.clearTimeout(reconnectRef.current); reconnectRef.current = null; } + if (idleWatchdogRef.current !== null) { + window.clearInterval(idleWatchdogRef.current); + idleWatchdogRef.current = null; + } + connectedAtRef.current = null; + lastEventAtRef.current = null; return; } @@ -2245,6 +2446,7 @@ const useLiveSession = ( const subscription = message.op === "snapshot" ? message.snapshot.subscription : message.subscription; const items = message.op === "snapshot" ? message.snapshot.items : [message.item]; + const subscriptionKey = getLiveSubscriptionKey(subscription); const updateAt = Date.now(); const mergeItems = ( @@ -2278,6 +2480,9 @@ const useLiveSession = ( case "equities": mergeItems(setEquities, items as EquityPrint[]); break; + case "equity-quotes": + mergeItems(setEquityQuotes, items as EquityQuote[]); + break; case "equity-joins": mergeItems(setEquityJoins, items as EquityPrintJoin[]); break; @@ -2301,7 +2506,19 @@ const useLiveSession = ( break; } + if (message.op === "snapshot") { + setHistoryCursors((current) => ({ + ...current, + [subscriptionKey]: message.snapshot.next_before + })); + setHistoryErrors((current) => ({ + ...current, + [subscriptionKey]: null + })); + } + if (items.length > 0) { + lastEventAtRef.current = updateAt; setLastEventByChannel((current) => ({ ...current, [subscription.channel]: updateAt @@ -2324,7 +2541,10 @@ const useLiveSession = ( return; } setStatus("connected"); - setConnectedAt(Date.now()); + const now = Date.now(); + setConnectedAt(now); + connectedAtRef.current = now; + lastEventAtRef.current = null; syncSubscriptions(socket); }; @@ -2346,6 +2566,8 @@ const useLiveSession = ( } setStatus("disconnected"); setConnectedAt(null); + connectedAtRef.current = null; + lastEventAtRef.current = null; subscribedKeysRef.current = new Set(); subscribedMapRef.current = new Map(); reconnectRef.current = window.setTimeout(connect, 1000); @@ -2357,14 +2579,43 @@ const useLiveSession = ( } setStatus("disconnected"); setConnectedAt(null); + connectedAtRef.current = null; + lastEventAtRef.current = null; socket.close(); }; }; connect(); + idleWatchdogRef.current = window.setInterval(() => { + if (!active) { + return; + } + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + const hasHotSubscription = Array.from(subscribedMapRef.current.values()).some((sub) => + LIVE_SESSION_HOT_CHANNELS.has(sub.channel) + ); + if (!hasHotSubscription) { + return; + } + const baseline = lastEventAtRef.current ?? connectedAtRef.current; + if (baseline === null) { + return; + } + if (Date.now() - baseline >= LIVE_SESSION_IDLE_RECONNECT_MS) { + console.warn("Live socket idle; reconnecting"); + socket.close(); + } + }, LIVE_SESSION_IDLE_CHECK_MS); return () => { active = false; + if (idleWatchdogRef.current !== null) { + window.clearInterval(idleWatchdogRef.current); + idleWatchdogRef.current = null; + } if (reconnectRef.current !== null) { window.clearTimeout(reconnectRef.current); } @@ -2385,6 +2636,42 @@ const useLiveSession = ( const currentKeys = subscribedKeysRef.current; const toSubscribe = manifest.filter((sub) => !currentKeys.has(getLiveSubscriptionKey(sub))); const removedKeys = Array.from(currentKeys).filter((key) => !nextKeys.has(key)); + const resetScopedChannels = new Set( + [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)] + .map((key) => subscribedMapRef.current.get(key) ?? nextMap.get(key) ?? null) + .filter((sub): sub is LiveSubscription => sub !== null) + .map((sub) => sub.channel) + .filter((channel) => channel === "options" || channel === "equities") + ); + if (resetScopedChannels.has("options")) { + setOptions([]); + } + if (resetScopedChannels.has("equities")) { + setEquities([]); + } + if (resetScopedChannels.size > 0) { + setHistoryCursors((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + setHistoryLoading((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + setHistoryErrors((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + } if (removedKeys.length > 0) { const removedSubs = removedKeys @@ -2401,14 +2688,117 @@ const useLiveSession = ( subscribedMapRef.current = nextMap; }, [enabled, manifest]); + const loadOlder = useCallback( + async (channel: LiveSubscription["channel"]) => { + const subscription = manifest.find((candidate) => candidate.channel === channel); + if (!enabled || !subscription) { + return; + } + const endpoint = LIVE_HISTORY_ENDPOINTS[subscription.channel]; + if (!endpoint) { + return; + } + const key = getLiveSubscriptionKey(subscription); + const cursor = historyCursors[key]; + if (!cursor || historyLoading[key]) { + return; + } + + setHistoryLoading((current) => ({ ...current, [key]: true })); + setHistoryErrors((current) => ({ ...current, [key]: null })); + + try { + const params = new URLSearchParams({ + before_ts: String(cursor.ts), + before_seq: String(cursor.seq), + limit: String(subscription.channel === "options" ? 500 : 200) + }); + if (subscription.channel === "options" || subscription.channel === "flow") { + appendOptionFlowFilters(params, subscription.filters); + } + appendLiveScopeParams(params, subscription); + const url = new URL(buildApiUrl(endpoint)); + url.search = params.toString(); + const response = await fetch(url.toString()); + if (!response.ok) { + const detail = await readErrorDetail(response); + throw new Error(detail || `HTTP ${response.status}`); + } + const payload = (await response.json()) as LiveHistoryResponse; + const older = payload.data ?? []; + + const mergeOlder = ( + setter: Dispatch>, + limit: number + ) => { + setter((prev) => + mergeNewest(older as T[], prev, limit, (evicted) => + incrementRetentionMetric("hotWindowEvictions", evicted) + ) + ); + }; + + switch (subscription.channel) { + case "options": + mergeOlder(setOptions, LIVE_HOT_WINDOW_OPTIONS); + break; + case "nbbo": + mergeOlder(setNbbo, LIVE_HOT_WINDOW); + break; + case "equities": + mergeOlder(setEquities, LIVE_HOT_WINDOW); + break; + case "equity-quotes": + mergeOlder(setEquityQuotes, LIVE_HOT_WINDOW); + break; + case "equity-joins": + mergeOlder(setEquityJoins, LIVE_HOT_WINDOW); + break; + case "flow": + mergeOlder(setFlow, LIVE_HOT_WINDOW); + break; + case "classifier-hits": + mergeOlder(setClassifierHits, LIVE_HOT_WINDOW); + break; + case "alerts": + mergeOlder(setAlerts, LIVE_HOT_WINDOW); + break; + case "inferred-dark": + mergeOlder(setInferredDark, LIVE_HOT_WINDOW); + break; + } + + setHistoryCursors((current) => ({ + ...current, + [key]: older.length > 0 ? payload.next_before : null + })); + setLastUpdate(Date.now()); + } catch (error) { + setHistoryErrors((current) => ({ + ...current, + [key]: error instanceof Error ? error.message : String(error) + })); + } finally { + setHistoryLoading((current) => ({ ...current, [key]: false })); + } + }, + [enabled, manifest, historyCursors, historyLoading] + ); + return { status, connectedAt, lastUpdate, lastEventByChannel, + manifest, + historyCursors, + historyLoading, + historyErrors, + loadOlder, options, nbbo, equities, + equityQuotes, equityJoins, flow, classifierHits, @@ -2475,7 +2865,9 @@ const TapeControls = ({ paused, onTogglePause, isAtTop, missed, onJump }: TapeCo - {active ? `+${missed} new` : ""} + + +{missed} new + ); }; @@ -3617,6 +4009,7 @@ const useTerminalState = () => { const [selectedAlert, setSelectedAlert] = useState(null); const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); const [selectedClassifierHit, setSelectedClassifierHit] = useState(null); + const [selectedInstrument, setSelectedInstrument] = useState(null); const [filterInput, setFilterInput] = useState(""); const [flowFilters, setFlowFilters] = useState(() => buildDefaultFlowFilters()); const [chartIntervalMs, setChartIntervalMs] = useState(CANDLE_INTERVALS[0].ms); @@ -3628,20 +4021,52 @@ const useTerminalState = () => { return Array.from(new Set(parts)); }, [filterInput]); const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); - const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]); + const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null; + const optionScope = useMemo( + () => ({ + underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined, + option_contract_id: + selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined + }), + [activeTickers, instrumentUnderlying, selectedInstrument] + ); + const equityScope = useMemo( + () => ({ + underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined + }), + [activeTickers, instrumentUnderlying] + ); + const chartTicker = useMemo( + () => instrumentUnderlying ?? activeTickers[0] ?? "SPY", + [activeTickers, instrumentUnderlying] + ); + const selectedInstrumentLabel = useMemo(() => { + if (!selectedInstrument) { + return null; + } + if (selectedInstrument.kind === "equity") { + return `Equity: ${selectedInstrument.underlyingId}`; + } + const display = formatOptionContractLabel(selectedInstrument.contractId); + return display + ? `Contract: ${display.ticker} ${display.expiration} ${display.strike}` + : `Contract: ${selectedInstrument.contractId}`; + }, [selectedInstrument]); const liveSession = useLiveSession( mode === "live", pathname, chartTicker, chartIntervalMs, - flowFilters + flowFilters, + optionScope, + equityScope ); const equitiesLiveSubscriptionActive = useMemo( () => - getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters).some( + getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope).some( (sub) => sub.channel === "equities" ), - [pathname, chartTicker, chartIntervalMs, flowFilters] + [pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope] ); const handleReplaySource = useCallback((value: string | null) => { @@ -3651,6 +4076,40 @@ const useTerminalState = () => { useEffect(() => { setReplaySource(null); }, [mode]); + + useEffect(() => { + if (!selectedAlert && !selectedClassifierHit && !selectedDarkEvent) { + return; + } + + const dismissDrawers = () => { + setSelectedAlert(null); + setSelectedClassifierHit(null); + setSelectedDarkEvent(null); + }; + + const handlePointerDown = (event: MouseEvent) => { + if ((event.target as Element | null)?.closest(".drawer")) { + return; + } + dismissDrawers(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + dismissDrawers(); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedAlert, selectedClassifierHit, selectedDarkEvent]); + const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); @@ -3800,7 +4259,9 @@ const useTerminalState = () => { freshnessMs: LIVE_OPTIONS_STALE_MS, retentionLimit: LIVE_HOT_WINDOW_OPTIONS, captureScroll: optionsAnchor.capture, - onNewItems: optionsScroll.onNewItems + onNewItems: optionsScroll.onNewItems, + shouldHold: () => !optionsScroll.isAtTopRef.current, + resumeSignal: optionsScroll.resumeTick }); const liveEquities = usePausableTapeView({ enabled: mode === "live", @@ -3809,7 +4270,9 @@ const useTerminalState = () => { lastUpdate: liveSession.lastUpdate, freshnessMs: LIVE_EQUITIES_STALE_MS, captureScroll: equitiesAnchor.capture, - onNewItems: equitiesScroll.onNewItems + onNewItems: equitiesScroll.onNewItems, + shouldHold: () => !equitiesScroll.isAtTopRef.current, + resumeSignal: equitiesScroll.resumeTick }); const liveFlow = usePausableTapeView({ enabled: mode === "live", @@ -3819,6 +4282,8 @@ const useTerminalState = () => { freshnessMs: LIVE_FLOW_STALE_MS, captureScroll: flowAnchor.capture, onNewItems: flowScroll.onNewItems, + shouldHold: () => !flowScroll.isAtTopRef.current, + resumeSignal: flowScroll.resumeTick, getItemTs: (item) => item.source_ts }); @@ -4157,6 +4622,39 @@ const useTerminalState = () => { return traceId.slice(idx); }, []); + const classifierHitsByPacketId = useMemo(() => { + const map = new Map(); + for (const hit of classifierHitsFeed.items) { + const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id); + if (!packetId) { + continue; + } + map.set(packetId, [...(map.get(packetId) ?? []), hit]); + } + return map; + }, [classifierHitsFeed.items, extractPacketIdFromClassifierHitTrace]); + + const packetIdByOptionTraceId = useMemo(() => { + const map = new Map(); + for (const packet of flowFeed.items) { + for (const member of packet.members) { + map.set(member, packet.id); + } + } + return map; + }, [flowFeed.items]); + + const classifierDecorByOptionTraceId = useMemo(() => { + const map = new Map(); + for (const [traceId, packetId] of packetIdByOptionTraceId) { + const primary = selectPrimaryClassifierHit(classifierHitsByPacketId.get(packetId) ?? []); + if (primary) { + map.set(traceId, buildClassifierDecor(primary)); + } + } + return map; + }, [classifierHitsByPacketId, packetIdByOptionTraceId]); + const selectedClassifierPacketId = useMemo(() => { if (!selectedClassifierHit) { return null; @@ -4268,19 +4766,31 @@ const useTerminalState = () => { if (!matchesOptionPrintFilters(print, flowFilters)) { return false; } + if ( + selectedInstrument?.kind === "option-contract" && + normalizeContractId(print.option_contract_id) !== selectedInstrument.contractId + ) { + return false; + } if (tickerSet.size === 0) { - return true; + return ( + !instrumentUnderlying || + extractUnderlying(normalizeContractId(print.option_contract_id)) === instrumentUnderlying + ); } return matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id))); }); - }, [flowFilters, optionsFeed.items, matchesTicker, tickerSet]); + }, [flowFilters, optionsFeed.items, matchesTicker, tickerSet, selectedInstrument, instrumentUnderlying]); const filteredEquities = useMemo(() => { if (tickerSet.size === 0) { + if (instrumentUnderlying) { + return equitiesFeed.items.filter((print) => print.underlying_id.toUpperCase() === instrumentUnderlying); + } return equitiesFeed.items; } return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id)); - }, [equitiesFeed.items, matchesTicker, tickerSet]); + }, [equitiesFeed.items, matchesTicker, tickerSet, instrumentUnderlying]); const equitiesSilentWarning = shouldShowEquitiesSilentFeedWarning({ wsStatus: liveSession.status, @@ -4603,6 +5113,9 @@ const useTerminalState = () => { setSelectedDarkEvent, selectedClassifierHit, setSelectedClassifierHit, + selectedInstrument, + setSelectedInstrument, + selectedInstrumentLabel, filterInput, setFilterInput, flowFilters, @@ -4632,6 +5145,9 @@ const useTerminalState = () => { equityPrintMap, equityJoinMap: resolvedEquityJoinMap, flowPacketMap: resolvedFlowPacketMap, + classifierHitsByPacketId, + packetIdByOptionTraceId, + classifierDecorByOptionTraceId, selectedEvidence, selectedFlowPacket, selectedDarkEvidence, @@ -4670,13 +5186,10 @@ const useTerminal = (): TerminalState => { return value; }; -const NAV_ITEMS = [ - { href: "/", label: "Overview" }, - { href: "/tape", label: "Tape" }, - { href: "/signals", label: "Signals" }, - { href: "/charts", label: "Charts" }, - { href: "/replay", label: "Replay" } -]; +export const NAV_ITEMS = [ + { href: "/", label: "Home" }, + { href: "/tape", label: "Tape" } +] as const; type PageFrameProps = { title: string; @@ -4717,6 +5230,7 @@ const FlowFilterSection = ({ }; export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) => { + const pathname = usePathname(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const activeCount = countActiveFlowFilterGroups(filters); @@ -4775,6 +5289,10 @@ export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) }; }, [open]); + useEffect(() => { + setOpen(false); + }, [pathname]); + return (
+ ); +}; + type PaneProps = { title: string; status?: ReactNode; @@ -4939,62 +5481,6 @@ const ShellMetricStrip = () => { ); }; -const FeedStatusBar = () => { - const state = useTerminal(); - const feeds = [ - { label: "Opt", feed: state.options }, - { label: "Eq", feed: state.equities }, - { label: "Flow", feed: state.flow }, - { label: "Alert", feed: state.alerts }, - { label: "Rule", feed: state.classifierHits }, - { label: "Dark", feed: state.inferredDark } - ]; - - return ( -
- {feeds.map(({ label, feed }) => ( -
- - {label} -
- ))} -
- ); -}; - -const OverviewBrief = () => { - const state = useTerminal(); - - return ( -
-
- Options - {formatFlowMetric(state.filteredOptions.length)} -
-
- Equities - {formatFlowMetric(state.filteredEquities.length)} -
-
- Flow - {formatFlowMetric(state.filteredFlow.length)} -
-
- Alerts - {formatFlowMetric(state.filteredAlerts.length)} -
-
- Rules - {formatFlowMetric(state.filteredClassifierHits.length)} -
-
- Dark - {formatFlowMetric(state.filteredInferredDark.length)} -
-
- ); -}; - type OptionsPaneProps = { limit?: number; }; @@ -5002,7 +5488,7 @@ type OptionsPaneProps = { const OptionsPane = ({ limit }: OptionsPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions; - const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 96); + const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 36); return ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5040,103 +5526,119 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> - {virtual.topSpacerHeight > 0 ? ( -
- ) : null} - {virtual.visibleItems.map((print) => { - const contractId = normalizeContractId(print.option_contract_id); - const contractDisplay = formatOptionContractLabel(contractId); - const quote = state.nbboMap.get(contractId); - const nbboAge = quote ? Math.abs(print.ts - quote.ts) : null; - const nbboStale = nbboAge !== null && nbboAge > NBBO_MAX_AGE_MS_SAFE; - const nbboMid = quote ? (quote.bid + quote.ask) / 2 : null; - const nbboSide = print.nbbo_side ?? classifyNbboSide(print.price, quote); - const notional = print.notional ?? print.price * print.size * 100; - - return ( -
-
-
- {contractDisplay ? ( - <> - {contractDisplay.ticker} - {contractDisplay.strike} - {contractDisplay.expiration} - - ) : ( - formatContractLabel(contractId) - )} -
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - Notional ${formatCompactUsd(notional)} - {print.conditions?.map((condition) => { - const normalized = condition.toUpperCase(); - const tone = - normalized === "SWEEP" - ? "condition-sweep" - : normalized === "ISO" - ? "condition-iso" - : "condition-neutral"; - return ( - - {normalized} - - ); - })} -
- {quote ? ( -
- Bid ${formatPrice(quote.bid)} - Ask ${formatPrice(quote.ask)} - Mid ${formatPrice(nbboMid ?? 0)} - {Math.round(nbboAge ?? 0)}ms - {nbboSide ? ( - - - {nbboSide} - - - - A - Ask - - - AA - Above Ask - - - B - Bid - - - BB - Below Bid - - - - ) : null} - {print.nbbo_side === "STALE" || nbboStale ? Stale : null} -
- ) : ( -
- - {print.nbbo_side === "STALE" ? "NBBO stale" : "NBBO missing"} - -
- )} -
-
{formatTime(print.ts)}
+
+
+
+ TIME + SYM + EXP + STRIKE + C/P + SPOT + DETAILS + TYPE + VALUE + SIDE + IV + CLASSIFIER
- ); - })} - {virtual.bottomSpacerHeight > 0 ? ( -
- ) : null} - + {virtual.topSpacerHeight > 0 ? ( +
+ ) : null} + {virtual.visibleItems.map((print) => { + const contractId = normalizeContractId(print.option_contract_id); + const parsed = parseOptionContractId(contractId); + const contractDisplay = formatOptionContractLabel(contractId); + const quote = state.nbboMap.get(contractId); + const hasPreservedNbbo = typeof print.execution_nbbo_side === "string"; + const nbboSide = + print.execution_nbbo_side ?? + print.nbbo_side ?? + (!hasPreservedNbbo ? classifyNbboSide(print.price, quote) : null); + const notional = print.notional ?? print.price * print.size * 100; + const spot = print.execution_underlying_spot; + const iv = print.execution_iv; + const decor = state.classifierDecorByOptionTraceId.get(print.trace_id); + const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase(); + const focusContract = (event: ReactMouseEvent) => { + event.stopPropagation(); + state.setSelectedInstrument({ + kind: "option-contract", + contractId, + underlyingId + }); + }; + const commonProps = { + className: `data-table-row data-table-row-button data-table-row-classified data-table-row-options${decor ? ` is-classified classifier-${decor.tone}` : ""}`, + style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined + }; + const cells = ( + <> + {formatTime(print.ts)} + + + + + + + + + + + + + {typeof spot === "number" ? formatPrice(spot) : "--"} + + {formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"} + + {print.option_type ?? "--"} + ${formatCompactUsd(notional)} + + {nbboSide ? ( + {nbboSide} + ) : ( + "--" + )} + + {typeof iv === "number" ? formatPct(iv) : "--"} + {decor ? humanizeClassifierId(decor.family) : "--"} + + ); + + return decor ? ( + + ) : ( +
+ {cells} +
+ ); + })} + {virtual.bottomSpacerHeight > 0 ? ( +
+ ) : null} +
+
)}
@@ -5150,7 +5652,7 @@ type EquitiesPaneProps = { const EquitiesPane = ({ limit }: EquitiesPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredEquities.slice(0, limit) : state.filteredEquities; - const virtual = useVirtualList(items, state.equitiesScroll.listRef, !limit, 78); + const virtual = useVirtualList(items, state.equitiesScroll.listRef, !limit, 36); return ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5190,32 +5692,47 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+
+ TIME + SYM + PRICE + SIZE + VENUE + TAPE +
{virtual.topSpacerHeight > 0 ? ( -
+
) : null} {virtual.visibleItems.map((print) => ( -
-
-
{print.underlying_id}
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - {print.offExchangeFlag ? ( - Off-Ex - ) : ( - Lit - )} -
-
-
{formatTime(print.ts)}
+
+ {formatTime(print.ts)} + + + + ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + {print.offExchangeFlag ? "Off-Ex" : "Lit"}
))} {virtual.bottomSpacerHeight > 0 ? ( -
+
) : null} - +
+
)}
@@ -5230,7 +5747,7 @@ type FlowPaneProps = { const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredFlow.slice(0, limit) : state.filteredFlow; - const virtual = useVirtualList(items, state.flowScroll.listRef, !limit, 104); + const virtual = useVirtualList(items, state.flowScroll.listRef, !limit, 44); return ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5268,9 +5785,21 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+
+ TIME + CONTRACT + PRINTS + SIZE + NOTIONAL + WINDOW + STRUCTURE + NBBO + QUALITY +
{virtual.topSpacerHeight > 0 ? ( -
+
) : null} {virtual.visibleItems.map((packet) => { const features = packet.features ?? {}; @@ -5304,57 +5833,44 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { const nbboAge = parseNumber(packet.join_quality.nbbo_age_ms, Number.NaN); const nbboStale = parseNumber(packet.join_quality.nbbo_stale, 0) > 0; const nbboMissing = parseNumber(packet.join_quality.nbbo_missing, 0) > 0; + const structureLabel = structureType + ? `${structureType.replace(/_/g, " ")}${structureRights ? ` ${structureRights}` : ""}${structureLegs > 0 ? ` ${structureLegs}L` : ""}${structureStrikes > 0 ? ` ${structureStrikes}K` : ""}` + : "--"; + const nbboLabel = Number.isFinite(nbboBid) && Number.isFinite(nbboAsk) + ? `${formatPrice(nbboBid)} x ${formatPrice(nbboAsk)}` + : Number.isFinite(nbboMid) + ? `Mid ${formatPrice(nbboMid)}` + : "--"; + const qualityLabel = [ + Number.isFinite(aggressiveCoverage) && aggressiveCoverage > 0 + ? `Agg ${formatPct(aggressiveBuyRatio)}/${formatPct(aggressiveSellRatio)} ${formatPct(aggressiveCoverage)} cov` + : null, + Number.isFinite(insideRatio) && insideRatio > 0 ? `In ${formatPct(insideRatio)}` : null, + Number.isFinite(nbboSpread) ? `Spr ${formatPrice(nbboSpread)}` : null, + Number.isFinite(nbboAge) ? `${Math.round(nbboAge)}ms` : null, + nbboStale ? "Stale" : null, + nbboMissing ? "Missing" : null + ].filter(Boolean).join(" | "); return ( -
-
-
{contract}
-
- {formatFlowMetric(count)} prints - {formatFlowMetric(totalSize)} size - Notional ${formatUsd(notional)} - {windowMs > 0 ? {formatFlowMetric(windowMs, "ms")} : null} - {structureType ? ( - - {structureType.replace(/_/g, " ")} - {structureRights ? ` ${structureRights}` : ""} - {structureLegs > 0 ? ` ${structureLegs}L` : ""} - {structureStrikes > 0 ? ` ${structureStrikes}K` : ""} - - ) : null} - {Number.isFinite(aggressiveCoverage) && aggressiveCoverage > 0 ? ( - - Agg {formatPct(aggressiveBuyRatio)} / {formatPct(aggressiveSellRatio)} - {Number.isFinite(insideRatio) && insideRatio > 0 - ? ` · In ${formatPct(insideRatio)}` - : ""} - {` · ${formatPct(aggressiveCoverage)} cov`} - - ) : null} - {Number.isFinite(nbboBid) && Number.isFinite(nbboAsk) ? ( - - NBBO ${formatPrice(nbboBid)} x ${formatPrice(nbboAsk)} - - ) : null} - {Number.isFinite(nbboMid) ? Mid ${formatPrice(nbboMid)} : null} - {Number.isFinite(nbboSpread) ? ( - Spread ${formatPrice(nbboSpread)} - ) : null} - {Number.isFinite(nbboAge) ? {Math.round(nbboAge)}ms : null} - {nbboStale ? NBBO stale : null} - {nbboMissing ? NBBO missing : null} -
-
-
- {formatTime(startTs)} → {formatTime(endTs)} -
+
+ {formatTime(startTs)} → {formatTime(endTs)} + {contract} + {formatFlowMetric(count)} + {formatFlowMetric(totalSize)} + ${formatUsd(notional)} + {windowMs > 0 ? formatFlowMetric(windowMs, "ms") : "--"} + {structureLabel} + {nbboLabel} + {qualityLabel || "--"}
); })} {virtual.bottomSpacerHeight > 0 ? ( -
+
) : null} - +
+
)}
@@ -5370,7 +5886,7 @@ type AlertsPaneProps = { const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts; - const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 92); + const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 46); return ( } > {withStrip ? : null} -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5408,9 +5924,19 @@ const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+
+ TIME + ALERT + SEV + SCORE + HITS + DIR + NOTE +
{virtual.topSpacerHeight > 0 ? ( -
+
) : null} {virtual.visibleItems.map((alert) => { const primary = alert.hits[0]; @@ -5419,7 +5945,7 @@ const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => return ( ); })} {virtual.bottomSpacerHeight > 0 ? ( -
+
) : null} - +
+
)}
@@ -5464,7 +5983,7 @@ type ClassifierPaneProps = { const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredClassifierHits.slice(0, limit) : state.filteredClassifierHits; - const virtual = useVirtualList(items, state.classifierScroll.listRef, !limit, 88); + const virtual = useVirtualList(items, state.classifierScroll.listRef, !limit, 44); return ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5501,35 +6020,40 @@ const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+
+ TIME + RULE + DIR + CONF + NOTE +
{virtual.topSpacerHeight > 0 ? ( -
+
) : null} {virtual.visibleItems.map((hit) => { const direction = normalizeDirection(hit.direction); return ( ); })} {virtual.bottomSpacerHeight > 0 ? ( -
+
) : null} - +
+
)}
@@ -5544,7 +6068,7 @@ type DarkPaneProps = { const DarkPane = ({ limit, className }: DarkPaneProps) => { const state = useTerminal(); const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark; - const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 88); + const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 44); return ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5581,9 +6105,18 @@ const DarkPane = ({ limit, className }: DarkPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+
+ TIME + TYPE + SYM + CONF + EVIDENCE + NOTE +
{virtual.topSpacerHeight > 0 ? ( -
+
) : null} {virtual.visibleItems.map((event) => { const underlying = inferDarkUnderlying(event, state.equityPrintMap, state.equityJoinMap); @@ -5591,7 +6124,7 @@ const DarkPane = ({ limit, className }: DarkPaneProps) => { return ( ); })} {virtual.bottomSpacerHeight > 0 ? ( -
+
) : null} - +
+
)}
@@ -5802,11 +6332,18 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
-
+ {state.selectedInstrumentLabel && state.selectedInstrument?.kind !== "option-contract" ? ( + + {state.selectedInstrumentLabel} + + + ) : null}