From 360f8360016ede45b35382d16c58d8e2d6a10c3d Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Fri, 3 Jul 2026 04:18:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(web-ui):=20ECharts=20=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E4=B8=A4=E4=BD=8D=E5=B0=8F=E6=95=B0=20+=20=E5=8F=96=E6=95=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BF=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图表格式化:新增 format.ts(fmt2/fmtPct),在 KlineChart/EquityChart/ CompareChart/PortfolioCompareChart/OptimizeHeatmap 统一应用——tooltip 用 valueFormatter、axis 用 axisLabel.formatter,所有数值显示两位小数。 取数翻页:fetchBars 当日期跨度超过 800 根时,循环调用 start=0,800,1600... 拼接结果,直到覆盖 startDate 或达上限(10 页 ≈ 32 年日线)。翻页后再按 日期范围闭区间过滤。实测 SZ:000001 翻 2 页拿到 1600 根(2019~2026)。 --- web-ui/src/api.ts | 47 +++++++++++++------ web-ui/src/components/CompareChart.vue | 13 ++++- web-ui/src/components/EquityChart.vue | 9 +++- web-ui/src/components/KlineChart.vue | 8 +++- web-ui/src/components/OptimizeHeatmap.vue | 2 +- .../src/components/PortfolioCompareChart.vue | 13 ++++- web-ui/src/format.ts | 13 +++++ 7 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 web-ui/src/format.ts diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index a61b4e6..740081a 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -47,10 +47,12 @@ export async function fetchStrategies(): Promise { /** * 按标的取 K 线行情(OHLCV)。 * - * 后端 /bars 仅支持按 count 取数(上限 800 根,约 3.2 年日线),不支持日期范围。 - * 这里固定拉满 800 根,由调用方按日期范围在前端过滤。 + * 后端 /bars 单次最多 800 根。当 startDate 到 endDate 跨度超过 800 根时, + * 自动分页拉取(start=0, 800, 1600...)拼接,直到覆盖 startDate 或达上限。 * 可选 startDate/endDate 对结果做闭区间过滤(ISO 日期字符串,如 "2024-01-01")。 */ +const MAX_PAGES = 10 // 翻页上限:10 × 800 = 8000 根(约 32 年日线) + export async function fetchBars( market: string, code: string, @@ -58,19 +60,34 @@ export async function fetchBars( startDate?: string, endDate?: string, ): Promise { - const params = new URLSearchParams({ - market, - code, - category, - count: '800', // 后端硬上限,前端按日期过滤 - }) - const resp = await fetch(`${BASE}/bars?${params}`) - if (!resp.ok) await throwError(resp) - const body = (await resp.json()) as { data: Record[] } - // 后端 bars 列名不统一:日线及以上是 `date`,分钟线是 `datetime`。 - // 归一化为统一 `datetime` 字段(取 ISO 前 19 位)。 - let bars = body.data.map((row) => normalizeBar(row)) - // 按日期范围过滤(闭区间,比较日期部分 YYYY-MM-DD) + let allBars: Bar[] = [] + for (let page = 0; page < MAX_PAGES; page++) { + const params = new URLSearchParams({ + market, + code, + category, + count: '800', + start: String(page * 800), + }) + const resp = await fetch(`${BASE}/bars?${params}`) + if (!resp.ok) await throwError(resp) + const body = (await resp.json()) as { data: Record[] } + const pageBars = body.data.map((row) => normalizeBar(row)) + if (pageBars.length === 0) break // 无更多数据 + + allBars = allBars.concat(pageBars) + + // 若已覆盖到 startDate(本页最早一根 ≤ startDate),停止翻页 + if (startDate && pageBars.length > 0) { + const oldest = pageBars[pageBars.length - 1].datetime.slice(0, 10) + if (oldest <= startDate) break + } + // 不足 800 根说明已到数据起点 + if (pageBars.length < 800) break + } + + // 按日期范围过滤(闭区间) + let bars = allBars if (startDate) bars = bars.filter((b) => b.datetime.slice(0, 10) >= startDate) if (endDate) bars = bars.filter((b) => b.datetime.slice(0, 10) <= endDate) return bars diff --git a/web-ui/src/components/CompareChart.vue b/web-ui/src/components/CompareChart.vue index 254e66f..6c50deb 100644 --- a/web-ui/src/components/CompareChart.vue +++ b/web-ui/src/components/CompareChart.vue @@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import echarts from '../echarts-setup' +import { fmt2 } from '../format' import type { BacktestResult } from '../types' const props = defineProps<{ @@ -34,11 +35,19 @@ function buildOption(): echarts.EChartsCoreOption { return { backgroundColor: 'transparent', - tooltip: { trigger: 'axis' }, + tooltip: { + trigger: 'axis', + valueFormatter: (v: number | string) => fmt2(Number(v)), + }, legend: { top: 0, data: seriesData.map((s) => s.name) }, grid: { left: '8%', right: '5%', top: 30, bottom: 50 }, xAxis: { type: 'category', data: allDates, boundaryGap: false }, - yAxis: { type: 'value', scale: true, name: '归一化净值' }, + yAxis: { + type: 'value', + scale: true, + name: '归一化净值', + axisLabel: { formatter: (v: number) => fmt2(v) }, + }, dataZoom: [{ type: 'inside', start: 0, end: 100 }], series: seriesData.map((s) => { const valMap = new Map(s.dates.map((d, i) => [d, s.values[i]])) diff --git a/web-ui/src/components/EquityChart.vue b/web-ui/src/components/EquityChart.vue index a802a34..3c79192 100644 --- a/web-ui/src/components/EquityChart.vue +++ b/web-ui/src/components/EquityChart.vue @@ -5,6 +5,7 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import echarts from '../echarts-setup' +import { fmt2 } from '../format' import type { EquityPoint } from '../types' const props = defineProps<{ @@ -28,7 +29,11 @@ function buildOption(): echarts.EChartsCoreOption { return { backgroundColor: 'transparent', - tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + valueFormatter: (v: number | string) => fmt2(Number(v)), + }, legend: { data: ['净值', '回撤%'], top: 0 }, grid: { left: '8%', right: '8%', top: 30, bottom: 50 }, xAxis: { @@ -44,12 +49,14 @@ function buildOption(): echarts.EChartsCoreOption { scale: true, position: 'left', splitLine: { lineStyle: { color: '#2a2e3a' } }, + axisLabel: { formatter: (v: number) => fmt2(v) }, }, { type: 'value', name: '回撤%', position: 'right', splitLine: { show: false }, + axisLabel: { formatter: (v: number) => fmt2(v) }, }, ], dataZoom: [{ type: 'inside', start: 0, end: 100 }], diff --git a/web-ui/src/components/KlineChart.vue b/web-ui/src/components/KlineChart.vue index 4439837..d1778f7 100644 --- a/web-ui/src/components/KlineChart.vue +++ b/web-ui/src/components/KlineChart.vue @@ -5,6 +5,7 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup' +import { fmt2 } from '../format' import type { Bar, Trade } from '../types' const props = defineProps<{ @@ -68,7 +69,11 @@ function buildOption(): echarts.EChartsCoreOption { return { backgroundColor: 'transparent', - tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + valueFormatter: (v: number | string) => fmt2(Number(v)), + }, legend: { data: ['K线'], top: 0 }, grid: { left: '8%', right: '3%', top: 30, bottom: 60 }, xAxis: { @@ -81,6 +86,7 @@ function buildOption(): echarts.EChartsCoreOption { yAxis: { scale: true, splitLine: { lineStyle: { color: '#2a2e3a' } }, + axisLabel: { formatter: (v: number) => fmt2(v) }, }, dataZoom: [ { type: 'inside', start: 60, end: 100 }, diff --git a/web-ui/src/components/OptimizeHeatmap.vue b/web-ui/src/components/OptimizeHeatmap.vue index 55ff99d..6d55257 100644 --- a/web-ui/src/components/OptimizeHeatmap.vue +++ b/web-ui/src/components/OptimizeHeatmap.vue @@ -47,7 +47,7 @@ function buildOption(): echarts.EChartsCoreOption { orient: 'horizontal', left: 'center', bottom: 0, - formatter: (v: number) => `${(v * 100).toFixed(0)}%`, + formatter: (v: number) => `${(v * 100).toFixed(2)}%`, inRange: { color: ['#18a058', '#2a2e3a', '#ef4146'] }, // 绿(低)→暗→红(高) }, series: [ diff --git a/web-ui/src/components/PortfolioCompareChart.vue b/web-ui/src/components/PortfolioCompareChart.vue index e367f80..245c5cd 100644 --- a/web-ui/src/components/PortfolioCompareChart.vue +++ b/web-ui/src/components/PortfolioCompareChart.vue @@ -4,6 +4,7 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import echarts from '../echarts-setup' +import { fmt2 } from '../format' import type { BacktestResult } from '../types' const props = defineProps<{ @@ -43,11 +44,19 @@ function buildOption( const allDates = Array.from(new Set(series.flatMap((s) => s.dates))).sort() return { backgroundColor: 'transparent', - tooltip: { trigger: 'axis' }, + tooltip: { + trigger: 'axis', + valueFormatter: (v: number | string) => fmt2(Number(v)), + }, legend: { top: 0, data: series.map((s) => s.name) }, grid: { left: '8%', right: '5%', top: 30, bottom: 50 }, xAxis: { type: 'category', data: allDates, boundaryGap: false }, - yAxis: { type: 'value', scale: true, name: '归一化净值' }, + yAxis: { + type: 'value', + scale: true, + name: '归一化净值', + axisLabel: { formatter: (v: number) => fmt2(v) }, + }, dataZoom: [{ type: 'inside', start: 0, end: 100 }], series: series.map((s) => { // 按 allDates 对齐(缺失日期 forward-fill) diff --git a/web-ui/src/format.ts b/web-ui/src/format.ts new file mode 100644 index 0000000..b41fbbc --- /dev/null +++ b/web-ui/src/format.ts @@ -0,0 +1,13 @@ +// 共享数字格式化工具。ECharts tooltip/axis 统一用两位小数。 + +/** 数字保留两位小数(NaN/Inf 返回 '-')。 */ +export function fmt2(v: number | null | undefined): string { + if (v === null || v === undefined || !Number.isFinite(v)) return '-' + return v.toFixed(2) +} + +/** 百分比(接受小数如 0.1234 → "12.34%")。 */ +export function fmtPct(v: number | null | undefined): string { + if (v === null || v === undefined || !Number.isFinite(v)) return '-' + return `${(v * 100).toFixed(2)}%` +}