Files
easy_tdx_max/web-ui/src/components/KlineChart.vue
T
Justin Gu 6aab7a82b5 feat(web-ui): Vue3 + ECharts 回测前端(单标的 + 组合回测)
独立前端工程(web-ui/),Vue3 + Vite + TypeScript + Pinia + ECharts。
通过 vite proxy 对接后端 /api,开发期无跨域。

单标的回测(/):
- StrategyPicker: 策略下拉 + 按 schema 动态渲染参数表单
- SymbolPicker: 选标的 + 日期范围取行情(默认3年,结束日=今天)
- KlineChart: K线主图 + 买卖点 markPoint(按 datetime 对齐)
- EquityChart: 净值曲线 + 回撤双轴图
- MetricTable: 19 项绩效指标(收益/风险/交易分组)
- TradeTable: 成交记录表

组合回测(/portfolio):
- StocksPicker: 多标的输入(增删标签)
- PortfolioCompareChart: 各标的净值叠加(归一化对比)
- PortfolioSummaryTable: 各标的绩效横向对比
- 顶部导航切换单标的/组合

契约修复(/check 审计):
- fetchBars 归一化 date/datetime 列名(日线返回 date)
- K线买卖点按完整 datetime 对齐(分钟线不再折叠)
- count 上限对齐后端 800
- ECharts 注册内联 dark 主题
- 参数表单空输入不打断编辑
- 网络错误友好提示

技术栈:Vue 3.5 / Vite 8 / TS 6 / Pinia / ECharts(按需引入 725KB)
2026-07-03 03:35:20 +08:00

135 lines
3.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// K线主图 + 买卖点标注(ECharts candlestick + markPoint)。
// 核心难点:把 trades 的 datetime 对齐到 K线时间轴——按 datetime 字符串建 index map。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts, { DOWN_COLOR, UP_COLOR } from '../echarts-setup'
import type { Bar, Trade } from '../types'
const props = defineProps<{
bars: Bar[]
trades: Trade[]
}>()
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value || props.bars.length === 0) return
chart ??= echarts.init(container.value, 'dark')
chart.setOption(buildOption(), true)
}
/** 构建 ECharts 配置。trades 的 datetime 对齐到 K线 index。 */
function buildOption(): echarts.EChartsCoreOption {
// 用完整 datetime 做 index key(避免分钟线 slice(0,10) 把同日 bar 折叠)。
// datetime 已由 api.ts 归一化为 "YYYY-MM-DDTHH:MM:SS" 格式。
const keys = props.bars.map((b) => b.datetime)
const keyIndex = new Map<string, number>()
keys.forEach((k, i) => keyIndex.set(k, i))
// x 轴显示:日线只显示日期,分钟线显示日期+时间
const isIntraday = keys.some((k) => k.length > 10)
const dates = keys.map((k) => (isIntraday ? k.replace('T', ' ').slice(5, 16) : k.slice(0, 10)))
const ohlc = props.bars.map((b) => [b.open, b.close, b.low, b.high])
// 买卖点 markPoint:按 trade.datetime 查 K线 indexprice 定位 y 轴。
// 注意:回测引擎的 trades datetime 目前是日线精度(YYYY-MM-DDT00:00:00),
// 分钟线回测时可能无法精确对齐到具体分钟 bar——这是引擎层限制,前端做容错。
const markPoints: Array<{
name: string
coord: [number, number]
itemStyle: { color: string }
symbol: string
symbolSize: number
}> = []
for (const t of props.trades) {
if (t.rejected) continue
// 归一化 trade datetime 与 bar key 同格式
const tKey = t.datetime.slice(0, 19).replace(' ', 'T')
let idx = keyIndex.get(tKey)
if (idx === undefined) {
// 引擎 trade 精度不足(日线回测分钟线场景):退回按日期首根 bar 匹配
const dayPrefix = tKey.slice(0, 10)
idx = keys.findIndex((k) => k.startsWith(dayPrefix))
if (idx === -1) continue
}
const isBuy = t.direction === 'BUY'
markPoints.push({
name: t.direction,
coord: [idx, t.price],
itemStyle: { color: isBuy ? UP_COLOR : DOWN_COLOR },
symbol: isBuy ? 'triangle' : 'pin',
symbolSize: 14,
})
}
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['K线'], top: 0 },
grid: { left: '8%', right: '3%', top: 30, bottom: 60 },
xAxis: {
type: 'category',
data: dates,
boundaryGap: true,
axisLine: { onZero: false },
splitLine: { show: false },
},
yAxis: {
scale: true,
splitLine: { lineStyle: { color: '#2a2e3a' } },
},
dataZoom: [
{ type: 'inside', start: 60, end: 100 },
{ type: 'slider', bottom: 10, start: 60, end: 100 },
],
series: [
{
name: 'K线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: UP_COLOR,
color0: DOWN_COLOR,
borderColor: UP_COLOR,
borderColor0: DOWN_COLOR,
},
markPoint: {
data: markPoints,
label: { show: false },
},
},
],
}
}
function resize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => [props.bars, props.trades], render)
</script>
<template>
<div ref="container" class="kline-chart"></div>
</template>
<style scoped>
.kline-chart {
width: 100%;
height: 420px;
}
</style>