Merge pull request #39 from dirtydishes/options-cache
make options tape history durable and add signal/raw view controls
This commit is contained in:
commit
f4108b9fe2
13 changed files with 1136 additions and 121 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
{"_type":"issue","id":"islandflow-200","title":"Implement durable options tape history","description":"Implement the plan from docs/plans/2026-05-16-1711-durable-options-tape-history.html: durable ClickHouse-backed options history, signal/all prints view selection, preserved execution context, stale semantics limited to live health, reset runbook, tests, and turn documentation.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T21:21:30Z","created_by":"dirtydishes","updated_at":"2026-05-16T21:26:51Z","started_at":"2026-05-16T21:21:33Z","closed_at":"2026-05-16T21:26:51Z","close_reason":"Implemented durable options tape history, signal/raw view selection, reset runbook, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-k4f","title":"Gate deploy script on docker workspace snapshot sync","description":"Prevent frozen-lockfile build failures during deploy by adding a local preflight in scripts/deploy.ts that runs bun run check:docker-workspace and aborts with a clear sync+commit remediation message when stale.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-15T23:01:44Z","created_by":"dirtydishes","updated_at":"2026-05-15T23:04:11Z","started_at":"2026-05-15T23:01:48Z","closed_at":"2026-05-15T23:04:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-k4f","title":"Gate deploy script on docker workspace snapshot sync","description":"Prevent frozen-lockfile build failures during deploy by adding a local preflight in scripts/deploy.ts that runs bun run check:docker-workspace and aborts with a clear sync+commit remediation message when stale.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-15T23:01:44Z","created_by":"dirtydishes","updated_at":"2026-05-15T23:04:11Z","started_at":"2026-05-15T23:01:48Z","closed_at":"2026-05-15T23:04:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-xll","title":"Fix bun.lock drift causing frozen-lockfile Docker build failures","description":"Docker image builds fail in multiple targets (candles, web, ingest services) because bun install --frozen-lockfile detects lockfile changes. Update workspace lockfile to match manifests and verify frozen install succeeds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-15T22:52:38Z","created_by":"dirtydishes","updated_at":"2026-05-15T22:55:23Z","started_at":"2026-05-15T22:52:40Z","closed_at":"2026-05-15T22:55:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-xll","title":"Fix bun.lock drift causing frozen-lockfile Docker build failures","description":"Docker image builds fail in multiple targets (candles, web, ingest services) because bun install --frozen-lockfile detects lockfile changes. Update workspace lockfile to match manifests and verify frozen install succeeds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-15T22:52:38Z","created_by":"dirtydishes","updated_at":"2026-05-15T22:55:23Z","started_at":"2026-05-15T22:52:40Z","closed_at":"2026-05-15T22:55:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-9nd","title":"Hosted synthetic tape redesign with internal control surface","description":"Implement hosted synthetic market redesign with shared deterministic regime engine, internal JetStream KV control plane, ingest coupling across options and equities, and an internal bottom-right synthetic-control drawer with Next proxy routes. Preserve the six public smart-money categories while adding hidden subtype families, soft coverage accounting, and backend-only admin endpoints.\n","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-14T01:25:02Z","created_by":"dirtydishes","updated_at":"2026-05-14T02:10:03Z","started_at":"2026-05-14T01:25:09Z","closed_at":"2026-05-14T02:10:03Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-9nd","title":"Hosted synthetic tape redesign with internal control surface","description":"Implement hosted synthetic market redesign with shared deterministic regime engine, internal JetStream KV control plane, ingest coupling across options and equities, and an internal bottom-right synthetic-control drawer with Next proxy routes. Preserve the six public smart-money categories while adding hidden subtype families, soft coverage accounting, and backend-only admin endpoints.\n","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-14T01:25:02Z","created_by":"dirtydishes","updated_at":"2026-05-14T02:10:03Z","started_at":"2026-05-14T01:25:09Z","closed_at":"2026-05-14T02:10:03Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
@ -10,6 +11,7 @@
|
||||||
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"islandflow-0sa","title":"Fix live tape auto-hold, history seam, and remove manual pause control","description":"The live tape should automatically hold when the user scrolls away from the top, resume when they return to the top or use Jump to top, and keep older prints available seamlessly beyond the hot window. Manual Pause/Resume control is now redundant and should be removed from live tape panes. This work should also fix the current regression where paused/held tapes still mutate, and align the options tape with a strict 100-row hot head backed by ClickHouse history.","notes":"Implemented live scroll-hold with no live pause button, demand-loaded ClickHouse history, a 100-row options hot head, and cache-first scoped snapshots. Validated with bun test apps/web/app/terminal.test.ts services/api/tests/live.test.ts and bun --cwd=apps/web run build.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T18:12:51Z","created_by":"dirtydishes","updated_at":"2026-05-16T18:23:43Z","started_at":"2026-05-16T18:12:54Z","closed_at":"2026-05-16T18:23:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-2db","title":"Manually remove stale islandflow local-infra containers from VPS","description":"The live VPS still has an older compose project named islandflow created from the repo-root docker-compose.yml. Inspection shows it is separate from the supported islandflow-vps deployment stack and exposes NATS, ClickHouse, and Redis on host ports. Container removal commands currently hang when run as the delta user through Docker, so cleanup likely needs a focused maintenance window and possibly host-level intervention or a Docker daemon restart.","notes":"The duplicate islandflow compose project on the VPS was confirmed live during inspection. Nginx Proxy Manager routes public traffic only to islandflow-vps web/api by Docker name, so the stale islandflow project appears to be stray local-infra state rather than part of the supported production path. Attempts to remove the stale containers with docker compose down and docker rm -f as the delta user hung and timed out, so manual cleanup likely needs a maintenance window and possibly Docker daemon intervention.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:27:27Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:28:59Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-2db","title":"Manually remove stale islandflow local-infra containers from VPS","description":"The live VPS still has an older compose project named islandflow created from the repo-root docker-compose.yml. Inspection shows it is separate from the supported islandflow-vps deployment stack and exposes NATS, ClickHouse, and Redis on host ports. Container removal commands currently hang when run as the delta user through Docker, so cleanup likely needs a focused maintenance window and possibly host-level intervention or a Docker daemon restart.","notes":"The duplicate islandflow compose project on the VPS was confirmed live during inspection. Nginx Proxy Manager routes public traffic only to islandflow-vps web/api by Docker name, so the stale islandflow project appears to be stray local-infra state rather than part of the supported production path. Attempts to remove the stale containers with docker compose down and docker rm -f as the delta user hung and timed out, so manual cleanup likely needs a maintenance window and possibly Docker daemon intervention.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:27:27Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:28:59Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-c87","title":"Clean up duplicate Islandflow Docker infra on VPS","description":"The live VPS is currently running both the production-style islandflow-vps Docker stack and an older root-level islandflow infra stack that publishes NATS, ClickHouse, and Redis on host ports. Investigate whether the older stack is unused, remove it safely if so, and update docs/deploy guidance so the server topology is clearer.","notes":"Inspected the live VPS and confirmed the duplicate compose project: islandflow-vps is the supported deployment stack, while a separate islandflow project from the repo-root docker-compose.yml still runs exposed NATS/ClickHouse/Redis containers. Verified Nginx Proxy Manager routes only to islandflow-vps web/api by Docker name. Attempted cleanup via docker compose down and docker rm -f on the stale islandflow containers, but those commands hung for the delta user and timed out. Added repo guardrails and docs so deploy warns when the duplicate project exists, and opened islandflow-2db for manual host-level cleanup during a maintenance window.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:16:05Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:28:07Z","started_at":"2026-05-16T01:16:09Z","closed_at":"2026-05-16T01:28:07Z","close_reason":"Completed the repo-side investigation and guardrails. Actual server-side container removal is blocked by hanging Docker operations and is tracked separately in islandflow-2db for a maintenance window.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-c87","title":"Clean up duplicate Islandflow Docker infra on VPS","description":"The live VPS is currently running both the production-style islandflow-vps Docker stack and an older root-level islandflow infra stack that publishes NATS, ClickHouse, and Redis on host ports. Investigate whether the older stack is unused, remove it safely if so, and update docs/deploy guidance so the server topology is clearer.","notes":"Inspected the live VPS and confirmed the duplicate compose project: islandflow-vps is the supported deployment stack, while a separate islandflow project from the repo-root docker-compose.yml still runs exposed NATS/ClickHouse/Redis containers. Verified Nginx Proxy Manager routes only to islandflow-vps web/api by Docker name. Attempted cleanup via docker compose down and docker rm -f on the stale islandflow containers, but those commands hung for the delta user and timed out. Added repo guardrails and docs so deploy warns when the duplicate project exists, and opened islandflow-2db for manual host-level cleanup during a maintenance window.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:16:05Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:28:07Z","started_at":"2026-05-16T01:16:09Z","closed_at":"2026-05-16T01:28:07Z","close_reason":"Completed the repo-side investigation and guardrails. Actual server-side container removal is blocked by hanging Docker operations and is tracked separately in islandflow-2db for a maintenance window.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-4gj","title":"Clarify Docker-first deploy workflow and mark native runtime experimental","description":"After inspecting the live VPS, native deployment is not ready for routine use: Nginx Proxy Manager routes to Docker container names, Bun is not installed on the host, sudo systemctl is not passwordless, and no Islandflow units exist. Update deploy messaging and docs so Docker remains the clearly recommended deployment path and native runtime is labeled experimental/future-facing with server prerequisites called out.","notes":"Updated deploy messaging and docs after live VPS inspection. scripts/deploy.ts now marks Docker as the default and recommended runtime, labels native as experimental, switches native systemctl default to sudo -n systemctl, and prints explicit native precheck failures for missing Bun/systemctl access/units. Updated README.md, deployment/docker/README.md, and deployment/native/README.md to reflect the current Docker + Nginx Proxy Manager topology. Validation: ./deploy --help, ./deploy main --runtime native --no-build (fails fast with Bun-missing message), bun run check:docker-workspace.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:10:11Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:12:39Z","started_at":"2026-05-16T01:10:14Z","closed_at":"2026-05-16T01:12:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-4gj","title":"Clarify Docker-first deploy workflow and mark native runtime experimental","description":"After inspecting the live VPS, native deployment is not ready for routine use: Nginx Proxy Manager routes to Docker container names, Bun is not installed on the host, sudo systemctl is not passwordless, and no Islandflow units exist. Update deploy messaging and docs so Docker remains the clearly recommended deployment path and native runtime is labeled experimental/future-facing with server prerequisites called out.","notes":"Updated deploy messaging and docs after live VPS inspection. scripts/deploy.ts now marks Docker as the default and recommended runtime, labels native as experimental, switches native systemctl default to sudo -n systemctl, and prints explicit native precheck failures for missing Bun/systemctl access/units. Updated README.md, deployment/docker/README.md, and deployment/native/README.md to reflect the current Docker + Nginx Proxy Manager topology. Validation: ./deploy --help, ./deploy main --runtime native --no-build (fails fast with Bun-missing message), bun run check:docker-workspace.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T01:10:11Z","created_by":"dirtydishes","updated_at":"2026-05-16T01:12:39Z","started_at":"2026-05-16T01:10:14Z","closed_at":"2026-05-16T01:12:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
26
.codex/hooks.json
Normal file
26
.codex/hooks.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bd prime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bd prime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,6 +117,7 @@ Each turn document must include these sections:
|
||||||
2. **Changes Made**
|
2. **Changes Made**
|
||||||
3. **Context**
|
3. **Context**
|
||||||
4. **Important Implementation Details**
|
4. **Important Implementation Details**
|
||||||
|
5. **Expected Impact for End-Users**
|
||||||
5. **Validation**
|
5. **Validation**
|
||||||
6. **Issues, Limitations, and Mitigations**
|
6. **Issues, Limitations, and Mitigations**
|
||||||
7. **Follow-up Work**
|
7. **Follow-up Work**
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,13 @@ h3 {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-filter-section-copy {
|
||||||
|
margin: -2px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.flow-filter-checkbox-grid,
|
.flow-filter-checkbox-grid,
|
||||||
.flow-filter-chip-grid {
|
.flow-filter-chip-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -617,6 +624,10 @@ h3 {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-filter-chip-grid-two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.flow-filter-check {
|
.flow-filter-check {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
getEffectiveOptionPrintFilters,
|
getEffectiveOptionPrintFilters,
|
||||||
getAlertWindowAnchorTs,
|
getAlertWindowAnchorTs,
|
||||||
getHotChannelFeedStatus,
|
getHotChannelFeedStatus,
|
||||||
getScopedLiveAutoHydrationChannels,
|
|
||||||
getLiveHistoryRetentionCap,
|
getLiveHistoryRetentionCap,
|
||||||
getOptionTableSnapshot,
|
getOptionTableSnapshot,
|
||||||
getOptionScope,
|
getOptionScope,
|
||||||
|
|
@ -164,6 +163,7 @@ describe("live manifest", () => {
|
||||||
|
|
||||||
expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]);
|
expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]);
|
||||||
expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C");
|
expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C");
|
||||||
|
expect(optionsSubscription?.snapshot_limit).toBe(100);
|
||||||
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
|
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -297,6 +297,24 @@ describe("contract-focused option helpers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes the selected options view in tape query params", () => {
|
||||||
|
expect(
|
||||||
|
buildOptionTapeQueryParams(
|
||||||
|
{
|
||||||
|
...buildDefaultFlowFilters(),
|
||||||
|
view: "raw",
|
||||||
|
securityTypes: undefined,
|
||||||
|
nbboSides: undefined,
|
||||||
|
optionTypes: undefined
|
||||||
|
},
|
||||||
|
{ underlying_ids: ["AAPL"] }
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
view: "raw",
|
||||||
|
underlying_ids: "AAPL"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the focus seed until the matching scoped subscription has loaded it", () => {
|
it("keeps the focus seed until the matching scoped subscription has loaded it", () => {
|
||||||
const seedItem = makeOptionPrint({
|
const seedItem = makeOptionPrint({
|
||||||
trace_id: "focused-seed",
|
trace_id: "focused-seed",
|
||||||
|
|
@ -635,52 +653,20 @@ describe("live tape history helpers", () => {
|
||||||
expect(next.map((item) => item.trace_id)).toEqual(["existing", "older-1"]);
|
expect(next.map((item) => item.trace_id)).toEqual(["existing", "older-1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps scoped option and equity history on the normal retention cap", () => {
|
it("keeps option and equity history effectively unbounded while scrolling", () => {
|
||||||
expect(
|
expect(
|
||||||
getLiveHistoryRetentionCap({
|
getLiveHistoryRetentionCap({
|
||||||
channel: "options",
|
channel: "options",
|
||||||
underlying_ids: ["AAPL"],
|
underlying_ids: ["AAPL"],
|
||||||
option_contract_id: "AAPL-2025-01-17-200-C"
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
} as any)
|
} as any)
|
||||||
).toBeGreaterThan(0);
|
).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
getLiveHistoryRetentionCap({
|
getLiveHistoryRetentionCap({
|
||||||
channel: "equities",
|
channel: "equities",
|
||||||
underlying_ids: ["AAPL"]
|
underlying_ids: ["AAPL"]
|
||||||
} as any)
|
} as any)
|
||||||
).toBeGreaterThan(0);
|
).toBe(0);
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps auto-hydrating scoped live history while next_before exists", () => {
|
|
||||||
const manifest = getLiveManifest(
|
|
||||||
"/tape",
|
|
||||||
"AAPL",
|
|
||||||
60000,
|
|
||||||
buildDefaultFlowFilters(),
|
|
||||||
{
|
|
||||||
underlying_ids: ["AAPL"],
|
|
||||||
option_contract_id: "AAPL-2025-01-17-200-C"
|
|
||||||
},
|
|
||||||
{ underlying_ids: ["AAPL"] }
|
|
||||||
);
|
|
||||||
const historyCursors = Object.fromEntries(
|
|
||||||
manifest.map((subscription) => [getLiveSubscriptionKey(subscription), { ts: 1, seq: 1 }])
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, historyCursors, {})
|
|
||||||
).toEqual(["options", "equities"]);
|
|
||||||
expect(
|
|
||||||
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, historyCursors, {
|
|
||||||
[getLiveSubscriptionKey(manifest.find((subscription) => subscription.channel === "options")!)]: true
|
|
||||||
})
|
|
||||||
).toEqual(["equities"]);
|
|
||||||
expect(
|
|
||||||
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, {
|
|
||||||
...historyCursors,
|
|
||||||
[getLiveSubscriptionKey(manifest.find((subscription) => subscription.channel === "equities")!)]: null
|
|
||||||
}, {})
|
|
||||||
).toEqual(["options"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores the same anchor key after live insertions at the top", () => {
|
it("restores the same anchor key after live insertions at the top", () => {
|
||||||
|
|
@ -811,6 +797,7 @@ describe("flow filter popup helpers", () => {
|
||||||
|
|
||||||
expect(countActiveFlowFilterGroups(defaults)).toBe(0);
|
expect(countActiveFlowFilterGroups(defaults)).toBe(0);
|
||||||
expect(countActiveFlowFilterGroups(next)).toBe(3);
|
expect(countActiveFlowFilterGroups(next)).toBe(3);
|
||||||
|
expect(countActiveFlowFilterGroups({ ...defaults, view: "raw" })).toBe(1);
|
||||||
expect(buildDefaultFlowFilters()).toEqual(defaults);
|
expect(buildDefaultFlowFilters()).toEqual(defaults);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -864,9 +851,15 @@ describe("signals helpers", () => {
|
||||||
expect(getAlertWindowAnchorTs([], 42)).toBe(42);
|
expect(getAlertWindowAnchorTs([], 42)).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns connected/stale live status labels without live wording", () => {
|
it("returns connected/held/stale live status labels without live wording", () => {
|
||||||
expect(statusLabel("connected", false, "live")).toBe("Connected");
|
expect(statusLabel("connected", false, "live")).toBe("Connected");
|
||||||
|
expect(statusLabel("connected", true, "live")).toBe("Held");
|
||||||
expect(statusLabel("stale", false, "live")).toBe("Feed behind");
|
expect(statusLabel("stale", false, "live")).toBe("Feed behind");
|
||||||
|
expect(statusLabel("stale", true, "live")).toBe("Feed behind");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps replay pause wording on replay tapes", () => {
|
||||||
|
expect(statusLabel("connected", true, "replay")).toBe("Paused");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats healthy scoped channels as connected even when no matching rows are visible", () => {
|
it("treats healthy scoped channels as connected even when no matching rows are visible", () => {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import type {
|
||||||
LiveHotChannelHealthMap,
|
LiveHotChannelHealthMap,
|
||||||
LiveSubscription,
|
LiveSubscription,
|
||||||
OptionFlowFilters,
|
OptionFlowFilters,
|
||||||
|
OptionFlowView,
|
||||||
OptionNbboSide,
|
OptionNbboSide,
|
||||||
OptionSecurityType,
|
OptionSecurityType,
|
||||||
OptionType,
|
OptionType,
|
||||||
|
|
@ -77,6 +78,7 @@ const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
|
||||||
1,
|
1,
|
||||||
100000
|
100000
|
||||||
);
|
);
|
||||||
|
const LIVE_OPTIONS_HEAD_LIMIT = 100;
|
||||||
const LIVE_HISTORY_SOFT_CAP = parseBoundedInt(
|
const LIVE_HISTORY_SOFT_CAP = parseBoundedInt(
|
||||||
process.env.NEXT_PUBLIC_LIVE_HISTORY_SOFT_CAP,
|
process.env.NEXT_PUBLIC_LIVE_HISTORY_SOFT_CAP,
|
||||||
5000,
|
5000,
|
||||||
|
|
@ -846,42 +848,12 @@ export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): numb
|
||||||
switch (subscription.channel) {
|
switch (subscription.channel) {
|
||||||
case "options":
|
case "options":
|
||||||
case "equities":
|
case "equities":
|
||||||
return LIVE_HISTORY_SOFT_CAP;
|
return 0;
|
||||||
default:
|
default:
|
||||||
return LIVE_HISTORY_SOFT_CAP;
|
return LIVE_HISTORY_SOFT_CAP;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getScopedLiveAutoHydrationChannels = (
|
|
||||||
enabled: boolean,
|
|
||||||
pathname: string,
|
|
||||||
manifest: LiveSubscription[],
|
|
||||||
historyCursors: Partial<Record<string, Cursor | null>>,
|
|
||||||
historyLoading: Partial<Record<string, boolean>>
|
|
||||||
): Array<Extract<LiveSubscription["channel"], "options" | "equities">> => {
|
|
||||||
if (!enabled || pathname !== "/tape") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const channels: Array<Extract<LiveSubscription["channel"], "options" | "equities">> = [];
|
|
||||||
for (const subscription of manifest) {
|
|
||||||
const scoped =
|
|
||||||
(subscription.channel === "options" &&
|
|
||||||
(subscription.underlying_ids?.length || subscription.option_contract_id)) ||
|
|
||||||
(subscription.channel === "equities" && subscription.underlying_ids?.length);
|
|
||||||
if (!scoped) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = getLiveSubscriptionKey(subscription);
|
|
||||||
if (historyCursors[key] && !historyLoading[key]) {
|
|
||||||
channels.push(subscription.channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLiveFeedStatus = (
|
export const getLiveFeedStatus = (
|
||||||
sourceStatus: WsStatus,
|
sourceStatus: WsStatus,
|
||||||
freshestTs: number | null,
|
freshestTs: number | null,
|
||||||
|
|
@ -1450,6 +1422,9 @@ export const countActiveFlowFilterGroups = (filters: OptionFlowFilters): number
|
||||||
if ((filters.minNotional ?? undefined) !== (defaults.minNotional ?? undefined)) {
|
if ((filters.minNotional ?? undefined) !== (defaults.minNotional ?? undefined)) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
if ((filters.view ?? defaults.view) !== defaults.view) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
@ -2027,8 +2002,11 @@ export const prunePinnedEntries = <T,>(
|
||||||
|
|
||||||
export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => {
|
export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): string => {
|
||||||
if (paused) {
|
if (paused) {
|
||||||
|
if (mode === "replay") {
|
||||||
return "Paused";
|
return "Paused";
|
||||||
}
|
}
|
||||||
|
return status === "connected" ? "Held" : statusLabel(status, false, mode);
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
return status === "disconnected" ? "Replay Down" : "Replay";
|
return status === "disconnected" ? "Replay Down" : "Replay";
|
||||||
|
|
@ -2512,22 +2490,20 @@ type PausableTapeViewConfig<T extends SortableItem & { seq: number }> = {
|
||||||
const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
config: PausableTapeViewConfig<T>
|
config: PausableTapeViewConfig<T>
|
||||||
): TapeState<T> => {
|
): TapeState<T> => {
|
||||||
const [paused, setPaused] = useState(false);
|
|
||||||
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
|
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
|
||||||
|
const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
setPaused(false);
|
|
||||||
setData(EMPTY_PAUSABLE_TAPE);
|
setData(EMPTY_PAUSABLE_TAPE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const holdForScroll = config.shouldHold ? config.shouldHold() : false;
|
|
||||||
setData((current) => {
|
setData((current) => {
|
||||||
const next = reducePausableTapeData(
|
const next = reducePausableTapeData(
|
||||||
current,
|
current,
|
||||||
config.sourceItems,
|
config.sourceItems,
|
||||||
paused || holdForScroll,
|
holdForScroll,
|
||||||
config.retentionLimit ?? LIVE_HOT_WINDOW
|
config.retentionLimit ?? LIVE_HOT_WINDOW
|
||||||
);
|
);
|
||||||
if (next === current) {
|
if (next === current) {
|
||||||
|
|
@ -2535,7 +2511,7 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const unseenCount = next.seenKeys.size - current.seenKeys.size;
|
const unseenCount = next.seenKeys.size - current.seenKeys.size;
|
||||||
if (!paused && unseenCount > 0) {
|
if (unseenCount > 0) {
|
||||||
config.onNewItems?.(unseenCount);
|
config.onNewItems?.(unseenCount);
|
||||||
config.captureScroll?.();
|
config.captureScroll?.();
|
||||||
}
|
}
|
||||||
|
|
@ -2548,17 +2524,11 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
config.onNewItems,
|
config.onNewItems,
|
||||||
config.captureScroll,
|
config.captureScroll,
|
||||||
config.retentionLimit,
|
config.retentionLimit,
|
||||||
config.shouldHold,
|
holdForScroll
|
||||||
paused
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config.enabled || paused) {
|
if (!config.enabled || holdForScroll) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const holdForScroll = config.shouldHold ? config.shouldHold() : false;
|
|
||||||
if (holdForScroll) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2581,14 +2551,9 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
config.onNewItems,
|
config.onNewItems,
|
||||||
config.retentionLimit,
|
config.retentionLimit,
|
||||||
config.resumeSignal,
|
config.resumeSignal,
|
||||||
config.shouldHold,
|
holdForScroll
|
||||||
paused
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const togglePause = useCallback(() => {
|
|
||||||
setPaused((current) => !current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const status = config.enabled ? config.sourceStatus : "disconnected";
|
const status = config.enabled ? config.sourceStatus : "disconnected";
|
||||||
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
||||||
const historyItems = config.historyTail ?? [];
|
const historyItems = config.historyTail ?? [];
|
||||||
|
|
@ -2602,9 +2567,9 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
lastUpdate: projected.lastUpdate,
|
lastUpdate: projected.lastUpdate,
|
||||||
replayTime: null,
|
replayTime: null,
|
||||||
replayComplete: false,
|
replayComplete: false,
|
||||||
paused,
|
paused: holdForScroll,
|
||||||
dropped: data.dropped,
|
dropped: data.dropped,
|
||||||
togglePause
|
togglePause: () => {}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3052,7 +3017,7 @@ export const getLiveManifest = (
|
||||||
? undefined
|
? undefined
|
||||||
: optionPrintFilters ?? flowFilters,
|
: optionPrintFilters ?? flowFilters,
|
||||||
...optionScope,
|
...optionScope,
|
||||||
snapshot_limit: LIVE_HOT_WINDOW_OPTIONS
|
snapshot_limit: LIVE_OPTIONS_HEAD_LIMIT
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (features.nbbo) {
|
if (features.nbbo) {
|
||||||
|
|
@ -3337,7 +3302,7 @@ const useLiveSession = (
|
||||||
|
|
||||||
switch (subscription.channel) {
|
switch (subscription.channel) {
|
||||||
case "options":
|
case "options":
|
||||||
mergeItems(setOptions, optionsRef, items as OptionPrint[], LIVE_HOT_WINDOW_OPTIONS, {
|
mergeItems(setOptions, optionsRef, items as OptionPrint[], LIVE_OPTIONS_HEAD_LIMIT, {
|
||||||
setter: setOptionsHistory,
|
setter: setOptionsHistory,
|
||||||
ref: optionsHistoryRef,
|
ref: optionsHistoryRef,
|
||||||
cap: getLiveHistoryRetentionCap(subscription)
|
cap: getLiveHistoryRetentionCap(subscription)
|
||||||
|
|
@ -3708,18 +3673,6 @@ const useLiveSession = (
|
||||||
[enabled, manifest, historyCursors, historyLoading]
|
[enabled, manifest, historyCursors, historyLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const channel of getScopedLiveAutoHydrationChannels(
|
|
||||||
enabled,
|
|
||||||
pathname,
|
|
||||||
manifest,
|
|
||||||
historyCursors,
|
|
||||||
historyLoading
|
|
||||||
)) {
|
|
||||||
void loadOlder(channel);
|
|
||||||
}
|
|
||||||
}, [enabled, pathname, manifest, historyCursors, historyLoading, loadOlder]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
connectedAt,
|
connectedAt,
|
||||||
|
|
@ -3794,6 +3747,7 @@ const TapeStatus = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
type TapeControlsProps = {
|
type TapeControlsProps = {
|
||||||
|
mode: TapeMode;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
onTogglePause: () => void;
|
onTogglePause: () => void;
|
||||||
isAtTop: boolean;
|
isAtTop: boolean;
|
||||||
|
|
@ -3801,13 +3755,15 @@ type TapeControlsProps = {
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TapeControls = ({ paused, onTogglePause, isAtTop, missed, onJump }: TapeControlsProps) => {
|
const TapeControls = ({ mode, paused, onTogglePause, isAtTop, missed, onJump }: TapeControlsProps) => {
|
||||||
const active = !isAtTop && missed > 0;
|
const active = !isAtTop && missed > 0;
|
||||||
return (
|
return (
|
||||||
<div className={`tape-controls${active ? " tape-controls-active" : ""}`}>
|
<div className={`tape-controls${active ? " tape-controls-active" : ""}`}>
|
||||||
|
{mode === "replay" ? (
|
||||||
<button className="pause-button" type="button" onClick={onTogglePause}>
|
<button className="pause-button" type="button" onClick={onTogglePause}>
|
||||||
{paused ? "Resume" : "Pause"}
|
{paused ? "Resume" : "Pause"}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
<button className="jump-button" type="button" onClick={onJump} disabled={isAtTop}>
|
<button className="jump-button" type="button" onClick={onJump} disabled={isAtTop}>
|
||||||
Jump to top
|
Jump to top
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -5373,7 +5329,7 @@ const useTerminalState = () => {
|
||||||
sourceItems: liveSession.options,
|
sourceItems: liveSession.options,
|
||||||
historyTail: liveSession.optionsHistory,
|
historyTail: liveSession.optionsHistory,
|
||||||
lastUpdate: liveSession.lastUpdate,
|
lastUpdate: liveSession.lastUpdate,
|
||||||
retentionLimit: LIVE_HOT_WINDOW_OPTIONS,
|
retentionLimit: LIVE_OPTIONS_HEAD_LIMIT,
|
||||||
captureScroll: optionsAnchor.capture,
|
captureScroll: optionsAnchor.capture,
|
||||||
onNewItems: optionsScroll.onNewItems,
|
onNewItems: optionsScroll.onNewItems,
|
||||||
shouldHold: () => !optionsScroll.isAtTopRef.current,
|
shouldHold: () => !optionsScroll.isAtTopRef.current,
|
||||||
|
|
@ -6925,6 +6881,17 @@ export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps)
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyView = (view: OptionFlowView) => {
|
||||||
|
onChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
view,
|
||||||
|
securityTypes: view === "raw" ? undefined : prev.securityTypes ?? DEFAULT_FLOW_SECURITY_TYPES,
|
||||||
|
nbboSides: view === "raw" ? undefined : prev.nbboSides,
|
||||||
|
optionTypes: view === "raw" ? undefined : prev.optionTypes,
|
||||||
|
minNotional: view === "raw" ? undefined : prev.minNotional
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -6989,6 +6956,27 @@ export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flow-filter-popover-body">
|
<div className="flow-filter-popover-body">
|
||||||
|
<FlowFilterSection title="Options View">
|
||||||
|
<div className="flow-filter-chip-grid flow-filter-chip-grid-two">
|
||||||
|
{[
|
||||||
|
{ label: "Signal", value: "signal" as const },
|
||||||
|
{ label: "All prints", value: "raw" as const }
|
||||||
|
].map((preset) => (
|
||||||
|
<button
|
||||||
|
className={`filter-chip ${filters.view === preset.value ? "is-active" : ""}`}
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyView(preset.value)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="flow-filter-section-copy">
|
||||||
|
Signal keeps classifier-ready prints. All prints includes raw option tape rows.
|
||||||
|
</p>
|
||||||
|
</FlowFilterSection>
|
||||||
|
|
||||||
<FlowFilterSection title="Security">
|
<FlowFilterSection title="Security">
|
||||||
<div className="flow-filter-checkbox-grid">
|
<div className="flow-filter-checkbox-grid">
|
||||||
{(["stock", "etf"] as OptionSecurityType[]).map((value) => (
|
{(["stock", "etf"] as OptionSecurityType[]).map((value) => (
|
||||||
|
|
@ -7141,6 +7129,7 @@ const OptionsPane = memo(({ state, limit }: OptionsPaneProps) => {
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.options.paused}
|
paused={state.options.paused}
|
||||||
onTogglePause={state.options.togglePause}
|
onTogglePause={state.options.togglePause}
|
||||||
isAtTop={state.optionsScroll.isAtTop}
|
isAtTop={state.optionsScroll.isAtTop}
|
||||||
|
|
@ -7329,6 +7318,7 @@ const EquitiesPane = memo(({ state, limit }: EquitiesPaneProps) => {
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.equities.paused}
|
paused={state.equities.paused}
|
||||||
onTogglePause={state.equities.togglePause}
|
onTogglePause={state.equities.togglePause}
|
||||||
isAtTop={state.equitiesScroll.isAtTop}
|
isAtTop={state.equitiesScroll.isAtTop}
|
||||||
|
|
@ -7432,6 +7422,7 @@ const FlowPane = memo(({ state, limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.flow.paused}
|
paused={state.flow.paused}
|
||||||
onTogglePause={state.flow.togglePause}
|
onTogglePause={state.flow.togglePause}
|
||||||
isAtTop={state.flowScroll.isAtTop}
|
isAtTop={state.flowScroll.isAtTop}
|
||||||
|
|
@ -7581,6 +7572,7 @@ const AlertsPane = memo(({ state, limit, withStrip = false, className }: AlertsP
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.alerts.paused}
|
paused={state.alerts.paused}
|
||||||
onTogglePause={state.alerts.togglePause}
|
onTogglePause={state.alerts.togglePause}
|
||||||
isAtTop={state.alertsScroll.isAtTop}
|
isAtTop={state.alertsScroll.isAtTop}
|
||||||
|
|
@ -7695,6 +7687,7 @@ const ClassifierPane = memo(({ state, limit, className }: ClassifierPaneProps) =
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.smartMoney.paused}
|
paused={state.smartMoney.paused}
|
||||||
onTogglePause={state.smartMoney.togglePause}
|
onTogglePause={state.smartMoney.togglePause}
|
||||||
isAtTop={state.classifierScroll.isAtTop}
|
isAtTop={state.classifierScroll.isAtTop}
|
||||||
|
|
@ -7818,6 +7811,7 @@ const DarkPane = memo(({ state, limit, className }: DarkPaneProps) => {
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<TapeControls
|
<TapeControls
|
||||||
|
mode={state.mode}
|
||||||
paused={state.inferredDark.paused}
|
paused={state.inferredDark.paused}
|
||||||
onTogglePause={state.inferredDark.togglePause}
|
onTogglePause={state.inferredDark.togglePause}
|
||||||
isAtTop={state.darkScroll.isAtTop}
|
isAtTop={state.darkScroll.isAtTop}
|
||||||
|
|
|
||||||
57
docs/clickhouse-reset-runbook.md
Normal file
57
docs/clickhouse-reset-runbook.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# ClickHouse Reset Runbook
|
||||||
|
|
||||||
|
This runbook is for deliberately wiping durable market-data history from ClickHouse in local development or on the VPS. It is destructive. Do not run these commands from application startup, deployment hooks, or unattended scripts.
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
Use this only when an operator has decided that existing option, equity, flow, and derived-event history should be discarded and rebuilt from fresh ingest.
|
||||||
|
|
||||||
|
Before running a reset:
|
||||||
|
|
||||||
|
- Confirm the target environment: local Docker or VPS Docker.
|
||||||
|
- Confirm there is no active analysis depending on the existing history.
|
||||||
|
- Take a backup if the data may be needed later.
|
||||||
|
- Stop ingest and API services so new writes do not race the reset.
|
||||||
|
|
||||||
|
## Local Docker Reset
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev:infra
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "SHOW TABLES"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS option_prints"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS option_nbbo"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS equity_prints"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS equity_quotes"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS equity_print_joins"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS flow_packets"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS smart_money_events"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS classifier_hits"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS alerts"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "TRUNCATE TABLE IF EXISTS inferred_dark_events"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the local compose project uses `deployment/docker/docker-compose.yml`, run the same commands with `docker compose -f deployment/docker/docker-compose.yml exec clickhouse ...`.
|
||||||
|
|
||||||
|
## VPS Docker Reset
|
||||||
|
|
||||||
|
On the VPS, first identify the active compose project and ClickHouse service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||||
|
docker compose -f deployment/docker/docker-compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Then stop writers and run the same `TRUNCATE TABLE IF EXISTS ...` commands against the active ClickHouse container. Prefer `docker compose exec clickhouse clickhouse-client --query "<query>"` when the compose project is healthy; otherwise use `docker exec <clickhouse-container> clickhouse-client --query "<query>"`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After the reset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "SELECT count() FROM option_prints"
|
||||||
|
docker compose exec clickhouse clickhouse-client --query "SELECT count() FROM flow_packets"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart ingest/API services through the normal dev or deployment path. The options tape should repopulate its 100-row hot head from new signal prints, and older rows should appear only after the scroll gate asks `/history/options` for ClickHouse-backed history.
|
||||||
363
docs/plans/2026-05-16-1711-durable-options-tape-history.html
Normal file
363
docs/plans/2026-05-16-1711-durable-options-tape-history.html
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Plan: Durable Options Tape History</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #06080b;
|
||||||
|
--surface: #0b1016;
|
||||||
|
--panel: #111820;
|
||||||
|
--panel-2: #0d141b;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-strong: rgba(245, 166, 35, 0.32);
|
||||||
|
--text: #e6edf4;
|
||||||
|
--muted: #90a0b2;
|
||||||
|
--faint: #6e7b8c;
|
||||||
|
--accent: #f5a623;
|
||||||
|
--accent-soft: rgba(245, 166, 35, 0.14);
|
||||||
|
--good: #25c17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(17, 24, 32, 0.86), rgba(6, 8, 11, 0.98) 340px),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.55 "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font: 700 0.78rem/1.2 "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
font-size: 2.1rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--muted);
|
||||||
|
font: 700 0.72rem/1.2 "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 32, 0.92), rgba(13, 20, 27, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
section.highlight {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: linear-gradient(180deg, rgba(245, 166, 35, 0.12), rgba(17, 24, 32, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font: 700 0.86rem/1.2 "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.12rem 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 1px solid rgba(37, 193, 122, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(37, 193, 122, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout p {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
main {
|
||||||
|
padding: 28px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">Plan Document</p>
|
||||||
|
<h1>Durable Options Tape History</h1>
|
||||||
|
<p class="summary">
|
||||||
|
Make the options tape a signal-first live instrument with scroll-gated historical depth: keep the hot cache at
|
||||||
|
100 option prints, load older rows from ClickHouse only at the scroll gate, preserve execution context, and
|
||||||
|
render ClickHouse-backed rows exactly like any other valid flow row.
|
||||||
|
</p>
|
||||||
|
<div class="meta" aria-label="Plan metadata">
|
||||||
|
<span class="chip">Created 2026-05-16 17:11</span>
|
||||||
|
<span class="chip">Mode: Plan</span>
|
||||||
|
<span class="chip">Surface: Options Tape</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="highlight">
|
||||||
|
<h2>Plan Summary</h2>
|
||||||
|
<p>
|
||||||
|
Treat <strong>stale</strong> strictly as feed health, not as historical-row quality. The user should be able to
|
||||||
|
analyze current live prints and earlier flow in one continuous tape, with no visual distinction between hot-cache
|
||||||
|
rows and ClickHouse-backed rows.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Goals</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Keep the options tape scrolling infinitely from the user's perspective.</li>
|
||||||
|
<li>Hold only the 100 newest option prints in the hot live cache.</li>
|
||||||
|
<li>Use ClickHouse as the durable source for older rows once the scroll gate requests history.</li>
|
||||||
|
<li>Store all option-print data, including synthetic prints and execution context such as NBBO, spot, and IV.</li>
|
||||||
|
<li>Surface historical flow as real analyzable flow, not as stale, old, or degraded data.</li>
|
||||||
|
<li>Keep the default tape view signal-first while exposing all/raw prints from the existing Filter menu.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Proposed Changes</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Keep <code>islandflow-0sa</code>'s useful pieces: scroll-hold behavior, <code>LIVE_OPTIONS_HEAD_LIMIT = 100</code>,
|
||||||
|
lazy <code>/history/options</code> loading, cache-first scoped snapshots, and preserved execution-context columns.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Stop tests and UI copy from asserting that valid rows older than 24 hours are <strong>stale</strong> when shown as
|
||||||
|
history.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Keep freshness gating only for live fanout/cache admission and channel health, not for historical validity.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Remove dead <code>LiveHistoryBuffer</code> and auto-hydration scaffolding if it remains unused after the flow is
|
||||||
|
explicit.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Keep the default options tape view as <code>signal</code>, and add a filter-menu view control with
|
||||||
|
<strong>Signal</strong> and <strong>All prints</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ensure hot-cache rows and ClickHouse history rows use the same row component, same styling, same sorting, and
|
||||||
|
same interactions.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Keep cursor/key-based deduping so scroll-gated history does not duplicate the 100-row hot head.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Relevant Context</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Prior work in <code>islandflow-0sa</code> already introduced scroll hold, a 100-row options head, lazy history,
|
||||||
|
and cache-first scoped snapshots.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The current storage/types path already includes execution context fields such as <code>execution_nbbo_*</code>,
|
||||||
|
<code>execution_underlying_*</code>, and <code>execution_iv*</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Synthetic options prints already emit some execution context; the durable fix should verify this data survives
|
||||||
|
ClickHouse writes and reads.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The UI should prefer preserved execution context in row rendering before falling back to current NBBO lookup.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Beads has related work in <code>islandflow-biq</code> for raw live options delivery and filter/backpressure
|
||||||
|
observability.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Implementation Steps</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Audit the existing options tape flow from ingest, ClickHouse write/read, live snapshot, history endpoint, and web
|
||||||
|
composition.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Adjust API/live semantics so valid ClickHouse history can be older than freshness thresholds without being treated
|
||||||
|
as degraded.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Add the Filter-menu view toggle for <code>Signal</code> and <code>All prints</code>, with short copy explaining
|
||||||
|
the difference.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Ensure <code>buildOptionTapeQueryParams</code>, live subscriptions, and <code>/history/options</code> all receive
|
||||||
|
the selected view consistently.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Confirm option row rendering uses preserved <code>execution_nbbo_side</code>, <code>execution_underlying_spot</code>,
|
||||||
|
and <code>execution_iv</code> when present.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Remove deprecated or unused history/autohydration code paths that no longer help the intended scroll-gated flow.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Add a deliberate reset path for local and VPS ClickHouse, documented as destructive and operator-confirmed.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Risks, Limitations, and Mitigations</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Risk:</strong> Resetting VPS data is destructive. <strong>Mitigation:</strong> make it a runbook or explicit
|
||||||
|
command with backup/confirmation, never automatic app startup behavior.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Risk:</strong> The signal/raw toggle could affect both options and flow filters unexpectedly.
|
||||||
|
<strong>Mitigation:</strong> test option subscriptions, history query params, and flow packet filtering separately.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Risk:</strong> Older history fetch latency could be visible at the scroll gate. <strong>Mitigation:</strong>
|
||||||
|
keep lazy loading, expose loading/error state if needed, and avoid background auto-hydration.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Risk:</strong> Prior fixes may have left overlapping history logic. <strong>Mitigation:</strong> remove unused
|
||||||
|
scaffolding only after tests cover the intended hot-cache plus ClickHouse path.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Validation</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Storage tests: <code>fetchRecentOptionPrints</code> and <code>fetchOptionPrintsBefore</code> return execution NBBO,
|
||||||
|
spot, IV, signal metadata, and raw/signal filtering correctly.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
API/live tests: generic options snapshots cap at 100, scoped snapshots prefer hot cache, history preserves
|
||||||
|
<code>next_before</code>, and rows older than 24 hours return as valid history.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Web tests: Filter menu toggles <code>Signal</code>/<code>All prints</code>, scroll gate calls
|
||||||
|
<code>loadOlder("options")</code>, ClickHouse rows compose with no duplicate seam and no distinct styling, and
|
||||||
|
preserved execution context drives Spot, Side, Details, and IV display.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Validation commands: <code>bun test packages/storage/tests/option-prints.test.ts services/api/tests/live.test.ts apps/web/app/terminal.test.ts</code>
|
||||||
|
and <code>bun --cwd=apps/web run build</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Open Questions</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Exact VPS reset command sequence should be confirmed against the live deployment state before execution.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Decide during implementation whether to track the reset/runbook in a new Beads issue or fold it into
|
||||||
|
<code>islandflow-biq</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="callout">
|
||||||
|
<p>
|
||||||
|
Fixed assumptions: historical ClickHouse rows should be visually indistinguishable from hot-cache rows, and local
|
||||||
|
plus VPS wipe should be an operator-confirmed reset path rather than a background migration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
245
docs/turns/2026-05-16-1725-durable-options-tape-history.html
Normal file
245
docs/turns/2026-05-16-1725-durable-options-tape-history.html
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Durable Options Tape History</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #06080b;
|
||||||
|
--panel: #111820;
|
||||||
|
--panel-2: #0d141b;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-strong: rgba(245, 166, 35, 0.34);
|
||||||
|
--text: #e6edf4;
|
||||||
|
--muted: #90a0b2;
|
||||||
|
--faint: #6e7b8c;
|
||||||
|
--accent: #f5a623;
|
||||||
|
--accent-soft: rgba(245, 166, 35, 0.14);
|
||||||
|
--good: #25c17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 32, 0.92), rgba(6, 8, 11, 0.98) 320px), var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.55 "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
h2,
|
||||||
|
.chip {
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 26px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 32, 0.94), rgba(13, 20, 27, 0.94));
|
||||||
|
}
|
||||||
|
|
||||||
|
section.summary-band {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: linear-gradient(180deg, rgba(245, 166, 35, 0.12), rgba(17, 24, 32, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.12rem 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--good);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
main {
|
||||||
|
padding: 28px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">Turn Document</p>
|
||||||
|
<h1>Durable Options Tape History</h1>
|
||||||
|
<p class="summary">
|
||||||
|
Implemented the durable options tape plan: the live hot head is capped at 100 rows, older rows are preserved behind
|
||||||
|
the scroll gate, ClickHouse history keeps execution context, and the Filter menu now exposes Signal versus All
|
||||||
|
prints semantics.
|
||||||
|
</p>
|
||||||
|
<div class="meta" aria-label="Turn metadata">
|
||||||
|
<span class="chip">2026-05-16 17:25</span>
|
||||||
|
<span class="chip">Beads: islandflow-200</span>
|
||||||
|
<span class="chip">Surface: Options Tape</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="summary-band">
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<p>
|
||||||
|
The options tape now behaves as a continuous instrument: the live cache stays lean, historical rows arrive only
|
||||||
|
when scrolling asks for them, and old valid rows are not treated as degraded just because they came from durable
|
||||||
|
history.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Changes Made</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Changed the API default options live cache limit to <code>100</code>.</li>
|
||||||
|
<li>Removed the unused scoped live auto-hydration path so history is loaded by the scroll gate.</li>
|
||||||
|
<li>Fixed unbounded options/equities history retention so a cap of <code>0</code> means keep the loaded tail.</li>
|
||||||
|
<li>Added a Filter menu <code>Options View</code> toggle for <code>Signal</code> and <code>All prints</code>.</li>
|
||||||
|
<li>Ensured All prints clears signal-only side/type/min-notional/security constraints.</li>
|
||||||
|
<li>Added a destructive ClickHouse reset runbook for local and VPS operators.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Context</h2>
|
||||||
|
<p>
|
||||||
|
The prior plan called out useful partial work already in the repo: ClickHouse history endpoints, execution-context
|
||||||
|
columns, scroll-hold behavior, and a shared row renderer. This implementation keeps those pieces and removes the
|
||||||
|
ambiguous history/autohydration behavior around them.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Important Implementation Details</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>/history/options</code> still uses the selected option filters and scope, including raw contract drilldowns.</li>
|
||||||
|
<li>Storage tests now verify execution NBBO side, underlying spot, IV, and signal reasons survive normalization.</li>
|
||||||
|
<li>The options row path already preferred <code>execution_nbbo_side</code>, <code>execution_underlying_spot</code>, and <code>execution_iv</code>; tests cover that behavior.</li>
|
||||||
|
<li>The reset runbook is documented in <code>docs/clickhouse-reset-runbook.md</code> and is explicitly operator-confirmed.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Expected Impact for End-Users</h2>
|
||||||
|
<p>
|
||||||
|
Traders can stay on a signal-first tape by default, switch to raw prints when investigating, and scroll into older
|
||||||
|
ClickHouse-backed flow without seeing a separate stale-history treatment.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Validation</h2>
|
||||||
|
<ul>
|
||||||
|
<li><span class="ok">Passed:</span> <code>bun test packages/storage/tests/option-prints.test.ts services/api/tests/live.test.ts apps/web/app/terminal.test.ts</code></li>
|
||||||
|
<li><span class="ok">Passed:</span> <code>bun --cwd=apps/web run build</code></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Issues, Limitations, and Mitigations</h2>
|
||||||
|
<ul>
|
||||||
|
<li>The ClickHouse reset remains destructive. Mitigation: documented as a manual runbook only, never automatic startup behavior.</li>
|
||||||
|
<li>No live browser smoke test was run in this turn. Mitigation: unit coverage and production build exercised the changed web paths.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Follow-up Work</h2>
|
||||||
|
<p>No new follow-up issue was needed. The implementation task is tracked and completed in <code>islandflow-200</code>.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
158
docs/turns/2026-05-16-live-tape-scroll-hold-history.html
Normal file
158
docs/turns/2026-05-16-live-tape-scroll-hold-history.html
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Turn Summary: Live tape scroll hold and lazy history</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0b1016;
|
||||||
|
--panel: #111820;
|
||||||
|
--border: rgba(255,255,255,0.1);
|
||||||
|
--text: #e6edf4;
|
||||||
|
--muted: #90a0b2;
|
||||||
|
--accent: #f5a623;
|
||||||
|
--accent-soft: rgba(245,166,35,0.16);
|
||||||
|
--ok: #25c17a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 16px/1.55 "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
main { max-width: 980px; margin: 0 auto; }
|
||||||
|
h1, h2 { line-height: 1.15; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 2rem; }
|
||||||
|
h2 { margin: 32px 0 12px; font-size: 1.1rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||||
|
p, li { color: var(--muted); }
|
||||||
|
.summary {
|
||||||
|
background: linear-gradient(180deg, rgba(245,166,35,0.14), rgba(245,166,35,0.05));
|
||||||
|
border: 1px solid rgba(245,166,35,0.24);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.meta { color: var(--muted); font-size: 0.92rem; margin-bottom: 18px; }
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
padding: 0.14rem 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0,0,0,0.22);
|
||||||
|
}
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="summary">
|
||||||
|
<h1>Live tape now holds on scroll, resumes at top, and lazy-loads deep history</h1>
|
||||||
|
<p>
|
||||||
|
The live tape no longer depends on a manual pause button in live mode. Scrolling away from the top now holds the
|
||||||
|
tape automatically, <code>Jump to top</code> resumes it, the options hot head is capped at 100 rows, and older
|
||||||
|
history is fetched from ClickHouse only when the scroll gate requests it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">Created 2026-05-16 during issue <code>islandflow-0sa</code>.</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<p>
|
||||||
|
This change aligns the tape with the intended operator workflow: hold the live head while investigating older
|
||||||
|
rows, keep historical prints valid even when old, and avoid preloading a large ClickHouse backlog until the
|
||||||
|
user actually scrolls into it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Changes Made</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<ul>
|
||||||
|
<li>Removed the live-mode Pause/Resume control from tape pane actions while keeping replay pause behavior intact.</li>
|
||||||
|
<li>Changed live tape status copy from manual <code>Paused</code> semantics to scroll-held <code>Held</code>.</li>
|
||||||
|
<li>Capped the live options head at <code>100</code> rows.</li>
|
||||||
|
<li>Stopped scoped live history from auto-hydrating in the background.</li>
|
||||||
|
<li>Made scoped options and equities snapshots prefer hot cached rows first, then backfill from ClickHouse when needed.</li>
|
||||||
|
<li>Made options and equities history retention effectively unbounded on the client so deep scrolling does not get trimmed away prematurely.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Context</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<p>
|
||||||
|
The tape previously mixed several behaviors: a manual pause button, automatic scroll holding, scoped background
|
||||||
|
auto-hydration, and a much deeper options hot head. That created two user-visible problems: the live control model
|
||||||
|
felt redundant, and older prints could disappear or feel inconsistent when switching views or waiting for newer
|
||||||
|
rows to arrive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Important Implementation Details</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<ul>
|
||||||
|
<li><code>apps/web/app/terminal.tsx</code>: live <code>usePausableTapeView</code> now treats scroll position as the hold source of truth.</li>
|
||||||
|
<li><code>apps/web/app/terminal.tsx</code>: options live snapshot and retention now use a strict <code>LIVE_OPTIONS_HEAD_LIMIT = 100</code>.</li>
|
||||||
|
<li><code>apps/web/app/terminal.tsx</code>: scoped history auto-hydration helper now returns no channels, so ClickHouse history stays lazy.</li>
|
||||||
|
<li><code>services/api/src/live.ts</code>: scoped option/equity snapshots now filter the hot cache first, then merge ClickHouse backfill without seam duplicates.</li>
|
||||||
|
</ul>
|
||||||
|
<pre><code>statusLabel("connected", true, "live") === "Held"
|
||||||
|
statusLabel("connected", true, "replay") === "Paused"</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Validation</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<ul>
|
||||||
|
<li class="ok">Passed: <code>bun test apps/web/app/terminal.test.ts services/api/tests/live.test.ts</code></li>
|
||||||
|
<li class="ok">Passed: <code>bun --cwd=apps/web run build</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Issues, Limitations, and Mitigations</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<ul>
|
||||||
|
<li>Scoped snapshots can still backfill from ClickHouse when the hot cache does not have enough matching rows. This is intentional so focused views do not start empty.</li>
|
||||||
|
<li>Deep history is now lazy rather than eager, which reduces surprise and load, but the first deep-scroll request still depends on ClickHouse latency.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Follow-up Work</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<p>No additional follow-up issues were created in this turn.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -48,6 +48,25 @@ describe("option-prints storage helpers", () => {
|
||||||
queries.push(query);
|
queries.push(query);
|
||||||
return {
|
return {
|
||||||
async json<T>() {
|
async json<T>() {
|
||||||
|
if (query.includes("trace-ctx")) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...basePrint,
|
||||||
|
trace_id: "trace-ctx",
|
||||||
|
conditions: [],
|
||||||
|
execution_nbbo_bid: "1.20",
|
||||||
|
execution_nbbo_ask: "1.30",
|
||||||
|
execution_nbbo_mid: "1.25",
|
||||||
|
execution_nbbo_side: "A",
|
||||||
|
execution_underlying_spot: "450.05",
|
||||||
|
execution_underlying_source: "equity_quote_mid",
|
||||||
|
execution_iv: "0.42",
|
||||||
|
execution_iv_source: "synthetic_pressure_model",
|
||||||
|
signal_reasons: ["large_notional"],
|
||||||
|
signal_pass: 1
|
||||||
|
}
|
||||||
|
] as T;
|
||||||
|
}
|
||||||
return [] as T;
|
return [] as T;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -63,8 +82,9 @@ describe("option-prints storage helpers", () => {
|
||||||
optionContractId: "AAPL-2025-01-17-200-C",
|
optionContractId: "AAPL-2025-01-17-200-C",
|
||||||
sinceTs: 123
|
sinceTs: 123
|
||||||
});
|
});
|
||||||
await fetchOptionPrintsBefore(client, 100, 5, 20, "alpaca");
|
await fetchOptionPrintsBefore(client, 100, 5, 20, "alpaca", { view: "raw" });
|
||||||
await fetchOptionPrintsByTraceIds(client, ["trace-1", "trace-2"]);
|
await fetchOptionPrintsByTraceIds(client, ["trace-1", "trace-2"]);
|
||||||
|
const rows = await fetchRecentOptionPrints(client, 1, "trace-ctx", { view: "signal" });
|
||||||
|
|
||||||
expect(queries[0]).toContain("signal_pass = 1");
|
expect(queries[0]).toContain("signal_pass = 1");
|
||||||
expect(queries[0]).toContain("(is_etf = 0 OR is_etf IS NULL)");
|
expect(queries[0]).toContain("(is_etf = 0 OR is_etf IS NULL)");
|
||||||
|
|
@ -76,7 +96,12 @@ describe("option-prints storage helpers", () => {
|
||||||
expect(queries[0]).toContain("ts >= 123");
|
expect(queries[0]).toContain("ts >= 123");
|
||||||
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
|
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
|
||||||
expect(queries[1]).toContain("startsWith(trace_id, 'alpaca')");
|
expect(queries[1]).toContain("startsWith(trace_id, 'alpaca')");
|
||||||
|
expect(queries[1]).not.toContain("signal_pass = 1");
|
||||||
expect(queries[1]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");
|
expect(queries[1]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");
|
||||||
expect(queries[2]).toContain("trace_id IN ('trace-1', 'trace-2')");
|
expect(queries[2]).toContain("trace_id IN ('trace-1', 'trace-2')");
|
||||||
|
expect(rows[0].execution_nbbo_side).toBe("A");
|
||||||
|
expect(rows[0].execution_underlying_spot).toBe(450.05);
|
||||||
|
expect(rows[0].execution_iv).toBe(0.42);
|
||||||
|
expect(rows[0].signal_reasons).toEqual(["large_notional"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ import {
|
||||||
type Cursor,
|
type Cursor,
|
||||||
type EquityCandle,
|
type EquityCandle,
|
||||||
type EquityPrint,
|
type EquityPrint,
|
||||||
type LiveChannel
|
type LiveChannel,
|
||||||
|
type OptionPrint
|
||||||
} from "@islandflow/types";
|
} from "@islandflow/types";
|
||||||
import { createMetrics } from "@islandflow/observability";
|
import { createMetrics } from "@islandflow/observability";
|
||||||
import type { RedisClientType } from "redis";
|
import type { RedisClientType } from "redis";
|
||||||
|
|
@ -71,7 +72,7 @@ const CHART_LIMITS = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_LIVE_LIMITS: GenericLiveLimits = {
|
const DEFAULT_LIVE_LIMITS: GenericLiveLimits = {
|
||||||
options: 1000,
|
options: 100,
|
||||||
nbbo: 1000,
|
nbbo: 1000,
|
||||||
equities: 1000,
|
equities: 1000,
|
||||||
"equity-quotes": 500,
|
"equity-quotes": 500,
|
||||||
|
|
@ -456,6 +457,54 @@ export const buildOptionSnapshotFilters = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchesScopedOptionSnapshot = (
|
||||||
|
item: OptionPrint,
|
||||||
|
subscription: Extract<LiveSubscription, { channel: "options" }>
|
||||||
|
): boolean => {
|
||||||
|
if (!matchesOptionPrintFilters(item, subscription.filters)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.option_contract_id && item.option_contract_id !== subscription.option_contract_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.underlying_ids?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(subscription.underlying_ids.map((value) => value.toUpperCase()));
|
||||||
|
return allowed.has(item.underlying_id.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesScopedEquitySnapshot = (
|
||||||
|
item: EquityPrint,
|
||||||
|
subscription: Extract<LiveSubscription, { channel: "equities" }>
|
||||||
|
): boolean => {
|
||||||
|
if (!subscription.underlying_ids?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(subscription.underlying_ids.map((value) => value.toUpperCase()));
|
||||||
|
return allowed.has(item.underlying_id.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeSnapshotBackfill = <T>(
|
||||||
|
cached: T[],
|
||||||
|
backfill: T[],
|
||||||
|
limit: number,
|
||||||
|
cursorOf: (item: T) => Cursor
|
||||||
|
): T[] => {
|
||||||
|
const deduped = new Map<string, T>();
|
||||||
|
|
||||||
|
for (const item of [...cached, ...backfill]) {
|
||||||
|
const cursor = cursorOf(item);
|
||||||
|
deduped.set(`${cursor.ts}:${cursor.seq}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortGenericItems(Array.from(deduped.values()), cursorOf).slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
||||||
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
||||||
|
|
||||||
|
|
@ -740,12 +789,20 @@ export class LiveStateManager {
|
||||||
async getSnapshot(subscription: LiveSubscription): Promise<FeedSnapshot<unknown>> {
|
async getSnapshot(subscription: LiveSubscription): Promise<FeedSnapshot<unknown>> {
|
||||||
switch (subscription.channel) {
|
switch (subscription.channel) {
|
||||||
case "options": {
|
case "options": {
|
||||||
|
const config = this.generic.options;
|
||||||
|
const limit = snapshotLimitFor(subscription, config.limit);
|
||||||
const scoped = Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
const scoped = Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
||||||
if (subscription.filters?.view === "raw" || scoped) {
|
if (subscription.filters?.view === "raw" || scoped) {
|
||||||
|
const cached = (this.genericItems.get("options") ?? [])
|
||||||
|
.filter((entry) => matchesScopedOptionSnapshot(entry, subscription))
|
||||||
|
.slice(0, limit);
|
||||||
|
let items = cached;
|
||||||
|
if (cached.length < limit) {
|
||||||
this.stats.scopedClickHouseSnapshots += 1;
|
this.stats.scopedClickHouseSnapshots += 1;
|
||||||
const limit = snapshotLimitFor(subscription, this.generic.options.limit);
|
|
||||||
const storageFilters = buildOptionSnapshotFilters(subscription);
|
const storageFilters = buildOptionSnapshotFilters(subscription);
|
||||||
const items = await fetchRecentOptionPrints(this.clickhouse, limit, undefined, storageFilters);
|
const backfill = await fetchRecentOptionPrints(this.clickhouse, limit, undefined, storageFilters);
|
||||||
|
items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ ts: entry.ts, seq: entry.seq }));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
@ -754,9 +811,7 @@ export class LiveStateManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.generic.options;
|
|
||||||
this.stats.genericCacheSnapshots += 1;
|
this.stats.genericCacheSnapshots += 1;
|
||||||
const limit = snapshotLimitFor(subscription, config.limit);
|
|
||||||
const items = (this.genericItems.get("options") ?? [])
|
const items = (this.genericItems.get("options") ?? [])
|
||||||
.filter((entry) => matchesOptionPrintFilters(entry, subscription.filters))
|
.filter((entry) => matchesOptionPrintFilters(entry, subscription.filters))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
@ -785,9 +840,16 @@ export class LiveStateManager {
|
||||||
const config = this.generic.equities;
|
const config = this.generic.equities;
|
||||||
const limit = snapshotLimitFor(subscription, config.limit);
|
const limit = snapshotLimitFor(subscription, config.limit);
|
||||||
if (subscription.underlying_ids?.length) {
|
if (subscription.underlying_ids?.length) {
|
||||||
|
const cached = (this.genericItems.get("equities") ?? [])
|
||||||
|
.filter((entry) => matchesScopedEquitySnapshot(entry, subscription))
|
||||||
|
.slice(0, limit);
|
||||||
|
let items = cached;
|
||||||
|
if (cached.length < limit) {
|
||||||
this.stats.scopedClickHouseSnapshots += 1;
|
this.stats.scopedClickHouseSnapshots += 1;
|
||||||
const filters: EquityPrintQueryFilters = { underlyingIds: subscription.underlying_ids };
|
const filters: EquityPrintQueryFilters = { underlyingIds: subscription.underlying_ids };
|
||||||
const items = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
|
const backfill = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
|
||||||
|
items = mergeSnapshotBackfill(cached, backfill, limit, config.cursor);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ describe("LiveStateManager", () => {
|
||||||
expect(limits.flow).toBe(500);
|
expect(limits.flow).toBe(500);
|
||||||
expect(limits["equity-quotes"]).toBe(500);
|
expect(limits["equity-quotes"]).toBe(500);
|
||||||
expect(limits.alerts).toBe(300);
|
expect(limits.alerts).toBe(300);
|
||||||
|
expect(resolveGenericLiveLimits({} as NodeJS.ProcessEnv).options).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates snapshots from redis generic windows", async () => {
|
it("hydrates snapshots from redis generic windows", async () => {
|
||||||
|
|
@ -520,6 +521,32 @@ describe("LiveStateManager", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("caps generic options snapshots at the 100-row hot head by default", async () => {
|
||||||
|
const manager = new LiveStateManager(makeClickHouse(), null);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (let seq = 1; seq <= 150; seq += 1) {
|
||||||
|
await manager.ingest("options", {
|
||||||
|
source_ts: now + seq,
|
||||||
|
ingest_ts: now + seq,
|
||||||
|
seq,
|
||||||
|
trace_id: `opt-${seq}`,
|
||||||
|
ts: now + seq,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
price: 1,
|
||||||
|
size: 10,
|
||||||
|
exchange: "X",
|
||||||
|
signal_pass: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await manager.getSnapshot({ channel: "options" });
|
||||||
|
|
||||||
|
expect(snapshot.items).toHaveLength(100);
|
||||||
|
expect((snapshot.items as Array<{ trace_id: string }>)[0].trace_id).toBe("opt-150");
|
||||||
|
expect(snapshot.next_before).toEqual({ ts: now + 51, seq: 51 });
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds scoped option snapshots from clickhouse rows older than 24h", async () => {
|
it("seeds scoped option snapshots from clickhouse rows older than 24h", async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||||
|
|
@ -627,6 +654,57 @@ describe("LiveStateManager", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers cached scoped option rows before clickhouse backfill", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const manager = new LiveStateManager(
|
||||||
|
makeClickHouse((query) =>
|
||||||
|
query.includes("FROM option_prints")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
source_ts: now - 1_000,
|
||||||
|
ingest_ts: now - 999,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "opt-backfill",
|
||||||
|
ts: now - 1_000,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
underlying_id: "AAPL",
|
||||||
|
price: 1,
|
||||||
|
size: 10,
|
||||||
|
exchange: "X",
|
||||||
|
signal_pass: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.ingest("options", {
|
||||||
|
source_ts: now,
|
||||||
|
ingest_ts: now + 1,
|
||||||
|
seq: 2,
|
||||||
|
trace_id: "opt-hot",
|
||||||
|
ts: now,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
underlying_id: "AAPL",
|
||||||
|
price: 2,
|
||||||
|
size: 10,
|
||||||
|
exchange: "X",
|
||||||
|
signal_pass: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.getSnapshot({
|
||||||
|
channel: "options",
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id).slice(0, 2)).toEqual([
|
||||||
|
"opt-hot",
|
||||||
|
"opt-backfill"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
|
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue