stabilize live api memory and add an options pipeline explainer #8
12 changed files with 2950 additions and 92 deletions
|
|
@ -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 repository’s 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 repository’s 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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
954
docs/anatomy.html
Normal 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>
|
||||||
222
docs/index.html
222
docs/index.html
|
|
@ -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">
|
||||||
|
|
|
||||||
584
docs/turns/2026-05-22-add-options-anatomy-explainer.html
Normal file
584
docs/turns/2026-05-22-add-options-anatomy-explainer.html
Normal 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 Islandflow’s 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
|
||||||
|
Islandflow’s 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 Islandflow’s 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 repo’s 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>
|
||||||
810
docs/turns/2026-05-22-stabilize-live-api-memory.html
Normal file
810
docs/turns/2026-05-22-stabilize-live-api-memory.html
Normal 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>
|
||||||
93
docs/turns/2026-05-23-reconcile-pr-8.html
Normal file
93
docs/turns/2026-05-23-reconcile-pr-8.html
Normal 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>
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
await this.persistList(write.listKey, write.cursorField, write.items, write.limit, write.cursor);
|
if (write.mode === "rewrite") {
|
||||||
|
try {
|
||||||
|
await this.persistList(write.listKey, write.cursorField, write.items, write.limit, write.cursor);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedisClientClosedError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
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;
|
this.stats.redisFlushCount += 1;
|
||||||
this.stats.redisFlushItems += write.items.length;
|
|
||||||
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue