diff --git a/.beads/config.yaml b/.beads/config.yaml index 12fdcdb..8344bac 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -53,7 +53,4 @@ # - github.org # - github.repo -sync: - remote: git+https://git.deltaisland.io/dirtydishes/islandflow.git - -sync.remote: "git+https://github.com/dirtydishes/islandflow.git" \ No newline at end of file +sync.remote: "http://dolt.deltaisland.io/islandflow" \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7552b8d..b1ab2c6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,5 @@ +{"_type":"issue","id":"islandflow-9ur","title":"address forgejo issue 15 tmp cve","description":"Track remediation for Forgejo issue #15: update tmp from vulnerable 0.2.5 to patched 0.2.6+ via root override and refreshed Bun lockfile, then validate with audit/tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-01T17:32:18Z","created_by":"dirtydishes","updated_at":"2026-06-01T17:36:01Z","started_at":"2026-06-01T17:32:23Z","closed_at":"2026-06-01T17:36:01Z","close_reason":"Resolved Forgejo issue #15 by bumping the tmp override to ^0.2.6, refreshing bun.lock to tmp@0.2.7, and validating with bun audit, bun why tmp, and bun test.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-m3d","title":"fix docs mirroring to github pages","description":"The repository docs folder is supposed to mirror to dirtydishes.github.io for GitHub Pages, but the mirroring is not working. Investigate the docs publishing workflow and repair the configuration or scripts so docs can be published reliably.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-31T22:05:48Z","created_by":"dirtydishes","updated_at":"2026-05-31T22:12:26Z","started_at":"2026-05-31T22:05:56Z","closed_at":"2026-05-31T22:12:26Z","close_reason":"Updated docs Pages workflow to publish into dirtydishes/dirtydishes.github.io under islandflow/docs, tightened docs index generation, regenerated docs index, and documented validation/limitations.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-2op","title":"[bug] Desktop app unclickable and no live data in hosted shell","description":"## Summary\\nDesktop Electron shell appears fully non-interactive (clicks do not work) and no live market data reaches the UI.\\n\\n## Why this matters\\nDesktop wrapper is currently unusable for core workflow and blocks users from validating market streams outside browser.\\n\\n## Scope\\nReproduce issue locally, identify root cause(s) in Electron shell and frontend integration, implement fix, and validate interactivity + data flow end-to-end.\\n\\n## Acceptance Criteria\\n- Desktop app responds to pointer interactions (navigation/actions clickable)\\n- Live data stream connects and updates UI in desktop mode\\n- Regression coverage or guardrails added where practical\\n- Findings and validation documented","status":"in_progress","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-24T04:23:55Z","created_by":"dirtydishes","updated_at":"2026-05-24T04:23:57Z","started_at":"2026-05-24T04:23:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-jad","title":"Sync docs pages workflow fix to github mirror","description":"GitHub is still running an older docs Pages workflow with configure-pages because github/main is behind forgejo/main. Push the already-fixed workflow commit to the GitHub mirror so Actions runs the gh-pages branch deployment flow instead.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T22:27:46Z","created_by":"dirtydishes","updated_at":"2026-05-23T22:28:24Z","started_at":"2026-05-23T22:28:10Z","closed_at":"2026-05-23T22:28:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-bc7","title":"Fix docs Pages workflow configure-pages failure","description":"Replace the current docs Pages deployment flow so workflow runs succeed even when configure-pages cannot read or enable the site. Keep published docs target behavior for dirtydishes.github.io/islandflow/docs.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T22:23:28Z","created_by":"dirtydishes","updated_at":"2026-05-23T22:25:19Z","started_at":"2026-05-23T22:23:31Z","closed_at":"2026-05-23T22:25:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -9,6 +11,7 @@ {"_type":"issue","id":"islandflow-yza","title":"Persist historical flow packets for alert detail replay","description":"## Why\nAlert details can show a missing persisted flow packet when the packet is no longer present in the Redis hot cache, even though the associated historical alert and evidence were loaded from ClickHouse.\n\n## What needs to be done\nTrace the API path that resolves alert detail flow packets, compare Redis hot-cache lookups with ClickHouse historical fetches, and ensure historical flow packet payloads are treated as first-class persisted data with context preserved when replaying or loading older alerts.\n\n## Acceptance Criteria\n- Alert detail flow packets load for historical alerts even when the packet is absent from Redis hot cache\n- Historical ClickHouse-backed flow packet responses preserve the context required by the UI\n- Relevant automated tests cover the regression or the gap is explicitly documented","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T06:52:04Z","created_by":"dirtydishes","updated_at":"2026-05-20T06:59:26Z","started_at":"2026-05-20T06:52:09Z","closed_at":"2026-05-20T06:59:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-jor","title":"Support Forgejo pull request status in desktop git panel","description":"The desktop app currently reports pull request status unavailable when a repository only has a Forgejo remote. Add native Forgejo/Gitea-style remote detection and pull request status lookup so Forgejo-only repositories can show PR state in the Codex app git panel.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T20:55:15Z","created_by":"dirtydishes","updated_at":"2026-05-19T20:59:46Z","started_at":"2026-05-19T20:55:25Z","closed_at":"2026-05-19T20:59:46Z","close_reason":"Patched the installed Codex desktop app bundle with a Forgejo PR status fallback and documented the local change.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-g3a","title":"Reconcile PR merge conflicts","description":"Resolve the current pull request conflicts for the nextjs-upgrade branch, validate the result, document the turn, and push the reconciled branch.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:44:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:47:35Z","started_at":"2026-05-19T18:44:56Z","closed_at":"2026-05-19T18:47:35Z","close_reason":"Merged forgejo/main into nextjs-upgrade, resolved README and Beads conflicts, updated JetStream retention tests, validated deploy help, Docker workspace sync, API/bus tests, and web build, and added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-9rc","title":"Implement native fast iterative deploy plan","description":"Implement the checked-in plan at plans/2026-05-18-native-fast-iterative-deploy-plan.md. Cover deploy-phase timing instrumentation, native deployment operational assets, deploy guardrails, validation/cutover documentation, and any required live VPS remediation that is safely actionable from this session. Track follow-up items separately if anything cannot be completed in-repo or on the live host.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T07:15:19Z","created_by":"dirtydishes","updated_at":"2026-05-18T07:34:03Z","started_at":"2026-05-18T07:15:25Z","closed_at":"2026-05-18T07:34:03Z","close_reason":"Implemented the native fast iterative deploy plan with deploy timing summaries, worker-only native fast mode, edge-cutover guardrails, local-on-server execution support, checked-in native ops assets, live audit findings, and turn documentation. Remaining cutover work is tracked in islandflow-vvw.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-jbi","title":"Hydrate alert evidence details from ClickHouse","description":"Alert detail drawers need to fetch persisted alert context from ClickHouse by trace id, including linked flow packets, option prints, preserved execution context, and explicit missing refs for UI diagnostics.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T14:55:43Z","created_by":"dirtydishes","updated_at":"2026-05-17T15:01:58Z","started_at":"2026-05-17T14:55:53Z","closed_at":"2026-05-17T15:01:58Z","close_reason":"Implemented ClickHouse-backed alert context hydration across storage, API, terminal drawer, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-8kj","title":"Configure persistent beads Dolt remote on deltaisland server","description":"Install the beads and Dolt CLIs on the server, configure a persistent Dolt sync remote backed by the server-hosted Forgejo repository, verify refs/dolt/data publication, and document Nginx Proxy Manager / firewall considerations.","status":"closed","priority":1,"issue_type":"task","assignee":"delta","created_at":"2026-05-17T10:31:31Z","created_by":"delta","updated_at":"2026-05-17T10:37:47Z","started_at":"2026-05-17T10:32:16Z","closed_at":"2026-05-17T10:37:47Z","close_reason":"Installed bd and dolt on the server, configured the Forgejo-backed Dolt remote, published refs/dolt/data, and documented the setup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} @@ -24,6 +27,11 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-xmi","title":"Resolve conflicts in PR 45","description":"Resolve the merge conflicts blocking Forgejo PR 45, validate the affected code paths, and push the reconciled branch back to Forgejo.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-06T03:33:52Z","created_by":"dirtydishes","updated_at":"2026-06-06T03:35:16Z","started_at":"2026-06-06T03:33:58Z","closed_at":"2026-06-06T03:35:16Z","close_reason":"Resolved the PR 45 merge conflict in .beads/issues.jsonl and validated the reconciled tracker file.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-8a6","title":"verify github pages token for docs mirror","description":"The docs mirror workflow now publishes islandflow/docs into dirtydishes/dirtydishes.github.io, but the GitHub Actions secret DOCS_PAGES_TOKEN must exist and have permission to push to that Pages repository. Verify the secret is configured and manually run the Publish Docs workflow after the mirror branch lands.","notes":"Direct manual publish to dirtydishes/dirtydishes.github.io succeeded on 2026-06-01 and https://dirtydishes.github.io/islandflow/docs/ returned HTTP 200. Remaining work is to verify DOCS_PAGES_TOKEN so the islandflow docs mirror workflow can publish future updates automatically.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-31T22:12:27Z","created_by":"dirtydishes","updated_at":"2026-06-01T13:45:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-5jt","title":"Add anatomy reference page","description":"Create a standalone docs/anatomy.html reference explaining how prints move through ingest, tape, flow packets, smart-money events, classifier hits, and alerts, including diagrams.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-31T21:20:34Z","created_by":"dirtydishes","updated_at":"2026-05-31T21:25:54Z","started_at":"2026-05-31T21:20:44Z","closed_at":"2026-05-31T21:25:54Z","close_reason":"Added the standalone anatomy reference page and linked it from the docs index.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-cig","title":"Expand CI quality gates","description":"Add a more robust CI workflow for the Bun/TypeScript monorepo, including formatting, linting, type checking, builds, and tests where appropriate.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-30T06:29:33Z","created_by":"dirtydishes","updated_at":"2026-05-30T06:34:11Z","started_at":"2026-05-30T06:29:41Z","closed_at":"2026-05-30T06:34:11Z","close_reason":"Expanded CI quality gates with Biome formatting/linting, public API route checks, Docker snapshot validation, tests, typecheck, and web build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-3l6","title":"fix ci typecheck bun path resolution","description":"Forgejo CI fails in scripts/typecheck.ts because the script shells out to bunx, which expects bun on PATH. The runner installs Bun by absolute path, so the typecheck helper should use the current Bun executable instead of PATH lookup.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-30T05:34:55Z","created_by":"dirtydishes","updated_at":"2026-05-30T06:00:31Z","started_at":"2026-05-30T05:35:02Z","closed_at":"2026-05-30T06:00:31Z","close_reason":"Fixed the Forgejo CI terminal import mismatch by switching the terminal client component to a namespace import; verified locally and on Forgejo run #56.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-wtg","title":"Harden drawer dialog focus behavior","description":"Fix terminal drawers so they expose modal dialog semantics, trap keyboard focus while open, and restore focus to the invoking control after close.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:55:25Z","created_by":"dirtydishes","updated_at":"2026-05-29T23:09:45Z","started_at":"2026-05-29T22:56:22Z","closed_at":"2026-05-29T23:09:45Z","close_reason":"Implemented modal dialog semantics, focus trapping, Escape dismissal, focus restoration, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-833","title":"Improve narrow options table responsiveness","description":"Adapt the Options route for narrow screens so dense tape tables remain contained in their panes, preserve row identity while horizontally panning, and keep the mobile ticker/filter controls readable.","acceptance_criteria":"Options tape panes have bounded heights on narrow screens; table body scrolls internally; first table column remains visible while panning; mobile topbar and filter controls have adequate spacing; web production build passes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:34:05Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:36:20Z","started_at":"2026-05-29T22:34:24Z","closed_at":"2026-05-29T22:36:20Z","close_reason":"Implemented narrow-screen options pane containment, sticky row context, touch-scroll affordances, and mobile control spacing. Validated with web build and in-browser narrow viewport checks.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-aq9","title":"Harden terminal UI error and overflow states","description":"Harden the web terminal against oversized API errors, non-JSON synthetic admin failures, and long status text so live trading panes remain stable under bad network/backend responses.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-29T22:10:16Z","created_by":"dirtydishes","updated_at":"2026-05-29T22:13:37Z","closed_at":"2026-05-29T22:13:37Z","close_reason":"Hardened terminal UI error rendering, synthetic admin failure parsing, long-message wrapping, and added focused tests.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -42,6 +50,8 @@ {"_type":"issue","id":"islandflow-kgu","title":"Reconcile PR #8 branch with current main","description":"Why this issue exists and what needs to be done: user requested reconciliation for PR #8. Identify the PR #8 branch, merge/rebase with current main, resolve conflicts, validate, and push the updated branch so the PR can merge cleanly.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T20:14:36Z","created_by":"dirtydishes","updated_at":"2026-05-23T20:24:29Z","started_at":"2026-05-23T20:14:39Z","closed_at":"2026-05-23T20:24:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-l9h","title":"stop persisting non-signal option prints in clickhouse","description":"Why: non-signal option prints are storage noise and should not be persisted by default.\\n\\nWhat: add OPTIONS_PERSIST_SIGNAL_ONLY env flag (default true), gate option_print inserts in ingest-options, add tests for persistence behavior, update env examples, and document one-off cleanup SQL for existing non-signal rows.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T03:02:32Z","created_by":"dirtydishes","updated_at":"2026-05-23T03:06:34Z","started_at":"2026-05-23T03:02:35Z","closed_at":"2026-05-23T03:06:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-2cj","title":"Add Forgejo-first agent workflow guidance to AGENTS.md","description":"Why this issue exists and what needs to be done:\\n- The repository’s canonical home is Forgejo at git.deltaisland.io, but AGENTS.md does not currently direct agents to prefer Forgejo-specific workflows.\\n- Update AGENTS.md so agents treat Forgejo as primary and use the fj CLI for pull request workflows.\\n- Keep existing Beads and completion instructions intact while clarifying remote preference and command usage.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-23T02:51:31Z","created_by":"dirtydishes","updated_at":"2026-05-23T02:55:42Z","closed_at":"2026-05-23T02:55:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-6ub","title":"Fix LiveStateManager default hot-head test expectation after recent API changes","description":"bun test v1.3.13 (bf2e2cec) currently fails on the case after the latest pulled changes. The failure appears unrelated to the server-load tuning work and should be investigated separately so targeted validation can pass cleanly again.","status":"open","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-22T06:09:44Z","created_by":"dirtydishes","updated_at":"2026-05-22T06:09:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-qke","title":"Tune healthchecks and Redis flush cadence to reduce server load","description":"Containerd and dockerd are consuming significant CPU due to frequent Docker healthcheck exec churn across multiple stacks, and the host Islandflow Redis instance is hot from aggressive live-cache rewrite behavior. Tune external stack healthcheck intervals and Islandflow Redis flush cadence to reduce steady-state load while preserving service behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-22T06:06:58Z","created_by":"dirtydishes","updated_at":"2026-05-22T06:11:40Z","started_at":"2026-05-22T06:07:03Z","closed_at":"2026-05-22T06:11:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xc5","title":"One-time bidirectional git remote backfill between github and forgejo","description":"Perform a one-time sync so github and forgejo contain the same branch/tag refs and historical commits, including pre-transition github history and newer forgejo commits. Document exact commands and validation results.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-21T01:25:05Z","created_by":"dirtydishes","updated_at":"2026-05-21T01:26:19Z","started_at":"2026-05-21T01:25:16Z","closed_at":"2026-05-21T01:26:19Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-y7b","title":"Fix false browser fallback in Electron renderer","description":"Why this issue exists and what needs to be done:\\nElectron sessions can briefly or permanently render browser-only fallback copy when runtime detection depends on async desktop AI state loading.\\n\\nImplement a runtime snapshot that is resolved synchronously on the client (shell marker + bridge presence) and kept independent from bridge.ai state fetch/subscribe behavior. Add bounded runtime resync/retry and lifecycle-triggered resync on focus/pageshow so late bridge exposure flips to desktop mode.\\n\\nUpdate desktop-ai tests to cover: runtime marker present before AI state resolves, bridge present with pending/rejected getState, and late runtime availability. Keep preload/IPC contract unchanged unless a verified failure requires it.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-21T00:06:52Z","created_by":"dirtydishes","updated_at":"2026-05-21T00:11:21Z","started_at":"2026-05-21T00:06:55Z","closed_at":"2026-05-21T00:11:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xtg","title":"implement ai alert copilot ux refinements","description":"Implement the AI alert Copilot UX plan: markdown result rendering, reusable task result states, in-session result caching with regenerate, task cancellation through the desktop bridge, tests, and required turn documentation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T23:30:50Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:37:58Z","started_at":"2026-05-20T23:30:58Z","closed_at":"2026-05-20T23:37:58Z","close_reason":"Implemented markdown Copilot rendering, session result caching, regenerate controls, task cancellation plumbing, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -58,12 +68,16 @@ {"_type":"issue","id":"islandflow-lm6","title":"Clarify repo turn documentation scope","description":"Update AGENTS.md so repository turn documentation clearly uses repo-local docs/turns and impeccable styling, without inheriting global non-repo computer-task styling.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T12:05:07Z","created_by":"dirtydishes","updated_at":"2026-05-19T12:06:12Z","started_at":"2026-05-19T12:05:14Z","closed_at":"2026-05-19T12:06:12Z","close_reason":"Verified AGENTS.md now scopes repo turn docs to docs/turns and makes impeccable the styling authority; added turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-6iq","title":"Update README for current project state","description":"Resolve README merge conflicts and document the current project state, including the smart money classification taxonomy, Next.js update, and deployment workflow changes.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T11:37:24Z","created_by":"dirtydishes","updated_at":"2026-05-19T11:40:01Z","started_at":"2026-05-19T11:37:31Z","closed_at":"2026-05-19T11:40:01Z","close_reason":"README conflict resolved and current project state documented, including smart-money taxonomy, Next.js update, and deployment workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-lib","title":"Upgrade apps/web to Next.js 16.2.6","description":"Upgrade the web app dependency stack to Next.js 16.2.6 with React 19, refresh Bun and mirrored Docker workspace lockfiles, keep runtime behavior unchanged, fix any focused web test fallout, validate the web build and targeted route tests, and document the completed work.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T11:04:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T11:31:23Z","started_at":"2026-05-19T11:04:57Z","closed_at":"2026-05-19T11:31:23Z","close_reason":"Upgraded apps/web to Next.js 16.2.6 with React 19, refreshed Bun lockfiles including the Docker workspace mirror, fixed the React 19 nullable ref type issue, and validated the web build, focused tests, Docker workspace sync, and route smoke checks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-fl5","title":"Decide final public posture for api.flow.deltaisland.io after native cutover","description":"Why this issue exists and what needs to be done:\\n- Native cutover now works end-to-end through Nginx Proxy Manager and the public API hostname now resolves directly to the VPS\\n- The API hostname was left DNS-only in Cloudflare during incident resolution, while the web hostname still uses the Cloudflare proxy\\n- We need to decide whether api.flow.deltaisland.io should remain direct-to-origin or be re-proxied through Cloudflare, then validate TLS, websocket, and operational behavior for the chosen posture","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-18T23:51:21Z","created_by":"dirtydishes","updated_at":"2026-05-18T23:51:21Z","dependencies":[{"issue_id":"islandflow-fl5","depends_on_id":"islandflow-vvw","type":"discovered-from","created_at":"2026-05-18T19:52:32Z","created_by":"dirtydishes","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-8fn","title":"implement alpaca-backed news wire view","description":"Why this issue exists and what needs to be done:\\nAdd an Alpaca-powered live news pipeline, API, storage, and web experience, including a dedicated /news route, Home preview, live fanout, history pagination, ticker resolution, and replay-mode live-only empty states.\\n\\nAcceptance criteria:\\n- normalized NewsStory contract and live channel exist\\n- ingest-news service backfills and streams Alpaca news\\n- API persists, serves, and fans out news\\n- web app exposes /news plus Home preview and drawer\\n- tests cover types, storage, API, and key UI behaviors\\n- turn documentation is added\\n\\nDesign:\\nReuse Islandflow drawer, chips, panes, and terminal styling; keep news live-only in v1 replay mode.\\n\\nNotes:\\nImplement client-side ticker filtering in v1 and expose latest revision only per provider+story_id.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T20:37:13Z","created_by":"dirtydishes","updated_at":"2026-05-18T20:55:11Z","started_at":"2026-05-18T20:37:20Z","closed_at":"2026-05-18T20:55:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-k8i","title":"Fix duplicate alert context import in API entrypoint","description":"Recent alert-context work introduced a duplicate fetchAlertContextByTraceId import in services/api/src/index.ts, which risks breaking TypeScript compilation and API startup. Remove the duplicate import and validate the affected API/web tests.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T13:01:58Z","created_by":"dirtydishes","updated_at":"2026-05-18T13:03:40Z","started_at":"2026-05-18T13:02:02Z","closed_at":"2026-05-18T13:03:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-lk9","title":"Fix PR creation workflow after Forgejo migration","description":"## Why\\nCreating pull requests with fails after the repository moved primary collaboration from GitHub to Forgejo. The current workflow still assumes GitHub GraphQL PR creation semantics, which do not work against the Forgejo remote.\\n\\n## What\\nInvestigate the current PR creation path, identify remaining GitHub-specific assumptions, and update the repo workflow/scripts/docs so contributors can reliably publish branches and open PRs in the Forgejo-based setup.\\n\\n## Acceptance Criteria\\n- The repo no longer instructs contributors to use a broken GitHub-specific PR creation path for Forgejo branches\\n- There is a documented and preferably scripted way to create the equivalent review request against Forgejo\\n- Validation demonstrates the new workflow behaves correctly or clearly documents any remaining platform limitation","status":"in_progress","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T10:26:47Z","created_by":"dirtydishes","updated_at":"2026-05-18T10:26:53Z","started_at":"2026-05-18T10:26:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-vvw","title":"Stage native public-edge cutover after worker soak","description":"Why this issue exists and what needs to be done:\\n- The native deploy path is now provisioned for worker-first iteration, with checked-in user units, rollback helpers, and edge guardrails\\n- Remaining work is to enable and soak native worker units, validate duplicate-processing behavior, then deliberately cut over the public web/api edge if warranted\\n- Final acceptance should include deciding whether Docker or native becomes the default runtime after operational evidence","notes":"2026-05-18: native infra, native app services, NPM public-edge retargeting, Docker rollback helpers, and Cloudflare/DNS API hostname recovery were implemented and verified. Public checks now pass for flow.deltaisland.io and api.flow.deltaisland.io. Remaining follow-up: decide whether api.flow.deltaisland.io should remain DNS-only or be re-proxied through Cloudflare under islandflow-fl5.","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T07:32:35Z","created_by":"dirtydishes","updated_at":"2026-05-18T23:52:32Z","started_at":"2026-05-18T23:51:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-bsg","title":"Fix public /replay/options proxy regression","description":"Restore correct public routing for GET /replay/options on flow.deltaisland.io. The app currently serves HTML for that API path, which indicates edge/proxy routing drift. Update the live proxy topology or deployment assets as needed, then validate with bun run scripts/check-public-api-routes.ts.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T07:15:19Z","created_by":"dirtydishes","updated_at":"2026-05-18T07:32:51Z","started_at":"2026-05-18T07:15:24Z","closed_at":"2026-05-18T07:32:51Z","close_reason":"Audited the live VPS and reverse proxy on 2026-05-18: public /replay/options now returns JSON, bun run scripts/check-public-api-routes.ts passes, and the active Nginx Proxy Manager config includes /replay in the API route matcher. No in-repo app code change was required.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-1ei","title":"Make deploy helper remote-aware for Forgejo","description":"Why: scripts/deploy.ts hardcodes git remote name origin for fetch/pull/push and branch verification, but this repository now uses forgejo/github remotes and may not have an origin remote. What: update deploy.ts to resolve the deploy git remote robustly (Forgejo-aware), use it across local prechecks, branch publish, and remote rollout git operations, and keep behavior explicit in output.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T03:20:12Z","created_by":"dirtydishes","updated_at":"2026-05-18T03:22:39Z","started_at":"2026-05-18T03:20:16Z","closed_at":"2026-05-18T03:22:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xod","title":"Add --fast mode to deploy helper","description":"Why: full main deploys rebuild all images and run full verification, which is slow for routine rollouts. What: add a --fast flag to scripts/deploy.ts with explicit behavior that short-circuits slow steps while preserving basic safety checks; update help text/docs for discoverability.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T02:50:47Z","created_by":"dirtydishes","updated_at":"2026-05-18T02:53:41Z","started_at":"2026-05-18T02:50:50Z","closed_at":"2026-05-18T02:53:41Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-cif","title":"hydrate alert evidence context from clickhouse","description":"Implement alert detail hydration from ClickHouse with a new context endpoint and frontend drawer evidence resolution. Includes storage lookup by alert trace_id/evidence refs, unresolved refs diagnostics, API route GET /flow/alerts/:trace_id/context, terminal evidence hydration + loading states/copy updates, and tests across storage/api/web.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T00:15:55Z","created_by":"dirtydishes","updated_at":"2026-05-18T00:17:38Z","started_at":"2026-05-18T00:16:00Z","closed_at":"2026-05-18T00:17:38Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-9j5","title":"Prepare PR for deploy allowlist cleanup","description":"Why this issue exists and what needs to be done:\\n- Package current deploy allowlist cleanup into a reviewable PR with multiple commits\\n- Add required turn documentation in docs/turns\\n- Run validation and push all artifacts","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T15:44:12Z","created_by":"dirtydishes","updated_at":"2026-05-17T15:53:55Z","started_at":"2026-05-17T15:44:22Z","closed_at":"2026-05-17T15:53:55Z","close_reason":"Packaged deploy allowlist cleanup into multi-commit PR branch with required turn documentation and push workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-4e9","title":"Polish terminal view","description":"Improve the Islandflow web terminal view with a focused UI polish pass aligned to the product design system.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T15:18:18Z","created_by":"dirtydishes","updated_at":"2026-05-17T15:25:02Z","started_at":"2026-05-17T15:18:21Z","closed_at":"2026-05-17T15:25:02Z","close_reason":"Polished terminal shell styling, responsive Tape actions, and documented the turn.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-lyt","title":"Summarize 2026-05-16 git activity for standup","description":"Create a grounded standup summary for yesterday's git activity, anchored to commits, changed files, and any linked PR context if present. Produce the required HTML document in docs/general and complete the beads + git handoff workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T14:02:57Z","created_by":"dirtydishes","updated_at":"2026-05-17T14:05:37Z","started_at":"2026-05-17T14:03:09Z","closed_at":"2026-05-17T14:05:37Z","close_reason":"Created docs/general standup summary for 2026-05-16 git activity, grounded to commits and changed files, and prepared the repo handoff workflow.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-sz8","title":"Fix public /replay/options proxy regression","description":"## Summary\nThe new deploy-time public route checker added in commit 1424a27 (\"fix durable options history routing\") currently fails against https://flow.deltaisland.io because GET /replay/options returns HTML instead of JSON.\n\n## Evidence\n- `bun run scripts/check-public-api-routes.ts https://flow.deltaisland.io` fails on `/replay/options?view=signal\u0026after_ts=0\u0026after_seq=0\u0026limit=1` with `returned non-JSON content (text/html; charset=UTF-8)`\n- `services/api/src/index.ts` implements `GET /replay/options`, so the HTML response indicates the request is landing on the web app instead of the API service\n- `deployment/docker/README.md` documents that same-origin proxy mode must include `/replay/*` in the API route matcher\n\n## Minimal Fix\nUpdate the live reverse proxy / edge route matcher for flow.deltaisland.io so `/replay/*` is forwarded to the API host, then rerun `bun run check:public-api-routes`.\n\n## Notes\nThis looks like a production proxy configuration regression rather than an in-repo application bug.","status":"open","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-17T13:06:11Z","created_by":"dirtydishes","updated_at":"2026-05-17T13:06:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -94,6 +108,9 @@ {"_type":"issue","id":"islandflow-zs0","title":"Migrate terminal UI to smart-money profiles","description":"Migrate apps/web terminal rendering to consume SmartMoneyEvent directly: primary profile, probability ladder, reason codes, and suppression/abstention state, while preserving legacy alert/classifier displays during the bridge.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:23Z","created_by":"dirtydishes","updated_at":"2026-05-05T05:39:58Z","closed_at":"2026-05-05T05:39:58Z","close_reason":"Completed terminal smart-money profile migration","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"auto-import","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-5rt","title":"Summarize June 2 git activity for standup","description":"Create the daily standup summary in docs/general for 2026-06-02 activity, anchored to yesterday's commits and touched files.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-03T16:30:03Z","created_by":"dirtydishes","updated_at":"2026-06-03T16:31:33Z","started_at":"2026-06-03T16:31:26Z","closed_at":"2026-06-03T16:31:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-3f4","title":"Publish May 31 standup git summary","description":"## Summary\nCreate the daily standup HTML summary for 2026-05-31 git activity in docs/general and regenerate any supporting docs index entries.\n\n## Why this matters\nThe team needs a grounded, commit-anchored standup artifact for May 31 repository activity.\n\n## Scope\nInspect May 31 git history, write the summary document in docs/general, update related generated docs metadata if needed, and close out the task.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-01T13:02:21Z","created_by":"dirtydishes","updated_at":"2026-06-01T13:04:45Z","started_at":"2026-06-01T13:02:29Z","closed_at":"2026-06-01T13:04:45Z","close_reason":"Added docs/general standup summary for 2026-05-31 and verified docs index discovery.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-6ak","title":"Clarify turn doc diff rendering instructions","description":"Make AGENTS.md explicit that turn documents should render diffs with the @pierre/diffs/ssr library import instead of attempting to run @pierre/diffs through bunx.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-30T02:01:59Z","created_by":"dirtydishes","updated_at":"2026-05-30T02:02:27Z","started_at":"2026-05-30T02:02:00Z","closed_at":"2026-05-30T02:02:27Z","close_reason":"Updated AGENTS.md to require @pierre/diffs/ssr rendering, forbid bunx @pierre/diffs attempts, and include a known-good preloadPatchDiff recipe.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-3kn","title":"Summarize 2026-05-28 git activity","description":"Prepare the standup-ready summary of yesterday's git activity, grounded in commits, PRs, and touched files, and store the HTML report in docs/general.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-29T13:02:25Z","created_by":"dirtydishes","updated_at":"2026-05-29T13:04:23Z","started_at":"2026-05-29T13:02:33Z","closed_at":"2026-05-29T13:04:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-3ys","title":"Expand Forgejo CI beyond the fast validate path","description":"Add follow-on Forgejo CI jobs after the initial baseline is stable. This should cover deferred work such as Docker image builds for deployment/docker, service-container integration tests for NATS/Redis/ClickHouse paths, and any later deploy or release automation that should not block the first fast PR gate.","status":"open","priority":3,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-24T00:34:09Z","created_by":"dirtydishes","updated_at":"2026-05-24T00:34:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-cwr","title":"polish terminal navigation drawer motion","description":"The shared terminal navigation drawer opens and closes abruptly because it mounts only while open and unmounts immediately on dismiss. Add calm, reduced-motion-safe drawer and backdrop transitions so the mobile navigation feels intentional without slowing task flow. Include validation for open and dismiss behavior if the existing drawer interaction coverage is touched.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-23T23:58:06Z","created_by":"dirtydishes","updated_at":"2026-05-24T00:05:16Z","started_at":"2026-05-23T23:58:17Z","closed_at":"2026-05-24T00:05:16Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -110,4 +127,6 @@ {"_type":"issue","id":"islandflow-zsy","title":"Expose Forgejo SSH on a direct DNS hostname","description":"git.deltaisland.io currently resolves through Cloudflare's proxy, so SSH on port 2222 does not complete even though the Forgejo container is listening on the host. If SSH-based git/beads workflows are desired, add a DNS-only hostname (or adjust the existing record) that points directly at the server for Forgejo SSH.","status":"open","priority":3,"issue_type":"task","created_at":"2026-05-17T10:34:06Z","created_by":"delta","updated_at":"2026-05-17T10:34:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-38p","title":"Add native deployment unit templates and rollback helpers","description":"The deploy helper now supports --runtime native, but the repo still relies on operator-managed systemd units and manual rollback. Add checked-in native deployment templates or provisioning guidance for the expected units, and consider lightweight rollback/smoke-test helpers once the host-native path is exercised on the real VPS.","status":"open","priority":3,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-15T23:46:42Z","created_by":"dirtydishes","updated_at":"2026-05-15T23:46:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-575","title":"Document smart-money event calendar env","description":"Document smart-money event-calendar environment configuration in env examples and README.\n","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-05T06:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-05T06:57:57Z","started_at":"2026-05-05T06:57:17Z","closed_at":"2026-05-05T06:57:57Z","close_reason":"Documented event-calendar env variables","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-a1m","title":"Publish June 3 standup summary","description":"Why this issue exists and what needs to be done:\\n- Produce the daily standup summary for git activity on 2026-06-03.\\n- Ground every statement in commits and touched files only.\\n- Save the HTML artifact under docs/general and complete the automation handoff workflow.","status":"closed","priority":4,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-04T13:02:04Z","created_by":"dirtydishes","updated_at":"2026-06-04T13:03:43Z","started_at":"2026-06-04T13:03:34Z","closed_at":"2026-06-04T13:03:43Z","close_reason":"Created docs/general/2026-06-04-standup-summary-2026-06-03.html with a commit-grounded summary of June 3 git activity.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-0jb","title":"Publish June 1 standup summary","description":"Why this issue exists and what needs to be done:\\n- Produce the daily standup summary for git activity on 2026-06-01.\\n- Ground every statement in commits and touched files only.\\n- Save the HTML artifact under docs/general and complete the automation handoff workflow.","status":"closed","priority":4,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-02T13:03:01Z","created_by":"dirtydishes","updated_at":"2026-06-02T13:05:51Z","started_at":"2026-06-02T13:03:16Z","closed_at":"2026-06-02T13:05:51Z","close_reason":"Created docs/general/2026-06-02-standup-summary-2026-06-01.html with a commit-grounded June 1 standup summary.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-1tu","title":"Publish 2026-05-24 standup summary","description":"Why this issue exists and what needs to be done\n\nCreate the daily standup summary for git activity on 2026-05-24, grounded in commits and touched files, then store the HTML report in docs/general.","status":"closed","priority":4,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:02:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:04:31Z","closed_at":"2026-05-25T13:04:31Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c746164..01724f6 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -30,17 +30,27 @@ jobs: apt-get install --yes --no-install-recommends curl unzip rm -rf /var/lib/apt/lists/* curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" ~/.bun/bin/bun --version - name: Install dependencies run: ~/.bun/bin/bun install --frozen-lockfile + - name: Check formatting + run: ~/.bun/bin/bun run fmt:check + + - name: Run lint + run: ~/.bun/bin/bun run lint + - name: Run typecheck run: ~/.bun/bin/bun run typecheck - name: Run tests run: ~/.bun/bin/bun test + - name: Check public API routes + run: ~/.bun/bin/bun run check:public-api-routes + - name: Check Docker workspace snapshot run: ~/.bun/bin/bun run check:docker-workspace diff --git a/AGENTS.md b/AGENTS.md index 9a0234c..72a0e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,22 +86,15 @@ Agent expectations: ## Required Turn Documentation -At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. +Follow the global turn-documentation rules in `~/.codex/AGENTS.md` for repository implementation tasks, plan documents, and non-repo computer tasks. -This documentation is mandatory whenever code, configuration, tests, or project files were changed. +For this repository, the repo-specific requirements are: -### Precedence and classification - -Use this decision order before creating a turn document: - -1. Check the minor/trivial exemption checklist below first. -2. If the task clearly matches an exemption, do not create a turn document. -3. If the task is a clearly substantive implementation change, create a turn document. -4. If classification is ambiguous or mixed, ask the user before creating a turn document. - -The minor/trivial exemptions override the general mandatory turn-document rule. - -For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. If `@pierre/diffs` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. +- Save repository implementation turn documents in `docs/turns/`. +- Use the `impeccable` skill to structure and style repository implementation turn documents when available. +- Render "Relevant Diff Snippets" with `@pierre/diffs/ssr`; use https://diffs.com/docs as the SSR reference. +- For minor updates to a previous change, update the existing turn document instead of creating a new one. +- The minor/trivial exemptions below override the general documentation requirement for this repository. ### No turn document for minor/trivial checklist matches @@ -116,69 +109,9 @@ Do not create a turn document when the change is minor/trivial and cleanly match If a change does not cleanly fit either exempt or substantive buckets, ask the user before creating a turn document. -### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: - -**"New Changes as of {time and date at which the change was made}"** -- **Summary of changes** -- **Why this change was made** -- **Code diffs** (use `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) -- **Related issues or PRs** - -Additionally, add a note to each section explaining why the changes were made. - -### Location - -Save the document in: - -```text -docs/turns/ -``` - -Use a clear timestamped filename: - -```text -docs/turns/YYYY-MM-DD-short-task-name.html -``` - -Example: - -```text -docs/turns/2026-05-14-add-market-replay-controls.html -``` - -### Format - -Use the `impeccable` skill to structure and style the document as clean, readable HTML. - -For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. - -If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: - -- A concise summary at the top -- A detailed explanation of what changed -- Relevant context or background -- Specific code snippets or examples when helpful -- Issues, limitations, tradeoffs, or mitigations -- Validation performed, including tests, builds, linters, or manual checks -- Any remaining follow-up work, with corresponding Beads issue IDs when applicable - -### Required Sections - -Each turn document must include these sections: - -1. **Summary** -2. **Changes Made** -3. **Context** -4. **Important Implementation Details** -5. **Relevant Diff Snippets** (render with `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) -6. **Expected Impact for End-Users** -7. **Validation** -8. **Issues, Limitations, and Mitigations** -9. **Follow-up Work** - ### Completion Rule -A task that requires a turn document is not complete until: +For repository implementation tasks that require turn documentation, the task is not complete until: 1. The Beads workflow is updated 2. The turn document is created in `docs/turns` @@ -187,40 +120,3 @@ A task that requires a turn document is not complete until: 5. `bd dolt push` succeeds 6. `git push forgejo ` succeeds 7. `git status` shows the branch is up to date with `forgejo/` - -For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. - -## Plan Mode Documentation - -When working in plan mode, do not modify implementation files. - -At the end of plan mode, provide a concise summary of the plan and ask the user whether they want to proceed with implementation. - -If the user asks to save the plan, create a user-readable HTML plan document in: - -```text -docs/plans/ -``` - -Use a clear timestamped filename: - -```text -docs/plans/YYYY-MM-DD-short-plan-name.html -``` - -The plan document should be labeled clearly as a plan and should include: - -1. **Plan Summary** -2. **Goals** -3. **Proposed Changes** -4. **Relevant Context** -5. **Implementation Steps** -6. **Risks, Limitations, and Mitigations** -7. **Open Questions** - -Always do the following when you finish a task, finish the beads workflow and and make a commit: -- Document the changes in a user-readable format -- Use the impeccable skill to structure the document as HTML -- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. -- Note any relevant issues or limitations that were addressed or mitigated by the changes. -- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html diff --git a/apps/web/app/api/admin/synthetic/control/route.ts b/apps/web/app/api/admin/synthetic/control/route.ts index 09f5629..578df3a 100644 --- a/apps/web/app/api/admin/synthetic/control/route.ts +++ b/apps/web/app/api/admin/synthetic/control/route.ts @@ -9,11 +9,8 @@ export async function GET(): Promise { } export async function PUT(req: Request): Promise { - return proxySyntheticAdminRequest( - "/admin/synthetic/control", - { - method: "PUT", - body: await req.text() - } - ); + return proxySyntheticAdminRequest("/admin/synthetic/control", { + method: "PUT", + body: await req.text() + }); } diff --git a/apps/web/app/api/admin/synthetic/routes.test.ts b/apps/web/app/api/admin/synthetic/routes.test.ts index eec575d..ee50525 100644 --- a/apps/web/app/api/admin/synthetic/routes.test.ts +++ b/apps/web/app/api/admin/synthetic/routes.test.ts @@ -1,8 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { - getSyntheticAdminProxyConfig, - isSyntheticAdminFeatureEnabled -} from "./shared"; +import { getSyntheticAdminProxyConfig, isSyntheticAdminFeatureEnabled } from "./shared"; const originalFetch = globalThis.fetch; diff --git a/apps/web/app/dashboard-mocks.tsx b/apps/web/app/dashboard-mocks.tsx index 101141c..1c23bb1 100644 --- a/apps/web/app/dashboard-mocks.tsx +++ b/apps/web/app/dashboard-mocks.tsx @@ -18,25 +18,29 @@ const variants: Record< > = { mock1: { title: "Command Deck", - premise: "Closest to the reference: left navigation, ticker ribbon, dense evidence panes, replay rail.", + premise: + "Closest to the reference: left navigation, ticker ribbon, dense evidence panes, replay rail.", mode: "Dense ops", layout: "classic" }, mock2: { title: "Investigation Stack", - premise: "A calmer analyst layout with the selected symbol story in the center and context wrapped around it.", + premise: + "A calmer analyst layout with the selected symbol story in the center and context wrapped around it.", mode: "Forensic", layout: "focus" }, mock3: { title: "Signal Wall", - premise: "Prioritizes alert triage and cross-symbol scanning before a user drills into price action.", + premise: + "Prioritizes alert triage and cross-symbol scanning before a user drills into price action.", mode: "Triage", layout: "signals" }, mock4: { title: "Replay Lab", - premise: "A replay-first structure with timeline, event tape, and causality context always visible.", + premise: + "A replay-first structure with timeline, event tape, and causality context always visible.", mode: "Replay", layout: "replay" } @@ -93,7 +97,10 @@ export function DashboardMock({ variant }: DashboardMockProps) { const config = variants[variant]; return ( -
+
{variant === "mock1" ? : null} @@ -277,7 +284,11 @@ function OptionTape({ condensed = false }: { condensed?: boolean }) { function ChartPanel({ compact = false }: { compact?: boolean }) { return ( - +
194.88 +2.34 (+1.22%) @@ -306,16 +317,24 @@ function ChartPanel({ compact = false }: { compact?: boolean }) { function SignalPanel({ hero = false }: { hero?: boolean }) { return ( - +
{signals.map(([time, title, symbol, value, tag]) => (
{title} - {symbol} / {value} + + {symbol} / {value} +
- + {tag}
@@ -332,7 +351,9 @@ function FeedHealth() { {feedHealth.map(([feed, status, lag, rate]) => (
{feed} - {status} + + {status} + {lag} {rate}/s
@@ -350,7 +371,9 @@ function DarkFlow() {
{time} {symbol} - {side} + + {side} + {size} {notional} {type} @@ -402,7 +425,11 @@ function EventContext() { function ReplayRail({ compact = false }: { compact?: boolean }) { return ( - +
@@ -430,8 +457,9 @@ function SymbolBrief() { +1.22%

- Dark sweep pressure aligns with short-window momentum and a fresh news catalyst. Context confidence is high, but - the largest block remains off-exchange and should be checked against next print behavior. + Dark sweep pressure aligns with short-window momentum and a fresh news catalyst. Context + confidence is high, but the largest block remains off-exchange and should be checked against + next print behavior.

Bullish @@ -444,7 +472,12 @@ function SymbolBrief() { function Sparkline({ direction }: { direction: string }) { return ( - + span { @@ -1772,17 +1817,39 @@ h3 { font-variant-numeric: tabular-nums; } -.classifier-green { --classifier-rgb: 37, 193, 122; } -.classifier-red { --classifier-rgb: 255, 107, 95; } -.classifier-amber { --classifier-rgb: 245, 166, 35; } -.classifier-copper { --classifier-rgb: 198, 122, 75; } -.classifier-blue { --classifier-rgb: 77, 163, 255; } -.classifier-teal { --classifier-rgb: 64, 210, 190; } -.classifier-yellowgreen { --classifier-rgb: 174, 210, 78; } -.classifier-violet { --classifier-rgb: 170, 130, 255; } -.classifier-cyan { --classifier-rgb: 94, 214, 255; } -.classifier-magenta { --classifier-rgb: 255, 92, 205; } -.classifier-neutral { --classifier-rgb: 192, 200, 210; } +.classifier-green { + --classifier-rgb: 37, 193, 122; +} +.classifier-red { + --classifier-rgb: 255, 107, 95; +} +.classifier-amber { + --classifier-rgb: 245, 166, 35; +} +.classifier-copper { + --classifier-rgb: 198, 122, 75; +} +.classifier-blue { + --classifier-rgb: 77, 163, 255; +} +.classifier-teal { + --classifier-rgb: 64, 210, 190; +} +.classifier-yellowgreen { + --classifier-rgb: 174, 210, 78; +} +.classifier-violet { + --classifier-rgb: 170, 130, 255; +} +.classifier-cyan { + --classifier-rgb: 94, 214, 255; +} +.classifier-magenta { + --classifier-rgb: 255, 92, 205; +} +.classifier-neutral { + --classifier-rgb: 192, 200, 210; +} .contract, .drawer-row-title { @@ -1932,7 +1999,9 @@ h3 { opacity: 0; pointer-events: none; transform: translateY(8px); - transition: opacity 0.15s ease, transform 0.15s ease; + transition: + opacity 0.15s ease, + transform 0.15s ease; z-index: 5; } @@ -2021,16 +2090,11 @@ h3 { } .empty { - display: flex; - align-items: center; - min-height: 76px; padding: 18px; border-radius: 12px; border: 1px dashed var(--border); background: var(--bg-soft); color: var(--text-dim); - line-height: 1.4; - overflow-wrap: anywhere; } .drawer { @@ -2063,7 +2127,10 @@ h3 { color: var(--text-dim); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28); z-index: 45; - transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease; + transition: + border-color 0.16s ease, + background-color 0.16s ease, + color 0.16s ease; } .synthetic-control-gear:hover, @@ -2229,7 +2296,9 @@ h3 { background: oklch(0.18 0.012 250 / 0.6); color: var(--text); text-align: left; - transition: border-color 150ms ease, background 150ms ease; + transition: + border-color 150ms ease, + background 150ms ease; } .news-row:hover { @@ -2301,7 +2370,6 @@ h3 { .synthetic-control-error { color: var(--red); - overflow-wrap: anywhere; } .drawer-header { @@ -2490,10 +2558,6 @@ h3 { min-height: 0; } - .page-grid-options > .terminal-pane { - height: clamp(430px, 68svh, 720px); - } - .command-deck-grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: @@ -2541,7 +2605,11 @@ h3 { @media (max-width: 720px) { .terminal-shell { - background-size: 24px 24px, 24px 24px, 100% 100%, auto; + background-size: + 24px 24px, + 24px 24px, + 100% 100%, + auto; } .terminal-nav-drawer { @@ -2568,7 +2636,7 @@ h3 { } .terminal-content { - padding: 18px 10px calc(22px + env(safe-area-inset-bottom)); + padding: 16px 10px 22px; } .page-shell { @@ -2620,19 +2688,11 @@ h3 { position: sticky; top: 0; z-index: 30; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - column-gap: 10px; - row-gap: 16px; - padding: 10px 10px 12px; + padding: 12px 10px; } .terminal-topbar-leading { - width: auto; - min-width: 0; - grid-column: 1; - grid-row: 1; + width: 100%; } .terminal-button, @@ -2651,50 +2711,30 @@ h3 { .terminal-topbar-actions, .terminal-topbar-controls, .terminal-topbar-mode { - min-width: 0; + width: 100%; justify-content: flex-start; } - .terminal-topbar-actions { - display: contents; - } - + .terminal-topbar-actions, .terminal-topbar-controls { - width: 100%; - grid-column: 1 / -1; - grid-row: 2; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 10px; - } - - .terminal-topbar-mode { - grid-column: 2; - grid-row: 1; - width: auto; - justify-content: flex-end; + flex-direction: column; + align-items: stretch; } .terminal-menu-trigger { - width: auto; + width: 100%; justify-content: center; } .terminal-topbar-mode .terminal-button, + .terminal-topbar-controls > .terminal-button, .terminal-topbar-leading > .terminal-button, .page-actions > .terminal-button, .page-actions > .flow-filter-popover { width: 100%; } - .terminal-topbar-controls > .terminal-button { - width: auto; - min-width: 76px; - } - .instrument-focus-chip { - grid-column: 1 / -1; max-width: none; min-height: 44px; justify-content: space-between; @@ -2718,10 +2758,6 @@ h3 { border-radius: 12px; } - .page-grid-options > .terminal-pane { - height: clamp(390px, 62svh, 620px); - } - .terminal-pane-head, .terminal-pane-body { padding: 14px 12px; @@ -2753,7 +2789,6 @@ h3 { width: 100%; flex-direction: column; align-items: stretch; - margin-top: 2px; } .flow-filter-popover { @@ -2791,19 +2826,6 @@ h3 { margin-inline: -12px; border-radius: 0; scroll-snap-type: x proximity; - scrollbar-gutter: stable; - overscroll-behavior-x: contain; - -webkit-overflow-scrolling: touch; - } - - .data-table-wrap::after { - content: ""; - position: sticky; - right: 0; - z-index: 5; - flex: 0 0 18px; - pointer-events: none; - background: linear-gradient(90deg, transparent, oklch(0.12 0.01 250 / 0.92)); } .data-table { @@ -2821,39 +2843,6 @@ h3 { padding-inline: 8px; } - .data-table-head .data-table-cell:first-child, - .data-table-row .data-table-cell:first-child { - position: sticky; - left: 0; - z-index: 4; - margin-left: -8px; - padding-left: 8px; - background: oklch(0.13 0.01 250); - box-shadow: - 1px 0 0 oklch(0.72 0.012 250 / 0.14), - 14px 0 18px oklch(0.06 0.01 250 / 0.42); - } - - .data-table-head .data-table-cell:first-child { - z-index: 6; - background: oklch(0.15 0.012 250); - } - - .data-table-row.is-even .data-table-cell:first-child { - background: oklch(0.145 0.011 250); - } - - .data-table-row:hover .data-table-cell:first-child, - .data-table-row:focus-visible .data-table-cell:first-child { - background: oklch(0.18 0.025 74); - } - - .data-table-row-classified .data-table-cell:first-child { - background: - linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.1)), transparent 90%), - oklch(0.13 0.01 250); - } - .data-table-row-options, .data-table-row-equities { height: 40px; @@ -2916,22 +2905,6 @@ h3 { } @media (max-width: 420px) { - .terminal-topbar { - column-gap: 8px; - row-gap: 14px; - padding-inline: 8px; - } - - .terminal-menu-trigger { - min-width: 92px; - padding-inline: 8px; - } - - .terminal-topbar-mode .terminal-button { - min-width: 82px; - padding-inline: 8px; - } - .terminal-content { padding-inline: 8px; } @@ -2993,9 +2966,7 @@ h3 { width: 34px; height: 34px; border-radius: 9px; - background: - linear-gradient(135deg, oklch(0.68 0.14 246), oklch(0.68 0.12 164)), - var(--blue-soft); + background: linear-gradient(135deg, oklch(0.68 0.14 246), oklch(0.68 0.12 164)), var(--blue-soft); box-shadow: inset 0 0 0 1px oklch(0.94 0.02 240 / 0.24); } diff --git a/apps/web/app/routes.test.ts b/apps/web/app/routes.test.ts index e217748..5206d51 100644 --- a/apps/web/app/routes.test.ts +++ b/apps/web/app/routes.test.ts @@ -4,7 +4,8 @@ const redirect = mock((path: string) => { throw new Error(`NEXT_REDIRECT:${path}`); }); -mock.module("next/navigation", () => ({ redirect })); +mock.module("next/navigation", () => ({ default: { redirect }, redirect })); +mock.module("next/navigation.js", () => ({ default: { redirect }, redirect })); describe("legacy page redirects", () => { beforeEach(() => { diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 1c9dc6c..d396602 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -1,10 +1,39 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, mock } from "bun:test"; import { getSubscriptionKey as getLiveSubscriptionKey } from "@islandflow/types"; -import { + +const redirect = mock((path: string) => { + throw new Error(`NEXT_REDIRECT:${path}`); +}); + +const nextNavigationMock = { + default: { + redirect, + usePathname: () => "/options" + }, + redirect, + usePathname: () => "/options" +}; + +const nextNavigationResolved = import.meta.resolve("next/navigation"); +const nextNavigationJsResolved = import.meta.resolve("next/navigation.js"); + +mock.module("next/navigation", () => ({ + ...nextNavigationMock +})); +mock.module("next/navigation.js", () => ({ + ...nextNavigationMock +})); +mock.module(nextNavigationResolved, () => ({ + ...nextNavigationMock +})); +mock.module(nextNavigationJsResolved, () => ({ + ...nextNavigationMock +})); + +const { NAV_ITEMS, appendHistoryTail, buildAlertContextPath, - buildTapeStatusAnnouncement, buildDefaultFlowFilters, buildOptionTapeQueryParams, classifierToneForFamily, @@ -14,7 +43,6 @@ import { countActiveFlowFilterGroups, filterOptionTapeItems, findAnchorRestoreIndex, - formatUiErrorMessage, formatCompactUsd, formatOptionContractLabel, flushPausableTapeData, @@ -51,54 +79,7 @@ import { resolveAlertFlowPacket, statusLabel, toggleFilterValue -} from "./terminal"; - -describe("tape status hardening", () => { - it("builds a screen-reader announcement with replay state and queued rows", () => { - expect( - buildTapeStatusAnnouncement({ - status: "connected", - replayTime: null, - replayComplete: false, - paused: true, - dropped: 12, - mode: "replay" - }) - ).toBe("Replay feed paused, time not available, 12 queued rows"); - }); - - it("announces stale live feeds without relying on the colored dot", () => { - expect( - buildTapeStatusAnnouncement({ - status: "stale", - replayTime: null, - replayComplete: false, - paused: false, - dropped: 0, - mode: "live" - }) - ).toBe("Live feed behind"); - }); -}); - -describe("terminal error message hardening", () => { - it("normalizes whitespace and clamps oversized messages before rendering", () => { - const longMessage = `API failed\n\n${"x".repeat(320)}`; - - const formatted = formatUiErrorMessage(longMessage); - - expect(formatted).toHaveLength(240); - expect(formatted).toStartWith("API failed x"); - expect(formatted).toEndWith("..."); - expect(formatted).not.toContain("\n"); - }); - - it("uses a fallback when an error payload is empty", () => { - expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe( - "Synthetic status could not be loaded" - ); - }); -}); +} = await import("./terminal"); const makeItem = (traceId: string, seq: number, ts: number) => ({ trace_id: traceId, @@ -330,12 +311,16 @@ describe("live manifest", () => { }); it("includes news subscriptions on home and /news", () => { - expect(getLiveManifest("/", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toContain( - "news" - ); - expect(getLiveManifest("/news", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toEqual([ - "news" - ]); + expect( + getLiveManifest("/", "SPY", 60000, buildDefaultFlowFilters()).map( + (subscription) => subscription.channel + ) + ).toContain("news"); + expect( + getLiveManifest("/news", "SPY", 60000, buildDefaultFlowFilters()).map( + (subscription) => subscription.channel + ) + ).toEqual(["news"]); }); it("scopes /charts subscriptions to chart channels only", () => { @@ -539,12 +524,36 @@ describe("route feature map", () => { describe("fixed tape virtualization config", () => { it("uses expected fixed row heights and overscan by table", () => { - expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 44, debugLabel: "options" }); - expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 36, debugLabel: "equities" }); - expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "flow" }); - expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "alerts" }); - expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "classifier" }); - expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "dark" }); + expect(getTapeVirtualConfig("options")).toEqual({ + rowHeight: 36, + overscan: 44, + debugLabel: "options" + }); + expect(getTapeVirtualConfig("equities")).toEqual({ + rowHeight: 36, + overscan: 36, + debugLabel: "equities" + }); + expect(getTapeVirtualConfig("flow")).toEqual({ + rowHeight: 44, + overscan: 24, + debugLabel: "flow" + }); + expect(getTapeVirtualConfig("alerts")).toEqual({ + rowHeight: 44, + overscan: 24, + debugLabel: "alerts" + }); + expect(getTapeVirtualConfig("classifier")).toEqual({ + rowHeight: 44, + overscan: 24, + debugLabel: "classifier" + }); + expect(getTapeVirtualConfig("dark")).toEqual({ + rowHeight: 44, + overscan: 24, + debugLabel: "dark" + }); }); }); @@ -731,7 +740,11 @@ describe("live tape history helpers", () => { }); it("promotes hot-window overflow into the history tail", () => { - const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)]; + const currentHot = [ + makeItem("hot-3", 3, 300), + makeItem("hot-2", 2, 200), + makeItem("hot-1", 1, 100) + ]; const incoming = [makeItem("hot-4", 4, 400)]; const { kept, evicted } = mergeNewestWithOverflow(incoming, currentHot, 3); @@ -746,7 +759,11 @@ describe("live tape history helpers", () => { let history: Array> = []; for (let seq = 1; seq <= 5; seq += 1) { - const { kept, evicted } = mergeNewestWithOverflow([makeItem(`row-${seq}`, seq, seq * 100)], hot, 2); + const { kept, evicted } = mergeNewestWithOverflow( + [makeItem(`row-${seq}`, seq, seq * 100)], + hot, + 2 + ); hot = kept; history = appendHistoryTail(history, evicted, hot, 5000); } @@ -781,13 +798,24 @@ describe("live tape history helpers", () => { }); it("dedupes the seam between promoted overflow and fetched history", () => { - const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)]; + const currentHot = [ + makeItem("hot-3", 3, 300), + makeItem("hot-2", 2, 200), + makeItem("hot-1", 1, 100) + ]; const { kept, evicted } = mergeNewestWithOverflow([makeItem("hot-4", 4, 400)], currentHot, 3); const promoted = appendHistoryTail([], evicted, kept, 5000); - const merged = appendHistoryTail(promoted, [makeItem("hot-1", 1, 100), makeItem("older", 0, 50)], kept, 5000); + const merged = appendHistoryTail( + promoted, + [makeItem("hot-1", 1, 100), makeItem("older", 0, 50)], + kept, + 5000 + ); expect(merged.map((item) => item.trace_id)).toEqual(["hot-1", "older"]); - expect(new Set([...kept, ...merged].map((item) => item.trace_id)).size).toBe(kept.length + merged.length); + expect(new Set([...kept, ...merged].map((item) => item.trace_id)).size).toBe( + kept.length + merged.length + ); }); it("trims the history tail to the soft cap", () => { @@ -840,10 +868,9 @@ describe("live tape history helpers", () => { makeItem("hist-2", 2, 200) ]; - expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([ - "hist-3", - "hist-2" - ]); + expect( + mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id) + ).toEqual(["hist-3", "hist-2"]); }); it("appends truly older lazy-loaded rows to the held history tail", () => { @@ -856,12 +883,9 @@ describe("live tape history helpers", () => { makeItem("older-0", 0, 50) ]; - expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([ - "hist-3", - "hist-2", - "older-1", - "older-0" - ]); + expect( + mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id) + ).toEqual(["hist-3", "hist-2", "older-1", "older-0"]); }); it("resyncs buffered live history by replacing the held segment after resume", () => { @@ -874,7 +898,12 @@ describe("live tape history helpers", () => { const resynced = appendHistoryTail([], [makeItem("overflow-newer", 6, 600), ...held], [], 0); expect(held.map((item) => item.trace_id)).toEqual(["hist-3", "hist-2", "older-1"]); - expect(resynced.map((item) => item.trace_id)).toEqual(["overflow-newer", "hist-3", "hist-2", "older-1"]); + expect(resynced.map((item) => item.trace_id)).toEqual([ + "overflow-newer", + "hist-3", + "hist-2", + "older-1" + ]); }); }); @@ -954,9 +983,21 @@ describe("classifier row decoration helpers", () => { it("selects primary hits by confidence, source timestamp, then seq", () => { const hit = selectPrimaryClassifierHit([ - { ...makeAlert({ classifier_id: "old", confidence: 0.9, source_ts: 1_000, seq: 1 }), direction: "bullish", explanations: [] }, - { ...makeAlert({ classifier_id: "new", confidence: 0.9, source_ts: 2_000, seq: 1 }), direction: "bullish", explanations: [] }, - { ...makeAlert({ classifier_id: "low", confidence: 0.5, source_ts: 3_000, seq: 9 }), direction: "bullish", explanations: [] } + { + ...makeAlert({ classifier_id: "old", confidence: 0.9, source_ts: 1_000, seq: 1 }), + direction: "bullish", + explanations: [] + }, + { + ...makeAlert({ classifier_id: "new", confidence: 0.9, source_ts: 2_000, seq: 1 }), + direction: "bullish", + explanations: [] + }, + { + ...makeAlert({ classifier_id: "low", confidence: 0.5, source_ts: 3_000, seq: 9 }), + direction: "bullish", + explanations: [] + } ]); expect(hit?.classifier_id).toBe("new"); @@ -1029,9 +1070,9 @@ describe("signals helpers", () => { ) ).toBe("bearish"); - expect(deriveAlertDirection(makeAlert({ hits: [{ direction: "weird", confidence: 0.4 }] }))).toBe( - "neutral" - ); + expect( + deriveAlertDirection(makeAlert({ hits: [{ direction: "weird", confidence: 0.4 }] })) + ).toBe("neutral"); expect(deriveAlertDirection(makeAlert({ hits: [] }))).toBe("neutral"); }); diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 694a353..d7afe6e 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import * as nextNavigation from "next/navigation"; import { createContext, memo, @@ -16,7 +16,6 @@ import { type CSSProperties, type Dispatch, type MouseEvent as ReactMouseEvent, - type RefObject, type ReactNode, type SetStateAction } from "react"; @@ -55,7 +54,12 @@ import { matchesFlowPacketFilters, matchesOptionPrintFilters } from "@islandflow/types"; -import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts"; +import { + createChart, + type IChartApi, + type SeriesMarker, + type UTCTimestamp +} from "lightweight-charts"; const parseBoundedInt = ( value: string | undefined, @@ -392,115 +396,6 @@ const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = []; const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = []; const EMPTY_NEWS_STORIES: NewsStory[] = []; -const TABBABLE_SELECTOR = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled]):not([type='hidden'])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex='-1'])" -].join(","); - -export const isElementTabbable = (element: HTMLElement): boolean => { - if (element.hasAttribute("disabled") || element.getAttribute("aria-hidden") === "true") { - return false; - } - - const tabIndex = element.getAttribute("tabindex"); - if (tabIndex && Number(tabIndex) < 0) { - return false; - } - - return Boolean(element.offsetParent || element.getClientRects().length > 0); -}; - -export const getTabbableElements = (root: HTMLElement): HTMLElement[] => { - return Array.from(root.querySelectorAll(TABBABLE_SELECTOR)).filter(isElementTabbable); -}; - -const useModalFocusTrap = ( - active: boolean, - rootRef: RefObject, - onClose: () => void, - restoreFocusRef?: RefObject -) => { - const fallbackFocusRef = useRef(null); - - useLayoutEffect(() => { - if (!active) { - return; - } - - fallbackFocusRef.current = - restoreFocusRef?.current ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null); - const root = rootRef.current; - if (!root) { - return; - } - - const focusTarget = getTabbableElements(root)[0] ?? root; - focusTarget.focus({ preventScroll: true }); - - return () => { - const restoreTarget = restoreFocusRef?.current ?? fallbackFocusRef.current; - if (restoreTarget?.isConnected) { - restoreTarget.focus({ preventScroll: true }); - } - fallbackFocusRef.current = null; - }; - }, [active, restoreFocusRef, rootRef]); - - useEffect(() => { - if (!active) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - const root = rootRef.current; - if (!root) { - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - return; - } - - if (event.key !== "Tab") { - return; - } - - const tabbable = getTabbableElements(root); - if (tabbable.length === 0) { - event.preventDefault(); - root.focus({ preventScroll: true }); - return; - } - - const first = tabbable[0]; - const last = tabbable[tabbable.length - 1]; - const activeElement = document.activeElement; - - if (event.shiftKey && activeElement === first) { - event.preventDefault(); - last.focus({ preventScroll: true }); - } else if (!event.shiftKey && activeElement === last) { - event.preventDefault(); - first.focus({ preventScroll: true }); - } else if (!root.contains(activeElement)) { - event.preventDefault(); - first.focus({ preventScroll: true }); - } - }; - - document.addEventListener("keydown", handleKeyDown, true); - return () => { - document.removeEventListener("keydown", handleKeyDown, true); - }; - }, [active, onClose, rootRef]); -}; - type CandlestickSeries = ReturnType; type EquityOverlayPoint = { @@ -617,20 +512,6 @@ const sampleToLimit = (items: T[], limit: number): T[] => { return sampled; }; -export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => { - const raw = - message instanceof Error - ? message.message - : typeof message === "string" - ? message - : String(message ?? ""); - const normalized = raw.replace(/\s+/g, " ").trim(); - if (!normalized) { - return fallback; - } - return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized; -}; - const readErrorDetail = async (response: Response): Promise => { const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; const text = await response.text(); @@ -654,9 +535,9 @@ const readErrorDetail = async (response: Response): Promise => { error?: string; message?: string; }; - return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`); + return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`; } catch { - return formatUiErrorMessage(`${statusLabel}: ${truncated}`); + return `${statusLabel}: ${truncated}`; } }; @@ -780,8 +661,9 @@ const frontendTapeDebugMetrics: Record = { const bumpTapeDebugMetric = (key: TapeDebugMetricKey, count = 1): void => { frontendTapeDebugMetrics[key] += count; if (DEV_TAPE_DEBUG && typeof window !== "undefined") { - (window as typeof window & { __IF_TAPE_DEBUG__?: Record }).__IF_TAPE_DEBUG__ = - frontendTapeDebugMetrics; + ( + window as typeof window & { __IF_TAPE_DEBUG__?: Record } + ).__IF_TAPE_DEBUG__ = frontendTapeDebugMetrics; } }; @@ -1171,9 +1053,8 @@ const buildApiUrl = (path: string): string => { return `${httpProtocol}://${host}${path}`; }; -export const isSyntheticAdminVisible = ( - value = process.env.NEXT_PUBLIC_SYNTHETIC_ADMIN -): boolean => value === "1"; +export const isSyntheticAdminVisible = (value = process.env.NEXT_PUBLIC_SYNTHETIC_ADMIN): boolean => + value === "1"; type SyntheticAdminStatusResponse = { enabled: boolean; @@ -1206,10 +1087,7 @@ const SYNTHETIC_PROFILE_ORDER: Array = { +const SYNTHETIC_PROFILE_LABELS: Record = { institutional_directional: "Institutional Directional", retail_whale: "Retail Whale", event_driven: "Event Driven", @@ -1390,10 +1268,17 @@ export const formatNewsTimestamp = (ts: number, now = Date.now()): string => { const date = new Date(ts); return isSameLocalDay(ts, now) ? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) - : date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); + : date.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit" + }); }; -const sanitizeNewsHtml = (value: string): { html: string; fallbackText: string; sanitized: boolean } => { +const sanitizeNewsHtml = ( + value: string +): { html: string; fallbackText: string; sanitized: boolean } => { const fallbackText = value .replace(//gi, " ") .replace(//gi, " ") @@ -1407,7 +1292,10 @@ const sanitizeNewsHtml = (value: string): { html: string; fallbackText: string; .replace(//gi, "") .replace(/\son\w+=(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") .replace(/\shref=(["'])javascript:[\s\S]*?\1/gi, ' href="#"') - .replace(/<(?!\/?(p|div|section|article|span|strong|em|b|i|ul|ol|li|br|a|h1|h2|h3|h4|blockquote)\b)[^>]*>/gi, ""); + .replace( + /<(?!\/?(p|div|section|article|span|strong|em|b|i|ul|ol|li|br|a|h1|h2|h3|h4|blockquote)\b)[^>]*>/gi, + "" + ); return { html: sanitized, fallbackText, sanitized: true }; } catch { return { html: "", fallbackText, sanitized: false }; @@ -1474,9 +1362,11 @@ export const deriveAlertDirection = (alert: AlertEvent): "bullish" | "bearish" | totals[direction].confidence += Number.isFinite(hit.confidence) ? hit.confidence : 0; } - const ranked = (Object.entries(totals) as Array< - ["bullish" | "bearish" | "neutral", { count: number; confidence: number }] - >).sort((a, b) => { + const ranked = ( + Object.entries(totals) as Array< + ["bullish" | "bearish" | "neutral", { count: number; confidence: number }] + > + ).sort((a, b) => { if (b[1].count !== a[1].count) { return b[1].count - a[1].count; } @@ -1490,7 +1380,10 @@ export const getAlertWindowAnchorTs = (alerts: AlertEvent[], fallbackNow = Date. if (alerts.length === 0) { return fallbackNow; } - return alerts.reduce((max, alert) => Math.max(max, alert.source_ts), alerts[0]?.source_ts ?? fallbackNow); + return alerts.reduce( + (max, alert) => Math.max(max, alert.source_ts), + alerts[0]?.source_ts ?? fallbackNow + ); }; const extractUnderlying = (contractId: string): string => { @@ -1634,14 +1527,13 @@ export const buildDefaultFlowFilters = (): OptionFlowFilters => ({ nbboSides: DEFAULT_FLOW_SIDES, optionTypes: DEFAULT_FLOW_OPTION_TYPES, minNotional: - FLOW_FILTER_PRESET === "all" - ? undefined - : FLOW_FILTER_PRESET === "balanced" - ? 5_000 - : undefined + FLOW_FILTER_PRESET === "all" ? undefined : FLOW_FILTER_PRESET === "balanced" ? 5_000 : undefined }); -const sameFilterValues = (left: T[] | undefined, right: T[] | undefined): boolean => { +const sameFilterValues = ( + left: T[] | undefined, + right: T[] | undefined +): boolean => { const leftValues = [...(left ?? [])].sort(); const rightValues = [...(right ?? [])].sort(); if (leftValues.length !== rightValues.length) { @@ -1840,7 +1732,7 @@ export const classifierToneForFamily = (classifierId: string): string => CLASSIFIER_FAMILY_TONES[classifierId] ?? "neutral"; export const smartMoneyToneForProfile = (profileId: SmartMoneyProfileId | null): string => - profileId ? SMART_MONEY_PROFILE_TONES[profileId] ?? "neutral" : "neutral"; + profileId ? (SMART_MONEY_PROFILE_TONES[profileId] ?? "neutral") : "neutral"; export const smartMoneyProfileLabel = (profileId: SmartMoneyProfileId | null): string => profileId ? humanizeClassifierId(profileId) : "Abstained"; @@ -1879,7 +1771,10 @@ export const getOptionTableSnapshot = ( ): { spot: string; iv: string; side: string; details: string; value: string } => { const side = print.execution_nbbo_side ?? print.nbbo_side ?? fallbackSide ?? "--"; return { - spot: typeof print.execution_underlying_spot === "number" ? formatPrice(print.execution_underlying_spot) : "--", + spot: + typeof print.execution_underlying_spot === "number" + ? formatPrice(print.execution_underlying_spot) + : "--", iv: typeof print.execution_iv === "number" ? formatPct(print.execution_iv) : "--", side, details: `${formatSize(print.size)}@${formatPrice(print.price)}_${side}`, @@ -2003,7 +1898,9 @@ const useScrollAnchor = ( } | null>(null); const readRenderedRows = useCallback((element: HTMLDivElement) => { - return Array.from(element.querySelectorAll("[data-tape-key][data-row-start][data-row-size]")) + return Array.from( + element.querySelectorAll("[data-tape-key][data-row-start][data-row-size]") + ) .map((node) => { const key = node.dataset.tapeKey; const start = Number(node.dataset.rowStart); @@ -2269,59 +2166,6 @@ export const statusLabel = (status: WsStatus, paused: boolean, mode: TapeMode): } }; -export const buildTapeStatusAnnouncement = ({ - status, - replayTime, - replayComplete, - paused, - dropped, - mode -}: Pick): string => { - const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); - const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ") - ? label.toLowerCase() - : `feed ${label.toLowerCase()}`; - const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`]; - - if (mode === "replay") { - parts.push(`time ${replayTime ? formatTime(replayTime) : "not available"}`); - } - - if (paused && dropped > 0) { - parts.push(`${dropped} queued rows`); - } - - return parts.join(", "); -}; - -const DataCell = ({ - children, - className = "", - title, - numeric = false -}: { - children: ReactNode; - className?: string; - title?: string; - numeric?: boolean; -}) => { - const classes = ["data-table-cell", numeric ? "data-table-cell-number" : "", className] - .filter(Boolean) - .join(" "); - - return ( - - {children} - - ); -}; - -const EmptyState = ({ children }: { children: ReactNode }) => ( -
- {children} -
-); - type TapeConfig = { mode: TapeMode; wsPath: string; @@ -2341,9 +2185,7 @@ type TapeConfig = { hotWindowLimit?: number; }; -const useTape = ( - config: TapeConfig -): TapeState => { +const useTape = (config: TapeConfig): TapeState => { const { mode, wsPath, replayPath, expectedType, latestPath, onNewItems, captureScroll } = config; const batchSize = config.batchSize ?? 40; const pollMs = config.pollMs ?? 1000; @@ -2889,20 +2731,16 @@ const usePausableTapeView = ( }; }; -const useLiveStream = ( - config: { - enabled: boolean; - wsPath: string; - expectedType: MessageType; - onNewItems?: (count: number) => void; - captureScroll?: () => void; - shouldHold?: () => boolean; - resumeSignal?: number; - } -): TapeState => { - const [status, setStatus] = useState( - config.enabled ? "connecting" : "disconnected" - ); +const useLiveStream = (config: { + enabled: boolean; + wsPath: string; + expectedType: MessageType; + onNewItems?: (count: number) => void; + captureScroll?: () => void; + shouldHold?: () => boolean; + resumeSignal?: number; +}): TapeState => { + const [status, setStatus] = useState(config.enabled ? "connecting" : "disconnected"); const [items, setItems] = useState([]); const [lastUpdate, setLastUpdate] = useState(null); const [replayTime] = useState(null); @@ -2961,8 +2799,7 @@ const useLiveStream = ( return; } - const nextBatch = - holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered; + const nextBatch = holdRef.current.length > 0 ? [...holdRef.current, ...buffered] : buffered; holdRef.current = []; setItems((prev) => @@ -3179,7 +3016,10 @@ const LIVE_HISTORY_ENDPOINTS: Partial { +const appendOptionFlowFilters = ( + params: URLSearchParams, + filters: OptionFlowFilters | undefined +): void => { if (!filters) { return; } @@ -3296,7 +3136,10 @@ export const shouldClearOptionFocusSeed = ( }; const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => { - if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) { + if ( + (subscription.channel === "options" || subscription.channel === "equities") && + subscription.underlying_ids?.length + ) { params.set("underlying_ids", subscription.underlying_ids.join(",")); } if (subscription.channel === "options" && subscription.option_contract_id) { @@ -3334,7 +3177,7 @@ export const getLiveManifest = ( filters: optionScope?.option_contract_id && optionPrintFilters === undefined ? undefined - : optionPrintFilters ?? flowFilters, + : (optionPrintFilters ?? flowFilters), ...optionScope, snapshot_limit: LIVE_OPTIONS_HEAD_LIMIT }); @@ -3589,7 +3432,8 @@ const useLiveSession = ( return; } - const subscription = message.op === "snapshot" ? message.snapshot.subscription : message.subscription; + const subscription = + message.op === "snapshot" ? message.snapshot.subscription : message.subscription; const items = message.op === "snapshot" ? message.snapshot.items : [message.item]; const subscriptionKey = getLiveSubscriptionKey(subscription); const updateAt = Date.now(); @@ -3697,10 +3541,16 @@ const useLiveSession = ( }); break; case "inferred-dark": - mergeItems(setInferredDark, inferredDarkRef, items as InferredDarkEvent[], LIVE_HOT_WINDOW, { - setter: setInferredDarkHistory, - ref: inferredDarkHistoryRef - }); + mergeItems( + setInferredDark, + inferredDarkRef, + items as InferredDarkEvent[], + LIVE_HOT_WINDOW, + { + setter: setInferredDarkHistory, + ref: inferredDarkHistoryRef + } + ); break; case "equity-candles": mergeItems(setChartCandles, chartCandlesRef, items as EquityCandle[]); @@ -4070,23 +3920,12 @@ const TapeStatus = ({ }: TapeStatusProps) => { const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode); const pausedLabel = paused && dropped > 0 ? `+${dropped} queued` : ""; - const announcement = buildTapeStatusAnnouncement({ - status, - replayTime, - replayComplete, - paused, - dropped, - mode - }); return (
-
- {error ? ( -

- {error} -

- ) : null} + {error ?

{error}

: null} )} @@ -9321,24 +9417,33 @@ function SyntheticControlDock() { export function TerminalAppShell({ children }: { children: ReactNode }) { const state = useTerminalState(); - const pathname = usePathname(); + const pathname = nextNavigation.usePathname(); const [drawerOpen, setDrawerOpen] = useState(false); const tickerFieldId = useId(); const tickerHintId = useId(); - const navTriggerRef = useRef(null); - const navDrawerRef = useRef(null); - const navDrawerTitleId = useId(); const activeNavHref = getTerminalNavCurrentHref(pathname); - const closeNavDrawer = useCallback(() => { - setDrawerOpen(false); - }, []); - - useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef); useEffect(() => { setDrawerOpen(false); }, [pathname]); + useEffect(() => { + if (!drawerOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setDrawerOpen(false); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [drawerOpen]); + return (
@@ -9354,7 +9459,6 @@ export function TerminalAppShell({ children }: { children: ReactNode }) { aria-expanded={drawerOpen} aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"} className="terminal-button terminal-menu-trigger" - ref={navTriggerRef} type="button" onClick={() => setDrawerOpen((current) => !current)} > @@ -9368,7 +9472,8 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
- {state.selectedInstrumentLabel && state.selectedInstrument?.kind !== "option-contract" ? ( + {state.selectedInstrumentLabel && + state.selectedInstrument?.kind !== "option-contract" ? ( {state.selectedInstrumentLabel}
@@ -9493,7 +9598,10 @@ export function TerminalAppShell({ children }: { children: ReactNode }) { ) : null} {state.selectedNewsStory ? ( - state.setSelectedNewsStory(null)} /> + state.setSelectedNewsStory(null)} + /> ) : null} {state.selectedClassifierHit ? ( diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 819bfbe..2e79aa6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,11 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "preserve", - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "incremental": true, "noEmit": true, "allowJs": true, @@ -24,8 +20,5 @@ ".next/types/**/*.ts", ".next-dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "scripts" - ] + "exclude": ["node_modules", "scripts"] } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..40e7fd3 --- /dev/null +++ b/biome.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "*.json", + "*.ts", + ".forgejo/workflows/*.yml", + "apps/**", + "deployment/docker/workspace-root/package.json", + "packages/**", + "scripts/**", + "services/**", + "!**/node_modules", + "!**/.next", + "!**/dist", + "!**/out", + "!**/coverage", + "!apps/web/tsconfig.tsbuildinfo" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "useAriaPropsSupportedByRole": "off", + "useFocusableInteractive": "off", + "useSemanticElements": "off" + }, + "complexity": { + "noImportantStyles": "off", + "noUselessContinue": "off", + "noUselessSwitchCase": "off", + "noUselessUndefinedInitialization": "off", + "useOptionalChain": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off", + "noUnusedFunctionParameters": "off", + "noUnusedImports": "off", + "noUnusedVariables": "off" + }, + "suspicious": { + "noArrayIndexKey": "off", + "noControlCharactersInRegex": "off", + "noExplicitAny": "off", + "noAssignInExpressions": "off", + "noShorthandPropertyOverrides": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "style": { + "noDescendingSpecificity": "off", + "noNonNullAssertion": "off", + "useExponentiationOperator": "off", + "useImportType": "off", + "useTemplate": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "none" + } + }, + "json": { + "parser": { + "allowComments": true, + "allowTrailingCommas": true + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock index 59bbee4..9b60caa 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@pierre/diffs": "^1.2.2", }, "devDependencies": { + "@biomejs/biome": "^2.4.16", "@types/bun": "^1.3.3", "@types/ws": "^8.18.1", "typescript": "^5.9.3", @@ -172,11 +173,30 @@ }, }, "overrides": { + "@electron/node-gyp": "^10.2.0-electron.2", "postcss": "^8.5.15", "tar": "^7.5.15", "tmp": "^0.2.5", }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@clickhouse/client": ["@clickhouse/client@0.2.10", "", { "dependencies": { "@clickhouse/client-common": "0.2.10" } }, "sha512-ZwBgzjEAFN/ogS0ym5KHVbR7Hx/oYCX01qGp2baEyfN2HM73kf/7Vp3GvMHWRy+zUXISONEtFv7UTViOXnmFrg=="], "@clickhouse/client-common": ["@clickhouse/client-common@0.2.10", "", {}, "sha512-BvTY0IXS96y9RUeNCpKL4HUzHmY80L0lDcGN0lmUD6zjOqYMn78+xyHYJ/AIAX7JQsc+/KwFt2soZutQTKxoGQ=="], @@ -213,7 +233,7 @@ "@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], - "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], + "@electron/node-gyp": ["@electron/node-gyp@10.2.0-electron.2", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OhO6fwqpetMO1vWI3+J8mb3a4s4A405tgKoUCJsgd4nyQDdFh0VvZm+gj/Cc70iRLQoIYUfSaAgYSVwmLsQHig=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], diff --git a/deployment/docker/workspace-root/bun.lock b/deployment/docker/workspace-root/bun.lock index 59bbee4..9b60caa 100644 --- a/deployment/docker/workspace-root/bun.lock +++ b/deployment/docker/workspace-root/bun.lock @@ -8,6 +8,7 @@ "@pierre/diffs": "^1.2.2", }, "devDependencies": { + "@biomejs/biome": "^2.4.16", "@types/bun": "^1.3.3", "@types/ws": "^8.18.1", "typescript": "^5.9.3", @@ -172,11 +173,30 @@ }, }, "overrides": { + "@electron/node-gyp": "^10.2.0-electron.2", "postcss": "^8.5.15", "tar": "^7.5.15", "tmp": "^0.2.5", }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@clickhouse/client": ["@clickhouse/client@0.2.10", "", { "dependencies": { "@clickhouse/client-common": "0.2.10" } }, "sha512-ZwBgzjEAFN/ogS0ym5KHVbR7Hx/oYCX01qGp2baEyfN2HM73kf/7Vp3GvMHWRy+zUXISONEtFv7UTViOXnmFrg=="], "@clickhouse/client-common": ["@clickhouse/client-common@0.2.10", "", {}, "sha512-BvTY0IXS96y9RUeNCpKL4HUzHmY80L0lDcGN0lmUD6zjOqYMn78+xyHYJ/AIAX7JQsc+/KwFt2soZutQTKxoGQ=="], @@ -213,7 +233,7 @@ "@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], - "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], + "@electron/node-gyp": ["@electron/node-gyp@10.2.0-electron.2", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OhO6fwqpetMO1vWI3+J8mb3a4s4A405tgKoUCJsgd4nyQDdFh0VvZm+gj/Cc70iRLQoIYUfSaAgYSVwmLsQHig=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], diff --git a/deployment/docker/workspace-root/package.json b/deployment/docker/workspace-root/package.json index d2482d0..7dc2533 100644 --- a/deployment/docker/workspace-root/package.json +++ b/deployment/docker/workspace-root/package.json @@ -15,6 +15,10 @@ "dev:desktop:remote": "bun run scripts/dev-desktop.ts --remote", "dev:web": "bun --cwd=apps/web run dev", "dev:services": "bun run scripts/dev-services.ts", + "fmt": "biome format --write .", + "fmt:check": "biome format .", + "lint": "biome lint .", + "check": "biome check .", "package:desktop": "bun --cwd=apps/desktop run package", "make:desktop": "bun --cwd=apps/desktop run make", "deploy": "bun run scripts/deploy.ts", @@ -26,6 +30,7 @@ "check:docker-workspace": "bun run scripts/check-docker-workspace.ts" }, "devDependencies": { + "@biomejs/biome": "^2.4.16", "@types/bun": "^1.3.3", "@types/ws": "^8.18.1", "typescript": "^5.9.3", @@ -34,7 +39,8 @@ "overrides": { "postcss": "^8.5.15", "tar": "^7.5.15", - "tmp": "^0.2.5" + "tmp": "^0.2.5", + "@electron/node-gyp": "^10.2.0-electron.2" }, "dependencies": { "@pierre/diffs": "^1.2.2" diff --git a/deployment/docker/workspace-root/tsconfig.base.json b/deployment/docker/workspace-root/tsconfig.base.json index 34b15d2..f98f46a 100644 --- a/deployment/docker/workspace-root/tsconfig.base.json +++ b/deployment/docker/workspace-root/tsconfig.base.json @@ -8,6 +8,6 @@ "isolatedModules": true, "resolveJsonModule": true, "skipLibCheck": true, - "noEmit": true, - }, + "noEmit": true + } } diff --git a/docs/turns/2026-05-29-fix-electron-node-gyp-install-resolution.html b/docs/turns/2026-05-29-fix-electron-node-gyp-install-resolution.html new file mode 100644 index 0000000..ac537c2 --- /dev/null +++ b/docs/turns/2026-05-29-fix-electron-node-gyp-install-resolution.html @@ -0,0 +1,192 @@ + + + + + + CI Dependency Resolution Fix + + + +

CI Dependency Resolution Fix

+ +
+

Summary

+

+ I fixed the failing Forgejo CI install by removing the GitHub git-commit dependency on + @electron/node-gyp from lock resolution and forcing it through the npm package + @electron/node-gyp@^10.2.0-electron.2 via repository overrides. +

+
+ +
+

Changes Made

+ +
+ +
+

Context

+

+ CI was failing in dependency install with this error: +

+
error: failed to download @electron/node-gyp@github:electron/node-gyp#06b29aa ... 404 Not Found
+

+ In this environment, that endpoint is interpreted by the Forgejo git proxy and the + short SHA is resolved against an unavailable internal mirror path. For a CI runner, this is + a fragile install path. +

+
+ +
+

Important Implementation Details

+
    +
  • + Using an override keeps all transitive graph consumers of @electron/node-gyp + on the same npm release and avoids GitHub tarball URL resolution entirely. +
  • +
  • + The lockfile entry moved from a git URL spec to + @electron/node-gyp@10.2.0-electron.2 with a resolved tarball checksum entry, + which is stable in CI contexts. +
  • +
  • + The Docker workspace copy was updated to avoid drift between root and + deployment lock snapshots. +
  • +
+
+ +
+

Relevant Diff Snippets

+
diff --git a/package.json b/package.json
+@@
+   "overrides": {
+     "postcss": "^8.5.15",
+     "tar": "^7.5.15",
+-    "tmp": "^0.2.5"
++    "tmp": "^0.2.5",
++    "@electron/node-gyp": "^10.2.0-electron.2"
+   },
+@@
+ diff --git a/deployment/docker/workspace-root/package.json b/deployment/docker/workspace-root/package.json
+@@
+   "overrides": {
+     "postcss": "^8.5.15",
+     "tar": "^7.5.15",
+-    "tmp": "^0.2.5"
++    "tmp": "^0.2.5",
++    "@electron/node-gyp": "^10.2.0-electron.2"
+   },
+@@
+ diff --git a/bun.lock b/bun.lock
+@@
+-    "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"],
++    "@electron/node-gyp": ["@electron/node-gyp@10.2.0-electron.2", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OhO6fwqpetMO1vWI3+J8mb3a4s4A405tgKoUCJsgd4nyQDdFh0VvZm+gj/Cc70iRLQoIYUfSaAgYSVwmLsQHig=="],
+@@
+ diff --git a/deployment/docker/workspace-root/bun.lock b/deployment/docker/workspace-root/bun.lock
+@@
+-    "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"],
++    "@electron/node-gyp": ["@electron/node-gyp@10.2.0-electron.2", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OhO6fwqpetMO1vWI3+J8mb3a4s4A405tgKoUCJsgd4nyQDdFh0VvZm+gj/Cc70iRLQoIYUfSaAgYSVwmLsQHig=="],
+
+

+ Note: For this repository-required documentation rule, lockfile snippets were summarized + directly because rendered @pierre/diffs output is very verbose with embedded + style payloads for each file block. +

+
+ +
+

Expected Impact for End-Users

+
    +
  • Forgejo CI installs should no longer fail on unresolved @electron/node-gyp GitHub commit tarball lookups.
  • +
  • Dependency install becomes deterministic using a versioned npm package artifact.
  • +
  • Docker workspace and root lockfiles remain in sync.
  • +
+
+ +
+

Validation

+
    +
  • bun install (lock refresh after override)
  • +
  • bun install --frozen-lockfile
  • +
  • bun run typecheck
  • +
  • bun run check:docker-workspace
  • +
  • bun test
  • +
  • bun --cwd=apps/web run build
  • +
+

All checks completed successfully.

+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • + The transitive package @electron/rebuild still references the same GitHub commit in its + dependency metadata, but override forces resolution to the npm package, which is now what the lock + consumes in this repo. +
  • +
  • + If another service writes lockfile with a different package-manager behavior, a re-sync is required. + We already captured this in the workflow by syncing the docker workspace copy. +
  • +
+
+ +
+

Follow-up Work

+
    +
  • Watch one CI run on Forgejo to confirm the endpoint that caused 404 is fully gone.
  • +
  • Consider a small dependency bump for @electron/rebuild if it later publishes a lockfile-safe package-only variant.
  • +
  • Pin lockfile sync as a required step in any scripted dependency maintenance path.
  • +
+
+ + diff --git a/docs/turns/2026-05-29-harden-drawer-dialog-focus.html b/docs/turns/2026-05-29-harden-drawer-dialog-focus.html deleted file mode 100644 index d0b5d0a..0000000 --- a/docs/turns/2026-05-29-harden-drawer-dialog-focus.html +++ /dev/null @@ -1,360 +0,0 @@ - - - - - - Harden Drawer Dialog Focus - - - -
-
-

Harden Drawer Dialog Focus

-

- Added consistent modal dialog semantics, keyboard focus trapping, Escape dismissal, and focus restoration for terminal drawers. -

-
- 2026-05-29 19:06 - Issue islandflow-wtg - apps/web -
-
- -
-

Summary

-

- The terminal drawers now behave as modal dialogs for keyboard and assistive technology users. Opening a drawer moves focus into it, Tab and Shift+Tab stay inside it, Escape closes it, and focus returns to the invoking control when practical. -

-
- -
-

Changes Made

-
    -
  • Added a shared useModalFocusTrap helper in apps/web/app/terminal.tsx.
  • -
  • Added role="dialog", aria-modal="true", stable labels, and tabIndex={-1} to alert, news, classifier, smart-money, inferred-dark, synthetic-control, and nav drawers.
  • -
  • Restored keyboard focus to the source trigger for nav and synthetic-control drawers after closing.
  • -
  • Replaced the nav drawer's standalone Escape listener with the shared modal focus handler.
  • -
-
- -
-

Context

-

- Islandflow is an operational trading terminal where drawer content contains evidence, feed controls, and navigation. These overlays previously closed on Escape or outside click, but they did not consistently identify as modal dialogs or keep keyboard focus inside the active layer. -

-
- -
-

Important Implementation Details

-
    -
  • The helper discovers tabbable descendants from the active drawer and cycles keyboard focus at the first and last controls.
  • -
  • Drawers without tabbable descendants focus their root element so Escape handling and screen-reader context still work.
  • -
  • The helper stores the previously focused element when a drawer opens, then restores it after unmount if the element is still connected.
  • -
  • Existing outside-click dismissal for evidence drawers was preserved.
  • -
-
- -
-

Relevant Diff Snippets

-

- Rendered with @pierre/diffs/ssr using preloadPatchDiff against the real - apps/web/app/terminal.tsx patch. The SSR output is embedded directly below. -

-
-
apps/web/app/terminal.tsx
-34+173
15 unmodified lines
16
17
18
19
20
21
369 unmodified lines
391
392
393
394
395
396
4497 unmodified lines
4894
4895
4896
4897
4898
4899
1 unmodified line
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
138 unmodified lines
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
51 unmodified lines
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
92 unmodified lines
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
84 unmodified lines
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
3509 unmodified lines
8856
8857
8858
8859
8860
8861
138 unmodified lines
9000
9001
9002
9003
9004
9005
9006
9007
9008
9009
9010
9011
9012
9013
9014
9015
9016
9017
9018
9019
9020
9021
160 unmodified lines
9182
9183
9184
9185
9186
9187
9188
9189
9190
9191
9192
9193
9194
9195
9196
9197
9198
9199
9200
9201
9202
9203
9204
9205
9206
9207
9208
9209
9210
9 unmodified lines
9220
9221
9222
9223
9224
9225
74 unmodified lines
9300
9301
9302
9303
9304
9305
9306
9307
9308
9309
9310
9311
9312
9313
9314
1 unmodified line
9316
9317
9318
9319
9320
9321
9322
15 unmodified lines
type CSSProperties,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type SetStateAction
} from "react";
369 unmodified lines
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
const EMPTY_NEWS_STORIES: NewsStory[] = [];
-
type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
-
type EquityOverlayPoint = {
4497 unmodified lines
};
-
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
const primary = alert.hits[0];
const direction = deriveAlertDirection(alert);
const severity = normalizeAlertSeverity(alert);
1 unmodified line
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
-
return (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Alert details</p>
<h3>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
138 unmodified lines
};
-
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
const body = sanitizeNewsHtml(story.content_html);
-
return (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">News wire</p>
<h3>{story.headline}</h3>
<p className="drawer-subtitle">
{story.source} · Published {formatDateTime(story.published_ts)}
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
51 unmodified lines
};
-
const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {
const direction = normalizeDirection(hit.direction);
const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
-
return (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Classifier hit</p>
<h3>{humanizeClassifierId(hit.classifier_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
92 unmodified lines
};
-
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
const primaryScore =
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
event.profile_scores[0];
const direction = normalizeDirection(event.primary_direction);
const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
-
return (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Smart money profile</p>
<h3>{smartMoneyProfileLabel(event.primary_profile_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
84 unmodified lines
};
-
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
const joinEvidence = evidence.filter(
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
);
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const traceRefs = event.evidence_refs.slice(0, 6);
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
-
return (
<aside className="drawer">
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Inferred dark</p>
<h3>{humanizeClassifierId(event.type)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
3509 unmodified lines
const [error, setError] = useState<string | null>(null);
const dirtyRef = useRef(false);
const savedRef = useRef<SyntheticControlState | null>(null);
-
useEffect(() => {
if (!visible) {
138 unmodified lines
<>
<button
aria-expanded={open}
aria-label="Synthetic control"
className={`synthetic-control-gear${open ? " is-open" : ""}`}
onClick={() => setOpen((current) => !current)}
type="button"
>
<span className="synthetic-control-gear-mark">+</span>
</button>
-
{open ? (
<aside className="synthetic-control-drawer" aria-label="Synthetic control drawer">
<div className="synthetic-control-header">
<div>
<p className="synthetic-control-kicker">Synthetic Control</p>
<h3>Hosted tape operator rail</h3>
</div>
<button className="drawer-close" onClick={() => setOpen(false)} type="button">
Close
</button>
</div>
160 unmodified lines
const [drawerOpen, setDrawerOpen] = useState(false);
const tickerFieldId = useId();
const tickerHintId = useId();
const activeNavHref = getTerminalNavCurrentHref(pathname);
-
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
-
useEffect(() => {
if (!drawerOpen) {
return;
}
-
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setDrawerOpen(false);
}
};
-
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [drawerOpen]);
-
return (
<TerminalContext.Provider value={state}>
<div className="terminal-shell">
9 unmodified lines
aria-expanded={drawerOpen}
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
className="terminal-button terminal-menu-trigger"
type="button"
onClick={() => setDrawerOpen((current) => !current)}
>
74 unmodified lines
aria-label="Close navigation drawer"
className="terminal-drawer-backdrop"
type="button"
onClick={() => setDrawerOpen(false)}
/>
<aside
aria-label="Primary navigation"
className="terminal-nav-drawer"
id="terminal-nav-drawer"
>
<div className="terminal-drawer-head">
<div className="terminal-brand">
<span className="terminal-brand-kicker">IF</span>
<span className="terminal-brand-name">islandflow</span>
</div>
1 unmodified line
aria-label="Close navigation drawer"
className="terminal-button terminal-drawer-close"
type="button"
onClick={() => setDrawerOpen(false)}
>
Close
</button>
15 unmodified lines
16
17
18
19
20
21
22
369 unmodified lines
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
4497 unmodified lines
5004
5005
5006
5007
5008
5009
5010
5011
1 unmodified line
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
138 unmodified lines
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
51 unmodified lines
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
92 unmodified lines
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
84 unmodified lines
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
3509 unmodified lines
8981
8982
8983
8984
8985
8986
8987
8988
8989
8990
8991
8992
8993
8994
8995
138 unmodified lines
9134
9135
9136
9137
9138
9139
9140
9141
9142
9143
9144
9145
9146
9147
9148
9149
9150
9151
9152
9153
9154
9155
9156
9157
9158
9159
9160
9161
9162
9163
9164
160 unmodified lines
9325
9326
9327
9328
9329
9330
9331
9332
9333
9334
9335
9336
9337
9338
9339
9340
9341
9342
9343
9344
9 unmodified lines
9354
9355
9356
9357
9358
9359
9360
74 unmodified lines
9435
9436
9437
9438
9439
9440
9441
9442
9443
9444
9445
9446
9447
9448
9449
9450
9451
9452
9453
1 unmodified line
9455
9456
9457
9458
9459
9460
9461
15 unmodified lines
type CSSProperties,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type RefObject,
type ReactNode,
type SetStateAction
} from "react";
369 unmodified lines
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
const EMPTY_NEWS_STORIES: NewsStory[] = [];
-
const TABBABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])"
].join(",");
-
export const isElementTabbable = (element: HTMLElement): boolean => {
if (element.hasAttribute("disabled") || element.getAttribute("aria-hidden") === "true") {
return false;
}
-
const tabIndex = element.getAttribute("tabindex");
if (tabIndex && Number(tabIndex) < 0) {
return false;
}
-
return Boolean(element.offsetParent || element.getClientRects().length > 0);
};
-
export const getTabbableElements = (root: HTMLElement): HTMLElement[] => {
return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter(isElementTabbable);
};
-
const useModalFocusTrap = (
active: boolean,
rootRef: RefObject<HTMLElement | null>,
onClose: () => void,
restoreFocusRef?: RefObject<HTMLElement | null>
) => {
const fallbackFocusRef = useRef<HTMLElement | null>(null);
-
useLayoutEffect(() => {
if (!active) {
return;
}
-
fallbackFocusRef.current =
restoreFocusRef?.current ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
const root = rootRef.current;
if (!root) {
return;
}
-
const focusTarget = getTabbableElements(root)[0] ?? root;
focusTarget.focus({ preventScroll: true });
-
return () => {
const restoreTarget = restoreFocusRef?.current ?? fallbackFocusRef.current;
if (restoreTarget?.isConnected) {
restoreTarget.focus({ preventScroll: true });
}
fallbackFocusRef.current = null;
};
}, [active, restoreFocusRef, rootRef]);
-
useEffect(() => {
if (!active) {
return;
}
-
const handleKeyDown = (event: KeyboardEvent) => {
const root = rootRef.current;
if (!root) {
return;
}
-
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
-
if (event.key !== "Tab") {
return;
}
-
const tabbable = getTabbableElements(root);
if (tabbable.length === 0) {
event.preventDefault();
root.focus({ preventScroll: true });
return;
}
-
const first = tabbable[0];
const last = tabbable[tabbable.length - 1];
const activeElement = document.activeElement;
-
if (event.shiftKey && activeElement === first) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && activeElement === last) {
event.preventDefault();
first.focus({ preventScroll: true });
} else if (!root.contains(activeElement)) {
event.preventDefault();
first.focus({ preventScroll: true });
}
};
-
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [active, onClose, rootRef]);
};
-
type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
-
type EquityOverlayPoint = {
4497 unmodified lines
};
-
const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: AlertDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const primary = alert.hits[0];
const direction = deriveAlertDirection(alert);
const severity = normalizeAlertSeverity(alert);
1 unmodified line
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const isContextLoading = contextStatus.traceId === alert.trace_id && contextStatus.loading;
const missingRefs = contextStatus.traceId === alert.trace_id ? contextStatus.missingRefs : [];
useModalFocusTrap(true, drawerRef, onClose);
-
return (
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Alert details</p>
<h3 id={titleId}>{primary ? humanizeClassifierId(primary.classifier_id) : "Alert"}</h3>
<p className="drawer-subtitle">{formatDateTime(alert.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
138 unmodified lines
};
-
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const body = sanitizeNewsHtml(story.content_html);
useModalFocusTrap(true, drawerRef, onClose);
-
return (
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">News wire</p>
<h3 id={titleId}>{story.headline}</h3>
<p className="drawer-subtitle">
{story.source} · Published {formatDateTime(story.published_ts)}
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
51 unmodified lines
};
-
const ClassifierHitDrawer = ({ hit, flowPacket, evidence, onClose }: ClassifierHitDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const direction = normalizeDirection(hit.direction);
const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
useModalFocusTrap(true, drawerRef, onClose);
-
return (
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Classifier hit</p>
<h3 id={titleId}>{humanizeClassifierId(hit.classifier_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(hit.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
92 unmodified lines
};
-
const SmartMoneyDrawer = ({ event, flowPacket, evidence, onClose }: SmartMoneyDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const primaryScore =
event.profile_scores.find((score) => score.profile_id === event.primary_profile_id) ??
event.profile_scores[0];
const direction = normalizeDirection(event.primary_direction);
const evidencePrints = evidence.filter((item) => item.kind === "print");
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
useModalFocusTrap(true, drawerRef, onClose);
-
return (
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Smart money profile</p>
<h3 id={titleId}>{smartMoneyProfileLabel(event.primary_profile_id)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
84 unmodified lines
};
-
const DarkDrawer = ({ event, evidence, underlying, onClose }: DarkDrawerProps) => {
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const joinEvidence = evidence.filter(
(item): item is { kind: "join"; id: string; join: EquityPrintJoin } => item.kind === "join"
);
const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
const traceRefs = event.evidence_refs.slice(0, 6);
const extraRefs = Math.max(0, event.evidence_refs.length - traceRefs.length);
useModalFocusTrap(true, drawerRef, onClose);
-
return (
<aside aria-labelledby={titleId} aria-modal="true" className="drawer" ref={drawerRef} role="dialog" tabIndex={-1}>
<div className="drawer-header">
<div>
<p className="drawer-eyebrow">Inferred dark</p>
<h3 id={titleId}>{humanizeClassifierId(event.type)}</h3>
<p className="drawer-subtitle">{formatDateTime(event.source_ts)}</p>
</div>
<button className="drawer-close" type="button" onClick={onClose}>
3509 unmodified lines
const [error, setError] = useState<string | null>(null);
const dirtyRef = useRef(false);
const savedRef = useRef<SyntheticControlState | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const drawerRef = useRef<HTMLElement | null>(null);
const titleId = useId();
-
const closeDrawer = useCallback(() => {
setOpen(false);
}, []);
-
useModalFocusTrap(open, drawerRef, closeDrawer, triggerRef);
-
useEffect(() => {
if (!visible) {
138 unmodified lines
<>
<button
aria-expanded={open}
aria-haspopup="dialog"
aria-label="Synthetic control"
className={`synthetic-control-gear${open ? " is-open" : ""}`}
onClick={() => setOpen((current) => !current)}
ref={triggerRef}
type="button"
>
<span className="synthetic-control-gear-mark">+</span>
</button>
-
{open ? (
<aside
aria-labelledby={titleId}
aria-modal="true"
className="synthetic-control-drawer"
ref={drawerRef}
role="dialog"
tabIndex={-1}
>
<div className="synthetic-control-header">
<div>
<p className="synthetic-control-kicker">Synthetic Control</p>
<h3 id={titleId}>Hosted tape operator rail</h3>
</div>
<button className="drawer-close" onClick={closeDrawer} type="button">
Close
</button>
</div>
160 unmodified lines
const [drawerOpen, setDrawerOpen] = useState(false);
const tickerFieldId = useId();
const tickerHintId = useId();
const navTriggerRef = useRef<HTMLButtonElement | null>(null);
const navDrawerRef = useRef<HTMLElement | null>(null);
const navDrawerTitleId = useId();
const activeNavHref = getTerminalNavCurrentHref(pathname);
const closeNavDrawer = useCallback(() => {
setDrawerOpen(false);
}, []);
-
useModalFocusTrap(drawerOpen, navDrawerRef, closeNavDrawer, navTriggerRef);
-
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
-
return (
<TerminalContext.Provider value={state}>
<div className="terminal-shell">
9 unmodified lines
aria-expanded={drawerOpen}
aria-label={drawerOpen ? "Close navigation menu" : "Open navigation menu"}
className="terminal-button terminal-menu-trigger"
ref={navTriggerRef}
type="button"
onClick={() => setDrawerOpen((current) => !current)}
>
74 unmodified lines
aria-label="Close navigation drawer"
className="terminal-drawer-backdrop"
type="button"
onClick={closeNavDrawer}
/>
<aside
aria-labelledby={navDrawerTitleId}
aria-modal="true"
className="terminal-nav-drawer"
id="terminal-nav-drawer"
ref={navDrawerRef}
role="dialog"
tabIndex={-1}
>
<div className="terminal-drawer-head">
<div className="terminal-brand" id={navDrawerTitleId}>
<span className="terminal-brand-kicker">IF</span>
<span className="terminal-brand-name">islandflow</span>
</div>
1 unmodified line
aria-label="Close navigation drawer"
className="terminal-button terminal-drawer-close"
type="button"
onClick={closeNavDrawer}
>
Close
</button>
-
-
- -
-

Expected Impact for End-Users

-

- Keyboard users can now open a drawer, move through its controls without falling into the page behind it, close it with Escape, and continue from the control they used to open it. Screen-reader users also get clearer modal dialog boundaries and labels. -

-
- -
-

Validation

-
    -
  • Passed: bun test apps/web/app/terminal.test.ts
  • -
  • Passed: bun --cwd=apps/web run build
  • -
  • Attempted Playwright browser verification against localhost:3001; the app did not become stable enough for a reliable scripted assertion before timeout, so automated browser validation is documented as incomplete.
  • -
-
- -
-

Issues, Limitations, and Mitigations

-
    -
  • The current unit test setup is logic-focused and does not include DOM interaction tests for Tab cycling.
  • -
  • A follow-up issue already exists for terminal navigation drawer interaction coverage: islandflow-3by.
  • -
  • The helper uses standard focusable element selectors and visibility checks; custom focusable widgets should still expose normal tab stops.
  • -
-
- -
-

Follow-up Work

-
    -
  • islandflow-3by: add interaction coverage for terminal navigation drawer.
  • -
  • Consider adding a lightweight DOM test harness for drawer focus restoration and Tab wrap behavior.
  • -
-
-
- - diff --git a/docs/turns/2026-05-29-harden-terminal-ui-errors.html b/docs/turns/2026-05-29-harden-terminal-ui-errors.html deleted file mode 100644 index 0f1b353..0000000 --- a/docs/turns/2026-05-29-harden-terminal-ui-errors.html +++ /dev/null @@ -1,614 +0,0 @@ - - - - - - Harden Terminal UI Error States - - - -
-
-

Turn document · 2026-05-29 18:12 EDT

-

Harden Terminal UI Error States

-

- This pass made the Islandflow web terminal more stable when backend or network responses are - messy: oversized error payloads are clamped, non-JSON admin failures are handled explicitly, - and long warning text now wraps inside terminal surfaces. -

-
- Beads: islandflow-aq9 - Surface: apps/web - Command: impeccable harden -
-
- -
-

Summary

-

- The terminal now sanitizes UI-facing error strings before rendering them, handles failed - synthetic-admin status responses before parsing JSON, and gives long warnings, drawer notes, - and filter copy safe wrapping behavior. -

-
- -
-

Changes Made

-
    -
  • - Added formatUiErrorMessage in apps/web/app/terminal.tsx to normalize - whitespace, provide fallback text, and clamp rendered error messages to 240 characters. -
  • -
  • - Routed chart, option-history, and synthetic-control error displays through the formatter so - server payloads cannot flood or distort the interface. -
  • -
  • - Updated synthetic admin status loading to check response.ok before JSON parsing, - reusing the existing response-detail reader for JSON, HTML, and plain-text failures. -
  • -
  • - Added aria-live and role="alert" where warning/error text changes need - to be announced by assistive technology. -
  • -
  • - Added wrapping containment for drawer notes, empty states, flow-filter copy, synthetic errors, - and history-load warnings. -
  • -
-
- -
-

Context

-

- Islandflow is a real-time market-data terminal. During live trading or replay investigation, - backend failures should be clear and recoverable without breaking table lanes, drawers, charts, - or operator controls. The hardened paths are intentionally small and utility-first so the - product keeps its dense instrument-panel character. -

-
- -
-

Important Implementation Details

-
    -
  • - The formatter accepts unknown, so callers can safely pass native errors, strings, - empty payloads, or unexpected values without branching at every render site. -
  • -
  • - readErrorDetail still preserves useful HTTP status context, but now returns a - UI-safe string even when the backend sends HTML, a huge plain-text body, or malformed JSON. -
  • -
  • - The CSS keeps the terminal layout dense. It wraps only message-like text surfaces, not the - horizontally scrollable market-data tables. -
  • -
  • - The dev-server smoke test showed desktop /options had no visible-overflow - offenders. Mobile still reports a document width wider than the viewport because the options - data table intentionally keeps a minimum width and scrolls horizontally. -
  • -
-
- -
-

Relevant Diff Snippets

-

- The snippets below were rendered with @pierre/diffs/ssr. They show the core UI error formatter, synthetic-admin failure handling, CSS wrapping guardrails, and regression coverage. -

-
apps/web/app/terminal.tsx
-2+16
506 unmodified lines
507
508
509
510
511
512
17 unmodified lines
530
531
532
533
534
535
536
537
506 unmodified lines
return sampled;
};
-
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`;
} catch {
return `${statusLabel}: ${truncated}`;
}
};
506 unmodified lines
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
17 unmodified lines
544
545
546
547
548
549
550
551
506 unmodified lines
return sampled;
};
-
export const formatUiErrorMessage = (message: unknown, fallback = "Request failed"): string => {
const raw =
message instanceof Error
? message.message
: typeof message === "string"
? message
: String(message ?? "");
const normalized = raw.replace(/\s+/g, " ").trim();
if (!normalized) {
return fallback;
}
return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized;
};
-
const readErrorDetail = async (response: Response): Promise<string> => {
const statusLabel = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
const text = await response.text();
17 unmodified lines
error?: string;
message?: string;
};
return formatUiErrorMessage(payload.detail ?? payload.error ?? payload.message ?? `${statusLabel}: ${truncated}`);
} catch {
return formatUiErrorMessage(`${statusLabel}: ${truncated}`);
}
};
-
apps/web/app/terminal.tsx
-2+4
8882 unmodified lines
8869
8870
8871
8872
8873
8874
42 unmodified lines
8917
8918
8919
8920
8921
8922
8923
8924
8882 unmodified lines
setLoading(false);
return;
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? body?.error ?? "Synthetic control update failed");
}
return (await response.json()) as SyntheticAdminControlResponse;
})
8882 unmodified lines
8883
8884
8885
8886
8887
8888
8889
8890
8891
42 unmodified lines
8934
8935
8936
8937
8938
8939
8940
8882 unmodified lines
setLoading(false);
return;
}
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
const nextStatus = (await response.json()) as SyntheticAdminStatusResponse;
setStatus(nextStatus);
if (!dirtyRef.current) {
42 unmodified lines
})
.then(async (response) => {
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
return (await response.json()) as SyntheticAdminControlResponse;
})
-
apps/web/app/globals.css
+7
9 unmodified lines
10
11
12
13
14
15
1234 unmodified lines
1249
1250
1251
1252
1253
1254
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
-
.chart-surface {
width: 100%;
height: 460px;
9 unmodified lines
10
11
12
13
14
15
16
1234 unmodified lines
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
9 unmodified lines
--text: oklch(0.93 0.014 250);
--text-dim: oklch(0.74 0.018 250);
--text-faint: oklch(0.59 0.016 250);
--text-muted: var(--text-dim);
--accent: oklch(0.78 0.12 74);
--accent-soft: oklch(0.78 0.12 74 / 0.1);
--green: oklch(0.74 0.13 151);
1234 unmodified lines
color: var(--text-dim);
}
-
.drawer-note,
.drawer-empty,
.note {
overflow-wrap: anywhere;
}
-
.chart-surface {
width: 100%;
height: 460px;
-
apps/web/app/terminal.test.ts
+19
80 unmodified lines
80
81
82
83
84
85
80 unmodified lines
});
});
-
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
80 unmodified lines
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
80 unmodified lines
});
});
-
describe("terminal error message hardening", () => {
it("normalizes whitespace and clamps oversized messages before rendering", () => {
const longMessage = `API failed\n\n${"x".repeat(320)}`;
-
const formatted = formatUiErrorMessage(longMessage);
-
expect(formatted).toHaveLength(240);
expect(formatted).toStartWith("API failed x");
expect(formatted).toEndWith("...");
expect(formatted).not.toContain("\n");
});
-
it("uses a fallback when an error payload is empty", () => {
expect(formatUiErrorMessage(" ", "Synthetic status could not be loaded")).toBe(
"Synthetic status could not be loaded"
);
});
});
-
const makeItem = (traceId: string, seq: number, ts: number) => ({
trace_id: traceId,
seq,
-
- -
-

Expected Impact for End-Users

-

- Users should see shorter, more useful failure messages when API routes, charts, history loading, - or synthetic-admin controls fail. The terminal should remain readable even when an upstream - service returns a long stack trace, an HTML error page, or text with unusual spacing. -

-
- -
-

Validation

-
    -
  • Passed: bun test apps/web/app/terminal.test.ts
  • -
  • Passed: bun --cwd=apps/web run build
  • -
  • - Passed: Playwright smoke loaded - http://localhost:3000/options at desktop and mobile widths; desktop scan found no - visible-overflow offenders. -
  • -
-
- -
-

Issues, Limitations, and Mitigations

-
    -
  • - This hardens UI rendering of failures. It does not change backend retry behavior or websocket - reconnection policy. -
  • -
  • - Mobile /options keeps a wider-than-viewport table by design. The mitigation is the - existing horizontal table scroll, which preserves data-column legibility. -
  • -
-
- -
-

Follow-up Work

-
    -
  • - islandflow-3by: add browser or DOM coverage for the shared terminal navigation - drawer interactions. -
  • -
  • - Consider adding a small DOM-level test for synthetic-admin non-JSON failure rendering if the - project adds a React component test harness. -
  • -
-
-
- - diff --git a/docs/turns/2026-05-29-harden-web-terminal-ui-states.html b/docs/turns/2026-05-29-harden-web-terminal-ui-states.html deleted file mode 100644 index a303937..0000000 --- a/docs/turns/2026-05-29-harden-web-terminal-ui-states.html +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - Harden Web Terminal UI States - - - -
-
-
- 2026-05-29 18:04 EDT - Beads: islandflow-ggm - Web terminal hardening -
-

Harden Web Terminal UI States

-

- The terminal UI now handles live and replay status, empty panes, clipped market data, and table semantics more reliably for real users, assistive technology, and unusual input values. -

-
- -
-

Summary

-

- I hardened the main web terminal surface by adding accessible feed announcements, reusable empty-state markup, safer data cells for clipped values, and stronger table semantics on the busiest tape panes. -

-
- -
-

Changes Made

-
    -
  • Added buildTapeStatusAnnouncement so live and replay feed states have complete screen-reader labels instead of relying on colored dots or terse visible labels.
  • -
  • Added reusable DataCell and EmptyState helpers for terminal panes.
  • -
  • Updated Options, Equities, and Flow panes with semantic column headers, rowgroups, cells, and useful title fallbacks for clipped values.
  • -
  • Improved empty-state layout so long messages wrap cleanly without collapsing the pane.
  • -
  • Added unicode-bidi: plaintext to table cells so mixed-direction symbols, ticker text, and unusual copied values are less likely to reorder confusingly.
  • -
  • Added focused tests for the new status-announcement helper.
  • -
-
- -
-

Context

-

- Islandflow is an evidence console for live market investigation. The UI has to remain useful when feeds are stale, paused, empty, or carrying long contract identifiers and numeric values. Hardening here focused on making the existing dense terminal more robust without changing its visual identity. -

-
- -
-

Important Implementation Details

-
    -
  • TapeStatus now exposes a polite status region with an aria-label such as Live feed behind or Replay feed paused, time not available, 12 queued rows.
  • -
  • The visible status dot is marked aria-hidden, keeping color as a visual cue rather than the only status carrier.
  • -
  • Table headers are generated from arrays to keep repeated header markup consistent.
  • -
  • Clipped values such as option contracts, exact timestamps, full notional values, NBBO quality strings, and venue labels now expose fuller details through title where useful.
  • -
-
- -
-

Relevant Diff Snippets

-

- Diff snippets are presented in the format expected by diffs.com-style unified diff rendering. @pierre/diffs is installed in this repo, but it does not expose a CLI binary, so the relevant unified snippets are embedded directly. -

-
diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx
-@@
-+export const buildTapeStatusAnnouncement = ({
-+  status,
-+  replayTime,
-+  replayComplete,
-+  paused,
-+  dropped,
-+  mode
-+}: Pick<TapeStatusProps, "status" | "replayTime" | "replayComplete" | "paused" | "dropped" | "mode">): string => {
-+  const label = replayComplete ? "Replay Complete" : statusLabel(status, paused, mode);
-+  const feedLabel = mode === "live" && label.toLowerCase().startsWith("feed ")
-+    ? label.toLowerCase()
-+    : `feed ${label.toLowerCase()}`;
-+  const parts = [`${mode === "live" ? "Live" : "Replay"} ${feedLabel}`];
-+  ...
-+};
-+
-+const EmptyState = ({ children }: { children: ReactNode }) => (
-+  <div className="empty" role="status" aria-live="polite">
-+    {children}
-+  </div>
-+);
-
-@@
--    <div className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}>
--      <span className="status-dot" />
-+    <div
-+      className={`status-inline status-${status} ${mode === "replay" ? "status-replay" : ""}`.trim()}
-+      role="status"
-+      aria-live="polite"
-+      aria-label={announcement}
-+    >
-+      <span className="status-dot" aria-hidden="true" />
- -
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
-@@
- .data-table-cell {
-   min-width: 0;
-   overflow: hidden;
-   text-overflow: ellipsis;
-   white-space: nowrap;
-   font-size: 0.72rem;
-+  unicode-bidi: plaintext;
- }
-
- .empty {
-+  display: flex;
-+  align-items: center;
-+  min-height: 76px;
-   padding: 18px;
-   border-radius: 12px;
-   border: 1px dashed var(--border);
-   background: var(--bg-soft);
-   color: var(--text-dim);
-+  line-height: 1.4;
-+  overflow-wrap: anywhere;
- }
- -
diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts
-@@
-+describe("tape status hardening", () => {
-+  it("announces stale live feeds without relying on the colored dot", () => {
-+    expect(
-+      buildTapeStatusAnnouncement({
-+        status: "stale",
-+        replayTime: null,
-+        replayComplete: false,
-+        paused: false,
-+        dropped: 0,
-+        mode: "live"
-+      })
-+    ).toBe("Live feed behind");
-+  });
-+});
-
- -
-

Expected Impact for End-Users

-

- Traders and researchers should get a steadier terminal under imperfect feed conditions. Screen-reader users get explicit live and replay status changes, empty panes announce themselves clearly, and clipped market values are easier to inspect without widening the layout. -

-
- -
-

Validation

-
    -
  • bun test apps/web/app/terminal.test.ts: 76 passing tests.
  • -
  • bun --cwd=apps/web run build: production build completed successfully.
  • -
  • Browser verification at http://localhost:3000/options: confirmed status regions, table semantics, column headers, rowgroup, and cells are present in the rendered page.
  • -
-
- -
-

Issues, Limitations, and Mitigations

-
    -
  • The Options pane can still be wider than a narrow viewport by design; the table remains inside its horizontal scroll container.
  • -
  • Alert, classifier, dark-event, and news panes still have some older one-off markup. This task hardened the highest-traffic tape panes first.
  • -
  • The browser check observed a history-load warning because backend history was unavailable locally. That state rendered cleanly and was not a build blocker.
  • -
-
- -
-

Follow-up Work

-
-

- No additional Beads issue was required during this turn. A sensible future pass would extend the same DataCell and EmptyState treatment to Alerts, Smart Money, Dark Events, and the chart evidence lists. -

-
-
-
- - diff --git a/docs/turns/2026-05-29-improve-narrow-options-table.html b/docs/turns/2026-05-29-improve-narrow-options-table.html deleted file mode 100644 index c4119bf..0000000 --- a/docs/turns/2026-05-29-improve-narrow-options-table.html +++ /dev/null @@ -1,359 +0,0 @@ - - - - - - Improve Narrow Options Table Responsiveness - - - -
-
-

Improve Narrow Options Table Responsiveness

-

- Adapted the Options route for narrow screens so dense tape tables stay inside bounded panes, - keep row identity visible while panning horizontally, and give the mobile ticker/filter controls - more room to breathe. -

-
- Completed: 2026-05-29 18:34 EDT - Issue: islandflow-833 - Surface: apps/web Options route -
-
- -
-

Summary

-

- The Options tape now behaves like a contained terminal pane on phone-sized screens instead of - stretching down the page with the full virtual table height. The first table column remains pinned - during horizontal panning, and the mobile topbar spacing is less compressed around the ticker field. -

-
- -
-

Changes Made

-
    -
  • Added bounded viewport-based heights for Options route panes at tablet and phone breakpoints.
  • -
  • Kept the first table column sticky on narrow screens so row context remains visible while panning.
  • -
  • Added a subtle right-edge affordance and touch scrolling refinements for horizontally wide tables.
  • -
  • Improved mobile topbar and page-action spacing around the Menu, Ticker, Contract Filter, and Filter controls.
  • -
-
- -
-

Context

-

- The Options route contains high-density virtualized market data. On desktop, panes have bounded heights - and table bodies scroll internally. At narrow breakpoints, the previous CSS changed those panes to - automatic height, which made the table read as an endless page rather than an isolated tape viewport. -

-
- -
-

Important Implementation Details

-
    -
  • The fix stays CSS-only and preserves the existing virtualized row markup and row-height assumptions.
  • -
  • The pane heights use svh so mobile browser chrome is handled better than with classic viewport units.
  • -
  • The sticky first column is limited to the narrow-screen breakpoint where horizontal panning is expected.
  • -
  • No backend URL or private environment configuration was added to committed source.
  • -
-
- -
-

Relevant Diff Snippets

-

- Rendered with @pierre/diffs/ssr using preloadPatchDiff against the real - apps/web/app/globals.css patch. The SSR output is embedded directly below. -

-
-
apps/web/app/globals.css
-9+109
1469
1470
1471
1472
1473
1474
2489
2490
2491
2492
2493
2494
2563
2564
2565
2566
2567
2568
2569
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2685
2686
2687
2688
2689
2690
2716
2717
2718
2719
2720
2721
2753
2754
2755
2756
2757
2758
2770
2771
2772
2773
2774
2775
2832
2833
2834
2835
2836
2837
}
-
.data-table-wrap {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-height: 0;
}
-
.command-deck-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
}
-
.terminal-content {
padding: 16px 10px 22px;
}
-
.page-shell {
position: sticky;
top: 0;
z-index: 30;
padding: 12px 10px;
}
-
.terminal-topbar-leading {
width: 100%;
}
-
.terminal-button,
.terminal-topbar-actions,
.terminal-topbar-controls,
.terminal-topbar-mode {
width: 100%;
justify-content: flex-start;
}
-
.terminal-topbar-actions,
.terminal-topbar-controls {
flex-direction: column;
align-items: stretch;
}
-
.terminal-menu-trigger {
width: 100%;
justify-content: center;
}
-
.terminal-topbar-mode .terminal-button,
.terminal-topbar-controls > .terminal-button,
.terminal-topbar-leading > .terminal-button,
.page-actions > .terminal-button,
.page-actions > .flow-filter-popover {
width: 100%;
}
-
.instrument-focus-chip {
max-width: none;
min-height: 44px;
justify-content: space-between;
border-radius: 12px;
}
-
.terminal-pane-head,
.terminal-pane-body {
padding: 14px 12px;
width: 100%;
flex-direction: column;
align-items: stretch;
}
-
.flow-filter-popover {
margin-inline: -12px;
border-radius: 0;
scroll-snap-type: x proximity;
}
-
.data-table {
padding-inline: 8px;
}
-
.data-table-row-options,
.data-table-row-equities {
height: 40px;
}
-
@media (max-width: 420px) {
.terminal-content {
padding-inline: 8px;
}
1469
1470
1471
1472
1473
1474
1475
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2568
2569
2570
2571
2572
2573
2574
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2753
2754
2755
2756
2757
2758
2759
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
}
-
.data-table-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
min-height: 0;
}
-
.page-grid-options > .terminal-pane {
height: clamp(430px, 68svh, 720px);
}
-
.command-deck-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
}
-
.terminal-content {
padding: 18px 10px calc(22px + env(safe-area-inset-bottom));
}
-
.page-shell {
position: sticky;
top: 0;
z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: 10px;
row-gap: 16px;
padding: 10px 10px 12px;
}
-
.terminal-topbar-leading {
width: auto;
min-width: 0;
grid-column: 1;
grid-row: 1;
}
-
.terminal-button,
.terminal-topbar-actions,
.terminal-topbar-controls,
.terminal-topbar-mode {
min-width: 0;
justify-content: flex-start;
}
-
.terminal-topbar-actions {
display: contents;
}
-
.terminal-topbar-controls {
width: 100%;
grid-column: 1 / -1;
grid-row: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 10px;
}
-
.terminal-topbar-mode {
grid-column: 2;
grid-row: 1;
width: auto;
justify-content: flex-end;
}
-
.terminal-menu-trigger {
width: auto;
justify-content: center;
}
-
.terminal-topbar-mode .terminal-button,
.terminal-topbar-leading > .terminal-button,
.page-actions > .terminal-button,
.page-actions > .flow-filter-popover {
width: 100%;
}
-
.terminal-topbar-controls > .terminal-button {
width: auto;
min-width: 76px;
}
-
.instrument-focus-chip {
grid-column: 1 / -1;
max-width: none;
min-height: 44px;
justify-content: space-between;
border-radius: 12px;
}
-
.page-grid-options > .terminal-pane {
height: clamp(390px, 62svh, 620px);
}
-
.terminal-pane-head,
.terminal-pane-body {
padding: 14px 12px;
width: 100%;
flex-direction: column;
align-items: stretch;
margin-top: 2px;
}
-
.flow-filter-popover {
margin-inline: -12px;
border-radius: 0;
scroll-snap-type: x proximity;
scrollbar-gutter: stable;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
-
.data-table-wrap::after {
content: "";
position: sticky;
right: 0;
z-index: 5;
flex: 0 0 18px;
pointer-events: none;
background: linear-gradient(90deg, transparent, oklch(0.12 0.01 250 / 0.92));
}
-
.data-table {
padding-inline: 8px;
}
-
.data-table-head .data-table-cell:first-child,
.data-table-row .data-table-cell:first-child {
position: sticky;
left: 0;
z-index: 4;
margin-left: -8px;
padding-left: 8px;
background: oklch(0.13 0.01 250);
box-shadow:
1px 0 0 oklch(0.72 0.012 250 / 0.14),
14px 0 18px oklch(0.06 0.01 250 / 0.42);
}
-
.data-table-head .data-table-cell:first-child {
z-index: 6;
background: oklch(0.15 0.012 250);
}
-
.data-table-row.is-even .data-table-cell:first-child {
background: oklch(0.145 0.011 250);
}
-
.data-table-row:hover .data-table-cell:first-child,
.data-table-row:focus-visible .data-table-cell:first-child {
background: oklch(0.18 0.025 74);
}
-
.data-table-row-classified .data-table-cell:first-child {
background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.1)), transparent 90%),
oklch(0.13 0.01 250);
}
-
.data-table-row-options,
.data-table-row-equities {
height: 40px;
}
-
@media (max-width: 420px) {
.terminal-topbar {
column-gap: 8px;
row-gap: 14px;
padding-inline: 8px;
}
-
.terminal-menu-trigger {
min-width: 92px;
padding-inline: 8px;
}
-
.terminal-topbar-mode .terminal-button {
min-width: 82px;
padding-inline: 8px;
}
-
.terminal-content {
padding-inline: 8px;
}
-
-
- -
-

Expected Impact for End-Users

-

- On phones and narrow browser windows, traders can scan the Options tape inside a stable pane instead of - losing the rest of the page to an unbounded table. Horizontal panning now keeps time context visible, - and the top controls are easier to distinguish and tap. -

-
- -
-

Validation

-
    -
  • Passed: bun --cwd=apps/web run build.
  • -
  • Passed: Browser check at 390px wide on http://localhost:3000/options.
  • -
  • Measured a contained pane height of 523px and internal table viewport of 317px with a larger internal scroll height.
  • -
  • Confirmed no page-level horizontal overflow and confirmed live tape status showed Connected.
  • -
-
- -
-

Issues, Limitations, and Mitigations

-
    -
  • The narrow-screen table is still a dense data table, not a separate card/list representation.
  • -
  • The sticky first column mitigates lost context without changing virtualization behavior or row heights.
  • -
  • The page remains vertically scrollable between stacked panes, but each tape now owns its internal scroll.
  • -
-
- -
-

Follow-up Work

-

- No required follow-up is left for islandflow-833. A future enhancement could add a dedicated compact row - layout for phone screens if the product wants less horizontal panning than the current dense terminal table. -

-
-
- - diff --git a/docs/turns/2026-05-30-expand-ci-quality-gates.html b/docs/turns/2026-05-30-expand-ci-quality-gates.html new file mode 100644 index 0000000..a498151 --- /dev/null +++ b/docs/turns/2026-05-30-expand-ci-quality-gates.html @@ -0,0 +1,137 @@ +Expand CI Quality Gates
Turn documentation · May 30, 2026

Expand CI Quality Gates

Added explicit formatting and linting to the Forgejo CI workflow, kept the existing Bun-first validation path, and formatted the current source tree once so the new formatter gate starts from a clean baseline.

Summary

The CI workflow now checks formatting, lint, type safety, tests, public API route wiring, Docker workspace snapshot drift, and the web production build. This gives pull requests a broader health signal without introducing a separate Node package manager or changing the repo's Bun-centered workflow.

Changes Made

  • Added @biomejs/biome as a pinned root development dependency.
  • Added root scripts: fmt, fmt:check, lint, and check.
  • Created biome.json with scoped includes for source, configs, scripts, and workflow files.
  • Expanded .forgejo/workflows/ci.yml with separate format, lint, and public API route checks.
  • Ran the formatter across existing in-scope files so fmt:check passes immediately.
  • Synced deployment/docker/workspace-root package and lock snapshots after dependency and script changes.

Context

The previous CI workflow already installed dependencies, ran typecheck, ran tests, checked the Docker workspace snapshot, and built the web app. The main gap was that style and lint regressions could land silently. Biome is a good first step because it gives fast formatter and linter coverage with one dependency and works cleanly with Bun.

Important Implementation Details

  • The Biome scope excludes generated and build-heavy paths such as node_modules, .next, dist, coverage, and apps/web/tsconfig.tsbuildinfo.
  • Some stricter recommended lint rules are disabled for now because the existing codebase has known legacy patterns. This keeps CI useful today while leaving a path to tighten rules incrementally.
  • The new CI order catches cheap failures first: formatting and lint run before typecheck, tests, route checks, snapshot checks, and build.

Relevant Diff Snippets

Rendered with @pierre/diffs/ssr. These snippets focus on the CI and configuration changes; the broader diff also includes the formatter baseline pass.

.forgejo/workflows/ci.yml

.forgejo/workflows/ci.yml
+9
35 unmodified lines
36
37
38
39
40
41
42
43
44
45
46
47
35 unmodified lines
- name: Install dependencies
run: ~/.bun/bin/bun install --frozen-lockfile
+
- name: Run typecheck
run: ~/.bun/bin/bun run typecheck
+
- name: Run tests
run: ~/.bun/bin/bun test
+
- name: Check Docker workspace snapshot
run: ~/.bun/bin/bun run check:docker-workspace
+
35 unmodified lines
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
35 unmodified lines
- name: Install dependencies
run: ~/.bun/bin/bun install --frozen-lockfile
+
- name: Check formatting
run: ~/.bun/bin/bun run fmt:check
+
- name: Run lint
run: ~/.bun/bin/bun run lint
+
- name: Run typecheck
run: ~/.bun/bin/bun run typecheck
+
- name: Run tests
run: ~/.bun/bin/bun test
+
- name: Check public API routes
run: ~/.bun/bin/bun run check:public-api-routes
+
- name: Check Docker workspace snapshot
run: ~/.bun/bin/bun run check:docker-workspace
+
+

package.json

package.json
+5
14 unmodified lines
15
16
17
18
19
20
5 unmodified lines
26
27
28
29
30
31
14 unmodified lines
"dev:desktop:remote": "bun run scripts/dev-desktop.ts --remote",
"dev:web": "bun --cwd=apps/web run dev",
"dev:services": "bun run scripts/dev-services.ts",
"package:desktop": "bun --cwd=apps/desktop run package",
"make:desktop": "bun --cwd=apps/desktop run make",
"deploy": "bun run scripts/deploy.ts",
5 unmodified lines
"check:docker-workspace": "bun run scripts/check-docker-workspace.ts"
},
"devDependencies": {
"@types/bun": "^1.3.3",
"@types/ws": "^8.18.1",
"typescript": "^5.9.3",
14 unmodified lines
15
16
17
18
19
20
21
22
23
24
5 unmodified lines
30
31
32
33
34
35
36
14 unmodified lines
"dev:desktop:remote": "bun run scripts/dev-desktop.ts --remote",
"dev:web": "bun --cwd=apps/web run dev",
"dev:services": "bun run scripts/dev-services.ts",
"fmt": "biome format --write .",
"fmt:check": "biome format .",
"lint": "biome lint .",
"check": "biome check .",
"package:desktop": "bun --cwd=apps/desktop run package",
"make:desktop": "bun --cwd=apps/desktop run make",
"deploy": "bun run scripts/deploy.ts",
5 unmodified lines
"check:docker-workspace": "bun run scripts/check-docker-workspace.ts"
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"@types/bun": "^1.3.3",
"@types/ws": "^8.18.1",
"typescript": "^5.9.3",

Expected Impact for End-Users

End users should see fewer regressions from accidental formatting drift, obvious lint mistakes, broken public API route wiring, Docker workspace snapshot drift, or web build failures. Product behavior is unchanged; this is a delivery-quality improvement.

Validation

passedbun run fmt:check
passedbun run lint
passedbun run typecheck
passedbun test (250 tests)
passedbun run check:public-api-routes
passedbun run check:docker-workspace
passedbun --cwd=apps/web run build

Issues, Limitations, and Mitigations

  • Lint is intentionally conservative at this stage. Several Biome recommended rules are disabled because enabling them all would require unrelated refactors across the terminal UI, services, and scripts.
  • The formatting baseline touched many files. These changes are mechanical and were validated with typecheck, tests, route checks, Docker snapshot checks, and the web build.
  • The workflow still installs Bun via the existing shell install path. A future improvement could switch to a reusable Forgejo-compatible setup action if one becomes preferred for this runner environment.

Follow-up Work

  • Incrementally re-enable stricter Biome rules once existing code patterns are cleaned up in focused PRs.
  • Add a dependency/security audit gate if the Forgejo runner has stable network access for that check.
  • Consider splitting CI into fast source checks and heavier build/test jobs if runtime becomes a bottleneck.
  • Consider adding artifact or coverage reporting after the current quality gates settle.
\ No newline at end of file diff --git a/docs/turns/2026-05-30-fix-forgejo-ci-test-mocks.html b/docs/turns/2026-05-30-fix-forgejo-ci-test-mocks.html new file mode 100644 index 0000000..72ea52d --- /dev/null +++ b/docs/turns/2026-05-30-fix-forgejo-ci-test-mocks.html @@ -0,0 +1,281 @@ + + + + + + Fix Forgejo CI terminal test mock alias + + + +
+
+
Turn document
+

Fix Forgejo CI terminal test mock alias

+

The remaining Forgejo-only failure was a Next.js module-shape mismatch in the terminal client component. I switched the terminal screen to a namespace import for next/navigation so Forgejo no longer trips over Bun's named-export resolution for usePathname.

+
+ Updated: 2026-05-30 01:57 EDT + Beads: islandflow-3l6 + Validation: targeted terminal test + full Bun suite passed +
+
+ +
+

New Changes as of 2026-05-30 01:57 EDT

+

This update follows the earlier Bun PATH and test-harness fixes. Forgejo was still failing inside the terminal component itself, where Bun 1.3.14 treated the direct usePathname import as a named-export mismatch. The component now reads the hook from the namespace import instead.

+

Summary of changes

+
    +
  • Changed apps/web/app/terminal.tsx to import next/navigation as a namespace.
  • +
  • Replaced the three direct usePathname() calls with nextNavigation.usePathname().
  • +
  • Left the earlier test mocks in place so the suite still covers both the package specifier and Bun's resolved path.
  • +
+

Why this change was made

+

The previous test-level mocks were enough for local Bun, but Forgejo's Bun 1.3.14 runtime still errored on the named export lookup inside the client component. Changing the import shape removes that check instead of asking the test harness to paper over it.

+

Code diff

+
import * as nextNavigation from "next/navigation";
+        

Related issues or PRs

+

islandflow-3l6

+
+ +
+

Summary

+

The remaining Forgejo failure was inside the terminal client component, not the install or typecheck stages. Using a namespace import keeps Bun from tripping over the usePathname named-export lookup in the runner.

+
+ +
+

Changes Made

+
    +
  • Updated apps/web/app/terminal.tsx to read usePathname through the nextNavigation namespace.
  • +
  • Kept the earlier test-harness aliases intact, since they still cover the old runner behavior and make the tests resilient.
  • +
  • Left the earlier Bun PATH and redirect-mock fixes intact, since they were already solving the other CI failure modes.
  • +
+
+ +
+

Context

+

The repository already had the Bun executable path fix and the routes mock alias fix in place. The remaining failure surfaced only in the full CI-shaped test run, where Bun 1.3.14 was stricter about the terminal client component's direct named import from next/navigation.

+
+ +
+

Important Implementation Details

+
    +
  • The terminal screen now reaches the pathname hook through the module namespace, which avoids Bun's stricter named-export check in CI.
  • +
  • This stays narrowly scoped to the client component and does not change the route semantics or the visible UI behavior.
  • +
  • The existing test mocks remain useful as guardrails, but the component import no longer depends on them to satisfy Bun's module loader.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs/ssr from the current working tree. It shows the terminal client component switching to a namespace import for next/navigation and updating the three pathname reads accordingly.

+
apps/web/app/terminal.tsx
-4+4
1
2
3
4
5
6
7
5369 unmodified lines
5377
5378
5379
5380
5381
5382
5383
1844 unmodified lines
7228
7229
7230
7231
7232
7233
7234
1863 unmodified lines
9098
9099
9100
9101
9102
9103
9104
"use client";
+
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
createContext,
memo,
5369 unmodified lines
};
+
const useTerminalState = () => {
const pathname = usePathname();
const routeFeatures = useMemo(() => getRouteFeatures(pathname), [pathname]);
const [mode, setMode] = useState<TapeMode>("live");
const [replaySource, setReplaySource] = useState<string | null>(null);
1844 unmodified lines
};
+
export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) => {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const activeCount = countActiveFlowFilterGroups(filters);
1863 unmodified lines
+
export function TerminalAppShell({ children }: { children: ReactNode }) {
const state = useTerminalState();
const pathname = usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
const tickerFieldId = useId();
const tickerHintId = useId();
1
2
3
4
5
6
7
5369 unmodified lines
5377
5378
5379
5380
5381
5382
5383
1844 unmodified lines
7228
7229
7230
7231
7232
7233
7234
1863 unmodified lines
9098
9099
9100
9101
9102
9103
9104
"use client";
+
import Link from "next/link";
import * as nextNavigation from "next/navigation";
import {
createContext,
memo,
5369 unmodified lines
};
+
const useTerminalState = () => {
const pathname = nextNavigation.usePathname();
const routeFeatures = useMemo(() => getRouteFeatures(pathname), [pathname]);
const [mode, setMode] = useState<TapeMode>("live");
const [replaySource, setReplaySource] = useState<string | null>(null);
1844 unmodified lines
};
+
export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) => {
const pathname = nextNavigation.usePathname();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const activeCount = countActiveFlowFilterGroups(filters);
1863 unmodified lines
+
export function TerminalAppShell({ children }: { children: ReactNode }) {
const state = useTerminalState();
const pathname = nextNavigation.usePathname();
const [drawerOpen, setDrawerOpen] = useState(false);
const tickerFieldId = useId();
const tickerHintId = useId();
+
+ +
+

Expected Impact for End-Users

+

Forgejo should stop failing on the terminal screen's CI-only module resolution mismatch, which reduces false negative pipeline runs and makes it easier to trust the branch when the suite passes.

+
+ +
+

Validation

+
    +
  • env PATH="$HOME/.bun/bin:/usr/bin:/bin" bun test apps/web/app/terminal.test.ts passed: 74 tests, 0 failures.
  • +
  • env PATH="$HOME/.bun/bin:/usr/bin:/bin" bun test passed: 250 tests, 0 failures.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+

This fix is intentionally narrow. If another CI-only Next.js import path shows up later, the same namespace-import pattern should be applied to the affected component or test file instead of broadening the mock surface globally. That keeps the failure signal honest and the test harness easy to reason about.

+
+ +
+

Follow-up Work

+
    +
  • Watch the next Forgejo run on this branch to confirm the namespace import clears the last failure.
  • +
  • If another module-shape mismatch appears, fold the shared mock setup into a tiny helper rather than repeating the alias logic by hand.
  • +
+
+
+ + diff --git a/package.json b/package.json index d2482d0..7dc2533 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "dev:desktop:remote": "bun run scripts/dev-desktop.ts --remote", "dev:web": "bun --cwd=apps/web run dev", "dev:services": "bun run scripts/dev-services.ts", + "fmt": "biome format --write .", + "fmt:check": "biome format .", + "lint": "biome lint .", + "check": "biome check .", "package:desktop": "bun --cwd=apps/desktop run package", "make:desktop": "bun --cwd=apps/desktop run make", "deploy": "bun run scripts/deploy.ts", @@ -26,6 +30,7 @@ "check:docker-workspace": "bun run scripts/check-docker-workspace.ts" }, "devDependencies": { + "@biomejs/biome": "^2.4.16", "@types/bun": "^1.3.3", "@types/ws": "^8.18.1", "typescript": "^5.9.3", @@ -34,7 +39,8 @@ "overrides": { "postcss": "^8.5.15", "tar": "^7.5.15", - "tmp": "^0.2.5" + "tmp": "^0.2.5", + "@electron/node-gyp": "^10.2.0-electron.2" }, "dependencies": { "@pierre/diffs": "^1.2.2" diff --git a/packages/bus/src/jetstream.ts b/packages/bus/src/jetstream.ts index b14ea01..96af151 100644 --- a/packages/bus/src/jetstream.ts +++ b/packages/bus/src/jetstream.ts @@ -16,7 +16,11 @@ import { nanos, millis } from "nats"; -import { getKnownStreamDefinitions, getStreamDefinition, type StreamRetentionClass } from "./streams"; +import { + getKnownStreamDefinitions, + getStreamDefinition, + type StreamRetentionClass +} from "./streams"; export type NatsConnectionOptions = { servers: string | string[]; @@ -251,9 +255,10 @@ const diffConfigFields = ( for (const field of fields) { const currentValue = getFieldValue(current, field); const desiredValue = getFieldValue(desired, field); - const matches = Array.isArray(currentValue) && Array.isArray(desiredValue) - ? arraysEqual(currentValue, desiredValue) - : currentValue === desiredValue; + const matches = + Array.isArray(currentValue) && Array.isArray(desiredValue) + ? arraysEqual(currentValue, desiredValue) + : currentValue === desiredValue; if (!matches) { deltas.push({ @@ -391,7 +396,10 @@ const formatStructuredValue = (value: unknown): string => { const formatStructuralMismatchMessage = (audit: StreamAuditReport): string => { const details = audit.structuralMismatch - .map((delta) => `${delta.field} current=${formatStructuredValue(delta.current)} desired=${formatStructuredValue(delta.desired)}`) + .map( + (delta) => + `${delta.field} current=${formatStructuredValue(delta.current)} desired=${formatStructuredValue(delta.desired)}` + ) .join("; "); return `Refusing to reconcile stream ${audit.name}: structural mismatch (${details})`; }; @@ -447,16 +455,18 @@ const formatReportLine = ( case "retention_drift": { const details = report.retentionDrift .map((delta) => { - const desiredValue = delta.field === "max_age" - ? formatDurationMs(millis(Number(delta.desired))) - : delta.field === "max_bytes" - ? formatBytes(Number(delta.desired)) - : formatStructuredValue(delta.desired); - const currentValue = delta.field === "max_age" - ? formatDurationMs(millis(Number(delta.current))) - : delta.field === "max_bytes" - ? formatBytes(Number(delta.current)) - : formatStructuredValue(delta.current); + const desiredValue = + delta.field === "max_age" + ? formatDurationMs(millis(Number(delta.desired))) + : delta.field === "max_bytes" + ? formatBytes(Number(delta.desired)) + : formatStructuredValue(delta.desired); + const currentValue = + delta.field === "max_age" + ? formatDurationMs(millis(Number(delta.current))) + : delta.field === "max_bytes" + ? formatBytes(Number(delta.current)) + : formatStructuredValue(delta.current); return `${delta.field}:${currentValue}->${desiredValue}`; }) .join(" "); @@ -464,7 +474,10 @@ const formatReportLine = ( } case "structural_mismatch": { const details = report.structuralMismatch - .map((delta) => `${delta.field}:${formatStructuredValue(delta.current)}->${formatStructuredValue(delta.desired)}`) + .map( + (delta) => + `${delta.field}:${formatStructuredValue(delta.current)}->${formatStructuredValue(delta.desired)}` + ) .join(" "); return `● ${report.name} structural-mismatch ${details}`; } diff --git a/packages/bus/src/streams.ts b/packages/bus/src/streams.ts index b23c125..c96b9ca 100644 --- a/packages/bus/src/streams.ts +++ b/packages/bus/src/streams.ts @@ -59,7 +59,9 @@ export const STREAM_CATALOG: readonly KnownStreamDefinition[] = [ { name: STREAM_NEWS, subject: SUBJECT_NEWS, retentionClass: "derived" } ]; -const STREAM_CATALOG_BY_NAME = new Map(STREAM_CATALOG.map((definition) => [definition.name, definition])); +const STREAM_CATALOG_BY_NAME = new Map( + STREAM_CATALOG.map((definition) => [definition.name, definition]) +); export const getKnownStreamDefinitions = (): readonly KnownStreamDefinition[] => { return STREAM_CATALOG; diff --git a/packages/bus/src/synthetic-control.ts b/packages/bus/src/synthetic-control.ts index 052d777..dcf0d0d 100644 --- a/packages/bus/src/synthetic-control.ts +++ b/packages/bus/src/synthetic-control.ts @@ -11,44 +11,31 @@ export const SYNTHETIC_CONTROL_GLOBAL_KEY = "global"; const codec = JSONCodec(); -const decodeSyntheticControlEntry = ( - entry: KvEntry | null | undefined -): SyntheticControlState => { +const decodeSyntheticControlEntry = (entry: KvEntry | null | undefined): SyntheticControlState => { if (!entry || entry.operation !== "PUT") { return DEFAULT_SYNTHETIC_CONTROL_STATE; } return SyntheticControlStateSchema.parse(entry.json()); }; -export const openSyntheticControlKv = async ( - js: JetStreamClient -): Promise => { +export const openSyntheticControlKv = async (js: JetStreamClient): Promise => { return js.views.kv(SYNTHETIC_CONTROL_BUCKET, { description: "Hosted synthetic market internal control state", history: 8 }); }; -export const readSyntheticControlState = async ( - kv: KV -): Promise => { - return decodeSyntheticControlEntry( - await kv.get(SYNTHETIC_CONTROL_GLOBAL_KEY) - ); +export const readSyntheticControlState = async (kv: KV): Promise => { + return decodeSyntheticControlEntry(await kv.get(SYNTHETIC_CONTROL_GLOBAL_KEY)); }; -export const ensureSyntheticControlState = async ( - kv: KV -): Promise => { +export const ensureSyntheticControlState = async (kv: KV): Promise => { const current = await kv.get(SYNTHETIC_CONTROL_GLOBAL_KEY); if (current && current.operation === "PUT") { return SyntheticControlStateSchema.parse(current.json()); } - await kv.put( - SYNTHETIC_CONTROL_GLOBAL_KEY, - codec.encode(DEFAULT_SYNTHETIC_CONTROL_STATE) - ); + await kv.put(SYNTHETIC_CONTROL_GLOBAL_KEY, codec.encode(DEFAULT_SYNTHETIC_CONTROL_STATE)); return DEFAULT_SYNTHETIC_CONTROL_STATE; }; @@ -57,10 +44,7 @@ export const writeSyntheticControlState = async ( control: Partial ): Promise => { const normalized = normalizeSyntheticControlState(control); - await kv.put( - SYNTHETIC_CONTROL_GLOBAL_KEY, - codec.encode(normalized) - ); + await kv.put(SYNTHETIC_CONTROL_GLOBAL_KEY, codec.encode(normalized)); return normalized; }; diff --git a/packages/bus/tests/jetstream.test.ts b/packages/bus/tests/jetstream.test.ts index 671632a..c7eb536 100644 --- a/packages/bus/tests/jetstream.test.ts +++ b/packages/bus/tests/jetstream.test.ts @@ -43,10 +43,9 @@ const buildMockStreamManager = (configs: Record) => }; const buildAllKnownConfigs = (env: Record = {}) => { - return Object.fromEntries(STREAMS.map((name) => [name, buildKnownStreamConfig(name, env)])) as Record< - string, - StreamConfig - >; + return Object.fromEntries( + STREAMS.map((name) => [name, buildKnownStreamConfig(name, env)]) + ) as Record; }; describe("jetstream retention defaults", () => { @@ -194,7 +193,9 @@ describe("runReconcileStreamsCommand", () => { }); expect(exitCode).toBe(1); - expect(outputs.some((line) => line.includes("OPTIONS_PRINTS") && line.includes("drift"))).toBe(true); + expect(outputs.some((line) => line.includes("OPTIONS_PRINTS") && line.includes("drift"))).toBe( + true + ); }); it("updates drift in --apply mode and reports actions", async () => { @@ -240,7 +241,11 @@ describe("runReconcileStreamsCommand", () => { }); expect(exitCode).toBe(1); - expect(outputs.some((line) => line.includes("OPTIONS_PRINTS") && line.includes("structural-mismatch"))).toBe(true); + expect( + outputs.some( + (line) => line.includes("OPTIONS_PRINTS") && line.includes("structural-mismatch") + ) + ).toBe(true); expect(errors.some((line) => line.includes("OPTIONS_PRINTS"))).toBe(true); }); }); diff --git a/packages/config/src/alpaca.ts b/packages/config/src/alpaca.ts index 697d65b..81762c4 100644 --- a/packages/config/src/alpaca.ts +++ b/packages/config/src/alpaca.ts @@ -15,14 +15,10 @@ type AlpacaCredentialEnv = { const normalize = (value: string | undefined): string => value?.trim() ?? ""; -export const resolveAlpacaCredentials = ( - env: AlpacaCredentialEnv -): AlpacaCredentials => { +export const resolveAlpacaCredentials = (env: AlpacaCredentialEnv): AlpacaCredentials => { const legacyToken = normalize(env.ALPACA_API_KEY); - const explicitKeyId = - normalize(env.ALPACA_API_KEY_ID) || normalize(env.ALPACA_KEY_ID); - const secret = - normalize(env.ALPACA_API_SECRET_KEY) || normalize(env.ALPACA_SECRET_KEY); + const explicitKeyId = normalize(env.ALPACA_API_KEY_ID) || normalize(env.ALPACA_KEY_ID); + const secret = normalize(env.ALPACA_API_SECRET_KEY) || normalize(env.ALPACA_SECRET_KEY); const keyId = explicitKeyId || legacyToken; const usesLegacyBearer = !explicitKeyId && !secret && legacyToken.length > 0; @@ -42,9 +38,7 @@ export const hasAlpacaCredentials = (credentials: AlpacaCredentials): boolean => return credentials.keyId.length > 0 && credentials.secret.length > 0; }; -export const buildAlpacaAuthHeaders = ( - credentials: AlpacaCredentials -): Record => { +export const buildAlpacaAuthHeaders = (credentials: AlpacaCredentials): Record => { if (credentials.usesLegacyBearer) { return { Authorization: `Bearer ${credentials.legacyToken}` diff --git a/packages/storage/src/alerts.ts b/packages/storage/src/alerts.ts index ae79e75..85f8850 100644 --- a/packages/storage/src/alerts.ts +++ b/packages/storage/src/alerts.ts @@ -99,7 +99,9 @@ const safeProfileScoreArray = (value: string): SmartMoneyProfileScore[] => { return { profile_id: String(record.profile_id ?? "") as SmartMoneyProfileScore["profile_id"], probability: Number(record.probability ?? 0), - confidence_band: String(record.confidence_band ?? "low") as SmartMoneyProfileScore["confidence_band"], + confidence_band: String( + record.confidence_band ?? "low" + ) as SmartMoneyProfileScore["confidence_band"], direction: String(record.direction ?? "unknown") as SmartMoneyProfileScore["direction"], reasons: Array.isArray(record.reasons) ? record.reasons.map((item) => String(item)) : [] }; @@ -122,7 +124,9 @@ export const fromAlertRecord = (record: AlertRecord): AlertEvent => { severity: record.severity, hits: safeHitArray(record.hits_json), evidence_refs: safeStringArray(record.evidence_refs_json), - ...(record.primary_profile_id ? { primary_profile_id: record.primary_profile_id as AlertEvent["primary_profile_id"] } : {}), + ...(record.primary_profile_id + ? { primary_profile_id: record.primary_profile_id as AlertEvent["primary_profile_id"] } + : {}), profile_scores: safeProfileScoreArray(record.profile_scores_json) }; }; diff --git a/packages/storage/src/clickhouse.ts b/packages/storage/src/clickhouse.ts index af469d7..2e1f0d3 100644 --- a/packages/storage/src/clickhouse.ts +++ b/packages/storage/src/clickhouse.ts @@ -35,16 +35,8 @@ import { OPTION_PRINTS_TABLE } from "./option-prints"; import { normalizeOptionNBBO, optionNBBOTableDDL, OPTION_NBBO_TABLE } from "./option-nbbo"; -import { - equityPrintsTableDDL, - EQUITY_PRINTS_TABLE, - normalizeEquityPrint -} from "./equity-prints"; -import { - equityQuotesTableDDL, - EQUITY_QUOTES_TABLE, - normalizeEquityQuote -} from "./equity-quotes"; +import { equityPrintsTableDDL, EQUITY_PRINTS_TABLE, normalizeEquityPrint } from "./equity-prints"; +import { equityQuotesTableDDL, EQUITY_QUOTES_TABLE, normalizeEquityQuote } from "./equity-quotes"; import { equityCandlesTableDDL, EQUITY_CANDLES_TABLE, @@ -93,13 +85,7 @@ import { toSmartMoneyEventRecord, type SmartMoneyEventRecord } from "./smart-money-events"; -import { - NEWS_TABLE, - newsTableDDL, - fromNewsRecord, - toNewsRecord, - type NewsRecord -} from "./news"; +import { NEWS_TABLE, newsTableDDL, fromNewsRecord, toNewsRecord, type NewsRecord } from "./news"; export type ClickHouseOptions = { url: string; @@ -116,7 +102,11 @@ type ClickHouseQueryResult = { export type ClickHouseClient = { exec(params: { query: string }): Promise; - insert(params: { table: string; values: unknown[]; format: ClickHouseQueryFormat }): Promise; + insert(params: { + table: string; + values: unknown[]; + format: ClickHouseQueryFormat; + }): Promise; query(params: { query: string; format: ClickHouseQueryFormat }): Promise; ping(): Promise<{ success: boolean; error?: Error }>; close(): Promise; @@ -140,7 +130,9 @@ const buildHeaders = (options: ClickHouseOptions, hasBody: boolean): Headers => } if (options.username || options.password) { - const auth = Buffer.from(`${options.username ?? "default"}:${options.password ?? ""}`).toString("base64"); + const auth = Buffer.from(`${options.username ?? "default"}:${options.password ?? ""}`).toString( + "base64" + ); headers.set("authorization", `Basic ${auth}`); } @@ -217,7 +209,8 @@ export const createClickHouseClient = (options: ClickHouseOptions): ClickHouseCl }); if (!response.ok) { - const message = (await response.text()).trim() || `${response.status} ${response.statusText}`; + const message = + (await response.text()).trim() || `${response.status} ${response.statusText}`; return { success: false, error: new Error(message) }; } @@ -237,9 +230,7 @@ export const createClickHouseClient = (options: ClickHouseOptions): ClickHouseCl }; }; -export const ensureOptionPrintsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureOptionPrintsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: optionPrintsTableDDL() }); @@ -248,73 +239,55 @@ export const ensureOptionPrintsTable = async ( } }; -export const ensureOptionNBBOTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureOptionNBBOTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: optionNBBOTableDDL() }); }; -export const ensureEquityPrintsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureEquityPrintsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: equityPrintsTableDDL() }); }; -export const ensureEquityQuotesTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureEquityQuotesTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: equityQuotesTableDDL() }); }; -export const ensureEquityCandlesTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureEquityCandlesTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: equityCandlesTableDDL() }); }; -export const ensureEquityPrintJoinsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureEquityPrintJoinsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: equityPrintJoinsTableDDL() }); }; -export const ensureInferredDarkTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureInferredDarkTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: inferredDarkTableDDL() }); }; -export const ensureFlowPacketsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureFlowPacketsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: flowPacketsTableDDL() }); }; -export const ensureSmartMoneyEventsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureSmartMoneyEventsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: smartMoneyEventsTableDDL() }); }; -export const ensureClassifierHitsTable = async ( - client: ClickHouseClient -): Promise => { +export const ensureClassifierHitsTable = async (client: ClickHouseClient): Promise => { await client.exec({ query: classifierHitsTableDDL() }); @@ -464,7 +437,10 @@ export const insertAlert = async (client: ClickHouseClient, alert: AlertEvent): }); }; -export const insertNewsStory = async (client: ClickHouseClient, story: NewsStory): Promise => { +export const insertNewsStory = async ( + client: ClickHouseClient, + story: NewsStory +): Promise => { const record = toNewsRecord(story); await client.insert({ table: NEWS_TABLE, @@ -617,17 +593,11 @@ export const enqueueClassifierHitInsert = ( writer.enqueue(CLASSIFIER_HITS_TABLE, toClassifierHitRecord(hit)); }; -export const enqueueAlertInsert = ( - writer: ClickHouseBatchWriter, - alert: AlertEvent -): void => { +export const enqueueAlertInsert = (writer: ClickHouseBatchWriter, alert: AlertEvent): void => { writer.enqueue(ALERTS_TABLE, toAlertRecord(alert)); }; -export const enqueueNewsStoryInsert = ( - writer: ClickHouseBatchWriter, - story: NewsStory -): void => { +export const enqueueNewsStoryInsert = (writer: ClickHouseBatchWriter, story: NewsStory): void => { writer.enqueue(NEWS_TABLE, toNewsRecord(story)); }; @@ -973,9 +943,7 @@ const normalizeFlowPacketRow = (row: unknown): FlowPacketRecord | null => { seq: coerceNumber(record.seq) as number, trace_id: String(record.trace_id ?? ""), id: String(record.id ?? ""), - members: Array.isArray(record.members) - ? record.members.map((value) => String(value)) - : [], + members: Array.isArray(record.members) ? record.members.map((value) => String(value)) : [], features_json: String(record.features_json ?? "{}"), join_quality_json: String(record.join_quality_json ?? "{}") }; @@ -1011,7 +979,9 @@ const normalizeSmartMoneyEventRow = (row: unknown): SmartMoneyEventRecord | null seq: coerceNumber(record.seq) as number, trace_id: String(record.trace_id ?? ""), event_id: String(record.event_id ?? ""), - packet_ids: Array.isArray(record.packet_ids) ? record.packet_ids.map((value) => String(value)) : [], + packet_ids: Array.isArray(record.packet_ids) + ? record.packet_ids.map((value) => String(value)) + : [], member_print_ids: Array.isArray(record.member_print_ids) ? record.member_print_ids.map((value) => String(value)) : [], @@ -1390,8 +1360,12 @@ export const fetchAlertContextByTraceId = async ( const packetIds = new Set(flowPackets.flatMap((packet) => [packet.id, packet.trace_id])); const printIds = new Set(optionPrints.map((print) => print.trace_id)); const missingRefs = refs.filter((ref) => { - const packetResolved = flowPacketCandidatesFromRef(ref).some((candidate) => packetIds.has(candidate)); - const printResolved = optionPrintCandidatesFromRef(ref).some((candidate) => printIds.has(candidate)); + const packetResolved = flowPacketCandidatesFromRef(ref).some((candidate) => + packetIds.has(candidate) + ); + const printResolved = optionPrintCandidatesFromRef(ref).some((candidate) => + printIds.has(candidate) + ); return !packetResolved && !printResolved; }); diff --git a/packages/storage/tests/alerts.test.ts b/packages/storage/tests/alerts.test.ts index f6d8859..c94e8c1 100644 --- a/packages/storage/tests/alerts.test.ts +++ b/packages/storage/tests/alerts.test.ts @@ -130,7 +130,10 @@ describe("alerts storage helpers", () => { }); it("returns an empty context when the alert is missing", async () => { - const bundle = await fetchAlertContextByTraceId(makeClient(() => []), "alert:missing"); + const bundle = await fetchAlertContextByTraceId( + makeClient(() => []), + "alert:missing" + ); expect(bundle).toEqual({ alert: null, diff --git a/packages/storage/tests/flow-packets.test.ts b/packages/storage/tests/flow-packets.test.ts index f31928b..b70f705 100644 --- a/packages/storage/tests/flow-packets.test.ts +++ b/packages/storage/tests/flow-packets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { createClickHouseClient, fetchFlowPacketById, fetchFlowPacketsBefore } from "../src/clickhouse"; +import { + createClickHouseClient, + fetchFlowPacketById, + fetchFlowPacketsBefore +} from "../src/clickhouse"; import { flowPacketsTableDDL, FLOW_PACKETS_TABLE, diff --git a/packages/storage/tests/news.test.ts b/packages/storage/tests/news.test.ts index c5b71c8..ebfbc34 100644 --- a/packages/storage/tests/news.test.ts +++ b/packages/storage/tests/news.test.ts @@ -1,16 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { ClickHouseClient } from "../src/clickhouse"; -import { - NEWS_TABLE, - fromNewsRecord, - newsTableDDL, - toNewsRecord -} from "../src/news"; -import { - fetchNewsAfter, - fetchNewsBefore, - fetchRecentNews -} from "../src/clickhouse"; +import { NEWS_TABLE, fromNewsRecord, newsTableDDL, toNewsRecord } from "../src/news"; +import { fetchNewsAfter, fetchNewsBefore, fetchRecentNews } from "../src/clickhouse"; const makeClient = (resolver: (query: string) => unknown[]): ClickHouseClient => ({ diff --git a/packages/storage/tests/option-prints.test.ts b/packages/storage/tests/option-prints.test.ts index 139b66a..ba83be8 100644 --- a/packages/storage/tests/option-prints.test.ts +++ b/packages/storage/tests/option-prints.test.ts @@ -5,7 +5,11 @@ import { fetchOptionPrintsByTraceIds, fetchRecentOptionPrints } from "../src/clickhouse"; -import { normalizeOptionPrint, optionPrintsTableDDL, OPTION_PRINTS_TABLE } from "../src/option-prints"; +import { + normalizeOptionPrint, + optionPrintsTableDDL, + OPTION_PRINTS_TABLE +} from "../src/option-prints"; const basePrint = { source_ts: 100, diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 0556bd8..8747608 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -18,37 +18,103 @@ export const OptionPrintSchema = EventMetaSchema.merge( size: z.number().int().positive(), exchange: z.string().min(1), conditions: z.array(z.string().min(1)).optional(), - underlying_id: z.preprocess((value) => (value === null ? undefined : value), z.string().min(1).optional()), - option_type: z.preprocess((value) => (value === null ? undefined : value), OptionTypeSchema.optional()), - notional: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()), - nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()), - execution_nbbo_bid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_nbbo_ask: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_nbbo_mid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_nbbo_spread: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_nbbo_bid_size: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()), - execution_nbbo_ask_size: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()), - execution_nbbo_ts: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()), - execution_nbbo_age_ms: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()), - execution_nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()), - execution_underlying_spot: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_underlying_bid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_underlying_ask: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_underlying_mid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_underlying_spread: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()), - execution_underlying_ts: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()), - execution_underlying_age_ms: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()), + underlying_id: z.preprocess( + (value) => (value === null ? undefined : value), + z.string().min(1).optional() + ), + option_type: z.preprocess( + (value) => (value === null ? undefined : value), + OptionTypeSchema.optional() + ), + notional: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().nonnegative().optional() + ), + nbbo_side: z.preprocess( + (value) => (value === null ? undefined : value), + OptionNbboSideSchema.optional() + ), + execution_nbbo_bid: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_nbbo_ask: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_nbbo_mid: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_nbbo_spread: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_nbbo_bid_size: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().int().nonnegative().optional() + ), + execution_nbbo_ask_size: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().int().nonnegative().optional() + ), + execution_nbbo_ts: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().int().nonnegative().optional() + ), + execution_nbbo_age_ms: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().nonnegative().optional() + ), + execution_nbbo_side: z.preprocess( + (value) => (value === null ? undefined : value), + OptionNbboSideSchema.optional() + ), + execution_underlying_spot: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_underlying_bid: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_underlying_ask: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_underlying_mid: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_underlying_spread: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().optional() + ), + execution_underlying_ts: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().int().nonnegative().optional() + ), + execution_underlying_age_ms: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().nonnegative().optional() + ), execution_underlying_source: z.preprocess( (value) => (value === null ? undefined : value), z.literal("equity_quote_mid").optional() ), - execution_iv: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()), + execution_iv: z.preprocess( + (value) => (value === null ? undefined : value), + z.number().nonnegative().optional() + ), execution_iv_source: z.preprocess( (value) => (value === null ? undefined : value), z.enum(["provider", "synthetic_pressure_model"]).optional() ), is_etf: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()), - signal_pass: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()), + signal_pass: z.preprocess( + (value) => (value === null ? undefined : value), + z.boolean().optional() + ), signal_reasons: z.array(z.string().min(1)).optional(), signal_profile: z.preprocess( (value) => (value === null ? undefined : value), @@ -146,7 +212,13 @@ export const SmartMoneyProfileIdSchema = z.enum([ export type SmartMoneyProfileId = z.infer; -export const SmartMoneyDirectionSchema = z.enum(["bullish", "bearish", "neutral", "mixed", "unknown"]); +export const SmartMoneyDirectionSchema = z.enum([ + "bullish", + "bearish", + "neutral", + "mixed", + "unknown" +]); export type SmartMoneyDirection = z.infer; diff --git a/packages/types/src/live.ts b/packages/types/src/live.ts index 10ac486..8e0e4ac 100644 --- a/packages/types/src/live.ts +++ b/packages/types/src/live.ts @@ -13,10 +13,7 @@ import { OptionPrintSchema, SmartMoneyEventSchema } from "./events"; -import { - OptionFlowFiltersSchema, - optionFlowFilterKey -} from "./options-flow"; +import { OptionFlowFiltersSchema, optionFlowFilterKey } from "./options-flow"; export const CursorSchema = z.object({ ts: z.number().int().nonnegative(), @@ -94,7 +91,15 @@ export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [ snapshot_limit: z.number().int().positive().optional() }), z.object({ - channel: z.enum(["nbbo", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark", "news"]), + channel: z.enum([ + "nbbo", + "equity-quotes", + "equity-joins", + "classifier-hits", + "alerts", + "inferred-dark", + "news" + ]), snapshot_limit: z.number().int().positive().optional() }), z.object({ diff --git a/packages/types/src/options-flow.ts b/packages/types/src/options-flow.ts index 75dd581..5f3da2f 100644 --- a/packages/types/src/options-flow.ts +++ b/packages/types/src/options-flow.ts @@ -212,9 +212,10 @@ export const deriveOptionPrintMetadata = ( const parsed = parseOptionContractId(print.option_contract_id); const underlying = parsed?.root?.toUpperCase(); const optionType = parsed?.right === "C" ? "call" : parsed?.right === "P" ? "put" : undefined; - const notional = Number.isFinite(print.price) && Number.isFinite(print.size) - ? Number((print.price * print.size * 100).toFixed(2)) - : undefined; + const notional = + Number.isFinite(print.price) && Number.isFinite(print.size) + ? Number((print.price * print.size * 100).toFixed(2)) + : undefined; return { underlying_id: underlying, @@ -243,7 +244,14 @@ const balancedThresholds = (config: OptionsSignalConfig): OptionsSignalConfig => export const evaluateOptionSignal = ( print: Pick< OptionPrint, - "size" | "conditions" | "signal_profile" | "underlying_id" | "option_type" | "notional" | "nbbo_side" | "is_etf" + | "size" + | "conditions" + | "signal_profile" + | "underlying_id" + | "option_type" + | "notional" + | "nbbo_side" + | "is_etf" >, baseConfig: OptionsSignalConfig ): OptionSignalDecision => { @@ -260,7 +268,8 @@ export const evaluateOptionSignal = ( const reasons: string[] = []; const notional = print.notional ?? 0; const side = print.nbbo_side ?? "MISSING"; - const isSweepOrIso = hasCondition(print.conditions, "SWEEP") || hasCondition(print.conditions, "ISO"); + const isSweepOrIso = + hasCondition(print.conditions, "SWEEP") || hasCondition(print.conditions, "ISO"); if (notional < config.minNotional) { return { @@ -413,8 +422,14 @@ export const matchesFlowPacketFilters = ( } const features = packet.features ?? {}; - const totalNotional = typeof features.total_notional === "number" ? features.total_notional : Number(features.total_notional ?? 0); - if (typeof filters.minNotional === "number" && (!Number.isFinite(totalNotional) || totalNotional < filters.minNotional)) { + const totalNotional = + typeof features.total_notional === "number" + ? features.total_notional + : Number(features.total_notional ?? 0); + if ( + typeof filters.minNotional === "number" && + (!Number.isFinite(totalNotional) || totalNotional < filters.minNotional) + ) { return false; } @@ -433,10 +448,7 @@ export const matchesFlowPacketFilters = ( : typeof features.structure_rights === "string" ? features.structure_rights.toLowerCase() : null; - if ( - !optionType || - !filters.optionTypes.some((selected) => optionType.includes(selected)) - ) { + if (!optionType || !filters.optionTypes.some((selected) => optionType.includes(selected))) { return false; } } diff --git a/packages/types/src/sp500.ts b/packages/types/src/sp500.ts index 72066f0..8a56306 100644 --- a/packages/types/src/sp500.ts +++ b/packages/types/src/sp500.ts @@ -501,7 +501,7 @@ export const SP500_SYMBOLS = [ "YUM", "ZBRA", "ZBH", - "ZTS", + "ZTS" ] as const; -export type Sp500Symbol = typeof SP500_SYMBOLS[number]; +export type Sp500Symbol = (typeof SP500_SYMBOLS)[number]; diff --git a/packages/types/src/synthetic-market.ts b/packages/types/src/synthetic-market.ts index ea30c86..e0d8938 100644 --- a/packages/types/src/synthetic-market.ts +++ b/packages/types/src/synthetic-market.ts @@ -26,10 +26,7 @@ const SMART_MONEY_PROFILE_IDS = [ "arbitrage", "hedge_reactive" ] as const satisfies readonly SmartMoneyProfileId[]; -const SYNTHETIC_SCENARIO_FAMILY_IDS = [ - ...SMART_MONEY_PROFILE_IDS, - "neutral_noise" -] as const; +const SYNTHETIC_SCENARIO_FAMILY_IDS = [...SMART_MONEY_PROFILE_IDS, "neutral_noise"] as const; const REGIME_IDS = [ "trend_up", "trend_down", @@ -54,18 +51,14 @@ export const SyntheticCoverageWindowMinutesSchema = z.union([ z.literal(20), z.literal(30) ]); -export type SyntheticCoverageWindowMinutes = z.infer< - typeof SyntheticCoverageWindowMinutesSchema ->; +export type SyntheticCoverageWindowMinutes = z.infer; export const SyntheticProfileWeightValueSchema = z.union([ z.literal(0.6), z.literal(1.0), z.literal(1.6) ]); -export type SyntheticProfileWeightValue = z.infer< - typeof SyntheticProfileWeightValueSchema ->; +export type SyntheticProfileWeightValue = z.infer; export const SyntheticProfileWeightMapSchema = z .object({ @@ -77,9 +70,7 @@ export const SyntheticProfileWeightMapSchema = z hedge_reactive: SyntheticProfileWeightValueSchema }) .strict(); -export type SyntheticProfileWeightMap = z.infer< - typeof SyntheticProfileWeightMapSchema ->; +export type SyntheticProfileWeightMap = z.infer; export const SyntheticControlStateSchema = z .object({ @@ -94,23 +85,14 @@ export const SyntheticControlStateSchema = z .strict(); export type SyntheticControlState = z.infer; -export const SyntheticSessionPhaseSchema = z.enum([ - "open", - "midday", - "power_hour", - "after_event" -]); +export const SyntheticSessionPhaseSchema = z.enum(["open", "midday", "power_hour", "after_event"]); export type SyntheticSessionPhase = z.infer; export const SyntheticRegimeSchema = z.enum(REGIME_IDS); export type SyntheticRegime = z.infer; -export const SyntheticScenarioFamilyIdSchema = z.enum( - SYNTHETIC_SCENARIO_FAMILY_IDS -); -export type SyntheticScenarioFamilyId = z.infer< - typeof SyntheticScenarioFamilyIdSchema ->; +export const SyntheticScenarioFamilyIdSchema = z.enum(SYNTHETIC_SCENARIO_FAMILY_IDS); +export type SyntheticScenarioFamilyId = z.infer; export const SyntheticCoverageConfigSchema = z .object({ @@ -118,9 +100,7 @@ export const SyntheticCoverageConfigSchema = z coverage_window_minutes: SyntheticCoverageWindowMinutesSchema }) .strict(); -export type SyntheticCoverageConfig = z.infer< - typeof SyntheticCoverageConfigSchema ->; +export type SyntheticCoverageConfig = z.infer; export const SyntheticDerivedStatusSchema = z .object({ @@ -131,9 +111,7 @@ export const SyntheticDerivedStatusSchema = z coverage_window_minutes: SyntheticCoverageWindowMinutesSchema }) .strict(); -export type SyntheticDerivedStatus = z.infer< - typeof SyntheticDerivedStatusSchema ->; +export type SyntheticDerivedStatus = z.infer; export type SyntheticSessionState = { session_phase: SyntheticSessionPhase; @@ -160,10 +138,7 @@ export type SyntheticUnderlyingState = { offExchangeBias: number; }; -export type SyntheticScenarioWeightMap = Record< - SyntheticScenarioFamilyId, - number ->; +export type SyntheticScenarioWeightMap = Record; export type SyntheticCoverageState = { profile_hit_counts: Record; @@ -195,10 +170,7 @@ export const DEFAULT_SYNTHETIC_CONTROL_STATE: SyntheticControlState = { updated_by: "system" }; -const PRESET_REGIME_BIAS: Record< - SyntheticControlPresetId, - Record -> = { +const PRESET_REGIME_BIAS: Record> = { balanced_demo: { trend_up: 1.0, trend_down: 0.95, @@ -257,10 +229,7 @@ const PRESET_ACTIVITY_BIAS: Record< quiet_range: { focusCount: 2, eventCount: 1, amplitude: 0.72 } }; -const REGIME_PROFILE_BIAS: Record< - SyntheticRegime, - SyntheticScenarioWeightMap -> = { +const REGIME_PROFILE_BIAS: Record = { trend_up: { institutional_directional: 1.35, retail_whale: 1.05, @@ -411,16 +380,12 @@ const mixSeed = (...parts: number[]): number => { return seed >>> 0; }; -const pick = (items: readonly T[], seed: number): T => { +const pick = (items: readonly T[], seed: number): T => { const index = Math.abs(seed) % items.length; return items[index]!; }; -const pickManyUnique = ( - items: readonly T[], - count: number, - seed: number -): T[] => { +const pickManyUnique = (items: readonly T[], count: number, seed: number): T[] => { const pool = [...items]; const output: T[] = []; let cursor = seed; @@ -432,10 +397,7 @@ const pickManyUnique = ( return output; }; -const weightedPick = ( - weights: Record, - seed: number -): T => { +const weightedPick = (weights: Record, seed: number): T => { const entries = Object.entries(weights) as Array<[T, number]>; const total = entries.reduce((sum, [, weight]) => sum + Math.max(0.0001, weight), 0); let target = positiveNoise(seed) * total; @@ -461,10 +423,7 @@ export const hashSyntheticSymbol = (value: string): number => { return hash; }; -export const buildEmptySyntheticProfileHitCounts = (): Record< - SmartMoneyProfileId, - number -> => ({ +export const buildEmptySyntheticProfileHitCounts = (): Record => ({ institutional_directional: 0, retail_whale: 0, event_driven: 0, @@ -487,10 +446,7 @@ export const normalizeSyntheticControlState = ( return SyntheticControlStateSchema.parse(merged); }; -const resolvePhaseBias = ( - phase: SyntheticSessionPhase, - regime: SyntheticRegime -): number => { +const resolvePhaseBias = (phase: SyntheticSessionPhase, regime: SyntheticRegime): number => { if (phase === "open") { return regime === "event_ramp" ? 1.08 : 1.02; } @@ -566,10 +522,7 @@ export const getSyntheticSessionState = ( mixSeed(activitySeed, 211) ); const focus_symbols: string[] = pickManyUnique( - [ - ...event_symbols, - ...SYNTHETIC_SYMBOLS.filter((symbol) => !event_symbols.includes(symbol)) - ], + [...event_symbols, ...SYNTHETIC_SYMBOLS.filter((symbol) => !event_symbols.includes(symbol))], focusCount, mixSeed(activitySeed, 389) ); @@ -579,11 +532,7 @@ export const getSyntheticSessionState = ( session_phase: phase, regime, volatility_level: roundTo( - clamp( - stateBase.volatility * amplitude + signedNoise(activitySeed + 3) * 0.08, - 0.18, - 1.2 - ) + clamp(stateBase.volatility * amplitude + signedNoise(activitySeed + 3) * 0.08, 0.18, 1.2) ), liquidity_level: roundTo( clamp( @@ -656,9 +605,7 @@ export const getSyntheticUnderlyingState = ( ? -meanRevertWave * (12 + session.liquidity_level * 10) : meanRevertWave * 6; const gammaChop = - session.regime === "dealer_gamma" - ? Math.sin((minuteOfSession + (hash % 11)) / 2.8) * 16 - : 0; + session.regime === "dealer_gamma" ? Math.sin((minuteOfSession + (hash % 11)) / 2.8) * 16 : 0; const noiseBps = signedNoise(mixSeed(hash, session.seed_bucket, control.shared_seed)) * (6 + session.volatility_level * 18); @@ -731,10 +678,7 @@ export const getSyntheticScenarioWeights = ( }; for (const profileId of SMART_MONEY_PROFILE_IDS) { - weights[profileId] = roundTo( - weights[profileId] * normalized.profile_weights[profileId], - 4 - ); + weights[profileId] = roundTo(weights[profileId] * normalized.profile_weights[profileId], 4); } if (isFocus) { @@ -745,10 +689,7 @@ export const getSyntheticScenarioWeights = ( } if (isEvent) { weights.event_driven = roundTo(weights.event_driven * 1.36, 4); - weights.institutional_directional = roundTo( - weights.institutional_directional * 1.04, - 4 - ); + weights.institutional_directional = roundTo(weights.institutional_directional * 1.04, 4); weights.neutral_noise = roundTo(weights.neutral_noise * 0.8, 4); } if (isPower) { @@ -765,10 +706,7 @@ export const getSyntheticScenarioWeights = ( export const getSyntheticCoverageBoost = ( profileId: SmartMoneyProfileId, coverageState: SyntheticCoverageState, - control: Pick< - SyntheticControlState, - "coverage_assist" | "coverage_window_minutes" - > + control: Pick ): number => { if (!control.coverage_assist) { return 1; diff --git a/packages/types/tests/live.test.ts b/packages/types/tests/live.test.ts index ef254b4..8e9738b 100644 --- a/packages/types/tests/live.test.ts +++ b/packages/types/tests/live.test.ts @@ -31,9 +31,7 @@ describe("live protocol types", () => { underlying_ids: ["NVDA", "AAPL"], option_contract_id: "AAPL-2025-01-17-200-C" }) - ).toBe( - 'options|{"view":"signal"}|underlyings:AAPL,NVDA|contract:AAPL-2025-01-17-200-C' - ); + ).toBe('options|{"view":"signal"}|underlyings:AAPL,NVDA|contract:AAPL-2025-01-17-200-C'); expect(getSubscriptionKey({ channel: "equities", underlying_ids: ["NVDA", "AAPL"] })).toBe( "equities|underlyings:AAPL,NVDA" ); diff --git a/scripts/check-docker-workspace.ts b/scripts/check-docker-workspace.ts index bc0d33e..6e63823 100644 --- a/scripts/check-docker-workspace.ts +++ b/scripts/check-docker-workspace.ts @@ -68,7 +68,9 @@ const listWorkspacePaths = async (workspacePatterns: string[]): Promise(); for (const pattern of workspacePatterns) { - const globPattern = pattern.endsWith("/") ? `${pattern}package.json` : `${pattern}/package.json`; + const globPattern = pattern.endsWith("/") + ? `${pattern}package.json` + : `${pattern}/package.json`; const glob = new Bun.Glob(globPattern); for await (const match of glob.scan({ cwd: repoRoot })) { const normalized = match.replaceAll("\\", "/"); @@ -124,15 +126,21 @@ const formatDependencyDiff = ( const check = async (): Promise => { const issues: string[] = []; - const [rootPackage, deploymentPackage, rootTsconfig, deploymentTsconfig, rootLock, deploymentLock] = - await Promise.all([ - parseObjectLiteral(rootPackagePath), - parseObjectLiteral(deploymentPackagePath), - parseObjectLiteral(rootTsconfigPath), - parseObjectLiteral(deploymentTsconfigPath), - parseObjectLiteral(rootLockPath), - parseObjectLiteral(deploymentLockPath) - ]); + const [ + rootPackage, + deploymentPackage, + rootTsconfig, + deploymentTsconfig, + rootLock, + deploymentLock + ] = await Promise.all([ + parseObjectLiteral(rootPackagePath), + parseObjectLiteral(deploymentPackagePath), + parseObjectLiteral(rootTsconfigPath), + parseObjectLiteral(deploymentTsconfigPath), + parseObjectLiteral(rootLockPath), + parseObjectLiteral(deploymentLockPath) + ]); const rootPackageSnapshot = stableStringify(rootPackage); const deploymentPackageSnapshot = stableStringify(deploymentPackage); @@ -172,7 +180,9 @@ const check = async (): Promise => { "peerDependencies" ]; for (const section of sections) { - const expectedMap = normalizedDependencyMap(workspacePackage[section] as DependencyMap | undefined); + const expectedMap = normalizedDependencyMap( + workspacePackage[section] as DependencyMap | undefined + ); const actualMap = normalizedDependencyMap( deploymentWorkspace[section] as DependencyMap | undefined ); @@ -212,7 +222,9 @@ const check = async (): Promise => { "peerDependencies" ]; for (const section of sections) { - const expectedMap = normalizedDependencyMap(rootWorkspace[section] as DependencyMap | undefined); + const expectedMap = normalizedDependencyMap( + rootWorkspace[section] as DependencyMap | undefined + ); const actualMap = normalizedDependencyMap( deploymentWorkspace[section] as DependencyMap | undefined ); diff --git a/scripts/check-public-api-routes.ts b/scripts/check-public-api-routes.ts index d1f0a18..42ced60 100644 --- a/scripts/check-public-api-routes.ts +++ b/scripts/check-public-api-routes.ts @@ -7,7 +7,10 @@ type RouteCheck = { const routeChecks: RouteCheck[] = [ { path: "/prints/options?view=signal&limit=1", expectJson: true }, - { path: "/history/options?view=signal&before_ts=4102444800000&before_seq=999999999&limit=1", expectJson: true }, + { + path: "/history/options?view=signal&before_ts=4102444800000&before_seq=999999999&limit=1", + expectJson: true + }, { path: "/replay/options?view=signal&after_ts=0&after_seq=0&limit=1", expectJson: true }, { path: "/nbbo/options?limit=1", expectJson: true }, { path: "/ws/live", expectJson: true } @@ -31,7 +34,9 @@ const assertPublicApiRoute = async ({ path, expectJson }: RouteCheck): Promise.remote." - ); + console.error(`Unable to resolve a deploy git remote. Checked candidates: ${deduped.join(", ")}`); + console.error("Set DEPLOY_GIT_REMOTE to a valid remote name or configure branch..remote."); process.exit(1); } @@ -748,7 +727,9 @@ fi return; } - const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + const units = nativeUnitsForScope(scope) + .map((value) => shellEscape(value)) + .join(" "); runRemoteScript( "Remote Runtime Precheck", `#!/usr/bin/env bash @@ -819,9 +800,7 @@ function remoteDockerRollout( upArgs.push("--force-recreate"); } const buildServices = dockerBuildServicesForScope(scope); - const buildCommand = noBuild - ? null - : `docker compose build ${buildServices.join(" ")}`; + const buildCommand = noBuild ? null : `docker compose build ${buildServices.join(" ")}`; const upCommand = `docker compose ${[...upArgs, ...rolloutServices].join(" ")}`; runRemoteScript( @@ -844,7 +823,9 @@ function remoteNativeRollout( scope: DeployScope, noBuild: boolean ): void { - const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + const units = nativeUnitsForScope(scope) + .map((value) => shellEscape(value)) + .join(" "); const buildSteps: string[] = []; if (!noBuild) { @@ -854,7 +835,11 @@ function remoteNativeRollout( } } - buildSteps.push(`${NATIVE_SYSTEMCTL_PREFIX} restart ${nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" ")}`); + buildSteps.push( + `${NATIVE_SYSTEMCTL_PREFIX} restart ${nativeUnitsForScope(scope) + .map((value) => shellEscape(value)) + .join(" ")}` + ); runRemoteScript( "Remote Rollout", @@ -899,9 +884,7 @@ function remoteDockerVerification(scope: DeployScope, fast: boolean): void { const psServices = dockerServicesForScope(scope); const logServices = dockerLogServicesForScope(scope); const psCommand = - psServices.length > 0 - ? `docker compose ps ${psServices.join(" ")}` - : "docker compose ps"; + psServices.length > 0 ? `docker compose ps ${psServices.join(" ")}` : "docker compose ps"; const logCommand = fast ? `echo '[deploy] Fast mode: skipping docker compose logs tail for quicker feedback.'` : `docker compose logs --tail=100 ${logServices.join(" ")}`; @@ -933,7 +916,9 @@ ${checks.join("\n")} } function remoteNativeVerification(scope: DeployScope, fast: boolean): void { - const units = nativeUnitsForScope(scope).map((value) => shellEscape(value)).join(" "); + const units = nativeUnitsForScope(scope) + .map((value) => shellEscape(value)) + .join(" "); const checks: string[] = []; if (scope === "full" || scope === "api" || scope === "services" || scope === "workers") { @@ -941,11 +926,11 @@ function remoteNativeVerification(scope: DeployScope, fast: boolean): void { } if (scopeIncludesApi(scope)) { - checks.push('curl -fksS http://127.0.0.1:4000/health'); + checks.push("curl -fksS http://127.0.0.1:4000/health"); } if (scopeIncludesWeb(scope)) { - checks.push('curl -I -fksS http://127.0.0.1:3000/'); + checks.push("curl -I -fksS http://127.0.0.1:3000/"); } runRemoteScript( @@ -962,7 +947,7 @@ fi declare -a units=(${units}) for unit in "\${units[@]}"; do ${NATIVE_SYSTEMCTL_PREFIX} is-active --quiet "$unit" - ${fast ? "echo \"[deploy] Fast mode: skipping unit status and recent journal dump for $unit.\"": `${NATIVE_SYSTEMCTL_PREFIX} status --no-pager "$unit" || true\n journalctl -u "$unit" -n 50 --no-pager || true`} + ${fast ? 'echo "[deploy] Fast mode: skipping unit status and recent journal dump for $unit."' : `${NATIVE_SYSTEMCTL_PREFIX} status --no-pager "$unit" || true\n journalctl -u "$unit" -n 50 --no-pager || true`} done ${checks.join("\n")} ` @@ -1074,9 +1059,7 @@ function main(): void { timedPhase(timings, "remote verification", () => remoteVerification(options.runtime, scope, options.fast) ); - timedPhase(timings, "public verification", () => - publicVerification(scope, options.fast) - ); + timedPhase(timings, "public verification", () => publicVerification(scope, options.fast)); printTimingSummary(timings); } diff --git a/scripts/generate-docs-index.mjs b/scripts/generate-docs-index.mjs index cf64a9d..4123a4e 100644 --- a/scripts/generate-docs-index.mjs +++ b/scripts/generate-docs-index.mjs @@ -6,7 +6,7 @@ const outputFile = path.join(docsDir, "index.html"); const dateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", - timeStyle: "short", + timeStyle: "short" }); function escapeHtml(value) { @@ -71,7 +71,7 @@ async function collectDocsFiles(rootDir, currentDir = rootDir, acc = []) { relativePath, category: relativePath.includes("/") ? relativePath.split("/")[0] : "root", sizeBytes: stats.size, - modifiedAt: stats.mtime, + modifiedAt: stats.mtime }); } } diff --git a/scripts/sync-docker-workspace.ts b/scripts/sync-docker-workspace.ts index e20b293..6ce5985 100644 --- a/scripts/sync-docker-workspace.ts +++ b/scripts/sync-docker-workspace.ts @@ -4,11 +4,7 @@ import path from "node:path"; const repoRoot = path.resolve(import.meta.dir, ".."); const deploymentRoot = path.join(repoRoot, "deployment/docker/workspace-root"); -const filesToSync = [ - "package.json", - "bun.lock", - "tsconfig.base.json" -] as const; +const filesToSync = ["package.json", "bun.lock", "tsconfig.base.json"] as const; for (const fileName of filesToSync) { const source = path.join(repoRoot, fileName); @@ -16,4 +12,3 @@ for (const fileName of filesToSync) { await copyFile(source, destination); console.log(`synced ${fileName}`); } - diff --git a/scripts/typecheck.ts b/scripts/typecheck.ts index 9e3ba06..91f520d 100644 --- a/scripts/typecheck.ts +++ b/scripts/typecheck.ts @@ -33,15 +33,30 @@ if (tsconfigs.length === 0) { } let failed = false; +const bunExecutable = process.execPath; for (const tsconfig of tsconfigs) { const label = relative(process.cwd(), tsconfig); console.log(`\nTypechecking ${label}`); - const result = Bun.spawnSync(["bunx", "tsc", "-p", tsconfig, "--noEmit", "--incremental", "false", "--pretty", "false"], { - stdout: "inherit", - stderr: "inherit" - }); + const result = Bun.spawnSync( + [ + bunExecutable, + "x", + "tsc", + "-p", + tsconfig, + "--noEmit", + "--incremental", + "false", + "--pretty", + "false" + ], + { + stdout: "inherit", + stderr: "inherit" + } + ); if (result.exitCode !== 0) { failed = true; diff --git a/services/api/src/index.ts b/services/api/src/index.ts index ffcd560..88ba825 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -465,8 +465,7 @@ const parseCandleParams = ( const endTs = params.end_ts ?? Date.now(); const limit = params.limit ?? env.REST_DEFAULT_LIMIT; - const startTs = - params.start_ts ?? Math.max(0, Math.floor(endTs - params.interval_ms * limit)); + const startTs = params.start_ts ?? Math.max(0, Math.floor(endTs - params.interval_ms * limit)); const rangeStart = Math.min(startTs, endTs); const rangeEnd = Math.max(startTs, endTs); @@ -482,7 +481,13 @@ const parseCandleParams = ( const parseCandleReplayParams = ( url: URL -): { underlyingId: string; intervalMs: number; afterTs: number; afterSeq: number; limit: number } => { +): { + underlyingId: string; + intervalMs: number; + afterTs: number; + afterSeq: number; + limit: number; +} => { const params = candleReplaySchema.parse({ underlying_id: url.searchParams.get("underlying_id") ?? undefined, interval_ms: url.searchParams.get("interval_ms") ?? undefined, @@ -601,7 +606,10 @@ const matchesScopedOptionSubscription = ( print: { underlying_id?: string; option_contract_id: string }, subscription: Extract ): boolean => { - if (subscription.option_contract_id && subscription.option_contract_id !== print.option_contract_id) { + if ( + subscription.option_contract_id && + subscription.option_contract_id !== print.option_contract_id + ) { return false; } if (subscription.underlying_ids?.length) { @@ -693,8 +701,7 @@ const run = async () => { env.OPTIONS_INGEST_ADAPTER, env.EQUITIES_INGEST_ADAPTER ); - const syntheticBackendDisabledReason = - getSyntheticBackendDisabledReason(syntheticBackendMode); + const syntheticBackendDisabledReason = getSyntheticBackendDisabledReason(syntheticBackendMode); const syntheticControlKv = await openSyntheticControlKv(js); let syntheticControl = await ensureSyntheticControlState(syntheticControlKv); const syntheticProfileHits = createRollingSyntheticProfileHits(); @@ -899,11 +906,7 @@ const run = async () => { } } - const subscribeWithReset = async ( - subject: string, - stream: string, - durableName: string - ) => { + const subscribeWithReset = async (subject: string, stream: string, durableName: string) => { const opts = buildDurableConsumer(durableName); applyDeliverPolicy(opts, env.API_DELIVER_POLICY); try { @@ -924,7 +927,8 @@ const run = async () => { try { await jsm.consumers.delete(stream, durableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: durableName, @@ -1023,8 +1027,12 @@ const run = async () => { } const matchingSubscriptions = - subscription.channel === "options" || subscription.channel === "flow" || subscription.channel === "equities" - ? [...subscriptionDefinitions.entries()].filter(([, candidate]) => candidate.channel === subscription.channel) + subscription.channel === "options" || + subscription.channel === "flow" || + subscription.channel === "equities" + ? [...subscriptionDefinitions.entries()].filter( + ([, candidate]) => candidate.channel === subscription.channel + ) : [[getSubscriptionKey(subscription), subscription] as const]; if (matchingSubscriptions.length === 0) { @@ -1032,8 +1040,12 @@ const run = async () => { } const optionItem = ingestChannel === "options" ? (item as OptionPrint) : null; - const equityItem = ingestChannel === "equities" ? (item as Parameters[0]) : null; - const flowItem = ingestChannel === "flow" ? (item as Parameters[0]) : null; + const equityItem = + ingestChannel === "equities" + ? (item as Parameters[0]) + : null; + const flowItem = + ingestChannel === "flow" ? (item as Parameters[0]) : null; let matchedSubscriptions = 0; for (const [key, candidate] of matchingSubscriptions) { @@ -1315,9 +1327,7 @@ const run = async () => { }, control: syntheticBackendMode === "synthetic" ? syntheticControl : null, derived, - ...(syntheticBackendDisabledReason - ? { disabled_reason: syntheticBackendDisabledReason } - : {}) + ...(syntheticBackendDisabledReason ? { disabled_reason: syntheticBackendDisabledReason } : {}) }; }; @@ -1385,11 +1395,7 @@ const run = async () => { syntheticControl = await writeSyntheticControlState(syntheticControlKv, payload); return jsonResponse({ control: syntheticControl, - derived: buildSyntheticDerivedStatus( - Date.now(), - syntheticControl, - syntheticProfileHits - ) + derived: buildSyntheticDerivedStatus(Date.now(), syntheticControl, syntheticProfileHits) }); } catch (error) { return jsonResponse( @@ -1436,7 +1442,13 @@ const run = async () => { if (req.method === "GET" && url.pathname === "/prints/equities/range") { try { const { underlyingId, startTs, endTs, limit } = parseEquityPrintRangeParams(url); - const data = await fetchEquityPrintsRange(clickhouse, underlyingId, startTs, endTs, limit); + const data = await fetchEquityPrintsRange( + clickhouse, + underlyingId, + startTs, + endTs, + limit + ); return jsonResponse({ data }); } catch (error) { return jsonResponse( @@ -1566,7 +1578,9 @@ const run = async () => { source, storageFilters ); - return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })) + ); } catch (error) { return jsonResponse( { @@ -1986,7 +2000,9 @@ const run = async () => { const payload = typeof message === "string" ? message - : new TextDecoder().decode(message instanceof Uint8Array ? message : new Uint8Array(message)); + : new TextDecoder().decode( + message instanceof Uint8Array ? message : new Uint8Array(message) + ); const parsed = LiveClientMessageSchema.parse(JSON.parse(payload)); if (parsed.op === "ping") { sendLiveMessage(socket, { diff --git a/services/api/src/live.ts b/services/api/src/live.ts index 40bbd20..2566e26 100644 --- a/services/api/src/live.ts +++ b/services/api/src/live.ts @@ -165,11 +165,21 @@ const parseGenericLimitFallback = (env: NodeJS.ProcessEnv, fallback: number): nu return Math.max(MIN_GENERIC_LIMIT, Math.min(MAX_GENERIC_LIMIT, Math.floor(parsed))); }; -export const resolveGenericLiveLimits = (env: NodeJS.ProcessEnv = process.env): GenericLiveLimits => { +export const resolveGenericLiveLimits = ( + env: NodeJS.ProcessEnv = process.env +): GenericLiveLimits => { const liveLimitDefault = parseGenericLimitFallback(env, DEFAULT_GENERIC_LIMIT); return { - options: parseGenericLimit(env, "options", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.options), - nbbo: parseGenericLimit(env, "nbbo", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.nbbo), + options: parseGenericLimit( + env, + "options", + env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.options + ), + nbbo: parseGenericLimit( + env, + "nbbo", + env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.nbbo + ), equities: parseGenericLimit( env, "equities", @@ -185,7 +195,11 @@ export const resolveGenericLiveLimits = (env: NodeJS.ProcessEnv = process.env): "equity-joins", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS["equity-joins"] ), - flow: parseGenericLimit(env, "flow", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.flow), + flow: parseGenericLimit( + env, + "flow", + env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.flow + ), "smart-money": parseGenericLimit( env, "smart-money", @@ -196,13 +210,21 @@ export const resolveGenericLiveLimits = (env: NodeJS.ProcessEnv = process.env): "classifier-hits", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS["classifier-hits"] ), - alerts: parseGenericLimit(env, "alerts", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.alerts), + alerts: parseGenericLimit( + env, + "alerts", + env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.alerts + ), "inferred-dark": parseGenericLimit( env, "inferred-dark", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS["inferred-dark"] ), - news: parseGenericLimit(env, "news", env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.news) + news: parseGenericLimit( + env, + "news", + env.LIVE_LIMIT_DEFAULT ? liveLimitDefault : DEFAULT_LIVE_LIMITS.news + ) }; }; @@ -227,12 +249,18 @@ const extractFreshnessTs = (channel: LiveGenericChannel, item: any): number | nu export const resolveLiveStateConfig = (env: NodeJS.ProcessEnv = process.env): LiveStateConfig => ({ limits: resolveGenericLiveLimits(env), - scopedCacheMaxKeys: parsePositiveInt(env.LIVE_SCOPED_CACHE_MAX_KEYS, DEFAULT_SCOPED_CACHE_MAX_KEYS), + scopedCacheMaxKeys: parsePositiveInt( + env.LIVE_SCOPED_CACHE_MAX_KEYS, + DEFAULT_SCOPED_CACHE_MAX_KEYS + ), redisFlushIntervalMs: parsePositiveInt( env.LIVE_REDIS_FLUSH_INTERVAL_MS, DEFAULT_REDIS_FLUSH_INTERVAL_MS ), - redisFlushMaxItems: parsePositiveInt(env.LIVE_REDIS_FLUSH_MAX_ITEMS, DEFAULT_REDIS_FLUSH_MAX_ITEMS) + redisFlushMaxItems: parsePositiveInt( + env.LIVE_REDIS_FLUSH_MAX_ITEMS, + DEFAULT_REDIS_FLUSH_MAX_ITEMS + ) }); const parsePositiveInt = (value: string | undefined, fallback: number): number => { const parsed = Number(value); @@ -242,10 +270,7 @@ const parsePositiveInt = (value: string | undefined, fallback: number): number = return Math.max(1, Math.floor(parsed)); }; -type RedisLike = Pick< - RedisClientType, - "isOpen" | "lRange" | "lPush" | "lTrim" | "hGet" | "hSet" ->; +type RedisLike = Pick; const parseCursor = (value: string | null): Cursor | null => { if (!value) { @@ -259,7 +284,9 @@ const parseCursor = (value: string | null): Cursor | null => { } }; -const getGenericConfig = (limits: GenericLiveLimits): { +const getGenericConfig = ( + limits: GenericLiveLimits +): { [K in LiveGenericChannel]: GenericFeedConfig; } => ({ options: { @@ -365,7 +392,7 @@ const parseJsonList = (payloads: string[], parse: (value: unknown) => T): T[] return items; }; -const compareCursors = (a: Cursor, b: Cursor): number => (b.ts - a.ts) || (b.seq - a.seq); +const compareCursors = (a: Cursor, b: Cursor): number => b.ts - a.ts || b.seq - a.seq; const sortGenericItems = (items: T[], cursorOf: (item: T) => Cursor): T[] => [...items].sort((a, b) => compareCursors(cursorOf(a), cursorOf(b))); @@ -480,7 +507,10 @@ const matchesScopedOptionSnapshot = ( return false; } - if (subscription.option_contract_id && item.option_contract_id !== subscription.option_contract_id) { + if ( + subscription.option_contract_id && + item.option_contract_id !== subscription.option_contract_id + ) { return false; } @@ -529,11 +559,8 @@ const candleCursorField = (underlyingId: string, intervalMs: number): string => const overlayRedisKey = (underlyingId: string): string => `live:equity-overlay:${underlyingId}`; const overlayCursorField = (underlyingId: string): string => `equities:${underlyingId}`; -const dropMatchingCursor = ( - items: T[], - target: Cursor, - cursorOf: (item: T) => Cursor -): T[] => items.filter((item) => compareCursors(cursorOf(item), target) !== 0); +const dropMatchingCursor = (items: T[], target: Cursor, cursorOf: (item: T) => Cursor): T[] => + items.filter((item) => compareCursors(cursorOf(item), target) !== 0); const insertNewestFirst = ( items: T[], @@ -676,7 +703,13 @@ export class LiveStateManager { this.pendingRedisWrites.clear(); for (const write of writes) { - await this.persistList(write.listKey, write.cursorField, write.items, write.limit, write.cursor); + await this.persistList( + write.listKey, + write.cursorField, + write.items, + write.limit, + write.cursor + ); this.stats.redisFlushCount += 1; this.stats.redisFlushItems += write.items.length; metrics.count("api.live.redis_flush_count", 1); @@ -726,7 +759,12 @@ export class LiveStateManager { } } - private updateFreshnessMetric(listKey: string, channel: LiveChannel, item: unknown, now = Date.now()): void { + private updateFreshnessMetric( + listKey: string, + channel: LiveChannel, + item: unknown, + now = Date.now() + ): void { const ts = channel === "equity-candles" || channel === "equity-overlay" ? typeof (item as { ts?: unknown })?.ts === "number" @@ -784,12 +822,22 @@ export class LiveStateManager { config.cursorField, parseCursor(await this.redis.hGet(CURSOR_HASH_KEY, config.cursorField)) ); - await this.persistList(config.redisKey, config.cursorField, cached, config.limit, this.genericCursors.get(config.cursorField) ?? null); + await this.persistList( + config.redisKey, + config.cursorField, + cached, + config.limit, + this.genericCursors.get(config.cursorField) ?? null + ); return; } } - const fresh = normalizeGenericItems(channel, await config.fetchRecent(this.clickhouse, config.limit), config); + const fresh = normalizeGenericItems( + channel, + await config.fetchRecent(this.clickhouse, config.limit), + config + ); this.stats.genericHydrateFromClickHouse += 1; this.stats.cacheDepthByKey.set(config.redisKey, fresh.length); this.genericItems.set(channel, fresh); @@ -806,7 +854,8 @@ export class LiveStateManager { 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) { const cached = (this.genericItems.get("options") ?? []) .filter((entry) => matchesScopedOptionSnapshot(entry, subscription)) @@ -815,8 +864,16 @@ export class LiveStateManager { if (cached.length < limit) { this.stats.scopedClickHouseSnapshots += 1; const storageFilters = buildOptionSnapshotFilters(subscription); - const backfill = await fetchRecentOptionPrints(this.clickhouse, limit, undefined, storageFilters); - items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ ts: entry.ts, seq: entry.seq })); + const backfill = await fetchRecentOptionPrints( + this.clickhouse, + limit, + undefined, + storageFilters + ); + items = mergeSnapshotBackfill(cached, backfill, limit, (entry) => ({ + ts: entry.ts, + seq: entry.seq + })); } return { subscription, @@ -942,7 +999,11 @@ export class LiveStateManager { this.candleItems.set(key, nextState.items); this.candleCursors.set(cursorField, cursor); this.touchAccess(this.candleAccess, key); - this.evictScopedCachesIfNeeded(this.candleItems as Map, this.candleCursors, this.candleAccess); + this.evictScopedCachesIfNeeded( + this.candleItems as Map, + this.candleCursors, + this.candleAccess + ); if (nextState.outOfOrder) { this.stats.outOfOrderEvents += 1; metrics.count("api.live.out_of_order_events", 1); @@ -968,7 +1029,11 @@ export class LiveStateManager { this.overlayItems.set(key, nextState.items); this.overlayCursors.set(cursorField, cursor); this.touchAccess(this.overlayAccess, key); - this.evictScopedCachesIfNeeded(this.overlayItems as Map, this.overlayCursors, this.overlayAccess); + this.evictScopedCachesIfNeeded( + this.overlayItems as Map, + this.overlayCursors, + this.overlayAccess + ); if (nextState.outOfOrder) { this.stats.outOfOrderEvents += 1; metrics.count("api.live.out_of_order_events", 1); @@ -991,10 +1056,19 @@ export class LiveStateManager { const nextState = channel === "nbbo" ? { - items: normalizeGenericItems(channel, [parsed, ...(this.genericItems.get(channel) ?? [])], config), + items: normalizeGenericItems( + channel, + [parsed, ...(this.genericItems.get(channel) ?? [])], + config + ), outOfOrder: false } - : insertNewestFirst(this.genericItems.get(channel) ?? [], parsed, config.cursor, config.limit); + : insertNewestFirst( + this.genericItems.get(channel) ?? [], + parsed, + config.cursor, + config.limit + ); if (nextState.outOfOrder) { this.stats.outOfOrderEvents += 1; @@ -1007,7 +1081,13 @@ export class LiveStateManager { if (nextState.items.length > 0) { this.updateFreshnessMetric(config.redisKey, channel, nextState.items[0]); } - this.queueRedisWrite(config.redisKey, config.cursorField, nextState.items, config.limit, cursor); + this.queueRedisWrite( + config.redisKey, + config.cursorField, + nextState.items, + config.limit, + cursor + ); return cursor; } } @@ -1022,18 +1102,34 @@ export class LiveStateManager { if (cached.length > 0) { this.candleItems.set(key, cached); this.touchAccess(this.candleAccess, key); - this.evictScopedCachesIfNeeded(this.candleItems as Map, this.candleCursors, this.candleAccess); + this.evictScopedCachesIfNeeded( + this.candleItems as Map, + this.candleCursors, + this.candleAccess + ); this.stats.cacheDepthByKey.set(key, cached.length); this.updateFreshnessMetric(key, "equity-candles", cached[0]); - this.candleCursors.set(cursorField, parseCursor(await this.redis.hGet(CURSOR_HASH_KEY, cursorField))); + this.candleCursors.set( + cursorField, + parseCursor(await this.redis.hGet(CURSOR_HASH_KEY, cursorField)) + ); return; } } - const fresh = await fetchRecentEquityCandles(this.clickhouse, underlyingId, intervalMs, CHART_LIMITS.candles); + const fresh = await fetchRecentEquityCandles( + this.clickhouse, + underlyingId, + intervalMs, + CHART_LIMITS.candles + ); this.candleItems.set(key, fresh); this.touchAccess(this.candleAccess, key); - this.evictScopedCachesIfNeeded(this.candleItems as Map, this.candleCursors, this.candleAccess); + this.evictScopedCachesIfNeeded( + this.candleItems as Map, + this.candleCursors, + this.candleAccess + ); this.stats.cacheDepthByKey.set(key, fresh.length); if (fresh.length > 0) { this.updateFreshnessMetric(key, "equity-candles", fresh[0]); @@ -1052,10 +1148,17 @@ export class LiveStateManager { if (cached.length > 0) { this.overlayItems.set(key, cached); this.touchAccess(this.overlayAccess, key); - this.evictScopedCachesIfNeeded(this.overlayItems as Map, this.overlayCursors, this.overlayAccess); + this.evictScopedCachesIfNeeded( + this.overlayItems as Map, + this.overlayCursors, + this.overlayAccess + ); this.stats.cacheDepthByKey.set(key, cached.length); this.updateFreshnessMetric(key, "equity-overlay", cached[0]); - this.overlayCursors.set(cursorField, parseCursor(await this.redis.hGet(CURSOR_HASH_KEY, cursorField))); + this.overlayCursors.set( + cursorField, + parseCursor(await this.redis.hGet(CURSOR_HASH_KEY, cursorField)) + ); return; } } @@ -1065,7 +1168,11 @@ export class LiveStateManager { ); this.overlayItems.set(key, fresh); this.touchAccess(this.overlayAccess, key); - this.evictScopedCachesIfNeeded(this.overlayItems as Map, this.overlayCursors, this.overlayAccess); + this.evictScopedCachesIfNeeded( + this.overlayItems as Map, + this.overlayCursors, + this.overlayAccess + ); this.stats.cacheDepthByKey.set(key, fresh.length); if (fresh.length > 0) { this.updateFreshnessMetric(key, "equity-overlay", fresh[0]); diff --git a/services/api/src/synthetic-control.ts b/services/api/src/synthetic-control.ts index cbc310b..bae0a34 100644 --- a/services/api/src/synthetic-control.ts +++ b/services/api/src/synthetic-control.ts @@ -83,11 +83,7 @@ export const buildSyntheticDerivedStatus = ( session_phase: session.session_phase, regime: session.regime, focus_symbols: session.focus_symbols, - profile_hit_counts: getSyntheticProfileHitCounts( - state, - now, - control.coverage_window_minutes - ), + profile_hit_counts: getSyntheticProfileHitCounts(state, now, control.coverage_window_minutes), coverage_window_minutes: control.coverage_window_minutes }); }; diff --git a/services/api/tests/alert-context.test.ts b/services/api/tests/alert-context.test.ts index e1b3c7b..9ae3c6b 100644 --- a/services/api/tests/alert-context.test.ts +++ b/services/api/tests/alert-context.test.ts @@ -3,7 +3,9 @@ import { isAlertContextPath, parseAlertContextTraceIdPath } from "../src/alert-c describe("alert context route helpers", () => { it("extracts a valid alert trace id from the context endpoint path", () => { - expect(parseAlertContextTraceIdPath("/flow/alerts/alert%3Actx%2Fone/context")).toBe("alert:ctx/one"); + expect(parseAlertContextTraceIdPath("/flow/alerts/alert%3Actx%2Fone/context")).toBe( + "alert:ctx/one" + ); }); it("returns null for unrelated alert paths", () => { diff --git a/services/api/tests/live.test.ts b/services/api/tests/live.test.ts index 78807ca..e8ac548 100644 --- a/services/api/tests/live.test.ts +++ b/services/api/tests/live.test.ts @@ -9,9 +9,7 @@ import { shouldFanoutLiveEvent } from "../src/live"; -const makeClickHouse = ( - queryResolver?: (query: string) => unknown[] -): ClickHouseClient => +const makeClickHouse = (queryResolver?: (query: string) => unknown[]): ClickHouseClient => ({ exec: async () => {}, insert: async () => {}, @@ -149,22 +147,18 @@ describe("LiveStateManager", () => { it("trims generic windows to configured per-channel limits", async () => { const redis = makeRedis(); const now = Date.now(); - const manager = new LiveStateManager( - makeClickHouse(), - redis as never, - { - options: 10000, - nbbo: 10000, - equities: 10000, - "equity-quotes": 10000, - "equity-joins": 10000, - flow: 2, - "smart-money": 10000, - "classifier-hits": 10000, - alerts: 10000, - "inferred-dark": 10000 - } - ); + const manager = new LiveStateManager(makeClickHouse(), redis as never, { + options: 10000, + nbbo: 10000, + equities: 10000, + "equity-quotes": 10000, + "equity-joins": 10000, + flow: 2, + "smart-money": 10000, + "classifier-hits": 10000, + alerts: 10000, + "inferred-dark": 10000 + }); await manager.ingest("flow", { source_ts: now, @@ -503,18 +497,15 @@ describe("LiveStateManager", () => { manager.getSnapshot({ channel: "flow" }) ]); - expect((optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([ - "opt-fresh", - "opt-stale" - ]); - expect((nbboSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([ - "nbbo-fresh", - "nbbo-stale" - ]); - expect((equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([ - "eq-fresh", - "eq-stale" - ]); + expect( + (optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id) + ).toEqual(["opt-fresh", "opt-stale"]); + expect( + (nbboSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id) + ).toEqual(["nbbo-fresh", "nbbo-stale"]); + expect( + (equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id) + ).toEqual(["eq-fresh", "eq-stale"]); expect((flowSnapshot.items as Array<{ id: string }>).map((item) => item.id)).toEqual([ "flow-fresh", "flow-stale" @@ -699,10 +690,9 @@ describe("LiveStateManager", () => { 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" - ]); + 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 () => { @@ -806,12 +796,12 @@ describe("LiveStateManager", () => { manager.getSnapshot({ channel: "flow" }) ]); - expect((optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([ - "opt-retained" - ]); - expect((equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([ - "eq-retained" - ]); + expect( + (optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id) + ).toEqual(["opt-retained"]); + expect( + (equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id) + ).toEqual(["eq-retained"]); expect((flowSnapshot.items as Array<{ id: string }>).map((item) => item.id)).toEqual([ "flow-retained" ]); @@ -1047,7 +1037,10 @@ describe("LiveStateManager", () => { }); it("tracks generic cache and scoped clickhouse snapshot sources separately", async () => { - const manager = new LiveStateManager(makeClickHouse(() => []), null); + const manager = new LiveStateManager( + makeClickHouse(() => []), + null + ); const now = Date.now(); await manager.ingest("options", { @@ -1075,7 +1068,10 @@ describe("LiveStateManager", () => { }); it("keeps backend channel health healthy when a scoped query is quiet", async () => { - const manager = new LiveStateManager(makeClickHouse(() => []), null); + const manager = new LiveStateManager( + makeClickHouse(() => []), + null + ); const now = Date.now(); await manager.ingest("options", { @@ -1098,7 +1094,9 @@ describe("LiveStateManager", () => { expect(quietSnapshot.items).toEqual([]); expect(manager.getHotChannelHealth().options.healthy).toBe(true); - expect(manager.getStatsSnapshot().freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options]).toBeLessThanOrEqual(50); + expect( + manager.getStatsSnapshot().freshnessAgeMsByKey[HOT_LIVE_REDIS_KEYS.options] + ).toBeLessThanOrEqual(50); }); it("exposes freshness helper for feed status", () => { diff --git a/services/candles/src/index.ts b/services/candles/src/index.ts index b5ccc6d..2834f5e 100644 --- a/services/candles/src/index.ts +++ b/services/candles/src/index.ts @@ -33,9 +33,7 @@ const envSchema = z.object({ CANDLE_INTERVALS_MS: z.string().default("60000,300000"), CANDLE_MAX_LATE_MS: z.coerce.number().int().nonnegative().default(0), CANDLE_CACHE_LIMIT: z.coerce.number().int().nonnegative().default(2000), - CANDLE_DELIVER_POLICY: z - .enum(["new", "all", "last", "last_per_subject"]) - .default("new"), + CANDLE_DELIVER_POLICY: z.enum(["new", "all", "last", "last_per_subject"]).default("new"), CANDLE_CONSUMER_RESET: z .preprocess((value) => { if (typeof value === "string") { @@ -290,7 +288,10 @@ const run = async () => { } else { try { const info = await jsm.consumers.info(STREAM_EQUITY_PRINTS, durableName); - if (info?.config?.deliver_policy && info.config.deliver_policy !== env.CANDLE_DELIVER_POLICY) { + if ( + info?.config?.deliver_policy && + info.config.deliver_policy !== env.CANDLE_DELIVER_POLICY + ) { logger.warn("resetting consumer due to deliver policy change", { durable: durableName, current: info.config.deliver_policy, @@ -301,7 +302,10 @@ const run = async () => { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("not found")) { - logger.warn("failed to inspect jetstream consumer", { durable: durableName, error: message }); + logger.warn("failed to inspect jetstream consumer", { + durable: durableName, + error: message + }); } } } @@ -327,7 +331,8 @@ const run = async () => { try { await jsm.consumers.delete(STREAM_EQUITY_PRINTS, durableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: durableName, diff --git a/services/compute/src/alert-scoring.ts b/services/compute/src/alert-scoring.ts index 83dc1ef..1ef24b3 100644 --- a/services/compute/src/alert-scoring.ts +++ b/services/compute/src/alert-scoring.ts @@ -14,4 +14,3 @@ export const scoreAlert = ( const severity = score >= 80 ? "high" : score >= 45 ? "medium" : "low"; return { score, severity }; }; - diff --git a/services/compute/src/classifiers.ts b/services/compute/src/classifiers.ts index fc86e52..ec8d2d2 100644 --- a/services/compute/src/classifiers.ts +++ b/services/compute/src/classifiers.ts @@ -573,10 +573,7 @@ const buildVerticalSpreadHit = ( }; }; -const buildLadderHit = ( - packet: FlowPacket, - config: ClassifierConfig -): ClassifierHit | null => { +const buildLadderHit = (packet: FlowPacket, config: ClassifierConfig): ClassifierHit | null => { const structureType = getStringFeature(packet, "structure_type"); if (structureType !== "ladder") { return null; @@ -648,7 +645,8 @@ const buildRollHit = (packet: FlowPacket, config: ClassifierConfig): ClassifierH } const activity = getLargeActivity(packet, config); - const qualifies = activity.totalPremium >= config.spikeMinPremium || activity.totalSize >= config.spikeMinSize; + const qualifies = + activity.totalPremium >= config.spikeMinPremium || activity.totalSize >= config.spikeMinSize; if (!qualifies) { return null; } @@ -708,7 +706,9 @@ const buildRollHit = (packet: FlowPacket, config: ClassifierConfig): ClassifierH const expiryNote = hasExpiryPair ? `Expiries: ${fromExpiry} -> ${toExpiry}${ - expiryDaysDelta !== null && expiryDaysDelta !== 0 ? ` (${Math.round(expiryDaysDelta)}d)` : "" + expiryDaysDelta !== null && expiryDaysDelta !== 0 + ? ` (${Math.round(expiryDaysDelta)}d)` + : "" }.` : "Expiry pairing unavailable."; const strikeNote = hasStrikePair @@ -850,9 +850,10 @@ export const evaluateClassifiers = ( const packetKind = getStringFeature(packet, "packet_kind"); const structureOnly = packetKind === "structure"; - const contractId = typeof packet.features.option_contract_id === "string" - ? packet.features.option_contract_id - : ""; + const contractId = + typeof packet.features.option_contract_id === "string" + ? packet.features.option_contract_id + : ""; const contract = structureOnly ? null : parseContractId(contractId); const hits: ClassifierHit[] = []; diff --git a/services/compute/src/equity-joins.ts b/services/compute/src/equity-joins.ts index 0f25bce..380b478 100644 --- a/services/compute/src/equity-joins.ts +++ b/services/compute/src/equity-joins.ts @@ -15,10 +15,7 @@ const roundTo = (value: number, digits = 4): number => { return Number(value.toFixed(digits)); }; -export const classifyQuotePlacement = ( - price: number, - join: EquityQuoteJoin -): QuotePlacement => { +export const classifyQuotePlacement = (price: number, join: EquityQuoteJoin): QuotePlacement => { if (!Number.isFinite(price)) { return "MISSING"; } diff --git a/services/compute/src/index.ts b/services/compute/src/index.ts index 8f01c7a..4712d3b 100644 --- a/services/compute/src/index.ts +++ b/services/compute/src/index.ts @@ -46,7 +46,7 @@ import { enqueueEquityPrintJoinInsert, enqueueFlowPacketInsert, enqueueInferredDarkInsert, - enqueueSmartMoneyEventInsert, + enqueueSmartMoneyEventInsert } from "@islandflow/storage"; import { AlertEventSchema, @@ -324,7 +324,9 @@ const buildPacketId = (cluster: ClusterState): string => { const isExpectedShutdownNatsError = (error: unknown): boolean => { const code = getErrorCode(error); - return runtimeState.shuttingDown && (code === "CONNECTION_DRAINING" || code === "CONNECTION_CLOSED"); + return ( + runtimeState.shuttingDown && (code === "CONNECTION_DRAINING" || code === "CONNECTION_CLOSED") + ); }; const createPlacementCounts = (): NbboPlacementCounts => ({ @@ -337,7 +339,14 @@ const createPlacementCounts = (): NbboPlacementCounts => ({ stale: 0 }); -const SPECIAL_PRINT_CONDITIONS = new Set(["AUCTION", "CROSS", "OPENING", "CLOSING", "COMPLEX", "SPREAD"]); +const SPECIAL_PRINT_CONDITIONS = new Set([ + "AUCTION", + "CROSS", + "OPENING", + "CLOSING", + "COMPLEX", + "SPREAD" +]); const SYNTHETIC_EVENT_CONDITION_RE = /^EVENT_(\d+)D$/i; const normalizeConditions = (conditions: readonly string[] | undefined): string[] => @@ -460,11 +469,7 @@ const storeRecentRootLeg = (leg: LegEvidence, anchorTs: number): void => { recentLegsByRoot.set(key, next); }; -const collectActiveLegs = ( - key: string, - anchorTs: number, - excludeId: string -): LegEvidence[] => { +const collectActiveLegs = (key: string, anchorTs: number, excludeId: string): LegEvidence[] => { const legs: LegEvidence[] = []; for (const [contractId, cluster] of clusters) { if (contractId === excludeId) { @@ -485,11 +490,7 @@ const collectActiveLegs = ( return legs; }; -const collectActiveRootLegs = ( - key: string, - anchorTs: number, - excludeId: string -): LegEvidence[] => { +const collectActiveRootLegs = (key: string, anchorTs: number, excludeId: string): LegEvidence[] => { const legs: LegEvidence[] = []; for (const [contractId, cluster] of clusters) { if (contractId === excludeId) { @@ -601,12 +602,19 @@ const applyDeliverPolicy = ( const buildCluster = (print: OptionPrint): ClusterState => { const placements = createPlacementCounts(); const normalizedConditions = normalizeConditions(print.conditions); - const executionIv = typeof print.execution_iv === "number" && Number.isFinite(print.execution_iv) ? print.execution_iv : null; + const executionIv = + typeof print.execution_iv === "number" && Number.isFinite(print.execution_iv) + ? print.execution_iv + : null; const executionUnderlyingMid = - typeof print.execution_underlying_mid === "number" && Number.isFinite(print.execution_underlying_mid) + typeof print.execution_underlying_mid === "number" && + Number.isFinite(print.execution_underlying_mid) ? print.execution_underlying_mid : null; - recordPlacement(placements, classifyPlacement(print.price, selectNbbo(print.option_contract_id, print.ts))); + recordPlacement( + placements, + classifyPlacement(print.price, selectNbbo(print.option_contract_id, print.ts)) + ); return { contractId: print.option_contract_id, underlyingId: print.underlying_id ?? null, @@ -661,11 +669,18 @@ const updateCluster = (cluster: ClusterState, print: OptionPrint): ClusterState if (typeof print.execution_iv === "number" && Number.isFinite(print.execution_iv)) { cluster.lastExecutionIv = print.execution_iv; cluster.minExecutionIv = - cluster.minExecutionIv === null ? print.execution_iv : Math.min(cluster.minExecutionIv, print.execution_iv); + cluster.minExecutionIv === null + ? print.execution_iv + : Math.min(cluster.minExecutionIv, print.execution_iv); cluster.maxExecutionIv = - cluster.maxExecutionIv === null ? print.execution_iv : Math.max(cluster.maxExecutionIv, print.execution_iv); + cluster.maxExecutionIv === null + ? print.execution_iv + : Math.max(cluster.maxExecutionIv, print.execution_iv); } - if (typeof print.execution_underlying_mid === "number" && Number.isFinite(print.execution_underlying_mid)) { + if ( + typeof print.execution_underlying_mid === "number" && + Number.isFinite(print.execution_underlying_mid) + ) { if (cluster.firstUnderlyingMid === null) { cluster.firstUnderlyingMid = print.execution_underlying_mid; } @@ -686,11 +701,7 @@ type NbboJoin = { const updateNbboCache = (nbbo: OptionNBBO): void => { const existing = nbboCache.get(nbbo.option_contract_id); - if ( - !existing || - nbbo.ts > existing.ts || - (nbbo.ts === existing.ts && nbbo.seq >= existing.seq) - ) { + if (!existing || nbbo.ts > existing.ts || (nbbo.ts === existing.ts && nbbo.seq >= existing.seq)) { nbboCache.set(nbbo.option_contract_id, nbbo); nbboCacheTouchedAt.set(nbbo.option_contract_id, Date.now()); } @@ -907,14 +918,18 @@ const flushCluster = async ( features.special_print_count = cluster.specialPrintCount; } if (cluster.minExecutionIv !== null && cluster.maxExecutionIv !== null) { - features.execution_iv_shock = roundTo(Math.max(0, cluster.maxExecutionIv - cluster.minExecutionIv)); + features.execution_iv_shock = roundTo( + Math.max(0, cluster.maxExecutionIv - cluster.minExecutionIv) + ); } if ( cluster.firstUnderlyingMid !== null && cluster.lastUnderlyingMid !== null && cluster.firstUnderlyingMid > 0 ) { - const moveBps = ((cluster.lastUnderlyingMid - cluster.firstUnderlyingMid) / cluster.firstUnderlyingMid) * 10_000; + const moveBps = + ((cluster.lastUnderlyingMid - cluster.firstUnderlyingMid) / cluster.firstUnderlyingMid) * + 10_000; features.underlying_move_bps = roundTo(moveBps); } const syntheticEventOffsetDays = parseSyntheticEventOffsetDays(cluster.conditions); @@ -1004,7 +1019,13 @@ const flushCluster = async ( const rollLegs = [currentLeg, ...rootCandidates]; const rollSummary = summarizeStructure(rollLegs); if (rollSummary?.type === "roll") { - await emitStructurePacketIfNeeded(js, batchWriter, rollLegs, rollSummary, currentLeg.contractId); + await emitStructurePacketIfNeeded( + js, + batchWriter, + rollLegs, + rollSummary, + currentLeg.contractId + ); } storeRecentLeg(currentLeg, anchorTs); @@ -1072,13 +1093,21 @@ const emitClassifiers = async ( const underlyingId = typeof packet.features.underlying_id === "string" ? packet.features.underlying_id - : parseContractId(typeof packet.features.option_contract_id === "string" ? packet.features.option_contract_id : "")?.root; + : parseContractId( + typeof packet.features.option_contract_id === "string" + ? packet.features.option_contract_id + : "" + )?.root; const referenceTs = typeof packet.features.end_ts === "number" && Number.isFinite(packet.features.end_ts) ? packet.features.end_ts : packet.source_ts; - const eventCalendarMatch = underlyingId ? eventCalendarProvider.findNextEvent(underlyingId, referenceTs) : null; - smartMoneyEvent = SmartMoneyEventSchema.parse(buildSmartMoneyEventFromPacket(packet, { eventCalendarMatch })); + const eventCalendarMatch = underlyingId + ? eventCalendarProvider.findNextEvent(underlyingId, referenceTs) + : null; + smartMoneyEvent = SmartMoneyEventSchema.parse( + buildSmartMoneyEventFromPacket(packet, { eventCalendarMatch }) + ); enqueueSmartMoneyEventInsert(batchWriter, smartMoneyEvent); await publishJson(js, SUBJECT_SMART_MONEY_EVENTS, smartMoneyEvent); emitCounters.smartMoneyEvents += 1; @@ -1282,20 +1311,29 @@ const run = async () => { if (env.SMART_MONEY_EVENT_CALENDAR_PATH) { try { - eventCalendarProvider = await loadEventCalendarProviderFromFile(env.SMART_MONEY_EVENT_CALENDAR_PATH); - logger.info("smart money event calendar loaded", { path: env.SMART_MONEY_EVENT_CALENDAR_PATH }); + eventCalendarProvider = await loadEventCalendarProviderFromFile( + env.SMART_MONEY_EVENT_CALENDAR_PATH + ); + logger.info("smart money event calendar loaded", { + path: env.SMART_MONEY_EVENT_CALENDAR_PATH + }); } catch (error) { eventCalendarProvider = createEmptyEventCalendarProvider(); - logger.warn("smart money event calendar unavailable; scoring will use neutral event features", { - path: env.SMART_MONEY_EVENT_CALENDAR_PATH, - error: error instanceof Error ? error.message : String(error) - }); + logger.warn( + "smart money event calendar unavailable; scoring will use neutral event features", + { + path: env.SMART_MONEY_EVENT_CALENDAR_PATH, + error: error instanceof Error ? error.message : String(error) + } + ); } } const redis = createRedisClient(env.REDIS_URL); redis.on("error", (error) => { - logger.warn("redis client error", { error: error instanceof Error ? error.message : String(error) }); + logger.warn("redis client error", { + error: error instanceof Error ? error.message : String(error) + }); }); await retry("redis connect", 120, 500, async () => { @@ -1379,7 +1417,10 @@ const run = async () => { } else { try { const info = await jsm.consumers.info(STREAM_OPTION_SIGNAL_PRINTS, durableName); - if (info?.config?.deliver_policy && info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY) { + if ( + info?.config?.deliver_policy && + info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY + ) { logger.warn("resetting consumer due to deliver policy change", { durable: durableName, current: info.config.deliver_policy, @@ -1390,7 +1431,10 @@ const run = async () => { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("not found")) { - logger.warn("failed to inspect jetstream consumer", { durable: durableName, error: message }); + logger.warn("failed to inspect jetstream consumer", { + durable: durableName, + error: message + }); } } } @@ -1402,13 +1446,19 @@ const run = async () => { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("not found")) { - logger.warn("failed to reset jetstream consumer", { durable: nbboDurableName, error: message }); + logger.warn("failed to reset jetstream consumer", { + durable: nbboDurableName, + error: message + }); } } } else { try { const info = await jsm.consumers.info(STREAM_OPTION_NBBO, nbboDurableName); - if (info?.config?.deliver_policy && info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY) { + if ( + info?.config?.deliver_policy && + info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY + ) { logger.warn("resetting consumer due to deliver policy change", { durable: nbboDurableName, current: info.config.deliver_policy, @@ -1419,7 +1469,10 @@ const run = async () => { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("not found")) { - logger.warn("failed to inspect jetstream consumer", { durable: nbboDurableName, error: message }); + logger.warn("failed to inspect jetstream consumer", { + durable: nbboDurableName, + error: message + }); } } } @@ -1440,7 +1493,10 @@ const run = async () => { } else { try { const info = await jsm.consumers.info(STREAM_EQUITY_PRINTS, equityPrintDurableName); - if (info?.config?.deliver_policy && info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY) { + if ( + info?.config?.deliver_policy && + info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY + ) { logger.warn("resetting consumer due to deliver policy change", { durable: equityPrintDurableName, current: info.config.deliver_policy, @@ -1475,7 +1531,10 @@ const run = async () => { } else { try { const info = await jsm.consumers.info(STREAM_EQUITY_QUOTES, equityQuoteDurableName); - if (info?.config?.deliver_policy && info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY) { + if ( + info?.config?.deliver_policy && + info.config.deliver_policy !== env.COMPUTE_DELIVER_POLICY + ) { logger.warn("resetting consumer due to deliver policy change", { durable: equityQuoteDurableName, current: info.config.deliver_policy, @@ -1515,7 +1574,8 @@ const run = async () => { try { await jsm.consumers.delete(STREAM_OPTION_SIGNAL_PRINTS, durableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: durableName, @@ -1551,7 +1611,8 @@ const run = async () => { try { await jsm.consumers.delete(STREAM_OPTION_NBBO, nbboDurableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: nbboDurableName, @@ -1582,12 +1643,16 @@ const run = async () => { throw error; } - logger.warn("resetting jetstream consumer", { durable: equityPrintDurableName, error: message }); + logger.warn("resetting jetstream consumer", { + durable: equityPrintDurableName, + error: message + }); try { await jsm.consumers.delete(STREAM_EQUITY_PRINTS, equityPrintDurableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: equityPrintDurableName, @@ -1626,7 +1691,8 @@ const run = async () => { try { await jsm.consumers.delete(STREAM_EQUITY_QUOTES, equityQuoteDurableName); } catch (deleteError) { - const deleteMessage = deleteError instanceof Error ? deleteError.message : String(deleteError); + const deleteMessage = + deleteError instanceof Error ? deleteError.message : String(deleteError); if (!deleteMessage.includes("not found")) { logger.warn("failed to delete jetstream consumer", { durable: equityQuoteDurableName, diff --git a/services/compute/src/parent-events.ts b/services/compute/src/parent-events.ts index d0654a4..5b4865b 100644 --- a/services/compute/src/parent-events.ts +++ b/services/compute/src/parent-events.ts @@ -78,7 +78,9 @@ const getDteDays = (packet: FlowPacket): number | null => { const inferDirection = (packet: FlowPacket): SmartMoneyDirection => { const structureRights = stringFeature(packet, "structure_rights"); - const optionType = stringFeature(packet, "option_type") || parseContractId(stringFeature(packet, "option_contract_id"))?.right; + const optionType = + stringFeature(packet, "option_type") || + parseContractId(stringFeature(packet, "option_contract_id"))?.right; const buy = numberFeature(packet, "nbbo_aggressive_buy_ratio"); const sell = numberFeature(packet, "nbbo_aggressive_sell_ratio"); const sellDominant = sell >= buy + 0.12; @@ -102,16 +104,26 @@ export type SmartMoneyParentEventOptions = { eventCalendarMatch?: EventCalendarMatch | null; }; -const buildFeatures = (packet: FlowPacket, options: SmartMoneyParentEventOptions = {}): SmartMoneyFeatures => { +const buildFeatures = ( + packet: FlowPacket, + options: SmartMoneyParentEventOptions = {} +): SmartMoneyFeatures => { const contractId = stringFeature(packet, "option_contract_id"); const contract = parseContractId(contractId); const underlyingMid = numberFeature(packet, "underlying_mid"); - const quoteAge = numberFeature(packet, "nbbo_age_ms") || numberFeature(packet, "underlying_quote_age_ms"); - const printCount = Math.max(0, Math.round(numberFeature(packet, "count") || packet.members.length)); + const quoteAge = + numberFeature(packet, "nbbo_age_ms") || numberFeature(packet, "underlying_quote_age_ms"); + const printCount = Math.max( + 0, + Math.round(numberFeature(packet, "count") || packet.members.length) + ); const staleCount = numberFeature(packet, "nbbo_stale_count"); const missingCount = numberFeature(packet, "nbbo_missing_count"); const structureLegs = Math.max(0, Math.round(numberFeature(packet, "structure_legs"))); - const strikeCount = Math.max(1, Math.round(numberFeature(packet, "structure_strikes") || (contract ? 1 : 0))); + const strikeCount = Math.max( + 1, + Math.round(numberFeature(packet, "structure_strikes") || (contract ? 1 : 0)) + ); const specialCount = numberFeature(packet, "special_print_count"); const calendarEventTs = options.eventCalendarMatch?.event_ts ?? null; const eventTs = calendarEventTs ?? numberFeature(packet, "corporate_event_ts"); @@ -119,7 +131,9 @@ const buildFeatures = (packet: FlowPacket, options: SmartMoneyParentEventOptions const expiryTs = contract ? Date.parse(`${contract.expiry}T00:00:00Z`) : Number.NaN; const atmProximity = - contract && underlyingMid > 0 ? Math.abs(contract.strike - underlyingMid) / underlyingMid : null; + contract && underlyingMid > 0 + ? Math.abs(contract.strike - underlyingMid) / underlyingMid + : null; return { contract_count: Math.max(1, structureLegs || 1), @@ -143,14 +157,18 @@ const buildFeatures = (packet: FlowPacket, options: SmartMoneyParentEventOptions nbbo_stale_ratio: printCount > 0 ? clamp((staleCount + missingCount) / printCount) : 0, quote_age_ms: quoteAge > 0 ? quoteAge : null, venue_count: Math.max(1, Math.round(numberFeature(packet, "venue_count") || 1)), - inter_fill_ms_mean: printCount > 1 ? numberFeature(packet, "window_ms") / Math.max(1, printCount - 1) : null, + inter_fill_ms_mean: + printCount > 1 ? numberFeature(packet, "window_ms") / Math.max(1, printCount - 1) : null, strike_count: strikeCount, strike_concentration: strikeCount > 0 ? clamp(1 / strikeCount) : 0, - ...(stringFeature(packet, "structure_type") ? { structure_type: stringFeature(packet, "structure_type") } : {}), + ...(stringFeature(packet, "structure_type") + ? { structure_type: stringFeature(packet, "structure_type") } + : {}), structure_legs: structureLegs, same_size_leg_symmetry: clamp(numberFeature(packet, "same_size_leg_symmetry")), net_directional_bias: clamp( - numberFeature(packet, "nbbo_aggressive_buy_ratio") - numberFeature(packet, "nbbo_aggressive_sell_ratio"), + numberFeature(packet, "nbbo_aggressive_buy_ratio") - + numberFeature(packet, "nbbo_aggressive_sell_ratio"), -1, 1 ), @@ -159,7 +177,10 @@ const buildFeatures = (packet: FlowPacket, options: SmartMoneyParentEventOptions underlying_move_bps: numberFeature(packet, "underlying_move_bps") || null, days_to_event: eventTs > 0 ? (eventTs - referenceTs) / MS_PER_DAY : null, expiry_after_event: eventTs > 0 && Number.isFinite(expiryTs) ? expiryTs >= eventTs : null, - pre_event_concentration: eventTs > 0 && eventTs >= referenceTs ? clamp(1 - (eventTs - referenceTs) / (21 * MS_PER_DAY)) : null, + pre_event_concentration: + eventTs > 0 && eventTs >= referenceTs + ? clamp(1 - (eventTs - referenceTs) / (21 * MS_PER_DAY)) + : null, special_print_ratio: printCount > 0 ? clamp(specialCount / printCount) : 0 }; }; @@ -170,7 +191,10 @@ const detectSuppression = (packet: FlowPacket, features: SmartMoneyFeatures): st .split(",") .map((item) => item.trim().toUpperCase()) .filter(Boolean); - if (conditions.some((condition) => SPECIAL_CONDITIONS.has(condition)) || features.special_print_ratio >= 0.34) { + if ( + conditions.some((condition) => SPECIAL_CONDITIONS.has(condition)) || + features.special_print_ratio >= 0.34 + ) { reasons.push("special_print_or_complex_context"); } if (features.nbbo_coverage_ratio < 0.35 || features.nbbo_stale_ratio >= 0.5) { @@ -198,7 +222,10 @@ const evaluateProfiles = ( const burstFactor = clamp(features.print_count / 8); const quality = clamp(features.nbbo_coverage_ratio - features.nbbo_stale_ratio); const shortDatedOtm = - dte <= 7 && features.atm_proximity !== null && features.atm_proximity >= 0.05 && features.option_type === "C"; + dte <= 7 && + features.atm_proximity !== null && + features.atm_proximity >= 0.05 && + features.option_type === "C"; const nearAtm = features.atm_proximity !== null && features.atm_proximity <= 0.015; const preEvent = features.days_to_event !== null && @@ -211,7 +238,11 @@ const evaluateProfiles = ( "institutional_directional", suppressed.length > 0 || shortDatedOtm ? 0.18 - : 0.2 + premiumFactor * 0.25 + burstFactor * 0.18 + quality * 0.16 + (buy >= 0.58 || sell >= 0.58 ? 0.12 : 0), + : 0.2 + + premiumFactor * 0.25 + + burstFactor * 0.18 + + quality * 0.16 + + (buy >= 0.58 || sell >= 0.58 ? 0.12 : 0), direction, [ "large_parent_event", @@ -232,13 +263,19 @@ const evaluateProfiles = ( ), score( "event_driven", - 0.12 + (preEvent ? 0.32 : 0) + premiumFactor * 0.14 + clamp(features.spread_widening ?? 0, 0, 0.16), + 0.12 + + (preEvent ? 0.32 : 0) + + premiumFactor * 0.14 + + clamp(features.spread_widening ?? 0, 0, 0.16), direction === "unknown" ? "neutral" : direction, ["event_calendar_alignment", "expiry_after_event", "pre_event_concentration"] ), score( "vol_seller", - 0.12 + (sell >= 0.58 ? 0.24 : 0) + (structure === "straddle" || structure === "strangle" ? 0.2 : 0) + premiumFactor * 0.14, + 0.12 + + (sell >= 0.58 ? 0.24 : 0) + + (structure === "straddle" || structure === "strangle" ? 0.2 : 0) + + premiumFactor * 0.14, "neutral", ["sell_side_premium", "short_vol_structure_evidence"] ), @@ -273,11 +310,16 @@ export const buildSmartMoneyEventFromPacket = ( const suppressed = detectSuppression(packet, features); const profileScores = evaluateProfiles(packet, features, suppressed); const primary = profileScores[0] ?? null; - const abstained = !primary || primary.probability < 0.42 || suppressed.includes("stale_or_missing_quote_context"); - const underlying = stringFeature(packet, "underlying_id") || parseContractId(features.option_contract_id ?? "")?.root || "UNKNOWN"; - const eventKind = features.structure_legs >= 2 || stringFeature(packet, "packet_kind") === "structure" - ? "multi_leg_event" - : "single_leg_event"; + const abstained = + !primary || primary.probability < 0.42 || suppressed.includes("stale_or_missing_quote_context"); + const underlying = + stringFeature(packet, "underlying_id") || + parseContractId(features.option_contract_id ?? "")?.root || + "UNKNOWN"; + const eventKind = + features.structure_legs >= 2 || stringFeature(packet, "packet_kind") === "structure" + ? "multi_leg_event" + : "single_leg_event"; return SmartMoneyEventSchema.parse({ source_ts: packet.source_ts, @@ -292,8 +334,8 @@ export const buildSmartMoneyEventFromPacket = ( event_window_ms: features.window_ms, features, profile_scores: profileScores, - primary_profile_id: abstained ? null : primary?.profile_id ?? null, - primary_direction: abstained ? "unknown" : primary?.direction ?? "unknown", + primary_profile_id: abstained ? null : (primary?.profile_id ?? null), + primary_direction: abstained ? "unknown" : (primary?.direction ?? "unknown"), abstained, suppressed_reasons: suppressed }); @@ -308,7 +350,9 @@ const LEGACY_PROFILE_MAP: Record = { hedge_reactive: "smart_money_hedge_reactive" }; -export const deriveClassifierHitsFromSmartMoneyEvent = (event: SmartMoneyEvent): ClassifierHit[] => { +export const deriveClassifierHitsFromSmartMoneyEvent = ( + event: SmartMoneyEvent +): ClassifierHit[] => { if (event.abstained || !event.primary_profile_id) { return []; } diff --git a/services/compute/src/rolling-stats.ts b/services/compute/src/rolling-stats.ts index d30b930..6987a51 100644 --- a/services/compute/src/rolling-stats.ts +++ b/services/compute/src/rolling-stats.ts @@ -24,9 +24,7 @@ type RollingWindowEntry = { }; const toNumbers = (values: string[]): number[] => { - return values - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)); + return values.map((value) => Number(value)).filter((value) => Number.isFinite(value)); }; export const computeStats = (values: number[]): { mean: number; stddev: number; count: number } => { diff --git a/services/compute/src/smart-money-evaluation.ts b/services/compute/src/smart-money-evaluation.ts index f2c4271..77cad52 100644 --- a/services/compute/src/smart-money-evaluation.ts +++ b/services/compute/src/smart-money-evaluation.ts @@ -1,4 +1,9 @@ -import type { FlowPacket, SmartMoneyDirection, SmartMoneyEvent, SmartMoneyProfileId } from "@islandflow/types"; +import type { + FlowPacket, + SmartMoneyDirection, + SmartMoneyEvent, + SmartMoneyProfileId +} from "@islandflow/types"; import { buildSmartMoneyEventFromPacket, type SmartMoneyParentEventOptions } from "./parent-events"; export type SmartMoneyLabel = { @@ -115,8 +120,12 @@ export const compareSmartMoneyReplayOutputs = ( liveEvents: SmartMoneyEvent[], batchEvents: SmartMoneyEvent[] ): ReplayConsistencyReport => { - const liveById = new Map(liveEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)])); - const batchById = new Map(batchEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)])); + const liveById = new Map( + liveEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)]) + ); + const batchById = new Map( + batchEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)]) + ); const ids = [...new Set([...liveById.keys(), ...batchById.keys()])].sort(); const mismatches: ReplayConsistencyMismatch[] = []; @@ -153,7 +162,9 @@ export const evaluateSmartMoneyEvents = ( const labelsById = new Map(labels.map((label) => [label.event_id, label])); const labeledEvents = events .map((event) => ({ event, label: labelsById.get(event.event_id) })) - .filter((entry): entry is { event: SmartMoneyEvent; label: SmartMoneyLabel } => Boolean(entry.label)); + .filter((entry): entry is { event: SmartMoneyEvent; label: SmartMoneyLabel } => + Boolean(entry.label) + ); const emitted = events.filter((event) => !event.abstained && event.primary_profile_id); const profilePrecision: SmartMoneyEvaluationReport["profile_precision"] = {}; @@ -163,7 +174,8 @@ export const evaluateSmartMoneyEvents = ( const predicted = labeledEvents.filter((entry) => entry.event.primary_profile_id === profile); const actual = labeledEvents.filter((entry) => entry.label.profile_id === profile); const truePositive = predicted.filter((entry) => entry.label.profile_id === profile).length; - profilePrecision[profile] = predicted.length > 0 ? round(truePositive / predicted.length) : null; + profilePrecision[profile] = + predicted.length > 0 ? round(truePositive / predicted.length) : null; profileRecall[profile] = actual.length > 0 ? round(truePositive / actual.length) : null; } @@ -175,7 +187,10 @@ export const evaluateSmartMoneyEvents = ( labeled_count: labeledEvents.length, emitted_count: emitted.length, abstained_count: events.filter((event) => event.abstained).length, - abstention_rate: events.length > 0 ? round(events.filter((event) => event.abstained).length / events.length) : 0, + abstention_rate: + events.length > 0 + ? round(events.filter((event) => event.abstained).length / events.length) + : 0, profile_precision: profilePrecision, profile_recall: profileRecall, calibration, @@ -195,7 +210,9 @@ const buildCalibration = ( })); for (const { event, label } of entries) { - const probability = event.profile_scores.find((entry) => entry.profile_id === event.primary_profile_id)?.probability ?? 0; + const probability = + event.profile_scores.find((entry) => entry.profile_id === event.primary_profile_id) + ?.probability ?? 0; const index = Math.min(bucketCount - 1, Math.floor(probability * bucketCount)); buckets[index].probabilities.push(probability); if (!event.abstained && event.primary_profile_id === label.profile_id) { @@ -209,9 +226,13 @@ const buildCalibration = ( count: bucket.probabilities.length, average_probability: bucket.probabilities.length > 0 - ? round(bucket.probabilities.reduce((sum, value) => sum + value, 0) / bucket.probabilities.length) + ? round( + bucket.probabilities.reduce((sum, value) => sum + value, 0) / + bucket.probabilities.length + ) : 0, - accuracy: bucket.probabilities.length > 0 ? round(bucket.correct / bucket.probabilities.length) : null + accuracy: + bucket.probabilities.length > 0 ? round(bucket.correct / bucket.probabilities.length) : null })); }; @@ -223,7 +244,10 @@ const buildEconomicSanity = ( sign: directionalSign(event.primary_direction), realized: label.realized_return_bps })) - .filter((entry): entry is { sign: number; realized: number } => entry.sign !== 0 && Number.isFinite(entry.realized)); + .filter( + (entry): entry is { sign: number; realized: number } => + entry.sign !== 0 && Number.isFinite(entry.realized) + ); if (directional.length === 0) { return { @@ -236,7 +260,12 @@ const buildEconomicSanity = ( const signedReturns = directional.map((entry) => entry.sign * entry.realized); return { directional_count: directional.length, - direction_hit_rate: round(signedReturns.filter((value) => value > 0).length / directional.length), - average_signed_return_bps: round(signedReturns.reduce((sum, value) => sum + value, 0) / signedReturns.length, 2) + direction_hit_rate: round( + signedReturns.filter((value) => value > 0).length / directional.length + ), + average_signed_return_bps: round( + signedReturns.reduce((sum, value) => sum + value, 0) / signedReturns.length, + 2 + ) }; }; diff --git a/services/compute/src/structure-packets.ts b/services/compute/src/structure-packets.ts index 82876f7..5db9e4e 100644 --- a/services/compute/src/structure-packets.ts +++ b/services/compute/src/structure-packets.ts @@ -134,7 +134,9 @@ const dayDiff = (from: string | null, to: string | null): number | null => { }; const sameSizeLegSymmetry = (legs: LegEvidence[]): number => { - const sizes = legs.map((leg) => leg.totalSize).filter((value) => Number.isFinite(value) && value > 0); + const sizes = legs + .map((leg) => leg.totalSize) + .filter((value) => Number.isFinite(value) && value > 0); if (sizes.length < 2) { return 0; } @@ -146,7 +148,10 @@ const sameSizeLegSymmetry = (legs: LegEvidence[]): number => { return min / max; }; -export const shouldEmitStructurePacket = (legs: LegEvidence[], currentLegContractId: string): boolean => { +export const shouldEmitStructurePacket = ( + legs: LegEvidence[], + currentLegContractId: string +): boolean => { if (legs.length < 2) { return false; } @@ -226,7 +231,8 @@ export const planStructurePacket = ( const totalSize = legs.reduce((sum, leg) => sum + leg.totalSize, 0); const count = legs.reduce((sum, leg) => sum + leg.members.length, 0); const placements = mergePlacements(legs); - const placementTotal = placements.aa + placements.a + placements.b + placements.bb + placements.mid; + const placementTotal = + placements.aa + placements.a + placements.b + placements.bb + placements.mid; const aggressiveTotal = placements.aa + placements.a + placements.b + placements.bb; const aggressiveBuy = placements.aa + placements.a; const aggressiveSell = placements.bb + placements.b; @@ -235,7 +241,10 @@ export const planStructurePacket = ( const nbboAggressiveSellRatio = aggressiveTotal > 0 ? aggressiveSell / aggressiveTotal : 0; const nbboAggressiveRatio = placementTotal > 0 ? aggressiveTotal / placementTotal : 0; - const source_ts = legs.reduce((min, leg) => Math.min(min, leg.source_ts), Number.POSITIVE_INFINITY); + const source_ts = legs.reduce( + (min, leg) => Math.min(min, leg.source_ts), + Number.POSITIVE_INFINITY + ); const ingest_ts = legs.reduce((max, leg) => Math.max(max, leg.ingest_ts), 0); const seq = legs.reduce((max, leg) => Math.max(max, leg.seq), 0); diff --git a/services/compute/src/structures.ts b/services/compute/src/structures.ts index f7d0026..183f05e 100644 --- a/services/compute/src/structures.ts +++ b/services/compute/src/structures.ts @@ -47,7 +47,10 @@ export const summarizeStructure = (legs: ContractLeg[]): StructureSummary | null legs: legs.length, strikes: strikes.length, strikeSpan, - rights: rights.size === 2 ? "C/P" : Array.from(rights)[0] ?? "", - contractIds: legs.map((leg) => leg.contractId).slice().sort() + rights: rights.size === 2 ? "C/P" : (Array.from(rights)[0] ?? ""), + contractIds: legs + .map((leg) => leg.contractId) + .slice() + .sort() }; }; diff --git a/services/compute/tests/classifiers.test.ts b/services/compute/tests/classifiers.test.ts index a160624..1cc950b 100644 --- a/services/compute/tests/classifiers.test.ts +++ b/services/compute/tests/classifiers.test.ts @@ -293,4 +293,3 @@ describe("compute classifiers", () => { expect(hit!.explanations[0]).toMatch(/Consistent with/i); }); }); - diff --git a/services/compute/tests/helpers.ts b/services/compute/tests/helpers.ts index ed7a494..906d275 100644 --- a/services/compute/tests/helpers.ts +++ b/services/compute/tests/helpers.ts @@ -17,16 +17,18 @@ export const TEST_CLASSIFIER_CONFIG: ClassifierConfig = { zeroDteMinSize: 400 }; -export const buildFlowPacket = (opts: { - id?: string; - source_ts?: number; - ingest_ts?: number; - seq?: number; - trace_id?: string; - members?: string[]; - features?: FlowPacket["features"]; - join_quality?: FlowPacket["join_quality"]; -} = {}): FlowPacket => { +export const buildFlowPacket = ( + opts: { + id?: string; + source_ts?: number; + ingest_ts?: number; + seq?: number; + trace_id?: string; + members?: string[]; + features?: FlowPacket["features"]; + join_quality?: FlowPacket["join_quality"]; + } = {} +): FlowPacket => { const id = opts.id ?? "flowpacket:test"; const source_ts = opts.source_ts ?? Date.parse("2025-01-01T14:30:00Z"); const ingest_ts = opts.ingest_ts ?? source_ts; @@ -66,4 +68,3 @@ export const buildFlowPacket = (opts: { export const getHit = (hits: ClassifierHit[], id: string): ClassifierHit | null => { return hits.find((hit) => hit.classifier_id === id) ?? null; }; - diff --git a/services/compute/tests/structure-packets.test.ts b/services/compute/tests/structure-packets.test.ts index 80dfa81..12f0a3b 100644 --- a/services/compute/tests/structure-packets.test.ts +++ b/services/compute/tests/structure-packets.test.ts @@ -18,7 +18,9 @@ const placements = (overrides?: Partial): LegEvidence ...overrides }); -const leg = (input: Partial & Pick): LegEvidence => { +const leg = ( + input: Partial & Pick +): LegEvidence => { return { contractId: input.contractId, root: "SPY", diff --git a/services/ingest-equities/src/adapters/alpaca.ts b/services/ingest-equities/src/adapters/alpaca.ts index b7fa871..fc93164 100644 --- a/services/ingest-equities/src/adapters/alpaca.ts +++ b/services/ingest-equities/src/adapters/alpaca.ts @@ -85,10 +85,14 @@ const decodePayload = (data: WebSocket.RawData): unknown => { } if (ArrayBuffer.isView(data)) { - return JSON.parse(new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))) as unknown; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)) + ) as unknown; } - return JSON.parse(new TextDecoder().decode(new Uint8Array(data as unknown as ArrayBuffer))) as unknown; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(data as unknown as ArrayBuffer)) + ) as unknown; }; const extractExchangeMeta = (payload: unknown): AlpacaExchangeMetaEntry[] => { @@ -103,8 +107,18 @@ const extractExchangeMeta = (payload: unknown): AlpacaExchangeMetaEntry[] => { continue; } const candidate = entry as Record; - const code = typeof candidate.code === "string" ? candidate.code : typeof candidate.exchange === "string" ? candidate.exchange : null; - const name = typeof candidate.name === "string" ? candidate.name : typeof candidate.description === "string" ? candidate.description : null; + const code = + typeof candidate.code === "string" + ? candidate.code + : typeof candidate.exchange === "string" + ? candidate.exchange + : null; + const name = + typeof candidate.name === "string" + ? candidate.name + : typeof candidate.description === "string" + ? candidate.description + : null; if (!code || !name) { continue; } @@ -128,9 +142,19 @@ const buildExchangeNameMap = (entries: AlpacaExchangeMetaEntry[]): Map): boolean => { +export const inferOffExchangeFlag = ( + exchangeCode: string | undefined, + exchangeNameMap: Map +): boolean => { if (!exchangeCode) { return false; } @@ -151,7 +175,9 @@ const buildWsUrl = (wsBaseUrl: string, feed: AlpacaEquitiesFeed): string => { return `${parsed.origin}/v2/${feed}`; }; -const fetchExchangeMeta = async (config: AlpacaEquitiesAdapterConfig): Promise> => { +const fetchExchangeMeta = async ( + config: AlpacaEquitiesAdapterConfig +): Promise> => { const url = new URL("/v2/stocks/meta/exchanges", config.restUrl); try { @@ -243,7 +269,10 @@ export const createAlpacaEquitiesAdapter = ( continue; } - const message = entry as (AlpacaTradeMessage | AlpacaQuoteMessage | { T?: string; msg?: string }); + const message = entry as + | AlpacaTradeMessage + | AlpacaQuoteMessage + | { T?: string; msg?: string }; const type = message.T; if (type === "success") { diff --git a/services/ingest-equities/src/adapters/synthetic.ts b/services/ingest-equities/src/adapters/synthetic.ts index 59e0a98..0c2bc1b 100644 --- a/services/ingest-equities/src/adapters/synthetic.ts +++ b/services/ingest-equities/src/adapters/synthetic.ts @@ -89,11 +89,7 @@ const priceForPlacement = ( return formatPrice(Math.max(0.01, price)); }; -const buildQuoteContext = ( - symbol: string, - now: number, - control: SyntheticControlState -) => { +const buildQuoteContext = (symbol: string, now: number, control: SyntheticControlState) => { const session = getSyntheticSessionState(now, control); const state = getSyntheticUnderlyingState(symbol, now, control, session); return { @@ -184,7 +180,9 @@ export const createSyntheticEquitiesAdapter = ( session.regime === "retail_chase"; if (allowDark) { - const darkSymbol = focusSymbols[seq % focusSymbols.length] ?? SYNTHETIC_SYMBOLS[symbolCursor % SYNTHETIC_SYMBOLS.length]!; + const darkSymbol = + focusSymbols[seq % focusSymbols.length] ?? + SYNTHETIC_SYMBOLS[symbolCursor % SYNTHETIC_SYMBOLS.length]!; const darkQuote = buildQuoteContext(darkSymbol, now, control); const darkPlacement = pickDarkPlacement( darkQuote.state.driftBps, @@ -203,13 +201,7 @@ export const createSyntheticEquitiesAdapter = ( if (handlers.onQuote) { quoteSeq += 1; void handlers.onQuote( - buildSyntheticQuote( - quoteSeq, - now - 2, - darkSymbol, - darkQuote.bid, - darkQuote.ask - ) + buildSyntheticQuote(quoteSeq, now - 2, darkSymbol, darkQuote.bid, darkQuote.ask) ); } @@ -236,11 +228,7 @@ export const createSyntheticEquitiesAdapter = ( const eventTs = now + i * 4; const quote = buildQuoteContext(symbol, eventTs, control); const clustered = focusSet.has(symbol); - const placement = pickPrimaryPlacement( - quote.state.driftBps, - session.regime, - seq + i - ); + const placement = pickPrimaryPlacement(quote.state.driftBps, session.regime, seq + i); const exchange = EXCHANGES[(seq + symbol.charCodeAt(0) + i) % EXCHANGES.length]!; const baseSize = throughput.litSizeBase + @@ -255,13 +243,7 @@ export const createSyntheticEquitiesAdapter = ( if (handlers.onQuote) { quoteSeq += 1; void handlers.onQuote( - buildSyntheticQuote( - quoteSeq, - eventTs - 2, - symbol, - quote.bid, - quote.ask - ) + buildSyntheticQuote(quoteSeq, eventTs - 2, symbol, quote.bid, quote.ask) ); } diff --git a/services/ingest-equities/src/index.ts b/services/ingest-equities/src/index.ts index 1b708ae..c91bb33 100644 --- a/services/ingest-equities/src/index.ts +++ b/services/ingest-equities/src/index.ts @@ -240,10 +240,7 @@ const run = async () => { await ensureEquityQuotesTable(clickhouse); }); - const adapter = selectAdapter( - env.EQUITIES_INGEST_ADAPTER, - () => syntheticControl - ); + const adapter = selectAdapter(env.EQUITIES_INGEST_ADAPTER, () => syntheticControl); logger.info("ingest adapter selected", { adapter: adapter.name }); const allowPublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS); const allowQuotePublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS); diff --git a/services/ingest-news/src/index.ts b/services/ingest-news/src/index.ts index 421eaf3..2f68289 100644 --- a/services/ingest-news/src/index.ts +++ b/services/ingest-news/src/index.ts @@ -126,9 +126,13 @@ const decodePayload = (data: WebSocket.RawData): unknown => { return JSON.parse(new TextDecoder().decode(new Uint8Array(data))) as unknown; } if (ArrayBuffer.isView(data)) { - return JSON.parse(new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))) as unknown; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)) + ) as unknown; } - return JSON.parse(new TextDecoder().decode(new Uint8Array(data as unknown as ArrayBuffer))) as unknown; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(data as unknown as ArrayBuffer)) + ) as unknown; }; const run = async () => { diff --git a/services/ingest-options/src/adapters/alpaca.ts b/services/ingest-options/src/adapters/alpaca.ts index 9ea844d..278d31a 100644 --- a/services/ingest-options/src/adapters/alpaca.ts +++ b/services/ingest-options/src/adapters/alpaca.ts @@ -152,10 +152,7 @@ const normalizeUnderlyings = (value: string[]): string[] => { return result; }; -const fetchJson = async ( - url: URL, - config: AlpacaOptionsAdapterConfig -): Promise => { +const fetchJson = async (url: URL, config: AlpacaOptionsAdapterConfig): Promise => { const response = await fetch(url.toString(), { headers: buildAlpacaAuthHeaders(config.credentials) }); @@ -235,10 +232,7 @@ const fetchOptionSnapshots = async ( return contracts; }; -const selectExpiries = ( - contracts: OptionContract[], - maxDteDays: number -): ExpiryInfo[] => { +const selectExpiries = (contracts: OptionContract[], maxDteDays: number): ExpiryInfo[] => { const today = new Date(); const expiryMap = new Map(); @@ -332,7 +326,9 @@ const selectContractsForUnderlying = ( const minStrike = price * (1 - config.moneynessPct); const maxStrike = price * (1 + config.moneynessPct); const strikePairs = Array.from(strikeMap.entries()) - .filter(([strike, pair]) => pair.call && pair.put && strike >= minStrike && strike <= maxStrike) + .filter( + ([strike, pair]) => pair.call && pair.put && strike >= minStrike && strike <= maxStrike + ) .map(([strike, pair]) => ({ strike, call: pair.call as string, @@ -540,7 +536,10 @@ export const createAlpacaOptionsAdapter = ( continue; } - const message = entry as AlpacaTradeMessage | AlpacaQuoteMessage | { T?: string; msg?: string }; + const message = entry as + | AlpacaTradeMessage + | AlpacaQuoteMessage + | { T?: string; msg?: string }; const type = message.T; if (type === "t") { diff --git a/services/ingest-options/src/adapters/databento.ts b/services/ingest-options/src/adapters/databento.ts index 6f174d8..9c18ed4 100644 --- a/services/ingest-options/src/adapters/databento.ts +++ b/services/ingest-options/src/adapters/databento.ts @@ -235,8 +235,7 @@ export const createDatabentoOptionsAdapter = ( return; } - const scaledPrice = - config.priceScale === 1 ? price : price / config.priceScale; + const scaledPrice = config.priceScale === 1 ? price : price / config.priceScale; const conditions = Array.isArray(payload.conditions) ? payload.conditions.map((entry) => String(entry)) diff --git a/services/ingest-options/src/adapters/ibkr.ts b/services/ingest-options/src/adapters/ibkr.ts index 68d2ece..60290e9 100644 --- a/services/ingest-options/src/adapters/ibkr.ts +++ b/services/ingest-options/src/adapters/ibkr.ts @@ -59,9 +59,7 @@ const readLines = async ( } }; -export const createIbkrOptionsAdapter = ( - config: IbkrOptionsAdapterConfig -): OptionIngestAdapter => { +export const createIbkrOptionsAdapter = (config: IbkrOptionsAdapterConfig): OptionIngestAdapter => { return { name: "ibkr", start: (handlers: OptionIngestHandlers) => { diff --git a/services/ingest-options/src/adapters/synthetic.ts b/services/ingest-options/src/adapters/synthetic.ts index 226f87c..761df68 100644 --- a/services/ingest-options/src/adapters/synthetic.ts +++ b/services/ingest-options/src/adapters/synthetic.ts @@ -715,10 +715,7 @@ const SYNTHETIC_PROFILES: Record = ...scenario, countRange: [scenario.countRange[0], scenario.countRange[1]], sizeRange: [scenario.sizeRange[0], scenario.sizeRange[1]], - targetNotionalRange: [ - scenario.targetNotionalRange[0], - scenario.targetNotionalRange[1] - ] + targetNotionalRange: [scenario.targetNotionalRange[0], scenario.targetNotionalRange[1]] })), pricePlacements: PLACEMENTS }, @@ -743,10 +740,7 @@ const SYNTHETIC_PROFILES: Record = scenarios: SCENARIO_LIBRARY.map((scenario) => ({ ...scenario, countRange: [scenario.countRange[0] + 2, scenario.countRange[1] + 4], - sizeRange: [ - Math.round(scenario.sizeRange[0] * 1.8), - Math.round(scenario.sizeRange[1] * 2.1) - ], + sizeRange: [Math.round(scenario.sizeRange[0] * 1.8), Math.round(scenario.sizeRange[1] * 2.1)], targetNotionalRange: [ Math.round(scenario.targetNotionalRange[0] * 1.7), Math.round(scenario.targetNotionalRange[1] * 2.0) @@ -768,7 +762,7 @@ const SMART_MONEY_TEMPLATE_SCENARIOS: Record< hedge_reactive: "reactive_put_wall" }; -const pick = (items: readonly T[], seed: number): T => { +const pick = (items: readonly T[], seed: number): T => { return items[Math.abs(seed) % items.length]!; }; @@ -850,9 +844,7 @@ export const updateSyntheticIvForTest = ( const sizeImpact = Math.log10(Math.max(10, input.size)) * 0.012; const notionalImpact = Math.log10(Math.max(1_000, input.notional)) * 0.01; pressure += - input.placement === "AA" - ? sizeImpact + notionalImpact - : (sizeImpact + notionalImpact) * 0.65; + input.placement === "AA" ? sizeImpact + notionalImpact : (sizeImpact + notionalImpact) * 0.65; } else if (input.placement === "MID") { pressure += 0.001; } else { @@ -879,8 +871,7 @@ const estimateSyntheticOptionMid = (input: { : Math.max(0, input.strike - input.underlying); const timeYears = Math.max(1, input.dteDays + 1) / 365; const baselineIv = initializeSyntheticIv(input.dteDays, input.moneyness); - const modeBoost = - input.mode === "firehose" ? 1.18 : input.mode === "active" ? 1.08 : 0.96; + const modeBoost = input.mode === "firehose" ? 1.18 : input.mode === "active" ? 1.08 : 0.96; const distance = Math.abs(input.moneyness - 1); const extrinsic = input.underlying * @@ -939,12 +930,7 @@ const chooseScenario = ( ): Scenario => { const session = getSyntheticSessionState(now, control); const focusSymbol = session.focus_symbols[0] ?? SYNTHETIC_SYMBOLS[0]!; - const familyWeights = getSyntheticScenarioWeights( - focusSymbol, - now, - control, - session - ); + const familyWeights = getSyntheticScenarioWeights(focusSymbol, now, control, session); const coverageCounts = getCoverageCounts(coverageState, now, control); const weightedScenarios = profile.scenarios.map((scenario, index) => { const familyWeight = familyWeights[scenario.label]; @@ -964,7 +950,10 @@ const chooseScenario = ( : 1; return { ...scenario, - weight: Math.max(1, Math.round(scenario.weight * familyWeight * coverageBoost * quietBias * 100)) + weight: Math.max( + 1, + Math.round(scenario.weight * familyWeight * coverageBoost * quietBias * 100) + ) }; }); return pickWeighted(weightedScenarios, now + control.shared_seed * 31); @@ -977,7 +966,8 @@ const pickScenarioSymbol = ( ): string => { const session = getSyntheticSessionState(now, control); const symbolPool = - scenario.preferredSymbols?.length && (scenario.label === "event_driven" || Math.abs(now) % 4 === 0) + scenario.preferredSymbols?.length && + (scenario.label === "event_driven" || Math.abs(now) % 4 === 0) ? [...scenario.preferredSymbols] : session.focus_symbols.length > 0 ? [...session.focus_symbols, ...SYNTHETIC_SYMBOLS] @@ -1033,11 +1023,12 @@ const buildDynamicFlowFeatures = ( 0, 0.26 ), - underlying_move_bps: Math.round( - (Number(scenario.flowFeatures.underlying_move_bps ?? underlying.driftBps) + - underlying.shockBps * 0.35) * - 100 - ) / 100, + underlying_move_bps: + Math.round( + (Number(scenario.flowFeatures.underlying_move_bps ?? underlying.driftBps) + + underlying.shockBps * 0.35) * + 100 + ) / 100, venue_count: Math.max( 1, Math.round( @@ -1059,18 +1050,14 @@ const buildBurst = ( coverageState: CoverageWindowState, scenarioOverride?: Scenario ): Burst => { - const scenario = - scenarioOverride ?? chooseScenario(profile, now, control, coverageState); + const scenario = scenarioOverride ?? chooseScenario(profile, now, control, coverageState); const symbol = pickScenarioSymbol(scenario, now, control); const symbolHash = hashSyntheticSymbol(symbol); const seed = symbolHash + burstIndex * 7; const session = getSyntheticSessionState(now, control); const underlyingState = getSyntheticUnderlyingState(symbol, now, control, session); const baseUnderlying = underlyingState.mid; - const expiryOffset = pick( - scenario.expiryOffsets ?? EXPIRY_OFFSETS, - symbolHash + burstIndex - ); + const expiryOffset = pick(scenario.expiryOffsets ?? EXPIRY_OFFSETS, symbolHash + burstIndex); const strikeStep = baseUnderlying >= 200 ? 10 : baseUnderlying >= 100 ? 5 : 2.5; const right = scenario.right === "either" @@ -1099,16 +1086,15 @@ const buildBurst = ( const priceStep = scenario.priceTrend === "up" ? 0.01 : scenario.priceTrend === "down" ? -0.01 : 0; const flowFeatures = buildDynamicFlowFeatures(scenario, symbol, now, control); - const legTemplates = - scenario.legs?.length - ? scenario.legs - : [ - { - right, - strikeMoneyness: scenario.strikeMoneyness, - placementScenarioId: scenario.placementProfile ?? scenario.label - } - ]; + const legTemplates = scenario.legs?.length + ? scenario.legs + : [ + { + right, + strikeMoneyness: scenario.strikeMoneyness, + placementScenarioId: scenario.placementProfile ?? scenario.label + } + ]; const targetNotionalPerLeg = targetNotional / legTemplates.length; const legs = legTemplates.map((template, legIndex): BurstLeg => { @@ -1127,8 +1113,7 @@ const buildBurst = ( const strike = Math.max( 1, templateStrike ?? - Math.round(baseUnderlying / strikeStep) * strikeStep + - strikeOffset * strikeStep + Math.round(baseUnderlying / strikeStep) * strikeStep + strikeOffset * strikeStep ); const legSize = Math.max(1, Math.round(baseSize * (template.sizeMultiplier ?? 1))); const legMoneyness = strike / baseUnderlying; @@ -1141,13 +1126,13 @@ const buildBurst = ( mode }); const targetMid = - targetNotionalPerLeg / - Math.max(1, legSize * cycles * OPTION_CONTRACT_MULTIPLIER); + targetNotionalPerLeg / Math.max(1, legSize * cycles * OPTION_CONTRACT_MULTIPLIER); const cappedTheoreticalMid = Math.min( theoreticalMid, Math.max(0.35, targetMid * (scenario.label === "institutional_directional" ? 2.2 : 2.6)) ); - const blendedMid = cappedTheoreticalMid * 0.45 + targetMid * 0.55 * (template.priceMultiplier ?? 1); + const blendedMid = + cappedTheoreticalMid * 0.45 + targetMid * 0.55 * (template.priceMultiplier ?? 1); return { contractId: `${symbol}-${expiry}-${formatStrike(strike)}-${template.right}`, right: template.right, @@ -1184,8 +1169,7 @@ const buildBurst = ( scenario.missingQuoteProbability ?? clampValue((1 - session.quote_cleanliness) * 0.16, 0, 0.18), staleQuoteProbability: - scenario.staleQuoteProbability ?? - clampValue((1 - session.quote_cleanliness) * 0.3, 0, 0.42) + scenario.staleQuoteProbability ?? clampValue((1 - session.quote_cleanliness) * 0.3, 0, 0.42) }; }; @@ -1202,7 +1186,9 @@ export const listSyntheticSmartMoneyScenariosForTest = (): SyntheticSmartMoneySc hiddenLabel: id === "neutral_noise" ? "single_print_mid" - : SMART_MONEY_TEMPLATE_SCENARIOS[id as Exclude<(typeof SMART_MONEY_SCENARIO_IDS)[number], "neutral_noise">] + : SMART_MONEY_TEMPLATE_SCENARIOS[ + id as Exclude<(typeof SMART_MONEY_SCENARIO_IDS)[number], "neutral_noise"> + ] })); export const buildSyntheticSmartMoneyBurstForTest = ( @@ -1233,18 +1219,18 @@ export const buildSyntheticSmartMoneyBurstForTest = ( updated_by: "system" } satisfies SyntheticControlState; const mode: SyntheticMarketMode = - scenarioId === "retail_whale" || scenarioId === "neutral_noise" - ? "realistic" - : "active"; + scenarioId === "retail_whale" || scenarioId === "neutral_noise" ? "realistic" : "active"; const profile = SYNTHETIC_PROFILES[mode]; const coverageState = createCoverageWindowState(); const scenario = scenarioId === "neutral_noise" ? profile.scenarios.find((candidate) => candidate.id === "single_print_mid")! : profile.scenarios.find( - (candidate) => candidate.id === SMART_MONEY_TEMPLATE_SCENARIOS[ - scenarioId as Exclude<(typeof SMART_MONEY_SCENARIO_IDS)[number], "neutral_noise"> - ] + (candidate) => + candidate.id === + SMART_MONEY_TEMPLATE_SCENARIOS[ + scenarioId as Exclude<(typeof SMART_MONEY_SCENARIO_IDS)[number], "neutral_noise"> + ] )!; return buildBurst(1, now, mode, profile, control, coverageState, scenario); }; @@ -1255,13 +1241,10 @@ export const buildSyntheticFlowPacketForTest = ( ): { packet: FlowPacket; hiddenLabel: string } => { const burst = buildSyntheticSmartMoneyBurstForTest(scenarioId, now); const primaryLeg = burst.legs[0]!; - const corporateEventOffset = Number( - burst.flowFeatures.corporate_event_ts_offset_days ?? 0 - ); + const corporateEventOffset = Number(burst.flowFeatures.corporate_event_ts_offset_days ?? 0); const totalSize = burst.legs.reduce((sum, leg) => sum + leg.baseSize * burst.cycles, 0); const totalPremium = burst.legs.reduce( - (sum, leg) => - sum + leg.basePrice * leg.baseSize * burst.cycles * OPTION_CONTRACT_MULTIPLIER, + (sum, leg) => sum + leg.basePrice * leg.baseSize * burst.cycles * OPTION_CONTRACT_MULTIPLIER, 0 ); const flowFeatures: FlowPacket["features"] = { @@ -1272,15 +1255,10 @@ export const buildSyntheticFlowPacketForTest = ( window_ms: Math.max(0, (burst.printCount - 1) * 45), total_size: totalSize, total_premium: Number(totalPremium.toFixed(2)), - total_notional: Number( - (burst.underlying * totalSize * OPTION_CONTRACT_MULTIPLIER).toFixed(2) - ), + total_notional: Number((burst.underlying * totalSize * OPTION_CONTRACT_MULTIPLIER).toFixed(2)), first_price: primaryLeg.basePrice, last_price: Number( - ( - primaryLeg.basePrice * - (1 + burst.priceStep * Math.max(0, burst.cycles - 1)) - ).toFixed(2) + (primaryLeg.basePrice * (1 + burst.priceStep * Math.max(0, burst.cycles - 1))).toFixed(2) ), nbbo_missing_count: 0, nbbo_stale_count: 0, @@ -1300,10 +1278,7 @@ export const buildSyntheticFlowPacketForTest = ( Number(flowFeatures.total_premium ?? totalPremium), 72_000 ); - flowFeatures.execution_iv_shock = Math.max( - Number(flowFeatures.execution_iv_shock ?? 0), - 0.22 - ); + flowFeatures.execution_iv_shock = Math.max(Number(flowFeatures.execution_iv_shock ?? 0), 0.22); } if (scenarioId === "event_driven") { flowFeatures.count = 2; @@ -1411,14 +1386,7 @@ export const buildSyntheticBurstForTest = ( return cached[burstIndex - 1]!; } for (let index = cached.length + 1; index <= burstIndex; index += 1) { - const current = buildBurst( - index, - now + index * 1_000, - mode, - profile, - control, - coverageState - ); + const current = buildBurst(index, now + index * 1_000, mode, profile, control, coverageState); recordCoverageHit(coverageState, current.label, now + index * 1_000); cached.push(current); } @@ -1466,14 +1434,7 @@ export const createSyntheticOptionsAdapter = ( }; if (!currentBurst || remainingRuns <= 0) { burstIndex += 1; - currentBurst = buildBurst( - burstIndex, - now, - config.mode, - profile, - control, - coverageState - ); + currentBurst = buildBurst(burstIndex, now, config.mode, profile, control, coverageState); recordCoverageHit(coverageState, currentBurst.label, now); remainingRuns = pickInt( profile.burstRunRange[0], @@ -1565,8 +1526,7 @@ export const createSyntheticOptionsAdapter = ( const quoteSeed = Math.abs(burst.seed + i * 17) % 1000; const missingQuote = quoteSeed / 1000 < burst.missingQuoteProbability; const staleQuote = - !missingQuote && - ((quoteSeed + 233) % 1000) / 1000 < burst.staleQuoteProbability; + !missingQuote && ((quoteSeed + 233) % 1000) / 1000 < burst.staleQuoteProbability; if (handlers.onNBBO && !missingQuote) { nbboSeq += 1; diff --git a/services/ingest-options/src/enrichment.ts b/services/ingest-options/src/enrichment.ts index 2104990..e5e8d63 100644 --- a/services/ingest-options/src/enrichment.ts +++ b/services/ingest-options/src/enrichment.ts @@ -48,7 +48,11 @@ export const selectAtOrBefore = ( if (item.ts > ts) { continue; } - if (!selected || item.ts > selected.ts || (item.ts === selected.ts && item.seq >= selected.seq)) { + if ( + !selected || + item.ts > selected.ts || + (item.ts === selected.ts && item.seq >= selected.seq) + ) { selected = item; } } diff --git a/services/ingest-options/src/index.ts b/services/ingest-options/src/index.ts index f416121..e4bd0b5 100644 --- a/services/ingest-options/src/index.ts +++ b/services/ingest-options/src/index.ts @@ -43,7 +43,12 @@ import { createDatabentoOptionsAdapter } from "./adapters/databento"; import { createIbkrOptionsAdapter } from "./adapters/ibkr"; import { createSyntheticOptionsAdapter } from "./adapters/synthetic"; import type { OptionIngestAdapter, StopHandler } from "./adapters/types"; -import { enrichOptionPrint, rememberContext, selectAtOrBefore, type ContextHistory } from "./enrichment"; +import { + enrichOptionPrint, + rememberContext, + selectAtOrBefore, + type ContextHistory +} from "./enrichment"; import { z } from "zod"; const service = "ingest-options"; @@ -87,7 +92,10 @@ const envSchema = z.object({ IBKR_EXPIRY: z.string().min(1).default("20250117"), IBKR_STRIKE: z.coerce.number().positive().default(450), IBKR_RIGHT: z - .preprocess((value) => (typeof value === "string" ? value.toUpperCase() : value), z.enum(["C", "P"])) + .preprocess( + (value) => (typeof value === "string" ? value.toUpperCase() : value), + z.enum(["C", "P"]) + ) .default("C"), IBKR_EXCHANGE: z.string().min(1).default("SMART"), IBKR_CURRENCY: z.string().min(1).default("USD"), @@ -395,10 +403,7 @@ const run = async () => { await ensureOptionNBBOTable(clickhouse); }); - const adapter = selectAdapter( - env.OPTIONS_INGEST_ADAPTER, - () => syntheticControl - ); + const adapter = selectAdapter(env.OPTIONS_INGEST_ADAPTER, () => syntheticControl); logger.info("ingest adapter selected", { adapter: adapter.name }); const allowPublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS); const allowNbboPublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS); @@ -421,7 +426,10 @@ const run = async () => { rawPrint.ts ); const equityQuote = parsedMetadata.underlying_id - ? selectAtOrBefore(equityQuoteHistoryByUnderlying.get(parsedMetadata.underlying_id), rawPrint.ts) + ? selectAtOrBefore( + equityQuoteHistoryByUnderlying.get(parsedMetadata.underlying_id), + rawPrint.ts + ) : null; const print = enrichOptionPrint(rawPrint, optionQuote, equityQuote, optionsSignalConfig); @@ -500,8 +508,16 @@ const run = async () => { const pruneTimer = setInterval(() => { const removed = - pruneContextHistory(nbboHistoryByContract, env.OPTION_CONTEXT_MAX_KEYS, env.OPTION_CONTEXT_TTL_MS) + - pruneContextHistory(equityQuoteHistoryByUnderlying, env.OPTION_CONTEXT_MAX_KEYS, env.OPTION_CONTEXT_TTL_MS); + pruneContextHistory( + nbboHistoryByContract, + env.OPTION_CONTEXT_MAX_KEYS, + env.OPTION_CONTEXT_TTL_MS + ) + + pruneContextHistory( + equityQuoteHistoryByUnderlying, + env.OPTION_CONTEXT_MAX_KEYS, + env.OPTION_CONTEXT_TTL_MS + ); logger.info("option context cache summary", { nbbo_context_keys: nbboHistoryByContract.size, equity_quote_context_keys: equityQuoteHistoryByUnderlying.size, diff --git a/services/refdata/src/event-calendar.ts b/services/refdata/src/event-calendar.ts index ba32599..431f784 100644 --- a/services/refdata/src/event-calendar.ts +++ b/services/refdata/src/event-calendar.ts @@ -1,6 +1,12 @@ import { mkdir } from "node:fs/promises"; -export type EventCalendarKind = "earnings" | "dividend" | "corporate_action" | "m_and_a" | "news" | "other"; +export type EventCalendarKind = + | "earnings" + | "dividend" + | "corporate_action" + | "m_and_a" + | "news" + | "other"; export type EventCalendarEntry = { underlying_id: string; @@ -56,7 +62,8 @@ const asNumber = (value: unknown): number | null => { return null; }; -const asString = (value: unknown): string | null => (typeof value === "string" && value.trim() ? value.trim() : null); +const asString = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim() : null; const parseCsvLine = (line: string): string[] => { const values: string[] = []; @@ -139,9 +146,14 @@ export const parseEventCalendarEntries = (value: unknown): EventCalendarEntry[] const record = row as Record; const underlying = asString(record.underlying_id ?? record.underlying ?? record.symbol); const eventTs = asNumber(record.event_ts ?? record.event_time ?? record.event_date); - const announcedTs = asNumber(record.announced_ts ?? record.available_ts ?? record.as_of_ts ?? record.created_ts) ?? 0; + const announcedTs = + asNumber( + record.announced_ts ?? record.available_ts ?? record.as_of_ts ?? record.created_ts + ) ?? 0; const rawKind = asString(record.event_kind ?? record.kind ?? record.type) ?? "other"; - const eventKind = EVENT_KINDS.has(rawKind as EventCalendarKind) ? (rawKind as EventCalendarKind) : "other"; + const eventKind = EVENT_KINDS.has(rawKind as EventCalendarKind) + ? (rawKind as EventCalendarKind) + : "other"; if (!underlying || eventTs === null || eventTs < 0 || announcedTs < 0) { return []; @@ -162,7 +174,9 @@ export const parseEventCalendarEntries = (value: unknown): EventCalendarEntry[] }); }; -export const createStaticEventCalendarProvider = (entries: EventCalendarEntry[]): EventCalendarProvider => { +export const createStaticEventCalendarProvider = ( + entries: EventCalendarEntry[] +): EventCalendarProvider => { const byUnderlying = new Map(); for (const entry of entries) { const key = normalizeUnderlying(entry.underlying_id); @@ -184,15 +198,20 @@ export const createStaticEventCalendarProvider = (entries: EventCalendarEntry[]) } const bucket = byUnderlying.get(key) ?? []; - const entry = bucket.find((candidate) => candidate.announced_ts <= asOfTs && candidate.event_ts >= asOfTs); + const entry = bucket.find( + (candidate) => candidate.announced_ts <= asOfTs && candidate.event_ts >= asOfTs + ); return entry ? { ...entry, days_to_event: (entry.event_ts - asOfTs) / MS_PER_DAY } : null; } }; }; -export const createEmptyEventCalendarProvider = (): EventCalendarProvider => createStaticEventCalendarProvider([]); +export const createEmptyEventCalendarProvider = (): EventCalendarProvider => + createStaticEventCalendarProvider([]); -export const loadEventCalendarProviderFromFile = async (path: string): Promise => { +export const loadEventCalendarProviderFromFile = async ( + path: string +): Promise => { const text = await Bun.file(path).text(); return createStaticEventCalendarProvider(parseEventCalendarEntries(JSON.parse(text))); }; @@ -212,7 +231,9 @@ export const fetchAlphaVantageEarningsCalendar = async ( const response = await (options.fetchFn ?? fetch)(url); const text = await response.text(); if (!response.ok) { - throw new Error(`Alpha Vantage earnings calendar request failed: ${response.status} ${text.slice(0, 160)}`); + throw new Error( + `Alpha Vantage earnings calendar request failed: ${response.status} ${text.slice(0, 160)}` + ); } if (/^(?:\s*\{|\s*Thank you for using Alpha Vantage)/i.test(text)) { throw new Error(`Alpha Vantage returned a non-calendar response: ${text.slice(0, 200)}`); @@ -221,7 +242,10 @@ export const fetchAlphaVantageEarningsCalendar = async ( return parseAlphaVantageEarningsCalendar(text, options.nowTs ?? Date.now()); }; -export const writeEventCalendarEntries = async (path: string, entries: EventCalendarEntry[]): Promise => { +export const writeEventCalendarEntries = async ( + path: string, + entries: EventCalendarEntry[] +): Promise => { const directory = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; if (directory) { await mkdir(directory, { recursive: true }); diff --git a/services/refdata/src/index.ts b/services/refdata/src/index.ts index c836adb..9c7f168 100644 --- a/services/refdata/src/index.ts +++ b/services/refdata/src/index.ts @@ -12,9 +12,14 @@ const logger = createLogger({ service }); logger.info("service starting"); -const eventCalendarPath = process.env.REFDATA_EVENT_CALENDAR_PATH ?? process.env.SMART_MONEY_EVENT_CALENDAR_PATH; -const eventCalendarProvider = process.env.REFDATA_EVENT_CALENDAR_PROVIDER ?? process.env.EVENT_CALENDAR_PROVIDER; -const refreshMs = Math.max(0, Number(process.env.REFDATA_EVENT_CALENDAR_REFRESH_MS ?? 86_400_000) || 0); +const eventCalendarPath = + process.env.REFDATA_EVENT_CALENDAR_PATH ?? process.env.SMART_MONEY_EVENT_CALENDAR_PATH; +const eventCalendarProvider = + process.env.REFDATA_EVENT_CALENDAR_PROVIDER ?? process.env.EVENT_CALENDAR_PROVIDER; +const refreshMs = Math.max( + 0, + Number(process.env.REFDATA_EVENT_CALENDAR_REFRESH_MS ?? 86_400_000) || 0 +); const getAlphaVantageOptions = (): AlphaVantageEarningsCalendarOptions | null => { const apiKey = process.env.ALPHA_VANTAGE_API_KEY; @@ -33,7 +38,9 @@ const getAlphaVantageOptions = (): AlphaVantageEarningsCalendarOptions | null => const refreshEventCalendar = async (): Promise => { if (!eventCalendarPath) { - logger.warn("event calendar refresh disabled; missing SMART_MONEY_EVENT_CALENDAR_PATH or REFDATA_EVENT_CALENDAR_PATH"); + logger.warn( + "event calendar refresh disabled; missing SMART_MONEY_EVENT_CALENDAR_PATH or REFDATA_EVENT_CALENDAR_PATH" + ); return; } if (eventCalendarProvider !== "alpha_vantage") { diff --git a/services/replay/src/index.ts b/services/replay/src/index.ts index de2d1ee..e29932a 100644 --- a/services/replay/src/index.ts +++ b/services/replay/src/index.ts @@ -52,11 +52,7 @@ type ReplayStreamKind = "options" | "nbbo" | "equities" | "equity-quotes"; type ReplayEvent = OptionPrint | OptionNBBO | EquityPrint | EquityQuote; -type FetchAfter = ( - afterTs: number, - afterSeq: number, - limit: number -) => Promise; +type FetchAfter = (afterTs: number, afterSeq: number, limit: number) => Promise; type ReplayStream = { kind: ReplayStreamKind; @@ -79,7 +75,12 @@ const STREAM_DEFS: Record< subject: string; streamName: string; rank: number; - fetchAfter: (client: ReturnType, afterTs: number, afterSeq: number, limit: number) => Promise; + fetchAfter: ( + client: ReturnType, + afterTs: number, + afterSeq: number, + limit: number + ) => Promise; } > = { options: { @@ -196,7 +197,9 @@ const getEventIngestTs = (event: ReplayEvent): number => const getEventSeq = (event: ReplayEvent): number => (Number.isFinite(event.seq) ? event.seq : 0); -const pickNextEvent = (streams: ReplayStream[]): { stream: ReplayStream; event: ReplayEvent } | null => { +const pickNextEvent = ( + streams: ReplayStream[] +): { stream: ReplayStream; event: ReplayEvent } | null => { let choice: { stream: ReplayStream; event: ReplayEvent } | null = null; for (const stream of streams) { @@ -313,7 +316,8 @@ const run = async () => { kind, subject: def.subject, streamName: def.streamName, - fetchAfter: (afterTs, afterSeq, limit) => def.fetchAfter(clickhouse, afterTs, afterSeq, limit), + fetchAfter: (afterTs, afterSeq, limit) => + def.fetchAfter(clickhouse, afterTs, afterSeq, limit), buffer: [], cursor: { ...startCursor }, done: false, diff --git a/tsconfig.base.json b/tsconfig.base.json index 34b15d2..f98f46a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,6 @@ "isolatedModules": true, "resolveJsonModule": true, "skipLibCheck": true, - "noEmit": true, - }, + "noEmit": true + } }