diff --git a/apps/web/app/charts/page.tsx b/apps/web/app/charts/page.tsx new file mode 100644 index 0000000..b1b8870 --- /dev/null +++ b/apps/web/app/charts/page.tsx @@ -0,0 +1,5 @@ +import { ChartsRoute } from "../terminal"; + +export default function Page() { + return ; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2da9583..0769f2a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,645 +1,803 @@ :root { - color-scheme: light; - font-family: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - "Liberation Mono", "Courier New", monospace; - background: #efece6; - color: #1d1d1b; - --panel: #fff6e8; - --panel-border: #d9cdb8; - --accent: #2f6d4f; - --accent-soft: rgba(47, 109, 79, 0.18); - --warning: #c46f2a; - --replay: #1f4a7b; - --grid: rgba(82, 64, 36, 0.12); + color-scheme: dark; + --bg: #06080b; + --bg-elevated: #0b1016; + --bg-pane: #111820; + --bg-pane-2: #0d141b; + --bg-soft: rgba(255, 255, 255, 0.03); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 177, 48, 0.35); + --text: #e6edf4; + --text-dim: #90a0b2; + --text-faint: #6e7b8c; + --accent: #f5a623; + --accent-soft: rgba(245, 166, 35, 0.12); + --green: #25c17a; + --green-soft: rgba(37, 193, 122, 0.12); + --red: #ff6b5f; + --red-soft: rgba(255, 107, 95, 0.14); + --blue: #4da3ff; + --blue-soft: rgba(77, 163, 255, 0.14); + --rail-width: 236px; + --topbar-height: 76px; } * { box-sizing: border-box; } +html, body { margin: 0; - min-height: 100vh; + min-height: 100%; } -.dashboard { +body { min-height: 100vh; - padding: 48px 8vw 72px; - display: grid; - gap: 32px; + font-family: var(--font-sans), sans-serif; + color: var(--text); background: - radial-gradient(circle at top left, #fff7df 0%, #efece6 56%), - repeating-linear-gradient( - 90deg, - transparent, - transparent 44px, - var(--grid) 45px, - var(--grid) 46px - ); + radial-gradient(circle at top left, rgba(245, 166, 35, 0.12), transparent 26%), + linear-gradient(180deg, #081017 0%, #05070a 100%); } -.header { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 24px; - flex-wrap: wrap; +a { + color: inherit; + text-decoration: none; } -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.4em; - font-size: 0.7rem; - margin: 0 0 12px; - color: #6f5b39; +button, +input { + font: inherit; } -h1 { - margin: 0 0 12px; - font-size: clamp(2.2rem, 4vw, 3.4rem); - letter-spacing: 0.15em; - text-transform: uppercase; -} - -.subtitle { - margin: 0; - max-width: 460px; - line-height: 1.6; - color: #4e3e25; -} - -.summary { +.terminal-shell { + min-height: 100vh; display: grid; - gap: 12px; - padding: 16px 20px; - border-radius: 16px; - border: 1px solid var(--panel-border); - background: #fffdf7; - min-width: 220px; + grid-template-columns: var(--rail-width) minmax(0, 1fr); + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 18%), + var(--bg); + background-size: 32px 32px, 32px 32px, 100% 100%, auto; } -.filter-bar { +.terminal-rail { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 18px; + display: flex; + flex-direction: column; + gap: 24px; + background: linear-gradient(180deg, rgba(11, 16, 22, 0.96), rgba(6, 8, 11, 0.98)); + border-right: 1px solid var(--border); +} + +.terminal-brand { + display: grid; + gap: 4px; +} + +.terminal-brand-kicker { + font-family: var(--font-display), sans-serif; + font-size: 0.78rem; + letter-spacing: 0.24em; + color: var(--accent); +} + +.terminal-brand-name { + font-family: var(--font-display), sans-serif; + font-size: 1.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.terminal-nav { + display: grid; + gap: 6px; +} + +.terminal-nav-link { + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.78rem; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.terminal-nav-link:hover { + border-color: var(--border); + color: var(--text); + background: var(--bg-soft); +} + +.terminal-nav-link-active { + border-color: var(--border-strong); + color: var(--text); + background: linear-gradient(90deg, rgba(245, 166, 35, 0.12), rgba(245, 166, 35, 0.04)); +} + +.shell-metrics { + margin-top: auto; + display: grid; + gap: 10px; +} + +.shell-metric { + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.shell-metric-label, +.overview-label, +.focus-label, +.terminal-filter-label, +.summary-title, +.filter-label { + display: block; + margin-bottom: 6px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.68rem; +} + +.shell-metric-value, +.overview-cell strong, +.focus-value { + font-family: var(--font-mono), monospace; + font-size: 0.92rem; +} + +.terminal-frame { + min-width: 0; + display: grid; + grid-template-rows: var(--topbar-height) minmax(0, 1fr); +} + +.terminal-topbar { + position: sticky; + top: 0; + z-index: 20; display: flex; align-items: center; justify-content: space-between; - gap: 20px; - padding: 16px 20px; - border-radius: 18px; - border: 1px solid var(--panel-border); - background: rgba(255, 253, 247, 0.9); + gap: 18px; + padding: 14px 24px; + background: rgba(7, 10, 14, 0.92); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); } -.filter-label { - margin: 0 0 6px; +.feed-status-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.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; + height: 8px; + border-radius: 999px; + 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-disconnected .feed-status-dot, +.status-disconnected .status-dot, +.chart-status-disconnected .chart-dot { + background: var(--red); + box-shadow: 0 0 0 4px rgba(255, 107, 95, 0.12); +} + +.terminal-topbar-controls { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap; +} + +.terminal-filter { + display: grid; + gap: 2px; + min-width: clamp(220px, 22vw, 320px); + flex: 0 1 320px; +} + +.terminal-filter-label { + margin-bottom: 0; + line-height: 1; +} + +.terminal-filter-field { + position: relative; + display: flex; + align-items: center; + min-height: 34px; +} + +.terminal-filter-field::before, +.terminal-filter-field::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + transition: + opacity 160ms ease, + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease; +} + +.terminal-filter-field::before { + height: 1px; + background: linear-gradient(90deg, rgba(245, 166, 35, 0.88), rgba(245, 166, 35, 0.14)); + opacity: 0.72; +} + +.terminal-filter-field::after { + height: 2px; + background: linear-gradient(90deg, rgba(255, 216, 154, 0.98), rgba(245, 166, 35, 0.92)); + transform: scaleX(0.18); + transform-origin: left center; + opacity: 0; +} + +.terminal-input { + min-width: 0; + width: 100%; + padding: 0 0 6px; + border: none; + border-radius: 0; + background: transparent; + color: var(--text); + font-family: var(--font-mono), monospace; + font-size: 0.92rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.terminal-input::placeholder { + color: rgba(193, 203, 224, 0.58); + font-size: 0.86rem; +} + +.terminal-filter:focus-within .terminal-filter-label { + color: #ffd89a; +} + +.terminal-filter:focus-within .terminal-filter-field::before { + background: linear-gradient(90deg, rgba(255, 216, 154, 0.9), rgba(245, 166, 35, 0.26)); + opacity: 0.94; +} + +.terminal-filter:focus-within .terminal-filter-field::after { + transform: scaleX(1); + opacity: 1; + box-shadow: 0 0 18px rgba(245, 166, 35, 0.34); +} + +.terminal-filter:focus-within .terminal-input { + color: #fff1cf; + text-shadow: 0 0 14px rgba(245, 166, 35, 0.16); +} + +.terminal-input:focus-visible, +.filter-input:focus-visible { + outline: none; +} + +.terminal-button, +.mode-button, +.filter-clear, +.jump-button, +.pause-button, +.interval-button, +.overlay-toggle, +.drawer-close { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + cursor: pointer; text-transform: uppercase; - letter-spacing: 0.3em; - font-size: 0.7rem; - color: #6f5b39; + letter-spacing: 0.12em; + font-size: 0.72rem; } -.filter-help { +.terminal-button:disabled, +.filter-clear:disabled, +.jump-button:disabled { + opacity: 0.45; + cursor: default; +} + +.terminal-button-primary, +.interval-button.active, +.overlay-toggle.overlay-toggle-on, +.mode-button { + border-color: var(--border-strong); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.18), rgba(245, 166, 35, 0.08)); + color: #ffd89a; +} + +.pause-button { + padding: 7px 10px; + font-size: 0.66rem; +} + +.terminal-content { + min-width: 0; + padding: 34px 24px 28px; +} + +.page-shell { + display: grid; + gap: 18px; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.page-title, +h1, +h2, +h3 { margin: 0; - color: #4e3e25; - font-size: 0.9rem; + font-family: var(--font-display), sans-serif; + text-transform: uppercase; + letter-spacing: 0.08em; } -.filter-controls { +.page-title { + font-size: clamp(2rem, 3vw, 2.8rem); +} + +.page-actions { display: flex; align-items: center; gap: 10px; } -.filter-input { - border: 1px solid rgba(111, 91, 57, 0.35); - border-radius: 999px; - padding: 8px 14px; - min-width: 220px; - background: #fffdf7; - font-family: inherit; - font-size: 0.9rem; - color: #1d1d1b; -} - -.filter-input:focus-visible { - outline: 2px solid rgba(47, 109, 79, 0.3); - outline-offset: 2px; -} - -.filter-clear { - border: 1px solid rgba(111, 91, 57, 0.35); - border-radius: 999px; - padding: 6px 12px; - background: rgba(111, 91, 57, 0.08); - color: #6f5b39; - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.filter-clear:disabled { - opacity: 0.5; - cursor: default; -} - -.summary-title { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.28em; - color: #6f5b39; -} - -.summary-value { - font-size: 1rem; -} - -.mode-button { - border: 1px solid rgba(31, 74, 123, 0.35); - border-radius: 999px; - padding: 8px 14px; - background: rgba(31, 74, 123, 0.12); - color: #1f4a7b; - font-size: 0.75rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.mode-button:focus-visible { - outline: 2px solid rgba(31, 74, 123, 0.4); - outline-offset: 2px; -} - -.cards { +.overview-strip, +.replay-matrix { display: grid; - gap: 28px; + gap: 12px; grid-template-columns: repeat(6, minmax(0, 1fr)); } -.card-options { - grid-column: span 4; +.overview-cell { + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)); } -.card-chart { - grid-column: span 6; -} - -.card-equities { - grid-column: span 2; -} - -.card-flow, -.card-alerts, -.card-classifiers, -.card-dark { - grid-column: span 2; -} - -.status { +.page-grid { display: grid; - gap: 8px; - padding: 16px 20px; - border-radius: 16px; - border: 1px solid var(--panel-border); - background: #fffdf7; - min-width: 220px; + gap: 16px; + align-items: stretch; } -.status-compact { - padding: 12px 16px; - min-width: 180px; +.page-grid-overview { + grid-template-columns: repeat(3, minmax(0, 1fr)); } -.status-paused { - background: #fff3e4; +.page-grid-tape { + grid-template-columns: minmax(0, 1.5fr) minmax(320px, 1fr); } -.status-dot { - width: 12px; - height: 12px; - border-radius: 999px; - background: var(--warning); - box-shadow: 0 0 0 4px rgba(196, 111, 42, 0.2); +.page-grid-signals { + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); + height: calc(100vh - var(--topbar-height) - 172px); + min-height: 620px; } -.status-connected .status-dot { - background: var(--accent); - box-shadow: 0 0 0 4px var(--accent-soft); +.page-grid-charts { + grid-template-columns: minmax(0, 1.7fr) minmax(320px, 1fr); } -.status-replay .status-dot { - background: var(--replay); - box-shadow: 0 0 0 4px rgba(31, 74, 123, 0.18); +.page-grid-replay { + grid-template-columns: repeat(3, minmax(0, 1fr)); } -.status-connecting .status-dot { - animation: pulse 1.2s ease-in-out infinite; +.page-grid-overview > :nth-child(1), +.page-grid-tape > :nth-child(1), +.page-grid-replay > :nth-child(1) { + grid-column: 1 / -1; } -.status span { - font-size: 0.95rem; -} - -.timestamp { - font-size: 0.8rem; - color: #6f5b39; -} - -.pause-button { - border: 1px solid rgba(47, 109, 79, 0.3); - border-radius: 999px; - padding: 6px 12px; - background: rgba(47, 109, 79, 0.12); - color: #2f6d4f; - font-size: 0.75rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.status-paused .pause-button { - border-color: rgba(196, 111, 42, 0.4); - background: rgba(196, 111, 42, 0.16); - color: #8c4a16; -} - -.pause-button:focus-visible { - outline: 2px solid rgba(47, 109, 79, 0.4); - outline-offset: 2px; -} - -.card-controls { +.terminal-pane { + min-width: 0; + height: 100%; display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 12px; - width: 100%; - margin-bottom: 20px; - flex: 0 0 auto; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), + var(--bg-pane); + overflow: hidden; } -.chart-controls { +.terminal-pane-head { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - margin-bottom: 18px; - flex-wrap: wrap; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); } -.chart-intervals { +.terminal-pane-title-row { display: flex; - flex-wrap: wrap; + align-items: center; + gap: 14px; + min-width: 0; +} + +.terminal-pane-title { + font-size: 1rem; +} + +.terminal-pane-status { + min-width: 0; +} + +.terminal-pane-actions, +.card-controls, +.chart-controls { + display: flex; + align-items: center; + justify-content: flex-end; gap: 8px; + flex-wrap: wrap; } -.interval-button { - border: 1px solid rgba(111, 91, 57, 0.35); - border-radius: 999px; - padding: 6px 12px; - background: rgba(111, 91, 57, 0.08); - color: #6f5b39; - font-size: 0.75rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.interval-button.active { - border-color: rgba(47, 109, 79, 0.6); - background: rgba(47, 109, 79, 0.1); - color: #2f6d4f; - box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12); -} - -.interval-button:focus-visible { - outline: 2px solid rgba(47, 109, 79, 0.4); - outline-offset: 2px; -} - -.chart-hint { - font-size: 0.8rem; - color: #6f5b39; +.terminal-pane-body, +.card-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 14px; + padding: 16px 18px 18px; } .chart-panel { display: grid; - gap: 16px; + gap: 12px; } .chart-meta { display: flex; align-items: center; - gap: 16px; + gap: 12px; flex-wrap: wrap; font-size: 0.8rem; - color: #5b4c34; -} - -.overlay-toggle { - border: 1px solid rgba(31, 74, 123, 0.35); - border-radius: 999px; - padding: 6px 12px; - background: rgba(31, 74, 123, 0.12); - color: #1f4a7b; - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.overlay-toggle.overlay-toggle-on { - border-color: rgba(31, 74, 123, 0.6); - background: rgba(31, 74, 123, 0.2); -} - -.overlay-toggle:focus-visible { - outline: 2px solid rgba(31, 74, 123, 0.4); - outline-offset: 2px; -} - -.overlay-legend { - color: #6f5b39; - font-size: 0.75rem; -} - -@media (max-width: 700px) { - .overlay-legend { - flex: 1 1 100%; - } + color: var(--text-dim); } .chart-status { display: inline-flex; align-items: center; gap: 8px; - font-weight: 600; } -.chart-dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: rgba(111, 91, 57, 0.4); -} - -.chart-status-connected .chart-dot { - background: rgba(47, 109, 79, 0.8); -} - -.chart-status-connecting .chart-dot { - background: rgba(31, 74, 123, 0.8); - animation: pulse 1.4s ease-in-out infinite; -} - -.chart-status-disconnected .chart-dot { - background: rgba(196, 111, 42, 0.8); -} - -.chart-meta-time { - color: #6f5b39; +.chart-meta-time, +.chart-hint, +.overlay-legend, +.timestamp, +.drawer-subtitle, +.drawer-note, +.drawer-empty, +.note, +.time { + color: var(--text-dim); } .chart-surface { width: 100%; - height: 360px; - border-radius: 18px; - border: 1px solid rgba(217, 205, 184, 0.6); - background: #fffdf7; + height: 460px; + border-radius: 12px; + border: 1px solid var(--border); + background: #0b1218; overflow: hidden; } .chart-empty { - margin-top: -4px; + margin-top: -2px; +} + +.chart-intervals { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.status span, +.timestamp, +.missed-count, +.chart-hint, +.meta, +.note, +.drawer-note, +.drawer-row-meta { + font-family: var(--font-mono), monospace; +} + +.status-replay .status-dot { + background: var(--blue); + box-shadow: 0 0 0 4px rgba(77, 163, 255, 0.14); +} + +.status-inline { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + min-width: 0; + font-family: var(--font-mono), monospace; +} + +.status-inline-label { + color: var(--text); + font-size: 0.82rem; +} + +.status-inline-meta { + color: var(--text-dim); + font-size: 0.72rem; +} + +.status-inline-counter { + min-width: 82px; + color: var(--accent); + font-size: 0.72rem; + opacity: 0; + transition: opacity 0.15s ease; +} + +.status-inline-counter-visible { + opacity: 1; } .tape-controls { display: flex; - flex-direction: column; - align-items: flex-end; + align-items: center; gap: 6px; - min-width: 120px; -} - -.jump-button { - border: 1px solid rgba(111, 91, 57, 0.35); - border-radius: 999px; - padding: 6px 12px; - background: rgba(111, 91, 57, 0.08); - color: #6f5b39; - font-size: 0.75rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; -} - -.jump-button:disabled { - opacity: 0.5; - cursor: default; -} - -.jump-button:not(:disabled) { - border-color: rgba(47, 109, 79, 0.6); - background: rgba(47, 109, 79, 0.1); - color: #2f6d4f; - box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.15); -} - -.jump-button:focus-visible { - outline: 2px solid rgba(111, 91, 57, 0.3); - outline-offset: 2px; + flex-wrap: wrap; } .missed-count { - padding: 4px 10px; - border-radius: 999px; - border: 1px solid rgba(31, 74, 123, 0.25); - background: rgba(31, 74, 123, 0.12); - color: #1f4a7b; - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; - max-height: 0; - opacity: 0; - transform: translateY(-6px); - overflow: hidden; - transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease; -} - -.tape-controls-active .jump-button { - transform: translateY(-6px); - transition: transform 0.2s ease; -} - -.tape-controls-active .missed-count { - max-height: 24px; - opacity: 1; - transform: translateY(0); -} - -.card { - border: 1px solid var(--panel-border); - border-radius: 24px; - background: var(--panel); - box-shadow: 0 30px 60px rgba(66, 45, 18, 0.14); - padding: 28px; -} - -.card-header { - display: flex; - flex-direction: column; - gap: 6px; - align-items: flex-start; - margin-bottom: 24px; -} - -.card-header h2 { - margin: 0 0 6px; - font-size: 1.4rem; -} - -.card-subtitle { - margin: 0; - color: #5b4c34; + min-width: 62px; + font-size: 0.72rem; + color: var(--accent); + text-align: right; } .list { - display: grid; - gap: 14px; - height: 480px; + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 10px; overflow: auto; - padding-right: 6px; - overflow-anchor: none; - scrollbar-gutter: stable; + padding: 12px; + padding-right: 8px; + border: 1px solid var(--border); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.09)); + align-self: stretch; } +.terminal-list-compact { + flex: 0 0 auto; + max-height: 260px; +} + +.page-grid-overview > :not(:first-child), +.page-grid-replay > :not(:first-child) { + height: clamp(430px, 58vh, 760px); +} + +.page-grid-signals > .terminal-pane { + height: 100%; + min-height: 0; +} + +.page-grid-tape > :first-child { + height: clamp(460px, 64vh, 880px); +} + +.page-grid-tape > :not(:first-child) { + height: clamp(400px, 50vh, 680px); +} + +.page-grid-charts > :first-child { + height: auto; +} + +.page-grid-charts > :last-child { + height: clamp(430px, 58vh, 760px); +} .row { display: flex; justify-content: space-between; - gap: 16px; - padding: 16px 18px; - border-radius: 18px; - background: rgba(255, 255, 255, 0.72); - border: 1px solid rgba(217, 205, 184, 0.6); + gap: 14px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.018)); } .row-button { width: 100%; text-align: left; - cursor: pointer; - font: inherit; color: inherit; } .row-button:hover { - border-color: rgba(47, 109, 79, 0.4); - box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12); + border-color: rgba(245, 166, 35, 0.25); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.07), rgba(255, 255, 255, 0.018)); } -.row-button:focus-visible { - outline: 2px solid rgba(47, 109, 79, 0.4); - outline-offset: 2px; -} - -.contract { - font-weight: 600; +.contract, +.drawer-row-title { margin-bottom: 6px; + font-size: 0.92rem; + font-weight: 600; } -.meta { +.meta, +.drawer-row-meta, +.flow-meta { display: flex; flex-wrap: wrap; - gap: 12px; - font-size: 0.85rem; - color: #5b4c34; + gap: 10px; + font-size: 0.76rem; } -.pill { - padding: 2px 8px; +.pill, +.drawer-chip, +.flag { + display: inline-flex; + align-items: center; + padding: 3px 8px; border-radius: 999px; - font-size: 0.7rem; + border: 1px solid var(--border); + font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase; - border: 1px solid rgba(111, 91, 57, 0.35); - color: #6f5b39; - background: rgba(111, 91, 57, 0.12); } .structure-tag { - border-color: rgba(39, 84, 138, 0.45); - color: #27548a; - background: rgba(39, 84, 138, 0.12); + border-color: rgba(77, 163, 255, 0.36); + color: #9bcbff; + background: var(--blue-soft); } -.aggressor-tag { - border-color: rgba(93, 70, 144, 0.45); - color: #5d4690; - background: rgba(93, 70, 144, 0.12); +.aggressor-tag, +.direction-bullish, +.severity-low, +.flag { + border-color: rgba(37, 193, 122, 0.34); + color: #98f0c0; + background: var(--green-soft); +} + +.direction-bearish, +.severity-high, +.nbbo-missing { + border-color: rgba(255, 107, 95, 0.34); + color: #ffc3bd; + background: var(--red-soft); +} + +.direction-neutral, +.severity-medium, +.flag-muted, +.nbbo-stale { + border-color: rgba(77, 163, 255, 0.26); + color: #bddcff; + background: var(--blue-soft); } .nbbo-meta { - font-size: 0.72rem; - color: #6f5b39; + margin-top: 8px; } .nbbo-side { position: relative; display: inline-flex; align-items: center; - margin-left: 4px; } .nbbo-tag { padding: 2px 6px; border-radius: 999px; - border: 1px solid rgba(111, 91, 57, 0.35); - font-size: 0.7rem; - letter-spacing: 0.08em; + border: 1px solid var(--border); + font-size: 0.68rem; text-transform: uppercase; } -.nbbo-tag-a { - border-color: rgba(47, 109, 79, 0.5); - color: #2f6d4f; - background: rgba(47, 109, 79, 0.16); -} - +.nbbo-tag-a, .nbbo-tag-aa { - border-color: rgba(26, 87, 60, 0.6); - color: #1a573c; - background: rgba(26, 87, 60, 0.2); -} - -.nbbo-tag-b { - border-color: rgba(140, 74, 22, 0.5); - color: #8c4a16; - background: rgba(196, 111, 42, 0.18); + border-color: rgba(37, 193, 122, 0.34); + color: #98f0c0; + background: var(--green-soft); } +.nbbo-tag-b, .nbbo-tag-bb { - border-color: rgba(110, 44, 12, 0.6); - color: #6e2c0c; - background: rgba(110, 44, 12, 0.2); + border-color: rgba(255, 107, 95, 0.34); + color: #ffc3bd; + background: var(--red-soft); } .nbbo-tooltip { position: absolute; right: 0; - bottom: 100%; - transform: translateY(-6px); + bottom: calc(100% + 10px); display: grid; gap: 4px; - padding: 8px 10px; + padding: 10px; border-radius: 10px; - border: 1px solid rgba(217, 205, 184, 0.8); - background: #fffdf7; - box-shadow: 0 12px 26px rgba(66, 45, 18, 0.18); + border: 1px solid var(--border); + background: rgba(7, 10, 14, 0.96); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); opacity: 0; pointer-events: none; + transform: translateY(8px); transition: opacity 0.15s ease, transform 0.15s ease; - z-index: 2; - white-space: nowrap; + z-index: 5; } .nbbo-tooltip-row { @@ -647,82 +805,106 @@ h1 { align-items: center; gap: 6px; font-size: 0.68rem; - color: #6f5b39; } .nbbo-side:hover .nbbo-tooltip, .nbbo-side:focus-within .nbbo-tooltip { opacity: 1; - transform: translateY(-10px); + transform: translateY(0); } -.nbbo-missing { - border-color: rgba(136, 58, 17, 0.4); - color: #8c3a11; - background: rgba(196, 111, 42, 0.16); +.alert-strips { + display: grid; + gap: 12px; } -.nbbo-stale { - border-color: rgba(31, 74, 123, 0.4); - color: #1f4a7b; - background: rgba(31, 74, 123, 0.12); +.alert-strip-section { + display: grid; + gap: 6px; } -.severity-high { - border-color: rgba(136, 58, 17, 0.6); - color: #8c3a11; - background: rgba(196, 111, 42, 0.2); +.alert-strip-header { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.68rem; } -.severity-medium { - border-color: rgba(31, 74, 123, 0.35); - color: #1f4a7b; - background: rgba(31, 74, 123, 0.12); +.alert-strip-bar { + display: flex; + height: 24px; + overflow: hidden; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); } -.severity-low { - border-color: rgba(47, 109, 79, 0.35); - color: #2f6d4f; - background: rgba(47, 109, 79, 0.12); +.strip-segment { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.64rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #03130a; } -.direction-bullish { - border-color: rgba(47, 109, 79, 0.35); - color: #2f6d4f; - background: rgba(47, 109, 79, 0.12); +.alert-strip-bar .severity-high, +.alert-strip-bar .direction-bearish { + background: rgba(255, 107, 95, 0.9); + color: #230705; } -.direction-bearish { - border-color: rgba(136, 58, 17, 0.6); - color: #8c3a11; - background: rgba(196, 111, 42, 0.2); +.alert-strip-bar .severity-medium, +.alert-strip-bar .direction-neutral { + background: rgba(77, 163, 255, 0.9); + color: #04111f; } -.direction-neutral { - border-color: rgba(111, 91, 57, 0.35); - color: #6f5b39; - background: rgba(111, 91, 57, 0.12); +.alert-strip-bar .severity-low, +.alert-strip-bar .direction-bullish { + background: rgba(37, 193, 122, 0.92); + color: #03130a; } -.note { - margin-top: 8px; - font-size: 0.78rem; - color: #5b4c34; +.focus-stack { + display: grid; + gap: 14px; +} + +.focus-block { + display: grid; + gap: 8px; +} + +.focus-value { + font-size: 1.4rem; +} + +.empty { + padding: 18px; + border-radius: 12px; + border: 1px dashed var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text-dim); } .drawer { position: fixed; - top: 88px; - right: 6vw; - width: min(360px, 92vw); - max-height: calc(100vh - 140px); + top: 24px; + right: 24px; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); overflow: auto; - padding: 20px; - border-radius: 20px; - border: 1px solid var(--panel-border); - background: #fffdf7; - box-shadow: 0 32px 60px rgba(66, 45, 18, 0.22); - z-index: 30; + padding: 18px; + border-radius: 14px; + border: 1px solid rgba(245, 166, 35, 0.2); + background: rgba(7, 10, 14, 0.97); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5); + z-index: 40; } .drawer-header { @@ -735,33 +917,10 @@ h1 { .drawer-eyebrow { margin: 0 0 6px; + font-size: 0.68rem; + color: var(--accent); text-transform: uppercase; - letter-spacing: 0.3em; - font-size: 0.65rem; - color: #6f5b39; -} - -.drawer h3 { - margin: 0 0 4px; - font-size: 1.1rem; -} - -.drawer-subtitle { - margin: 0; - color: #6f5b39; - font-size: 0.8rem; -} - -.drawer-close { - border: 1px solid rgba(111, 91, 57, 0.35); - border-radius: 999px; - padding: 6px 12px; - background: rgba(111, 91, 57, 0.08); - color: #6f5b39; - font-size: 0.7rem; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; + letter-spacing: 0.14em; } .drawer-meta { @@ -771,169 +930,30 @@ h1 { margin-bottom: 16px; } -.drawer-chip { - padding: 2px 8px; - border-radius: 999px; - border: 1px solid rgba(111, 91, 57, 0.35); - background: rgba(111, 91, 57, 0.08); - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.08em; -} - .drawer-section { + display: grid; + gap: 10px; margin-bottom: 18px; } .drawer-section h4 { - margin: 0 0 10px; - font-size: 0.85rem; + margin: 0; + color: var(--text-faint); text-transform: uppercase; - letter-spacing: 0.22em; - color: #6f5b39; + letter-spacing: 0.12em; + font-size: 0.72rem; } .drawer-list { display: grid; - gap: 12px; + gap: 10px; } .drawer-row { padding: 12px 14px; - border-radius: 14px; - border: 1px solid rgba(217, 205, 184, 0.6); - background: rgba(255, 255, 255, 0.75); -} - -.drawer-row-title { - font-weight: 600; - margin-bottom: 6px; -} - -.drawer-row-meta { - display: flex; - flex-wrap: wrap; - gap: 10px; - font-size: 0.75rem; - color: #5b4c34; -} - -.drawer-note { - margin: 8px 0 0; - font-size: 0.72rem; - color: #5b4c34; -} - -.drawer-empty { - margin: 0; - font-size: 0.78rem; - color: #6f5b39; -} - -.alert-strips { - display: grid; - gap: 12px; - margin-bottom: 16px; - padding: 12px 14px; - border-radius: 14px; - border: 1px solid rgba(217, 205, 184, 0.6); - background: rgba(255, 255, 255, 0.7); -} - -.alert-strip-section { - display: grid; - gap: 6px; -} - -.alert-strip-header { - display: flex; - justify-content: space-between; - font-size: 0.7rem; - color: #6f5b39; - text-transform: uppercase; - letter-spacing: 0.22em; -} - -.alert-strip-bar { - display: flex; - height: 26px; - border-radius: 999px; - overflow: hidden; - border: 1px solid rgba(217, 205, 184, 0.6); - background: rgba(111, 91, 57, 0.08); -} - -.strip-segment { - display: flex; - align-items: center; - justify-content: center; - font-size: 0.65rem; - color: #fffdf7; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.alert-strip-bar .severity-high { - background: rgba(196, 111, 42, 0.85); - color: #3b1a09; -} - -.alert-strip-bar .severity-medium { - background: rgba(31, 74, 123, 0.8); -} - -.alert-strip-bar .severity-low { - background: rgba(47, 109, 79, 0.8); -} - -.alert-strip-bar .direction-bullish { - background: rgba(47, 109, 79, 0.85); -} - -.alert-strip-bar .direction-bearish { - background: rgba(196, 111, 42, 0.85); - color: #3b1a09; -} - -.alert-strip-bar .direction-neutral { - background: rgba(111, 91, 57, 0.65); -} - -.flow-meta span { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.flag { - padding: 2px 8px; - border-radius: 999px; - font-size: 0.7rem; - letter-spacing: 0.1em; - text-transform: uppercase; - border: 1px solid rgba(47, 109, 79, 0.4); - color: #2f6d4f; - background: rgba(47, 109, 79, 0.12); -} - -.flag-muted { - border-color: rgba(111, 91, 57, 0.4); - color: #6f5b39; - background: rgba(111, 91, 57, 0.12); -} - -.time { - font-size: 0.85rem; - color: #6f5b39; - text-align: right; -} - -.empty { - padding: 24px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.7); - border: 1px dashed rgba(217, 205, 184, 0.8); - color: #5b4c34; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); } @keyframes pulse { @@ -941,30 +961,101 @@ h1 { transform: scale(1); } 50% { - transform: scale(1.25); + transform: scale(1.18); } 100% { transform: scale(1); } } -@media (max-width: 720px) { - .dashboard { - padding: 36px 6vw 56px; +@media (max-width: 1180px) { + .terminal-shell { + grid-template-columns: 1fr; } - .filter-bar { + .terminal-rail { + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .shell-metrics { + margin-top: 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 980px) { + .page-grid-overview, + .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-tape > :nth-child(1), + .page-grid-replay > :nth-child(1) { + grid-column: auto; + grid-row: auto; + } + + .page-grid-overview > :not(:first-child), + .page-grid-signals > .terminal-pane, + .page-grid-replay > :not(:first-child), + .page-grid-tape > :first-child, + .page-grid-tape > :not(:first-child), + .page-grid-charts > :last-child { + height: auto; + } + + .page-grid-signals { + grid-template-rows: auto; + min-height: 0; + } + + .terminal-topbar { + position: static; + height: auto; + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 720px) { + .terminal-content { + padding: 18px 14px 22px; + } + + .page-header, + .terminal-pane-head, + .chart-controls, + .card-controls, + .terminal-pane-actions { flex-direction: column; align-items: flex-start; } - .filter-controls { - width: 100%; + .terminal-pane-title-row { flex-direction: column; - align-items: stretch; + align-items: flex-start; } - .filter-input { + .terminal-topbar-controls { + width: 100%; + } + + .terminal-filter { + width: 100%; + flex-basis: 100%; + } + + .terminal-input { width: 100%; } @@ -977,102 +1068,14 @@ h1 { text-align: left; } + .chart-surface { + height: 320px; + } + .drawer { position: static; - width: 100%; + width: auto; max-height: none; - } - - .list { - min-height: 360px; - } -} - -@media (max-width: 1100px) { - .cards { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .card-chart, - .card-options, - .card-equities, - .card-flow, - .card-alerts, - .card-classifiers, - .card-dark { - grid-column: span 2; - } -} - -@media (max-width: 860px) { - .cards { - grid-template-columns: minmax(0, 1fr); - } - - .card-chart, - .card-options, - .card-equities, - .card-flow, - .card-alerts, - .card-classifiers, - .card-dark { - grid-column: span 1; - } -} -.card-flow .row, -.card-alerts .row, -.card-classifiers .row, -.card-dark .row { - padding: 12px 14px; -} - -.card-flow .meta, -.card-alerts .meta, -.card-classifiers .meta, -.card-dark .meta { - font-size: 0.78rem; -} - -.card-flow .note, -.card-alerts .note, -.card-classifiers .note, -.card-dark .note { - font-size: 0.72rem; -} - -.card-flow, -.card-alerts, -.card-classifiers, -.card-dark { - display: flex; - flex-direction: column; - height: 960px; - overflow: hidden; -} - -.card-body { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: 0; - gap: 16px; -} - -.card-body .list { - flex: 1 1 0; - min-height: 0; - height: auto; -} - -@media (max-width: 720px) { - .card-flow, - .card-alerts, - .card-classifiers, - .card-dark { - height: 780px; - } - - .chart-surface { - height: 280px; + margin-top: 14px; } } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c27753d..ea8e34b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,9 +1,29 @@ import "./globals.css"; import type { ReactNode } from "react"; +import { IBM_Plex_Mono, IBM_Plex_Sans, Quantico } from "next/font/google"; +import { TerminalAppShell } from "./terminal"; + +const display = Quantico({ + subsets: ["latin"], + weight: ["400", "700"], + variable: "--font-display" +}); + +const sans = IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600"], + variable: "--font-sans" +}); + +const mono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-mono" +}); export const metadata = { - title: "Islandflow", - description: "Realtime options flow & off-exchange analysis" + title: "Islandflow Terminal", + description: "Realtime options flow and off-exchange analysis terminal" }; type RootLayoutProps = { @@ -13,7 +33,9 @@ type RootLayoutProps = { export default function RootLayout({ children }: RootLayoutProps) { return ( - {children} + + {children} + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 579ad2c..a6807b8 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,3764 +1,5 @@ -"use client"; +import { OverviewRoute } from "./terminal"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { - AlertEvent, - ClassifierHitEvent, - EquityCandle, - EquityPrint, - EquityPrintJoin, - FlowPacket, - InferredDarkEvent, - OptionNBBO, - OptionPrint -} from "@islandflow/types"; -import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts"; - -const MAX_ITEMS = 500; -const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS); -const NBBO_MAX_AGE_MS_SAFE = - Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000; -const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); -const CANDLE_INTERVALS = [ - { label: "1m", ms: 60000 }, - { label: "5m", ms: 300000 } -]; - -type CandlestickSeries = ReturnType; - -type EquityOverlayPoint = { - ts: number; - price: number; - size: number; - offExchangeFlag: boolean; -}; - -type ChartCandle = { - time: UTCTimestamp; - open: number; - high: number; - low: number; - close: number; -}; - -const formatIntervalLabel = (intervalMs: number): string => { - const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs); - if (match) { - return match.label; - } - if (intervalMs >= 60000) { - return `${Math.round(intervalMs / 60000)}m`; - } - if (intervalMs >= 1000) { - return `${Math.round(intervalMs / 1000)}s`; - } - return `${intervalMs}ms`; -}; - -const toChartTime = (ts: number): UTCTimestamp => { - return Math.floor(ts / 1000) as UTCTimestamp; -}; - -type ChartTimeLike = number | string | { year: number; month: number; day: number }; - -const chartTimeToMs = (value: ChartTimeLike): number | null => { - if (typeof value === "number") { - return Math.floor(value * 1000); - } - - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; - } - - if (value && typeof value === "object") { - const { year, month, day } = value; - if ( - Number.isFinite(year) && - Number.isFinite(month) && - Number.isFinite(day) && - year >= 1970 && - month >= 1 && - month <= 12 && - day >= 1 && - day <= 31 - ) { - return Date.UTC(year, month - 1, day); - } - } - - return null; -}; - -const toChartCandle = (candle: EquityCandle): ChartCandle => { - return { - time: toChartTime(candle.ts), - open: candle.open, - high: candle.high, - low: candle.low, - close: candle.close - }; -}; - -const clamp = (value: number, min: number, max: number): number => { - if (!Number.isFinite(value)) { - return min; - } - return Math.max(min, Math.min(max, value)); -}; - -const sampleToLimit = (items: T[], limit: number): T[] => { - if (items.length <= limit) { - return items; - } - - const safeLimit = Math.max(1, Math.floor(limit)); - const step = Math.ceil(items.length / safeLimit); - const sampled: T[] = []; - for (let idx = 0; idx < items.length; idx += step) { - sampled.push(items[idx]); - } - - return sampled; -}; - -const readErrorDetail = async (response: Response): Promise => { - const text = await response.text(); - if (!text) { - return ""; - } - try { - const payload = JSON.parse(text) as { - detail?: string; - error?: string; - message?: string; - }; - return payload.detail ?? payload.error ?? payload.message ?? text; - } catch { - return text; - } -}; - -type WsStatus = "connecting" | "connected" | "disconnected"; - -type TapeMode = "live" | "replay"; - -type MessageType = - | "option-print" - | "option-nbbo" - | "equity-print" - | "equity-candle" - | "equity-join" - | "flow-packet" - | "inferred-dark" - | "classifier-hit" - | "alert"; - -type StreamMessage = { - type: MessageType; - payload: T; -}; - -type ReplayCursor = { - ts: number; - seq: number; -}; - -type ReplayResponse = { - data: T[]; - next: ReplayCursor | null; -}; - -const inferTracePrefix = (traceId: string): string => { - const match = traceId.match(/^(.*)-\d+$/); - return match ? match[1] : traceId; -}; - -const extractTracePrefix = (item: T): string | null => { - const traceId = (item as { trace_id?: string }).trace_id; - if (!traceId) { - return null; - } - return inferTracePrefix(traceId); -}; - -const extractReplaySource = (item: T): string | null => { - const prefix = extractTracePrefix(item); - if (!prefix) { - return null; - } - - const normalized = prefix.toLowerCase(); - if (normalized.startsWith("synthetic")) { - return "synthetic"; - } - if (normalized.startsWith("databento")) { - return "databento"; - } - if (normalized.startsWith("alpaca")) { - return "alpaca"; - } - if (normalized.startsWith("ibkr")) { - return "ibkr"; - } - - return prefix; -}; - -type SortableItem = { - ts?: number; - source_ts?: number; - ingest_ts?: number; - seq?: number; - trace_id?: string; - id?: string; -}; - -const extractSortTs = (item: SortableItem): number => - item.ts ?? item.source_ts ?? item.ingest_ts ?? 0; - -const extractSortSeq = (item: SortableItem): number => item.seq ?? 0; - -const buildItemKey = (item: SortableItem): string | null => { - if (item.trace_id) { - return `${item.trace_id}:${item.seq ?? ""}`; - } - - if (item.id) { - return `id:${item.id}`; - } - - return null; -}; - -const mergeNewest = (incoming: T[], existing: T[]): T[] => { - const combined = [...incoming, ...existing]; - if (combined.length === 0) { - return combined; - } - - const seen = new Set(); - const deduped: T[] = []; - - for (const item of combined) { - const key = buildItemKey(item); - if (key) { - if (seen.has(key)) { - continue; - } - seen.add(key); - } - deduped.push(item); - } - - deduped.sort((a, b) => { - const delta = extractSortTs(b) - extractSortTs(a); - if (delta !== 0) { - return delta; - } - return extractSortSeq(b) - extractSortSeq(a); - }); - - return deduped.slice(0, MAX_ITEMS); -}; - -type TapeState = { - status: WsStatus; - items: T[]; - lastUpdate: number | null; - replayTime: number | null; - replayComplete: boolean; - paused: boolean; - dropped: number; - togglePause: () => void; -}; - -const buildWsUrl = (path: string): string => { - const envBase = process.env.NEXT_PUBLIC_API_URL; - - if (envBase) { - const url = new URL(envBase); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.pathname = path; - url.search = ""; - url.hash = ""; - return url.toString(); - } - - const { protocol, hostname } = window.location; - const wsProtocol = protocol === "https:" ? "wss" : "ws"; - const isLocal = LOCAL_HOSTS.has(hostname); - const host = isLocal ? `${hostname}:4000` : window.location.host; - - return `${wsProtocol}://${host}${path}`; -}; - -const buildApiUrl = (path: string): string => { - const envBase = process.env.NEXT_PUBLIC_API_URL; - - if (envBase) { - const url = new URL(envBase); - const secure = url.protocol === "https:" || url.protocol === "wss:"; - url.protocol = secure ? "https:" : "http:"; - url.pathname = path; - url.search = ""; - url.hash = ""; - return url.toString(); - } - - const { protocol, hostname } = window.location; - const httpProtocol = protocol === "https:" ? "https" : "http"; - const isLocal = LOCAL_HOSTS.has(hostname); - const host = isLocal ? `${hostname}:4000` : window.location.host; - - return `${httpProtocol}://${host}${path}`; -}; - -const formatPrice = (price: number): string => { - if (!Number.isFinite(price)) { - return "0.00"; - } - return price.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); -}; - -const formatSize = (size: number): string => { - return size.toLocaleString(); -}; - -const formatTime = (ts: number): string => { - return new Date(ts).toLocaleTimeString(); -}; - -const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`; - -const formatPct = (value: number): string => `${Math.round(value * 100)}%`; - -const formatUsd = (value: number): string => { - if (!Number.isFinite(value)) { - return "0.00"; - } - return value.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); -}; - -const normalizeContractId = (value: string): string => value.trim(); - -const formatContractLabel = (value: string): string => { - const normalized = normalizeContractId(value); - if (!normalized) { - return "Unknown contract"; - } - if (/^\d+$/.test(normalized)) { - return `Instrument ${normalized}`; - } - return normalized; -}; - -const formatDateTime = (ts: number): string => { - const date = new Date(ts); - return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; -}; - -const humanizeClassifierId = (value: string): string => { - if (!value) { - return "Classifier"; - } - - return value - .split("_") - .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) - .join(" "); -}; - -const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" => { - const normalized = value.toLowerCase(); - if (normalized === "bullish" || normalized === "bearish" || normalized === "neutral") { - return normalized; - } - return "neutral"; -}; - -const extractUnderlying = (contractId: string): string => { - const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/); - if (match?.[1]) { - return match[1].toUpperCase(); - } - return contractId.split("-")[0]?.toUpperCase() ?? contractId.toUpperCase(); -}; - -const extractEquityTraceFromJoin = (joinId: string): string | null => { - const match = joinId.match(/^equityjoin:(.+)$/); - return match?.[1] ?? null; -}; - -const inferDarkUnderlying = ( - event: InferredDarkEvent, - equityPrints: Map, - equityJoins: Map -): string | null => { - for (const ref of event.evidence_refs) { - const join = equityJoins.get(ref); - if (!join) { - continue; - } - const underlying = join.features.underlying_id; - if (typeof underlying === "string" && underlying.length > 0) { - return underlying.toUpperCase(); - } - } - - const match = event.trace_id.match(/^dark:(?:stealth_accumulation|distribution):([^:]+):/); - if (match?.[1]) { - return match[1].toUpperCase(); - } - - for (const ref of event.evidence_refs) { - const traceId = extractEquityTraceFromJoin(ref); - if (!traceId) { - continue; - } - const print = equityPrints.get(traceId); - if (print) { - return print.underlying_id.toUpperCase(); - } - } - - return null; -}; - -const parseNumber = (value: unknown, fallback: number): number => { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - - if (typeof value === "string") { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - - return fallback; -}; - -const parseBoolean = (value: unknown, fallback = false): boolean => { - if (typeof value === "boolean") { - return value; - } - if (typeof value === "number") { - return value !== 0; - } - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (["true", "1", "yes", "on"].includes(normalized)) { - return true; - } - if (["false", "0", "no", "off"].includes(normalized)) { - return false; - } - } - return fallback; -}; - -const getJoinString = (join: EquityPrintJoin, key: string): string | null => { - const value = join.features[key]; - return typeof value === "string" ? value : null; -}; - -const getJoinNumber = (join: EquityPrintJoin, key: string, fallback = Number.NaN): number => { - return parseNumber(join.features[key], fallback); -}; - -const getJoinBoolean = (join: EquityPrintJoin, key: string): boolean => { - return parseBoolean(join.features[key], false); -}; - -type NbboSide = "AA" | "A" | "B" | "BB"; - -const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): NbboSide | null => { - if (!quote || !Number.isFinite(price)) { - return null; - } - - const bid = quote.bid; - const ask = quote.ask; - if (!Number.isFinite(bid) || !Number.isFinite(ask) || ask <= 0) { - return null; - } - - const spread = Math.max(0, ask - bid); - const epsilon = Math.max(0.01, spread * 0.05); - - if (price > ask + epsilon) { - return "AA"; - } - if (price >= ask - epsilon) { - return "A"; - } - if (price < bid - epsilon) { - return "BB"; - } - if (price <= bid + epsilon) { - return "B"; - } - - const mid = (bid + ask) / 2; - return price >= mid ? "A" : "B"; -}; - -type ListScrollState = { - listRef: React.RefObject; - isAtTop: boolean; - isAtTopRef: React.MutableRefObject; - missed: number; - resumeTick: number; - onNewItems: (count: number) => void; - jumpToTop: () => void; -}; - -const useListScroll = (): ListScrollState => { - const listRef = useRef(null); - const [isAtTop, setIsAtTop] = useState(true); - const [missed, setMissed] = useState(0); - const [resumeTick, setResumeTick] = useState(0); - const isAtTopRef = useRef(true); - const prevAtTopRef = useRef(true); - - useEffect(() => { - isAtTopRef.current = isAtTop; - }, [isAtTop]); - - const updateScrollState = useCallback(() => { - const el = listRef.current; - if (!el) { - return; - } - - const atTop = el.scrollTop <= 2; - - if (atTop && !prevAtTopRef.current) { - setResumeTick((prev) => prev + 1); - } - - prevAtTopRef.current = atTop; - isAtTopRef.current = atTop; - setIsAtTop(atTop); - - if (atTop) { - setMissed(0); - } - }, [isAtTopRef]); - - useEffect(() => { - const el = listRef.current; - if (!el) { - return; - } - - const onScroll = () => { - updateScrollState(); - }; - - updateScrollState(); - el.addEventListener("scroll", onScroll); - - return () => { - el.removeEventListener("scroll", onScroll); - }; - }, [updateScrollState]); - - const onNewItems = useCallback((count: number) => { - if (count <= 0) { - return; - } - - if (isAtTopRef.current) { - setMissed(0); - return; - } - - setMissed((prev) => prev + count); - }, []); - - const jumpToTop = useCallback(() => { - const el = listRef.current; - if (!el) { - return; - } - - isAtTopRef.current = true; - el.scrollTop = 0; - updateScrollState(); - }, [isAtTopRef, listRef, updateScrollState]); - - return { - listRef, - isAtTop, - isAtTopRef, - missed, - resumeTick, - onNewItems, - jumpToTop - }; -}; - -const useScrollAnchor = ( - listRef: React.RefObject, - isAtTopRef: React.MutableRefObject -) => { - const pendingRef = useRef<{ height: number } | null>(null); - - const capture = useCallback(() => { - if (isAtTopRef.current) { - pendingRef.current = null; - return; - } - - const el = listRef.current; - if (!el) { - return; - } - - pendingRef.current = { - height: el.scrollHeight - }; - }, [isAtTopRef, listRef]); - - const apply = useCallback(() => { - const pending = pendingRef.current; - if (!pending) { - return; - } - - const el = listRef.current; - if (!el) { - return; - } - - if (isAtTopRef.current) { - pendingRef.current = null; - return; - } - - const delta = el.scrollHeight - pending.height; - if (delta !== 0) { - el.scrollTop = Math.max(0, el.scrollTop + delta); - } - pendingRef.current = null; - }, [isAtTopRef, listRef]); - - return { capture, apply }; -}; - -const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => { - if (paused) { - return "Paused"; - } - - if (mode === "replay") { - return status === "disconnected" ? "Replay Down" : "Replay"; - } - - switch (status) { - case "connected": - return "Live"; - case "connecting": - return "Connecting"; - case "disconnected": - default: - return "Disconnected"; - } -}; - -type TapeConfig = { - mode: TapeMode; - wsPath: string; - replayPath: string; - latestPath?: string; - expectedType: MessageType; - batchSize?: number; - pollMs?: number; - captureScroll?: () => void; - onNewItems?: (count: number) => void; - getItemTs?: (item: T) => number; - getReplayKey?: (item: T) => string | null; - replaySourceKey?: string | null; - onReplaySourceKey?: (key: string | null) => void; -}; - -const useTape = ( - config: TapeConfig -): TapeState => { - const { mode, wsPath, replayPath, expectedType, latestPath, onNewItems, captureScroll } = config; - const batchSize = config.batchSize ?? 40; - const pollMs = config.pollMs ?? 1000; - const getItemTs = config.getItemTs ?? extractSortTs; - const getReplayKey = config.getReplayKey ?? extractTracePrefix; - const replaySourceKey = config.replaySourceKey ?? null; - const onReplaySourceKey = config.onReplaySourceKey; - const [status, setStatus] = useState("connecting"); - const [items, setItems] = useState([]); - const [lastUpdate, setLastUpdate] = useState(null); - const [replayTime, setReplayTime] = useState(null); - const [replayComplete, setReplayComplete] = useState(false); - const [paused, setPaused] = useState(false); - const [dropped, setDropped] = useState(0); - const reconnectRef = useRef(null); - const socketRef = useRef(null); - const cursorRef = useRef({ ts: 0, seq: 0 }); - const replayEndRef = useRef(null); - const replayCompleteRef = useRef(false); - const replaySourceRef = useRef(null); - const replaySourceNotifiedRef = useRef(null); - const emptyPollsRef = useRef(0); - const pausedRef = useRef(paused); - const pendingRef = useRef([]); - const pendingCountRef = useRef(0); - const flushHandleRef = useRef(null); - - useEffect(() => { - pausedRef.current = paused; - }, [paused]); - - const cancelFlush = useCallback(() => { - if (flushHandleRef.current !== null) { - cancelAnimationFrame(flushHandleRef.current); - flushHandleRef.current = null; - } - }, []); - - const scheduleFlush = useCallback(() => { - if (flushHandleRef.current !== null) { - return; - } - - flushHandleRef.current = requestAnimationFrame(() => { - flushHandleRef.current = null; - const buffered = pendingRef.current; - if (buffered.length === 0) { - return; - } - pendingRef.current = []; - - const pendingCount = pendingCountRef.current; - pendingCountRef.current = 0; - - if (onNewItems && pendingCount > 0) { - onNewItems(pendingCount); - } - - if (captureScroll) { - captureScroll(); - } - - setItems((prev) => mergeNewest(buffered, prev)); - setLastUpdate(Date.now()); - }); - }, [captureScroll, onNewItems]); - - const togglePause = useCallback(() => { - setPaused((prev) => { - const next = !prev; - if (!next) { - setDropped(0); - } - return next; - }); - }, []); - - useEffect(() => { - setItems([]); - setLastUpdate(null); - setReplayTime(null); - setReplayComplete(false); - replayCompleteRef.current = false; - replaySourceRef.current = null; - replaySourceNotifiedRef.current = null; - emptyPollsRef.current = 0; - setDropped(0); - setStatus("connecting"); - cursorRef.current = { ts: 0, seq: 0 }; - pendingRef.current = []; - pendingCountRef.current = 0; - cancelFlush(); - }, [mode, replaySourceKey, cancelFlush]); - - useEffect(() => { - if (mode !== "replay" || !latestPath) { - replayEndRef.current = null; - return; - } - - let active = true; - replayEndRef.current = null; - setReplayComplete(false); - replayCompleteRef.current = false; - - const fetchReplayEnd = async () => { - try { - const url = new URL(buildApiUrl(latestPath)); - url.searchParams.set("limit", "1"); - if (replaySourceKey) { - url.searchParams.set("source", replaySourceKey); - } - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Replay baseline failed with ${response.status}`); - } - - const payload = (await response.json()) as { data?: T[] }; - const latest = payload.data?.[0]; - if (active && latest) { - replayEndRef.current = getItemTs(latest); - } - } catch (error) { - console.warn("Failed to load replay end cursor", error); - } - }; - - void fetchReplayEnd(); - - return () => { - active = false; - }; - }, [mode, latestPath, getItemTs, replaySourceKey]); - - useEffect(() => { - if (mode !== "live") { - return; - } - - let active = true; - - const connect = () => { - if (!active) { - return; - } - - setStatus("connecting"); - - const socket = new WebSocket(buildWsUrl(wsPath)); - socketRef.current = socket; - - socket.onopen = () => { - if (!active) { - return; - } - setStatus("connected"); - }; - - socket.onmessage = (event) => { - if (!active) { - return; - } - - try { - const message = JSON.parse(event.data) as StreamMessage; - if (!message || message.type !== expectedType) { - return; - } - - if (pausedRef.current) { - setDropped((prev) => prev + 1); - setLastUpdate(Date.now()); - return; - } - - pendingRef.current.push(message.payload); - pendingCountRef.current += 1; - scheduleFlush(); - } catch (error) { - console.warn("Failed to parse websocket payload", error); - } - }; - - socket.onclose = () => { - if (!active) { - return; - } - - setStatus("disconnected"); - reconnectRef.current = window.setTimeout(() => { - connect(); - }, 1000); - }; - - socket.onerror = () => { - if (!active) { - return; - } - - setStatus("disconnected"); - socket.close(); - }; - }; - - connect(); - - return () => { - active = false; - cancelFlush(); - if (reconnectRef.current !== null) { - window.clearTimeout(reconnectRef.current); - } - if (socketRef.current) { - socketRef.current.close(); - } - }; - }, [mode, wsPath, expectedType, scheduleFlush, cancelFlush]); - - useEffect(() => { - if (mode !== "replay") { - return; - } - - let active = true; - - const poll = async () => { - if (!active || pausedRef.current) { - return; - } - - if (replayCompleteRef.current) { - return; - } - - try { - let keepPolling = true; - - while (keepPolling && active && !pausedRef.current) { - const replayEnd = replayEndRef.current; - const cursor = cursorRef.current; - - if (replayEnd !== null && cursor.ts >= replayEnd) { - replayCompleteRef.current = true; - setReplayComplete(true); - setStatus("disconnected"); - return; - } - - const url = new URL(buildApiUrl(replayPath)); - url.searchParams.set("after_ts", cursor.ts.toString()); - url.searchParams.set("after_seq", cursor.seq.toString()); - url.searchParams.set("limit", batchSize.toString()); - const desiredSource = replaySourceKey ?? replaySourceRef.current; - if (desiredSource) { - url.searchParams.set("source", desiredSource); - } - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Replay request failed with ${response.status}`); - } - - const payload = (await response.json()) as ReplayResponse; - - let sourcePrefix = replaySourceRef.current; - if (replaySourceKey) { - if (sourcePrefix !== replaySourceKey) { - sourcePrefix = replaySourceKey; - replaySourceRef.current = replaySourceKey; - } - } else if (!sourcePrefix) { - const firstWithTrace = payload.data.find((item) => getReplayKey(item)); - if (firstWithTrace) { - sourcePrefix = getReplayKey(firstWithTrace); - replaySourceRef.current = sourcePrefix ?? null; - } - } - - if (onReplaySourceKey && sourcePrefix && replaySourceNotifiedRef.current !== sourcePrefix) { - replaySourceNotifiedRef.current = sourcePrefix; - onReplaySourceKey(sourcePrefix); - } - - const filtered = sourcePrefix - ? payload.data.filter((item) => getReplayKey(item) === sourcePrefix) - : payload.data; - - const hasForeign = - sourcePrefix && - payload.data.some((item) => { - const prefix = getReplayKey(item); - return prefix !== null && prefix !== sourcePrefix; - }); - - if (filtered.length > 0) { - const nextItems = [...filtered].reverse(); - pendingRef.current.push(...nextItems); - pendingCountRef.current += nextItems.length; - scheduleFlush(); - const last = filtered.at(-1); - if (last) { - const lastTs = getItemTs(last); - setReplayTime(lastTs); - if (replayEnd !== null && lastTs >= replayEnd) { - cursorRef.current = { ts: lastTs, seq: last.seq }; - replayCompleteRef.current = true; - setReplayComplete(true); - setStatus("disconnected"); - return; - } - } - emptyPollsRef.current = 0; - } else if (sourcePrefix) { - emptyPollsRef.current += 1; - } - - if (payload.next) { - cursorRef.current = payload.next; - } - - setStatus("connected"); - keepPolling = filtered.length === batchSize; - - if (keepPolling) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - - if (!replaySourceKey && hasForeign) { - replayCompleteRef.current = true; - setReplayComplete(true); - setStatus("disconnected"); - return; - } - - if (sourcePrefix && emptyPollsRef.current >= 3) { - replayCompleteRef.current = true; - setReplayComplete(true); - setStatus("disconnected"); - return; - } - } - } catch (error) { - console.warn("Replay poll failed", error); - setStatus("disconnected"); - } - }; - - void poll(); - const interval = window.setInterval(poll, pollMs); - - return () => { - active = false; - window.clearInterval(interval); - cancelFlush(); - }; - }, [ - mode, - replayPath, - batchSize, - pollMs, - scheduleFlush, - cancelFlush, - getItemTs, - getReplayKey, - replaySourceKey, - onReplaySourceKey - ]); - - return { - status, - items, - lastUpdate, - replayTime, - replayComplete, - paused, - dropped, - togglePause - }; -}; - -const useLiveStream = ( - config: { - enabled: boolean; - wsPath: string; - expectedType: MessageType; - onNewItems?: (count: number) => void; - captureScroll?: () => void; - shouldHold?: () => boolean; - resumeSignal?: number; - } -): TapeState => { - const [status, setStatus] = useState( - config.enabled ? "connecting" : "disconnected" - ); - const [items, setItems] = useState([]); - const [lastUpdate, setLastUpdate] = useState(null); - const [replayTime] = useState(null); - const [replayComplete] = useState(false); - const [paused, setPaused] = useState(false); - const [dropped, setDropped] = useState(0); - const reconnectRef = useRef(null); - const socketRef = useRef(null); - const pausedRef = useRef(paused); - const pendingRef = useRef([]); - const pendingCountRef = useRef(0); - const flushHandleRef = useRef(null); - const holdRef = useRef([]); - - useEffect(() => { - pausedRef.current = paused; - }, [paused]); - - const cancelFlush = useCallback(() => { - if (flushHandleRef.current !== null) { - cancelAnimationFrame(flushHandleRef.current); - flushHandleRef.current = null; - } - }, []); - - const scheduleFlush = useCallback(() => { - if (flushHandleRef.current !== null) { - return; - } - - flushHandleRef.current = requestAnimationFrame(() => { - flushHandleRef.current = null; - const buffered = pendingRef.current; - if (buffered.length === 0) { - return; - } - pendingRef.current = []; - - const pendingCount = pendingCountRef.current; - pendingCountRef.current = 0; - - if (config.onNewItems && pendingCount > 0) { - config.onNewItems(pendingCount); - } - - const shouldHold = config.shouldHold ? config.shouldHold() : false; - if (!shouldHold && config.captureScroll) { - config.captureScroll(); - } - - if (shouldHold) { - holdRef.current = mergeNewest(buffered, holdRef.current); - setLastUpdate(Date.now()); - return; - } - - const nextBatch = - holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered; - holdRef.current = []; - - setItems((prev) => mergeNewest(nextBatch, prev)); - setLastUpdate(Date.now()); - }); - }, [config.captureScroll, config.onNewItems, config.shouldHold]); - - const togglePause = useCallback(() => { - setPaused((prev) => { - const next = !prev; - if (!next) { - setDropped(0); - } - return next; - }); - }, []); - - useEffect(() => { - if (!config.enabled) { - setStatus("disconnected"); - setItems([]); - setLastUpdate(null); - pendingRef.current = []; - pendingCountRef.current = 0; - holdRef.current = []; - cancelFlush(); - return; - } - - let active = true; - - const connect = () => { - if (!active) { - return; - } - - setStatus("connecting"); - - const socket = new WebSocket(buildWsUrl(config.wsPath)); - socketRef.current = socket; - - socket.onopen = () => { - if (!active) { - return; - } - setStatus("connected"); - }; - - socket.onmessage = (event) => { - if (!active) { - return; - } - - try { - const message = JSON.parse(event.data) as StreamMessage; - if (!message || message.type !== config.expectedType) { - return; - } - - if (pausedRef.current) { - setDropped((prev) => prev + 1); - setLastUpdate(Date.now()); - return; - } - - pendingRef.current.push(message.payload); - pendingCountRef.current += 1; - scheduleFlush(); - } catch (error) { - console.warn("Failed to parse live stream payload", error); - } - }; - - socket.onclose = () => { - if (!active) { - return; - } - - setStatus("disconnected"); - reconnectRef.current = window.setTimeout(() => { - connect(); - }, 1000); - }; - - socket.onerror = () => { - if (!active) { - return; - } - - setStatus("disconnected"); - socket.close(); - }; - }; - - connect(); - - return () => { - active = false; - cancelFlush(); - if (reconnectRef.current !== null) { - window.clearTimeout(reconnectRef.current); - } - if (socketRef.current) { - socketRef.current.close(); - } - }; - }, [config.enabled, config.expectedType, config.wsPath, scheduleFlush, cancelFlush]); - - useEffect(() => { - if (config.resumeSignal === undefined) { - return; - } - if (config.shouldHold && config.shouldHold()) { - return; - } - if (holdRef.current.length === 0) { - return; - } - setItems((prev) => mergeNewest(holdRef.current, prev)); - holdRef.current = []; - setLastUpdate(Date.now()); - }, [config.resumeSignal, config.shouldHold]); - - return { - status, - items, - lastUpdate, - replayTime, - replayComplete, - paused, - dropped, - togglePause - }; -}; - -const useFlowStream = ( - enabled: boolean, - onNewItems?: (count: number) => void, - captureScroll?: () => void, - shouldHold?: () => boolean, - resumeSignal?: number -): TapeState => { - return useLiveStream({ - enabled, - wsPath: "/ws/flow", - expectedType: "flow-packet", - onNewItems, - captureScroll, - shouldHold, - resumeSignal - }); -}; - -type TapeStatusProps = { - status: WsStatus; - lastUpdate: number | null; - replayTime: number | null; - replayComplete: boolean; - paused: boolean; - dropped: number; - mode: TapeMode; - onTogglePause: () => void; -}; - -const TapeStatus = ({ - status, - lastUpdate, - replayTime, - replayComplete, - paused, - dropped, - mode, - onTogglePause -}: TapeStatusProps) => { - const replayClass = mode === "replay" ? "status-replay" : ""; - const pausedClass = paused ? "status-paused" : ""; - const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); - - return ( -
- - {label} - {lastUpdate ? ( - Updated {formatTime(lastUpdate)} - ) : ( - Waiting for data - )} - {paused && dropped > 0 ? ( - {dropped} new while paused - ) : null} - {mode === "replay" ? ( - - Replay time {replayTime ? formatTime(replayTime) : "—"} - - ) : null} - -
- ); -}; - -type TapeControlsProps = { - isAtTop: boolean; - missed: number; - onJump: () => void; -}; - -const TapeControls = ({ isAtTop, missed, onJump }: TapeControlsProps) => { - const active = !isAtTop && missed > 0; - return ( -
- - {active ? `+${missed} new` : ""} -
- ); -}; - -type CandleChartProps = { - ticker: string; - intervalMs: number; - mode: TapeMode; - replayTime?: number | null; - classifierHits: ClassifierHitEvent[]; - inferredDark: InferredDarkEvent[]; - onClassifierHitClick: (hit: ClassifierHitEvent) => void; - onInferredDarkClick: (event: InferredDarkEvent) => void; -}; - -type MarkerAction = - | { kind: "hit"; hit: ClassifierHitEvent } - | { kind: "dark"; event: InferredDarkEvent }; - -const CandleChart = ({ - ticker, - intervalMs, - mode, - replayTime = null, - classifierHits, - inferredDark, - onClassifierHitClick, - onInferredDarkClick -}: CandleChartProps) => { - const containerRef = useRef(null); - const chartRef = useRef(null); - const seriesRef = useRef(null); - const socketRef = useRef(null); - const reconnectRef = useRef(null); - const overlaySocketRef = useRef(null); - const overlayReconnectRef = useRef(null); - const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null); - - const markerLookupRef = useRef>(new Map()); - const [visibleRangeMs, setVisibleRangeMs] = useState<{ from: number; to: number } | null>(null); - const onHitClickRef = useRef(onClassifierHitClick); - const onDarkClickRef = useRef(onInferredDarkClick); - - const overlayCanvasRef = useRef(null); - const overlayCtxRef = useRef(null); - const overlayDataRef = useRef([]); - const overlayLiveRef = useRef([]); - const overlayLastFetchRef = useRef<{ startTs: number; endTs: number; ticker: string } | null>( - null - ); - const overlayFetchAbortRef = useRef(null); - const overlayTimerRef = useRef(null); - - const [overlayEnabled, setOverlayEnabled] = useState(true); - - const drawOverlay = useCallback( - (points: EquityOverlayPoint[]) => { - const canvas = overlayCanvasRef.current; - const ctx = overlayCtxRef.current; - const chart = chartRef.current; - if (!canvas || !ctx || !chart) { - return; - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - if (!overlayEnabled || points.length === 0) { - canvas.style.opacity = "0"; - return; - } - - const timeScale = chart.timeScale(); - if (!seriesRef.current) { - canvas.style.opacity = "0"; - return; - } - - const filtered = points.filter((point) => point.offExchangeFlag); - const sampled = sampleToLimit(filtered, 1400); - - const maxRadius = 10; - const minRadius = 2; - const maxSize = Math.max(1, ...sampled.map((point) => point.size)); - - ctx.globalAlpha = 0.9; - ctx.fillStyle = "rgba(31, 74, 123, 0.55)"; - ctx.strokeStyle = "rgba(31, 74, 123, 0.95)"; - - for (const point of sampled) { - const x = timeScale.timeToCoordinate(toChartTime(point.ts)); - const y = seriesRef.current.priceToCoordinate(point.price); - if (x === null || y === null) { - continue; - } - - const radius = clamp( - minRadius + (Math.sqrt(point.size) / Math.sqrt(maxSize)) * (maxRadius - minRadius), - minRadius, - maxRadius - ); - - ctx.beginPath(); - ctx.arc(x, y, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } - - ctx.globalAlpha = 1; - canvas.style.opacity = "1"; - }, - [overlayEnabled] - ); - - useEffect(() => { - drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); - }, [drawOverlay, ticker, intervalMs, mode]); - - useEffect(() => { - onHitClickRef.current = onClassifierHitClick; - }, [onClassifierHitClick]); - - useEffect(() => { - onDarkClickRef.current = onInferredDarkClick; - }, [onInferredDarkClick]); - - const markerBundle = useMemo(() => { - const lookup = new Map(); - const markers: SeriesMarker[] = []; - - if (!visibleRangeMs) { - return { markers, lookup }; - } - - const { from, to } = visibleRangeMs; - const inRangeHits = classifierHits - .filter((hit) => hit.source_ts >= from && hit.source_ts <= to) - .sort((a, b) => { - const delta = a.source_ts - b.source_ts; - if (delta !== 0) { - return delta; - } - return a.seq - b.seq; - }); - const inRangeDark = inferredDark - .filter((event) => event.source_ts >= from && event.source_ts <= to) - .sort((a, b) => { - const delta = a.source_ts - b.source_ts; - if (delta !== 0) { - return delta; - } - return a.seq - b.seq; - }); - - const MAX_HIT_MARKERS = 220; - const MAX_DARK_MARKERS = 120; - const MAX_TOTAL_MARKERS = 320; - - const cappedHits = - inRangeHits.length > MAX_HIT_MARKERS - ? inRangeHits.slice(inRangeHits.length - MAX_HIT_MARKERS) - : inRangeHits; - const cappedDark = - inRangeDark.length > MAX_DARK_MARKERS - ? inRangeDark.slice(inRangeDark.length - MAX_DARK_MARKERS) - : inRangeDark; - - for (const hit of cappedHits) { - const direction = normalizeDirection(hit.direction); - const markerId = `hit:${hit.trace_id}:${hit.seq}`; - lookup.set(markerId, { kind: "hit", hit }); - - markers.push({ - id: markerId, - time: toChartTime(hit.source_ts), - position: direction === "bullish" ? "belowBar" : "aboveBar", - color: - direction === "bullish" - ? "#2f6d4f" - : direction === "bearish" - ? "#c46f2a" - : "rgba(111, 91, 57, 0.9)", - shape: - direction === "bullish" - ? "arrowUp" - : direction === "bearish" - ? "arrowDown" - : "circle", - text: hit.classifier_id ? hit.classifier_id.slice(0, 3).toUpperCase() : "H" - }); - } - - for (const event of cappedDark) { - const markerId = `dark:${event.trace_id}:${event.seq}`; - lookup.set(markerId, { kind: "dark", event }); - markers.push({ - id: markerId, - time: toChartTime(event.source_ts), - position: "aboveBar", - color: "rgba(31, 74, 123, 0.9)", - shape: "square", - text: "D" - }); - } - - markers.sort((a, b) => { - const delta = Number(a.time) - Number(b.time); - if (delta !== 0) { - return delta; - } - return String(a.id ?? "").localeCompare(String(b.id ?? "")); - }); - - const cappedMarkers = - markers.length > MAX_TOTAL_MARKERS - ? markers.slice(markers.length - MAX_TOTAL_MARKERS) - : markers; - - if (cappedMarkers !== markers) { - const nextLookup = new Map(); - for (const marker of cappedMarkers) { - const id = marker.id; - if (typeof id !== "string") { - continue; - } - const action = lookup.get(id); - if (action) { - nextLookup.set(id, action); - } - } - return { markers: cappedMarkers, lookup: nextLookup }; - } - - return { markers: cappedMarkers, lookup }; - }, [classifierHits, inferredDark, visibleRangeMs]); - - useEffect(() => { - if (!seriesRef.current) { - return; - } - markerLookupRef.current = markerBundle.lookup; - seriesRef.current.setMarkers(markerBundle.markers); - }, [markerBundle]); - - const replayBucket = useMemo(() => { - if (mode !== "replay" || replayTime === null) { - return null; - } - return Math.floor(replayTime / intervalMs); - }, [mode, replayTime, intervalMs]); - const replayEndTs = useMemo(() => { - if (replayBucket === null) { - return null; - } - return (replayBucket + 1) * intervalMs - 1; - }, [replayBucket, intervalMs]); - const [ready, setReady] = useState(false); - const [status, setStatus] = useState(mode === "live" ? "connecting" : "connected"); - const [lastUpdate, setLastUpdate] = useState(null); - const [hasData, setHasData] = useState(false); - const [error, setError] = useState(null); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - const width = container.clientWidth || 600; - const height = container.clientHeight || 360; - const chart = createChart(container, { - width, - height, - layout: { - background: { color: "#fffdf7" }, - textColor: "#4e3e25" - }, - grid: { - vertLines: { color: "rgba(82, 64, 36, 0.12)" }, - horzLines: { color: "rgba(82, 64, 36, 0.12)" } - }, - crosshair: { - vertLine: { color: "rgba(47, 109, 79, 0.35)" }, - horzLine: { color: "rgba(47, 109, 79, 0.35)" } - }, - timeScale: { - borderColor: "rgba(111, 91, 57, 0.35)", - timeVisible: true, - secondsVisible: intervalMs < 60000 - }, - rightPriceScale: { - borderColor: "rgba(111, 91, 57, 0.35)" - } - }); - - const overlayCanvas = document.createElement("canvas"); - overlayCanvas.width = Math.max(1, Math.floor(width)); - overlayCanvas.height = Math.max(1, Math.floor(height)); - overlayCanvas.style.position = "absolute"; - overlayCanvas.style.inset = "0"; - overlayCanvas.style.pointerEvents = "none"; - overlayCanvas.style.zIndex = "2"; - overlayCanvas.style.opacity = "0"; - container.style.position = "relative"; - container.appendChild(overlayCanvas); - overlayCanvasRef.current = overlayCanvas; - overlayCtxRef.current = overlayCanvas.getContext("2d"); - - const series = chart.addCandlestickSeries({ - upColor: "#2f6d4f", - downColor: "#c46f2a", - borderVisible: false, - wickUpColor: "#2f6d4f", - wickDownColor: "#c46f2a" - }); - - chartRef.current = chart; - seriesRef.current = series; - setReady(true); - - const timeScale = chart.timeScale(); - const updateVisibleRange = () => { - const range = timeScale.getVisibleRange(); - if (!range) { - setVisibleRangeMs(null); - return; - } - const from = chartTimeToMs(range.from); - const to = chartTimeToMs(range.to); - if (from === null || to === null) { - setVisibleRangeMs(null); - return; - } - - setVisibleRangeMs({ - from: Math.min(from, to), - to: Math.max(from, to) - }); - }; - - const clickHandler = (param: { hoveredObjectId?: unknown }) => { - const hovered = param.hoveredObjectId; - if (hovered === null || hovered === undefined) { - return; - } - const key = typeof hovered === "string" ? hovered : String(hovered); - const action = markerLookupRef.current.get(key); - if (!action) { - return; - } - if (action.kind === "hit") { - onHitClickRef.current(action.hit); - } else { - onDarkClickRef.current(action.event); - } - }; - - updateVisibleRange(); - timeScale.subscribeVisibleTimeRangeChange(updateVisibleRange); - chart.subscribeClick(clickHandler); - - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) { - return; - } - const { width: nextWidth, height: nextHeight } = entry.contentRect; - if (Number.isFinite(nextWidth) && Number.isFinite(nextHeight)) { - const nextW = Math.max(1, Math.floor(nextWidth)); - const nextH = Math.max(1, Math.floor(nextHeight)); - chart.applyOptions({ - width: nextW, - height: nextH - }); - - const canvas = overlayCanvasRef.current; - if (canvas) { - canvas.width = nextW; - canvas.height = nextH; - } - } - }); - - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - timeScale.unsubscribeVisibleTimeRangeChange(updateVisibleRange); - chart.unsubscribeClick(clickHandler); - chart.remove(); - chartRef.current = null; - seriesRef.current = null; - overlayCtxRef.current = null; - overlayCanvasRef.current?.remove(); - overlayCanvasRef.current = null; - }; - }, []); - - useEffect(() => { - if (!ready || !seriesRef.current) { - return; - } - - if (mode === "replay" && replayBucket === null) { - setError(null); - setHasData(false); - setLastUpdate(null); - lastCandleRef.current = null; - seriesRef.current.setData([]); - overlayDataRef.current = []; - overlayLiveRef.current = []; - overlayLastFetchRef.current = null; - setStatus("connected"); - return; - } - - let active = true; - setError(null); - setHasData(false); - setLastUpdate(null); - lastCandleRef.current = null; - seriesRef.current.setData([]); - overlayDataRef.current = []; - overlayLiveRef.current = []; - overlayLastFetchRef.current = null; - setStatus(mode === "live" ? "connecting" : "connected"); - - const fetchCandles = async () => { - try { - const url = new URL(buildApiUrl("/candles/equities")); - url.searchParams.set("underlying_id", ticker); - url.searchParams.set("interval_ms", intervalMs.toString()); - url.searchParams.set("limit", "300"); - url.searchParams.set("cache", "1"); - if (mode === "replay" && replayEndTs !== null) { - url.searchParams.set("end_ts", replayEndTs.toString()); - } - const response = await fetch(url.toString()); - if (!response.ok) { - const detail = await readErrorDetail(response); - throw new Error( - `Candle fetch failed (${response.status})${detail ? `: ${detail}` : ""}` - ); - } - const payload = (await response.json()) as { data?: EquityCandle[] }; - if (!active || !seriesRef.current) { - return; - } - const sorted = [...(payload.data ?? [])].sort((a, b) => { - if (a.ts !== b.ts) { - return a.ts - b.ts; - } - return a.seq - b.seq; - }); - const chartData = sorted.map(toChartCandle); - seriesRef.current.setData(chartData); - chartRef.current?.timeScale().fitContent(); - drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); - - if (sorted.length > 0) { - const last = sorted[sorted.length - 1]; - lastCandleRef.current = { time: toChartTime(last.ts), seq: last.seq }; - setHasData(true); - setLastUpdate(last.ingest_ts ?? last.ts); - } - } catch (error) { - if (!active) { - return; - } - setError(error instanceof Error ? error.message : String(error)); - setStatus("disconnected"); - setHasData(false); - } - }; - - - const ensureOverlayListener = () => { - if (!chartRef.current) { - return; - } - - const handler = () => { - const combined = [...overlayDataRef.current, ...overlayLiveRef.current]; - drawOverlay(combined); - scheduleOverlayFetch(); - }; - - chartRef.current.timeScale().subscribeVisibleTimeRangeChange(handler); - return () => { - chartRef.current?.timeScale().unsubscribeVisibleTimeRangeChange(handler); - }; - }; - - const cancelOverlayFetch = () => { - if (overlayFetchAbortRef.current) { - overlayFetchAbortRef.current.abort(); - overlayFetchAbortRef.current = null; - } - }; - - const fetchOverlayRange = async (startTs: number, endTs: number) => { - cancelOverlayFetch(); - const abort = new AbortController(); - overlayFetchAbortRef.current = abort; - - const url = new URL(buildApiUrl("/prints/equities/range")); - url.searchParams.set("underlying_id", ticker); - url.searchParams.set("start_ts", Math.floor(startTs).toString()); - url.searchParams.set("end_ts", Math.floor(endTs).toString()); - url.searchParams.set("limit", "2500"); - - const response = await fetch(url.toString(), { signal: abort.signal }); - if (!response.ok) { - const detail = await readErrorDetail(response); - throw new Error( - `Equity range fetch failed (${response.status})${detail ? `: ${detail}` : ""}` - ); - } - - const payload = (await response.json()) as { data?: EquityPrint[] }; - const prints = payload.data ?? []; - overlayDataRef.current = prints.map((print) => ({ - ts: print.ts, - price: print.price, - size: print.size, - offExchangeFlag: print.offExchangeFlag - })); - overlayLiveRef.current = []; - overlayLastFetchRef.current = { startTs, endTs, ticker }; - }; - - function scheduleOverlayFetch() { - if (overlayTimerRef.current !== null) { - window.clearTimeout(overlayTimerRef.current); - } - - overlayTimerRef.current = window.setTimeout(() => { - if (!active || !chartRef.current || !seriesRef.current) { - return; - } - - const timeScale = chartRef.current.timeScale(); - const range = timeScale.getVisibleRange(); - if (!range) { - return; - } - - const startTs = chartTimeToMs(range.from); - const endTs = chartTimeToMs(range.to); - if (startTs === null || endTs === null) { - return; - } - const last = overlayLastFetchRef.current; - - const needsFetch = - !last || - last.ticker !== ticker || - startTs < last.startTs || - endTs > last.endTs || - Math.abs(endTs - last.endTs) > intervalMs * 6; - - if (!needsFetch) { - return; - } - - void fetchOverlayRange(startTs, endTs) - .then(() => { - drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); - }) - .catch((error) => { - if (!active) { - return; - } - if (error instanceof DOMException && error.name === "AbortError") { - return; - } - console.warn("Overlay fetch failed", error); - }); - }, 180); - } - - const overlayUnsubscribe = ensureOverlayListener(); - scheduleOverlayFetch(); - - void fetchCandles(); - - return () => { - active = false; - cancelOverlayFetch(); - if (overlayTimerRef.current !== null) { - window.clearTimeout(overlayTimerRef.current); - overlayTimerRef.current = null; - } - overlayUnsubscribe?.(); - }; - }, [ready, ticker, intervalMs, mode, replayBucket, replayEndTs]); - - useEffect(() => { - if (!ready || mode !== "live" || !seriesRef.current) { - if (socketRef.current) { - socketRef.current.close(); - } - if (reconnectRef.current !== null) { - window.clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - - if (overlaySocketRef.current) { - overlaySocketRef.current.close(); - } - if (overlayReconnectRef.current !== null) { - window.clearTimeout(overlayReconnectRef.current); - overlayReconnectRef.current = null; - } - - return; - } - - let active = true; - - const connect = () => { - if (!active) { - return; - } - - setStatus("connecting"); - const socket = new WebSocket(buildWsUrl("/ws/equity-candles")); - socketRef.current = socket; - - socket.onopen = () => { - if (!active) { - return; - } - setStatus("connected"); - }; - - socket.onmessage = (event) => { - if (!active || !seriesRef.current) { - return; - } - - try { - const message = JSON.parse(event.data) as StreamMessage; - if (!message || message.type !== "equity-candle") { - return; - } - - const candle = message.payload; - if (candle.underlying_id !== ticker || candle.interval_ms !== intervalMs) { - return; - } - - const chartCandle = toChartCandle(candle); - const last = lastCandleRef.current; - if (last) { - if (chartCandle.time < last.time) { - return; - } - if (chartCandle.time === last.time && candle.seq <= last.seq) { - return; - } - } - - seriesRef.current.update(chartCandle); - lastCandleRef.current = { time: chartCandle.time, seq: candle.seq }; - setHasData(true); - setLastUpdate(candle.ingest_ts ?? candle.ts); - drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); - } catch (error) { - console.warn("Failed to parse candle payload", error); - } - }; - - socket.onclose = () => { - if (!active) { - return; - } - setStatus("disconnected"); - reconnectRef.current = window.setTimeout(connect, 1000); - }; - - socket.onerror = () => { - if (!active) { - return; - } - setStatus("disconnected"); - socket.close(); - }; - }; - - const connectOverlay = () => { - if (!active) { - return; - } - - const socket = new WebSocket(buildWsUrl("/ws/equities")); - overlaySocketRef.current = socket; - - socket.onmessage = (event) => { - if (!active) { - return; - } - - try { - const message = JSON.parse(event.data) as StreamMessage; - if (!message || message.type !== "equity-print") { - return; - } - - const print = message.payload; - if (print.underlying_id !== ticker) { - return; - } - - overlayLiveRef.current.push({ - ts: print.ts, - price: print.price, - size: print.size, - offExchangeFlag: print.offExchangeFlag - }); - - if (overlayLiveRef.current.length > 1500) { - overlayLiveRef.current = overlayLiveRef.current.slice(-1500); - } - - drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); - } catch (error) { - console.warn("Failed to parse equity print payload", error); - } - }; - - socket.onclose = () => { - if (!active) { - return; - } - overlayReconnectRef.current = window.setTimeout(connectOverlay, 1500); - }; - - socket.onerror = () => { - if (!active) { - return; - } - socket.close(); - }; - }; - - connect(); - connectOverlay(); - - return () => { - active = false; - if (reconnectRef.current !== null) { - window.clearTimeout(reconnectRef.current); - reconnectRef.current = null; - } - if (socketRef.current) { - socketRef.current.close(); - } - - if (overlayReconnectRef.current !== null) { - window.clearTimeout(overlayReconnectRef.current); - overlayReconnectRef.current = null; - } - if (overlaySocketRef.current) { - overlaySocketRef.current.close(); - } - }; - }, [ready, mode, ticker, intervalMs, drawOverlay]); - - useEffect(() => { - if (!chartRef.current) { - return; - } - chartRef.current.timeScale().applyOptions({ - timeVisible: true, - secondsVisible: intervalMs < 60000 - }); - }, [intervalMs]); - - const statusText = statusLabel(status, false, mode); - const intervalLabel = formatIntervalLabel(intervalMs); - const emptyLabel = - mode === "live" - ? status === "connected" - ? `No candles yet. First ${intervalLabel} candle appears after the window closes.` - : "Chart offline. Start candles service." - : "No candles for this replay window."; - - return ( -
-
-
- - {statusText} -
- - {lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"} - - - Blue circles = off-exchange trades -
-
- {error ? ( -
Chart error: {error}
- ) : !hasData ? ( -
{emptyLabel}
- ) : null} -
- ); -}; - -type AlertSeverityStripProps = { - alerts: AlertEvent[]; -}; - -const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { - const windowMs = 30 * 60 * 1000; - const now = Date.now(); - const severityCounts = alerts.reduce( - (acc, alert) => { - if (now - alert.source_ts > windowMs) { - return acc; - } - if (alert.severity === "high") { - acc.high += 1; - } else if (alert.severity === "medium") { - acc.medium += 1; - } else { - acc.low += 1; - } - return acc; - }, - { high: 0, medium: 0, low: 0 } - ); - - const directionCounts = alerts.reduce( - (acc, alert) => { - if (now - alert.source_ts > windowMs) { - return acc; - } - const direction = normalizeDirection(alert.hits[0]?.direction ?? "neutral"); - acc[direction] += 1; - return acc; - }, - { bullish: 0, bearish: 0, neutral: 0 } - ); - - const severityTotal = severityCounts.high + severityCounts.medium + severityCounts.low; - const highPct = severityTotal > 0 ? (severityCounts.high / severityTotal) * 100 : 0; - const mediumPct = severityTotal > 0 ? (severityCounts.medium / severityTotal) * 100 : 0; - const lowPct = severityTotal > 0 ? (severityCounts.low / severityTotal) * 100 : 0; - - const directionTotal = - directionCounts.bullish + directionCounts.bearish + directionCounts.neutral; - const bullishPct = directionTotal > 0 ? (directionCounts.bullish / directionTotal) * 100 : 0; - const bearishPct = directionTotal > 0 ? (directionCounts.bearish / directionTotal) * 100 : 0; - const neutralPct = directionTotal > 0 ? (directionCounts.neutral / directionTotal) * 100 : 0; - - return ( -
-
-
- Severity (last 30m) - {severityTotal} alerts -
-
-
- {severityCounts.high > 0 ? `High ${severityCounts.high}` : ""} -
-
- {severityCounts.medium > 0 ? `Med ${severityCounts.medium}` : ""} -
-
- {severityCounts.low > 0 ? `Low ${severityCounts.low}` : ""} -
-
-
-
-
- Direction (last 30m) - {directionTotal} alerts -
-
-
- {directionCounts.bullish > 0 ? `Bull ${directionCounts.bullish}` : ""} -
-
- {directionCounts.bearish > 0 ? `Bear ${directionCounts.bearish}` : ""} -
-
- {directionCounts.neutral > 0 ? `Neut ${directionCounts.neutral}` : ""} -
-
-
-
- ); -}; - -type EvidenceItem = - | { kind: "flow"; id: string; packet: FlowPacket } - | { kind: "print"; id: string; print: OptionPrint } - | { kind: "unknown"; id: string }; - -type DarkEvidenceItem = - | { kind: "join"; id: string; join: EquityPrintJoin } - | { kind: "unknown"; id: string }; - -type AlertDrawerProps = { - alert: AlertEvent; - flowPacket: FlowPacket | null; - evidence: EvidenceItem[]; - onClose: () => void; -}; - -const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => { - const primary = alert.hits[0]; - const direction = primary ? normalizeDirection(primary.direction) : "neutral"; - const evidencePrints = evidence.filter((item) => item.kind === "print"); - const unknownCount = evidence.filter((item) => item.kind === "unknown").length; - - return ( - - ); -}; - -type ClassifierHitDrawerProps = { - hit: ClassifierHitEvent; - flowPacket: FlowPacket | null; - evidence: EvidenceItem[]; - onClose: () => void; -}; - -const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => { - const direction = normalizeDirection(hit.direction); - const evidencePrints = evidence.filter((item) => item.kind === "print"); - const unknownCount = evidence.filter((item) => item.kind === "unknown").length; - - return ( - - ); -}; - -type DarkDrawerProps = { - event: InferredDarkEvent; - evidence: DarkEvidenceItem[]; - underlying: string | null; - onClose: () => void; -}; - -const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => { - const joinEvidence = evidence.filter( - (item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join" - ); - const unknownCount = evidence.filter((item) => item.kind === "unknown").length; - const traceRefs = event.evidence_refs.slice(0, 6); - const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length); - - return ( - - ); -}; - -const formatFlowMetric = (value: number, suffix?: string): string => { - if (suffix) { - return `${value}${suffix}`; - } - - return value.toLocaleString(); -}; - -export default function HomePage() { - const [mode, setMode] = useState("live"); - const [replaySource, setReplaySource] = useState(null); - const [selectedAlert, setSelectedAlert] = useState(null); - const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); - const [selectedClassifierHit, setSelectedClassifierHit] = useState(null); - const [filterInput, setFilterInput] = useState(""); - const [chartIntervalMs, setChartIntervalMs] = useState(CANDLE_INTERVALS[0].ms); - - const handleReplaySource = useCallback((value: string | null) => { - setReplaySource(value); - }, []); - - useEffect(() => { - setReplaySource(null); - }, [mode]); - const optionsScroll = useListScroll(); - const equitiesScroll = useListScroll(); - const flowScroll = useListScroll(); - const darkScroll = useListScroll(); - const alertsScroll = useListScroll(); - const classifierScroll = useListScroll(); - - const optionsAnchor = useScrollAnchor(optionsScroll.listRef, optionsScroll.isAtTopRef); - const equitiesAnchor = useScrollAnchor(equitiesScroll.listRef, equitiesScroll.isAtTopRef); - const flowAnchor = useScrollAnchor(flowScroll.listRef, flowScroll.isAtTopRef); - const darkAnchor = useScrollAnchor(darkScroll.listRef, darkScroll.isAtTopRef); - const alertsAnchor = useScrollAnchor(alertsScroll.listRef, alertsScroll.isAtTopRef); - const classifierAnchor = useScrollAnchor( - classifierScroll.listRef, - classifierScroll.isAtTopRef - ); - const disableReplayGrouping = useCallback(() => null, []); - - const options = useTape({ - mode, - wsPath: "/ws/options", - replayPath: "/replay/options", - latestPath: "/prints/options", - expectedType: "option-print", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: optionsAnchor.capture, - onNewItems: optionsScroll.onNewItems, - getReplayKey: extractReplaySource, - onReplaySourceKey: handleReplaySource - }); - - const equities = useTape({ - mode, - wsPath: "/ws/equities", - replayPath: "/replay/equities", - latestPath: "/prints/equities", - expectedType: "equity-print", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: equitiesAnchor.capture, - onNewItems: equitiesScroll.onNewItems - }); - - const equityJoins = useTape({ - mode, - wsPath: "/ws/equity-joins", - replayPath: "/replay/equity-joins", - latestPath: "/joins/equities", - expectedType: "equity-join", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - getReplayKey: disableReplayGrouping - }); - - const nbbo = useTape({ - mode, - wsPath: "/ws/options-nbbo", - replayPath: "/replay/nbbo", - latestPath: "/nbbo/options", - expectedType: "option-nbbo", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - getReplayKey: extractReplaySource, - replaySourceKey: replaySource - }); - - const inferredDark = useTape({ - mode, - wsPath: "/ws/inferred-dark", - replayPath: "/replay/inferred-dark", - latestPath: "/dark/inferred", - expectedType: "inferred-dark", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: darkAnchor.capture, - onNewItems: darkScroll.onNewItems, - getReplayKey: disableReplayGrouping - }); - - const flow = useTape({ - mode, - wsPath: "/ws/flow", - replayPath: "/replay/flow", - latestPath: "/flow/packets", - expectedType: "flow-packet", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: flowAnchor.capture, - onNewItems: flowScroll.onNewItems, - getReplayKey: disableReplayGrouping - }); - const alerts = useTape({ - mode, - wsPath: "/ws/alerts", - replayPath: "/replay/alerts", - latestPath: "/flow/alerts", - expectedType: "alert", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: alertsAnchor.capture, - onNewItems: alertsScroll.onNewItems, - getReplayKey: disableReplayGrouping - }); - const classifierHits = useTape({ - mode, - wsPath: "/ws/classifier-hits", - replayPath: "/replay/classifier-hits", - latestPath: "/flow/classifier-hits", - expectedType: "classifier-hit", - batchSize: mode === "replay" ? 120 : undefined, - pollMs: mode === "replay" ? 200 : undefined, - captureScroll: classifierAnchor.capture, - onNewItems: classifierScroll.onNewItems, - getReplayKey: disableReplayGrouping - }); - - useLayoutEffect(() => { - optionsAnchor.apply(); - }, [options.items, optionsAnchor.apply]); - - useLayoutEffect(() => { - equitiesAnchor.apply(); - }, [equities.items, equitiesAnchor.apply]); - - useLayoutEffect(() => { - flowAnchor.apply(); - }, [flow.items, flowAnchor.apply]); - - useLayoutEffect(() => { - darkAnchor.apply(); - }, [inferredDark.items, darkAnchor.apply]); - - useLayoutEffect(() => { - alertsAnchor.apply(); - }, [alerts.items, alertsAnchor.apply]); - - useLayoutEffect(() => { - classifierAnchor.apply(); - }, [classifierHits.items, classifierAnchor.apply]); - - const activeTickers = useMemo(() => { - const parts = filterInput - .split(/[,\s]+/) - .map((value) => value.trim().toUpperCase()) - .filter(Boolean); - return Array.from(new Set(parts)); - }, [filterInput]); - - const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); - const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]); - - const nbboMap = useMemo(() => { - const map = new Map(); - for (const quote of nbbo.items) { - const contractId = normalizeContractId(quote.option_contract_id); - const existing = map.get(contractId); - if ( - !existing || - quote.ts > existing.ts || - (quote.ts === existing.ts && quote.seq >= existing.seq) - ) { - map.set(contractId, quote); - } - } - return map; - }, [nbbo.items]); - - const optionPrintMap = useMemo(() => { - const map = new Map(); - for (const print of options.items) { - if (print.trace_id) { - map.set(print.trace_id, print); - } - } - return map; - }, [options.items]); - - const equityPrintMap = useMemo(() => { - const map = new Map(); - for (const print of equities.items) { - if (print.trace_id) { - map.set(print.trace_id, print); - } - } - return map; - }, [equities.items]); - - const equityJoinMap = useMemo(() => { - const map = new Map(); - for (const join of equityJoins.items) { - map.set(join.id, join); - } - return map; - }, [equityJoins.items]); - - const flowPacketMap = useMemo(() => { - const map = new Map(); - for (const packet of flow.items) { - map.set(packet.id, packet); - } - return map; - }, [flow.items]); - - const selectedEvidence = useMemo((): EvidenceItem[] => { - if (!selectedAlert) { - return []; - } - - return selectedAlert.evidence_refs.map((id) => { - const packet = flowPacketMap.get(id); - if (packet) { - return { kind: "flow", id, packet }; - } - const print = optionPrintMap.get(id); - if (print) { - return { kind: "print", id, print }; - } - return { kind: "unknown", id }; - }); - }, [selectedAlert, flowPacketMap, optionPrintMap]); - - const selectedFlowPacket = useMemo(() => { - if (!selectedAlert) { - return null; - } - const packetId = selectedAlert.evidence_refs[0]; - return packetId ? flowPacketMap.get(packetId) ?? null : null; - }, [selectedAlert, flowPacketMap]); - - const selectedDarkEvidence = useMemo((): DarkEvidenceItem[] => { - if (!selectedDarkEvent) { - return []; - } - - return selectedDarkEvent.evidence_refs.map((id) => { - const join = equityJoinMap.get(id); - if (join) { - return { kind: "join", id, join }; - } - return { kind: "unknown", id }; - }); - }, [selectedDarkEvent, equityJoinMap]); - - const selectedDarkUnderlying = useMemo(() => { - if (!selectedDarkEvent) { - return null; - } - return inferDarkUnderlying(selectedDarkEvent, equityPrintMap, equityJoinMap); - }, [selectedDarkEvent, equityJoinMap, equityPrintMap]); - - useEffect(() => { - if (mode !== "live") { - setSelectedAlert(null); - } - setSelectedDarkEvent(null); - setSelectedClassifierHit(null); - }, [mode]); - - const extractPacketContract = useCallback((packet: FlowPacket): string => { - const contract = packet.features.option_contract_id; - if (typeof contract === "string") { - return contract; - } - const match = packet.id.match(/^flowpacket:([^:]+):/); - return match?.[1] ?? packet.id; - }, []); - - const extractUnderlyingFromTrace = useCallback((traceId: string): string | null => { - const match = traceId.match(/flowpacket:([^:]+):/); - if (!match?.[1]) { - return null; - } - return extractUnderlying(match[1]); - }, []); - - const extractPacketIdFromClassifierHitTrace = useCallback((traceId: string): string | null => { - const idx = traceId.indexOf("flowpacket:"); - if (idx < 0) { - return null; - } - return traceId.slice(idx); - }, []); - - const selectedClassifierPacketId = useMemo(() => { - if (!selectedClassifierHit) { - return null; - } - return extractPacketIdFromClassifierHitTrace(selectedClassifierHit.trace_id); - }, [extractPacketIdFromClassifierHitTrace, selectedClassifierHit]); - - const selectedClassifierFlowPacket = useMemo(() => { - if (!selectedClassifierPacketId) { - return null; - } - return flowPacketMap.get(selectedClassifierPacketId) ?? null; - }, [flowPacketMap, selectedClassifierPacketId]); - - const selectedClassifierEvidence = useMemo((): EvidenceItem[] => { - if (!selectedClassifierHit) { - return []; - } - - if (!selectedClassifierPacketId) { - return []; - } - - const packet = flowPacketMap.get(selectedClassifierPacketId); - if (!packet) { - return []; - } - - return packet.members.map((id) => { - const print = optionPrintMap.get(id); - if (print) { - return { kind: "print", id, print }; - } - return { kind: "unknown", id }; - }); - }, [flowPacketMap, optionPrintMap, selectedClassifierHit, selectedClassifierPacketId]); - - const inferAlertUnderlying = useCallback( - (alert: AlertEvent): string | null => { - const fromTrace = extractUnderlyingFromTrace(alert.trace_id); - if (fromTrace) { - return fromTrace; - } - - const packetId = alert.evidence_refs[0]; - if (packetId) { - const packet = flowPacketMap.get(packetId); - if (packet) { - return extractUnderlying(extractPacketContract(packet)); - } - } - - for (const ref of alert.evidence_refs) { - const print = optionPrintMap.get(ref); - if (print) { - return extractUnderlying(print.option_contract_id); - } - } - - return null; - }, - [extractPacketContract, extractUnderlyingFromTrace, flowPacketMap, optionPrintMap] - ); - - const matchesTicker = useCallback( - (value: string | null) => { - if (tickerSet.size === 0) { - return true; - } - if (!value) { - return false; - } - return tickerSet.has(value.toUpperCase()); - }, - [tickerSet] - ); - - const filteredOptions = useMemo(() => { - if (tickerSet.size === 0) { - return options.items; - } - return options.items.filter((print) => - matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id))) - ); - }, [options.items, matchesTicker, tickerSet]); - - const filteredEquities = useMemo(() => { - if (tickerSet.size === 0) { - return equities.items; - } - return equities.items.filter((print) => matchesTicker(print.underlying_id)); - }, [equities.items, matchesTicker, tickerSet]); - - const filteredInferredDark = useMemo(() => { - if (tickerSet.size === 0) { - return inferredDark.items; - } - return inferredDark.items.filter((event) => { - const underlying = inferDarkUnderlying(event, equityPrintMap, equityJoinMap); - return matchesTicker(underlying); - }); - }, [equityJoinMap, equityPrintMap, inferredDark.items, matchesTicker, tickerSet]); - - const filteredFlow = useMemo(() => { - if (tickerSet.size === 0) { - return flow.items; - } - return flow.items.filter((packet) => - matchesTicker(extractUnderlying(extractPacketContract(packet))) - ); - }, [flow.items, extractPacketContract, matchesTicker, tickerSet]); - - const filteredAlerts = useMemo(() => { - if (tickerSet.size === 0) { - return alerts.items; - } - return alerts.items.filter((alert) => matchesTicker(inferAlertUnderlying(alert))); - }, [alerts.items, inferAlertUnderlying, matchesTicker, tickerSet]); - - const filteredClassifierHits = useMemo(() => { - if (tickerSet.size === 0) { - return classifierHits.items; - } - return classifierHits.items.filter((hit) => { - const underlying = extractUnderlyingFromTrace(hit.trace_id); - return matchesTicker(underlying); - }); - }, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]); - - const chartClassifierHits = useMemo(() => { - const desired = chartTicker.toUpperCase(); - return classifierHits.items - .filter((hit) => extractUnderlyingFromTrace(hit.trace_id) === desired) - .sort((a, b) => { - const delta = a.source_ts - b.source_ts; - if (delta !== 0) { - return delta; - } - return a.seq - b.seq; - }); - }, [chartTicker, classifierHits.items, extractUnderlyingFromTrace]); - - const chartInferredDark = useMemo(() => { - const desired = chartTicker.toUpperCase(); - return inferredDark.items - .filter((event) => inferDarkUnderlying(event, equityPrintMap, equityJoinMap) === desired) - .sort((a, b) => { - const delta = a.source_ts - b.source_ts; - if (delta !== 0) { - return delta; - } - return a.seq - b.seq; - }); - }, [chartTicker, inferredDark.items, equityJoinMap, equityPrintMap]); - - const findAlertForClassifierHit = useCallback( - (hit: ClassifierHitEvent): AlertEvent | null => { - const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id); - if (!packetId) { - return null; - } - - const desiredTrace = `alert:${packetId}`; - return ( - alerts.items.find( - (item) => item.trace_id === desiredTrace || item.evidence_refs[0] === packetId - ) ?? null - ); - }, - [alerts.items, extractPacketIdFromClassifierHitTrace] - ); - - const openFromClassifierHit = useCallback( - (hit: ClassifierHitEvent) => { - const alert = findAlertForClassifierHit(hit); - if (alert) { - setSelectedClassifierHit(null); - setSelectedDarkEvent(null); - setSelectedAlert(alert); - return; - } - - setSelectedAlert(null); - setSelectedDarkEvent(null); - setSelectedClassifierHit(hit); - }, - [findAlertForClassifierHit] - ); - - const handleClassifierMarkerClick = useCallback( - (hit: ClassifierHitEvent) => { - openFromClassifierHit(hit); - }, - [openFromClassifierHit] - ); - - const handleDarkMarkerClick = useCallback((event: InferredDarkEvent) => { - setSelectedAlert(null); - setSelectedClassifierHit(null); - setSelectedDarkEvent(event); - }, []); - - const lastSeen = useMemo(() => { - return [ - options.lastUpdate, - equities.lastUpdate, - inferredDark.lastUpdate, - flow.lastUpdate, - alerts.lastUpdate, - classifierHits.lastUpdate - ] - .filter((value): value is number => value !== null) - .sort((a, b) => b - a)[0] ?? null; - }, [ - options.lastUpdate, - equities.lastUpdate, - inferredDark.lastUpdate, - flow.lastUpdate, - alerts.lastUpdate, - classifierHits.lastUpdate - ]); - - const toggleMode = () => { - setMode((prev) => (prev === "live" ? "replay" : "live")); - }; - - return ( -
-
-
-

Realtime flow workspace

-

Islandflow

-

- Options + equities streaming over WebSocket or replayed from ClickHouse. -

-
-
- Last update - - {lastSeen ? formatTime(lastSeen) : "Waiting for data"} - - -
-
- -
-
-

Ticker filter

-

- {activeTickers.length > 0 ? `Filtering ${activeTickers.join(", ")}` : "All tickers"} -

-
-
- setFilterInput(event.target.value)} - placeholder="SPY, NVDA, AAPL" - /> - -
-
- -
-
-
-
-

Equity Chart

-

- Server-built {formatIntervalLabel(chartIntervalMs)} candles for {chartTicker}. -

-
-
-
-
- {CANDLE_INTERVALS.map((interval) => ( - - ))} -
- {activeTickers.length > 1 ? ( - Charting first of {activeTickers.length} tickers - ) : ( - Charting {chartTicker} - )} -
- -
- -
-
-
-

Options Tape

-

Newest prints first (max {MAX_ITEMS}).

-
-
-
- - -
- -
- {filteredOptions.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No option prints match the current filter." - : mode === "live" - ? "No option prints yet. Start ingest-options." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredOptions.map((print) => { - const contractId = normalizeContractId(print.option_contract_id); - const quote = 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 = classifyNbboSide(print.price, quote); - const notional = print.price * print.size * 100; - - return ( -
-
-
{formatContractLabel(contractId)}
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - Notional ${formatUsd(notional)} - {print.conditions?.length ? ( - {print.conditions.join(", ")} - ) : null} -
- {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} - {nbboStale ? Stale : null} -
- ) : ( -
- NBBO missing -
- )} -
-
{formatTime(print.ts)}
-
- ); - }) - )} -
-
- -
-
-
-

Equities Tape

-

Off-exchange flag highlighted.

-
-
-
- - -
- -
- {filteredEquities.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No equity prints match the current filter." - : mode === "live" - ? "No equity prints yet. Start ingest-equities." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredEquities.map((print) => ( -
-
-
{print.underlying_id}
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - {print.offExchangeFlag ? ( - Off-Ex - ) : ( - Lit - )} -
-
-
{formatTime(print.ts)}
-
- )) - )} -
-
- -
-
-
-

Flow Packets

-

Deterministic clusters.

-
-
-
- - -
- -
-
- {filteredFlow.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No flow packets match the current filter." - : mode === "live" - ? "No flow packets yet. Start compute." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredFlow.map((packet) => { - const features = packet.features ?? {}; - const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); - const count = parseNumber(features.count, packet.members.length); - const totalSize = parseNumber(features.total_size, 0); - const totalNotional = parseNumber(features.total_notional, Number.NaN); - const notional = Number.isFinite(totalNotional) - ? totalNotional - : parseNumber(features.total_premium, 0) * 100; - const startTs = parseNumber(features.start_ts, packet.source_ts); - const endTs = parseNumber(features.end_ts, startTs); - const windowMs = parseNumber(features.window_ms, 0); - const structureType = - typeof features.structure_type === "string" ? features.structure_type : ""; - const structureLegs = parseNumber(features.structure_legs, 0); - const structureRights = - typeof features.structure_rights === "string" ? features.structure_rights : ""; - const structureStrikes = parseNumber(features.structure_strikes, 0); - const nbboBid = parseNumber(features.nbbo_bid, Number.NaN); - const nbboAsk = parseNumber(features.nbbo_ask, Number.NaN); - const nbboMid = parseNumber(features.nbbo_mid, Number.NaN); - const nbboSpread = parseNumber(features.nbbo_spread, Number.NaN); - const aggressiveBuyRatio = parseNumber( - features.nbbo_aggressive_buy_ratio, - Number.NaN - ); - const aggressiveSellRatio = parseNumber( - features.nbbo_aggressive_sell_ratio, - Number.NaN - ); - const aggressiveCoverage = parseNumber(features.nbbo_coverage_ratio, Number.NaN); - const insideRatio = parseNumber(features.nbbo_inside_ratio, Number.NaN); - 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; - - 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)} -
-
- ); - }) - )} -
-
-
- -
-
-
-

Alerts

-

Rule-based scoring from flow packets.

-
-
-
- - -
- -
- -
- {filteredAlerts.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No alerts match the current filter." - : mode === "live" - ? "No alerts yet. Start compute." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredAlerts.map((alert) => { - const primary = alert.hits[0]; - const direction = primary ? normalizeDirection(primary.direction) : "neutral"; - - return ( - - ); - }) - )} -
-
-
- -
-
-
-

Classifier Hits

-

Raw rule hits before alert scoring.

-
-
-
- - -
- -
-
- {filteredClassifierHits.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No classifier hits match the current filter." - : mode === "live" - ? "No classifier hits yet. Start compute." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredClassifierHits.map((hit) => { - const direction = normalizeDirection(hit.direction); - return ( - - ); - }) - )} -
-
-
- -
-
-
-

Inferred Dark

-

Off-exchange patterns inferred from equity joins.

-
-
-
- - -
- -
-
- {filteredInferredDark.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No inferred dark events match the current filter." - : mode === "live" - ? "No inferred dark events yet. Start compute." - : "Replay queue empty. Ensure ClickHouse has data."} -
- ) : ( - filteredInferredDark.map((event) => { - const underlying = inferDarkUnderlying(event, equityPrintMap, equityJoinMap); - const evidenceCount = event.evidence_refs.length; - return ( - - ); - }) - )} -
-
-
-
- - {selectedAlert ? ( - setSelectedAlert(null)} - /> - ) : null} - - {selectedClassifierHit ? ( - setSelectedClassifierHit(null)} - /> - ) : null} - - {selectedDarkEvent ? ( - setSelectedDarkEvent(null)} - /> - ) : null} -
- ); +export default function Page() { + return ; } diff --git a/apps/web/app/replay/page.tsx b/apps/web/app/replay/page.tsx new file mode 100644 index 0000000..2044bee --- /dev/null +++ b/apps/web/app/replay/page.tsx @@ -0,0 +1,5 @@ +import { ReplayRoute } from "../terminal"; + +export default function Page() { + return ; +} diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/signals/page.tsx new file mode 100644 index 0000000..e510e26 --- /dev/null +++ b/apps/web/app/signals/page.tsx @@ -0,0 +1,5 @@ +import { SignalsRoute } from "../terminal"; + +export default function Page() { + return ; +} diff --git a/apps/web/app/tape/page.tsx b/apps/web/app/tape/page.tsx new file mode 100644 index 0000000..344ada0 --- /dev/null +++ b/apps/web/app/tape/page.tsx @@ -0,0 +1,5 @@ +import { TapeRoute } from "../terminal"; + +export default function Page() { + return ; +} diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx new file mode 100644 index 0000000..8f1c2e0 --- /dev/null +++ b/apps/web/app/terminal.tsx @@ -0,0 +1,4190 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode +} from "react"; +import type { + AlertEvent, + ClassifierHitEvent, + EquityCandle, + EquityPrint, + EquityPrintJoin, + FlowPacket, + InferredDarkEvent, + OptionNBBO, + OptionPrint +} from "@islandflow/types"; +import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts"; + +const MAX_ITEMS = 500; +const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS); +const NBBO_MAX_AGE_MS_SAFE = + Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); +const CANDLE_INTERVALS = [ + { label: "1m", ms: 60000 }, + { label: "5m", ms: 300000 } +]; + +type CandlestickSeries = ReturnType; + +type EquityOverlayPoint = { + ts: number; + price: number; + size: number; + offExchangeFlag: boolean; +}; + +type ChartCandle = { + time: UTCTimestamp; + open: number; + high: number; + low: number; + close: number; +}; + +const formatIntervalLabel = (intervalMs: number): string => { + const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs); + if (match) { + return match.label; + } + if (intervalMs >= 60000) { + return `${Math.round(intervalMs / 60000)}m`; + } + if (intervalMs >= 1000) { + return `${Math.round(intervalMs / 1000)}s`; + } + return `${intervalMs}ms`; +}; + +const toChartTime = (ts: number): UTCTimestamp => { + return Math.floor(ts / 1000) as UTCTimestamp; +}; + +type ChartTimeLike = number | string | { year: number; month: number; day: number }; + +const chartTimeToMs = (value: ChartTimeLike): number | null => { + if (typeof value === "number") { + return Math.floor(value * 1000); + } + + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + + if (value && typeof value === "object") { + const { year, month, day } = value; + if ( + Number.isFinite(year) && + Number.isFinite(month) && + Number.isFinite(day) && + year >= 1970 && + month >= 1 && + month <= 12 && + day >= 1 && + day <= 31 + ) { + return Date.UTC(year, month - 1, day); + } + } + + return null; +}; + +const toChartCandle = (candle: EquityCandle): ChartCandle => { + return { + time: toChartTime(candle.ts), + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close + }; +}; + +const clamp = (value: number, min: number, max: number): number => { + if (!Number.isFinite(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); +}; + +const sampleToLimit = (items: T[], limit: number): T[] => { + if (items.length <= limit) { + return items; + } + + const safeLimit = Math.max(1, Math.floor(limit)); + const step = Math.ceil(items.length / safeLimit); + const sampled: T[] = []; + for (let idx = 0; idx < items.length; idx += step) { + sampled.push(items[idx]); + } + + return sampled; +}; + +const readErrorDetail = async (response: Response): Promise => { + const text = await response.text(); + if (!text) { + return ""; + } + try { + const payload = JSON.parse(text) as { + detail?: string; + error?: string; + message?: string; + }; + return payload.detail ?? payload.error ?? payload.message ?? text; + } catch { + return text; + } +}; + +type WsStatus = "connecting" | "connected" | "disconnected"; + +type TapeMode = "live" | "replay"; + +type MessageType = + | "option-print" + | "option-nbbo" + | "equity-print" + | "equity-candle" + | "equity-join" + | "flow-packet" + | "inferred-dark" + | "classifier-hit" + | "alert"; + +type StreamMessage = { + type: MessageType; + payload: T; +}; + +type ReplayCursor = { + ts: number; + seq: number; +}; + +type ReplayResponse = { + data: T[]; + next: ReplayCursor | null; +}; + +const inferTracePrefix = (traceId: string): string => { + const match = traceId.match(/^(.*)-\d+$/); + return match ? match[1] : traceId; +}; + +const extractTracePrefix = (item: T): string | null => { + const traceId = (item as { trace_id?: string }).trace_id; + if (!traceId) { + return null; + } + return inferTracePrefix(traceId); +}; + +const extractReplaySource = (item: T): string | null => { + const prefix = extractTracePrefix(item); + if (!prefix) { + return null; + } + + const normalized = prefix.toLowerCase(); + if (normalized.startsWith("synthetic")) { + return "synthetic"; + } + if (normalized.startsWith("databento")) { + return "databento"; + } + if (normalized.startsWith("alpaca")) { + return "alpaca"; + } + if (normalized.startsWith("ibkr")) { + return "ibkr"; + } + + return prefix; +}; + +type SortableItem = { + ts?: number; + source_ts?: number; + ingest_ts?: number; + seq?: number; + trace_id?: string; + id?: string; +}; + +const extractSortTs = (item: SortableItem): number => + item.ts ?? item.source_ts ?? item.ingest_ts ?? 0; + +const extractSortSeq = (item: SortableItem): number => item.seq ?? 0; + +const buildItemKey = (item: SortableItem): string | null => { + if (item.trace_id) { + return `${item.trace_id}:${item.seq ?? ""}`; + } + + if (item.id) { + return `id:${item.id}`; + } + + return null; +}; + +const mergeNewest = (incoming: T[], existing: T[]): T[] => { + const combined = [...incoming, ...existing]; + if (combined.length === 0) { + return combined; + } + + const seen = new Set(); + const deduped: T[] = []; + + for (const item of combined) { + const key = buildItemKey(item); + if (key) { + if (seen.has(key)) { + continue; + } + seen.add(key); + } + deduped.push(item); + } + + deduped.sort((a, b) => { + const delta = extractSortTs(b) - extractSortTs(a); + if (delta !== 0) { + return delta; + } + return extractSortSeq(b) - extractSortSeq(a); + }); + + return deduped.slice(0, MAX_ITEMS); +}; + +type TapeState = { + status: WsStatus; + items: T[]; + lastUpdate: number | null; + replayTime: number | null; + replayComplete: boolean; + paused: boolean; + dropped: number; + togglePause: () => void; +}; + +const buildWsUrl = (path: string): string => { + const envBase = process.env.NEXT_PUBLIC_API_URL; + + if (envBase) { + const url = new URL(envBase); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = path; + url.search = ""; + url.hash = ""; + return url.toString(); + } + + const { protocol, hostname } = window.location; + const wsProtocol = protocol === "https:" ? "wss" : "ws"; + const isLocal = LOCAL_HOSTS.has(hostname); + const host = isLocal ? `${hostname}:4000` : window.location.host; + + return `${wsProtocol}://${host}${path}`; +}; + +const buildApiUrl = (path: string): string => { + const envBase = process.env.NEXT_PUBLIC_API_URL; + + if (envBase) { + const url = new URL(envBase); + const secure = url.protocol === "https:" || url.protocol === "wss:"; + url.protocol = secure ? "https:" : "http:"; + url.pathname = path; + url.search = ""; + url.hash = ""; + return url.toString(); + } + + const { protocol, hostname } = window.location; + const httpProtocol = protocol === "https:" ? "https" : "http"; + const isLocal = LOCAL_HOSTS.has(hostname); + const host = isLocal ? `${hostname}:4000` : window.location.host; + + return `${httpProtocol}://${host}${path}`; +}; + +const formatPrice = (price: number): string => { + if (!Number.isFinite(price)) { + return "0.00"; + } + return price.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); +}; + +const formatSize = (size: number): string => { + return size.toLocaleString(); +}; + +const formatTime = (ts: number): string => { + return new Date(ts).toLocaleTimeString(); +}; + +const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`; + +const formatPct = (value: number): string => `${Math.round(value * 100)}%`; + +const formatUsd = (value: number): string => { + if (!Number.isFinite(value)) { + return "0.00"; + } + return value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); +}; + +const normalizeContractId = (value: string): string => value.trim(); + +const formatContractLabel = (value: string): string => { + const normalized = normalizeContractId(value); + if (!normalized) { + return "Unknown contract"; + } + if (/^\d+$/.test(normalized)) { + return `Instrument ${normalized}`; + } + return normalized; +}; + +const formatDateTime = (ts: number): string => { + const date = new Date(ts); + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; +}; + +const humanizeClassifierId = (value: string): string => { + if (!value) { + return "Classifier"; + } + + return value + .split("_") + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join(" "); +}; + +const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" => { + const normalized = value.toLowerCase(); + if (normalized === "bullish" || normalized === "bearish" || normalized === "neutral") { + return normalized; + } + return "neutral"; +}; + +const extractUnderlying = (contractId: string): string => { + const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/); + if (match?.[1]) { + return match[1].toUpperCase(); + } + return contractId.split("-")[0]?.toUpperCase() ?? contractId.toUpperCase(); +}; + +const extractEquityTraceFromJoin = (joinId: string): string | null => { + const match = joinId.match(/^equityjoin:(.+)$/); + return match?.[1] ?? null; +}; + +const inferDarkUnderlying = ( + event: InferredDarkEvent, + equityPrints: Map, + equityJoins: Map +): string | null => { + for (const ref of event.evidence_refs) { + const join = equityJoins.get(ref); + if (!join) { + continue; + } + const underlying = join.features.underlying_id; + if (typeof underlying === "string" && underlying.length > 0) { + return underlying.toUpperCase(); + } + } + + const match = event.trace_id.match(/^dark:(?:stealth_accumulation|distribution):([^:]+):/); + if (match?.[1]) { + return match[1].toUpperCase(); + } + + for (const ref of event.evidence_refs) { + const traceId = extractEquityTraceFromJoin(ref); + if (!traceId) { + continue; + } + const print = equityPrints.get(traceId); + if (print) { + return print.underlying_id.toUpperCase(); + } + } + + return null; +}; + +const parseNumber = (value: unknown, fallback: number): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return fallback; +}; + +const parseBoolean = (value: unknown, fallback = false): boolean => { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "off"].includes(normalized)) { + return false; + } + } + return fallback; +}; + +const getJoinString = (join: EquityPrintJoin, key: string): string | null => { + const value = join.features[key]; + return typeof value === "string" ? value : null; +}; + +const getJoinNumber = (join: EquityPrintJoin, key: string, fallback = Number.NaN): number => { + return parseNumber(join.features[key], fallback); +}; + +const getJoinBoolean = (join: EquityPrintJoin, key: string): boolean => { + return parseBoolean(join.features[key], false); +}; + +type NbboSide = "AA" | "A" | "B" | "BB"; + +const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): NbboSide | null => { + if (!quote || !Number.isFinite(price)) { + return null; + } + + const bid = quote.bid; + const ask = quote.ask; + if (!Number.isFinite(bid) || !Number.isFinite(ask) || ask <= 0) { + return null; + } + + const spread = Math.max(0, ask - bid); + const epsilon = Math.max(0.01, spread * 0.05); + + if (price > ask + epsilon) { + return "AA"; + } + if (price >= ask - epsilon) { + return "A"; + } + if (price < bid - epsilon) { + return "BB"; + } + if (price <= bid + epsilon) { + return "B"; + } + + const mid = (bid + ask) / 2; + return price >= mid ? "A" : "B"; +}; + +type ListScrollState = { + listRef: React.RefObject; + isAtTop: boolean; + isAtTopRef: React.MutableRefObject; + missed: number; + resumeTick: number; + onNewItems: (count: number) => void; + jumpToTop: () => void; +}; + +const useListScroll = (): ListScrollState => { + const listRef = useRef(null); + const [isAtTop, setIsAtTop] = useState(true); + const [missed, setMissed] = useState(0); + const [resumeTick, setResumeTick] = useState(0); + const isAtTopRef = useRef(true); + const prevAtTopRef = useRef(true); + + useEffect(() => { + isAtTopRef.current = isAtTop; + }, [isAtTop]); + + const updateScrollState = useCallback(() => { + const el = listRef.current; + if (!el) { + return; + } + + const atTop = el.scrollTop <= 2; + + if (atTop && !prevAtTopRef.current) { + setResumeTick((prev) => prev + 1); + } + + prevAtTopRef.current = atTop; + isAtTopRef.current = atTop; + setIsAtTop(atTop); + + if (atTop) { + setMissed(0); + } + }, [isAtTopRef]); + + useEffect(() => { + const el = listRef.current; + if (!el) { + return; + } + + const onScroll = () => { + updateScrollState(); + }; + + updateScrollState(); + el.addEventListener("scroll", onScroll); + + return () => { + el.removeEventListener("scroll", onScroll); + }; + }, [updateScrollState]); + + const onNewItems = useCallback((count: number) => { + if (count <= 0) { + return; + } + + if (isAtTopRef.current) { + setMissed(0); + return; + } + + setMissed((prev) => prev + count); + }, []); + + const jumpToTop = useCallback(() => { + const el = listRef.current; + if (!el) { + return; + } + + isAtTopRef.current = true; + el.scrollTop = 0; + updateScrollState(); + }, [isAtTopRef, listRef, updateScrollState]); + + return { + listRef, + isAtTop, + isAtTopRef, + missed, + resumeTick, + onNewItems, + jumpToTop + }; +}; + +const useScrollAnchor = ( + listRef: React.RefObject, + isAtTopRef: React.MutableRefObject +) => { + const pendingRef = useRef<{ height: number } | null>(null); + + const capture = useCallback(() => { + if (isAtTopRef.current) { + pendingRef.current = null; + return; + } + + const el = listRef.current; + if (!el) { + return; + } + + pendingRef.current = { + height: el.scrollHeight + }; + }, [isAtTopRef, listRef]); + + const apply = useCallback(() => { + const pending = pendingRef.current; + if (!pending) { + return; + } + + const el = listRef.current; + if (!el) { + return; + } + + if (isAtTopRef.current) { + pendingRef.current = null; + return; + } + + const delta = el.scrollHeight - pending.height; + if (delta !== 0) { + el.scrollTop = Math.max(0, el.scrollTop + delta); + } + pendingRef.current = null; + }, [isAtTopRef, listRef]); + + return { capture, apply }; +}; + +const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => { + if (paused) { + return "Paused"; + } + + if (mode === "replay") { + return status === "disconnected" ? "Replay Down" : "Replay"; + } + + switch (status) { + case "connected": + return "Live"; + case "connecting": + return "Connecting"; + case "disconnected": + default: + return "Disconnected"; + } +}; + +type TapeConfig = { + mode: TapeMode; + wsPath: string; + replayPath: string; + latestPath?: string; + expectedType: MessageType; + batchSize?: number; + pollMs?: number; + captureScroll?: () => void; + onNewItems?: (count: number) => void; + getItemTs?: (item: T) => number; + getReplayKey?: (item: T) => string | null; + replaySourceKey?: string | null; + onReplaySourceKey?: (key: string | null) => void; +}; + +const useTape = ( + config: TapeConfig +): TapeState => { + const { mode, wsPath, replayPath, expectedType, latestPath, onNewItems, captureScroll } = config; + const batchSize = config.batchSize ?? 40; + const pollMs = config.pollMs ?? 1000; + const getItemTs = config.getItemTs ?? extractSortTs; + const getReplayKey = config.getReplayKey ?? extractTracePrefix; + const replaySourceKey = config.replaySourceKey ?? null; + const onReplaySourceKey = config.onReplaySourceKey; + const [status, setStatus] = useState("connecting"); + const [items, setItems] = useState([]); + const [lastUpdate, setLastUpdate] = useState(null); + const [replayTime, setReplayTime] = useState(null); + const [replayComplete, setReplayComplete] = useState(false); + const [paused, setPaused] = useState(false); + const [dropped, setDropped] = useState(0); + const reconnectRef = useRef(null); + const socketRef = useRef(null); + const cursorRef = useRef({ ts: 0, seq: 0 }); + const replayEndRef = useRef(null); + const replayCompleteRef = useRef(false); + const replaySourceRef = useRef(null); + const replaySourceNotifiedRef = useRef(null); + const emptyPollsRef = useRef(0); + const pausedRef = useRef(paused); + const pendingRef = useRef([]); + const pendingCountRef = useRef(0); + const flushHandleRef = useRef(null); + + useEffect(() => { + pausedRef.current = paused; + }, [paused]); + + const cancelFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + cancelAnimationFrame(flushHandleRef.current); + flushHandleRef.current = null; + } + }, []); + + const scheduleFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + return; + } + + flushHandleRef.current = requestAnimationFrame(() => { + flushHandleRef.current = null; + const buffered = pendingRef.current; + if (buffered.length === 0) { + return; + } + pendingRef.current = []; + + const pendingCount = pendingCountRef.current; + pendingCountRef.current = 0; + + if (onNewItems && pendingCount > 0) { + onNewItems(pendingCount); + } + + if (captureScroll) { + captureScroll(); + } + + setItems((prev) => mergeNewest(buffered, prev)); + setLastUpdate(Date.now()); + }); + }, [captureScroll, onNewItems]); + + const togglePause = useCallback(() => { + setPaused((prev) => { + const next = !prev; + if (!next) { + setDropped(0); + } + return next; + }); + }, []); + + useEffect(() => { + setItems([]); + setLastUpdate(null); + setReplayTime(null); + setReplayComplete(false); + replayCompleteRef.current = false; + replaySourceRef.current = null; + replaySourceNotifiedRef.current = null; + emptyPollsRef.current = 0; + setDropped(0); + setStatus("connecting"); + cursorRef.current = { ts: 0, seq: 0 }; + pendingRef.current = []; + pendingCountRef.current = 0; + cancelFlush(); + }, [mode, replaySourceKey, cancelFlush]); + + useEffect(() => { + if (mode !== "replay" || !latestPath) { + replayEndRef.current = null; + return; + } + + let active = true; + replayEndRef.current = null; + setReplayComplete(false); + replayCompleteRef.current = false; + + const fetchReplayEnd = async () => { + try { + const url = new URL(buildApiUrl(latestPath)); + url.searchParams.set("limit", "1"); + if (replaySourceKey) { + url.searchParams.set("source", replaySourceKey); + } + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Replay baseline failed with ${response.status}`); + } + + const payload = (await response.json()) as { data?: T[] }; + const latest = payload.data?.[0]; + if (active && latest) { + replayEndRef.current = getItemTs(latest); + } + } catch (error) { + console.warn("Failed to load replay end cursor", error); + } + }; + + void fetchReplayEnd(); + + return () => { + active = false; + }; + }, [mode, latestPath, getItemTs, replaySourceKey]); + + useEffect(() => { + if (mode !== "live") { + return; + } + + let active = true; + + const connect = () => { + if (!active) { + return; + } + + setStatus("connecting"); + + const socket = new WebSocket(buildWsUrl(wsPath)); + socketRef.current = socket; + + socket.onopen = () => { + if (!active) { + return; + } + setStatus("connected"); + }; + + socket.onmessage = (event) => { + if (!active) { + return; + } + + try { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== expectedType) { + return; + } + + if (pausedRef.current) { + setDropped((prev) => prev + 1); + setLastUpdate(Date.now()); + return; + } + + pendingRef.current.push(message.payload); + pendingCountRef.current += 1; + scheduleFlush(); + } catch (error) { + console.warn("Failed to parse websocket payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + reconnectRef.current = window.setTimeout(() => { + connect(); + }, 1000); + }; + + socket.onerror = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + socket.close(); + }; + }; + + connect(); + + return () => { + active = false; + cancelFlush(); + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + } + if (socketRef.current) { + socketRef.current.close(); + } + }; + }, [mode, wsPath, expectedType, scheduleFlush, cancelFlush]); + + useEffect(() => { + if (mode !== "replay") { + return; + } + + let active = true; + + const poll = async () => { + if (!active || pausedRef.current) { + return; + } + + if (replayCompleteRef.current) { + return; + } + + try { + let keepPolling = true; + + while (keepPolling && active && !pausedRef.current) { + const replayEnd = replayEndRef.current; + const cursor = cursorRef.current; + + if (replayEnd !== null && cursor.ts >= replayEnd) { + replayCompleteRef.current = true; + setReplayComplete(true); + setStatus("disconnected"); + return; + } + + const url = new URL(buildApiUrl(replayPath)); + url.searchParams.set("after_ts", cursor.ts.toString()); + url.searchParams.set("after_seq", cursor.seq.toString()); + url.searchParams.set("limit", batchSize.toString()); + const desiredSource = replaySourceKey ?? replaySourceRef.current; + if (desiredSource) { + url.searchParams.set("source", desiredSource); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Replay request failed with ${response.status}`); + } + + const payload = (await response.json()) as ReplayResponse; + + let sourcePrefix = replaySourceRef.current; + if (replaySourceKey) { + if (sourcePrefix !== replaySourceKey) { + sourcePrefix = replaySourceKey; + replaySourceRef.current = replaySourceKey; + } + } else if (!sourcePrefix) { + const firstWithTrace = payload.data.find((item) => getReplayKey(item)); + if (firstWithTrace) { + sourcePrefix = getReplayKey(firstWithTrace); + replaySourceRef.current = sourcePrefix ?? null; + } + } + + if (onReplaySourceKey && sourcePrefix && replaySourceNotifiedRef.current !== sourcePrefix) { + replaySourceNotifiedRef.current = sourcePrefix; + onReplaySourceKey(sourcePrefix); + } + + const filtered = sourcePrefix + ? payload.data.filter((item) => getReplayKey(item) === sourcePrefix) + : payload.data; + + const hasForeign = + sourcePrefix && + payload.data.some((item) => { + const prefix = getReplayKey(item); + return prefix !== null && prefix !== sourcePrefix; + }); + + if (filtered.length > 0) { + const nextItems = [...filtered].reverse(); + pendingRef.current.push(...nextItems); + pendingCountRef.current += nextItems.length; + scheduleFlush(); + const last = filtered.at(-1); + if (last) { + const lastTs = getItemTs(last); + setReplayTime(lastTs); + if (replayEnd !== null && lastTs >= replayEnd) { + cursorRef.current = { ts: lastTs, seq: last.seq }; + replayCompleteRef.current = true; + setReplayComplete(true); + setStatus("disconnected"); + return; + } + } + emptyPollsRef.current = 0; + } else if (sourcePrefix) { + emptyPollsRef.current += 1; + } + + if (payload.next) { + cursorRef.current = payload.next; + } + + setStatus("connected"); + keepPolling = filtered.length === batchSize; + + if (keepPolling) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + if (!replaySourceKey && hasForeign) { + replayCompleteRef.current = true; + setReplayComplete(true); + setStatus("disconnected"); + return; + } + + if (sourcePrefix && emptyPollsRef.current >= 3) { + replayCompleteRef.current = true; + setReplayComplete(true); + setStatus("disconnected"); + return; + } + } + } catch (error) { + console.warn("Replay poll failed", error); + setStatus("disconnected"); + } + }; + + void poll(); + const interval = window.setInterval(poll, pollMs); + + return () => { + active = false; + window.clearInterval(interval); + cancelFlush(); + }; + }, [ + mode, + replayPath, + batchSize, + pollMs, + scheduleFlush, + cancelFlush, + getItemTs, + getReplayKey, + replaySourceKey, + onReplaySourceKey + ]); + + return { + status, + items, + lastUpdate, + replayTime, + replayComplete, + paused, + dropped, + togglePause + }; +}; + +const useLiveStream = ( + config: { + enabled: boolean; + wsPath: string; + expectedType: MessageType; + onNewItems?: (count: number) => void; + captureScroll?: () => void; + shouldHold?: () => boolean; + resumeSignal?: number; + } +): TapeState => { + const [status, setStatus] = useState( + config.enabled ? "connecting" : "disconnected" + ); + const [items, setItems] = useState([]); + const [lastUpdate, setLastUpdate] = useState(null); + const [replayTime] = useState(null); + const [replayComplete] = useState(false); + const [paused, setPaused] = useState(false); + const [dropped, setDropped] = useState(0); + const reconnectRef = useRef(null); + const socketRef = useRef(null); + const pausedRef = useRef(paused); + const pendingRef = useRef([]); + const pendingCountRef = useRef(0); + const flushHandleRef = useRef(null); + const holdRef = useRef([]); + + useEffect(() => { + pausedRef.current = paused; + }, [paused]); + + const cancelFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + cancelAnimationFrame(flushHandleRef.current); + flushHandleRef.current = null; + } + }, []); + + const scheduleFlush = useCallback(() => { + if (flushHandleRef.current !== null) { + return; + } + + flushHandleRef.current = requestAnimationFrame(() => { + flushHandleRef.current = null; + const buffered = pendingRef.current; + if (buffered.length === 0) { + return; + } + pendingRef.current = []; + + const pendingCount = pendingCountRef.current; + pendingCountRef.current = 0; + + if (config.onNewItems && pendingCount > 0) { + config.onNewItems(pendingCount); + } + + const shouldHold = config.shouldHold ? config.shouldHold() : false; + if (!shouldHold && config.captureScroll) { + config.captureScroll(); + } + + if (shouldHold) { + holdRef.current = mergeNewest(buffered, holdRef.current); + setLastUpdate(Date.now()); + return; + } + + const nextBatch = + holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered; + holdRef.current = []; + + setItems((prev) => mergeNewest(nextBatch, prev)); + setLastUpdate(Date.now()); + }); + }, [config.captureScroll, config.onNewItems, config.shouldHold]); + + const togglePause = useCallback(() => { + setPaused((prev) => { + const next = !prev; + if (!next) { + setDropped(0); + } + return next; + }); + }, []); + + useEffect(() => { + if (!config.enabled) { + setStatus("disconnected"); + setItems([]); + setLastUpdate(null); + pendingRef.current = []; + pendingCountRef.current = 0; + holdRef.current = []; + cancelFlush(); + return; + } + + let active = true; + + const connect = () => { + if (!active) { + return; + } + + setStatus("connecting"); + + const socket = new WebSocket(buildWsUrl(config.wsPath)); + socketRef.current = socket; + + socket.onopen = () => { + if (!active) { + return; + } + setStatus("connected"); + }; + + socket.onmessage = (event) => { + if (!active) { + return; + } + + try { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== config.expectedType) { + return; + } + + if (pausedRef.current) { + setDropped((prev) => prev + 1); + setLastUpdate(Date.now()); + return; + } + + pendingRef.current.push(message.payload); + pendingCountRef.current += 1; + scheduleFlush(); + } catch (error) { + console.warn("Failed to parse live stream payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + reconnectRef.current = window.setTimeout(() => { + connect(); + }, 1000); + }; + + socket.onerror = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + socket.close(); + }; + }; + + connect(); + + return () => { + active = false; + cancelFlush(); + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + } + if (socketRef.current) { + socketRef.current.close(); + } + }; + }, [config.enabled, config.expectedType, config.wsPath, scheduleFlush, cancelFlush]); + + useEffect(() => { + if (config.resumeSignal === undefined) { + return; + } + if (config.shouldHold && config.shouldHold()) { + return; + } + if (holdRef.current.length === 0) { + return; + } + setItems((prev) => mergeNewest(holdRef.current, prev)); + holdRef.current = []; + setLastUpdate(Date.now()); + }, [config.resumeSignal, config.shouldHold]); + + return { + status, + items, + lastUpdate, + replayTime, + replayComplete, + paused, + dropped, + togglePause + }; +}; + +const useFlowStream = ( + enabled: boolean, + onNewItems?: (count: number) => void, + captureScroll?: () => void, + shouldHold?: () => boolean, + resumeSignal?: number +): TapeState => { + return useLiveStream({ + enabled, + wsPath: "/ws/flow", + expectedType: "flow-packet", + onNewItems, + captureScroll, + shouldHold, + resumeSignal + }); +}; + +type TapeStatusProps = { + status: WsStatus; + lastUpdate: number | null; + replayTime: number | null; + replayComplete: boolean; + paused: boolean; + dropped: number; + mode: TapeMode; +}; + +const TapeStatus = ({ + status, + lastUpdate: _lastUpdate, + replayTime, + replayComplete, + paused, + dropped, + mode +}: TapeStatusProps) => { + const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); + const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : ""; + + return ( +
+ + {label} + {mode === "replay" ? ( + + Replay time {replayTime ? formatTime(replayTime) : "—"} + + ) : null} + + {pausedLabel || "+000 queued"} + +
+ ); +}; + +type TapeControlsProps = { + paused: boolean; + onTogglePause: () => void; + isAtTop: boolean; + missed: number; + onJump: () => void; +}; + +const TapeControls = ({ paused, onTogglePause, isAtTop, missed, onJump }: TapeControlsProps) => { + const active = !isAtTop && missed > 0; + return ( +
+ + + {active ? `+${missed} new` : ""} +
+ ); +}; + +type CandleChartProps = { + ticker: string; + intervalMs: number; + mode: TapeMode; + replayTime?: number | null; + classifierHits: ClassifierHitEvent[]; + inferredDark: InferredDarkEvent[]; + onClassifierHitClick: (hit: ClassifierHitEvent) => void; + onInferredDarkClick: (event: InferredDarkEvent) => void; +}; + +type MarkerAction = + | { kind: "hit"; hit: ClassifierHitEvent } + | { kind: "dark"; event: InferredDarkEvent }; + +const CandleChart = ({ + ticker, + intervalMs, + mode, + replayTime = null, + classifierHits, + inferredDark, + onClassifierHitClick, + onInferredDarkClick +}: CandleChartProps) => { + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef(null); + const socketRef = useRef(null); + const reconnectRef = useRef(null); + const overlaySocketRef = useRef(null); + const overlayReconnectRef = useRef(null); + const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null); + + const markerLookupRef = useRef>(new Map()); + const [visibleRangeMs, setVisibleRangeMs] = useState<{ from: number; to: number } | null>(null); + const onHitClickRef = useRef(onClassifierHitClick); + const onDarkClickRef = useRef(onInferredDarkClick); + + const overlayCanvasRef = useRef(null); + const overlayCtxRef = useRef(null); + const overlayDataRef = useRef([]); + const overlayLiveRef = useRef([]); + const overlayLastFetchRef = useRef<{ startTs: number; endTs: number; ticker: string } | null>( + null + ); + const overlayFetchAbortRef = useRef(null); + const overlayTimerRef = useRef(null); + + const [overlayEnabled, setOverlayEnabled] = useState(true); + + const drawOverlay = useCallback( + (points: EquityOverlayPoint[]) => { + const canvas = overlayCanvasRef.current; + const ctx = overlayCtxRef.current; + const chart = chartRef.current; + if (!canvas || !ctx || !chart) { + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (!overlayEnabled || points.length === 0) { + canvas.style.opacity = "0"; + return; + } + + const timeScale = chart.timeScale(); + if (!seriesRef.current) { + canvas.style.opacity = "0"; + return; + } + + const filtered = points.filter((point) => point.offExchangeFlag); + const sampled = sampleToLimit(filtered, 1400); + + const maxRadius = 10; + const minRadius = 2; + const maxSize = Math.max(1, ...sampled.map((point) => point.size)); + + ctx.globalAlpha = 0.9; + ctx.fillStyle = "rgba(31, 74, 123, 0.55)"; + ctx.strokeStyle = "rgba(31, 74, 123, 0.95)"; + + for (const point of sampled) { + const x = timeScale.timeToCoordinate(toChartTime(point.ts)); + const y = seriesRef.current.priceToCoordinate(point.price); + if (x === null || y === null) { + continue; + } + + const radius = clamp( + minRadius + (Math.sqrt(point.size) / Math.sqrt(maxSize)) * (maxRadius - minRadius), + minRadius, + maxRadius + ); + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + + ctx.globalAlpha = 1; + canvas.style.opacity = "1"; + }, + [overlayEnabled] + ); + + useEffect(() => { + drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); + }, [drawOverlay, ticker, intervalMs, mode]); + + useEffect(() => { + onHitClickRef.current = onClassifierHitClick; + }, [onClassifierHitClick]); + + useEffect(() => { + onDarkClickRef.current = onInferredDarkClick; + }, [onInferredDarkClick]); + + const markerBundle = useMemo(() => { + const lookup = new Map(); + const markers: SeriesMarker[] = []; + + if (!visibleRangeMs) { + return { markers, lookup }; + } + + const { from, to } = visibleRangeMs; + const inRangeHits = classifierHits + .filter((hit) => hit.source_ts >= from && hit.source_ts <= to) + .sort((a, b) => { + const delta = a.source_ts - b.source_ts; + if (delta !== 0) { + return delta; + } + return a.seq - b.seq; + }); + const inRangeDark = inferredDark + .filter((event) => event.source_ts >= from && event.source_ts <= to) + .sort((a, b) => { + const delta = a.source_ts - b.source_ts; + if (delta !== 0) { + return delta; + } + return a.seq - b.seq; + }); + + const MAX_HIT_MARKERS = 220; + const MAX_DARK_MARKERS = 120; + const MAX_TOTAL_MARKERS = 320; + + const cappedHits = + inRangeHits.length > MAX_HIT_MARKERS + ? inRangeHits.slice(inRangeHits.length - MAX_HIT_MARKERS) + : inRangeHits; + const cappedDark = + inRangeDark.length > MAX_DARK_MARKERS + ? inRangeDark.slice(inRangeDark.length - MAX_DARK_MARKERS) + : inRangeDark; + + for (const hit of cappedHits) { + const direction = normalizeDirection(hit.direction); + const markerId = `hit:${hit.trace_id}:${hit.seq}`; + lookup.set(markerId, { kind: "hit", hit }); + + markers.push({ + id: markerId, + time: toChartTime(hit.source_ts), + position: direction === "bullish" ? "belowBar" : "aboveBar", + color: + direction === "bullish" + ? "#2f6d4f" + : direction === "bearish" + ? "#c46f2a" + : "rgba(111, 91, 57, 0.9)", + shape: + direction === "bullish" + ? "arrowUp" + : direction === "bearish" + ? "arrowDown" + : "circle", + text: hit.classifier_id ? hit.classifier_id.slice(0, 3).toUpperCase() : "H" + }); + } + + for (const event of cappedDark) { + const markerId = `dark:${event.trace_id}:${event.seq}`; + lookup.set(markerId, { kind: "dark", event }); + markers.push({ + id: markerId, + time: toChartTime(event.source_ts), + position: "aboveBar", + color: "rgba(31, 74, 123, 0.9)", + shape: "square", + text: "D" + }); + } + + markers.sort((a, b) => { + const delta = Number(a.time) - Number(b.time); + if (delta !== 0) { + return delta; + } + return String(a.id ?? "").localeCompare(String(b.id ?? "")); + }); + + const cappedMarkers = + markers.length > MAX_TOTAL_MARKERS + ? markers.slice(markers.length - MAX_TOTAL_MARKERS) + : markers; + + if (cappedMarkers !== markers) { + const nextLookup = new Map(); + for (const marker of cappedMarkers) { + const id = marker.id; + if (typeof id !== "string") { + continue; + } + const action = lookup.get(id); + if (action) { + nextLookup.set(id, action); + } + } + return { markers: cappedMarkers, lookup: nextLookup }; + } + + return { markers: cappedMarkers, lookup }; + }, [classifierHits, inferredDark, visibleRangeMs]); + + useEffect(() => { + if (!seriesRef.current) { + return; + } + markerLookupRef.current = markerBundle.lookup; + seriesRef.current.setMarkers(markerBundle.markers); + }, [markerBundle]); + + const replayBucket = useMemo(() => { + if (mode !== "replay" || replayTime === null) { + return null; + } + return Math.floor(replayTime / intervalMs); + }, [mode, replayTime, intervalMs]); + const replayEndTs = useMemo(() => { + if (replayBucket === null) { + return null; + } + return (replayBucket + 1) * intervalMs - 1; + }, [replayBucket, intervalMs]); + const [ready, setReady] = useState(false); + const [status, setStatus] = useState(mode === "live" ? "connecting" : "connected"); + const [lastUpdate, setLastUpdate] = useState(null); + const [hasData, setHasData] = useState(false); + const [error, setError] = useState(null); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const width = container.clientWidth || 600; + const height = container.clientHeight || 360; + const chart = createChart(container, { + width, + height, + layout: { + background: { color: "#fffdf7" }, + textColor: "#4e3e25" + }, + grid: { + vertLines: { color: "rgba(82, 64, 36, 0.12)" }, + horzLines: { color: "rgba(82, 64, 36, 0.12)" } + }, + crosshair: { + vertLine: { color: "rgba(47, 109, 79, 0.35)" }, + horzLine: { color: "rgba(47, 109, 79, 0.35)" } + }, + timeScale: { + borderColor: "rgba(111, 91, 57, 0.35)", + timeVisible: true, + secondsVisible: intervalMs < 60000 + }, + rightPriceScale: { + borderColor: "rgba(111, 91, 57, 0.35)" + } + }); + + const overlayCanvas = document.createElement("canvas"); + overlayCanvas.width = Math.max(1, Math.floor(width)); + overlayCanvas.height = Math.max(1, Math.floor(height)); + overlayCanvas.style.position = "absolute"; + overlayCanvas.style.inset = "0"; + overlayCanvas.style.pointerEvents = "none"; + overlayCanvas.style.zIndex = "2"; + overlayCanvas.style.opacity = "0"; + container.style.position = "relative"; + container.appendChild(overlayCanvas); + overlayCanvasRef.current = overlayCanvas; + overlayCtxRef.current = overlayCanvas.getContext("2d"); + + const series = chart.addCandlestickSeries({ + upColor: "#2f6d4f", + downColor: "#c46f2a", + borderVisible: false, + wickUpColor: "#2f6d4f", + wickDownColor: "#c46f2a" + }); + + chartRef.current = chart; + seriesRef.current = series; + setReady(true); + + const timeScale = chart.timeScale(); + const updateVisibleRange = () => { + const range = timeScale.getVisibleRange(); + if (!range) { + setVisibleRangeMs(null); + return; + } + const from = chartTimeToMs(range.from); + const to = chartTimeToMs(range.to); + if (from === null || to === null) { + setVisibleRangeMs(null); + return; + } + + setVisibleRangeMs({ + from: Math.min(from, to), + to: Math.max(from, to) + }); + }; + + const clickHandler = (param: { hoveredObjectId?: unknown }) => { + const hovered = param.hoveredObjectId; + if (hovered === null || hovered === undefined) { + return; + } + const key = typeof hovered === "string" ? hovered : String(hovered); + const action = markerLookupRef.current.get(key); + if (!action) { + return; + } + if (action.kind === "hit") { + onHitClickRef.current(action.hit); + } else { + onDarkClickRef.current(action.event); + } + }; + + updateVisibleRange(); + timeScale.subscribeVisibleTimeRangeChange(updateVisibleRange); + chart.subscribeClick(clickHandler); + + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const { width: nextWidth, height: nextHeight } = entry.contentRect; + if (Number.isFinite(nextWidth) && Number.isFinite(nextHeight)) { + const nextW = Math.max(1, Math.floor(nextWidth)); + const nextH = Math.max(1, Math.floor(nextHeight)); + chart.applyOptions({ + width: nextW, + height: nextH + }); + + const canvas = overlayCanvasRef.current; + if (canvas) { + canvas.width = nextW; + canvas.height = nextH; + } + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + timeScale.unsubscribeVisibleTimeRangeChange(updateVisibleRange); + chart.unsubscribeClick(clickHandler); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + overlayCtxRef.current = null; + overlayCanvasRef.current?.remove(); + overlayCanvasRef.current = null; + }; + }, []); + + useEffect(() => { + if (!ready || !seriesRef.current) { + return; + } + + if (mode === "replay" && replayBucket === null) { + setError(null); + setHasData(false); + setLastUpdate(null); + lastCandleRef.current = null; + seriesRef.current.setData([]); + overlayDataRef.current = []; + overlayLiveRef.current = []; + overlayLastFetchRef.current = null; + setStatus("connected"); + return; + } + + let active = true; + setError(null); + setHasData(false); + setLastUpdate(null); + lastCandleRef.current = null; + seriesRef.current.setData([]); + overlayDataRef.current = []; + overlayLiveRef.current = []; + overlayLastFetchRef.current = null; + setStatus(mode === "live" ? "connecting" : "connected"); + + const fetchCandles = async () => { + try { + const url = new URL(buildApiUrl("/candles/equities")); + url.searchParams.set("underlying_id", ticker); + url.searchParams.set("interval_ms", intervalMs.toString()); + url.searchParams.set("limit", "300"); + url.searchParams.set("cache", "1"); + if (mode === "replay" && replayEndTs !== null) { + url.searchParams.set("end_ts", replayEndTs.toString()); + } + const response = await fetch(url.toString()); + if (!response.ok) { + const detail = await readErrorDetail(response); + throw new Error( + `Candle fetch failed (${response.status})${detail ? `: ${detail}` : ""}` + ); + } + const payload = (await response.json()) as { data?: EquityCandle[] }; + if (!active || !seriesRef.current) { + return; + } + const sorted = [...(payload.data ?? [])].sort((a, b) => { + if (a.ts !== b.ts) { + return a.ts - b.ts; + } + return a.seq - b.seq; + }); + const chartData = sorted.map(toChartCandle); + seriesRef.current.setData(chartData); + chartRef.current?.timeScale().fitContent(); + drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); + + if (sorted.length > 0) { + const last = sorted[sorted.length - 1]; + lastCandleRef.current = { time: toChartTime(last.ts), seq: last.seq }; + setHasData(true); + setLastUpdate(last.ingest_ts ?? last.ts); + } + } catch (error) { + if (!active) { + return; + } + setError(error instanceof Error ? error.message : String(error)); + setStatus("disconnected"); + setHasData(false); + } + }; + + + const ensureOverlayListener = () => { + if (!chartRef.current) { + return; + } + + const handler = () => { + const combined = [...overlayDataRef.current, ...overlayLiveRef.current]; + drawOverlay(combined); + scheduleOverlayFetch(); + }; + + chartRef.current.timeScale().subscribeVisibleTimeRangeChange(handler); + return () => { + chartRef.current?.timeScale().unsubscribeVisibleTimeRangeChange(handler); + }; + }; + + const cancelOverlayFetch = () => { + if (overlayFetchAbortRef.current) { + overlayFetchAbortRef.current.abort(); + overlayFetchAbortRef.current = null; + } + }; + + const fetchOverlayRange = async (startTs: number, endTs: number) => { + cancelOverlayFetch(); + const abort = new AbortController(); + overlayFetchAbortRef.current = abort; + + const url = new URL(buildApiUrl("/prints/equities/range")); + url.searchParams.set("underlying_id", ticker); + url.searchParams.set("start_ts", Math.floor(startTs).toString()); + url.searchParams.set("end_ts", Math.floor(endTs).toString()); + url.searchParams.set("limit", "2500"); + + const response = await fetch(url.toString(), { signal: abort.signal }); + if (!response.ok) { + const detail = await readErrorDetail(response); + throw new Error( + `Equity range fetch failed (${response.status})${detail ? `: ${detail}` : ""}` + ); + } + + const payload = (await response.json()) as { data?: EquityPrint[] }; + const prints = payload.data ?? []; + overlayDataRef.current = prints.map((print) => ({ + ts: print.ts, + price: print.price, + size: print.size, + offExchangeFlag: print.offExchangeFlag + })); + overlayLiveRef.current = []; + overlayLastFetchRef.current = { startTs, endTs, ticker }; + }; + + function scheduleOverlayFetch() { + if (overlayTimerRef.current !== null) { + window.clearTimeout(overlayTimerRef.current); + } + + overlayTimerRef.current = window.setTimeout(() => { + if (!active || !chartRef.current || !seriesRef.current) { + return; + } + + const timeScale = chartRef.current.timeScale(); + const range = timeScale.getVisibleRange(); + if (!range) { + return; + } + + const startTs = chartTimeToMs(range.from); + const endTs = chartTimeToMs(range.to); + if (startTs === null || endTs === null) { + return; + } + const last = overlayLastFetchRef.current; + + const needsFetch = + !last || + last.ticker !== ticker || + startTs < last.startTs || + endTs > last.endTs || + Math.abs(endTs - last.endTs) > intervalMs * 6; + + if (!needsFetch) { + return; + } + + void fetchOverlayRange(startTs, endTs) + .then(() => { + drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); + }) + .catch((error) => { + if (!active) { + return; + } + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.warn("Overlay fetch failed", error); + }); + }, 180); + } + + const overlayUnsubscribe = ensureOverlayListener(); + scheduleOverlayFetch(); + + void fetchCandles(); + + return () => { + active = false; + cancelOverlayFetch(); + if (overlayTimerRef.current !== null) { + window.clearTimeout(overlayTimerRef.current); + overlayTimerRef.current = null; + } + overlayUnsubscribe?.(); + }; + }, [ready, ticker, intervalMs, mode, replayBucket, replayEndTs]); + + useEffect(() => { + if (!ready || mode !== "live" || !seriesRef.current) { + if (socketRef.current) { + socketRef.current.close(); + } + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + reconnectRef.current = null; + } + + if (overlaySocketRef.current) { + overlaySocketRef.current.close(); + } + if (overlayReconnectRef.current !== null) { + window.clearTimeout(overlayReconnectRef.current); + overlayReconnectRef.current = null; + } + + return; + } + + let active = true; + + const connect = () => { + if (!active) { + return; + } + + setStatus("connecting"); + const socket = new WebSocket(buildWsUrl("/ws/equity-candles")); + socketRef.current = socket; + + socket.onopen = () => { + if (!active) { + return; + } + setStatus("connected"); + }; + + socket.onmessage = (event) => { + if (!active || !seriesRef.current) { + return; + } + + try { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== "equity-candle") { + return; + } + + const candle = message.payload; + if (candle.underlying_id !== ticker || candle.interval_ms !== intervalMs) { + return; + } + + const chartCandle = toChartCandle(candle); + const last = lastCandleRef.current; + if (last) { + if (chartCandle.time < last.time) { + return; + } + if (chartCandle.time === last.time && candle.seq <= last.seq) { + return; + } + } + + seriesRef.current.update(chartCandle); + lastCandleRef.current = { time: chartCandle.time, seq: candle.seq }; + setHasData(true); + setLastUpdate(candle.ingest_ts ?? candle.ts); + drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); + } catch (error) { + console.warn("Failed to parse candle payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + setStatus("disconnected"); + reconnectRef.current = window.setTimeout(connect, 1000); + }; + + socket.onerror = () => { + if (!active) { + return; + } + setStatus("disconnected"); + socket.close(); + }; + }; + + const connectOverlay = () => { + if (!active) { + return; + } + + const socket = new WebSocket(buildWsUrl("/ws/equities")); + overlaySocketRef.current = socket; + + socket.onmessage = (event) => { + if (!active) { + return; + } + + try { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== "equity-print") { + return; + } + + const print = message.payload; + if (print.underlying_id !== ticker) { + return; + } + + overlayLiveRef.current.push({ + ts: print.ts, + price: print.price, + size: print.size, + offExchangeFlag: print.offExchangeFlag + }); + + if (overlayLiveRef.current.length > 1500) { + overlayLiveRef.current = overlayLiveRef.current.slice(-1500); + } + + drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]); + } catch (error) { + console.warn("Failed to parse equity print payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + overlayReconnectRef.current = window.setTimeout(connectOverlay, 1500); + }; + + socket.onerror = () => { + if (!active) { + return; + } + socket.close(); + }; + }; + + connect(); + connectOverlay(); + + return () => { + active = false; + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + reconnectRef.current = null; + } + if (socketRef.current) { + socketRef.current.close(); + } + + if (overlayReconnectRef.current !== null) { + window.clearTimeout(overlayReconnectRef.current); + overlayReconnectRef.current = null; + } + if (overlaySocketRef.current) { + overlaySocketRef.current.close(); + } + }; + }, [ready, mode, ticker, intervalMs, drawOverlay]); + + useEffect(() => { + if (!chartRef.current) { + return; + } + chartRef.current.timeScale().applyOptions({ + timeVisible: true, + secondsVisible: intervalMs < 60000 + }); + }, [intervalMs]); + + const statusText = statusLabel(status, false, mode); + const intervalLabel = formatIntervalLabel(intervalMs); + const emptyLabel = + mode === "live" + ? status === "connected" + ? `No candles yet. First ${intervalLabel} candle appears after the window closes.` + : "Chart offline. Start candles service." + : "No candles for this replay window."; + + return ( +
+
+
+ + {statusText} +
+ + {lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"} + + + Blue circles = off-exchange trades +
+
+ {error ? ( +
Chart error: {error}
+ ) : !hasData ? ( +
{emptyLabel}
+ ) : null} +
+ ); +}; + +type AlertSeverityStripProps = { + alerts: AlertEvent[]; +}; + +const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => { + const windowMs = 30 * 60 * 1000; + const now = Date.now(); + const severityCounts = alerts.reduce( + (acc, alert) => { + if (now - alert.source_ts > windowMs) { + return acc; + } + if (alert.severity === "high") { + acc.high += 1; + } else if (alert.severity === "medium") { + acc.medium += 1; + } else { + acc.low += 1; + } + return acc; + }, + { high: 0, medium: 0, low: 0 } + ); + + const directionCounts = alerts.reduce( + (acc, alert) => { + if (now - alert.source_ts > windowMs) { + return acc; + } + const direction = normalizeDirection(alert.hits[0]?.direction ?? "neutral"); + acc[direction] += 1; + return acc; + }, + { bullish: 0, bearish: 0, neutral: 0 } + ); + + const severityTotal = severityCounts.high + severityCounts.medium + severityCounts.low; + const highPct = severityTotal > 0 ? (severityCounts.high / severityTotal) * 100 : 0; + const mediumPct = severityTotal > 0 ? (severityCounts.medium / severityTotal) * 100 : 0; + const lowPct = severityTotal > 0 ? (severityCounts.low / severityTotal) * 100 : 0; + + const directionTotal = + directionCounts.bullish + directionCounts.bearish + directionCounts.neutral; + const bullishPct = directionTotal > 0 ? (directionCounts.bullish / directionTotal) * 100 : 0; + const bearishPct = directionTotal > 0 ? (directionCounts.bearish / directionTotal) * 100 : 0; + const neutralPct = directionTotal > 0 ? (directionCounts.neutral / directionTotal) * 100 : 0; + + return ( +
+
+
+ Severity (last 30m) + {severityTotal} alerts +
+
+
+ {severityCounts.high > 0 ? `High ${severityCounts.high}` : ""} +
+
+ {severityCounts.medium > 0 ? `Med ${severityCounts.medium}` : ""} +
+
+ {severityCounts.low > 0 ? `Low ${severityCounts.low}` : ""} +
+
+
+
+
+ Direction (last 30m) + {directionTotal} alerts +
+
+
+ {directionCounts.bullish > 0 ? `Bull ${directionCounts.bullish}` : ""} +
+
+ {directionCounts.bearish > 0 ? `Bear ${directionCounts.bearish}` : ""} +
+
+ {directionCounts.neutral > 0 ? `Neut ${directionCounts.neutral}` : ""} +
+
+
+
+ ); +}; + +type EvidenceItem = + | { kind: "flow"; id: string; packet: FlowPacket } + | { kind: "print"; id: string; print: OptionPrint } + | { kind: "unknown"; id: string }; + +type DarkEvidenceItem = + | { kind: "join"; id: string; join: EquityPrintJoin } + | { kind: "unknown"; id: string }; + +type AlertDrawerProps = { + alert: AlertEvent; + flowPacket: FlowPacket | null; + evidence: EvidenceItem[]; + onClose: () => void; +}; + +const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => { + const primary = alert.hits[0]; + const direction = primary ? normalizeDirection(primary.direction) : "neutral"; + const evidencePrints = evidence.filter((item) => item.kind === "print"); + const unknownCount = evidence.filter((item) => item.kind === "unknown").length; + + return ( + + ); +}; + +type ClassifierHitDrawerProps = { + hit: ClassifierHitEvent; + flowPacket: FlowPacket | null; + evidence: EvidenceItem[]; + onClose: () => void; +}; + +const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => { + const direction = normalizeDirection(hit.direction); + const evidencePrints = evidence.filter((item) => item.kind === "print"); + const unknownCount = evidence.filter((item) => item.kind === "unknown").length; + + return ( + + ); +}; + +type DarkDrawerProps = { + event: InferredDarkEvent; + evidence: DarkEvidenceItem[]; + underlying: string | null; + onClose: () => void; +}; + +const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => { + const joinEvidence = evidence.filter( + (item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join" + ); + const unknownCount = evidence.filter((item) => item.kind === "unknown").length; + const traceRefs = event.evidence_refs.slice(0, 6); + const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length); + + return ( + + ); +}; + +const formatFlowMetric = (value: number, suffix?: string): string => { + if (suffix) { + return `${value}${suffix}`; + } + + return value.toLocaleString(); +}; + +const useTerminalState = () => { + const [mode, setMode] = useState("live"); + const [replaySource, setReplaySource] = useState(null); + const [selectedAlert, setSelectedAlert] = useState(null); + const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); + const [selectedClassifierHit, setSelectedClassifierHit] = useState(null); + const [filterInput, setFilterInput] = useState(""); + const [chartIntervalMs, setChartIntervalMs] = useState(CANDLE_INTERVALS[0].ms); + + const handleReplaySource = useCallback((value: string | null) => { + setReplaySource(value); + }, []); + + useEffect(() => { + setReplaySource(null); + }, [mode]); + const optionsScroll = useListScroll(); + const equitiesScroll = useListScroll(); + const flowScroll = useListScroll(); + const darkScroll = useListScroll(); + const alertsScroll = useListScroll(); + const classifierScroll = useListScroll(); + + const optionsAnchor = useScrollAnchor(optionsScroll.listRef, optionsScroll.isAtTopRef); + const equitiesAnchor = useScrollAnchor(equitiesScroll.listRef, equitiesScroll.isAtTopRef); + const flowAnchor = useScrollAnchor(flowScroll.listRef, flowScroll.isAtTopRef); + const darkAnchor = useScrollAnchor(darkScroll.listRef, darkScroll.isAtTopRef); + const alertsAnchor = useScrollAnchor(alertsScroll.listRef, alertsScroll.isAtTopRef); + const classifierAnchor = useScrollAnchor( + classifierScroll.listRef, + classifierScroll.isAtTopRef + ); + const disableReplayGrouping = useCallback(() => null, []); + + const options = useTape({ + mode, + wsPath: "/ws/options", + replayPath: "/replay/options", + latestPath: "/prints/options", + expectedType: "option-print", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: optionsAnchor.capture, + onNewItems: optionsScroll.onNewItems, + getReplayKey: extractReplaySource, + onReplaySourceKey: handleReplaySource + }); + + const equities = useTape({ + mode, + wsPath: "/ws/equities", + replayPath: "/replay/equities", + latestPath: "/prints/equities", + expectedType: "equity-print", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: equitiesAnchor.capture, + onNewItems: equitiesScroll.onNewItems + }); + + const equityJoins = useTape({ + mode, + wsPath: "/ws/equity-joins", + replayPath: "/replay/equity-joins", + latestPath: "/joins/equities", + expectedType: "equity-join", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + getReplayKey: disableReplayGrouping + }); + + const nbbo = useTape({ + mode, + wsPath: "/ws/options-nbbo", + replayPath: "/replay/nbbo", + latestPath: "/nbbo/options", + expectedType: "option-nbbo", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + getReplayKey: extractReplaySource, + replaySourceKey: replaySource + }); + + const inferredDark = useTape({ + mode, + wsPath: "/ws/inferred-dark", + replayPath: "/replay/inferred-dark", + latestPath: "/dark/inferred", + expectedType: "inferred-dark", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: darkAnchor.capture, + onNewItems: darkScroll.onNewItems, + getReplayKey: disableReplayGrouping + }); + + const flow = useTape({ + mode, + wsPath: "/ws/flow", + replayPath: "/replay/flow", + latestPath: "/flow/packets", + expectedType: "flow-packet", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: flowAnchor.capture, + onNewItems: flowScroll.onNewItems, + getReplayKey: disableReplayGrouping + }); + const alerts = useTape({ + mode, + wsPath: "/ws/alerts", + replayPath: "/replay/alerts", + latestPath: "/flow/alerts", + expectedType: "alert", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: alertsAnchor.capture, + onNewItems: alertsScroll.onNewItems, + getReplayKey: disableReplayGrouping + }); + const classifierHits = useTape({ + mode, + wsPath: "/ws/classifier-hits", + replayPath: "/replay/classifier-hits", + latestPath: "/flow/classifier-hits", + expectedType: "classifier-hit", + batchSize: mode === "replay" ? 120 : undefined, + pollMs: mode === "replay" ? 200 : undefined, + captureScroll: classifierAnchor.capture, + onNewItems: classifierScroll.onNewItems, + getReplayKey: disableReplayGrouping + }); + + useLayoutEffect(() => { + optionsAnchor.apply(); + }, [options.items, optionsAnchor.apply]); + + useLayoutEffect(() => { + equitiesAnchor.apply(); + }, [equities.items, equitiesAnchor.apply]); + + useLayoutEffect(() => { + flowAnchor.apply(); + }, [flow.items, flowAnchor.apply]); + + useLayoutEffect(() => { + darkAnchor.apply(); + }, [inferredDark.items, darkAnchor.apply]); + + useLayoutEffect(() => { + alertsAnchor.apply(); + }, [alerts.items, alertsAnchor.apply]); + + useLayoutEffect(() => { + classifierAnchor.apply(); + }, [classifierHits.items, classifierAnchor.apply]); + + const activeTickers = useMemo(() => { + const parts = filterInput + .split(/[,\s]+/) + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); + return Array.from(new Set(parts)); + }, [filterInput]); + + const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); + const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]); + + const nbboMap = useMemo(() => { + const map = new Map(); + for (const quote of nbbo.items) { + const contractId = normalizeContractId(quote.option_contract_id); + const existing = map.get(contractId); + if ( + !existing || + quote.ts > existing.ts || + (quote.ts === existing.ts && quote.seq >= existing.seq) + ) { + map.set(contractId, quote); + } + } + return map; + }, [nbbo.items]); + + const optionPrintMap = useMemo(() => { + const map = new Map(); + for (const print of options.items) { + if (print.trace_id) { + map.set(print.trace_id, print); + } + } + return map; + }, [options.items]); + + const equityPrintMap = useMemo(() => { + const map = new Map(); + for (const print of equities.items) { + if (print.trace_id) { + map.set(print.trace_id, print); + } + } + return map; + }, [equities.items]); + + const equityJoinMap = useMemo(() => { + const map = new Map(); + for (const join of equityJoins.items) { + map.set(join.id, join); + } + return map; + }, [equityJoins.items]); + + const flowPacketMap = useMemo(() => { + const map = new Map(); + for (const packet of flow.items) { + map.set(packet.id, packet); + } + return map; + }, [flow.items]); + + const selectedEvidence = useMemo((): EvidenceItem[] => { + if (!selectedAlert) { + return []; + } + + return selectedAlert.evidence_refs.map((id) => { + const packet = flowPacketMap.get(id); + if (packet) { + return { kind: "flow", id, packet }; + } + const print = optionPrintMap.get(id); + if (print) { + return { kind: "print", id, print }; + } + return { kind: "unknown", id }; + }); + }, [selectedAlert, flowPacketMap, optionPrintMap]); + + const selectedFlowPacket = useMemo(() => { + if (!selectedAlert) { + return null; + } + const packetId = selectedAlert.evidence_refs[0]; + return packetId ? flowPacketMap.get(packetId) ?? null : null; + }, [selectedAlert, flowPacketMap]); + + const selectedDarkEvidence = useMemo((): DarkEvidenceItem[] => { + if (!selectedDarkEvent) { + return []; + } + + return selectedDarkEvent.evidence_refs.map((id) => { + const join = equityJoinMap.get(id); + if (join) { + return { kind: "join", id, join }; + } + return { kind: "unknown", id }; + }); + }, [selectedDarkEvent, equityJoinMap]); + + const selectedDarkUnderlying = useMemo(() => { + if (!selectedDarkEvent) { + return null; + } + return inferDarkUnderlying(selectedDarkEvent, equityPrintMap, equityJoinMap); + }, [selectedDarkEvent, equityJoinMap, equityPrintMap]); + + useEffect(() => { + if (mode !== "live") { + setSelectedAlert(null); + } + setSelectedDarkEvent(null); + setSelectedClassifierHit(null); + }, [mode]); + + const extractPacketContract = useCallback((packet: FlowPacket): string => { + const contract = packet.features.option_contract_id; + if (typeof contract === "string") { + return contract; + } + const match = packet.id.match(/^flowpacket:([^:]+):/); + return match?.[1] ?? packet.id; + }, []); + + const extractUnderlyingFromTrace = useCallback((traceId: string): string | null => { + const match = traceId.match(/flowpacket:([^:]+):/); + if (!match?.[1]) { + return null; + } + return extractUnderlying(match[1]); + }, []); + + const extractPacketIdFromClassifierHitTrace = useCallback((traceId: string): string | null => { + const idx = traceId.indexOf("flowpacket:"); + if (idx < 0) { + return null; + } + return traceId.slice(idx); + }, []); + + const selectedClassifierPacketId = useMemo(() => { + if (!selectedClassifierHit) { + return null; + } + return extractPacketIdFromClassifierHitTrace(selectedClassifierHit.trace_id); + }, [extractPacketIdFromClassifierHitTrace, selectedClassifierHit]); + + const selectedClassifierFlowPacket = useMemo(() => { + if (!selectedClassifierPacketId) { + return null; + } + return flowPacketMap.get(selectedClassifierPacketId) ?? null; + }, [flowPacketMap, selectedClassifierPacketId]); + + const selectedClassifierEvidence = useMemo((): EvidenceItem[] => { + if (!selectedClassifierHit) { + return []; + } + + if (!selectedClassifierPacketId) { + return []; + } + + const packet = flowPacketMap.get(selectedClassifierPacketId); + if (!packet) { + return []; + } + + return packet.members.map((id) => { + const print = optionPrintMap.get(id); + if (print) { + return { kind: "print", id, print }; + } + return { kind: "unknown", id }; + }); + }, [flowPacketMap, optionPrintMap, selectedClassifierHit, selectedClassifierPacketId]); + + const inferAlertUnderlying = useCallback( + (alert: AlertEvent): string | null => { + const fromTrace = extractUnderlyingFromTrace(alert.trace_id); + if (fromTrace) { + return fromTrace; + } + + const packetId = alert.evidence_refs[0]; + if (packetId) { + const packet = flowPacketMap.get(packetId); + if (packet) { + return extractUnderlying(extractPacketContract(packet)); + } + } + + for (const ref of alert.evidence_refs) { + const print = optionPrintMap.get(ref); + if (print) { + return extractUnderlying(print.option_contract_id); + } + } + + return null; + }, + [extractPacketContract, extractUnderlyingFromTrace, flowPacketMap, optionPrintMap] + ); + + const matchesTicker = useCallback( + (value: string | null) => { + if (tickerSet.size === 0) { + return true; + } + if (!value) { + return false; + } + return tickerSet.has(value.toUpperCase()); + }, + [tickerSet] + ); + + const filteredOptions = useMemo(() => { + if (tickerSet.size === 0) { + return options.items; + } + return options.items.filter((print) => + matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id))) + ); + }, [options.items, matchesTicker, tickerSet]); + + const filteredEquities = useMemo(() => { + if (tickerSet.size === 0) { + return equities.items; + } + return equities.items.filter((print) => matchesTicker(print.underlying_id)); + }, [equities.items, matchesTicker, tickerSet]); + + const filteredInferredDark = useMemo(() => { + if (tickerSet.size === 0) { + return inferredDark.items; + } + return inferredDark.items.filter((event) => { + const underlying = inferDarkUnderlying(event, equityPrintMap, equityJoinMap); + return matchesTicker(underlying); + }); + }, [equityJoinMap, equityPrintMap, inferredDark.items, matchesTicker, tickerSet]); + + const filteredFlow = useMemo(() => { + if (tickerSet.size === 0) { + return flow.items; + } + return flow.items.filter((packet) => + matchesTicker(extractUnderlying(extractPacketContract(packet))) + ); + }, [flow.items, extractPacketContract, matchesTicker, tickerSet]); + + const filteredAlerts = useMemo(() => { + if (tickerSet.size === 0) { + return alerts.items; + } + return alerts.items.filter((alert) => matchesTicker(inferAlertUnderlying(alert))); + }, [alerts.items, inferAlertUnderlying, matchesTicker, tickerSet]); + + const filteredClassifierHits = useMemo(() => { + if (tickerSet.size === 0) { + return classifierHits.items; + } + return classifierHits.items.filter((hit) => { + const underlying = extractUnderlyingFromTrace(hit.trace_id); + return matchesTicker(underlying); + }); + }, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]); + + const chartClassifierHits = useMemo(() => { + const desired = chartTicker.toUpperCase(); + return classifierHits.items + .filter((hit) => extractUnderlyingFromTrace(hit.trace_id) === desired) + .sort((a, b) => { + const delta = a.source_ts - b.source_ts; + if (delta !== 0) { + return delta; + } + return a.seq - b.seq; + }); + }, [chartTicker, classifierHits.items, extractUnderlyingFromTrace]); + + const chartInferredDark = useMemo(() => { + const desired = chartTicker.toUpperCase(); + return inferredDark.items + .filter((event) => inferDarkUnderlying(event, equityPrintMap, equityJoinMap) === desired) + .sort((a, b) => { + const delta = a.source_ts - b.source_ts; + if (delta !== 0) { + return delta; + } + return a.seq - b.seq; + }); + }, [chartTicker, inferredDark.items, equityJoinMap, equityPrintMap]); + + const findAlertForClassifierHit = useCallback( + (hit: ClassifierHitEvent): AlertEvent | null => { + const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id); + if (!packetId) { + return null; + } + + const desiredTrace = `alert:${packetId}`; + return ( + alerts.items.find( + (item) => item.trace_id === desiredTrace || item.evidence_refs[0] === packetId + ) ?? null + ); + }, + [alerts.items, extractPacketIdFromClassifierHitTrace] + ); + + const openFromClassifierHit = useCallback( + (hit: ClassifierHitEvent) => { + const alert = findAlertForClassifierHit(hit); + if (alert) { + setSelectedClassifierHit(null); + setSelectedDarkEvent(null); + setSelectedAlert(alert); + return; + } + + setSelectedAlert(null); + setSelectedDarkEvent(null); + setSelectedClassifierHit(hit); + }, + [findAlertForClassifierHit] + ); + + const handleClassifierMarkerClick = useCallback( + (hit: ClassifierHitEvent) => { + openFromClassifierHit(hit); + }, + [openFromClassifierHit] + ); + + const handleDarkMarkerClick = useCallback((event: InferredDarkEvent) => { + setSelectedAlert(null); + setSelectedClassifierHit(null); + setSelectedDarkEvent(event); + }, []); + + const lastSeen = useMemo(() => { + return [ + options.lastUpdate, + equities.lastUpdate, + inferredDark.lastUpdate, + flow.lastUpdate, + alerts.lastUpdate, + classifierHits.lastUpdate + ] + .filter((value): value is number => value !== null) + .sort((a, b) => b - a)[0] ?? null; + }, [ + options.lastUpdate, + equities.lastUpdate, + inferredDark.lastUpdate, + flow.lastUpdate, + alerts.lastUpdate, + classifierHits.lastUpdate + ]); + + return { + mode, + setMode, + replaySource, + setReplaySource, + selectedAlert, + setSelectedAlert, + selectedDarkEvent, + setSelectedDarkEvent, + selectedClassifierHit, + setSelectedClassifierHit, + filterInput, + setFilterInput, + chartIntervalMs, + setChartIntervalMs, + optionsScroll, + equitiesScroll, + flowScroll, + darkScroll, + alertsScroll, + classifierScroll, + options, + equities, + equityJoins, + nbbo, + inferredDark, + flow, + alerts, + classifierHits, + activeTickers, + tickerSet, + chartTicker, + nbboMap, + optionPrintMap, + equityPrintMap, + equityJoinMap, + flowPacketMap, + selectedEvidence, + selectedFlowPacket, + selectedDarkEvidence, + selectedDarkUnderlying, + selectedClassifierPacketId, + selectedClassifierFlowPacket, + selectedClassifierEvidence, + filteredOptions, + filteredEquities, + filteredInferredDark, + filteredFlow, + filteredAlerts, + filteredClassifierHits, + chartClassifierHits, + chartInferredDark, + openFromClassifierHit, + handleClassifierMarkerClick, + handleDarkMarkerClick, + lastSeen, + toggleMode: () => { + setMode((prev) => (prev === "live" ? "replay" : "live")); + } + }; +}; + +type TerminalState = ReturnType; + +const TerminalContext = createContext(null); + +const useTerminal = (): TerminalState => { + const value = useContext(TerminalContext); + if (!value) { + throw new Error("Terminal context missing"); + } + return value; +}; + +const NAV_ITEMS = [ + { href: "/", label: "Overview" }, + { href: "/tape", label: "Tape" }, + { href: "/signals", label: "Signals" }, + { href: "/charts", label: "Charts" }, + { href: "/replay", label: "Replay" } +]; + +type PageFrameProps = { + title: string; + actions?: ReactNode; + children: ReactNode; +}; + +const PageFrame = ({ title, actions, children }: PageFrameProps) => { + return ( +
+
+

{title}

+ {actions ?
{actions}
: null} +
+ {children} +
+ ); +}; + +type PaneProps = { + title: string; + status?: ReactNode; + actions?: ReactNode; + className?: string; + children: ReactNode; +}; + +const Pane = ({ title, status, actions, className = "", children }: PaneProps) => { + const classes = ["terminal-pane", className].filter(Boolean).join(" "); + return ( +
+
+
+

{title}

+ {status ?
{status}
: null} +
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +}; + +const ShellMetricStrip = () => { + const state = useTerminal(); + const focus = state.activeTickers.length > 0 ? state.activeTickers.join(", ") : "ALL"; + const replay = state.replaySource ? state.replaySource.toUpperCase() : "AUTO"; + + return ( +
+
+ Mode + {state.mode === "live" ? "LIVE" : "REPLAY"} +
+
+ Focus + {focus} +
+
+ Source + {replay} +
+
+ Last + + {state.lastSeen ? formatTime(state.lastSeen) : "WAITING"} + +
+
+ ); +}; + +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; +}; + +const OptionsPane = ({ limit }: OptionsPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions; + + return ( + + } + actions={ + + } + > +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No option prints match the current filter." + : state.mode === "live" + ? "No option prints yet. Start ingest-options." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((print) => { + const contractId = normalizeContractId(print.option_contract_id); + 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 = classifyNbboSide(print.price, quote); + const notional = print.price * print.size * 100; + + return ( +
+
+
{formatContractLabel(contractId)}
+
+ ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + Notional ${formatUsd(notional)} + {print.conditions?.length ? {print.conditions.join(", ")} : null} +
+ {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} + {nbboStale ? Stale : null} +
+ ) : ( +
+ NBBO missing +
+ )} +
+
{formatTime(print.ts)}
+
+ ); + }) + )} +
+
+ ); +}; + +type EquitiesPaneProps = { + limit?: number; +}; + +const EquitiesPane = ({ limit }: EquitiesPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredEquities.slice(0, limit) : state.filteredEquities; + + return ( + + } + actions={ + + } + > +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No equity prints match the current filter." + : state.mode === "live" + ? "No equity prints yet. Start ingest-equities." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((print) => ( +
+
+
{print.underlying_id}
+
+ ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + {print.offExchangeFlag ? ( + Off-Ex + ) : ( + Lit + )} +
+
+
{formatTime(print.ts)}
+
+ )) + )} +
+
+ ); +}; + +type FlowPaneProps = { + limit?: number; + title?: string; +}; + +const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredFlow.slice(0, limit) : state.filteredFlow; + + return ( + + } + actions={ + + } + > +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No flow packets match the current filter." + : state.mode === "live" + ? "No flow packets yet. Start compute." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((packet) => { + const features = packet.features ?? {}; + const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); + const count = parseNumber(features.count, packet.members.length); + const totalSize = parseNumber(features.total_size, 0); + const totalNotional = parseNumber(features.total_notional, Number.NaN); + const notional = Number.isFinite(totalNotional) + ? totalNotional + : parseNumber(features.total_premium, 0) * 100; + const startTs = parseNumber(features.start_ts, packet.source_ts); + const endTs = parseNumber(features.end_ts, startTs); + const windowMs = parseNumber(features.window_ms, 0); + const structureType = + typeof features.structure_type === "string" ? features.structure_type : ""; + const structureLegs = parseNumber(features.structure_legs, 0); + const structureRights = + typeof features.structure_rights === "string" ? features.structure_rights : ""; + const structureStrikes = parseNumber(features.structure_strikes, 0); + const nbboBid = parseNumber(features.nbbo_bid, Number.NaN); + const nbboAsk = parseNumber(features.nbbo_ask, Number.NaN); + const nbboMid = parseNumber(features.nbbo_mid, Number.NaN); + const nbboSpread = parseNumber(features.nbbo_spread, Number.NaN); + const aggressiveBuyRatio = parseNumber(features.nbbo_aggressive_buy_ratio, Number.NaN); + const aggressiveSellRatio = parseNumber( + features.nbbo_aggressive_sell_ratio, + Number.NaN + ); + const aggressiveCoverage = parseNumber(features.nbbo_coverage_ratio, Number.NaN); + const insideRatio = parseNumber(features.nbbo_inside_ratio, Number.NaN); + 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; + + 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)} +
+
+ ); + }) + )} +
+
+ ); +}; + +type AlertsPaneProps = { + limit?: number; + withStrip?: boolean; +}; + +const AlertsPane = ({ limit, withStrip = false }: AlertsPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts; + + return ( + + } + actions={ + + } + > + {withStrip ? : null} +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No alerts match the current filter." + : state.mode === "live" + ? "No alerts yet. Start compute." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((alert) => { + const primary = alert.hits[0]; + const direction = primary ? normalizeDirection(primary.direction) : "neutral"; + + return ( + + ); + }) + )} +
+
+ ); +}; + +type ClassifierPaneProps = { + limit?: number; +}; + +const ClassifierPane = ({ limit }: ClassifierPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredClassifierHits.slice(0, limit) : state.filteredClassifierHits; + + return ( + + } + actions={ + + } + > +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No classifier hits match the current filter." + : state.mode === "live" + ? "No classifier hits yet. Start compute." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((hit) => { + const direction = normalizeDirection(hit.direction); + return ( + + ); + }) + )} +
+
+ ); +}; + +type DarkPaneProps = { + limit?: number; +}; + +const DarkPane = ({ limit }: DarkPaneProps) => { + const state = useTerminal(); + const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark; + + return ( + + } + actions={ + + } + > +
+ {items.length === 0 ? ( +
+ {state.tickerSet.size > 0 + ? "No inferred dark events match the current filter." + : state.mode === "live" + ? "No inferred dark events yet. Start compute." + : "Replay queue empty. Ensure ClickHouse has data."} +
+ ) : ( + items.map((event) => { + const underlying = inferDarkUnderlying(event, state.equityPrintMap, state.equityJoinMap); + const evidenceCount = event.evidence_refs.length; + + return ( + + ); + }) + )} +
+
+ ); +}; + +type ChartPaneProps = { + title?: string; +}; + +const ChartPane = ({ title = "Chart" }: ChartPaneProps) => { + const state = useTerminal(); + + return ( + +
+ {CANDLE_INTERVALS.map((interval) => ( + + ))} +
+ {state.chartTicker} +
+ } + > + + + ); +}; + +const FocusPane = () => { + const state = useTerminal(); + const hits = state.chartClassifierHits.slice(-10).reverse(); + const dark = state.chartInferredDark.slice(-10).reverse(); + + return ( + +
+
+
Ticker
+
{state.chartTicker}
+
+
+
Rules
+ {hits.length === 0 ? ( +
No rule hits for {state.chartTicker}.
+ ) : ( +
+ {hits.map((hit) => ( + + ))} +
+ )} +
+
+
Dark
+ {dark.length === 0 ? ( +
No inferred dark events for {state.chartTicker}.
+ ) : ( +
+ {dark.map((event) => ( + + ))} +
+ )} +
+
+
+ ); +}; + +const ReplayConsole = () => { + const state = useTerminal(); + const replayActive = state.mode === "replay"; + + return ( + + {replayActive ? "Switch Live" : "Switch Replay"} + + } + > +
+
+ Mode + {replayActive ? "Replay" : "Live"} +
+
+ Source + {state.replaySource ? state.replaySource.toUpperCase() : "Auto"} +
+
+ Replay Clock + {state.options.replayTime ? formatTime(state.options.replayTime) : "—"} +
+
+ Packets + {formatFlowMetric(state.filteredFlow.length)} +
+
+
+ ); +}; + +export function TerminalAppShell({ children }: { children: ReactNode }) { + const state = useTerminalState(); + const pathname = usePathname(); + + return ( + +
+ + +
+
+ +
+ + + +
+
+ +
{children}
+
+ + {state.selectedAlert ? ( + state.setSelectedAlert(null)} + /> + ) : null} + + {state.selectedClassifierHit ? ( + state.setSelectedClassifierHit(null)} + /> + ) : null} + + {state.selectedDarkEvent ? ( + state.setSelectedDarkEvent(null)} + /> + ) : null} +
+
+ ); +} + +export function OverviewRoute() { + return ( + + +
+ + + + +
+
+ ); +} + +export function TapeRoute() { + return ( + +
+ + + +
+
+ ); +} + +export function SignalsRoute() { + return ( + +
+ + + +
+
+ ); +} + +export function ChartsRoute() { + return ( + +
+ + +
+
+ ); +} + +export function ReplayRoute() { + return ( + +
+ + + + +
+
+ ); +}