Add quote lifecycle statistics chart
All checks were successful
deploy / deploy (push) Successful in 1m10s

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.
This commit is contained in:
philipp 2026-06-12 20:34:41 +02:00
parent 2e95c95246
commit 75d7f64627
6 changed files with 296 additions and 4 deletions

26
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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,
),

View file

@ -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 (
<div
aria-label={`Quote lifecycle statistics chart, ${plainCodeLabel(grain)}, ${plainCodeLabel(range)}`}
className="quote-statistics-chart"
ref={chartRef}
role="img"
/>
);
}
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({
))}
</select>
</label>
<label className="field quote-statistics-range-field">
<span>Chart range</span>
<select disabled={grain === 'all_time'} value={range} onChange={(event) => onRangeChange(event.target.value)}>
{QUOTE_LIFECYCLE_STAT_RANGES.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>
@ -977,6 +1198,11 @@ function QuoteLifecycleStatisticsPanel({
<strong>{formatInteger((selectedCurrent?.missing_identifier_count || 0) + (selectedCurrent?.missing_timestamp_count || 0))}</strong>
</div>
</div>
{chartRows.length ? (
<QuoteLifecycleStatisticsChart grain={grain} range={range} rows={chartRows} />
) : (
<div className="quote-statistics-chart-empty">No persisted quote statistics are available for this range.</div>
)}
<TableFrame className="quote-statistics-frame">
<table className="quote-statistics-table">
<thead>
@ -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}
/>
<form className="quote-lifecycle-lookup" onSubmit={submitLookup}>

View file

@ -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;

View file

@ -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/);