stabilize live api memory and add an options pipeline explainer #8

Open
dirtydishes wants to merge 6 commits from stabilize-live-api-memory into main
12 changed files with 2950 additions and 92 deletions

View file

@ -21,6 +21,7 @@
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-kgu","title":"Reconcile PR #8 branch with current main","description":"Why this issue exists and what needs to be done: user requested reconciliation for PR #8. Identify the PR #8 branch, merge/rebase with current main, resolve conflicts, validate, and push the updated branch so the PR can merge cleanly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T20:14:36Z","created_by":"dirtydishes","updated_at":"2026-05-23T20:24:29Z","started_at":"2026-05-23T20:14:39Z","closed_at":"2026-05-23T20:24:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-l9h","title":"stop persisting non-signal option prints in clickhouse","description":"Why: non-signal option prints are storage noise and should not be persisted by default.\\n\\nWhat: add OPTIONS_PERSIST_SIGNAL_ONLY env flag (default true), gate option_print inserts in ingest-options, add tests for persistence behavior, update env examples, and document one-off cleanup SQL for existing non-signal rows.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T03:02:32Z","created_by":"dirtydishes","updated_at":"2026-05-23T03:06:34Z","started_at":"2026-05-23T03:02:35Z","closed_at":"2026-05-23T03:06:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-l9h","title":"stop persisting non-signal option prints in clickhouse","description":"Why: non-signal option prints are storage noise and should not be persisted by default.\\n\\nWhat: add OPTIONS_PERSIST_SIGNAL_ONLY env flag (default true), gate option_print inserts in ingest-options, add tests for persistence behavior, update env examples, and document one-off cleanup SQL for existing non-signal rows.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T03:02:32Z","created_by":"dirtydishes","updated_at":"2026-05-23T03:06:34Z","started_at":"2026-05-23T03:02:35Z","closed_at":"2026-05-23T03:06:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-2cj","title":"Add Forgejo-first agent workflow guidance to AGENTS.md","description":"Why this issue exists and what needs to be done:\\n- The repositorys canonical home is Forgejo at git.deltaisland.io, but AGENTS.md does not currently direct agents to prefer Forgejo-specific workflows.\\n- Update AGENTS.md so agents treat Forgejo as primary and use the fj CLI for pull request workflows.\\n- Keep existing Beads and completion instructions intact while clarifying remote preference and command usage.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-23T02:51:31Z","created_by":"dirtydishes","updated_at":"2026-05-23T02:55:42Z","closed_at":"2026-05-23T02:55:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-2cj","title":"Add Forgejo-first agent workflow guidance to AGENTS.md","description":"Why this issue exists and what needs to be done:\\n- The repositorys canonical home is Forgejo at git.deltaisland.io, but AGENTS.md does not currently direct agents to prefer Forgejo-specific workflows.\\n- Update AGENTS.md so agents treat Forgejo as primary and use the fj CLI for pull request workflows.\\n- Keep existing Beads and completion instructions intact while clarifying remote preference and command usage.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-23T02:51:31Z","created_by":"dirtydishes","updated_at":"2026-05-23T02:55:42Z","closed_at":"2026-05-23T02:55:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-xc5","title":"One-time bidirectional git remote backfill between github and forgejo","description":"Perform a one-time sync so github and forgejo contain the same branch/tag refs and historical commits, including pre-transition github history and newer forgejo commits. Document exact commands and validation results.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-21T01:25:05Z","created_by":"dirtydishes","updated_at":"2026-05-21T01:26:19Z","started_at":"2026-05-21T01:25:16Z","closed_at":"2026-05-21T01:26:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xc5","title":"One-time bidirectional git remote backfill between github and forgejo","description":"Perform a one-time sync so github and forgejo contain the same branch/tag refs and historical commits, including pre-transition github history and newer forgejo commits. Document exact commands and validation results.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-21T01:25:05Z","created_by":"dirtydishes","updated_at":"2026-05-21T01:26:19Z","started_at":"2026-05-21T01:25:16Z","closed_at":"2026-05-21T01:26:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -106,7 +106,7 @@ REPLAY_LOG_EVERY=1000
# API live retention (generic channels) # API live retention (generic channels)
LIVE_LIMIT_DEFAULT=1000 LIVE_LIMIT_DEFAULT=1000
LIVE_LIMIT_OPTIONS=1000 LIVE_LIMIT_OPTIONS=100
LIVE_LIMIT_NBBO=1000 LIVE_LIMIT_NBBO=1000
LIVE_LIMIT_EQUITIES=1000 LIVE_LIMIT_EQUITIES=1000
LIVE_LIMIT_EQUITY_QUOTES=500 LIVE_LIMIT_EQUITY_QUOTES=500
@ -116,6 +116,7 @@ LIVE_LIMIT_SMART_MONEY=300
LIVE_LIMIT_CLASSIFIER_HITS=300 LIVE_LIMIT_CLASSIFIER_HITS=300
LIVE_LIMIT_ALERTS=300 LIVE_LIMIT_ALERTS=300
LIVE_LIMIT_INFERRED_DARK=300 LIVE_LIMIT_INFERRED_DARK=300
LIVE_LIMIT_NEWS=100
LIVE_SCOPED_CACHE_MAX_KEYS=32 LIVE_SCOPED_CACHE_MAX_KEYS=32
LIVE_REDIS_FLUSH_INTERVAL_MS=250 LIVE_REDIS_FLUSH_INTERVAL_MS=250
LIVE_REDIS_FLUSH_MAX_ITEMS=100 LIVE_REDIS_FLUSH_MAX_ITEMS=100

View file

@ -72,12 +72,12 @@ const parseBoundedInt = (
return Math.max(min, Math.min(max, Math.floor(parsed))); return Math.max(min, Math.min(max, Math.floor(parsed)));
}; };
const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 600, 1, 100000); const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 600, 1, 2000);
const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt( const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS, process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
1200, 1200,
1, 1,
100000 2000
); );
const LIVE_OPTIONS_HEAD_LIMIT = 100; const LIVE_OPTIONS_HEAD_LIMIT = 100;
const LIVE_HISTORY_SOFT_CAP = parseBoundedInt( const LIVE_HISTORY_SOFT_CAP = parseBoundedInt(

View file

@ -132,7 +132,7 @@ REPLAY_LOG_EVERY=1000
# API live retention # API live retention
LIVE_LIMIT_DEFAULT=1000 LIVE_LIMIT_DEFAULT=1000
LIVE_LIMIT_OPTIONS=1000 LIVE_LIMIT_OPTIONS=100
LIVE_LIMIT_NBBO=1000 LIVE_LIMIT_NBBO=1000
LIVE_LIMIT_EQUITIES=1000 LIVE_LIMIT_EQUITIES=1000
LIVE_LIMIT_EQUITY_QUOTES=500 LIVE_LIMIT_EQUITY_QUOTES=500
@ -142,6 +142,7 @@ LIVE_LIMIT_SMART_MONEY=300
LIVE_LIMIT_CLASSIFIER_HITS=300 LIVE_LIMIT_CLASSIFIER_HITS=300
LIVE_LIMIT_ALERTS=300 LIVE_LIMIT_ALERTS=300
LIVE_LIMIT_INFERRED_DARK=300 LIVE_LIMIT_INFERRED_DARK=300
LIVE_LIMIT_NEWS=100
LIVE_SCOPED_CACHE_MAX_KEYS=32 LIVE_SCOPED_CACHE_MAX_KEYS=32
LIVE_REDIS_FLUSH_INTERVAL_MS=250 LIVE_REDIS_FLUSH_INTERVAL_MS=250
LIVE_REDIS_FLUSH_MAX_ITEMS=100 LIVE_REDIS_FLUSH_MAX_ITEMS=100

954
docs/anatomy.html Normal file
View file

@ -0,0 +1,954 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Anatomy of an Options Print and Smart Money</title>
<style>
:root {
color-scheme: dark;
--bg-core: oklch(0.14 0.015 240);
--bg-shell: oklch(0.17 0.018 240);
--bg-pane: oklch(0.2 0.02 240);
--bg-pane-2: oklch(0.23 0.02 240);
--bg-soft: oklch(0.28 0.022 240 / 0.48);
--line: oklch(0.48 0.03 240 / 0.34);
--line-strong: oklch(0.67 0.06 75 / 0.48);
--text: oklch(0.93 0.01 240);
--text-dim: oklch(0.74 0.02 240);
--text-faint: oklch(0.62 0.016 240);
--amber: oklch(0.79 0.16 76);
--amber-soft: oklch(0.33 0.07 76 / 0.32);
--green: oklch(0.74 0.15 154);
--green-soft: oklch(0.31 0.06 154 / 0.3);
--blue: oklch(0.72 0.12 244);
--blue-soft: oklch(0.31 0.05 244 / 0.28);
--red: oklch(0.69 0.17 28);
--red-soft: oklch(0.31 0.06 28 / 0.32);
--shadow: 0 30px 80px rgba(0, 0, 0, 0.38);
--radius-xl: 20px;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--mono: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
--display: "Quantico", "IBM Plex Sans", sans-serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(circle at 0% 0%, rgba(245, 166, 35, 0.09), transparent 24%),
radial-gradient(circle at 100% 0%, rgba(77, 163, 255, 0.12), transparent 26%),
linear-gradient(180deg, var(--bg-shell) 0%, var(--bg-core) 100%);
}
a {
color: var(--amber);
}
code,
pre,
.eyebrow,
.chip,
.mini-label,
th,
.lane-label {
font-family: var(--mono);
}
main {
width: min(1440px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 64px;
}
.hero {
display: grid;
gap: 22px;
padding: 28px;
border: 1px solid var(--line);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%),
linear-gradient(135deg, rgba(17, 24, 32, 0.96) 0%, rgba(8, 11, 16, 0.98) 100%);
box-shadow: var(--shadow);
}
.hero-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
color: var(--text-dim);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
line-height: 1.08;
}
h1 {
font-family: var(--display);
font-size: clamp(2.35rem, 5vw, 4.8rem);
letter-spacing: 0.04em;
text-transform: uppercase;
}
h2 {
font-family: var(--display);
font-size: 1.5rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h3 {
font-size: 1.04rem;
font-weight: 650;
}
.hero-copy {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.85fr);
gap: 26px;
align-items: start;
}
.lede {
margin: 0;
max-width: 68ch;
color: var(--text-dim);
font-size: 1rem;
line-height: 1.62;
}
.hero-note {
padding: 18px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.hero-note p {
margin: 0;
color: var(--text-dim);
line-height: 1.55;
}
.hero-note p + p {
margin-top: 12px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--line);
text-decoration: none;
color: var(--text);
background: rgba(255, 255, 255, 0.03);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chip:hover {
border-color: var(--line-strong);
}
.chip .dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex: 0 0 auto;
}
.chip.raw .dot {
background: var(--blue);
}
.chip.derived .dot {
background: var(--amber);
}
.chip.store .dot {
background: var(--green);
}
.chip.live .dot {
background: var(--red);
}
.stack {
display: grid;
gap: 22px;
margin-top: 22px;
}
.section {
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.016) 100%),
var(--bg-pane);
box-shadow: var(--shadow);
}
.section-head {
display: grid;
gap: 8px;
margin-bottom: 18px;
}
.section-head p,
.copy p,
.copy li,
.table-wrap td,
.table-wrap th {
color: var(--text-dim);
line-height: 1.58;
}
.section-head p,
.copy p {
margin: 0;
max-width: 74ch;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.summary-card {
padding: 18px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%);
}
.summary-card p {
margin: 10px 0 0;
}
.mini-label {
display: inline-block;
margin-bottom: 8px;
color: var(--text-faint);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.flow-scroll {
overflow-x: auto;
padding-bottom: 6px;
}
.flow-board {
min-width: 1320px;
display: grid;
gap: 18px;
}
.lanes {
display: grid;
grid-template-columns: 160px repeat(6, minmax(180px, 1fr));
gap: 14px;
align-items: stretch;
}
.lane-label {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
color: var(--text-faint);
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-align: center;
}
.node {
position: relative;
min-height: 138px;
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: var(--bg-pane-2);
display: grid;
gap: 10px;
}
.node strong {
font-size: 1.02rem;
line-height: 1.25;
}
.node p {
margin: 0;
color: var(--text-dim);
font-size: 0.92rem;
line-height: 1.48;
}
.node ul {
margin: 0;
padding-left: 18px;
color: var(--text-dim);
}
.node li + li {
margin-top: 6px;
}
.node.raw {
box-shadow: inset 0 0 0 1px rgba(77, 163, 255, 0.18);
}
.node.derived {
box-shadow: inset 0 0 0 1px rgba(245, 166, 35, 0.24);
}
.node.store {
box-shadow: inset 0 0 0 1px rgba(37, 193, 122, 0.24);
}
.node.live {
box-shadow: inset 0 0 0 1px rgba(255, 107, 95, 0.24);
}
.node-arrow::after {
content: "";
position: absolute;
top: 50%;
right: -12px;
width: 24px;
height: 2px;
background: linear-gradient(90deg, var(--line-strong) 0%, var(--amber) 100%);
}
.node-arrow::before {
content: "";
position: absolute;
top: calc(50% - 5px);
right: -12px;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 8px solid var(--amber);
}
.branch-grid {
display: grid;
grid-template-columns: 160px repeat(6, minmax(180px, 1fr));
gap: 14px;
align-items: stretch;
}
.branch-empty {
border-radius: var(--radius-lg);
border: 1px dashed transparent;
min-height: 112px;
}
.branch-box {
min-height: 112px;
}
.branch-box p {
margin: 0;
}
.copy {
display: grid;
gap: 16px;
}
.copy ul {
margin: 0;
padding-left: 18px;
}
.copy li + li {
margin-top: 8px;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
}
.callout {
padding: 18px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
}
.callout p {
margin: 0;
}
.callout p + p {
margin-top: 10px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
color: var(--text);
background: rgba(255, 255, 255, 0.035);
font-size: 0.76rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: 0;
}
pre {
margin: 0;
padding: 18px;
border-radius: var(--radius-lg);
background: oklch(0.13 0.012 240);
border: 1px solid var(--line);
color: var(--text);
overflow-x: auto;
line-height: 1.5;
font-size: 0.88rem;
}
.footer-note {
color: var(--text-faint);
font-size: 0.88rem;
}
@media (max-width: 1080px) {
.hero-copy,
.detail-grid,
.summary-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
main {
width: min(100vw, calc(100vw - 20px));
padding-top: 12px;
}
.hero,
.section {
padding: 18px;
border-radius: 18px;
}
h1 {
font-size: clamp(1.9rem, 8vw, 2.8rem);
}
}
</style>
</head>
<body>
<main>
<section class="hero">
<div class="hero-top">
<span class="eyebrow">Islandflow Reference · Options Flow Pipeline</span>
<nav class="toolbar" aria-label="Page navigation">
<a class="chip raw" href="#flow-chart"><span class="dot"></span>Flow Chart</a>
<a class="chip derived" href="#executive"><span class="dot"></span>Executive</a>
<a class="chip store" href="#technical"><span class="dot"></span>Technical</a>
<a class="chip live" href="#operator"><span class="dot"></span>Operator Detail</a>
</nav>
</div>
<div class="hero-copy">
<div>
<h1>The Anatomy of an Options Print and Smart Money</h1>
<p class="lede">
This page explains how a single options print moves through Islandflow under normal market conditions,
how the signal gate decides whether compute should care, how a parent flow packet is assembled, and how
smart-money, classifier-hit, and alert events emerge from that packet. It is designed as one artifact
with three reading depths: executive, mixed technical, and operator-level.
</p>
</div>
<aside class="hero-note">
<p>
The key distinction is structural: the options tape is print-level, flow is packet-level, and smart
money is model output on those packets.
</p>
<p>
Synthetic mode changes the source of prints and NBBO context, not the downstream architecture.
</p>
</aside>
</div>
</section>
<div class="stack">
<section class="section">
<div class="section-head">
<h2>Legend</h2>
<p>Color coding is semantic, not decorative, so you can scan the diagram without relearning the vocabulary.</p>
</div>
<div class="toolbar" aria-label="Legend">
<span class="chip raw"><span class="dot"></span>Raw market or synthetic input</span>
<span class="chip derived"><span class="dot"></span>Derived compute stage</span>
<span class="chip store"><span class="dot"></span>Stored or persisted state</span>
<span class="chip live"><span class="dot"></span>API, websocket, or user-facing surface</span>
</div>
</section>
<section class="section" id="flow-chart">
<div class="section-head">
<h2>Main Flow Chart</h2>
<p>
The first row shows the common path every print touches. The second row shows the branch between prints
that remain tape-only and prints that become packet candidates for smart-money evaluation.
</p>
</div>
<div class="flow-scroll">
<div class="flow-board">
<div class="lanes">
<div class="lane-label">Input</div>
<article class="node raw node-arrow">
<span class="mini-label">Stage 1</span>
<strong>Option print candidate arrives</strong>
<p>
The source can be a native market adapter or the synthetic adapter. Synthetic mode can also emit a
matching NBBO update.
</p>
</article>
<article class="node raw node-arrow">
<span class="mini-label">Stage 2</span>
<strong>ingest-options enriches the print</strong>
<p>
The service joins recent option NBBO and underlying equity quote context, derives metadata, and
computes <code>signal_pass</code>.
</p>
</article>
<article class="node store node-arrow">
<span class="mini-label">Stage 3</span>
<strong>Raw print is written and published</strong>
<ul>
<li>ClickHouse: <code>option_prints</code></li>
<li>NATS: <code>options.prints</code></li>
</ul>
</article>
<article class="node derived node-arrow">
<span class="mini-label">Stage 4</span>
<strong>Signal gate decides if compute should care</strong>
<p>
Only <code>signal_pass=true</code> prints are published to <code>options.prints.signal</code> and
consumed by compute.
</p>
</article>
<article class="node derived node-arrow">
<span class="mini-label">Stage 5</span>
<strong>compute builds or updates a parent cluster</strong>
<p>
Nearby signal prints for the same contract are grouped inside the cluster window while NBBO and
equity-quote caches supply context.
</p>
</article>
<article class="node live">
<span class="mini-label">Stage 6</span>
<strong>API and UI consume the resulting streams</strong>
<p>
The API hydrates hot snapshots, history endpoints read ClickHouse, and the terminal surfaces tape,
flow, smart-money, classifier, and alert views.
</p>
</article>
</div>
<div class="branch-grid">
<div class="lane-label">Tape-only branch</div>
<div class="branch-empty"></div>
<div class="branch-empty"></div>
<article class="node store branch-box">
<span class="mini-label">Branch A</span>
<strong>Raw print remains visible</strong>
<p>
Even if the print does not pass the signal gate, it still exists in ClickHouse and can appear in
raw tape or history views.
</p>
</article>
<article class="node derived branch-box">
<span class="mini-label">Branch A outcome</span>
<strong>No compute packet path</strong>
<p>
No <code>FlowPacket</code>, no smart-money evaluation, no classifier hits, and no alert emission.
</p>
</article>
<div class="branch-empty"></div>
<div class="branch-empty"></div>
</div>
<div class="branch-grid">
<div class="lane-label">Smart-money branch</div>
<div class="branch-empty"></div>
<div class="branch-empty"></div>
<div class="branch-empty"></div>
<article class="node derived branch-box">
<span class="mini-label">Branch B</span>
<strong>Signal print enters compute</strong>
<p>
compute subscribes to <code>options.prints.signal</code>, not raw <code>options.prints</code>.
</p>
</article>
<article class="node derived branch-box">
<span class="mini-label">Branch B outcome</span>
<strong>FlowPacket is emitted</strong>
<ul>
<li>ClickHouse: <code>flow_packets</code></li>
<li>NATS: <code>flow.packets</code></li>
</ul>
</article>
<article class="node live branch-box">
<span class="mini-label">Branch B continuation</span>
<strong>Smart-money, classifier hits, alerts</strong>
<p>
The packet is scored into a <code>SmartMoneyEvent</code>, which may abstain, produce classifier
hits, and finally emit an alert.
</p>
</article>
</div>
</div>
</div>
</section>
<section class="section" id="executive">
<div class="section-head">
<h2>Executive Read</h2>
<p>
The shortest truthful version of the system: not every options print is considered meaningful, and smart
money is not detected directly from a single print.
</p>
</div>
<div class="summary-grid">
<article class="summary-card">
<span class="mini-label">1. Tape</span>
<h3>Every print is stored</h3>
<p>
All enriched prints are written to ClickHouse and published to the raw options subject. This preserves
evidence even when the print is uninteresting for higher-order inference.
</p>
</article>
<article class="summary-card">
<span class="mini-label">2. Compute</span>
<h3>Only signal prints reach the parent-event engine</h3>
<p>
A print must pass the signal gate before compute clusters it with neighboring prints and builds a
packet that represents a possible parent order.
</p>
</article>
<article class="summary-card">
<span class="mini-label">3. Smart money</span>
<h3>Smart money is a scored interpretation</h3>
<p>
The model evaluates the packet using quote quality, aggressor mix, size, structure, DTE, IV, and event
context. It can still abstain if the evidence is weak or suppressed.
</p>
</article>
</div>
</section>
<section class="section" id="technical">
<div class="section-head">
<h2>Mixed Technical Walkthrough</h2>
<p>
This layer is for teammates who know the product and want the exact branching logic without reading
through service code first.
</p>
</div>
<div class="detail-grid">
<div class="copy">
<p>
<strong>Step 1:</strong> a candidate print enters <code>ingest-options</code>. In synthetic mode this
print was manufactured by the synthetic adapter, which may also emit a synthetic NBBO update for the
same contract.
</p>
<p>
<strong>Step 2:</strong> the print is enriched with the most recent option NBBO and underlying equity
quote at or before the print timestamp. The service derives metadata, execution-side context, and the
<code>signal_pass</code> decision.
</p>
<p>
<strong>Step 3:</strong> the enriched print is persisted to ClickHouse and published to
<code>options.prints</code>. If <code>signal_pass=true</code>, the same print is also published to
<code>options.prints.signal</code>.
</p>
<p>
<strong>Step 4:</strong> compute subscribes to the signal subject plus NBBO and equity-quote subjects.
It does not build packet candidates from every raw print. It only clusters signal prints.
</p>
<p>
<strong>Step 5:</strong> compute aggregates nearby signal prints for the same option contract into a
cluster, then flushes that cluster into a <code>FlowPacket</code> with features such as total premium,
print count, aggressor ratios, NBBO coverage, stale-quote counts, IV context, and structure clues.
</p>
<p>
<strong>Step 6:</strong> the packet is transformed into a <code>SmartMoneyEvent</code>. If suppression
rules trip or the top profile probability is too weak, the event abstains. Otherwise, it can emit
classifier hits and finally an alert with evidence references back to the packet and member prints.
</p>
</div>
<aside class="callout">
<span class="mini-label">Important distinction</span>
<p>
A <code>FlowPacket</code> is already a derived parent-event candidate. It is not just another name for
the options tape.
</p>
<p>
A <code>SmartMoneyEvent</code> is model output on that packet, not a raw tape fact. The system treats
it as evidence-backed interpretation with explicit abstention and suppression paths.
</p>
</aside>
</div>
</section>
<section class="section" id="operator">
<div class="section-head">
<h2>Operator and Code-Level Detail</h2>
<p>
This section is for someone tracing the live pipeline, debugging a regression, or trying to understand
exactly why a given print surfaced on tape but did or did not become a smart-money event.
</p>
</div>
<div class="copy">
<p>
The first fork is the signal gate in <code>ingest-options</code>. The enriched print is always stored and
published raw. The only thing <code>signal_pass</code> controls is whether compute receives that print on
<code>options.prints.signal</code>.
</p>
<p>
The compute service maintains separate caches for option NBBO and underlying equity quotes. When signal
prints arrive, it flushes aged clusters, extends the active cluster for that contract if the print lands
within the configured window, or emits the old cluster and starts a new one.
</p>
<p>
The cluster becomes a <code>FlowPacket</code> only after compute summarizes parent-level features. That
packet then passes through smart-money scoring. The scoring layer derives a profile set such as
institutional directional, retail whale, event driven, vol seller, arbitrage, or hedge reactive.
</p>
<p>
A packet can still fail to produce actionable downstream artifacts. Suppression rules down-rank special
print context, stale or missing quote context, and cross-like execution patterns. The top profile must
also clear the probability threshold. If it does not, the smart-money event is emitted in abstained form
and classifier hits stop there.
</p>
<p>
If the packet does clear those checks, compute writes and publishes the smart-money event, derives up to
a few classifier hits from the top profile set, scores a final alert, and publishes all three derived
streams. The API subscribes to those subjects and fans them out into live websocket channels while
ClickHouse remains the history source behind <code>/history/*</code>.
</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Subject or table</th>
<th>Produced by</th>
<th>Carries</th>
<th>Why it exists</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>options.prints</code></td>
<td><code>ingest-options</code></td>
<td>All enriched option prints</td>
<td>Preserves the full tape, even when a print is not interesting enough for compute.</td>
</tr>
<tr>
<td><code>options.prints.signal</code></td>
<td><code>ingest-options</code></td>
<td>Signal-passing option prints</td>
<td>Acts as the compute admission gate so packet building starts from a filtered tape.</td>
</tr>
<tr>
<td><code>flow.packets</code></td>
<td><code>compute</code></td>
<td>Parent-event candidates</td>
<td>Turns several child prints into one summarized event with market-structure features.</td>
</tr>
<tr>
<td><code>flow.smart_money</code></td>
<td><code>compute</code></td>
<td>Smart-money evaluations</td>
<td>Publishes the scored interpretation of a packet, including abstained outcomes.</td>
</tr>
<tr>
<td><code>flow.classifier_hits</code></td>
<td><code>compute</code></td>
<td>Top classifier consequences</td>
<td>Exposes the strongest profile-level labels that downstream UX and alerting can decorate.</td>
</tr>
<tr>
<td><code>flow.alerts</code></td>
<td><code>compute</code></td>
<td>Alert events with evidence refs</td>
<td>Packages the final severity and supporting evidence into a user-facing alert stream.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Normal Path Versus Smart-Money Path</h2>
<p>
These two sequences are easy to confuse, especially because both begin with the same enriched tape
record.
</p>
</div>
<div class="detail-grid">
<div class="callout">
<span class="mini-label">Normal market path</span>
<p>
Print arrives, gets enriched, gets stored, appears on the raw tape, and stops there unless it passes
the signal gate. This is the dominant path for ordinary or low-signal activity.
</p>
</div>
<div class="callout">
<span class="mini-label">Smart-money path</span>
<p>
Print arrives, passes the signal gate, joins a cluster, becomes a packet, receives a smart-money score,
then may emit classifier hits and an alert if the packet is not suppressed or abstained.
</p>
</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Annotated Event Sequence</h2>
<p>
The example below is the shortest operator-friendly way to think about the branch that leads to a
smart-money result.
</p>
</div>
<pre><code>1. Synthetic or market adapter emits OptionPrint candidate
2. ingest-options enriches it with latest NBBO and underlying quote context
3. Enriched print is written to ClickHouse option_prints
4. Enriched print is published to options.prints
5. If signal_pass=true, the same print is also published to options.prints.signal
6. compute consumes options.prints.signal and updates the active contract cluster
7. Cluster flush builds a FlowPacket with parent-level features
8. FlowPacket is written to ClickHouse flow_packets and published to flow.packets
9. compute scores the packet into a SmartMoneyEvent
10. If suppressed or low-confidence, the SmartMoneyEvent abstains and stops there
11. Otherwise classifier hits are emitted
12. Alert scoring emits a final alert with evidence refs to smart-money event, flow packet, and member prints
13. API subscribes to these streams and exposes them through live websocket channels and ClickHouse-backed history</code></pre>
</section>
<section class="section">
<div class="section-head">
<h2>What Synthetic Mode Changes</h2>
<p>
Synthetic mode can make the upstream generator artificial, but the downstream branch logic stays
identical.
</p>
</div>
<div class="copy">
<p>
The synthetic adapter constructs an <code>OptionPrint</code> with fields such as
<code>execution_iv_source="synthetic_pressure_model"</code>, and it may emit a synthetic NBBO for the
same contract. From that point forward, the pipeline is the same one used for normal ingest.
</p>
<p>
That means synthetic smart-money is not a special smart-money subsystem. It is the standard
signal-to-packet-to-smart-money pipeline running on synthetic upstream events.
</p>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Code Anchors</h2>
<p>
If you want to confirm this page against the code, these are the most useful entry points.
</p>
</div>
<div class="copy">
<ul>
<li><code>services/ingest-options/src/enrichment.ts</code>: enriches the print and decides <code>signal_pass</code>.</li>
<li><code>services/ingest-options/src/index.ts</code>: writes prints and publishes raw versus signal subjects.</li>
<li><code>services/compute/src/index.ts</code>: subscribes to signal prints, maintains clusters, emits packets, smart money, hits, and alerts.</li>
<li><code>services/compute/src/parent-events.ts</code>: builds <code>SmartMoneyEvent</code>, suppression rules, primary profile, abstention, and classifier derivation.</li>
<li><code>packages/bus/src/subjects.ts</code>: canonical subject names for the pipeline.</li>
</ul>
</div>
<p class="footer-note">
This document is intended as a living product reference, not a turn artifact. If the packet features,
thresholds, or stream names change, update this page alongside the relevant pipeline code.
</p>
</section>
</div>
</main>
</body>
</html>

View file

@ -207,36 +207,76 @@
</header> </header>
<section class="toolbar"> <section class="toolbar">
<div class="stats"><strong id="visible-count">35</strong> of <strong>35</strong> files shown</div> <div class="stats"><strong id="visible-count">47</strong> of <strong>47</strong> files shown</div>
<input id="doc-search" class="search" type="search" placeholder="Filter by filename or folder..." autocomplete="off" /> <input id="doc-search" class="search" type="search" placeholder="Filter by filename or folder..." autocomplete="off" />
<nav class="chips"><a class="chip" href="#category-turns">turns <span>28</span></a> <nav class="chips"><a class="chip" href="#category-turns">turns <span>37</span></a>
<a class="chip" href="#category-daily-git">daily-git <span>1</span></a> <a class="chip" href="#category-daily-git">daily-git <span>1</span></a>
<a class="chip" href="#category-general">general <span>2</span></a> <a class="chip" href="#category-general">general <span>4</span></a>
<a class="chip" href="#category-plans">plans <span>2</span></a> <a class="chip" href="#category-plans">plans <span>2</span></a>
<a class="chip" href="#category-root">root <span>2</span></a></nav> <a class="chip" href="#category-root">root <span>3</span></a></nav>
</section> </section>
<section class="groups" id="groups"> <section class="groups" id="groups">
<section class="group" id="category-turns"> <section class="group" id="category-turns">
<h2>turns <span>28</span></h2> <h2>turns <span>37</span></h2>
<ul class="doc-list"> <ul class="doc-list">
<li class="doc-item" data-search="turns/2026-05-19-publish-docs-pages-index.html turns"> <li class="doc-item" data-search="turns/2026-05-22-stabilize-live-api-memory.html turns">
<a class="doc-link" href="./turns/2026-05-19-publish-docs-pages-index.html">turns/2026-05-19-publish-docs-pages-index.html</a> <a class="doc-link" href="./turns/2026-05-22-stabilize-live-api-memory.html">turns/2026-05-22-stabilize-live-api-memory.html</a>
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>6.7 KB</span> <span>26 KB</span>
<span>May 19, 2026, 2:59 PM</span> <span>May 22, 2026, 9:47 PM</span>
</div> </div>
</li> </li>
<li class="doc-item" data-search="turns/2026-05-18-native-public-edge-cutover.html turns"> <li class="doc-item" data-search="turns/2026-05-22-publish-standup-summary-2026-05-21.html turns">
<a class="doc-link" href="./turns/2026-05-18-native-public-edge-cutover.html">turns/2026-05-18-native-public-edge-cutover.html</a> <a class="doc-link" href="./turns/2026-05-22-publish-standup-summary-2026-05-21.html">turns/2026-05-22-publish-standup-summary-2026-05-21.html</a>
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>19 KB</span> <span>5.5 KB</span>
<span>May 19, 2026, 2:48 PM</span> <span>May 22, 2026, 9:04 AM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-21-publish-standup-summary-2026-05-20.html turns">
<a class="doc-link" href="./turns/2026-05-21-publish-standup-summary-2026-05-20.html">turns/2026-05-21-publish-standup-summary-2026-05-20.html</a>
<div class="meta">
<span class="tag">html</span>
<span>5.0 KB</span>
<span>May 21, 2026, 9:05 AM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-20-refresh-readme-github-description.html turns">
<a class="doc-link" href="./turns/2026-05-20-refresh-readme-github-description.html">turns/2026-05-20-refresh-readme-github-description.html</a>
<div class="meta">
<span class="tag">html</span>
<span>7.7 KB</span>
<span>May 20, 2026, 9:54 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-20-remote-backfill-sync.html turns">
<a class="doc-link" href="./turns/2026-05-20-remote-backfill-sync.html">turns/2026-05-20-remote-backfill-sync.html</a>
<div class="meta">
<span class="tag">html</span>
<span>4.3 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-20-fix-alert-flow-packet-history.html turns">
<a class="doc-link" href="./turns/2026-05-20-fix-alert-flow-packet-history.html">turns/2026-05-20-fix-alert-flow-packet-history.html</a>
<div class="meta">
<span class="tag">html</span>
<span>14 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -246,37 +286,7 @@
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>9.8 KB</span> <span>9.8 KB</span>
<span>May 19, 2026, 2:48 PM</span> <span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-18-native-fast-iterative-deploy.html turns">
<a class="doc-link" href="./turns/2026-05-18-native-fast-iterative-deploy.html">turns/2026-05-18-native-fast-iterative-deploy.html</a>
<div class="meta">
<span class="tag">html</span>
<span>9.0 KB</span>
<span>May 19, 2026, 2:48 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html turns">
<a class="doc-link" href="./turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html">turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html</a>
<div class="meta">
<span class="tag">html</span>
<span>6.4 KB</span>
<span>May 19, 2026, 8:05 AM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-0739-update-readme-current-state.html turns">
<a class="doc-link" href="./turns/2026-05-19-0739-update-readme-current-state.html">turns/2026-05-19-0739-update-readme-current-state.html</a>
<div class="meta">
<span class="tag">html</span>
<span>9.8 KB</span>
<span>May 19, 2026, 7:39 AM</span>
</div> </div>
</li> </li>
@ -286,7 +296,87 @@
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>9.0 KB</span> <span>9.0 KB</span>
<span>May 19, 2026, 7:31 AM</span> <span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-harden-native-ssh-deploy-checks.html turns">
<a class="doc-link" href="./turns/2026-05-19-harden-native-ssh-deploy-checks.html">turns/2026-05-19-harden-native-ssh-deploy-checks.html</a>
<div class="meta">
<span class="tag">html</span>
<span>7.0 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-native-options-recovery-guardrails.html turns">
<a class="doc-link" href="./turns/2026-05-19-native-options-recovery-guardrails.html">turns/2026-05-19-native-options-recovery-guardrails.html</a>
<div class="meta">
<span class="tag">html</span>
<span>7.7 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-publish-docs-pages-index.html turns">
<a class="doc-link" href="./turns/2026-05-19-publish-docs-pages-index.html">turns/2026-05-19-publish-docs-pages-index.html</a>
<div class="meta">
<span class="tag">html</span>
<span>6.7 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-0739-update-readme-current-state.html turns">
<a class="doc-link" href="./turns/2026-05-19-0739-update-readme-current-state.html">turns/2026-05-19-0739-update-readme-current-state.html</a>
<div class="meta">
<span class="tag">html</span>
<span>9.8 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html turns">
<a class="doc-link" href="./turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html">turns/2026-05-19-0805-clarify-repo-turn-doc-rules.html</a>
<div class="meta">
<span class="tag">html</span>
<span>6.4 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-19-fix-native-alpaca-news.html turns">
<a class="doc-link" href="./turns/2026-05-19-fix-native-alpaca-news.html">turns/2026-05-19-fix-native-alpaca-news.html</a>
<div class="meta">
<span class="tag">html</span>
<span>12 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-18-native-fast-iterative-deploy.html turns">
<a class="doc-link" href="./turns/2026-05-18-native-fast-iterative-deploy.html">turns/2026-05-18-native-fast-iterative-deploy.html</a>
<div class="meta">
<span class="tag">html</span>
<span>9.0 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div>
</li>
<li class="doc-item" data-search="turns/2026-05-18-native-public-edge-cutover.html turns">
<a class="doc-link" href="./turns/2026-05-18-native-public-edge-cutover.html">turns/2026-05-18-native-public-edge-cutover.html</a>
<div class="meta">
<span class="tag">html</span>
<span>19 KB</span>
<span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -296,7 +386,7 @@
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>7.0 KB</span> <span>7.0 KB</span>
<span>May 18, 2026, 4:54 PM</span> <span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -513,7 +603,7 @@
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>16 KB</span> <span>16 KB</span>
<span>May 19, 2026, 2:55 PM</span> <span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -522,15 +612,35 @@
<section class="group" id="category-general"> <section class="group" id="category-general">
<h2>general <span>2</span></h2> <h2>general <span>4</span></h2>
<ul class="doc-list"> <ul class="doc-list">
<li class="doc-item" data-search="general/2026-05-22-standup-summary-2026-05-21.html general">
<a class="doc-link" href="./general/2026-05-22-standup-summary-2026-05-21.html">general/2026-05-22-standup-summary-2026-05-21.html</a>
<div class="meta">
<span class="tag">html</span>
<span>11 KB</span>
<span>May 22, 2026, 9:04 AM</span>
</div>
</li>
<li class="doc-item" data-search="general/2026-05-21-standup-summary-2026-05-20.html general">
<a class="doc-link" href="./general/2026-05-21-standup-summary-2026-05-20.html">general/2026-05-21-standup-summary-2026-05-20.html</a>
<div class="meta">
<span class="tag">html</span>
<span>16 KB</span>
<span>May 21, 2026, 9:05 AM</span>
</div>
</li>
<li class="doc-item" data-search="general/2026-05-18-standup-summary-2026-05-17.html general"> <li class="doc-item" data-search="general/2026-05-18-standup-summary-2026-05-17.html general">
<a class="doc-link" href="./general/2026-05-18-standup-summary-2026-05-17.html">general/2026-05-18-standup-summary-2026-05-17.html</a> <a class="doc-link" href="./general/2026-05-18-standup-summary-2026-05-17.html">general/2026-05-18-standup-summary-2026-05-17.html</a>
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>19 KB</span> <span>19 KB</span>
<span>May 18, 2026, 9:05 AM</span> <span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -557,7 +667,7 @@
<div class="meta"> <div class="meta">
<span class="tag">html</span> <span class="tag">html</span>
<span>3.8 KB</span> <span>3.8 KB</span>
<span>May 19, 2026, 2:48 PM</span> <span>May 20, 2026, 9:26 PM</span>
</div> </div>
</li> </li>
@ -576,9 +686,19 @@
<section class="group" id="category-root"> <section class="group" id="category-root">
<h2>root <span>2</span></h2> <h2>root <span>3</span></h2>
<ul class="doc-list"> <ul class="doc-list">
<li class="doc-item" data-search="anatomy.html root">
<a class="doc-link" href="./anatomy.html">anatomy.html</a>
<div class="meta">
<span class="tag">html</span>
<span>33 KB</span>
<span>May 22, 2026, 10:22 PM</span>
</div>
</li>
<li class="doc-item" data-search="clickhouse-reset-runbook.md root"> <li class="doc-item" data-search="clickhouse-reset-runbook.md root">
<a class="doc-link" href="./clickhouse-reset-runbook.md">clickhouse-reset-runbook.md</a> <a class="doc-link" href="./clickhouse-reset-runbook.md">clickhouse-reset-runbook.md</a>
<div class="meta"> <div class="meta">

View file

@ -0,0 +1,584 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Turn Record: Add Options Anatomy Explainer</title>
<style>
:root {
color-scheme: dark;
--bg: #070a0d;
--surface: #10161d;
--surface-2: #131b23;
--surface-3: #19232d;
--ink: #e7edf4;
--muted: #96a5b7;
--faint: #6f7f92;
--line: rgba(255, 255, 255, 0.1);
--accent: #f5a623;
--accent-soft: rgba(245, 166, 35, 0.14);
--good: #25c17a;
--good-soft: rgba(37, 193, 122, 0.14);
--shadow: 0 28px 70px rgba(0, 0, 0, 0.38);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(245, 166, 35, 0.08), transparent 26%),
linear-gradient(180deg, #0a0d11 0%, var(--bg) 100%);
color: var(--ink);
font: 16px/1.62 "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
}
main {
width: min(1120px, calc(100vw - 40px));
margin: 28px auto 60px;
}
.hero,
.card {
border: 1px solid var(--line);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.015) 100%),
var(--surface);
box-shadow: var(--shadow);
}
.hero {
padding: 30px;
}
.eyebrow,
h1,
h2,
h3,
code,
pre,
.meta-label {
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
line-height: 1.08;
}
h1 {
margin-top: 16px;
font-size: clamp(2.3rem, 4.8vw, 4.2rem);
letter-spacing: 0.04em;
text-transform: uppercase;
}
h2 {
font-size: 1.28rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h3 {
font-size: 1rem;
}
.lede {
max-width: 72ch;
margin-top: 16px;
color: var(--muted);
}
.meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-top: 24px;
}
.meta-card {
padding: 16px 18px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--surface-2);
}
.meta-label {
display: block;
color: var(--faint);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.meta-value {
display: block;
margin-top: 8px;
font-weight: 650;
}
.grid {
display: grid;
gap: 18px;
margin-top: 20px;
}
.card {
padding: 24px;
}
p {
margin: 0;
}
p + p,
p + ul,
ul + p,
ul + ul {
margin-top: 12px;
}
ul {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
.callout {
margin-top: 16px;
padding: 16px 18px;
border-radius: 16px;
border: 1px solid rgba(37, 193, 122, 0.2);
background: var(--good-soft);
}
.callout strong {
color: var(--good);
}
code {
padding: 0.08rem 0.35rem;
border-radius: 0.42rem;
background: rgba(255, 255, 255, 0.06);
}
pre {
margin: 12px 0 0;
padding: 16px;
border-radius: 16px;
overflow-x: auto;
background: #0a0f15;
border: 1px solid var(--line);
color: var(--ink);
font-size: 0.88rem;
line-height: 1.5;
}
.two-up {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.small {
color: var(--muted);
font-size: 0.92rem;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 16px;
}
.metric {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--surface-3);
}
.metric strong {
display: block;
font: 700 1.18rem/1.15 "IBM Plex Sans", "Avenir Next", sans-serif;
}
.metric span {
color: var(--muted);
font-size: 0.9rem;
}
.diff-grid {
display: grid;
gap: 18px;
margin-top: 18px;
}
.diff-shell {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%);
}
.diff-shell h3 {
margin-bottom: 10px;
}
.diff-render {
min-height: 120px;
}
details {
margin-top: 12px;
}
summary {
cursor: pointer;
color: var(--accent);
font-weight: 600;
}
a {
color: var(--accent);
}
</style>
</head>
<body>
<main>
<section class="hero">
<span class="eyebrow">Turn Record · May 22, 2026</span>
<h1>Add Options Anatomy Explainer</h1>
<p class="lede">
Added a standalone <code>docs/anatomy.html</code> reference page that explains the
full lifecycle of an options print, from ingest and signal gating through flow packet
construction, smart-money scoring, classifier hits, alerts, and API/live consumption.
The page is styled to match Islandflows product register and layered so exec, mixed
technical, and operator-level readers can all use the same artifact.
</p>
<div class="meta">
<div class="meta-card">
<span class="meta-label">Beads</span>
<span class="meta-value"><code>islandflow-hpf</code></span>
</div>
<div class="meta-card">
<span class="meta-label">Artifact</span>
<span class="meta-value"><code>docs/anatomy.html</code></span>
</div>
<div class="meta-card">
<span class="meta-label">Register</span>
<span class="meta-value">Product, evidence-console styling</span>
</div>
<div class="meta-card">
<span class="meta-label">Secondary Change</span>
<span class="meta-value">Regenerated <code>docs/index.html</code></span>
</div>
</div>
</section>
<div class="grid">
<section class="card">
<h2>Summary</h2>
<p>
The repo now includes a reusable explainer page for one of the most important pieces of
Islandflows mental model: how a raw or synthetic options print turns into visible tape,
a flow packet, and sometimes a smart-money or alert event. Instead of scattering that
explanation across chat answers and source code, the new page centralizes the pipeline in
a designed HTML document that can be browsed directly under <code>docs/</code>.
</p>
<div class="callout">
<strong>Primary outcome:</strong> the new page makes the option-print pipeline legible at
three reading depths without forcing someone to reconstruct the architecture from service
code.
</div>
</section>
<section class="card">
<h2>Changes Made</h2>
<ul>
<li>
Added <code>docs/anatomy.html</code> as a standalone explainer page titled
<em>The Anatomy of an Options Print and Smart Money</em>.
</li>
<li>
Built a large flow-chart section that distinguishes the common tape path from the
signal-to-packet-to-smart-money branch.
</li>
<li>
Layered the page into executive, mixed technical, and operator-level explanations so
one artifact works for multiple audiences.
</li>
<li>
Included subject/table mapping, annotated sequence detail, synthetic-mode notes, and
code anchors back into the real repo.
</li>
<li>
Regenerated <code>docs/index.html</code> so the new explainer is discoverable from the
existing docs index.
</li>
</ul>
</section>
<section class="card">
<h2>Context</h2>
<p>
The user asked for a true flow-chart explanation of what happens when options tape comes
in under normal market scenarios and when smart-money behavior is detected, with the
important caveat that the current environment is using synthetic prints. The repo already
had the implementation details, but not a clear product artifact that unified ingest,
compute, storage, bus subjects, and API/live consumption into one readable document.
</p>
<p>
Because Islandflows UI language is already defined as an “evidence console,” the new
page needed to feel operational and precise rather than like a generic landing page or a
decorative infographic.
</p>
</section>
<section class="card">
<h2>Important Implementation Details</h2>
<div class="two-up">
<div>
<h3>Information architecture</h3>
<ul>
<li>
The page starts with a semantic legend and a visual flow board so readers can build
the correct mental model before diving into prose.
</li>
<li>
The explanation then deepens in three layers: executive read, mixed technical
walkthrough, and operator/code-level detail.
</li>
<li>
The normal tape path and the smart-money path are split explicitly so readers do not
confuse raw tape visibility with compute-derived inference.
</li>
</ul>
</div>
<div>
<h3>Design choices</h3>
<ul>
<li>
The visual treatment follows the repos product register: dark, stable, evidence-first,
amber used as a sparse signal, monospace labels for pipeline semantics.
</li>
<li>
The flow chart is pure HTML and CSS, not a JavaScript diagram dependency, so the
page remains portable and straightforward to keep in sync with the repo.
</li>
<li>
<code>docs/index.html</code> was regenerated with the existing script so the page
participates in the current docs navigation surface instead of becoming a hidden one-off.
</li>
</ul>
</div>
</div>
</section>
<section class="card">
<h2>Relevant Diff Snippets</h2>
<p class="small">
These snippets are rendered with the Diffs library from
<a href="https://diffs.com/docs">diffs.com</a>, with a plain-text fallback kept inline.
</p>
<div class="diff-grid">
<article class="diff-shell">
<h3><code>docs/anatomy.html</code>: new explainer page and flow-board structure</h3>
<div class="diff-render" id="diff-anatomy"></div>
<details>
<summary>Plain-text fallback</summary>
<pre>+ Added docs/anatomy.html
+ Product-register dark evidence-console styling
+ Main flow chart with common path, tape-only branch, and smart-money branch
+ Layered explanation sections for executive, mixed technical, and operator audiences
+ Subject map, annotated sequence, synthetic mode notes, and code anchors</pre>
</details>
</article>
<article class="diff-shell">
<h3><code>docs/index.html</code>: regenerated docs surface with new entry count</h3>
<div class="diff-render" id="diff-index"></div>
<details>
<summary>Plain-text fallback</summary>
<pre>- 35 files shown
+ 47 files shown
- root/general counts from prior docs set
+ updated counts after regenerating the index, including the new anatomy explainer entry</pre>
</details>
</article>
</div>
</section>
<section class="card">
<h2>Expected Impact for End-Users</h2>
<ul>
<li>
Teammates and operators now have a single place to understand why a print can appear on
tape without ever becoming a smart-money event.
</li>
<li>
The synthetic-print caveat is captured directly in the artifact, which should reduce
confusion when debugging or demoing the current environment.
</li>
<li>
The docs surface becomes more useful as a living product reference, not just a collection
of turn records and plans.
</li>
</ul>
</section>
<section class="card">
<h2>Validation</h2>
<ul>
<li>
Generated the new page at <code>docs/anatomy.html</code> and verified the title and
major sections are present.
</li>
<li>
Regenerated the docs index with
<code>node scripts/generate-docs-index.mjs</code>, which completed successfully and
reported <code>47 entries</code>.
</li>
<li>
Confirmed the new explainer page is included in the docs surface by regenerating
<code>docs/index.html</code>.
</li>
</ul>
<div class="metrics">
<div class="metric">
<strong>1</strong>
<span>new reusable explainer page</span>
</div>
<div class="metric">
<strong>47</strong>
<span>docs index entries after regeneration</span>
</div>
<div class="metric">
<strong>3</strong>
<span>reader depth layers on the page</span>
</div>
</div>
</section>
<section class="card">
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>
The page is intentionally hand-authored HTML rather than a generated diagram artifact.
That keeps it portable, but it also means future pipeline changes should update this page
manually.
</li>
<li>
The docs index regeneration reflects the full current <code>docs/</code> tree, so the
visible counts changed by more than one file compared with the previously committed
index.
</li>
<li>
This validation pass verified structure and generation success, but did not include a
browser-rendered visual QA step against multiple viewport sizes.
</li>
</ul>
</section>
<section class="card">
<h2>Follow-up Work</h2>
<ul>
<li>
Add reciprocal links from more domain-specific docs such as <code>smartmoney.md</code>
back to <code>docs/anatomy.html</code>.
</li>
<li>
Consider a second reference page focused specifically on one concrete synthetic example,
from a burst of prints to the final alert payload.
</li>
<li>
If the flow-packet feature set evolves, keep the anatomy page in lockstep with those
changes so it remains a trustworthy operator reference.
</li>
</ul>
</section>
</div>
<script type="module">
const snippets = {
anatomy: `diff --git a/docs/anatomy.html b/docs/anatomy.html
new file mode 100644
--- /dev/null
+++ b/docs/anatomy.html
@@
+<title>The Anatomy of an Options Print and Smart Money</title>
+<section class="hero">...</section>
+<section id="flow-chart">main flow board with common path and branch rows</section>
+<section id="executive">executive read</section>
+<section id="technical">mixed technical walkthrough</section>
+<section id="operator">operator and code-level detail</section>
+<section>subject map, annotated event sequence, synthetic mode notes, code anchors</section>`,
index: `diff --git a/docs/index.html b/docs/index.html
--- a/docs/index.html
+++ b/docs/index.html
@@
-<div class="stats"><strong id="visible-count">35</strong> of <strong>35</strong> files shown</div>
+<div class="stats"><strong id="visible-count">47</strong> of <strong>47</strong> files shown</div>
@@
-<a class="chip" href="#category-general">general <span>2</span></a>
+<a class="chip" href="#category-general">general <span>4</span></a>
@@
+<a class="doc-link" href="./anatomy.html">anatomy.html</a>`
};
const fallbackRender = (id, text) => {
const target = document.getElementById(id);
if (!target) {
return;
}
const pre = document.createElement("pre");
pre.textContent = text;
target.replaceChildren(pre);
};
try {
const { FileDiff } = await import("https://esm.sh/@pierre/diffs");
const targets = [
["diff-anatomy", snippets.anatomy],
["diff-index", snippets.index]
];
for (const [id, text] of targets) {
const mount = document.getElementById(id);
if (!mount) {
continue;
}
const diff = new FileDiff(text, { language: "diff" });
mount.appendChild(diff.element);
}
} catch (error) {
console.warn("Failed to load diffs.com renderer", error);
fallbackRender("diff-anatomy", snippets.anatomy);
fallbackRender("diff-index", snippets.index);
}
</script>
</main>
</body>
</html>

View file

@ -0,0 +1,810 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Turn Record: Stabilize Live API Memory</title>
<style>
:root {
color-scheme: light;
--bg: #f3f1eb;
--surface: #fffdf8;
--surface-strong: #f7f2e9;
--ink: #1f1b16;
--muted: #62584c;
--line: #d6c8b4;
--accent: #8d5a2b;
--accent-soft: rgba(141, 90, 43, 0.12);
--good: #245c3b;
--warn: #8a4b15;
--shadow: 0 24px 60px rgba(61, 44, 21, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(141, 90, 43, 0.12), transparent 32%),
linear-gradient(180deg, #f7f3ec 0%, var(--bg) 100%);
color: var(--ink);
font: 16px/1.6 "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, serif;
}
main {
width: min(1120px, calc(100vw - 40px));
margin: 32px auto 56px;
}
.hero {
background: var(--surface);
border: 1px solid rgba(214, 200, 180, 0.9);
border-radius: 28px;
padding: 32px;
box-shadow: var(--shadow);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font: 600 12px/1.2 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
line-height: 1.1;
}
h1 {
margin-top: 16px;
font-size: clamp(2.4rem, 5vw, 4rem);
letter-spacing: -0.04em;
}
.lede {
max-width: 72ch;
margin-top: 18px;
color: var(--muted);
font-size: 1.08rem;
}
.hero-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-top: 28px;
}
.meta-card {
padding: 16px 18px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid rgba(214, 200, 180, 0.9);
}
.meta-label {
display: block;
color: var(--muted);
font: 600 0.76rem/1.2 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.meta-value {
display: block;
margin-top: 8px;
font: 700 1.05rem/1.3 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
.grid {
display: grid;
gap: 20px;
margin-top: 20px;
}
.card {
background: var(--surface);
border: 1px solid rgba(214, 200, 180, 0.9);
border-radius: 24px;
padding: 24px;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 1.35rem;
margin-bottom: 14px;
}
p {
margin: 0;
}
p + p,
ul + p,
p + ul,
ul + ul {
margin-top: 12px;
}
ul {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
.callout {
padding: 16px 18px;
border-radius: 18px;
background: rgba(36, 92, 59, 0.08);
border: 1px solid rgba(36, 92, 59, 0.16);
}
.callout strong {
color: var(--good);
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-top: 16px;
}
.metric {
padding: 14px 16px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid rgba(214, 200, 180, 0.9);
}
.metric strong {
display: block;
font: 700 1.2rem/1.2 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
.metric span {
color: var(--muted);
font: 500 0.9rem/1.4 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
code,
pre {
font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
code {
padding: 0.08rem 0.35rem;
border-radius: 0.45rem;
background: rgba(99, 86, 67, 0.08);
}
pre {
overflow-x: auto;
margin: 12px 0 0;
padding: 16px;
border-radius: 16px;
background: #1f1b16;
color: #f7f2e9;
font-size: 0.88rem;
line-height: 1.5;
}
.diff-grid {
display: grid;
gap: 18px;
}
.diff-shell {
border: 1px solid rgba(214, 200, 180, 0.9);
border-radius: 20px;
padding: 16px;
background: linear-gradient(180deg, #fffdf9 0%, #f7f2ea 100%);
}
.diff-shell h3 {
font-size: 1rem;
margin-bottom: 10px;
}
.diff-render {
min-height: 120px;
}
details {
margin-top: 12px;
}
summary {
cursor: pointer;
color: var(--accent);
font: 600 0.88rem/1.3 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
.two-up {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.small {
color: var(--muted);
font-size: 0.92rem;
}
a {
color: var(--accent);
}
</style>
</head>
<body>
<main>
<section class="hero">
<span class="eyebrow">Turn Record · May 22, 2026</span>
<h1>Stabilize Live API Memory and Internal Traffic</h1>
<p class="lede">
The Islandflow live API was repeatedly getting OOM-killed on the VPS because the hot live
cache could retain oversized channel windows and rewrite whole Redis lists at high
frequency. This turn applied an immediate server-side mitigation, hardened the API cache
path in code, and rolled the changes onto the native systemd deployment.
</p>
<div class="hero-meta">
<div class="meta-card">
<span class="meta-label">Branch</span>
<span class="meta-value"><code>stabilize-live-api-memory</code></span>
</div>
<div class="meta-card">
<span class="meta-label">Beads</span>
<span class="meta-value"><code>islandflow-thp</code></span>
</div>
<div class="meta-card">
<span class="meta-label">Deployment</span>
<span class="meta-value">Native systemd user services on the VPS</span>
</div>
<div class="meta-card">
<span class="meta-label">Primary Outcome</span>
<span class="meta-value">API RSS returned to roughly 115-130 MB after rollout</span>
</div>
</div>
</section>
<div class="grid">
<section class="card">
<h2>Summary</h2>
<p>
The live API is now bounded in three layers instead of trusting environment values and
reconnect behavior. First, the VPS <code>.env</code> was reset to safer live-window
values and the oversized Redis hot-cache keys were cleared. Second, the API now clamps
generic live cache limits per channel in code. Third, generic live feed persistence now
appends deltas into Redis instead of cloning and rewriting entire lists on every flush.
</p>
<div class="callout" style="margin-top: 16px">
<strong>Observed on the VPS after rollout:</strong>
the API stayed healthy through restart, minute metrics showed much smaller cache depths,
and the kernel did not log any new Bun OOM kill after the hardened restart.
</div>
</section>
<section class="card">
<h2>Changes Made</h2>
<ul>
<li>
Added channel-specific hard caps in
<code>services/api/src/live.ts</code> so oversized
<code>LIVE_LIMIT_*</code> values are clamped before use.
</li>
<li>
Changed generic live Redis persistence from full-list rewrite behavior to append-plus-trim,
with rewrite fallback only when the in-memory ordering has to be rebuilt.
</li>
<li>
Serialized Redis flushes during shutdown so service restarts do not race with a closing
Redis client.
</li>
<li>
Added API minute-log visibility for live subscription counts, Redis flush deltas,
payload bytes, snapshot sizes, and process memory usage.
</li>
<li>
Tightened the browser-exposed live window caps in
<code>apps/web/app/terminal.tsx</code> and aligned the tracked env examples with the safer
production defaults, including <code>LIVE_LIMIT_NEWS</code>.
</li>
<li>
Applied the emergency mitigation directly on the VPS:
updated <code>/home/delta/islandflow/.env</code>, created
<code>/home/delta/islandflow/.env.backup-2026-05-22-2131</code>, deleted stale
<code>live:*</code> Redis keys, rebuilt the web app, and restarted
<code>islandflow-api.service</code> and <code>islandflow-web.service</code>.
</li>
</ul>
</section>
<section class="card">
<h2>Context</h2>
<p>
The VPS was killing <code>islandflow-api.service</code> several times on May 22, 2026.
Kernel logs showed Bun reaching roughly 8-9 GiB RSS inside the API service cgroup before
the OOM killer stepped in. The API minute logs also showed channel depths pinned at
<code>10000</code> for multiple feeds, plus massive cumulative Redis rewrite churn.
</p>
<p>
Most of the “huge bandwidth” in <code>btop</code> was local loopback traffic: Bun talking
to Redis, NATS, and ClickHouse on <code>127.0.0.1</code>. That meant the problem was not a
public-edge flood, it was the live cache architecture multiplying internal work on the box.
</p>
</section>
<section class="card">
<h2>Important Implementation Details</h2>
<div class="two-up">
<div>
<h3 style="margin-bottom: 10px">API hardening</h3>
<ul>
<li>
Hard caps now bound generic channel windows even if env values drift upward.
</li>
<li>
<code>snapshot_limit</code> is still honored, but only up to the lower of the request,
the configured limit, and the safe channel cap.
</li>
<li>
Generic feeds use incremental Redis appends; scoped candle and overlay caches still
use full rewrites because they are much smaller and keyed differently.
</li>
</ul>
</div>
<div>
<h3 style="margin-bottom: 10px">Operational changes</h3>
<ul>
<li>
The VPS now runs with a much smaller hot live footprint:
options <code>100</code>, flow <code>500</code>, alerts <code>300</code>,
news <code>100</code>.
</li>
<li>
Old Redis hot-cache keys were deleted so the API did not rehydrate oversized lists on boot.
</li>
<li>
The web app was rebuilt on the VPS checkout after switching that checkout onto
<code>stabilize-live-api-memory</code>.
</li>
</ul>
</div>
</div>
</section>
<section class="card">
<h2>Relevant Diff Snippets</h2>
<p class="small">
These snippets are rendered with the Diffs library from
<a href="https://diffs.com/docs">diffs.com</a>, with a plain-text fallback kept inline in the file.
</p>
<div class="diff-grid" style="margin-top: 18px">
<article class="diff-shell">
<h3><code>services/api/src/live.ts</code>: hard caps and append-based generic Redis flushes</h3>
<div class="diff-render" id="diff-live"></div>
<details>
<summary>Plain-text fallback</summary>
<pre>Added LIVE_GENERIC_LIMIT_CAPS, clamped env/configured limits, changed generic writes from
queueRedisWrite(items:[...items]) to queueGenericRedisWrite(item, items, forceRewrite), and split
Redis persistence into rewrite and append paths with shutdown-safe flush serialization.</pre>
</details>
</article>
<article class="diff-shell">
<h3><code>services/api/src/index.ts</code>: minute metrics now include memory and live subscription visibility</h3>
<div class="diff-render" id="diff-index"></div>
<details>
<summary>Plain-text fallback</summary>
<pre>Added buildLiveSubscriptionMetrics(), previous snapshot tracking, flush delta logging,
memory snapshots, and gauges for RSS, heap used, active sockets, and per-channel subscriptions.</pre>
</details>
</article>
<article class="diff-shell">
<h3><code>.env.example</code> and <code>apps/web/app/terminal.tsx</code>: safer default windows</h3>
<div class="diff-render" id="diff-config"></div>
<details>
<summary>Plain-text fallback</summary>
<pre>Reduced LIVE_LIMIT_OPTIONS in tracked examples to 100, added LIVE_LIMIT_NEWS=100,
and lowered the client-exposed maximum live hot windows from 100000 to 2000.</pre>
</details>
</article>
</div>
</section>
<section class="card">
<h2>Expected Impact for End-Users</h2>
<ul>
<li>
The hosted app should stop disappearing behind API restarts caused by the kernel OOM killer.
</li>
<li>
Live feeds should still feel current, but the server will retain a tighter hot window instead of
hoarding oversized in-memory histories.
</li>
<li>
The operator experience on the VPS should improve because internal loopback churn is materially lower.
</li>
</ul>
</section>
<section class="card">
<h2>Validation</h2>
<ul>
<li>
Local API test gate passed:
<code>bun test services/api/tests/live.test.ts</code>
</li>
<li>
Local web production build passed:
<code>bun --cwd=apps/web run build</code>
</li>
<li>
VPS mitigation applied successfully. Redis reported <code>1524</code> live keys removed before restart.
</li>
<li>
After mitigation restart, <code>systemctl --user status islandflow-api.service</code> showed the
API at about <code>84 MB</code> RSS instead of multi-GB startup drift.
</li>
<li>
After rolling the hardened branch onto the VPS, the API minute log at
<code>2026-05-22 21:44:11 EDT</code> showed:
</li>
</ul>
<div class="metrics">
<div class="metric">
<strong>119.6 MB</strong>
<span>API RSS from the minute memory snapshot</span>
</div>
<div class="metric">
<strong>100</strong>
<span><code>live:options</code> depth</span>
</div>
<div class="metric">
<strong>500</strong>
<span><code>live:flow</code>, <code>live:alerts</code>, and <code>live:equity-quotes</code> caps held</span>
</div>
<div class="metric">
<strong>34,559</strong>
<span>Redis flush items in that minute delta</span>
</div>
<div class="metric">
<strong>9.18 MB</strong>
<span>Redis flush payload bytes in that minute delta</span>
</div>
<div class="metric">
<strong>No new OOM</strong>
<span>Kernel logs after the hardened restart</span>
</div>
</div>
</section>
<section class="card">
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>
The new minute metrics are cumulative plus delta-based. They are much more useful than the old
absolute counters, but they still reset on process restart.
</li>
<li>
<code>snapshotItemsByChannel</code> remains empty when no live websocket clients are connected.
That is expected because snapshots are only recorded when a snapshot is actually served.
</li>
<li>
Quiet feeds such as news and inferred-dark can still show very old freshness ages in logs.
That reflects inactivity, not a broken hot path.
</li>
<li>
The append-based Redis path deliberately falls back to a rewrite when out-of-order live events
require the in-memory ordering to be rebuilt. That keeps correctness ahead of theoretical write minimization.
</li>
</ul>
</section>
<section class="card">
<h2>Follow-up Work</h2>
<ul>
<li>
Add explicit alerting for repeated API RSS growth and for minute-level flush deltas that jump far above the new baseline.
</li>
<li>
Decide whether quiet-channel freshness logs should suppress extremely stale values for feeds like news to reduce operator noise.
</li>
<li>
Consider moving the live cache metrics into a dashboard view so operators do not need to parse journal lines manually.
</li>
</ul>
</section>
</div>
</main>
<script type="module">
const diffs = [
{
id: "diff-live",
name: "services/api/src/live.ts",
oldContents: `const DEFAULT_LIVE_LIMITS: GenericLiveLimits = {
options: 100,
nbbo: 1000,
equities: 1000,
"equity-quotes": 500,
"equity-joins": 500,
flow: 500,
"smart-money": 300,
"classifier-hits": 300,
alerts: 300,
"inferred-dark": 300,
news: 100
};
const parseGenericLimit = (env, channel, fallback) => {
const key = GENERIC_LIMIT_ENV_KEYS[channel];
const raw = env[key];
if (!raw || raw.trim().length === 0) {
return fallback;
}
const parsed = Number(raw);
const bounded = Math.max(MIN_GENERIC_LIMIT, Math.min(MAX_GENERIC_LIMIT, Math.floor(parsed)));
return bounded;
};
type BufferedRedisWrite = {
listKey: string;
cursorField: string;
items: unknown[];
limit: number;
cursor: Cursor | null;
updates: number;
};
private queueRedisWrite(listKey, cursorField, items, limit, cursor) {
const existing = this.pendingRedisWrites.get(listKey);
const write: BufferedRedisWrite = {
listKey,
cursorField,
items: [...items],
limit,
cursor,
updates: (existing?.updates ?? 0) + 1
};
this.pendingRedisWrites.set(listKey, write);
}
private async persistList(listKey, cursorField, items, limit, cursor) {
const payloads = items.map((entry) => JSON.stringify(entry));
await this.redis.lTrim(listKey, 1, 0);
if (payloads.length > 0) {
for (let idx = payloads.length - 1; idx >= 0; idx -= 1) {
await this.redis.lPush(listKey, payloads[idx]);
}
await this.redis.lTrim(listKey, 0, limit - 1);
}
await this.redis.hSet(CURSOR_HASH_KEY, cursorField, JSON.stringify(cursor));
}`,
newContents: `export const LIVE_GENERIC_LIMIT_CAPS: GenericLiveLimits = {
options: 100,
nbbo: 1000,
equities: 1000,
"equity-quotes": 500,
"equity-joins": 500,
flow: 500,
"smart-money": 300,
"classifier-hits": 300,
alerts: 300,
"inferred-dark": 300,
news: 100
};
const clampConfiguredLimit = (channel: LiveGenericChannel, value: number): number =>
Math.max(MIN_GENERIC_LIMIT, Math.min(LIVE_GENERIC_LIMIT_CAPS[channel], Math.floor(value)));
const parseGenericLimit = (env, channel, fallback) => {
const key = GENERIC_LIMIT_ENV_KEYS[channel];
const raw = env[key];
if (!raw || raw.trim().length === 0) {
return clampConfiguredLimit(channel, fallback);
}
const parsed = Number(raw);
const bounded = clampConfiguredLimit(channel, Math.min(MAX_GENERIC_LIMIT, parsed));
return bounded;
};
type BufferedRedisRewrite = {
mode: "rewrite";
listKey: string;
cursorField: string;
items: unknown[];
limit: number;
cursor: Cursor | null;
updates: number;
};
type BufferedRedisAppend = {
mode: "append";
listKey: string;
cursorField: string;
payloads: string[];
limit: number;
cursor: Cursor | null;
updates: number;
};
private queueGenericRedisWrite(listKey, cursorField, item, items, limit, cursor, forceRewrite = false) {
const existing = this.pendingRedisWrites.get(listKey);
const nextUpdateCount = (existing?.updates ?? 0) + 1;
if (forceRewrite || existing?.mode === "rewrite") {
this.pendingRedisWrites.set(listKey, {
mode: "rewrite",
listKey,
cursorField,
items: [...items],
limit,
cursor,
updates: nextUpdateCount
});
} else {
this.pendingRedisWrites.set(listKey, {
mode: "append",
listKey,
cursorField,
payloads: [...(existing?.mode === "append" ? existing.payloads : []), JSON.stringify(item)],
limit,
cursor,
updates: nextUpdateCount
});
}
}
private async persistListAppend(listKey, cursorField, payloads, limit, cursor) {
for (const payload of payloads) {
await this.redis.lPush(listKey, payload);
}
await this.redis.lTrim(listKey, 0, limit - 1);
await this.redis.hSet(CURSOR_HASH_KEY, cursorField, JSON.stringify(cursor));
}`
},
{
id: "diff-index",
name: "services/api/src/index.ts",
oldContents: `const liveStateMetricsTimer = setInterval(() => {
const snapshot = liveState.getStatsSnapshot();
const hotFeedHealth = liveState.getHotChannelHealth();
const hotFeedLagMs = {
options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null,
equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null,
flow: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.flow] ?? null,
nbbo: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.nbbo] ?? null
};
logger.info("live cache metrics", {
...snapshot,
hotFeedLagMs,
hotFeedHealth,
snapshotSourceCounts: {
generic_cache_snapshot: snapshot.genericCacheSnapshots,
scoped_clickhouse_snapshot: snapshot.scopedClickHouseSnapshots
}
});
}, 60000);`,
newContents: `const buildLiveSubscriptionMetrics = () => {
const uniqueSubscriptionsByChannel: Partial<Record<LiveSubscription["channel"], number>> = {};
const socketFanoutByChannel: Partial<Record<LiveSubscription["channel"], number>> = {};
for (const subscription of subscriptionDefinitions.values()) {
uniqueSubscriptionsByChannel[subscription.channel] =
(uniqueSubscriptionsByChannel[subscription.channel] ?? 0) + 1;
}
for (const [key, sockets] of subscriptionSockets.entries()) {
const subscription = subscriptionDefinitions.get(key);
if (!subscription || sockets.size === 0) {
continue;
}
socketFanoutByChannel[subscription.channel] =
(socketFanoutByChannel[subscription.channel] ?? 0) + sockets.size;
}
return {
liveSocketCount: liveSocketSubscriptions.size,
uniqueSubscriptionsByChannel,
socketFanoutByChannel
};
};
let previousLiveStats = liveState.getStatsSnapshot();
let previousMemoryUsage = process.memoryUsage();
const liveStateMetricsTimer = setInterval(() => {
const snapshot = liveState.getStatsSnapshot();
const hotFeedHealth = liveState.getHotChannelHealth();
const subscriptionMetrics = buildLiveSubscriptionMetrics();
const memoryUsage = process.memoryUsage();
const flushDelta = {
redisFlushCount: snapshot.redisFlushCount - previousLiveStats.redisFlushCount,
redisFlushItems: snapshot.redisFlushItems - previousLiveStats.redisFlushItems,
redisFlushPayloadBytes: snapshot.redisFlushPayloadBytes - previousLiveStats.redisFlushPayloadBytes
};
const memorySnapshot = {
rss_bytes: memoryUsage.rss,
heap_used_bytes: memoryUsage.heapUsed,
rss_delta_bytes: memoryUsage.rss - previousMemoryUsage.rss
};
logger.info("live cache metrics", {
...snapshot,
flushDelta,
memorySnapshot,
liveSubscriptions: subscriptionMetrics
});
metrics.gauge("api.memory.rss_bytes", memoryUsage.rss);
metrics.gauge("api.live.active_sockets", subscriptionMetrics.liveSocketCount);
}, 60000);`
},
{
id: "diff-config",
name: "config excerpt",
oldContents: `// apps/web/app/terminal.tsx
const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 600, 1, 100000);
const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
1200,
1,
100000
);
# .env.example
LIVE_LIMIT_OPTIONS=1000
LIVE_LIMIT_INFERRED_DARK=300`,
newContents: `// apps/web/app/terminal.tsx
const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 600, 1, 2000);
const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
1200,
1,
2000
);
# .env.example
LIVE_LIMIT_OPTIONS=100
LIVE_LIMIT_INFERRED_DARK=300
LIVE_LIMIT_NEWS=100`
}
];
try {
const { FileDiff } = await import("https://esm.sh/@pierre/diffs");
for (const diff of diffs) {
const container = document.getElementById(diff.id);
if (!container) continue;
const fileDiff = new FileDiff({ theme: "github-light" });
fileDiff.render({
oldFile: { name: diff.name, contents: diff.oldContents },
newFile: { name: diff.name, contents: diff.newContents },
containerWrapper: container
});
}
} catch (error) {
console.warn("Failed to load diffs.com renderer", error);
}
</script>
</body>
</html>

View file

@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Turn Doc - Reconcile PR #8</title>
<style>
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem auto; max-width: 920px; line-height: 1.5; color: #111827; }
h1,h2 { line-height: 1.2; }
code,pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { background:#f3f4f6; padding:0.9rem; border-radius:8px; overflow:auto; }
section { margin: 1.2rem 0; }
</style>
</head>
<body>
<h1>Reconcile PR #8 with main</h1>
<p><strong>Date:</strong> 2026-05-23</p>
<section>
<h2>Summary</h2>
<p>Reconciled PR #8 by merging the latest <code>main</code> into <code>stabilize-live-api-memory</code>, resolving the only merge conflict, and pushing the updated branch to Forgejo.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Checked out <code>stabilize-live-api-memory</code> from <code>forgejo/stabilize-live-api-memory</code>.</li>
<li>Merged <code>forgejo/main</code> into the PR branch.</li>
<li>Resolved merge conflict in <code>.beads/issues.jsonl</code>.</li>
<li>Closed Beads issue <code>islandflow-kgu</code> for this reconciliation work.</li>
<li>Pushed Beads data and git branch updates to Forgejo.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>The user requested reconciliation for PR #8. PR #8 head branch (<code>stabilize-live-api-memory</code>) was behind current <code>main</code>, so the branch needed an integration merge to clear mergeability drift.</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The only merge conflict was in Beads tracker data (<code>.beads/issues.jsonl</code>).</li>
<li>Conflict resolution preserved both upstream tracker updates and the in-progress reconciliation issue record.</li>
<li>No service/application source files required manual code conflict resolution in this reconciliation pass.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p>Rendered in diffs.com-compatible unified diff style:</p>
<pre><code class="language-diff">commit 6584f7d1545019da663ab3ec9719d06e25c5244e
Merge: db73700 8464287
Author: dirtydishes
+ merge main into stabilize-live-api-memory to reconcile pr 8
MM .beads/issues.jsonl</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>End-users should not see functional UI/API changes from this reconciliation itself; the impact is operational: PR #8 can now be merged cleanly against current mainline history.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li>Verified branch push to Forgejo succeeded.</li>
<li>Verified Beads push (<code>bd dolt push</code>) succeeded.</li>
<li>Verified final git state reports branch aligned with <code>forgejo/stabilize-live-api-memory</code>.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li><strong>Limitation:</strong> <code>fj</code> auth was initially unauthorized in this session.</li>
<li><strong>Mitigation:</strong> Re-auth was completed by user and confirmed with successful <code>fj pr view 8</code>.</li>
<li><strong>Scope note:</strong> This turn focused on branch reconciliation, not feature behavior changes.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Proceed with normal review/merge flow for PR #8 in Forgejo.</li>
<li>If additional commits land on <code>main</code> before merge, re-run a quick reconciliation pass.</li>
</ul>
</section>
</body>
</html>

View file

@ -307,6 +307,35 @@ const subscriptionSockets = new Map<string, Set<LiveSocket>>();
const subscriptionDefinitions = new Map<string, LiveSubscription>(); const subscriptionDefinitions = new Map<string, LiveSubscription>();
const liveHeartbeats = new Map<LiveSocket, ReturnType<typeof setInterval>>(); const liveHeartbeats = new Map<LiveSocket, ReturnType<typeof setInterval>>();
const buildLiveSubscriptionMetrics = (): {
liveSocketCount: number;
uniqueSubscriptionsByChannel: Partial<Record<LiveSubscription["channel"], number>>;
socketFanoutByChannel: Partial<Record<LiveSubscription["channel"], number>>;
} => {
const uniqueSubscriptionsByChannel: Partial<Record<LiveSubscription["channel"], number>> = {};
const socketFanoutByChannel: Partial<Record<LiveSubscription["channel"], number>> = {};
for (const subscription of subscriptionDefinitions.values()) {
uniqueSubscriptionsByChannel[subscription.channel] =
(uniqueSubscriptionsByChannel[subscription.channel] ?? 0) + 1;
}
for (const [key, sockets] of subscriptionSockets.entries()) {
const subscription = subscriptionDefinitions.get(key);
if (!subscription || sockets.size === 0) {
continue;
}
socketFanoutByChannel[subscription.channel] =
(socketFanoutByChannel[subscription.channel] ?? 0) + sockets.size;
}
return {
liveSocketCount: liveSocketSubscriptions.size,
uniqueSubscriptionsByChannel,
socketFanoutByChannel
};
};
const jsonResponse = (body: unknown, status = 200): Response => { const jsonResponse = (body: unknown, status = 200): Response => {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
@ -759,6 +788,8 @@ const run = async () => {
const liveState = new LiveStateManager(clickhouse, redis, resolveLiveStateConfig()); const liveState = new LiveStateManager(clickhouse, redis, resolveLiveStateConfig());
await liveState.hydrate(); await liveState.hydrate();
let previousLiveStats = liveState.getStatsSnapshot();
let previousMemoryUsage = process.memoryUsage();
const warnLiveLag = ( const warnLiveLag = (
channel: keyof typeof HOT_LIVE_REDIS_KEYS, channel: keyof typeof HOT_LIVE_REDIS_KEYS,
ageMs: number | null | undefined ageMs: number | null | undefined
@ -778,25 +809,52 @@ const run = async () => {
const liveStateMetricsTimer = setInterval(() => { const liveStateMetricsTimer = setInterval(() => {
const snapshot = liveState.getStatsSnapshot(); const snapshot = liveState.getStatsSnapshot();
const hotFeedHealth = liveState.getHotChannelHealth(); const hotFeedHealth = liveState.getHotChannelHealth();
const subscriptionMetrics = buildLiveSubscriptionMetrics();
const memoryUsage = process.memoryUsage();
const hotFeedLagMs = { const hotFeedLagMs = {
options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null, options: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] ?? null,
equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null, equities: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.equities] ?? null,
flow: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.flow] ?? null, flow: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.flow] ?? null,
nbbo: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.nbbo] ?? null nbbo: snapshot.freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.nbbo] ?? null
}; };
const flushDelta = {
redisFlushCount: snapshot.redisFlushCount - previousLiveStats.redisFlushCount,
redisFlushItems: snapshot.redisFlushItems - previousLiveStats.redisFlushItems,
redisFlushPayloadBytes: snapshot.redisFlushPayloadBytes - previousLiveStats.redisFlushPayloadBytes
};
const memorySnapshot = {
rss_bytes: memoryUsage.rss,
heap_used_bytes: memoryUsage.heapUsed,
heap_total_bytes: memoryUsage.heapTotal,
external_bytes: memoryUsage.external,
array_buffers_bytes: memoryUsage.arrayBuffers,
rss_delta_bytes: memoryUsage.rss - previousMemoryUsage.rss,
heap_used_delta_bytes: memoryUsage.heapUsed - previousMemoryUsage.heapUsed
};
logger.info("live cache metrics", { logger.info("live cache metrics", {
...snapshot, ...snapshot,
hotFeedLagMs, hotFeedLagMs,
hotFeedHealth, hotFeedHealth,
flushDelta,
memorySnapshot,
liveSubscriptions: subscriptionMetrics,
snapshotSourceCounts: { snapshotSourceCounts: {
generic_cache_snapshot: snapshot.genericCacheSnapshots, generic_cache_snapshot: snapshot.genericCacheSnapshots,
scoped_clickhouse_snapshot: snapshot.scopedClickHouseSnapshots scoped_clickhouse_snapshot: snapshot.scopedClickHouseSnapshots
} }
}); });
metrics.gauge("api.memory.rss_bytes", memoryUsage.rss);
metrics.gauge("api.memory.heap_used_bytes", memoryUsage.heapUsed);
metrics.gauge("api.live.active_sockets", subscriptionMetrics.liveSocketCount);
for (const [channel, count] of Object.entries(subscriptionMetrics.uniqueSubscriptionsByChannel)) {
metrics.gauge("api.live.subscription_count", count, { channel });
}
warnLiveLag("options", hotFeedLagMs.options); warnLiveLag("options", hotFeedLagMs.options);
warnLiveLag("equities", hotFeedLagMs.equities); warnLiveLag("equities", hotFeedLagMs.equities);
warnLiveLag("flow", hotFeedLagMs.flow); warnLiveLag("flow", hotFeedLagMs.flow);
warnLiveLag("nbbo", hotFeedLagMs.nbbo); warnLiveLag("nbbo", hotFeedLagMs.nbbo);
previousLiveStats = snapshot;
previousMemoryUsage = memoryUsage;
}, 60000); }, 60000);
const consumerBindings = [ const consumerBindings = [

View file

@ -89,6 +89,20 @@ const DEFAULT_LIVE_LIMITS: GenericLiveLimits = {
news: 100 news: 100
}; };
export const LIVE_GENERIC_LIMIT_CAPS: GenericLiveLimits = {
options: 100,
nbbo: 1000,
equities: 1000,
"equity-quotes": 500,
"equity-joins": 500,
flow: 500,
"smart-money": 300,
"classifier-hits": 300,
alerts: 300,
"inferred-dark": 300,
news: 100
};
const DEFAULT_SCOPED_CACHE_MAX_KEYS = 32; const DEFAULT_SCOPED_CACHE_MAX_KEYS = 32;
const DEFAULT_REDIS_FLUSH_INTERVAL_MS = 250; const DEFAULT_REDIS_FLUSH_INTERVAL_MS = 250;
const DEFAULT_REDIS_FLUSH_MAX_ITEMS = 100; const DEFAULT_REDIS_FLUSH_MAX_ITEMS = 100;
@ -134,7 +148,7 @@ const parseGenericLimit = (
const key = GENERIC_LIMIT_ENV_KEYS[channel]; const key = GENERIC_LIMIT_ENV_KEYS[channel];
const raw = env[key]; const raw = env[key];
if (!raw || raw.trim().length === 0) { if (!raw || raw.trim().length === 0) {
return fallback; return clampConfiguredLimit(channel, fallback);
} }
const parsed = Number(raw); const parsed = Number(raw);
@ -143,7 +157,7 @@ const parseGenericLimit = (
return fallback; return fallback;
} }
const bounded = Math.max(MIN_GENERIC_LIMIT, Math.min(MAX_GENERIC_LIMIT, Math.floor(parsed))); const bounded = clampConfiguredLimit(channel, Math.min(MAX_GENERIC_LIMIT, parsed));
if (bounded !== parsed) { if (bounded !== parsed) {
console.warn(`Clamped ${key} from ${parsed} to ${bounded}`); console.warn(`Clamped ${key} from ${parsed} to ${bounded}`);
} }
@ -226,7 +240,7 @@ const extractFreshnessTs = (channel: LiveGenericChannel, item: any): number | nu
}; };
export const resolveLiveStateConfig = (env: NodeJS.ProcessEnv = process.env): LiveStateConfig => ({ export const resolveLiveStateConfig = (env: NodeJS.ProcessEnv = process.env): LiveStateConfig => ({
limits: resolveGenericLiveLimits(env), limits: clampGenericLimitMap(resolveGenericLiveLimits(env)),
scopedCacheMaxKeys: parsePositiveInt(env.LIVE_SCOPED_CACHE_MAX_KEYS, DEFAULT_SCOPED_CACHE_MAX_KEYS), scopedCacheMaxKeys: parsePositiveInt(env.LIVE_SCOPED_CACHE_MAX_KEYS, DEFAULT_SCOPED_CACHE_MAX_KEYS),
redisFlushIntervalMs: parsePositiveInt( redisFlushIntervalMs: parsePositiveInt(
env.LIVE_REDIS_FLUSH_INTERVAL_MS, env.LIVE_REDIS_FLUSH_INTERVAL_MS,
@ -559,7 +573,8 @@ const insertNewestFirst = <T>(
}; };
}; };
type BufferedRedisWrite = { type BufferedRedisRewrite = {
mode: "rewrite";
listKey: string; listKey: string;
cursorField: string; cursorField: string;
items: unknown[]; items: unknown[];
@ -568,9 +583,67 @@ type BufferedRedisWrite = {
updates: number; updates: number;
}; };
type BufferedRedisAppend = {
mode: "append";
listKey: string;
cursorField: string;
payloads: string[];
limit: number;
cursor: Cursor | null;
updates: number;
};
type BufferedRedisWrite = BufferedRedisRewrite | BufferedRedisAppend;
export type LiveStateStatsSnapshot = {
genericHydrateFromRedis: number;
genericHydrateFromClickHouse: number;
genericCacheSnapshots: number;
scopedClickHouseSnapshots: number;
trimOperations: number;
redisFlushCount: number;
redisFlushItems: number;
redisFlushPayloadBytes: number;
cacheEvictions: number;
outOfOrderEvents: number;
cacheDepthByKey: Record<string, number>;
freshnessAgeMsByKey: Record<string, number>;
snapshotItemsByChannel: Record<string, number>;
};
const isLiveStateConfig = (value: GenericLiveLimits | LiveStateConfig): value is LiveStateConfig => const isLiveStateConfig = (value: GenericLiveLimits | LiveStateConfig): value is LiveStateConfig =>
"limits" in value; "limits" in value;
const isRedisClientClosedError = (error: unknown): boolean =>
error instanceof Error && error.message.toLowerCase().includes("client is closed");
const clampConfiguredLimit = (channel: LiveGenericChannel, value: number): number =>
Math.max(MIN_GENERIC_LIMIT, Math.min(LIVE_GENERIC_LIMIT_CAPS[channel], Math.floor(value)));
const clampGenericLimitMap = (limits: GenericLiveLimits): GenericLiveLimits =>
Object.fromEntries(
(Object.keys(LIVE_GENERIC_LIMIT_CAPS) as LiveGenericChannel[]).map((channel) => [
channel,
clampConfiguredLimit(channel, limits[channel] ?? DEFAULT_LIVE_LIMITS[channel])
])
) as GenericLiveLimits;
const normalizeLiveStateConfig = (config: GenericLiveLimits | LiveStateConfig): LiveStateConfig => {
if (isLiveStateConfig(config)) {
return {
...config,
limits: clampGenericLimitMap(config.limits)
};
}
return {
limits: clampGenericLimitMap(config),
scopedCacheMaxKeys: DEFAULT_SCOPED_CACHE_MAX_KEYS,
redisFlushIntervalMs: DEFAULT_REDIS_FLUSH_INTERVAL_MS,
redisFlushMaxItems: DEFAULT_REDIS_FLUSH_MAX_ITEMS
};
};
export class LiveStateManager { export class LiveStateManager {
private readonly config: LiveStateConfig; private readonly config: LiveStateConfig;
private readonly generic: { private readonly generic: {
@ -586,6 +659,7 @@ export class LiveStateManager {
private readonly overlayAccess = new Map<string, number>(); private readonly overlayAccess = new Map<string, number>();
private readonly pendingRedisWrites = new Map<string, BufferedRedisWrite>(); private readonly pendingRedisWrites = new Map<string, BufferedRedisWrite>();
private readonly redisFlushTimer: ReturnType<typeof setInterval> | null; private readonly redisFlushTimer: ReturnType<typeof setInterval> | null;
private redisFlushInFlight: Promise<void> | null = null;
private readonly stats = { private readonly stats = {
genericHydrateFromRedis: 0, genericHydrateFromRedis: 0,
genericHydrateFromClickHouse: 0, genericHydrateFromClickHouse: 0,
@ -594,10 +668,12 @@ export class LiveStateManager {
trimOperations: 0, trimOperations: 0,
redisFlushCount: 0, redisFlushCount: 0,
redisFlushItems: 0, redisFlushItems: 0,
redisFlushPayloadBytes: 0,
cacheEvictions: 0, cacheEvictions: 0,
outOfOrderEvents: 0, outOfOrderEvents: 0,
cacheDepthByKey: new Map<string, number>(), cacheDepthByKey: new Map<string, number>(),
freshnessAgeMsByKey: new Map<string, number>() freshnessAgeMsByKey: new Map<string, number>(),
snapshotItemsByChannel: new Map<string, number>()
}; };
constructor( constructor(
@ -605,14 +681,7 @@ export class LiveStateManager {
private readonly redis: RedisLike | null, private readonly redis: RedisLike | null,
config: GenericLiveLimits | LiveStateConfig = resolveLiveStateConfig() config: GenericLiveLimits | LiveStateConfig = resolveLiveStateConfig()
) { ) {
this.config = isLiveStateConfig(config) this.config = normalizeLiveStateConfig(config);
? config
: {
limits: config,
scopedCacheMaxKeys: DEFAULT_SCOPED_CACHE_MAX_KEYS,
redisFlushIntervalMs: DEFAULT_REDIS_FLUSH_INTERVAL_MS,
redisFlushMaxItems: DEFAULT_REDIS_FLUSH_MAX_ITEMS
};
this.generic = getGenericConfig(this.config.limits); this.generic = getGenericConfig(this.config.limits);
this.redisFlushTimer = this.redisFlushTimer =
this.redis && this.redis.isOpen this.redis && this.redis.isOpen
@ -630,19 +699,7 @@ export class LiveStateManager {
await this.flushRedisWrites(); await this.flushRedisWrites();
} }
getStatsSnapshot(): { getStatsSnapshot(): LiveStateStatsSnapshot {
genericHydrateFromRedis: number;
genericHydrateFromClickHouse: number;
genericCacheSnapshots: number;
scopedClickHouseSnapshots: number;
trimOperations: number;
redisFlushCount: number;
redisFlushItems: number;
cacheEvictions: number;
outOfOrderEvents: number;
cacheDepthByKey: Record<string, number>;
freshnessAgeMsByKey: Record<string, number>;
} {
return { return {
genericHydrateFromRedis: this.stats.genericHydrateFromRedis, genericHydrateFromRedis: this.stats.genericHydrateFromRedis,
genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse, genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse,
@ -651,10 +708,12 @@ export class LiveStateManager {
trimOperations: this.stats.trimOperations, trimOperations: this.stats.trimOperations,
redisFlushCount: this.stats.redisFlushCount, redisFlushCount: this.stats.redisFlushCount,
redisFlushItems: this.stats.redisFlushItems, redisFlushItems: this.stats.redisFlushItems,
redisFlushPayloadBytes: this.stats.redisFlushPayloadBytes,
cacheEvictions: this.stats.cacheEvictions, cacheEvictions: this.stats.cacheEvictions,
outOfOrderEvents: this.stats.outOfOrderEvents, outOfOrderEvents: this.stats.outOfOrderEvents,
cacheDepthByKey: Object.fromEntries(this.stats.cacheDepthByKey), cacheDepthByKey: Object.fromEntries(this.stats.cacheDepthByKey),
freshnessAgeMsByKey: Object.fromEntries(this.stats.freshnessAgeMsByKey) freshnessAgeMsByKey: Object.fromEntries(this.stats.freshnessAgeMsByKey),
snapshotItemsByChannel: Object.fromEntries(this.stats.snapshotItemsByChannel)
}; };
} }
@ -668,6 +727,22 @@ export class LiveStateManager {
} }
async flushRedisWrites(): Promise<void> { async flushRedisWrites(): Promise<void> {
if (this.redisFlushInFlight) {
return this.redisFlushInFlight;
}
this.redisFlushInFlight = this.flushRedisWritesInternal();
try {
await this.redisFlushInFlight;
} finally {
this.redisFlushInFlight = null;
if (this.pendingRedisWrites.size > 0 && this.redis?.isOpen) {
void this.flushRedisWrites();
}
}
}
private async flushRedisWritesInternal(): Promise<void> {
if (!this.redis?.isOpen) { if (!this.redis?.isOpen) {
return; return;
} }
@ -676,11 +751,50 @@ export class LiveStateManager {
this.pendingRedisWrites.clear(); this.pendingRedisWrites.clear();
for (const write of writes) { for (const write of writes) {
if (write.mode === "rewrite") {
try {
await this.persistList(write.listKey, write.cursorField, write.items, write.limit, write.cursor); await this.persistList(write.listKey, write.cursorField, write.items, write.limit, write.cursor);
this.stats.redisFlushCount += 1; } catch (error) {
if (isRedisClientClosedError(error)) {
return;
}
throw error;
}
this.stats.redisFlushItems += write.items.length; this.stats.redisFlushItems += write.items.length;
this.stats.redisFlushPayloadBytes += write.items.reduce(
(total, item) => total + JSON.stringify(item).length,
0
);
} else {
try {
await this.persistListAppend(
write.listKey,
write.cursorField,
write.payloads,
write.limit,
write.cursor
);
} catch (error) {
if (isRedisClientClosedError(error)) {
return;
}
throw error;
}
this.stats.redisFlushItems += write.payloads.length;
this.stats.redisFlushPayloadBytes += write.payloads.reduce((total, payload) => total + payload.length, 0);
}
this.stats.redisFlushCount += 1;
metrics.count("api.live.redis_flush_count", 1); metrics.count("api.live.redis_flush_count", 1);
metrics.count("api.live.redis_flush_items", write.items.length); metrics.count(
"api.live.redis_flush_items",
write.mode === "rewrite" ? write.items.length : write.payloads.length
);
metrics.count(
"api.live.redis_flush_payload_bytes",
write.mode === "rewrite"
? write.items.reduce((total, item) => total + JSON.stringify(item).length, 0)
: write.payloads.reduce((total, payload) => total + payload.length, 0)
);
} }
} }
@ -739,7 +853,12 @@ export class LiveStateManager {
} }
} }
private queueRedisWrite( private recordSnapshotItems(channel: LiveSubscription["channel"], count: number): void {
this.stats.snapshotItemsByChannel.set(channel, count);
metrics.gauge("api.live.snapshot_items", count, { channel });
}
private queueRedisRewrite(
listKey: string, listKey: string,
cursorField: string, cursorField: string,
items: unknown[], items: unknown[],
@ -751,7 +870,8 @@ export class LiveStateManager {
} }
const existing = this.pendingRedisWrites.get(listKey); const existing = this.pendingRedisWrites.get(listKey);
const write: BufferedRedisWrite = { const write: BufferedRedisRewrite = {
mode: "rewrite",
listKey, listKey,
cursorField, cursorField,
items: [...items], items: [...items],
@ -765,6 +885,51 @@ export class LiveStateManager {
} }
} }
private queueGenericRedisWrite(
listKey: string,
cursorField: string,
item: unknown,
items: unknown[],
limit: number,
cursor: Cursor | null,
forceRewrite = false
): void {
if (!this.redis?.isOpen) {
return;
}
const existing = this.pendingRedisWrites.get(listKey);
const nextUpdateCount = (existing?.updates ?? 0) + 1;
if (forceRewrite || existing?.mode === "rewrite") {
const write: BufferedRedisRewrite = {
mode: "rewrite",
listKey,
cursorField,
items: [...items],
limit,
cursor,
updates: nextUpdateCount
};
this.pendingRedisWrites.set(listKey, write);
} else {
const payload = JSON.stringify(item);
const write: BufferedRedisAppend = {
mode: "append",
listKey,
cursorField,
payloads: [...(existing?.mode === "append" ? existing.payloads : []), payload],
limit,
cursor,
updates: nextUpdateCount
};
this.pendingRedisWrites.set(listKey, write);
}
if (nextUpdateCount >= this.config.redisFlushMaxItems) {
void this.flushRedisWrites();
}
}
async hydrate(): Promise<void> { async hydrate(): Promise<void> {
const channels = Object.keys(this.generic) as LiveGenericChannel[]; const channels = Object.keys(this.generic) as LiveGenericChannel[];
await Promise.all(channels.map((channel) => this.hydrateGeneric(channel))); await Promise.all(channels.map((channel) => this.hydrateGeneric(channel)));
@ -818,6 +983,7 @@ export class LiveStateManager {
const backfill = await fetchRecentOptionPrints(this.clickhouse, limit, undefined, storageFilters); const backfill = await fetchRecentOptionPrints(this.clickhouse, limit, undefined, storageFilters);
items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ ts: entry.ts, seq: entry.seq })); items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ ts: entry.ts, seq: entry.seq }));
} }
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -830,6 +996,7 @@ export class LiveStateManager {
const items = (this.genericItems.get("options") ?? []) const items = (this.genericItems.get("options") ?? [])
.filter((entry) => matchesOptionPrintFilters(entry, subscription.filters)) .filter((entry) => matchesOptionPrintFilters(entry, subscription.filters))
.slice(0, limit); .slice(0, limit);
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -844,6 +1011,7 @@ export class LiveStateManager {
const items = (this.genericItems.get("flow") ?? []) const items = (this.genericItems.get("flow") ?? [])
.filter((entry) => matchesFlowPacketFilters(entry, subscription.filters)) .filter((entry) => matchesFlowPacketFilters(entry, subscription.filters))
.slice(0, limit); .slice(0, limit);
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -865,6 +1033,7 @@ export class LiveStateManager {
const backfill = await fetchRecentEquityPrints(this.clickhouse, limit, filters); const backfill = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
items = mergeSnapshotBackfill(cached, backfill, limit, config.cursor); items = mergeSnapshotBackfill(cached, backfill, limit, config.cursor);
} }
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -874,6 +1043,7 @@ export class LiveStateManager {
} }
this.stats.genericCacheSnapshots += 1; this.stats.genericCacheSnapshots += 1;
const items = (this.genericItems.get("equities") ?? []).slice(0, limit); const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -889,6 +1059,7 @@ export class LiveStateManager {
} }
this.touchAccess(this.candleAccess, key); this.touchAccess(this.candleAccess, key);
const items = this.candleItems.get(key) ?? []; const items = this.candleItems.get(key) ?? [];
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -904,6 +1075,7 @@ export class LiveStateManager {
} }
this.touchAccess(this.overlayAccess, key); this.touchAccess(this.overlayAccess, key);
const items = this.overlayItems.get(key) ?? []; const items = this.overlayItems.get(key) ?? [];
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -916,6 +1088,7 @@ export class LiveStateManager {
this.stats.genericCacheSnapshots += 1; this.stats.genericCacheSnapshots += 1;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit); const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
this.recordSnapshotItems(subscription.channel, items.length);
return { return {
subscription, subscription,
items, items,
@ -951,7 +1124,7 @@ export class LiveStateManager {
if (nextState.items.length > 0) { if (nextState.items.length > 0) {
this.updateFreshnessMetric(key, "equity-candles", nextState.items[0]); this.updateFreshnessMetric(key, "equity-candles", nextState.items[0]);
} }
this.queueRedisWrite(key, cursorField, nextState.items, CHART_LIMITS.candles, cursor); this.queueRedisRewrite(key, cursorField, nextState.items, CHART_LIMITS.candles, cursor);
return cursor; return cursor;
} }
case "equity-overlay": { case "equity-overlay": {
@ -977,7 +1150,7 @@ export class LiveStateManager {
if (nextState.items.length > 0) { if (nextState.items.length > 0) {
this.updateFreshnessMetric(key, "equity-overlay", nextState.items[0]); this.updateFreshnessMetric(key, "equity-overlay", nextState.items[0]);
} }
this.queueRedisWrite(key, cursorField, nextState.items, CHART_LIMITS.overlay, cursor); this.queueRedisRewrite(key, cursorField, nextState.items, CHART_LIMITS.overlay, cursor);
return cursor; return cursor;
} }
default: { default: {
@ -1007,7 +1180,15 @@ export class LiveStateManager {
if (nextState.items.length > 0) { if (nextState.items.length > 0) {
this.updateFreshnessMetric(config.redisKey, channel, nextState.items[0]); this.updateFreshnessMetric(config.redisKey, channel, nextState.items[0]);
} }
this.queueRedisWrite(config.redisKey, config.cursorField, nextState.items, config.limit, cursor); this.queueGenericRedisWrite(
config.redisKey,
config.cursorField,
parsed,
nextState.items,
config.limit,
cursor,
nextState.outOfOrder
);
return cursor; return cursor;
} }
} }
@ -1102,4 +1283,23 @@ export class LiveStateManager {
this.stats.cacheDepthByKey.set(listKey, Math.min(items.length, limit)); this.stats.cacheDepthByKey.set(listKey, Math.min(items.length, limit));
await this.redis.hSet(CURSOR_HASH_KEY, cursorField, JSON.stringify(cursor)); await this.redis.hSet(CURSOR_HASH_KEY, cursorField, JSON.stringify(cursor));
} }
private async persistListAppend(
listKey: string,
cursorField: string,
payloads: string[],
limit: number,
cursor: Cursor | null
): Promise<void> {
if (!this.redis?.isOpen) {
return;
}
for (const payload of payloads) {
await this.redis.lPush(listKey, payload);
}
await this.redis.lTrim(listKey, 0, limit - 1);
this.stats.trimOperations += 1;
await this.redis.hSet(CURSOR_HASH_KEY, cursorField, JSON.stringify(cursor));
}
} }

View file

@ -27,6 +27,7 @@ const makeClickHouse = (
const makeRedis = () => { const makeRedis = () => {
const lists = new Map<string, string[]>(); const lists = new Map<string, string[]>();
const hashes = new Map<string, Map<string, string>>(); const hashes = new Map<string, Map<string, string>>();
let clearTrimCount = 0;
return { return {
isOpen: true, isOpen: true,
@ -41,6 +42,9 @@ const makeRedis = () => {
}, },
async lTrim(key: string, start: number, stop: number) { async lTrim(key: string, start: number, stop: number) {
const next = lists.get(key) ?? []; const next = lists.get(key) ?? [];
if (start > stop) {
clearTrimCount += 1;
}
lists.set(key, start > stop ? [] : next.slice(start, stop + 1)); lists.set(key, start > stop ? [] : next.slice(start, stop + 1));
return "OK"; return "OK";
}, },
@ -52,6 +56,9 @@ const makeRedis = () => {
hash.set(field, value); hash.set(field, value);
hashes.set(key, hash); hashes.set(key, hash);
return 1; return 1;
},
getClearTrimCount() {
return clearTrimCount;
} }
}; };
}; };
@ -64,8 +71,8 @@ describe("LiveStateManager", () => {
LIVE_LIMIT_FLOW: "bad" LIVE_LIMIT_FLOW: "bad"
} as NodeJS.ProcessEnv); } as NodeJS.ProcessEnv);
expect(limits.options).toBe(777); expect(limits.options).toBe(100);
expect(limits.nbbo).toBe(100000); expect(limits.nbbo).toBe(1000);
expect(limits.flow).toBe(500); expect(limits.flow).toBe(500);
expect(limits["equity-quotes"]).toBe(500); expect(limits["equity-quotes"]).toBe(500);
expect(limits.alerts).toBe(300); expect(limits.alerts).toBe(300);
@ -209,11 +216,13 @@ describe("LiveStateManager", () => {
const flushed = await redis.lRange("live:flow", 0, 99); const flushed = await redis.lRange("live:flow", 0, 99);
expect(persisted).toHaveLength(0); expect(persisted).toHaveLength(0);
expect(flushed).toHaveLength(2); expect(flushed).toHaveLength(2);
expect(redis.getClearTrimCount()).toBe(0);
const stats = manager.getStatsSnapshot(); const stats = manager.getStatsSnapshot();
expect(stats.trimOperations).toBeGreaterThan(0); expect(stats.trimOperations).toBeGreaterThan(0);
expect(stats.redisFlushCount).toBeGreaterThan(0); expect(stats.redisFlushCount).toBeGreaterThan(0);
expect(stats.cacheDepthByKey["live:flow"]).toBe(2); expect(stats.cacheDepthByKey["live:flow"]).toBe(2);
expect(stats.redisFlushPayloadBytes).toBeGreaterThan(0);
}); });
it("reorders out-of-order live events without dropping newest-first semantics", async () => { it("reorders out-of-order live events without dropping newest-first semantics", async () => {
@ -1074,6 +1083,33 @@ describe("LiveStateManager", () => {
expect(stats.scopedClickHouseSnapshots).toBe(1); expect(stats.scopedClickHouseSnapshots).toBe(1);
}); });
it("clamps oversized snapshot requests to the server-side channel cap", async () => {
const manager = new LiveStateManager(makeClickHouse(), null);
const now = Date.now();
for (let idx = 0; idx < 120; idx += 1) {
await manager.ingest("options", {
source_ts: now + idx,
ingest_ts: now + idx + 1,
seq: idx + 1,
trace_id: `opt-${idx + 1}`,
ts: now + idx,
option_contract_id: `SPY-2025-01-17-${500 + idx}-C`,
price: 1,
size: 10,
exchange: "X"
});
}
const snapshot = await manager.getSnapshot({
channel: "options",
snapshot_limit: 10_000
});
expect(snapshot.items).toHaveLength(100);
expect(manager.getStatsSnapshot().snapshotItemsByChannel.options).toBe(100);
});
it("keeps backend channel health healthy when a scoped query is quiet", async () => { it("keeps backend channel health healthy when a scoped query is quiet", async () => {
const manager = new LiveStateManager(makeClickHouse(() => []), null); const manager = new LiveStateManager(makeClickHouse(() => []), null);
const now = Date.now(); const now = Date.now();