Add quote lifecycle statistics chart
All checks were successful
deploy / deploy (push) Successful in 1m10s
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:
parent
2e95c95246
commit
75d7f64627
6 changed files with 296 additions and 4 deletions
26
package-lock.json
generated
26
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "near-intents-monitor-poc",
|
"name": "near-intents-monitor-poc",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"near-api-js": "^7.2.0",
|
"near-api-js": "^7.2.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|
@ -1584,6 +1585,16 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.331",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"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==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
|
@ -2753,6 +2770,15 @@
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"near-api-js": "^7.2.0",
|
"near-api-js": "^7.2.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ async function handleApiRequest({ req, res, url, auth }) {
|
||||||
if (req.method === 'GET' && url.pathname === '/api/strategy/quote-lifecycle/statistics') {
|
if (req.method === 'GET' && url.pathname === '/api/strategy/quote-lifecycle/statistics') {
|
||||||
const payload = await loadQuoteLifecycleStatisticsPayload({
|
const payload = await loadQuoteLifecycleStatisticsPayload({
|
||||||
grains: url.searchParams.get('grains') || undefined,
|
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);
|
return sendJson(res, 200, payload);
|
||||||
}
|
}
|
||||||
|
|
@ -583,7 +583,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||||
),
|
),
|
||||||
safeSourceLoad(
|
safeSourceLoad(
|
||||||
'quote_lifecycle_statistics',
|
'quote_lifecycle_statistics',
|
||||||
() => loadQuoteLifecycleStatistics(pool, { limit: 120 }),
|
() => loadQuoteLifecycleStatistics(pool, { limit: 1000 }),
|
||||||
[],
|
[],
|
||||||
sourceErrors,
|
sourceErrors,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
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 EmptyState from '../components/EmptyState.jsx';
|
||||||
import MetricCard from '../components/MetricCard.jsx';
|
import MetricCard from '../components/MetricCard.jsx';
|
||||||
|
|
@ -13,6 +17,16 @@ import {
|
||||||
updateSelectedLifecycleFromLookup,
|
updateSelectedLifecycleFromLookup,
|
||||||
} from '../lib/quoteLifecycleInvestigator.js';
|
} from '../lib/quoteLifecycleInvestigator.js';
|
||||||
|
|
||||||
|
useEcharts([
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
DataZoomComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
||||||
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
|
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
|
||||||
const COMPETITIVENESS_GROUP_ROW_COUNT = 12;
|
const COMPETITIVENESS_GROUP_ROW_COUNT = 12;
|
||||||
|
|
@ -39,6 +53,21 @@ const QUOTE_LIFECYCLE_STAT_GRAINS = [
|
||||||
['month', 'Month'],
|
['month', 'Month'],
|
||||||
['all_time', 'All-time'],
|
['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 = [
|
const QUOTE_LIFECYCLE_STAT_BUCKETS = [
|
||||||
['success', 'Success'],
|
['success', 'Success'],
|
||||||
['submitted_no_reply', 'Submitted no reply'],
|
['submitted_no_reply', 'Submitted no reply'],
|
||||||
|
|
@ -51,6 +80,18 @@ const QUOTE_LIFECYCLE_STAT_BUCKETS = [
|
||||||
['observed_no_decision', 'Observed no decision'],
|
['observed_no_decision', 'Observed no decision'],
|
||||||
['unavailable', 'Unavailable'],
|
['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) {
|
async function copyIdentifier(value) {
|
||||||
if (!value || !navigator?.clipboard?.writeText) return;
|
if (!value || !navigator?.clipboard?.writeText) return;
|
||||||
|
|
@ -78,7 +119,7 @@ async function fetchQuoteLifecycleLookup(identifier) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchQuoteLifecycleStatistics() {
|
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: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
|
@ -258,6 +299,175 @@ function latestStatisticRow(rows, grain) {
|
||||||
))[0] || null;
|
))[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) {
|
function formatStatisticWindow(row) {
|
||||||
if (!row) return 'Unavailable';
|
if (!row) return 'Unavailable';
|
||||||
if (row.grain === 'all_time') return 'All-time';
|
if (row.grain === 'all_time') return 'All-time';
|
||||||
|
|
@ -927,8 +1137,10 @@ function QuoteLifecycleStatisticsPanel({
|
||||||
grain,
|
grain,
|
||||||
liveRows,
|
liveRows,
|
||||||
onGrainChange,
|
onGrainChange,
|
||||||
|
onRangeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
persistedRows,
|
persistedRows,
|
||||||
|
range,
|
||||||
state,
|
state,
|
||||||
}) {
|
}) {
|
||||||
const allTime = latestStatisticRow(persistedRows, 'all_time');
|
const allTime = latestStatisticRow(persistedRows, 'all_time');
|
||||||
|
|
@ -939,6 +1151,7 @@ function QuoteLifecycleStatisticsPanel({
|
||||||
const liveCurrent = latestStatisticRow(liveRows, grain);
|
const liveCurrent = latestStatisticRow(liveRows, grain);
|
||||||
const persistedGeneratedAt = persistedRows?.[0]?.computed_at || null;
|
const persistedGeneratedAt = persistedRows?.[0]?.computed_at || null;
|
||||||
const liveGeneratedAt = liveRows?.[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);
|
const rows = fixedRows(selectedRows, grain === 'all_time' ? 1 : 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -952,6 +1165,14 @@ function QuoteLifecycleStatisticsPanel({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<button className="button secondary" disabled={state === 'loading'} onClick={onRefresh} type="button">
|
||||||
{state === 'loading' ? 'Refreshing stats' : 'Refresh stats'}
|
{state === 'loading' ? 'Refreshing stats' : 'Refresh stats'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -977,6 +1198,11 @@ function QuoteLifecycleStatisticsPanel({
|
||||||
<strong>{formatInteger((selectedCurrent?.missing_identifier_count || 0) + (selectedCurrent?.missing_timestamp_count || 0))}</strong>
|
<strong>{formatInteger((selectedCurrent?.missing_identifier_count || 0) + (selectedCurrent?.missing_timestamp_count || 0))}</strong>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<TableFrame className="quote-statistics-frame">
|
||||||
<table className="quote-statistics-table">
|
<table className="quote-statistics-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -1038,6 +1264,7 @@ function QuoteLifecycleTable({ items, statistics }) {
|
||||||
const [showStrategyRejected, setShowStrategyRejected] = useState(true);
|
const [showStrategyRejected, setShowStrategyRejected] = useState(true);
|
||||||
const [problemFilter, setProblemFilter] = useState('all');
|
const [problemFilter, setProblemFilter] = useState('all');
|
||||||
const [statGrain, setStatGrain] = useState('five_minute');
|
const [statGrain, setStatGrain] = useState('five_minute');
|
||||||
|
const [statRange, setStatRange] = useState('one_day');
|
||||||
const [statsState, setStatsState] = useState('idle');
|
const [statsState, setStatsState] = useState('idle');
|
||||||
const [statsError, setStatsError] = useState(null);
|
const [statsError, setStatsError] = useState(null);
|
||||||
const [statisticsSnapshot, setStatisticsSnapshot] = useState(() => statistics || null);
|
const [statisticsSnapshot, setStatisticsSnapshot] = useState(() => statistics || null);
|
||||||
|
|
@ -1232,8 +1459,10 @@ function QuoteLifecycleTable({ items, statistics }) {
|
||||||
grain={statGrain}
|
grain={statGrain}
|
||||||
liveRows={liveStatisticRows}
|
liveRows={liveStatisticRows}
|
||||||
onGrainChange={setStatGrain}
|
onGrainChange={setStatGrain}
|
||||||
|
onRangeChange={setStatRange}
|
||||||
onRefresh={() => refreshStatistics().catch(() => {})}
|
onRefresh={() => refreshStatistics().catch(() => {})}
|
||||||
persistedRows={persistedStatisticRows}
|
persistedRows={persistedStatisticRows}
|
||||||
|
range={statRange}
|
||||||
state={statsState}
|
state={statsState}
|
||||||
/>
|
/>
|
||||||
<form className="quote-lifecycle-lookup" onSubmit={submitLookup}>
|
<form className="quote-lifecycle-lookup" onSubmit={submitLookup}>
|
||||||
|
|
|
||||||
|
|
@ -631,7 +631,12 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
min-width: 180px;
|
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;
|
font-size: 0.84rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
@ -663,6 +668,22 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.quote-statistics-table {
|
||||||
min-width: 980px;
|
min-width: 980px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
const strategySource = readFileSync(new URL('../src/operator-dashboard/static/pages/StrategyPage.jsx', import.meta.url), 'utf8');
|
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 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 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');
|
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, /\/api\/strategy\/quote-lifecycle\//);
|
||||||
assert.match(strategySource, /QUOTE_LIFECYCLE_PROBLEM_FILTERS/);
|
assert.match(strategySource, /QUOTE_LIFECYCLE_PROBLEM_FILTERS/);
|
||||||
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_GRAINS/);
|
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_GRAINS/);
|
||||||
|
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_RANGES/);
|
||||||
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_BUCKETS/);
|
assert.match(strategySource, /QUOTE_LIFECYCLE_STAT_BUCKETS/);
|
||||||
assert.match(strategySource, /fetchQuoteLifecycleStatistics/);
|
assert.match(strategySource, /fetchQuoteLifecycleStatistics/);
|
||||||
assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\/statistics/);
|
assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\/statistics/);
|
||||||
|
assert.match(strategySource, /statistics\?limit=1000/);
|
||||||
assert.match(strategySource, /five_minute/);
|
assert.match(strategySource, /five_minute/);
|
||||||
assert.match(strategySource, /all_time/);
|
assert.match(strategySource, /all_time/);
|
||||||
assert.match(strategySource, /submitted_no_reply/);
|
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, /success/);
|
||||||
assert.match(strategySource, /quote_lifecycle_statistics/);
|
assert.match(strategySource, /quote_lifecycle_statistics/);
|
||||||
assert.match(strategySource, /Refresh stats/);
|
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, /awaiting_executor/);
|
||||||
assert.match(strategySource, /quote_not_found_or_finished/);
|
assert.match(strategySource, /quote_not_found_or_finished/);
|
||||||
assert.match(strategySource, /approved_no_command/);
|
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(strategySource, /quote-lifecycle-placeholder-row/);
|
||||||
assert.match(stylesSource, /\.quote-investigator-panel/);
|
assert.match(stylesSource, /\.quote-investigator-panel/);
|
||||||
assert.match(stylesSource, /\.quote-statistics-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-statistics-table/);
|
||||||
assert.match(stylesSource, /\.quote-lifecycle-lookup/);
|
assert.match(stylesSource, /\.quote-lifecycle-lookup/);
|
||||||
assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row\.is-selected/);
|
assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row\.is-selected/);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue