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-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-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-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}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ REPLAY_LOG_EVERY=1000
|
|||
|
||||
# API live retention (generic channels)
|
||||
LIVE_LIMIT_DEFAULT=1000
|
||||
LIVE_LIMIT_OPTIONS=1000
|
||||
LIVE_LIMIT_OPTIONS=100
|
||||
LIVE_LIMIT_NBBO=1000
|
||||
LIVE_LIMIT_EQUITIES=1000
|
||||
LIVE_LIMIT_EQUITY_QUOTES=500
|
||||
|
|
@ -116,6 +116,7 @@ LIVE_LIMIT_SMART_MONEY=300
|
|||
LIVE_LIMIT_CLASSIFIER_HITS=300
|
||||
LIVE_LIMIT_ALERTS=300
|
||||
LIVE_LIMIT_INFERRED_DARK=300
|
||||
LIVE_LIMIT_NEWS=100
|
||||
LIVE_SCOPED_CACHE_MAX_KEYS=32
|
||||
LIVE_REDIS_FLUSH_INTERVAL_MS=250
|
||||
LIVE_REDIS_FLUSH_MAX_ITEMS=100
|
||||
|
|
|
|||
|
|
@ -72,12 +72,12 @@ const parseBoundedInt = (
|
|||
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(
|
||||
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
|
||||
1200,
|
||||
1,
|
||||
100000
|
||||
2000
|
||||
);
|
||||
const LIVE_OPTIONS_HEAD_LIMIT = 100;
|
||||
const LIVE_HISTORY_SOFT_CAP = parseBoundedInt(
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ REPLAY_LOG_EVERY=1000
|
|||
|
||||
# API live retention
|
||||
LIVE_LIMIT_DEFAULT=1000
|
||||
LIVE_LIMIT_OPTIONS=1000
|
||||
LIVE_LIMIT_OPTIONS=100
|
||||
LIVE_LIMIT_NBBO=1000
|
||||
LIVE_LIMIT_EQUITIES=1000
|
||||
LIVE_LIMIT_EQUITY_QUOTES=500
|
||||
|
|
@ -142,6 +142,7 @@ LIVE_LIMIT_SMART_MONEY=300
|
|||
LIVE_LIMIT_CLASSIFIER_HITS=300
|
||||
LIVE_LIMIT_ALERTS=300
|
||||
LIVE_LIMIT_INFERRED_DARK=300
|
||||
LIVE_LIMIT_NEWS=100
|
||||
LIVE_SCOPED_CACHE_MAX_KEYS=32
|
||||
LIVE_REDIS_FLUSH_INTERVAL_MS=250
|
||||
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>
|
||||
|
||||
<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" />
|
||||
<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-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-root">root <span>2</span></a></nav>
|
||||
<a class="chip" href="#category-root">root <span>3</span></a></nav>
|
||||
</section>
|
||||
|
||||
<section class="groups" id="groups">
|
||||
<section class="group" id="category-turns">
|
||||
<h2>turns <span>28</span></h2>
|
||||
<h2>turns <span>37</span></h2>
|
||||
<ul class="doc-list">
|
||||
|
||||
<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>
|
||||
<li class="doc-item" data-search="turns/2026-05-22-stabilize-live-api-memory.html turns">
|
||||
<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">
|
||||
<span class="tag">html</span>
|
||||
<span>6.7 KB</span>
|
||||
<span>May 19, 2026, 2:59 PM</span>
|
||||
<span>26 KB</span>
|
||||
<span>May 22, 2026, 9:47 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>
|
||||
<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-22-publish-standup-summary-2026-05-21.html">turns/2026-05-22-publish-standup-summary-2026-05-21.html</a>
|
||||
<div class="meta">
|
||||
<span class="tag">html</span>
|
||||
<span>19 KB</span>
|
||||
<span>May 19, 2026, 2:48 PM</span>
|
||||
<span>5.5 KB</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>
|
||||
</li>
|
||||
|
||||
|
|
@ -246,37 +286,7 @@
|
|||
<div class="meta">
|
||||
<span class="tag">html</span>
|
||||
<span>9.8 KB</span>
|
||||
<span>May 19, 2026, 2:48 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>
|
||||
<span>May 20, 2026, 9:26 PM</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
|
@ -286,7 +296,87 @@
|
|||
<div class="meta">
|
||||
<span class="tag">html</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>
|
||||
</li>
|
||||
|
||||
|
|
@ -296,7 +386,7 @@
|
|||
<div class="meta">
|
||||
<span class="tag">html</span>
|
||||
<span>7.0 KB</span>
|
||||
<span>May 18, 2026, 4:54 PM</span>
|
||||
<span>May 20, 2026, 9:26 PM</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
|
@ -513,7 +603,7 @@
|
|||
<div class="meta">
|
||||
<span class="tag">html</span>
|
||||
<span>16 KB</span>
|
||||
<span>May 19, 2026, 2:55 PM</span>
|
||||
<span>May 20, 2026, 9:26 PM</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
|
@ -522,15 +612,35 @@
|
|||
|
||||
|
||||
<section class="group" id="category-general">
|
||||
<h2>general <span>2</span></h2>
|
||||
<h2>general <span>4</span></h2>
|
||||
<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">
|
||||
<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">
|
||||
<span class="tag">html</span>
|
||||
<span>19 KB</span>
|
||||
<span>May 18, 2026, 9:05 AM</span>
|
||||
<span>May 20, 2026, 9:26 PM</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
|
@ -557,7 +667,7 @@
|
|||
<div class="meta">
|
||||
<span class="tag">html</span>
|
||||
<span>3.8 KB</span>
|
||||
<span>May 19, 2026, 2:48 PM</span>
|
||||
<span>May 20, 2026, 9:26 PM</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
|
|
@ -576,9 +686,19 @@
|
|||
|
||||
|
||||
<section class="group" id="category-root">
|
||||
<h2>root <span>2</span></h2>
|
||||
<h2>root <span>3</span></h2>
|
||||
<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">
|
||||
<a class="doc-link" href="./clickhouse-reset-runbook.md">clickhouse-reset-runbook.md</a>
|
||||
<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 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 => {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
|
|
@ -759,6 +788,8 @@ const run = async () => {
|
|||
|
||||
const liveState = new LiveStateManager(clickhouse, redis, resolveLiveStateConfig());
|
||||
await liveState.hydrate();
|
||||
let previousLiveStats = liveState.getStatsSnapshot();
|
||||
let previousMemoryUsage = process.memoryUsage();
|
||||
const warnLiveLag = (
|
||||
channel: keyof typeof HOT_LIVE_REDIS_KEYS,
|
||||
ageMs: number | null | undefined
|
||||
|
|
@ -778,25 +809,52 @@ const run = async () => {
|
|||
const liveStateMetricsTimer = setInterval(() => {
|
||||
const snapshot = liveState.getStatsSnapshot();
|
||||
const hotFeedHealth = liveState.getHotChannelHealth();
|
||||
const subscriptionMetrics = buildLiveSubscriptionMetrics();
|
||||
const memoryUsage = process.memoryUsage();
|
||||
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
|
||||
};
|
||||
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", {
|
||||
...snapshot,
|
||||
hotFeedLagMs,
|
||||
hotFeedHealth,
|
||||
flushDelta,
|
||||
memorySnapshot,
|
||||
liveSubscriptions: subscriptionMetrics,
|
||||
snapshotSourceCounts: {
|
||||
generic_cache_snapshot: snapshot.genericCacheSnapshots,
|
||||
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("equities", hotFeedLagMs.equities);
|
||||
warnLiveLag("flow", hotFeedLagMs.flow);
|
||||
warnLiveLag("nbbo", hotFeedLagMs.nbbo);
|
||||
previousLiveStats = snapshot;
|
||||
previousMemoryUsage = memoryUsage;
|
||||
}, 60000);
|
||||
|
||||
const consumerBindings = [
|
||||
|
|
|
|||
|
|
@ -89,6 +89,20 @@ const DEFAULT_LIVE_LIMITS: GenericLiveLimits = {
|
|||
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_REDIS_FLUSH_INTERVAL_MS = 250;
|
||||
const DEFAULT_REDIS_FLUSH_MAX_ITEMS = 100;
|
||||
|
|
@ -134,7 +148,7 @@ const parseGenericLimit = (
|
|||
const key = GENERIC_LIMIT_ENV_KEYS[channel];
|
||||
const raw = env[key];
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return fallback;
|
||||
return clampConfiguredLimit(channel, fallback);
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
|
|
@ -143,7 +157,7 @@ const parseGenericLimit = (
|
|||
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) {
|
||||
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 => ({
|
||||
limits: resolveGenericLiveLimits(env),
|
||||
limits: clampGenericLimitMap(resolveGenericLiveLimits(env)),
|
||||
scopedCacheMaxKeys: parsePositiveInt(env.LIVE_SCOPED_CACHE_MAX_KEYS, DEFAULT_SCOPED_CACHE_MAX_KEYS),
|
||||
redisFlushIntervalMs: parsePositiveInt(
|
||||
env.LIVE_REDIS_FLUSH_INTERVAL_MS,
|
||||
|
|
@ -559,7 +573,8 @@ const insertNewestFirst = <T>(
|
|||
};
|
||||
};
|
||||
|
||||
type BufferedRedisWrite = {
|
||||
type BufferedRedisRewrite = {
|
||||
mode: "rewrite";
|
||||
listKey: string;
|
||||
cursorField: string;
|
||||
items: unknown[];
|
||||
|
|
@ -568,9 +583,67 @@ type BufferedRedisWrite = {
|
|||
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 =>
|
||||
"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 {
|
||||
private readonly config: LiveStateConfig;
|
||||
private readonly generic: {
|
||||
|
|
@ -586,6 +659,7 @@ export class LiveStateManager {
|
|||
private readonly overlayAccess = new Map<string, number>();
|
||||
private readonly pendingRedisWrites = new Map<string, BufferedRedisWrite>();
|
||||
private readonly redisFlushTimer: ReturnType<typeof setInterval> | null;
|
||||
private redisFlushInFlight: Promise<void> | null = null;
|
||||
private readonly stats = {
|
||||
genericHydrateFromRedis: 0,
|
||||
genericHydrateFromClickHouse: 0,
|
||||
|
|
@ -594,10 +668,12 @@ export class LiveStateManager {
|
|||
trimOperations: 0,
|
||||
redisFlushCount: 0,
|
||||
redisFlushItems: 0,
|
||||
redisFlushPayloadBytes: 0,
|
||||
cacheEvictions: 0,
|
||||
outOfOrderEvents: 0,
|
||||
cacheDepthByKey: new Map<string, number>(),
|
||||
freshnessAgeMsByKey: new Map<string, number>()
|
||||
freshnessAgeMsByKey: new Map<string, number>(),
|
||||
snapshotItemsByChannel: new Map<string, number>()
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -605,14 +681,7 @@ export class LiveStateManager {
|
|||
private readonly redis: RedisLike | null,
|
||||
config: GenericLiveLimits | LiveStateConfig = resolveLiveStateConfig()
|
||||
) {
|
||||
this.config = isLiveStateConfig(config)
|
||||
? config
|
||||
: {
|
||||
limits: config,
|
||||
scopedCacheMaxKeys: DEFAULT_SCOPED_CACHE_MAX_KEYS,
|
||||
redisFlushIntervalMs: DEFAULT_REDIS_FLUSH_INTERVAL_MS,
|
||||
redisFlushMaxItems: DEFAULT_REDIS_FLUSH_MAX_ITEMS
|
||||
};
|
||||
this.config = normalizeLiveStateConfig(config);
|
||||
this.generic = getGenericConfig(this.config.limits);
|
||||
this.redisFlushTimer =
|
||||
this.redis && this.redis.isOpen
|
||||
|
|
@ -630,19 +699,7 @@ export class LiveStateManager {
|
|||
await this.flushRedisWrites();
|
||||
}
|
||||
|
||||
getStatsSnapshot(): {
|
||||
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>;
|
||||
} {
|
||||
getStatsSnapshot(): LiveStateStatsSnapshot {
|
||||
return {
|
||||
genericHydrateFromRedis: this.stats.genericHydrateFromRedis,
|
||||
genericHydrateFromClickHouse: this.stats.genericHydrateFromClickHouse,
|
||||
|
|
@ -651,10 +708,12 @@ export class LiveStateManager {
|
|||
trimOperations: this.stats.trimOperations,
|
||||
redisFlushCount: this.stats.redisFlushCount,
|
||||
redisFlushItems: this.stats.redisFlushItems,
|
||||
redisFlushPayloadBytes: this.stats.redisFlushPayloadBytes,
|
||||
cacheEvictions: this.stats.cacheEvictions,
|
||||
outOfOrderEvents: this.stats.outOfOrderEvents,
|
||||
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> {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -676,11 +751,50 @@ export class LiveStateManager {
|
|||
this.pendingRedisWrites.clear();
|
||||
|
||||
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.redisFlushItems += write.items.length;
|
||||
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,
|
||||
cursorField: string,
|
||||
items: unknown[],
|
||||
|
|
@ -751,7 +870,8 @@ export class LiveStateManager {
|
|||
}
|
||||
|
||||
const existing = this.pendingRedisWrites.get(listKey);
|
||||
const write: BufferedRedisWrite = {
|
||||
const write: BufferedRedisRewrite = {
|
||||
mode: "rewrite",
|
||||
listKey,
|
||||
cursorField,
|
||||
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> {
|
||||
const channels = Object.keys(this.generic) as LiveGenericChannel[];
|
||||
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);
|
||||
items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ ts: entry.ts, seq: entry.seq }));
|
||||
}
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -830,6 +996,7 @@ export class LiveStateManager {
|
|||
const items = (this.genericItems.get("options") ?? [])
|
||||
.filter((entry) => matchesOptionPrintFilters(entry, subscription.filters))
|
||||
.slice(0, limit);
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -844,6 +1011,7 @@ export class LiveStateManager {
|
|||
const items = (this.genericItems.get("flow") ?? [])
|
||||
.filter((entry) => matchesFlowPacketFilters(entry, subscription.filters))
|
||||
.slice(0, limit);
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -865,6 +1033,7 @@ export class LiveStateManager {
|
|||
const backfill = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
|
||||
items = mergeSnapshotBackfill(cached, backfill, limit, config.cursor);
|
||||
}
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -874,6 +1043,7 @@ export class LiveStateManager {
|
|||
}
|
||||
this.stats.genericCacheSnapshots += 1;
|
||||
const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -889,6 +1059,7 @@ export class LiveStateManager {
|
|||
}
|
||||
this.touchAccess(this.candleAccess, key);
|
||||
const items = this.candleItems.get(key) ?? [];
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -904,6 +1075,7 @@ export class LiveStateManager {
|
|||
}
|
||||
this.touchAccess(this.overlayAccess, key);
|
||||
const items = this.overlayItems.get(key) ?? [];
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -916,6 +1088,7 @@ export class LiveStateManager {
|
|||
this.stats.genericCacheSnapshots += 1;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
|
||||
this.recordSnapshotItems(subscription.channel, items.length);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -951,7 +1124,7 @@ export class LiveStateManager {
|
|||
if (nextState.items.length > 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;
|
||||
}
|
||||
case "equity-overlay": {
|
||||
|
|
@ -977,7 +1150,7 @@ export class LiveStateManager {
|
|||
if (nextState.items.length > 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;
|
||||
}
|
||||
default: {
|
||||
|
|
@ -1007,7 +1180,15 @@ export class LiveStateManager {
|
|||
if (nextState.items.length > 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1102,4 +1283,23 @@ export class LiveStateManager {
|
|||
this.stats.cacheDepthByKey.set(listKey, Math.min(items.length, limit));
|
||||
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 lists = new Map<string, string[]>();
|
||||
const hashes = new Map<string, Map<string, string>>();
|
||||
let clearTrimCount = 0;
|
||||
|
||||
return {
|
||||
isOpen: true,
|
||||
|
|
@ -41,6 +42,9 @@ const makeRedis = () => {
|
|||
},
|
||||
async lTrim(key: string, start: number, stop: number) {
|
||||
const next = lists.get(key) ?? [];
|
||||
if (start > stop) {
|
||||
clearTrimCount += 1;
|
||||
}
|
||||
lists.set(key, start > stop ? [] : next.slice(start, stop + 1));
|
||||
return "OK";
|
||||
},
|
||||
|
|
@ -52,6 +56,9 @@ const makeRedis = () => {
|
|||
hash.set(field, value);
|
||||
hashes.set(key, hash);
|
||||
return 1;
|
||||
},
|
||||
getClearTrimCount() {
|
||||
return clearTrimCount;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -64,8 +71,8 @@ describe("LiveStateManager", () => {
|
|||
LIVE_LIMIT_FLOW: "bad"
|
||||
} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(limits.options).toBe(777);
|
||||
expect(limits.nbbo).toBe(100000);
|
||||
expect(limits.options).toBe(100);
|
||||
expect(limits.nbbo).toBe(1000);
|
||||
expect(limits.flow).toBe(500);
|
||||
expect(limits["equity-quotes"]).toBe(500);
|
||||
expect(limits.alerts).toBe(300);
|
||||
|
|
@ -209,11 +216,13 @@ describe("LiveStateManager", () => {
|
|||
const flushed = await redis.lRange("live:flow", 0, 99);
|
||||
expect(persisted).toHaveLength(0);
|
||||
expect(flushed).toHaveLength(2);
|
||||
expect(redis.getClearTrimCount()).toBe(0);
|
||||
|
||||
const stats = manager.getStatsSnapshot();
|
||||
expect(stats.trimOperations).toBeGreaterThan(0);
|
||||
expect(stats.redisFlushCount).toBeGreaterThan(0);
|
||||
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 () => {
|
||||
|
|
@ -1074,6 +1083,33 @@ describe("LiveStateManager", () => {
|
|||
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 () => {
|
||||
const manager = new LiveStateManager(makeClickHouse(() => []), null);
|
||||
const now = Date.now();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue