Compare commits

...

3 commits

Author SHA1 Message Date
philipp
2e95c95246 Persist quote lifecycle statistics
All checks were successful
deploy / deploy (push) Successful in 1m0s
Proof: Added PostgreSQL-backed quote lifecycle statistics for all-time, monthly, weekly, daily, hourly, and five-minute windows; wired authenticated dashboard API, live WebSocket counters, history-writer refresh before pruning, and dashboard controls; validated with targeted tests, full npm test, and operator dashboard build.

Assumptions: Unique quote counts are keyed by durable quote_id, quote windows use the first durable lifecycle timestamp in UTC with Monday UTC weeks, and missing identifiers or timestamps are represented as unavailable evidence instead of fabricated quote identities.

Still fake: Fee-aware PnL, cashflow, oracle-deviation, size distribution, matched-only analytics, and reconstruction of already-pruned detail remain outside this turn.
2026-06-12 20:02:09 +02:00
philipp
893617c30d Open implementation turn: Persisted quote statistics
Proof: Establish the approved persisted quote statistics turn with falsifiable rollup, API, dashboard, and validation requirements.

Assumptions: The requested statistics are the active implementation scope, and broader analytics from the old backlog item remains follow-up work.

Still fake: Planning docs only; no persisted statistics, API, dashboard, tests, deployment, or live evidence are implemented by this commit.
2026-06-12 19:45:24 +02:00
philipp
8805282f59 Archive implementation turn: Quote lifecycle investigator and missed quote follow-up
Proof: Preserve the completed implementation turn and record its outcome in the tracked archive.
Assumptions: The archived files capture the relevant planning state for the completed turn.
Still fake: Archiving does not validate the work by itself; external evidence still governs whether the result is trustworthy.
2026-06-12 19:43:16 +02:00
20 changed files with 1937 additions and 244 deletions

View file

@ -23,6 +23,7 @@ Legacy note:
- 2026-06-06: `maker-response-latency-and-quote-competitiveness` closed with status `passed`. Durable maker timing, relay failure classification, pair-native competitiveness surfaces, dashboard cleanup, and emergency quote-history retention fixes were implemented, validated, and deployed; long-term storage shape is opened as a separate turn.
- 2026-06-12: `quote-lifecycle-retention-and-analytics-rollups` closed with status `paused`. Paused after retention, dashboard, semantic, and notification fixes were deployed; the next urgent proof is removing verifier salt refresh from the quote execution hot path.
- 2026-06-12: `verifier-salt-hot-path-removal-and-executor-queue-guardrails` closed with status `passed`. Verifier current_salt RPC was removed from the quote-response hot path, cached salt failures now reject before signing or relay, queue delay and salt health are visible and alertable, and live post-deploy evidence showed cache or unavailable salt results with no new-runtime refresh rows.
- 2026-06-12: `quote-lifecycle-investigator-and-missed-quote-follow-up` closed with status `passed`. Durable quote lookup, pinned lifecycle investigator, regression tests, workflow deploy, and live lookup evidence passed on cb87910.
## Research Turns
## Planning Events
@ -45,3 +46,4 @@ Legacy note:
- 2026-06-06: opened implementation turn `quote-lifecycle-retention-and-analytics-rollups` from backlog items none.
- 2026-06-12: opened implementation turn `verifier-salt-hot-path-removal-and-executor-queue-guardrails` from backlog items none.
- 2026-06-12: opened implementation turn `quote-lifecycle-investigator-and-missed-quote-follow-up` from backlog items I012; remaining broad analytics-workbench scope was re-added as I017.
- 2026-06-12: opened implementation turn `persisted-quote-statistics-rollups-and-live-counters` from backlog items I017.

View file

@ -20,7 +20,7 @@ Rules:
- [I013] Benchmark and PnL analytics: compare trade PnL versus mark-to-market, all-BTC hold, all-EURe hold, passive 50/50 hold, and hindsight trade quality against later benchmarks.
- [I016] (soon) Treasury cashflow and fee ledger: durably record deposits, withdrawals, bridge or network costs, and other non-trade cashflows so dashboard profit can move from mark-to-market to true net PnL. tags=pnl,fees,treasury
- [I017] (soon) Remaining quote analytics workbench beyond lifecycle investigation: quote count and volume windows, size-bucket distributions, oracle-deviation views, matched-only filters, and broader execution analytics. tags=analytics,dashboard,quotes
- [I017] (soon) Remaining quote analytics workbench beyond persisted state rollups: volume windows, size-bucket distributions, oracle-deviation views, matched-only filters, and broader execution analytics. tags=analytics,dashboard,quotes
## Research Candidates
- [R001] Compare Kraken and CoinGecko drift and freshness for the assets needed to price the active pair.
- [R002] Test whether the active pair's implied rate diverges from external reference prices enough to justify execution after a simple 2% gross threshold.

View file

@ -1,190 +1,95 @@
# Implementation Turn: Quote lifecycle investigator and missed quote follow-up
# Implementation Turn: Persisted quote statistics rollups and live counters
Status: open
Opened: 2026-06-12
## Goal
Improve the dashboard Quote lifecycle view so operators can pin, search, and follow missed or pending quotes from durable evidence even after live rows move on.
Persist durable quote lifecycle statistics for 5-minute, hourly, daily, weekly, monthly, and all-time windows so operators can see unique quote totals and outcome-state counts from the same history used by live lifecycle evidence.
## Selected Backlog Items
- [I012] Quotes and execution analytics workbench: live quote tape, quote count and volume windows, size-bucket distributions, oracle-deviation views, per-quote decision and execution trace, and matched-only filters.
## Selected backlog items
- [I017] Persisted quote-state rollups only. The broader quote analytics workbench scope from the original backlog item has been re-added to backlog for a later turn.
This turn intentionally implements only the per-quote lifecycle investigation and missed-quote follow-up part of I012. Remaining analytics-workbench work has been re-added to backlog as follow-on scope.
## Constraints
- Preserve trading behavior: no strategy, pricing, threshold, notional, inventory, arming, signer, pair enablement, quote skip, or relay policy changes.
- Do not add active pair, edge, notional, latency, response-policy, or retention env vars.
- Do not introduce a new storage system or retention policy.
- Preserve existing lifecycle and executor result rows as readable.
- Use repo workflow for deployment only; no manual kubectl rollout or image patching.
- Add regression tests for every bug fix or behavior contract introduced here.
## Current Shape To Inspect
- `src/operator-dashboard/static/pages/StrategyPage.jsx`
- `QuoteLifecycleSection`
- `QuoteLifecycleTable`
- `LifecycleDetails`
- pause/filter/expanded-row behavior
- `src/operator-dashboard/static/state/dashboardReducer.js`
- `quote_lifecycle.updated` handling
- `src/core/operator-dashboard.mjs`
- `deriveQuoteLifecycleRows`
- `buildLiveQuoteLifecycleRows`
- `buildStrategySummary`
- lifecycle row normalization/enrichment
- `src/lib/postgres.mjs`
- recent quote, decision, command, execution result, and outcome loaders
- `src/apps/operator-dashboard.mjs`
- bootstrap data loading
- REST API routes
- WebSocket update path
- Existing dashboard/static tests under `test/`.
## Hard Constraints
- Do not change strategy selection, pricing, edge thresholds, notional limits, inventory checks, arming, signer identity, pair enablement, executor behavior, relay behavior, or quote skipping behavior.
- Do not add active pair, edge, notional, latency, response-policy, or retention environment variables.
- Do not add a storage system or retention policy. Use existing PostgreSQL history and repo-owned workflow.
- Preserve old lifecycle and executor-result rows as readable; missing fields become `unavailable`.
- Bug fixes require regression tests.
- No manual `kubectl` rollout, image patching, or cluster repair. Deploy only through the repo workflow.
- Do not stage or revert unrelated dirty worktree files.
## Implementation Plan
### 1. Add a durable lifecycle lookup contract
- Add a backend loader that can find lifecycle evidence by one identifier:
- `quote_id`
- `decision_id`
- `command_id`
- Reuse existing durable tables and normalizers:
- `swap_demand_events`
- `trade_decisions`
- `execute_trade_commands`
- `trade_execution_results`
- quote outcomes table
- Return the same normalized lifecycle row shape produced by `deriveQuoteLifecycleRows`, enriched for UI in the same way as recent rows.
- Include lookup metadata:
- requested identifier
- matched identifier type
- found/not found
- lookup timestamp
- latest stage timestamp
- If no row is found, return a structured not-found response instead of a dashboard error.
- Make missing old fields render as `null`/`Unavailable`; do not require new timing fields to exist.
### 1. Inspect Current Paths
- Read the current quote lifecycle row derivation, durable lookup, reducer, dashboard routes, static dashboard components, existing rollup or retention loaders, migrations/schema setup, and related tests.
- Confirm whether an existing quote lifecycle rollup table can store the requested state buckets. Prefer extending existing rollup machinery when it is compatible; otherwise add one narrow PostgreSQL aggregate table dedicated to quote lifecycle statistics.
### 2. Expose a dashboard API endpoint
- Add an authenticated operator-dashboard route for lifecycle lookup, for example:
- `GET /api/strategy/quote-lifecycle/:identifier`
- The endpoint should:
- validate a non-empty identifier
- call the durable lookup loader
- return `{ ok, lifecycle_row, lookup }`
- return a 404-style payload for not found without crashing the dashboard
- Do not expose secrets or raw relay payloads beyond what existing lifecycle rows already show.
### 2. Classification Contract
- Build a reusable lifecycle statistics classifier on normalized durable lifecycle rows.
- Assign each quote to one latest state bucket per recompute:
- `success`: durable completed/success result or settlement evidence exists
- `not_filled`: durable terminal not-filled or expired outcome exists
- `submission_failed`: executor or relay submission failed
- `submitted_no_reply`: command was submitted or relay-accepted, but no durable terminal reply is known
- `awaiting_executor`: strategy approved and command/executor evidence is still missing
- `blocked_before_submit`: executor or safety gate blocked before venue submission
- `rejected_by_strategy`: strategy rejected the quote before execution
- `unavailable`: old, partial, or malformed evidence cannot be classified safely
- Terminal states must take precedence over intermediate states when multiple history rows exist for the same quote.
- Count distinct `quote_id` values. Track missing quote identifiers separately as unavailable evidence; do not fabricate identifiers.
- Use the quote's first durable lifecycle timestamp as its window timestamp. If the first timestamp is missing, use the earliest available durable stage timestamp and mark timestamp provenance as unavailable in testable code.
### 3. Improve frontend state and interaction model
- Replace the current "pause solves investigation" behavior with a selected quote investigator:
- row action: inspect or pin quote
- selected row highlight
- stable investigator panel above the table or directly below controls
- selected quote remains visible even when filters change or live rows move
- Keep pause as table-only:
- paused table keeps row order and row contents stable
- selected investigator can still refresh from durable lookup
- resume table applies latest rolling rows without clearing the selected quote
- Add identifier lookup:
- search input for quote id, decision id, or command id
- loading, found, not-found, and error states
- copyable identifiers remain available
- Add problem-state filters without changing lifecycle data:
- all
- awaiting executor
- approved/no command
- blocked before submit
- submission failed
- quote not found or finished
- strategy rejected
- submitted/awaiting outcome
- Ensure filters do not hide or clear the selected investigator.
### 3. Persistence
- Add or extend a PostgreSQL-backed persisted rollup with:
- grain: `all_time`, `month`, `week`, `day`, `hour`, `five_minute`
- UTC `window_start` and `window_end` for windowed grains
- unique quote total
- missing identifier count
- state bucket counts
- computed timestamp
- Keep the storage idempotent with a deterministic key per grain/window and upsert semantics.
- Recompute from existing durable history rather than appending event deltas that can drift.
- Wire recomputation into the existing lifecycle history or dashboard maintenance path so live systems persist updated stats without requiring manual scripts.
- Do not introduce new retention knobs or background services unless an existing repo-owned process already performs the relevant maintenance.
### 4. Make `Awaiting executor` explain the next step
- In the investigator panel, show a concise stage status:
- quote observed
- strategy decision
- command recorded or missing
- executor result recorded or missing
- relay/outcome status when present
- For `command_emitted` / `Awaiting executor` rows:
- show command age using the current clock
- show the command id and command timestamp
- show "waiting for executor result" only when no result is found
- if a later lookup finds an executor result, show the terminal status instead of stale awaiting text
- For relay failures, make `quote_not_found_or_finished` visible as a first-class reason.
### 4. Dashboard API
- Add an authenticated operator-dashboard endpoint for quote lifecycle statistics.
- Return all-time totals plus recent windows for the requested grains. Clamp limits server-side and preserve missing or unavailable fields in the response.
- Include enough metadata for the UI to label UTC windows and live/update freshness without implying unavailable truth is known.
### 5. Keep live updates and lookup refresh coherent
- When `quote_lifecycle.updated` arrives:
- update the rolling table as it does today
- if the selected quote appears in the live payload, refresh the selected panel from that row immediately
- if the selected quote is absent from the live payload, schedule or expose a durable lookup refresh for the selected identifier
- Avoid unbounded request loops:
- debounce lookup refreshes client-side
- do not add new env vars for refresh timing
- keep manual refresh available
- Show whether selected evidence came from:
- current live rows
- durable lookup
- not found
- lookup error
### 5. Dashboard UI
- Add a statistics surface to the Quote lifecycle page or its existing operator context.
- Provide controls for `5m`, `hour`, `day`, `week`, `month`, and `all-time`.
- Display unique quote totals and state bucket counts for the selected grain.
- Show live counters for current windows and all-time totals that update from the live dashboard stream.
- Pausing the recent lifecycle table must not freeze the statistics surface or live counters.
- Use existing component, style, and reducer patterns; keep controls dense and operational rather than marketing-style.
### 6. Preserve layout quality
- Keep the lifecycle table full-width and scan-friendly.
- Avoid large marketing-style or decorative redesign.
- Keep row heights stable with existing fixed row slots.
- Ensure long quote ids and reason text truncate or wrap intentionally.
- Do not nest cards inside cards; use a panel/detail layout consistent with the existing dashboard.
- Keep old rows with missing fields readable on desktop and mobile widths.
### 6. Tests
- Add focused classifier/window tests for state precedence, UTC boundaries, Monday UTC weeks, missing IDs, missing timestamps, and old-field compatibility.
- Add persistence tests proving idempotent upsert/recompute and all-time totals.
- Add route tests proving authentication and persisted response shape.
- Add reducer/UI tests proving live counters update while the recent table is paused.
- Add regression coverage for a quote that begins as `submitted_no_reply` or `awaiting_executor`, leaves the live recent table, and later refreshes into `submission_failed`, `not_filled`, or `success` through durable history.
### 7. Tests
- Add or update core tests for lifecycle lookup and row derivation:
- command-only row classifies as `Awaiting executor`
- later executor result for the same quote updates the lifecycle state
- old rows missing timing fields normalize safely
- Add backend API tests or static route tests:
- lookup by quote id
- lookup by decision id
- lookup by command id
- not-found response
- Add frontend/static tests proving:
- Quote lifecycle has a selected/pinned investigator
- pause language is table-specific
- identifier lookup controls exist
- problem-state filters include awaiting executor and quote-not-found/finished
- selected investigation is not represented only by expanded table rows
- Add a regression test for the described bug:
- start with a selected `Awaiting executor` quote
- update the displayed recent list so that quote is absent
- prove the selected quote detail remains available or can be refreshed from durable lookup
### 7. Validation And Deployment
- Run targeted tests covering the changed backend and dashboard surfaces.
- Run full `npm test`.
- Build the dashboard bundle.
- Commit with required `Proof:`, `Assumptions:`, and `Still fake:` body.
- Push only after tests and build pass.
- Let the repo workflow deploy the new image.
- Collect live evidence:
- deployed image digest or tag includes this commit
- lifecycle live rows still flow
- statistics endpoint returns real persisted rows
- live counters move without requiring the quote to remain visible in the rolling table
### 8. Validation Commands
- Run targeted tests first, likely:
- `node --test test/operator-dashboard*.test.mjs test/*lifecycle*.test.mjs`
- add narrower filenames as tests are introduced
- Run full:
- `npm test`
- Build dashboard bundle:
- `npm run operator-dashboard:build`
## Assumptions To Verify While Coding
- Existing lifecycle history has a stable enough `quote_id` for unique quote counting. Rows without one are counted as unavailable evidence, not invented unique quotes.
- Existing durable history has enough timestamps to assign quotes to UTC windows. Missing timestamps remain unavailable rather than blocking all statistics.
- Current lifecycle lookup and row derivation already encode most state distinctions; the statistics classifier should reuse that truth instead of creating a competing interpretation.
### 9. Commit, deploy, and evidence
- Commit implementation with required body:
- `Proof: ...`
- `Assumptions: ...`
- `Still fake: ...`
- Push only if tests and dashboard build pass.
- Let repo workflow deploy; do not manually patch deployments.
- Collect live evidence after deploy:
- deployments on the new image
- dashboard still receives live lifecycle updates
- durable lookup returns at least one recent quote lifecycle row
- an unresolved or missed quote can be inspected by identifier without relying on it being visible in the rolling table
- selected quote UI does not clear on live row movement
## Assumptions
- Existing durable quote, decision, command, execution result, and outcome rows are sufficient to reconstruct a single quote lifecycle.
- The dashboard can add a small authenticated read endpoint without changing service topology.
- Client-side debounce plus manual refresh is enough to keep pinned quote investigation fresh without adding a new response-policy or retention setting.
## Still Fake
- Venue-native terminal fill truth remains incomplete where outcome rows only infer not-filled or settlement through existing attribution.
- Fee-aware realized PnL and broader quote analytics workbench views remain out of scope.
- Historical detail that has already been pruned cannot be reconstructed beyond existing rollups.
## Still Fake Or Deferred
- Fee-aware PnL, treasury cashflow, benchmark markout, oracle-deviation, size distribution, and matched-only analytics are not part of this turn.
- Statistics can only be as complete as the already-retained quote lifecycle/history tables. Already-pruned detail cannot be reconstructed.

124
PROOF.md
View file

@ -1,80 +1,74 @@
# Implementation Proof: Quote lifecycle investigator and missed quote follow-up
# Implementation Proof: Persisted quote statistics rollups and live counters
Status: open
Opened: 2026-06-12
## Hypothesis
If the operator dashboard turns the quote lifecycle table into a durable quote investigator, an operator can pause or filter the live quote list, select an `Awaiting executor` or otherwise missed quote, and continue following that exact quote through command, executor result, relay failure, outcome, or unresolved state even after the rolling live table moves on.
## Problem Evidence
- The current Quote lifecycle page is a rolling live table of recent rows.
- Pausing freezes the displayed table snapshot, including stale `Awaiting executor` evidence.
- When live updates resume, the quote can leave the recent window and become hard to find again.
- The existing expandable row explains the lifecycle only while the row remains in the rendered recent set.
- This prevents practical investigation of missed quotes such as commands that later become `quote_not_found_or_finished`, `verifier_salt_unavailable`, stale command rejects, or successful submissions.
Persisted quote lifecycle statistics over 5-minute, hourly, daily, weekly, monthly, and all-time windows will let the operator answer "how many quotes did we see and where did they end up?" without relying on the rolling dashboard table or browser memory.
## Scope
- Improve the existing `Quote lifecycle` dashboard view, not a new dashboard product area.
- Add a durable quote lookup path by `quote_id`, `decision_id`, or `command_id` using existing history tables.
- Add a selected or pinned quote investigator that remains visible independently from the rolling table rows.
- Make pause freeze table ordering only; the selected quote investigation must still refresh from durable evidence.
- Add search or lookup controls so a quote can be found again after it leaves the recent table.
- Add filters for investigation states that matter to missed quotes:
- awaiting executor
- approved but command missing
- blocked before submit
- submission failed
- quote not found or finished
- strategy rejected
- submitted or awaiting outcome
- Improve the selected quote detail so `Awaiting executor` clearly shows:
- command id and command timestamp
- elapsed time since command
- whether an executor result is now stored
- relay result or block reason if present
- next expected stage when still unresolved
- Preserve old executor result and lifecycle rows as readable when newer timing or result fields are missing.
- Keep the implementation read-side and dashboard-only unless a small shared lifecycle normalizer change is required.
- This turn implements the persisted quote-state rollup part of the selected analytics backlog item. The broader analytics workbench work, including quote volume distribution, size buckets, oracle-deviation views, matched-only filters, and broader execution analytics, remains backlog follow-up scope.
- Use the existing durable quote lifecycle/history data and existing PostgreSQL storage. Do not add a new storage system, retention policy, or long-running external job outside the repo-owned workflow.
- Persist aggregates for these grains:
- `all_time`
- `month`
- `week`
- `day`
- `hour`
- `five_minute`
- A quote is assigned to a time-window by its first observed durable lifecycle timestamp in UTC. Weekly windows start on Monday UTC. The quote's state bucket may refresh later when new durable executor, relay, or settlement evidence arrives.
- Count unique quotes by `quote_id` when present. Rows without a durable quote identifier must remain visible as `unavailable` or `missing_identifier_count`; the implementation must not invent fake unique identifiers to inflate quote totals.
- Persist and expose at least these state buckets:
- `rejected_by_strategy`
- `awaiting_executor`
- `submission_failed`
- `submitted_no_reply`
- `success`
- `not_filled`
- `blocked_before_submit`
- `unavailable`
- Preserve old lifecycle and executor-result rows. Missing fields should count as unavailable instead of crashing or silently disappearing.
- Expose authenticated dashboard access to the persisted statistics and render them in the operator dashboard with live counts that continue updating independently of any paused recent lifecycle table.
## Non-goals
- Do not change strategy selection, pricing, edge thresholds, notional limits, inventory checks, arming, signer identity, or pair enablement.
- Do not skip quotes or alter executor/relay response behavior.
- Do not change strategy selection, pricing, edge thresholds, notional limits, inventory checks, arming, signer identity, pair enablement, executor behavior, relay behavior, or quote skipping behavior.
- Do not add active pair, edge, notional, latency, response-policy, or retention environment variables.
- Do not add a new retention policy or new long-term analytics store in this turn.
- Do not build the full remaining analytics workbench: quote volume windows, size-bucket distributions, oracle-deviation views, and matched-only PnL analytics remain backlog work.
- Do not claim submitted quote responses are completed trades unless durable outcome and settlement evidence support that claim.
- Do not use manual kubectl rollout, image patching, or ad hoc production repair.
- Do not add PnL, fee-aware accounting, treasury cashflow, oracle-deviation analysis, size-bucket analytics, matched-only filters, or strategy tuning in this turn.
- Do not manually patch images, roll deployments, or repair the cluster outside the repo workflow.
- Do not rewrite `THESIS.md`.
## Definition of Done
- The Quote lifecycle page has a stable selected or pinned quote investigator separate from the rolling table rows.
- Selecting an `Awaiting executor` row lets the operator continue following that quote after the table is paused, resumed, filtered, or updated.
- A selected quote can be refreshed or auto-refreshed from durable evidence even when it is no longer present in `recent_lifecycle_rows`.
- Lookup by `quote_id`, `decision_id`, or `command_id` returns the same normalized lifecycle shape used by the table, including stage timestamps, reason text, timing, command, execution result, and outcome fields when present.
- Problem-state filters make missed quote rows easy to isolate without hiding the selected quote investigation.
- Rows with missing old fields render as unavailable instead of crashing or disappearing.
- Bug regression coverage proves:
- a selected quote remains inspectable after it leaves the recent live list
- an `Awaiting executor` row can later refresh into a terminal executor or relay result
- lookup by quote, decision, and command identifiers uses durable evidence and does not rely only on the current client-side recent rows
- Targeted dashboard tests pass.
- Full `npm test` passes.
- The operator dashboard bundle builds.
- The planning and implementation commits include `Proof:`, `Assumptions:`, and `Still fake:`.
- If tests pass, deploy only through the repo push workflow and collect live evidence:
- repo-owned deployments are on the new image
- Quote lifecycle still receives live rows
- at least one durable quote lookup returns a lifecycle row
- a missed or pending quote can be inspected by identifier after it is not relied on as a visible recent row
- Existing quote lifecycle/history tables can be rolled up into persisted statistics for all requested grains and all-time totals.
- Aggregation is idempotent: recomputing the same history does not double-count quotes or duplicate windows.
- Each counted quote lands in exactly one state bucket per grain window, with terminal evidence taking precedence over intermediate states.
- The dashboard API returns authenticated persisted statistics, including all-time totals and recent windows.
- The dashboard UI shows the statistics, has controls for the requested grains, and live counts keep changing while the recent quote table is paused.
- Regression tests prove:
- unique quote dedupe across lifecycle, decision, command, executor, and relay evidence
- `submitted_no_reply` does not become `success` without durable success/settlement evidence
- a later executor or relay result refreshes the persisted bucket for the quote's original time window
- old or missing fields are represented as unavailable rather than throwing or hiding rows
- all requested window boundaries are UTC and weekly windows start Monday UTC
- all-time totals match the same classified quote set as the windowed stats
- Run targeted tests, full `npm test`, and build the dashboard bundle.
- Commit with `Proof:`, `Assumptions:`, and `Still fake:` in the commit body.
- Push and deploy only after tests pass, then collect live evidence for:
- new image from the repo workflow
- quote lifecycle live rows still flowing
- persisted statistics endpoint returning real rows
- live dashboard counts moving without relying on recent-table visibility
## Failure Conditions
- Pause still freezes the only available evidence for a selected quote.
- A selected quote cannot be found once it leaves the rolling recent table.
- The lookup path searches only the browser's current rows instead of durable history.
- The UI improves labels but still cannot answer what happened after `Awaiting executor`.
- Missed-quote filters hide the selected investigation or make old rows unreadable.
- The implementation changes trading behavior or strategy economics.
- No regression test covers the row-follow-up bug.
- Statistics are derived only from browser state, recent WebSocket rows, or mock fixtures.
- A single quote can be counted more than once in the same grain window.
- Submitted-but-unanswered quotes are mislabeled as successful.
- Missing old fields crash aggregation or make rows vanish.
- The implementation changes trading behavior, execution behavior, pair enablement, thresholds, notional limits, inventory safety, or arming behavior.
- Deployment requires manual cluster image patching or ad hoc rollout intervention.
## Real Versus Fake
- Real in scope: dashboard investigation backed by durable quote, decision, command, executor result, and outcome rows already stored by repo-owned services.
- Still fake or incomplete: venue-native final fill truth, fee-aware realized PnL, broader quote analytics workbench, and historical aggregate drilldowns beyond the existing retained lifecycle detail and rollups.
## What Is Real
- The proof is about durable aggregate truth from the same stored quote lifecycle evidence used by operator investigation and live lifecycle rows.
## Still Fake Or Out Of Scope
- Final venue-native fill truth remains limited to the durable evidence already present in the system.
- Fee-aware PnL, cashflow, benchmark markouts, quote size distributions, oracle-deviation analysis, and matched-only analytics remain future turns.

View file

@ -0,0 +1,190 @@
# Implementation Turn: Quote lifecycle investigator and missed quote follow-up
Status: open
Opened: 2026-06-12
## Goal
Improve the dashboard Quote lifecycle view so operators can pin, search, and follow missed or pending quotes from durable evidence even after live rows move on.
## Selected Backlog Items
- [I012] Quotes and execution analytics workbench: live quote tape, quote count and volume windows, size-bucket distributions, oracle-deviation views, per-quote decision and execution trace, and matched-only filters.
This turn intentionally implements only the per-quote lifecycle investigation and missed-quote follow-up part of I012. Remaining analytics-workbench work has been re-added to backlog as follow-on scope.
## Constraints
- Preserve trading behavior: no strategy, pricing, threshold, notional, inventory, arming, signer, pair enablement, quote skip, or relay policy changes.
- Do not add active pair, edge, notional, latency, response-policy, or retention env vars.
- Do not introduce a new storage system or retention policy.
- Preserve existing lifecycle and executor result rows as readable.
- Use repo workflow for deployment only; no manual kubectl rollout or image patching.
- Add regression tests for every bug fix or behavior contract introduced here.
## Current Shape To Inspect
- `src/operator-dashboard/static/pages/StrategyPage.jsx`
- `QuoteLifecycleSection`
- `QuoteLifecycleTable`
- `LifecycleDetails`
- pause/filter/expanded-row behavior
- `src/operator-dashboard/static/state/dashboardReducer.js`
- `quote_lifecycle.updated` handling
- `src/core/operator-dashboard.mjs`
- `deriveQuoteLifecycleRows`
- `buildLiveQuoteLifecycleRows`
- `buildStrategySummary`
- lifecycle row normalization/enrichment
- `src/lib/postgres.mjs`
- recent quote, decision, command, execution result, and outcome loaders
- `src/apps/operator-dashboard.mjs`
- bootstrap data loading
- REST API routes
- WebSocket update path
- Existing dashboard/static tests under `test/`.
## Implementation Plan
### 1. Add a durable lifecycle lookup contract
- Add a backend loader that can find lifecycle evidence by one identifier:
- `quote_id`
- `decision_id`
- `command_id`
- Reuse existing durable tables and normalizers:
- `swap_demand_events`
- `trade_decisions`
- `execute_trade_commands`
- `trade_execution_results`
- quote outcomes table
- Return the same normalized lifecycle row shape produced by `deriveQuoteLifecycleRows`, enriched for UI in the same way as recent rows.
- Include lookup metadata:
- requested identifier
- matched identifier type
- found/not found
- lookup timestamp
- latest stage timestamp
- If no row is found, return a structured not-found response instead of a dashboard error.
- Make missing old fields render as `null`/`Unavailable`; do not require new timing fields to exist.
### 2. Expose a dashboard API endpoint
- Add an authenticated operator-dashboard route for lifecycle lookup, for example:
- `GET /api/strategy/quote-lifecycle/:identifier`
- The endpoint should:
- validate a non-empty identifier
- call the durable lookup loader
- return `{ ok, lifecycle_row, lookup }`
- return a 404-style payload for not found without crashing the dashboard
- Do not expose secrets or raw relay payloads beyond what existing lifecycle rows already show.
### 3. Improve frontend state and interaction model
- Replace the current "pause solves investigation" behavior with a selected quote investigator:
- row action: inspect or pin quote
- selected row highlight
- stable investigator panel above the table or directly below controls
- selected quote remains visible even when filters change or live rows move
- Keep pause as table-only:
- paused table keeps row order and row contents stable
- selected investigator can still refresh from durable lookup
- resume table applies latest rolling rows without clearing the selected quote
- Add identifier lookup:
- search input for quote id, decision id, or command id
- loading, found, not-found, and error states
- copyable identifiers remain available
- Add problem-state filters without changing lifecycle data:
- all
- awaiting executor
- approved/no command
- blocked before submit
- submission failed
- quote not found or finished
- strategy rejected
- submitted/awaiting outcome
- Ensure filters do not hide or clear the selected investigator.
### 4. Make `Awaiting executor` explain the next step
- In the investigator panel, show a concise stage status:
- quote observed
- strategy decision
- command recorded or missing
- executor result recorded or missing
- relay/outcome status when present
- For `command_emitted` / `Awaiting executor` rows:
- show command age using the current clock
- show the command id and command timestamp
- show "waiting for executor result" only when no result is found
- if a later lookup finds an executor result, show the terminal status instead of stale awaiting text
- For relay failures, make `quote_not_found_or_finished` visible as a first-class reason.
### 5. Keep live updates and lookup refresh coherent
- When `quote_lifecycle.updated` arrives:
- update the rolling table as it does today
- if the selected quote appears in the live payload, refresh the selected panel from that row immediately
- if the selected quote is absent from the live payload, schedule or expose a durable lookup refresh for the selected identifier
- Avoid unbounded request loops:
- debounce lookup refreshes client-side
- do not add new env vars for refresh timing
- keep manual refresh available
- Show whether selected evidence came from:
- current live rows
- durable lookup
- not found
- lookup error
### 6. Preserve layout quality
- Keep the lifecycle table full-width and scan-friendly.
- Avoid large marketing-style or decorative redesign.
- Keep row heights stable with existing fixed row slots.
- Ensure long quote ids and reason text truncate or wrap intentionally.
- Do not nest cards inside cards; use a panel/detail layout consistent with the existing dashboard.
- Keep old rows with missing fields readable on desktop and mobile widths.
### 7. Tests
- Add or update core tests for lifecycle lookup and row derivation:
- command-only row classifies as `Awaiting executor`
- later executor result for the same quote updates the lifecycle state
- old rows missing timing fields normalize safely
- Add backend API tests or static route tests:
- lookup by quote id
- lookup by decision id
- lookup by command id
- not-found response
- Add frontend/static tests proving:
- Quote lifecycle has a selected/pinned investigator
- pause language is table-specific
- identifier lookup controls exist
- problem-state filters include awaiting executor and quote-not-found/finished
- selected investigation is not represented only by expanded table rows
- Add a regression test for the described bug:
- start with a selected `Awaiting executor` quote
- update the displayed recent list so that quote is absent
- prove the selected quote detail remains available or can be refreshed from durable lookup
### 8. Validation Commands
- Run targeted tests first, likely:
- `node --test test/operator-dashboard*.test.mjs test/*lifecycle*.test.mjs`
- add narrower filenames as tests are introduced
- Run full:
- `npm test`
- Build dashboard bundle:
- `npm run operator-dashboard:build`
### 9. Commit, deploy, and evidence
- Commit implementation with required body:
- `Proof: ...`
- `Assumptions: ...`
- `Still fake: ...`
- Push only if tests and dashboard build pass.
- Let repo workflow deploy; do not manually patch deployments.
- Collect live evidence after deploy:
- deployments on the new image
- dashboard still receives live lifecycle updates
- durable lookup returns at least one recent quote lifecycle row
- an unresolved or missed quote can be inspected by identifier without relying on it being visible in the rolling table
- selected quote UI does not clear on live row movement
## Assumptions
- Existing durable quote, decision, command, execution result, and outcome rows are sufficient to reconstruct a single quote lifecycle.
- The dashboard can add a small authenticated read endpoint without changing service topology.
- Client-side debounce plus manual refresh is enough to keep pinned quote investigation fresh without adding a new response-policy or retention setting.
## Still Fake
- Venue-native terminal fill truth remains incomplete where outcome rows only infer not-filled or settlement through existing attribution.
- Fee-aware realized PnL and broader quote analytics workbench views remain out of scope.
- Historical detail that has already been pruned cannot be reconstructed beyond existing rollups.

View file

@ -0,0 +1,80 @@
# Implementation Proof: Quote lifecycle investigator and missed quote follow-up
Status: open
Opened: 2026-06-12
## Hypothesis
If the operator dashboard turns the quote lifecycle table into a durable quote investigator, an operator can pause or filter the live quote list, select an `Awaiting executor` or otherwise missed quote, and continue following that exact quote through command, executor result, relay failure, outcome, or unresolved state even after the rolling live table moves on.
## Problem Evidence
- The current Quote lifecycle page is a rolling live table of recent rows.
- Pausing freezes the displayed table snapshot, including stale `Awaiting executor` evidence.
- When live updates resume, the quote can leave the recent window and become hard to find again.
- The existing expandable row explains the lifecycle only while the row remains in the rendered recent set.
- This prevents practical investigation of missed quotes such as commands that later become `quote_not_found_or_finished`, `verifier_salt_unavailable`, stale command rejects, or successful submissions.
## Scope
- Improve the existing `Quote lifecycle` dashboard view, not a new dashboard product area.
- Add a durable quote lookup path by `quote_id`, `decision_id`, or `command_id` using existing history tables.
- Add a selected or pinned quote investigator that remains visible independently from the rolling table rows.
- Make pause freeze table ordering only; the selected quote investigation must still refresh from durable evidence.
- Add search or lookup controls so a quote can be found again after it leaves the recent table.
- Add filters for investigation states that matter to missed quotes:
- awaiting executor
- approved but command missing
- blocked before submit
- submission failed
- quote not found or finished
- strategy rejected
- submitted or awaiting outcome
- Improve the selected quote detail so `Awaiting executor` clearly shows:
- command id and command timestamp
- elapsed time since command
- whether an executor result is now stored
- relay result or block reason if present
- next expected stage when still unresolved
- Preserve old executor result and lifecycle rows as readable when newer timing or result fields are missing.
- Keep the implementation read-side and dashboard-only unless a small shared lifecycle normalizer change is required.
## Non-goals
- Do not change strategy selection, pricing, edge thresholds, notional limits, inventory checks, arming, signer identity, or pair enablement.
- Do not skip quotes or alter executor/relay response behavior.
- Do not add active pair, edge, notional, latency, response-policy, or retention environment variables.
- Do not add a new retention policy or new long-term analytics store in this turn.
- Do not build the full remaining analytics workbench: quote volume windows, size-bucket distributions, oracle-deviation views, and matched-only PnL analytics remain backlog work.
- Do not claim submitted quote responses are completed trades unless durable outcome and settlement evidence support that claim.
- Do not use manual kubectl rollout, image patching, or ad hoc production repair.
## Definition of Done
- The Quote lifecycle page has a stable selected or pinned quote investigator separate from the rolling table rows.
- Selecting an `Awaiting executor` row lets the operator continue following that quote after the table is paused, resumed, filtered, or updated.
- A selected quote can be refreshed or auto-refreshed from durable evidence even when it is no longer present in `recent_lifecycle_rows`.
- Lookup by `quote_id`, `decision_id`, or `command_id` returns the same normalized lifecycle shape used by the table, including stage timestamps, reason text, timing, command, execution result, and outcome fields when present.
- Problem-state filters make missed quote rows easy to isolate without hiding the selected quote investigation.
- Rows with missing old fields render as unavailable instead of crashing or disappearing.
- Bug regression coverage proves:
- a selected quote remains inspectable after it leaves the recent live list
- an `Awaiting executor` row can later refresh into a terminal executor or relay result
- lookup by quote, decision, and command identifiers uses durable evidence and does not rely only on the current client-side recent rows
- Targeted dashboard tests pass.
- Full `npm test` passes.
- The operator dashboard bundle builds.
- The planning and implementation commits include `Proof:`, `Assumptions:`, and `Still fake:`.
- If tests pass, deploy only through the repo push workflow and collect live evidence:
- repo-owned deployments are on the new image
- Quote lifecycle still receives live rows
- at least one durable quote lookup returns a lifecycle row
- a missed or pending quote can be inspected by identifier after it is not relied on as a visible recent row
## Failure Conditions
- Pause still freezes the only available evidence for a selected quote.
- A selected quote cannot be found once it leaves the rolling recent table.
- The lookup path searches only the browser's current rows instead of durable history.
- The UI improves labels but still cannot answer what happened after `Awaiting executor`.
- Missed-quote filters hide the selected investigation or make old rows unreadable.
- The implementation changes trading behavior or strategy economics.
- No regression test covers the row-follow-up bug.
## Real Versus Fake
- Real in scope: dashboard investigation backed by durable quote, decision, command, executor result, and outcome rows already stored by repo-owned services.
- Still fake or incomplete: venue-native final fill truth, fee-aware realized PnL, broader quote analytics workbench, and historical aggregate drilldowns beyond the existing retained lifecycle detail and rollups.

View file

@ -28,6 +28,7 @@ import {
loadLatestPortfolioMetric,
loadPortfolioMetricInputs,
refreshIntentRequestOutcomes,
refreshQuoteLifecycleStatistics,
refreshQuoteOutcomes,
runQuoteLifecycleRetentionMaintenance,
seedTradingConfig,
@ -202,6 +203,10 @@ const state = {
last_quote_lifecycle_rollup_at: null,
quote_lifecycle_rollup_count: 0,
quote_lifecycle_rollup_error: null,
last_quote_lifecycle_statistics_at: null,
quote_lifecycle_statistics_count: 0,
quote_lifecycle_statistics_error: null,
latest_quote_lifecycle_statistics: null,
last_retention_maintenance_at: null,
retention_maintenance: null,
retention_summary: null,
@ -233,6 +238,9 @@ await refreshIntentRequestOutcomeAttributions().then((records) => publishIntentR
await refreshQuoteLifecycleRetentionState().catch((error) => {
state.retention_error = serializeError(error);
});
await refreshQuoteLifecycleStatisticsState({ persist: true }).catch((error) => {
state.quote_lifecycle_statistics_error = serializeError(error);
});
let quoteLifecycleRetentionMaintenanceInFlight = null;
const quoteLifecycleRetentionMaintenanceTimer = setInterval(() => {
@ -340,9 +348,15 @@ async function maybeRunQuoteLifecycleRetentionMaintenance({ force = false } = {}
quoteLifecycleRetentionMaintenanceInFlight = (async () => {
try {
const result = await runQuoteLifecycleRetentionMaintenance(pool, {
now: new Date(nowMs).toISOString(),
const nowIso = new Date(nowMs).toISOString();
const statisticsResult = await refreshQuoteLifecycleStatisticsState({
now: nowIso,
persist: true,
});
const result = await runQuoteLifecycleRetentionMaintenance(pool, {
now: nowIso,
});
result.quote_lifecycle_statistics = statisticsResult;
applyRetentionMaintenanceResult(result);
await refreshQuoteLifecycleRetentionState();
if (result.status === 'success' && Number(result.pruned_counts?.deletedCount || 0) > 0) {
@ -419,6 +433,33 @@ async function refreshQuoteLifecycleRetentionState() {
return summary;
}
async function refreshQuoteLifecycleStatisticsState({
now = new Date().toISOString(),
persist = false,
} = {}) {
let result = null;
try {
result = persist
? await refreshQuoteLifecycleStatistics(pool, { now })
: null;
} catch (error) {
state.quote_lifecycle_statistics_error = serializeError(error);
throw error;
}
if (!result) return state.latest_quote_lifecycle_statistics;
state.last_quote_lifecycle_statistics_at = result.computed_at;
state.quote_lifecycle_statistics_count = result.statistic_row_count;
state.quote_lifecycle_statistics_error = null;
state.latest_quote_lifecycle_statistics = {
computed_at: result.computed_at,
quote_count: result.quote_count,
statistic_row_count: result.statistic_row_count,
evidence_counts: result.evidence_counts,
};
return state.latest_quote_lifecycle_statistics;
}
async function handleWrittenHistoryEvent({
topic,
partition,
@ -560,6 +601,10 @@ const controlApi = startControlApi({
last_retention_maintenance_at: state.last_retention_maintenance_at,
retention_error: state.retention_error,
storage_pressure: state.storage_pressure,
last_quote_lifecycle_statistics_at: state.last_quote_lifecycle_statistics_at,
quote_lifecycle_statistics_count: state.quote_lifecycle_statistics_count,
quote_lifecycle_statistics_error: state.quote_lifecycle_statistics_error,
latest_quote_lifecycle_statistics: state.latest_quote_lifecycle_statistics,
last_quote_lifecycle_rollup_at: state.last_quote_lifecycle_rollup_at,
quote_lifecycle_rollup_count: state.quote_lifecycle_rollup_count,
quote_lifecycle_rollup_error: state.quote_lifecycle_rollup_error,

View file

@ -13,6 +13,7 @@ import {
applyDashboardLiveEvent,
buildDashboardBootstrap,
buildDashboardControlErrorResponse,
buildLiveQuoteLifecycleStatistics,
buildLiveQuoteLifecycleRows,
buildQuoteLifecycleLookupResponse,
buildLiveStatusBar,
@ -47,6 +48,7 @@ import {
loadPairConfigSummary,
loadQuoteLifecycleLookupEvidence,
loadQuoteLifecycleRetentionSummary,
loadQuoteLifecycleStatistics,
loadRecentAlertTransitions,
loadRecentDepositStatuses,
loadRecentEnvironmentStatuses,
@ -60,6 +62,7 @@ import {
loadSubmissionPage,
loadSubmissionSummary,
pauseTradingPair,
refreshQuoteLifecycleStatistics,
seedTradingConfig,
setTradingPairMode,
} from '../lib/postgres.mjs';
@ -231,6 +234,7 @@ webSocketServer.on('connection', (socket, _req, authContext) => {
webSockets.add(socket);
dashboardRuntimeState.websocket_clients = webSockets.size;
const recentLifecycleRows = buildLiveQuoteLifecycleRows(liveState);
const quoteLifecycleStatistics = buildLiveQuoteLifecycleStatistics(liveState);
dashboardRuntimeState.latest_maker_competitiveness = buildMakerCompetitivenessSummary({
lifecycleRows: recentLifecycleRows,
});
@ -240,6 +244,7 @@ webSocketServer.on('connection', (socket, _req, authContext) => {
live: {
recent_quotes: liveState.recent_quotes,
recent_lifecycle_rows: recentLifecycleRows,
quote_lifecycle_statistics: quoteLifecycleStatistics,
maker_competitiveness: dashboardRuntimeState.latest_maker_competitiveness,
status_bar: buildLiveStatusBar(liveState),
},
@ -380,6 +385,14 @@ async function handleApiRequest({ req, res, url, auth }) {
return sendJson(res, 200, submissionPage);
}
if (req.method === 'GET' && url.pathname === '/api/strategy/quote-lifecycle/statistics') {
const payload = await loadQuoteLifecycleStatisticsPayload({
grains: url.searchParams.get('grains') || undefined,
limit: Number(url.searchParams.get('limit') || 120),
});
return sendJson(res, 200, payload);
}
const lifecycleLookupMatch = req.method === 'GET'
? url.pathname.match(/^\/api\/strategy\/quote-lifecycle\/(.+)$/)
: null;
@ -484,6 +497,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
recentQuoteOutcomes,
quoteLifecycleRetention,
quoteLifecycleRollups,
quoteLifecycleStatistics,
recentIntentRequests,
recentAlertTransitions,
recentEnvironmentStatuses,
@ -567,6 +581,12 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
[],
sourceErrors,
),
safeSourceLoad(
'quote_lifecycle_statistics',
() => loadQuoteLifecycleStatistics(pool, { limit: 120 }),
[],
sourceErrors,
),
safeSourceLoad(
'recent_intent_requests',
() => loadRecentIntentRequests(pool, {
@ -613,6 +633,14 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
recentQuoteOutcomes,
quoteLifecycleRetention,
quoteLifecycleRollups,
quoteLifecycleStatistics: {
persisted: {
source: 'persisted',
generated_at: quoteLifecycleStatistics[0]?.computed_at || null,
rows: quoteLifecycleStatistics,
},
live: buildLiveQuoteLifecycleStatistics(liveState),
},
recentIntentRequests,
recentAlertTransitions,
recentEnvironmentStatuses,
@ -644,6 +672,32 @@ async function loadQuoteLifecycleLookupPayload({ identifier }) {
});
}
async function loadQuoteLifecycleStatisticsPayload({ grains, limit } = {}) {
const refreshed = await refreshQuoteLifecycleStatistics(pool, {
now: new Date().toISOString(),
grains,
});
const rows = await loadQuoteLifecycleStatistics(pool, {
grains,
limit,
});
return {
ok: true,
refreshed_at: refreshed.computed_at,
persisted: {
source: 'persisted',
generated_at: rows[0]?.computed_at || refreshed.computed_at,
rows,
},
live: buildLiveQuoteLifecycleStatistics(liveState),
refresh: {
statistic_row_count: refreshed.statistic_row_count,
quote_count: refreshed.quote_count,
evidence_counts: refreshed.evidence_counts,
},
};
}
async function loadServiceSnapshots() {
const services = listDashboardServices(config);
return Promise.all(services.map((service) => loadServiceSnapshot(service)));

View file

@ -3,6 +3,7 @@ import { bridgeDepositObservedAt } from './bridge-assets.mjs';
import { summarizeFundingObservations } from './funding-observations.mjs';
import { buildMakerCompetitivenessSummary } from './maker-competitiveness.mjs';
import { resolveDashboardRequestAuth } from './operator-dashboard-auth.mjs';
import { buildQuoteLifecycleStatistics } from './quote-lifecycle-statistics.mjs';
import { TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES } from './quote-outcomes.mjs';
import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs';
@ -417,6 +418,27 @@ export function buildLiveQuoteLifecycleRows(state, { flashQuoteId = null, flashA
));
}
export function buildLiveQuoteLifecycleStatistics(state, {
computedAt = new Date().toISOString(),
} = {}) {
const lifecycleRows = deriveQuoteLifecycleRows({
recentQuotes: state.recent_quotes,
recentTradeDecisions: state.recent_trade_decisions,
recentExecuteTradeCommands: state.recent_execute_trade_commands,
recentExecutionResults: state.recent_execution_results,
recentQuoteOutcomes: state.recent_quote_outcomes,
limit: null,
}).map((row) => enrichLifecycleRowForUi({ config: state.config, row }));
return {
source: 'live_recent',
generated_at: computedAt,
rows: buildQuoteLifecycleStatistics({
lifecycleRows,
computedAt,
}),
};
}
export function applyDashboardLiveEvent(state, { topic, event }) {
if (!event?.payload) return [];
@ -538,6 +560,7 @@ export function buildDashboardBootstrap({
recentQuoteOutcomes = [],
quoteLifecycleRetention = null,
quoteLifecycleRollups = [],
quoteLifecycleStatistics = null,
recentIntentRequests = [],
recentAlertTransitions,
recentEnvironmentStatuses = [],
@ -633,6 +656,7 @@ export function buildDashboardBootstrap({
recentExecutionResults,
recentQuoteOutcomes,
quoteLifecycleRollups,
quoteLifecycleStatistics,
}),
system: buildSystemSummary({
servicesByName,
@ -1728,6 +1752,7 @@ function buildStrategySummary({
recentExecutionResults = [],
recentQuoteOutcomes = [],
quoteLifecycleRollups = [],
quoteLifecycleStatistics = null,
}) {
const strategyState = servicesByName['strategy-engine']?.state || {};
const executorState = servicesByName['trade-executor']?.state || {};
@ -1796,6 +1821,18 @@ function buildStrategySummary({
? recentDecisions
: [...durableDecisionsById.values()].slice(0, 20),
recent_lifecycle_rows: lifecycleRows,
quote_lifecycle_statistics: quoteLifecycleStatistics || {
persisted: {
source: 'persisted',
generated_at: null,
rows: [],
},
live: {
source: 'live_recent',
generated_at: null,
rows: [],
},
},
trade_funnel: tradeFunnel,
maker_competitiveness: makerCompetitiveness,
skipped_counts: strategyState.skipped_counts || {},
@ -2051,6 +2088,18 @@ function buildSystemSummary({
|| historyWriterState.retention_summary
|| null,
quote_lifecycle_rollups: quoteLifecycleRollups || [],
quote_lifecycle_statistics:
historyWriterState.latest_quote_lifecycle_statistics
|| null,
last_quote_lifecycle_statistics_at:
historyWriterState.last_quote_lifecycle_statistics_at
|| null,
quote_lifecycle_statistics_count:
historyWriterState.quote_lifecycle_statistics_count
?? null,
quote_lifecycle_statistics_error:
historyWriterState.quote_lifecycle_statistics_error
|| null,
last_retention_maintenance_at:
quoteLifecycleRetention?.latest_run?.completed_at
|| historyWriterState.last_retention_maintenance_at
@ -2687,6 +2736,14 @@ function buildQuoteLifecycleUpdate(state, { flashQuoteId = null } = {}) {
type: 'quote_lifecycle.updated',
recent_lifecycle_rows: lifecycleRows,
maker_competitiveness: buildMakerCompetitivenessSummary({ lifecycleRows, generatedAt: receivedAt }),
quote_lifecycle_statistics: {
source: 'live_recent',
generated_at: receivedAt,
rows: buildQuoteLifecycleStatistics({
lifecycleRows,
computedAt: receivedAt,
}),
},
flash_quote_id: flashQuoteId || null,
received_at: receivedAt,
};

View file

@ -0,0 +1,474 @@
export const QUOTE_LIFECYCLE_STATISTIC_GRAINS = Object.freeze([
'all_time',
'month',
'week',
'day',
'hour',
'five_minute',
]);
export const QUOTE_LIFECYCLE_STATISTIC_BUCKETS = Object.freeze([
'success',
'not_filled',
'submission_failed',
'submitted_no_reply',
'awaiting_executor',
'approved_no_command',
'blocked_before_submit',
'rejected_by_strategy',
'observed_no_decision',
'unavailable',
]);
const BUCKET_RANK = Object.freeze({
unavailable: 0,
observed_no_decision: 1,
approved_no_command: 2,
awaiting_executor: 3,
rejected_by_strategy: 4,
blocked_before_submit: 4,
submitted_no_reply: 5,
submission_failed: 6,
not_filled: 7,
success: 8,
});
export function normalizeQuoteLifecycleStatisticsGrains(grains = QUOTE_LIFECYCLE_STATISTIC_GRAINS) {
const requested = Array.isArray(grains) ? grains : String(grains || '').split(',');
const normalized = requested
.map((grain) => String(grain || '').trim())
.filter((grain) => QUOTE_LIFECYCLE_STATISTIC_GRAINS.includes(grain));
return normalized.length ? [...new Set(normalized)] : [...QUOTE_LIFECYCLE_STATISTIC_GRAINS];
}
export function emptyQuoteLifecycleBucketCounts() {
return Object.fromEntries(QUOTE_LIFECYCLE_STATISTIC_BUCKETS.map((bucket) => [bucket, 0]));
}
export function classifyQuoteLifecycleStatisticsBucket(row = null) {
if (!row) return 'unavailable';
const lifecycleState = normalizeToken(row.lifecycle_state);
const executionStatus = normalizeToken(row.execution?.status || row.stage_details?.execution_status);
const outcomeStatus = normalizeToken(
row.outcome_status
|| row.outcome?.outcome_status
|| row.execution?.outcome_status
|| row.stage_details?.durable_outcome_status
|| row.stage_details?.execution_outcome_status,
);
const reasonCode = normalizeToken(
row.reason_code
|| row.execution?.failure_category
|| row.execution?.result_code
|| row.outcome?.outcome_reason,
);
if (
lifecycleState === 'completed'
|| ['completed', 'filled', 'settled', 'finalized'].includes(outcomeStatus)
) {
return 'success';
}
if (
lifecycleState === 'not_filled'
|| ['not_filled', 'expired', 'cancelled'].includes(outcomeStatus)
) {
return 'not_filled';
}
if (
lifecycleState === 'failed'
|| executionStatus === 'failed'
|| reasonCode === 'submission_failed'
|| reasonCode === 'quote_not_found_or_finished'
) {
return 'submission_failed';
}
if (
lifecycleState === 'submitted'
|| lifecycleState === 'awaiting_outcome'
|| executionStatus === 'submitted'
|| outcomeStatus === 'awaiting_outcome'
) {
return 'submitted_no_reply';
}
if (lifecycleState === 'command_emitted') return 'awaiting_executor';
if (lifecycleState === 'evaluated') return 'approved_no_command';
if (lifecycleState === 'blocked') return 'blocked_before_submit';
if (lifecycleState === 'rejected') return 'rejected_by_strategy';
if (lifecycleState === 'observed') return 'observed_no_decision';
return 'unavailable';
}
export function quoteLifecycleStatisticWindow(timestamp, grain) {
if (grain === 'all_time') {
return {
grain,
window_start: null,
window_end: null,
};
}
const date = new Date(timestamp || '');
if (Number.isNaN(date.getTime())) return null;
const start = new Date(date.getTime());
switch (grain) {
case 'five_minute':
start.setUTCSeconds(0, 0);
start.setUTCMinutes(Math.floor(start.getUTCMinutes() / 5) * 5);
break;
case 'hour':
start.setUTCMinutes(0, 0, 0);
break;
case 'day':
start.setUTCHours(0, 0, 0, 0);
break;
case 'week': {
start.setUTCHours(0, 0, 0, 0);
const daysSinceMonday = (start.getUTCDay() + 6) % 7;
start.setUTCDate(start.getUTCDate() - daysSinceMonday);
break;
}
case 'month':
start.setUTCDate(1);
start.setUTCHours(0, 0, 0, 0);
break;
default:
return null;
}
const end = new Date(start.getTime());
switch (grain) {
case 'five_minute':
end.setUTCMinutes(end.getUTCMinutes() + 5);
break;
case 'hour':
end.setUTCHours(end.getUTCHours() + 1);
break;
case 'day':
end.setUTCDate(end.getUTCDate() + 1);
break;
case 'week':
end.setUTCDate(end.getUTCDate() + 7);
break;
case 'month':
end.setUTCMonth(end.getUTCMonth() + 1);
break;
default:
return null;
}
return {
grain,
window_start: start.toISOString(),
window_end: end.toISOString(),
};
}
export function buildQuoteLifecycleStatistics({
lifecycleRows = [],
grains = QUOTE_LIFECYCLE_STATISTIC_GRAINS,
computedAt = new Date().toISOString(),
} = {}) {
const normalizedGrains = normalizeQuoteLifecycleStatisticsGrains(grains);
const groups = new Map();
const deduped = dedupeLifecycleRows(lifecycleRows);
if (normalizedGrains.includes('all_time')) {
ensureStatisticGroup(groups, {
grain: 'all_time',
window_start: null,
window_end: null,
computedAt,
});
}
for (const entry of deduped.identified) {
const row = entry.row;
const bucket = classifyQuoteLifecycleStatisticsBucket(row);
const activityAt = entry.first_activity_at;
addRowToStatisticGroup(groups, {
grain: 'all_time',
window_start: null,
window_end: null,
bucket,
computedAt,
hasQuoteIdentifier: true,
hasTimestamp: Boolean(activityAt),
enabled: normalizedGrains.includes('all_time'),
});
if (!activityAt) continue;
for (const grain of normalizedGrains.filter((candidate) => candidate !== 'all_time')) {
const window = quoteLifecycleStatisticWindow(activityAt, grain);
if (!window) continue;
addRowToStatisticGroup(groups, {
...window,
bucket,
computedAt,
hasQuoteIdentifier: true,
hasTimestamp: true,
enabled: true,
});
}
}
for (const row of deduped.missingIdentifier) {
const bucket = 'unavailable';
const activityAt = firstLifecycleTimestamp(row);
addRowToStatisticGroup(groups, {
grain: 'all_time',
window_start: null,
window_end: null,
bucket,
computedAt,
hasQuoteIdentifier: false,
hasTimestamp: Boolean(activityAt),
enabled: normalizedGrains.includes('all_time'),
});
if (!activityAt) continue;
for (const grain of normalizedGrains.filter((candidate) => candidate !== 'all_time')) {
const window = quoteLifecycleStatisticWindow(activityAt, grain);
if (!window) continue;
addRowToStatisticGroup(groups, {
...window,
bucket,
computedAt,
hasQuoteIdentifier: false,
hasTimestamp: true,
enabled: true,
});
}
}
return [...groups.values()]
.map((group) => ({
...group,
stat_id: quoteLifecycleStatisticId(group),
bucket_counts: normalizeBucketCounts(group.bucket_counts),
}))
.sort(compareStatisticRows);
}
export function normalizeQuoteLifecycleStatisticsRow(row = {}) {
return {
stat_id: row.stat_id || quoteLifecycleStatisticId(row),
grain: row.grain || 'all_time',
window_start: toIsoTimestamp(row.window_start),
window_end: toIsoTimestamp(row.window_end),
quote_count: Number(row.quote_count || 0),
missing_identifier_count: Number(row.missing_identifier_count || 0),
missing_timestamp_count: Number(row.missing_timestamp_count || 0),
bucket_counts: normalizeBucketCounts(row.bucket_counts),
computed_at: toIsoTimestamp(row.computed_at),
};
}
export function quoteLifecycleStatisticId(row = {}) {
const grain = row.grain || 'all_time';
const windowStart = row.window_start || 'all';
return `quote-lifecycle-stat:${grain}:${windowStart}`;
}
function dedupeLifecycleRows(rows = []) {
const identifiedByQuote = new Map();
const missingIdentifier = [];
for (const row of rows || []) {
const quoteId = String(row?.quote_id || '').trim();
if (!quoteId) {
missingIdentifier.push(row);
continue;
}
const activityAt = firstLifecycleTimestamp(row);
const bucket = classifyQuoteLifecycleStatisticsBucket(row);
const existing = identifiedByQuote.get(quoteId);
if (!existing) {
identifiedByQuote.set(quoteId, {
row,
first_activity_at: activityAt,
latest_stage_at: latestLifecycleTimestamp(row),
bucket,
});
continue;
}
const firstActivityAt = earlierTimestamp(existing.first_activity_at, activityAt);
const latestStageAt = laterTimestamp(existing.latest_stage_at, latestLifecycleTimestamp(row));
const existingRank = BUCKET_RANK[existing.bucket] ?? 0;
const nextRank = BUCKET_RANK[bucket] ?? 0;
const useNextRow = nextRank > existingRank
|| (nextRank === existingRank && timestampValue(latestLifecycleTimestamp(row)) >= timestampValue(existing.latest_stage_at));
identifiedByQuote.set(quoteId, {
row: useNextRow ? row : existing.row,
first_activity_at: firstActivityAt,
latest_stage_at: latestStageAt,
bucket: useNextRow ? bucket : existing.bucket,
});
}
return {
identified: [...identifiedByQuote.values()],
missingIdentifier,
};
}
function addRowToStatisticGroup(groups, {
grain,
window_start,
window_end,
bucket,
computedAt,
hasQuoteIdentifier,
hasTimestamp,
enabled,
}) {
if (!enabled) return;
const group = ensureStatisticGroup(groups, {
grain,
window_start,
window_end,
computedAt,
});
if (hasQuoteIdentifier) {
group.quote_count += 1;
} else {
group.missing_identifier_count += 1;
}
if (!hasTimestamp) group.missing_timestamp_count += 1;
group.bucket_counts[bucket] = Number(group.bucket_counts[bucket] || 0) + 1;
}
function ensureStatisticGroup(groups, {
grain,
window_start,
window_end,
computedAt,
}) {
const key = `${grain}:${window_start || 'all'}`;
if (!groups.has(key)) {
groups.set(key, {
stat_id: null,
grain,
window_start,
window_end,
quote_count: 0,
missing_identifier_count: 0,
missing_timestamp_count: 0,
bucket_counts: emptyQuoteLifecycleBucketCounts(),
computed_at: toIsoTimestamp(computedAt) || new Date().toISOString(),
});
}
return groups.get(key);
}
function compareStatisticRows(left, right) {
const grainOrder = new Map(QUOTE_LIFECYCLE_STATISTIC_GRAINS.map((grain, index) => [grain, index]));
const grainComparison = (grainOrder.get(left.grain) ?? 99) - (grainOrder.get(right.grain) ?? 99);
if (grainComparison !== 0) return grainComparison;
const windowComparison = timestampValue(right.window_start) - timestampValue(left.window_start);
return Number.isFinite(windowComparison) ? windowComparison : 0;
}
function firstLifecycleTimestamp(row = {}) {
return earliestTimestamp([
row.quote_activity_at,
row.quote_observed_at,
row.decision_at,
row.command_at,
row.execution_result_at,
row.outcome_observed_at,
row.latest_stage_at,
]);
}
function latestLifecycleTimestamp(row = {}) {
return latestTimestamp([
row.latest_stage_at,
row.outcome_observed_at,
row.execution_result_at,
row.command_at,
row.decision_at,
row.quote_observed_at,
row.quote_activity_at,
]);
}
function normalizeBucketCounts(value = {}) {
const source = typeof value === 'string' ? parseJsonObject(value) : value || {};
return Object.fromEntries(
QUOTE_LIFECYCLE_STATISTIC_BUCKETS.map((bucket) => [bucket, Number(source[bucket] || 0)]),
);
}
function parseJsonObject(value) {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function normalizeToken(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_');
}
function timestampValue(value) {
const parsed = Date.parse(value || '');
return Number.isFinite(parsed) ? parsed : -Infinity;
}
function toIsoTimestamp(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function earliestTimestamp(values = []) {
let earliest = null;
for (const value of values) {
const iso = toIsoTimestamp(value);
if (!iso) continue;
earliest = earliestTimestampPair(earliest, iso);
}
return earliest;
}
function latestTimestamp(values = []) {
let latest = null;
for (const value of values) {
const iso = toIsoTimestamp(value);
if (!iso) continue;
latest = laterTimestamp(latest, iso);
}
return latest;
}
function earliestTimestampPair(left, right) {
if (!left) return toIsoTimestamp(right);
if (!right) return toIsoTimestamp(left);
return new Date(Math.min(timestampValue(left), timestampValue(right))).toISOString();
}
function earlierTimestamp(left, right) {
if (!left) return toIsoTimestamp(right);
if (!right) return toIsoTimestamp(left);
return new Date(Math.min(timestampValue(left), timestampValue(right))).toISOString();
}
function laterTimestamp(left, right) {
if (!left) return toIsoTimestamp(right);
if (!right) return toIsoTimestamp(left);
return new Date(Math.max(timestampValue(left), timestampValue(right))).toISOString();
}

View file

@ -1,8 +1,15 @@
import { Pool } from 'pg';
import { deriveIntentRequestOutcomeRecords } from '../core/intent-request-outcomes.mjs';
import { deriveQuoteLifecycleRows } from '../core/operator-dashboard.mjs';
import { buildCashEquivalentValuationAssets } from '../core/portfolio-metrics.mjs';
import { deriveQuoteOutcomeRecords } from '../core/quote-outcomes.mjs';
import {
QUOTE_LIFECYCLE_STATISTIC_GRAINS,
buildQuoteLifecycleStatistics,
normalizeQuoteLifecycleStatisticsGrains,
normalizeQuoteLifecycleStatisticsRow,
} from '../core/quote-lifecycle-statistics.mjs';
import {
DEFAULT_QUOTE_LIFECYCLE_RETENTION_POLICY,
QUOTE_LIFECYCLE_DATA_CLASSES,
@ -53,6 +60,7 @@ const RETENTION_POLICIES_TABLE = 'retention_policies';
const QUOTE_LIFECYCLE_ROLLUPS_TABLE = 'quote_lifecycle_rollups';
const QUOTE_LIFECYCLE_ROLLUP_WATERMARKS_TABLE = 'quote_lifecycle_rollup_watermarks';
const QUOTE_LIFECYCLE_RETENTION_RUNS_TABLE = 'quote_lifecycle_retention_runs';
const QUOTE_LIFECYCLE_STATISTICS_TABLE = 'quote_lifecycle_statistics';
const QUOTE_LIFECYCLE_ROLLUP_WINDOWS_PER_PASS = 48;
const SUPPORTED_ASSET_IMPORT_RUNS_TABLE = 'supported_asset_import_runs';
const TRADING_ASSETS_TABLE = 'trading_assets';
@ -389,6 +397,28 @@ export async function ensureQuoteLifecycleRetentionSchema(pool) {
ON ${QUOTE_LIFECYCLE_ROLLUPS_TABLE} (source_data_class, window_end DESC)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${QUOTE_LIFECYCLE_STATISTICS_TABLE} (
stat_id TEXT PRIMARY KEY,
grain TEXT NOT NULL,
window_start TIMESTAMPTZ,
window_end TIMESTAMPTZ,
quote_count INTEGER NOT NULL DEFAULT 0,
missing_identifier_count INTEGER NOT NULL DEFAULT 0,
missing_timestamp_count INTEGER NOT NULL DEFAULT 0,
bucket_counts JSONB NOT NULL DEFAULT '{}'::jsonb,
computed_at TIMESTAMPTZ NOT NULL
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS ${QUOTE_LIFECYCLE_STATISTICS_TABLE}_grain_window_idx
ON ${QUOTE_LIFECYCLE_STATISTICS_TABLE} (grain, window_start DESC NULLS LAST)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS ${QUOTE_LIFECYCLE_STATISTICS_TABLE}_computed_at_idx
ON ${QUOTE_LIFECYCLE_STATISTICS_TABLE} (computed_at DESC)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${QUOTE_LIFECYCLE_ROLLUP_WATERMARKS_TABLE} (
rollup_key TEXT PRIMARY KEY,
@ -3149,6 +3179,193 @@ export async function loadRecentQuoteLifecycleRollups(pool, { limit = 50 } = {})
return result.rows.map(normalizeQuoteLifecycleRollupRow);
}
export async function refreshQuoteLifecycleStatistics(pool, {
now = new Date().toISOString(),
grains = QUOTE_LIFECYCLE_STATISTIC_GRAINS,
} = {}) {
const evidence = await loadQuoteLifecycleStatisticsEvidence(pool);
const lifecycleRows = deriveQuoteLifecycleRows({
recentQuotes: evidence.recentQuotes,
recentTradeDecisions: evidence.recentTradeDecisions,
recentExecuteTradeCommands: evidence.recentExecuteTradeCommands,
recentExecutionResults: evidence.recentExecutionResults,
recentQuoteOutcomes: evidence.recentQuoteOutcomes,
limit: null,
});
const statistics = buildQuoteLifecycleStatistics({
lifecycleRows,
grains,
computedAt: now,
});
await upsertQuoteLifecycleStatistics(pool, statistics);
return {
computed_at: now,
quote_count: statistics.find((row) => row.grain === 'all_time')?.quote_count || 0,
statistic_row_count: statistics.length,
evidence_counts: {
quotes: evidence.recentQuotes.length,
decisions: evidence.recentTradeDecisions.length,
commands: evidence.recentExecuteTradeCommands.length,
execution_results: evidence.recentExecutionResults.length,
outcomes: evidence.recentQuoteOutcomes.length,
lifecycle_rows: lifecycleRows.length,
},
statistics,
};
}
export async function loadQuoteLifecycleStatistics(pool, {
grains = QUOTE_LIFECYCLE_STATISTIC_GRAINS,
limit = 96,
} = {}) {
const normalizedGrains = normalizeQuoteLifecycleStatisticsGrains(grains);
const normalizedLimit = Math.max(1, Math.min(1000, Number(limit) || 96));
const result = await pool.query(
`
WITH ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY grain
ORDER BY window_start DESC NULLS LAST
) AS grain_rank
FROM ${QUOTE_LIFECYCLE_STATISTICS_TABLE}
WHERE grain = ANY($1::text[])
)
SELECT *
FROM ranked
WHERE grain = 'all_time'
OR grain_rank <= $2
ORDER BY
CASE grain
WHEN 'all_time' THEN 0
WHEN 'month' THEN 1
WHEN 'week' THEN 2
WHEN 'day' THEN 3
WHEN 'hour' THEN 4
WHEN 'five_minute' THEN 5
ELSE 99
END,
window_start DESC NULLS LAST
`,
[normalizedGrains, normalizedLimit],
);
return result.rows.map(normalizeQuoteLifecycleStatisticsRow);
}
async function loadQuoteLifecycleStatisticsEvidence(pool) {
const [
quotesResult,
decisionsResult,
commandsResult,
executionResultsResult,
outcomesResult,
] = await Promise.all([
pool.query(`
SELECT observed_at, ingested_at, payload
FROM swap_demand_events
ORDER BY COALESCE(observed_at, ingested_at) ASC
`),
pool.query(`
SELECT observed_at, ingested_at, payload
FROM trade_decisions
ORDER BY COALESCE(observed_at, ingested_at) ASC
`),
pool.query(`
SELECT observed_at, ingested_at, payload
FROM execute_trade_commands
ORDER BY COALESCE(observed_at, ingested_at) ASC
`),
pool.query(`
SELECT
r.observed_at AS result_observed_at,
r.ingested_at AS result_ingested_at,
r.payload AS result_payload,
c.ingested_at AS command_ingested_at,
c.payload AS command_payload,
d.payload AS decision_payload,
o.payload AS outcome_payload
FROM trade_execution_results r
LEFT JOIN execute_trade_commands c
ON c.decision_key = r.decision_key
LEFT JOIN trade_decisions d
ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id')
LEFT JOIN ${QUOTE_OUTCOMES_TABLE} o
ON o.quote_id = r.quote_id
ORDER BY COALESCE(r.observed_at, r.ingested_at) ASC
`),
pool.query(`
SELECT
quote_id,
decision_id,
command_id,
execution_result_status,
execution_result_code,
submitted_at,
command_at,
outcome_status,
outcome_observed_at,
outcome_source,
attribution_status,
attribution_method,
attributed_inventory_delta,
computed_at,
payload
FROM ${QUOTE_OUTCOMES_TABLE}
ORDER BY COALESCE(outcome_observed_at, computed_at) ASC
`),
]);
return {
recentQuotes: quotesResult.rows.map(normalizeRecentQuoteRow),
recentTradeDecisions: decisionsResult.rows.map((row) => ({
observed_at: toIsoTimestamp(row.observed_at),
ingested_at: toIsoTimestamp(row.ingested_at),
payload: row.payload,
})),
recentExecuteTradeCommands: commandsResult.rows.map((row) => normalizeExecuteTradeCommandRow(row)),
recentExecutionResults: executionResultsResult.rows.map((row) => normalizeExecutionResultRow(row)),
recentQuoteOutcomes: outcomesResult.rows.map((row) => normalizeQuoteOutcomeRow(row)),
};
}
async function upsertQuoteLifecycleStatistics(pool, statistics = []) {
for (const row of statistics || []) {
await pool.query(
`
INSERT INTO ${QUOTE_LIFECYCLE_STATISTICS_TABLE} (
stat_id,
grain,
window_start,
window_end,
quote_count,
missing_identifier_count,
missing_timestamp_count,
bucket_counts,
computed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
ON CONFLICT (stat_id) DO UPDATE SET
quote_count = EXCLUDED.quote_count,
missing_identifier_count = EXCLUDED.missing_identifier_count,
missing_timestamp_count = EXCLUDED.missing_timestamp_count,
bucket_counts = EXCLUDED.bucket_counts,
computed_at = EXCLUDED.computed_at
`,
[
row.stat_id,
row.grain,
row.window_start,
row.window_end,
row.quote_count,
row.missing_identifier_count,
row.missing_timestamp_count,
JSON.stringify(row.bucket_counts || {}),
row.computed_at,
],
);
}
}
async function countSuccessfulQuoteEvidence(pool) {
const result = await pool.query(`
SELECT COUNT(*)::INT AS count

View file

@ -31,6 +31,26 @@ const QUOTE_LIFECYCLE_PROBLEM_FILTERS = [
['strategy_rejected', 'Strategy rejected'],
['submitted_awaiting_outcome', 'Submitted or awaiting outcome'],
];
const QUOTE_LIFECYCLE_STAT_GRAINS = [
['five_minute', '5m'],
['hour', 'Hour'],
['day', 'Day'],
['week', 'Week'],
['month', 'Month'],
['all_time', 'All-time'],
];
const QUOTE_LIFECYCLE_STAT_BUCKETS = [
['success', 'Success'],
['submitted_no_reply', 'Submitted no reply'],
['submission_failed', 'Submission failed'],
['awaiting_executor', 'Awaiting executor'],
['approved_no_command', 'Approved no command'],
['rejected_by_strategy', 'Rejected by strategy'],
['blocked_before_submit', 'Blocked before submit'],
['not_filled', 'Not filled'],
['observed_no_decision', 'Observed no decision'],
['unavailable', 'Unavailable'],
];
async function copyIdentifier(value) {
if (!value || !navigator?.clipboard?.writeText) return;
@ -57,6 +77,21 @@ async function fetchQuoteLifecycleLookup(identifier) {
return payload;
}
async function fetchQuoteLifecycleStatistics() {
const response = await fetch('/api/strategy/quote-lifecycle/statistics?limit=160', {
headers: {
accept: 'application/json',
},
});
const text = await response.text();
const payload = text.trim() ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(payload?.error || text.trim() || `HTTP ${response.status}`);
}
return payload;
}
function useNow(intervalMs = 1000) {
const [now, setNow] = useState(() => Date.now());
@ -201,6 +236,41 @@ function plainCodeLabel(value, fallback = 'Unavailable') {
return text.replaceAll('_', ' ');
}
function formatInteger(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '0';
return number.toLocaleString();
}
function statisticBucketCount(row, bucket) {
return Number(row?.bucket_counts?.[bucket] || 0);
}
function quoteLifecycleStatisticRows(statistics, source = 'persisted') {
return statistics?.[source]?.rows || [];
}
function latestStatisticRow(rows, grain) {
const matches = (rows || []).filter((row) => row?.grain === grain);
if (grain === 'all_time') return matches[0] || null;
return matches.sort((left, right) => (
Date.parse(right.window_start || '') - Date.parse(left.window_start || '')
))[0] || null;
}
function formatStatisticWindow(row) {
if (!row) return 'Unavailable';
if (row.grain === 'all_time') return 'All-time';
const start = formatTimestamp(row.window_start);
const end = formatTimestamp(row.window_end);
return `${start} to ${end}`;
}
function statisticFreshnessLabel(value) {
if (!value) return 'Unavailable';
return formatTimestamp(value);
}
function strategyDecisionStatus(decision) {
if (decision?.decision === 'approved') return 'Strategy approved';
if (decision?.decision === 'rejected') return 'Strategy rejected';
@ -852,10 +922,125 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
);
}
function QuoteLifecycleTable({ items }) {
function QuoteLifecycleStatisticsPanel({
error,
grain,
liveRows,
onGrainChange,
onRefresh,
persistedRows,
state,
}) {
const allTime = latestStatisticRow(persistedRows, 'all_time');
const selectedRows = grain === 'all_time'
? [allTime].filter(Boolean)
: (persistedRows || []).filter((row) => row?.grain === grain).slice(0, 8);
const selectedCurrent = latestStatisticRow(persistedRows, grain);
const liveCurrent = latestStatisticRow(liveRows, grain);
const persistedGeneratedAt = persistedRows?.[0]?.computed_at || null;
const liveGeneratedAt = liveRows?.[0]?.computed_at || null;
const rows = fixedRows(selectedRows, grain === 'all_time' ? 1 : 8);
return (
<div className="quote-statistics-panel">
<div className="quote-statistics-head">
<label className="field quote-statistics-grain-field">
<span>Stats window</span>
<select value={grain} onChange={(event) => onGrainChange(event.target.value)}>
{QUOTE_LIFECYCLE_STAT_GRAINS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<button className="button secondary" disabled={state === 'loading'} onClick={onRefresh} type="button">
{state === 'loading' ? 'Refreshing stats' : 'Refresh stats'}
</button>
<Pill label={`Persisted ${statisticFreshnessLabel(persistedGeneratedAt)}`} stateLabel={persistedGeneratedAt ? 'healthy' : 'unknown'} />
<Pill label={`Live ${statisticFreshnessLabel(liveGeneratedAt)}`} stateLabel={liveGeneratedAt ? 'healthy' : 'unknown'} />
</div>
{error ? <div className="banner error">{error}</div> : null}
<div className="quote-statistics-summary">
<div>
<span>All-time unique quotes</span>
<strong>{formatInteger(allTime?.quote_count)}</strong>
</div>
<div>
<span>{grain === 'all_time' ? 'All-time selected' : 'Current persisted window'}</span>
<strong>{formatInteger(selectedCurrent?.quote_count)}</strong>
</div>
<div>
<span>Live recent window</span>
<strong>{formatInteger(liveCurrent?.quote_count)}</strong>
</div>
<div>
<span>Unavailable evidence</span>
<strong>{formatInteger((selectedCurrent?.missing_identifier_count || 0) + (selectedCurrent?.missing_timestamp_count || 0))}</strong>
</div>
</div>
<TableFrame className="quote-statistics-frame">
<table className="quote-statistics-table">
<thead>
<tr>
<th>Window</th>
<th>Unique quotes</th>
<th>Success</th>
<th>Submitted no reply</th>
<th>Submission failed</th>
<th>Awaiting executor</th>
<th>Rejected</th>
<th>Other / unavailable</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
if (!row) {
return (
<tr className="quote-statistics-placeholder-row" key={`quote-stat-placeholder:${index}`}>
<td colSpan={8}>{index === 0 ? 'No persisted quote statistics are available yet.' : ''}</td>
</tr>
);
}
const other = statisticBucketCount(row, 'approved_no_command')
+ statisticBucketCount(row, 'blocked_before_submit')
+ statisticBucketCount(row, 'not_filled')
+ statisticBucketCount(row, 'observed_no_decision');
const unavailable = statisticBucketCount(row, 'unavailable')
+ Number(row.missing_identifier_count || 0)
+ Number(row.missing_timestamp_count || 0);
return (
<tr key={row.stat_id || `${row.grain}:${row.window_start || index}`}>
<td>
<div>{formatStatisticWindow(row)}</div>
<div className="status-subtle">{plainCodeLabel(row.grain)}</div>
</td>
<td>{formatInteger(row.quote_count)}</td>
<td>{formatInteger(statisticBucketCount(row, 'success'))}</td>
<td>{formatInteger(statisticBucketCount(row, 'submitted_no_reply'))}</td>
<td>{formatInteger(statisticBucketCount(row, 'submission_failed'))}</td>
<td>{formatInteger(statisticBucketCount(row, 'awaiting_executor'))}</td>
<td>{formatInteger(statisticBucketCount(row, 'rejected_by_strategy'))}</td>
<td>
<div>{formatInteger(other)} other</div>
<div className="status-subtle">{formatInteger(unavailable)} unavailable</div>
</td>
</tr>
);
})}
</tbody>
</table>
</TableFrame>
</div>
);
}
function QuoteLifecycleTable({ items, statistics }) {
const [expanded, setExpanded] = useState(() => new Set());
const [showStrategyRejected, setShowStrategyRejected] = useState(true);
const [problemFilter, setProblemFilter] = useState('all');
const [statGrain, setStatGrain] = useState('five_minute');
const [statsState, setStatsState] = useState('idle');
const [statsError, setStatsError] = useState(null);
const [statisticsSnapshot, setStatisticsSnapshot] = useState(() => statistics || null);
const latestItemsRef = useRef(items || []);
const [quoteDisplayPaused, setQuoteDisplayPaused] = useState(false);
const [displayItems, setDisplayItems] = useState(() => items || []);
@ -872,6 +1057,14 @@ function QuoteLifecycleTable({ items }) {
selectedInvestigationRef.current = selectedInvestigation;
}, [selectedInvestigation]);
useEffect(() => {
if (!statistics) return;
setStatisticsSnapshot((current) => ({
persisted: statistics.persisted || current?.persisted || { source: 'persisted', generated_at: null, rows: [] },
live: statistics.live || current?.live || { source: 'live_recent', generated_at: null, rows: [] },
}));
}, [statistics]);
useEffect(() => () => {
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
}, []);
@ -1008,13 +1201,41 @@ function QuoteLifecycleTable({ items }) {
setDisplayNow(Date.now());
}
async function refreshStatistics() {
setStatsState('loading');
setStatsError(null);
try {
const payload = await fetchQuoteLifecycleStatistics();
setStatisticsSnapshot((current) => ({
persisted: payload?.persisted || current?.persisted || { source: 'persisted', generated_at: null, rows: [] },
live: payload?.live || current?.live || { source: 'live_recent', generated_at: null, rows: [] },
}));
setStatsState('loaded');
} catch (error) {
setStatsState('error');
setStatsError(error?.message || 'Statistics refresh failed');
}
}
function toggleQuoteDisplayPaused() {
if (quoteDisplayPaused) applyLatestLifecycleDisplay();
setQuoteDisplayPaused((paused) => !paused);
}
const persistedStatisticRows = quoteLifecycleStatisticRows(statisticsSnapshot, 'persisted');
const liveStatisticRows = quoteLifecycleStatisticRows(statisticsSnapshot, 'live');
return (
<>
<QuoteLifecycleStatisticsPanel
error={statsError}
grain={statGrain}
liveRows={liveStatisticRows}
onGrainChange={setStatGrain}
onRefresh={() => refreshStatistics().catch(() => {})}
persistedRows={persistedStatisticRows}
state={statsState}
/>
<form className="quote-lifecycle-lookup" onSubmit={submitLookup}>
<label className="field quote-lifecycle-lookup-field">
<span>Lookup identifier</span>
@ -1819,7 +2040,7 @@ function SuccessfulTradesSection({ funnel, counts }) {
);
}
function QuoteLifecycleSection({ items }) {
function QuoteLifecycleSection({ items, statistics }) {
return (
<section className="panel full-width-evidence-panel">
<div className="panel-head">
@ -1831,7 +2052,7 @@ function QuoteLifecycleSection({ items }) {
</div>
</div>
</div>
<QuoteLifecycleTable items={items} />
<QuoteLifecycleTable items={items} statistics={statistics} />
</section>
);
}
@ -1855,7 +2076,12 @@ export default function StrategyPage({ strategy, onControl, page = 'strategy' })
case 'strategy-trades':
return <SuccessfulTradesSection counts={counts} funnel={funnel} />;
case 'strategy-lifecycle':
return <QuoteLifecycleSection items={strategy.strategy_state.recent_lifecycle_rows} />;
return (
<QuoteLifecycleSection
items={strategy.strategy_state.recent_lifecycle_rows}
statistics={strategy.strategy_state.quote_lifecycle_statistics}
/>
);
case 'strategy':
default:
return <StrategyOverviewSection strategy={strategy} />;

View file

@ -19,6 +19,13 @@ function applySocketMessage(dashboard, payload, session) {
maker_competitiveness:
payload.live.maker_competitiveness
|| dashboard.strategy.strategy_state.maker_competitiveness,
quote_lifecycle_statistics: {
...(dashboard.strategy.strategy_state.quote_lifecycle_statistics || {}),
live:
payload.live.quote_lifecycle_statistics
|| dashboard.strategy.strategy_state.quote_lifecycle_statistics?.live
|| null,
},
},
} : dashboard.strategy,
status_bar: {
@ -53,6 +60,13 @@ function applySocketMessage(dashboard, payload, session) {
maker_competitiveness:
payload.maker_competitiveness
|| dashboard.strategy.strategy_state.maker_competitiveness,
quote_lifecycle_statistics: {
...(dashboard.strategy.strategy_state.quote_lifecycle_statistics || {}),
live:
payload.quote_lifecycle_statistics
|| dashboard.strategy.strategy_state.quote_lifecycle_statistics?.live
|| null,
},
},
},
},

View file

@ -610,6 +610,77 @@ table.lifecycle-table th:nth-child(5) {
table-layout: fixed;
}
.quote-statistics-panel {
display: grid;
gap: 12px;
margin-bottom: 14px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.46);
}
.quote-statistics-head {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 10px;
}
.quote-statistics-grain-field {
min-width: 180px;
}
.quote-statistics-grain-field span {
font-size: 0.84rem;
color: var(--muted);
}
.quote-statistics-summary {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.quote-statistics-summary > div {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.42);
}
.quote-statistics-summary span {
display: block;
color: var(--muted);
font-size: 0.82rem;
}
.quote-statistics-summary strong {
display: block;
margin-top: 4px;
font-size: 1.25rem;
font-variant-numeric: tabular-nums;
}
.quote-statistics-table {
min-width: 980px;
table-layout: fixed;
}
.quote-statistics-table td {
font-variant-numeric: tabular-nums;
}
.quote-statistics-table th:nth-child(1),
.quote-statistics-table td:nth-child(1) {
width: 260px;
}
.quote-statistics-placeholder-row td {
color: var(--muted);
}
.quote-lifecycle-controls {
display: flex;
flex-wrap: wrap;

View file

@ -22,6 +22,13 @@ test('history writer replays durable topics but joins the raw quote firehose liv
assert.match(source, /insertHistoryEvents/);
assert.match(source, /quoteLifecycleRetentionMaintenanceIntervalMs\s*=\s*60 \* 1000/);
assert.match(source, /runQuoteLifecycleRetentionMaintenance/);
assert.match(source, /refreshQuoteLifecycleStatistics/);
assert.match(source, /refreshQuoteLifecycleStatisticsState\(\{[\s\S]*persist:\s*true/);
assert.match(source, /const statisticsResult = await refreshQuoteLifecycleStatisticsState/);
assert.match(source, /result\.quote_lifecycle_statistics = statisticsResult/);
assert.match(source, /last_quote_lifecycle_statistics_at/);
assert.match(source, /quote_lifecycle_statistics_count/);
assert.match(source, /quote_lifecycle_statistics_error/);
assert.match(source, /loadQuoteLifecycleRetentionSummary/);
assert.match(source, /maybeRunQuoteLifecycleRetentionMaintenance/);
assert.match(source, /quoteLifecycleRetentionMaintenanceInFlight/);

View file

@ -47,6 +47,14 @@ test('operator dashboard exposes authenticated durable quote lifecycle lookup AP
assert.match(source, /payload\.ok \? 200 : 404/);
});
test('operator dashboard exposes authenticated persisted quote lifecycle statistics API', () => {
assert.match(source, /\/api\/strategy\/quote-lifecycle\/statistics/);
assert.match(source, /loadQuoteLifecycleStatisticsPayload/);
assert.match(source, /refreshQuoteLifecycleStatistics/);
assert.match(source, /loadQuoteLifecycleStatistics/);
assert.match(source, /quote_lifecycle_statistics/);
});
test('operator dashboard bootstrap does not synchronously refresh intent request outcomes', () => {
assert.match(source, /loadRecentIntentRequests\(pool, \{[\s\S]*refreshOutcomes: false/);
});

View file

@ -40,6 +40,17 @@ test('strategy page owns consolidated quote lifecycle and successful trade table
assert.match(strategySource, /fetchQuoteLifecycleLookup/);
assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\//);
assert.match(strategySource, /QUOTE_LIFECYCLE_PROBLEM_FILTERS/);
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_GRAINS/);
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_BUCKETS/);
assert.match(strategySource, /fetchQuoteLifecycleStatistics/);
assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\/statistics/);
assert.match(strategySource, /five_minute/);
assert.match(strategySource, /all_time/);
assert.match(strategySource, /submitted_no_reply/);
assert.match(strategySource, /submission_failed/);
assert.match(strategySource, /success/);
assert.match(strategySource, /quote_lifecycle_statistics/);
assert.match(strategySource, /Refresh stats/);
assert.match(strategySource, /awaiting_executor/);
assert.match(strategySource, /quote_not_found_or_finished/);
assert.match(strategySource, /approved_no_command/);
@ -53,6 +64,8 @@ test('strategy page owns consolidated quote lifecycle and successful trade table
assert.match(strategySource, /visibleRows/);
assert.match(strategySource, /quote-lifecycle-placeholder-row/);
assert.match(stylesSource, /\.quote-investigator-panel/);
assert.match(stylesSource, /\.quote-statistics-panel/);
assert.match(stylesSource, /\.quote-statistics-table/);
assert.match(stylesSource, /\.quote-lifecycle-lookup/);
assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row\.is-selected/);
assert.match(stylesSource, /\.shell \{[\s\S]*?width: 100%;[\s\S]*?margin: 0;[\s\S]*?\}/);

View file

@ -310,6 +310,26 @@ test('dashboard bootstrap exposes quote lifecycle retention and rollup storage t
quote_count: 4,
completed_count: 0,
}],
quoteLifecycleStatistics: {
persisted: {
source: 'persisted',
generated_at: '2026-06-06T11:00:00.000Z',
rows: [{
grain: 'all_time',
quote_count: 4,
bucket_counts: { submission_failed: 4 },
}],
},
live: {
source: 'live_recent',
generated_at: '2026-06-06T11:00:01.000Z',
rows: [{
grain: 'five_minute',
quote_count: 1,
bucket_counts: { awaiting_executor: 1 },
}],
},
},
recentIntentRequests: [],
recentAlertTransitions: [],
recentEnvironmentStatuses: [],
@ -329,6 +349,8 @@ test('dashboard bootstrap exposes quote lifecycle retention and rollup storage t
assert.equal(payload.system.persistence.preserved_successful_quote_count, 2);
assert.equal(payload.system.persistence.quote_lifecycle_rollups[0].rollup_id, 'rollup-1');
assert.equal(payload.strategy.strategy_state.maker_competitiveness.persisted_rollup_count, 1);
assert.equal(payload.strategy.strategy_state.quote_lifecycle_statistics.persisted.rows[0].quote_count, 4);
assert.equal(payload.strategy.strategy_state.quote_lifecycle_statistics.live.rows[0].bucket_counts.awaiting_executor, 1);
});
test('basic auth resolves operator identity and reuses a session cookie', () => {
@ -572,6 +594,60 @@ test('socket lifecycle messages replace strategy rows without page refresh', ()
assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].live_flash_at, '2026-04-04T09:00:00.000Z');
});
test('socket lifecycle statistics update live counts while preserving persisted totals', () => {
const dashboard = {
funds: { recent_quotes: [] },
status_bar: {},
strategy: {
strategy_state: {
recent_lifecycle_rows: [],
quote_lifecycle_statistics: {
persisted: {
source: 'persisted',
rows: [{
grain: 'all_time',
quote_count: 12,
bucket_counts: { success: 2 },
}],
},
live: {
source: 'live_recent',
rows: [],
},
},
},
},
};
const state = {
dashboard,
session: { authenticated: true },
page: 'strategy-lifecycle',
};
const next = dashboardReducer(state, {
type: 'socket.message.received',
payload: {
type: 'quote_lifecycle.updated',
recent_lifecycle_rows: [],
quote_lifecycle_statistics: {
source: 'live_recent',
rows: [{
grain: 'five_minute',
quote_count: 1,
bucket_counts: { awaiting_executor: 1 },
}],
},
},
});
assert.equal(next.dashboard.strategy.strategy_state.quote_lifecycle_statistics.persisted.rows[0].quote_count, 12);
assert.equal(next.dashboard.strategy.strategy_state.quote_lifecycle_statistics.live.rows[0].quote_count, 1);
assert.equal(
next.dashboard.strategy.strategy_state.quote_lifecycle_statistics.live.rows[0].bucket_counts.awaiting_executor,
1,
);
});
test('selected awaiting executor quote remains inspectable after live rows move and refreshes from durable result', () => {
const config = buildConfig();
const awaiting = buildQuoteLifecycleLookupResponse({

View file

@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
loadQuoteLifecycleStatistics,
refreshQuoteLifecycleStatistics,
} from '../src/lib/postgres.mjs';
test('postgres quote lifecycle statistics refresh idempotently upserts state buckets by original quote window', async () => {
const upserts = [];
let includeFailure = false;
const pool = {
async query(sql, params = []) {
if (sql.includes('FROM swap_demand_events') && sql.includes('ORDER BY COALESCE')) {
return {
rows: [{
observed_at: '2026-06-12T17:31:01.000Z',
ingested_at: '2026-06-12T17:31:01.000Z',
payload: {
quote_id: 'quote-stat',
pair: 'btc->eure',
asset_in: 'btc',
asset_out: 'eure',
amount_in: '1',
amount_out: '2',
},
}],
};
}
if (sql.includes('FROM trade_decisions') && sql.includes('ORDER BY COALESCE')) {
return {
rows: [{
observed_at: '2026-06-12T17:31:02.000Z',
ingested_at: '2026-06-12T17:31:02.000Z',
payload: {
quote_id: 'quote-stat',
decision_id: 'decision-stat',
pair: 'btc->eure',
decision: 'approved',
decision_reason: 'strategy_approved',
},
}],
};
}
if (sql.includes('FROM execute_trade_commands') && sql.includes('ORDER BY COALESCE')) {
return {
rows: [{
observed_at: '2026-06-12T17:31:03.000Z',
ingested_at: '2026-06-12T17:31:03.000Z',
payload: {
quote_id: 'quote-stat',
decision_id: 'decision-stat',
command_id: 'cmd-stat',
pair: 'btc->eure',
},
}],
};
}
if (sql.includes('FROM trade_execution_results r')) {
return {
rows: includeFailure ? [{
result_observed_at: '2026-06-12T17:36:10.000Z',
result_ingested_at: '2026-06-12T17:36:10.000Z',
result_payload: {
quote_id: 'quote-stat',
decision_id: 'decision-stat',
command_id: 'cmd-stat',
status: 'failed',
result_code: 'submission_failed',
failure_category: 'quote_not_found_or_finished',
},
command_ingested_at: '2026-06-12T17:31:03.000Z',
command_payload: {
quote_id: 'quote-stat',
decision_id: 'decision-stat',
command_id: 'cmd-stat',
},
decision_payload: {
quote_id: 'quote-stat',
decision_id: 'decision-stat',
decision: 'approved',
},
outcome_payload: null,
}] : [],
};
}
if (sql.includes('FROM quote_outcome_attributions') && sql.includes('ORDER BY COALESCE')) {
return { rows: [] };
}
if (sql.includes('INSERT INTO quote_lifecycle_statistics')) {
upserts.push({ sql, params });
return { rows: [], rowCount: 1 };
}
throw new Error(`unexpected query: ${sql}`);
},
};
await refreshQuoteLifecycleStatistics(pool, {
now: '2026-06-12T17:32:00.000Z',
});
includeFailure = true;
await refreshQuoteLifecycleStatistics(pool, {
now: '2026-06-12T17:37:00.000Z',
});
const fiveMinuteUpserts = upserts.filter((query) => (
query.params[1] === 'five_minute'
&& query.params[2] === '2026-06-12T17:30:00.000Z'
));
assert.equal(fiveMinuteUpserts.length, 2);
assert.equal(fiveMinuteUpserts.at(-1).params[4], 1);
const latestBuckets = JSON.parse(fiveMinuteUpserts.at(-1).params[7]);
assert.equal(latestBuckets.submission_failed, 1);
assert.equal(latestBuckets.awaiting_executor, 0);
assert.equal(
upserts.some((query) => (
query.params[1] === 'five_minute'
&& query.params[2] === '2026-06-12T17:35:00.000Z'
)),
false,
);
});
test('postgres quote lifecycle statistics loader returns all-time and clamped grain rows', async () => {
const pool = {
async query(sql, params = []) {
assert.match(sql, /FROM quote_lifecycle_statistics/);
assert.deepEqual(params, [['all_time', 'five_minute'], 2]);
return {
rows: [{
stat_id: 'quote-lifecycle-stat:all_time:all',
grain: 'all_time',
window_start: null,
window_end: null,
quote_count: 3,
missing_identifier_count: 1,
missing_timestamp_count: 0,
bucket_counts: { success: 1, submission_failed: 2 },
computed_at: '2026-06-12T17:40:00.000Z',
}],
};
},
};
const rows = await loadQuoteLifecycleStatistics(pool, {
grains: ['all_time', 'five_minute'],
limit: 2,
});
assert.equal(rows.length, 1);
assert.equal(rows[0].quote_count, 3);
assert.equal(rows[0].bucket_counts.success, 1);
assert.equal(rows[0].bucket_counts.submitted_no_reply, 0);
});

View file

@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildQuoteLifecycleStatistics,
classifyQuoteLifecycleStatisticsBucket,
quoteLifecycleStatisticWindow,
} from '../src/core/quote-lifecycle-statistics.mjs';
test('quote lifecycle statistics classify states without upgrading submitted-only rows to success', () => {
assert.equal(classifyQuoteLifecycleStatisticsBucket({
lifecycle_state: 'submitted',
execution: { status: 'submitted', result_code: 'quote_response_ok' },
}), 'submitted_no_reply');
assert.equal(classifyQuoteLifecycleStatisticsBucket({
lifecycle_state: 'failed',
execution: { status: 'failed', failure_category: 'quote_not_found_or_finished' },
}), 'submission_failed');
assert.equal(classifyQuoteLifecycleStatisticsBucket({
lifecycle_state: 'command_emitted',
}), 'awaiting_executor');
assert.equal(classifyQuoteLifecycleStatisticsBucket({
lifecycle_state: 'completed',
outcome_status: 'completed',
has_settlement_evidence: true,
}), 'success');
});
test('quote lifecycle statistics use UTC grain boundaries with Monday UTC weeks', () => {
assert.deepEqual(quoteLifecycleStatisticWindow('2026-06-12T17:34:44.123Z', 'five_minute'), {
grain: 'five_minute',
window_start: '2026-06-12T17:30:00.000Z',
window_end: '2026-06-12T17:35:00.000Z',
});
assert.deepEqual(quoteLifecycleStatisticWindow('2026-06-14T23:59:00.000Z', 'week'), {
grain: 'week',
window_start: '2026-06-08T00:00:00.000Z',
window_end: '2026-06-15T00:00:00.000Z',
});
assert.deepEqual(quoteLifecycleStatisticWindow('2026-06-01T00:00:00.000Z', 'month'), {
grain: 'month',
window_start: '2026-06-01T00:00:00.000Z',
window_end: '2026-07-01T00:00:00.000Z',
});
});
test('quote lifecycle statistics dedupe quotes and refresh later results in the original quote window', () => {
const rows = [
{
quote_id: 'quote-follow',
lifecycle_state: 'command_emitted',
quote_activity_at: '2026-06-12T17:31:01.000Z',
latest_stage_at: '2026-06-12T17:31:03.000Z',
},
{
quote_id: 'quote-follow',
lifecycle_state: 'failed',
quote_activity_at: '2026-06-12T17:31:01.000Z',
latest_stage_at: '2026-06-12T17:36:10.000Z',
execution: {
status: 'failed',
failure_category: 'quote_not_found_or_finished',
},
},
];
const statistics = buildQuoteLifecycleStatistics({
lifecycleRows: rows,
computedAt: '2026-06-12T17:40:00.000Z',
});
const allTime = statistics.find((row) => row.grain === 'all_time');
const fiveMinute = statistics.find((row) => (
row.grain === 'five_minute'
&& row.window_start === '2026-06-12T17:30:00.000Z'
));
const laterFiveMinute = statistics.find((row) => (
row.grain === 'five_minute'
&& row.window_start === '2026-06-12T17:35:00.000Z'
));
assert.equal(allTime.quote_count, 1);
assert.equal(allTime.bucket_counts.submission_failed, 1);
assert.equal(allTime.bucket_counts.awaiting_executor, 0);
assert.equal(fiveMinute.quote_count, 1);
assert.equal(fiveMinute.bucket_counts.submission_failed, 1);
assert.equal(laterFiveMinute, undefined);
});
test('quote lifecycle statistics preserve missing identifiers as unavailable evidence', () => {
const statistics = buildQuoteLifecycleStatistics({
lifecycleRows: [{
quote_id: null,
lifecycle_state: 'observed',
quote_activity_at: '2026-06-12T17:31:01.000Z',
}],
computedAt: '2026-06-12T17:40:00.000Z',
});
const allTime = statistics.find((row) => row.grain === 'all_time');
const fiveMinute = statistics.find((row) => row.grain === 'five_minute');
assert.equal(allTime.quote_count, 0);
assert.equal(allTime.missing_identifier_count, 1);
assert.equal(allTime.bucket_counts.unavailable, 1);
assert.equal(fiveMinute.quote_count, 0);
assert.equal(fiveMinute.missing_identifier_count, 1);
});