fix(web-ui): ECharts 数字两位小数 + 取数自动翻页

图表格式化:新增 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)。
This commit is contained in:
Justin Gu
2026-07-03 04:18:23 +08:00
parent ff3476f83a
commit 360f836001
7 changed files with 83 additions and 22 deletions
+32 -15
View File
@@ -47,10 +47,12 @@ export async function fetchStrategies(): Promise<StrategiesResponse> {
/**
* 按标的取 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<Bar[]> {
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<string, unknown>[] }
// 后端 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<string, unknown>[] }
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
+11 -2
View File
@@ -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]]))
+8 -1
View File
@@ -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 }],
+7 -1
View File
@@ -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 },
+1 -1
View File
@@ -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: [
@@ -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
+13
View File
@@ -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)}%`
}