mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
独立前端工程(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)
135 lines
3.9 KiB
Vue
135 lines
3.9 KiB
Vue
<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线 index,price 定位 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>
|