From 75d7f64627df80c0713afb9893c29c7262c4828e Mon Sep 17 00:00:00 2001 From: philipp Date: Fri, 12 Jun 2026 20:34:41 +0200 Subject: [PATCH] Add quote lifecycle statistics chart Proof: Added an ECharts stacked persisted quote lifecycle statistics chart with range controls, raised dashboard statistics loads to the existing 1000-row clamp, and covered the UI wiring with static regression assertions. Ran targeted dashboard tests, full npm test, and npm run operator-dashboard:build. Assumptions: Persisted quote lifecycle statistics remain the durable source of truth for the chart; live counters stay separate until the history rollup refresh records them. Still fake: Fee-aware PnL, quote size distributions, oracle-deviation analytics, and any already-pruned lifecycle detail remain out of scope; the chart can only show retained persisted statistics. --- package-lock.json | 26 ++ package.json | 1 + src/apps/operator-dashboard.mjs | 4 +- .../static/pages/StrategyPage.jsx | 231 +++++++++++++++++- src/operator-dashboard/static/styles.css | 23 +- test/operator-dashboard-ui-static.test.mjs | 15 ++ 6 files changed, 296 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 12983c9..ece8491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "near-intents-monitor-poc", "version": "0.1.0", "dependencies": { + "echarts": "^5.6.0", "kafkajs": "^2.2.4", "near-api-js": "^7.2.0", "pg": "^8.20.0", @@ -1584,6 +1585,16 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -2564,6 +2575,12 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -2753,6 +2770,15 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index 666d681..3d9d8c8 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "node --test" }, "dependencies": { + "echarts": "^5.6.0", "kafkajs": "^2.2.4", "near-api-js": "^7.2.0", "pg": "^8.20.0", diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 705b625..87e5ed0 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -388,7 +388,7 @@ async function handleApiRequest({ req, res, url, auth }) { 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), + limit: Number(url.searchParams.get('limit') || 1000), }); return sendJson(res, 200, payload); } @@ -583,7 +583,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { ), safeSourceLoad( 'quote_lifecycle_statistics', - () => loadQuoteLifecycleStatistics(pool, { limit: 120 }), + () => loadQuoteLifecycleStatistics(pool, { limit: 1000 }), [], sourceErrors, ), diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index f0ab027..3e44353 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -1,4 +1,8 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { BarChart, LineChart } from 'echarts/charts'; +import { DataZoomComponent, GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'; +import { init as initEcharts, use as useEcharts } from 'echarts/core'; +import { CanvasRenderer } from 'echarts/renderers'; import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; @@ -13,6 +17,16 @@ import { updateSelectedLifecycleFromLookup, } from '../lib/quoteLifecycleInvestigator.js'; +useEcharts([ + BarChart, + LineChart, + DataZoomComponent, + GridComponent, + LegendComponent, + TooltipComponent, + CanvasRenderer, +]); + const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']); const COMPETITIVENESS_GROUP_ROW_COUNT = 12; @@ -39,6 +53,21 @@ const QUOTE_LIFECYCLE_STAT_GRAINS = [ ['month', 'Month'], ['all_time', 'All-time'], ]; +const QUOTE_LIFECYCLE_STAT_RANGES = [ + ['one_hour', '1h'], + ['six_hours', '6h'], + ['one_day', '24h'], + ['seven_days', '7d'], + ['thirty_days', '30d'], + ['all_loaded', 'Loaded'], +]; +const QUOTE_LIFECYCLE_STAT_RANGE_DURATIONS_MS = { + one_hour: 60 * 60 * 1000, + six_hours: 6 * 60 * 60 * 1000, + one_day: 24 * 60 * 60 * 1000, + seven_days: 7 * 24 * 60 * 60 * 1000, + thirty_days: 30 * 24 * 60 * 60 * 1000, +}; const QUOTE_LIFECYCLE_STAT_BUCKETS = [ ['success', 'Success'], ['submitted_no_reply', 'Submitted no reply'], @@ -51,6 +80,18 @@ const QUOTE_LIFECYCLE_STAT_BUCKETS = [ ['observed_no_decision', 'Observed no decision'], ['unavailable', 'Unavailable'], ]; +const QUOTE_LIFECYCLE_STAT_BUCKET_COLORS = { + success: '#1f7a5a', + submitted_no_reply: '#0f5b84', + submission_failed: '#b64328', + awaiting_executor: '#b36a0c', + approved_no_command: '#6d58a8', + rejected_by_strategy: '#7b8794', + blocked_before_submit: '#9b4d2f', + not_filled: '#566f3d', + observed_no_decision: '#4b5563', + unavailable: '#9ca3af', +}; async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -78,7 +119,7 @@ async function fetchQuoteLifecycleLookup(identifier) { } async function fetchQuoteLifecycleStatistics() { - const response = await fetch('/api/strategy/quote-lifecycle/statistics?limit=160', { + const response = await fetch('/api/strategy/quote-lifecycle/statistics?limit=1000', { headers: { accept: 'application/json', }, @@ -258,6 +299,175 @@ function latestStatisticRow(rows, grain) { ))[0] || null; } +function statisticRowsForGrain(rows, grain) { + return (rows || []) + .filter((row) => row?.grain === grain) + .sort((left, right) => (statisticRowWindowMarker(left) || 0) - (statisticRowWindowMarker(right) || 0)); +} + +function statisticRowWindowMarker(row) { + const end = Date.parse(row?.window_end || ''); + if (Number.isFinite(end)) return end; + const start = Date.parse(row?.window_start || ''); + if (Number.isFinite(start)) return start; + const computed = Date.parse(row?.computed_at || ''); + return Number.isFinite(computed) ? computed : null; +} + +function statisticRowsForRange(rows, grain, range, now = Date.now()) { + const grainRows = statisticRowsForGrain(rows, grain); + if (grain === 'all_time' || range === 'all_loaded') return grainRows; + + const durationMs = QUOTE_LIFECYCLE_STAT_RANGE_DURATIONS_MS[range]; + if (!durationMs) return grainRows; + const cutoff = now - durationMs; + const rangedRows = grainRows.filter((row) => { + const marker = statisticRowWindowMarker(row); + return marker == null || marker >= cutoff; + }); + return rangedRows.length ? rangedRows : grainRows.slice(-1); +} + +function formatStatisticChartLabel(row) { + if (!row) return 'Unavailable'; + if (row.grain === 'all_time') return 'All-time'; + const at = Date.parse(row.window_start || row.window_end || ''); + if (!Number.isFinite(at)) return 'Unavailable'; + const date = new Date(at); + const options = row.grain === 'five_minute' || row.grain === 'hour' + ? { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' } + : row.grain === 'day' || row.grain === 'week' + ? { month: 'short', day: '2-digit', timeZone: 'UTC' } + : { year: 'numeric', month: 'short', timeZone: 'UTC' }; + return new Intl.DateTimeFormat('en-GB', options).format(date).replace(',', ''); +} + +function buildQuoteLifecycleStatisticsChartOption(rows) { + const labels = rows.map(formatStatisticChartLabel); + const stateSeries = QUOTE_LIFECYCLE_STAT_BUCKETS.map(([bucket, label]) => ({ + name: label, + type: 'bar', + stack: 'quote-states', + emphasis: { focus: 'series' }, + data: rows.map((row) => statisticBucketCount(row, bucket)), + itemStyle: { + color: QUOTE_LIFECYCLE_STAT_BUCKET_COLORS[bucket], + }, + })); + + return { + backgroundColor: 'transparent', + animationDuration: 220, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (value) => formatInteger(value), + }, + legend: { + type: 'scroll', + top: 0, + left: 0, + right: 0, + textStyle: { color: '#60716a' }, + pageIconColor: '#1f7a5a', + pageTextStyle: { color: '#60716a' }, + }, + grid: { + left: 48, + right: 18, + top: 74, + bottom: rows.length > 18 ? 64 : 38, + containLabel: true, + }, + xAxis: { + type: 'category', + data: labels, + axisLabel: { + color: '#60716a', + hideOverlap: true, + rotate: rows.length > 18 ? 35 : 0, + }, + axisLine: { lineStyle: { color: 'rgba(24, 33, 30, 0.22)' } }, + axisTick: { alignWithLabel: true }, + }, + yAxis: { + type: 'value', + minInterval: 1, + splitLine: { lineStyle: { color: 'rgba(24, 33, 30, 0.1)' } }, + axisLabel: { + color: '#60716a', + formatter: (value) => formatInteger(value), + }, + }, + dataZoom: rows.length > 24 + ? [ + { type: 'inside', throttle: 50 }, + { + type: 'slider', + height: 18, + bottom: 12, + borderColor: 'rgba(24, 33, 30, 0.14)', + fillerColor: 'rgba(31, 122, 90, 0.16)', + handleStyle: { color: '#1f7a5a' }, + textStyle: { color: '#60716a' }, + }, + ] + : [], + series: [ + ...stateSeries, + { + name: 'Total', + type: 'line', + smooth: false, + symbolSize: 5, + data: rows.map((row) => Number(row?.quote_count || 0)), + lineStyle: { width: 2, color: '#18211e' }, + itemStyle: { color: '#18211e' }, + emphasis: { focus: 'series' }, + }, + ], + }; +} + +function QuoteLifecycleStatisticsChart({ grain, range, rows }) { + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const option = useMemo(() => buildQuoteLifecycleStatisticsChartOption(rows), [rows]); + + useEffect(() => { + if (!chartRef.current) return undefined; + const chart = initEcharts(chartRef.current, null, { renderer: 'canvas' }); + chartInstanceRef.current = chart; + + function resizeChart() { + chart.resize(); + } + + window.addEventListener('resize', resizeChart); + return () => { + window.removeEventListener('resize', resizeChart); + chart.dispose(); + chartInstanceRef.current = null; + }; + }, []); + + useEffect(() => { + if (!chartInstanceRef.current) return; + chartInstanceRef.current.clear(); + chartInstanceRef.current.setOption(option); + chartInstanceRef.current.resize(); + }, [option]); + + return ( +
+ ); +} + function formatStatisticWindow(row) { if (!row) return 'Unavailable'; if (row.grain === 'all_time') return 'All-time'; @@ -927,8 +1137,10 @@ function QuoteLifecycleStatisticsPanel({ grain, liveRows, onGrainChange, + onRangeChange, onRefresh, persistedRows, + range, state, }) { const allTime = latestStatisticRow(persistedRows, 'all_time'); @@ -939,6 +1151,7 @@ function QuoteLifecycleStatisticsPanel({ const liveCurrent = latestStatisticRow(liveRows, grain); const persistedGeneratedAt = persistedRows?.[0]?.computed_at || null; const liveGeneratedAt = liveRows?.[0]?.computed_at || null; + const chartRows = statisticRowsForRange(persistedRows, grain, range); const rows = fixedRows(selectedRows, grain === 'all_time' ? 1 : 8); return ( @@ -952,6 +1165,14 @@ function QuoteLifecycleStatisticsPanel({ ))} + @@ -977,6 +1198,11 @@ function QuoteLifecycleStatisticsPanel({ {formatInteger((selectedCurrent?.missing_identifier_count || 0) + (selectedCurrent?.missing_timestamp_count || 0))}
+ {chartRows.length ? ( + + ) : ( +
No persisted quote statistics are available for this range.
+ )} @@ -1038,6 +1264,7 @@ function QuoteLifecycleTable({ items, statistics }) { const [showStrategyRejected, setShowStrategyRejected] = useState(true); const [problemFilter, setProblemFilter] = useState('all'); const [statGrain, setStatGrain] = useState('five_minute'); + const [statRange, setStatRange] = useState('one_day'); const [statsState, setStatsState] = useState('idle'); const [statsError, setStatsError] = useState(null); const [statisticsSnapshot, setStatisticsSnapshot] = useState(() => statistics || null); @@ -1232,8 +1459,10 @@ function QuoteLifecycleTable({ items, statistics }) { grain={statGrain} liveRows={liveStatisticRows} onGrainChange={setStatGrain} + onRangeChange={setStatRange} onRefresh={() => refreshStatistics().catch(() => {})} persistedRows={persistedStatisticRows} + range={statRange} state={statsState} /> diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index 9df35d6..5812b45 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -631,7 +631,12 @@ table.lifecycle-table th:nth-child(5) { min-width: 180px; } -.quote-statistics-grain-field span { +.quote-statistics-range-field { + min-width: 150px; +} + +.quote-statistics-grain-field span, +.quote-statistics-range-field span { font-size: 0.84rem; color: var(--muted); } @@ -663,6 +668,22 @@ table.lifecycle-table th:nth-child(5) { font-variant-numeric: tabular-nums; } +.quote-statistics-chart { + width: 100%; + height: 320px; + min-height: 320px; +} + +.quote-statistics-chart-empty { + min-height: 160px; + display: grid; + place-items: center; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.34); +} + .quote-statistics-table { min-width: 980px; table-layout: fixed; diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 8b6be79..5cf964f 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const strategySource = readFileSync(new URL('../src/operator-dashboard/static/pages/StrategyPage.jsx', import.meta.url), 'utf8'); +const packageSource = readFileSync(new URL('../package.json', import.meta.url), 'utf8'); const fundsSource = readFileSync(new URL('../src/operator-dashboard/static/pages/FundsPage.jsx', import.meta.url), 'utf8'); const stylesSource = readFileSync(new URL('../src/operator-dashboard/static/styles.css', import.meta.url), 'utf8'); const serviceCardSource = readFileSync(new URL('../src/operator-dashboard/static/components/ServiceCard.jsx', import.meta.url), 'utf8'); @@ -41,9 +42,11 @@ test('strategy page owns consolidated quote lifecycle and successful trade table 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_RANGES/); assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_BUCKETS/); assert.match(strategySource, /fetchQuoteLifecycleStatistics/); assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\/statistics/); + assert.match(strategySource, /statistics\?limit=1000/); assert.match(strategySource, /five_minute/); assert.match(strategySource, /all_time/); assert.match(strategySource, /submitted_no_reply/); @@ -51,6 +54,16 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /success/); assert.match(strategySource, /quote_lifecycle_statistics/); assert.match(strategySource, /Refresh stats/); + assert.match(strategySource, /Chart range/); + assert.match(strategySource, /QuoteLifecycleStatisticsChart/); + assert.match(strategySource, /echarts\/core/); + assert.match(strategySource, /BarChart/); + assert.match(strategySource, /LineChart/); + assert.match(strategySource, /DataZoomComponent/); + assert.match(strategySource, /stack: 'quote-states'/); + assert.match(strategySource, /name: 'Total'/); + assert.match(strategySource, /quote-statistics-chart/); + assert.match(packageSource, /"echarts": "\^5\.6\.0"/); assert.match(strategySource, /awaiting_executor/); assert.match(strategySource, /quote_not_found_or_finished/); assert.match(strategySource, /approved_no_command/); @@ -65,6 +78,8 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /quote-lifecycle-placeholder-row/); assert.match(stylesSource, /\.quote-investigator-panel/); assert.match(stylesSource, /\.quote-statistics-panel/); + assert.match(stylesSource, /\.quote-statistics-chart/); + assert.match(stylesSource, /\.quote-statistics-range-field/); 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/);