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)
This commit is contained in:
Justin Gu
2026-07-03 03:35:20 +08:00
parent 38731114b6
commit 6aab7a82b5
30 changed files with 3829 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+71
View File
@@ -0,0 +1,71 @@
# easy-tdx 回测 Web UI
单标的策略回测的可视化前端(Vue3 + ECharts)。对应后端回测 REST API`/api/v1/backtest/*`)。
## 快速开始
需要两个进程:后端 FastAPI(提供回测 + 行情 API+ 前端 Vite dev server。
```bash
# 1. 启动后端(仓库根目录,使用 venv)
venv/Scripts/easy-tdx serve --port 8000
# 2. 启动前端(本目录)
npm install # 首次
npm run dev # 启动在 http://localhost:5173
```
浏览器打开 `http://localhost:5173`。开发期 `/api` 请求由 vite proxy 转发到后端 `127.0.0.1:8000`,无需处理跨域。
## 功能(MVP-A
- **选标的取行情**:沪深北市场,日线/周线/分钟线,调 `/api/v1/bars`
- **策略选择 + 动态参数表单**:下拉选预置策略(MA交叉/MACD/布林/RSI/KDJ),参数表单按后端 schema 自动渲染
- **回测报告**
- K线主图 + 买卖点标注(ECharts candlestick + markPoint
- 净值曲线 + 回撤双轴图
- 19 项绩效指标表(收益/风险/交易分组)
- 成交记录表
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 框架 | Vue 3.5 + TypeScript | Composition API + `<script setup>` |
| 构建 | Vite 8 | dev proxy 解决跨域 |
| 状态 | Pinia | 策略列表 / OHLCV / 回测结果 |
| 路由 | Vue Router 4 | MVP 仅 `/`,预留组合/寻优/对比 |
| 图表 | ECharts(按需引入) | candlestick / line / markPoint |
## 目录结构
```
src/
├── main.ts # 入口(Pinia + Router
├── App.vue # 根布局
├── router.ts # 路由
├── api.ts # fetch 封装(strategies/bars/backtest
├── types.ts # 后端 schema 的 TS 镜像
├── echarts-setup.ts # ECharts 按需注册
├── style.css # 深色金融主题
├── stores/backtest.ts # 状态管理
├── views/BacktestView.vue # 主页面(左配置 / 右报告)
└── components/
├── StrategyPicker.vue # 策略下拉 + 动态参数表单
├── SymbolPicker.vue # 选标的取行情
├── KlineChart.vue # K线 + 买卖点
├── EquityChart.vue # 净值 + 回撤
├── MetricTable.vue # 绩效指标表
└── TradeTable.vue # 成交记录表
```
## 生产构建
```bash
npm run build # 产物在 dist/,由 FastAPI 同源托管(待后续配置)
```
## 注意事项
- **取行情需要网络**`/api/v1/bars` 通过 TDX 协议取真实行情,需后端能连通行情服务器。无网络时可手动构造 OHLCV(后续可加内联数据粘贴入口)。
- **A股配色惯例**:红涨绿跌(与 ECharts 默认相反),见 `echarts-setup.ts`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>easy-tdx 回测</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1429
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "web-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"echarts": "^6.1.0",
"pinia": "^3.0.4",
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
// 根组件:顶部标题栏 + 路由出口。
</script>
<template>
<div class="app">
<header class="app-header">
<h1>easy-tdx 回测</h1>
<nav class="app-nav">
<RouterLink to="/" active-class="active">单标的回测</RouterLink>
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
</nav>
</header>
<main class="app-main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: 48px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.app-header h1 {
font-size: 16px;
font-weight: 600;
}
.app-nav {
display: flex;
gap: 16px;
}
.app-nav a {
color: var(--text-dim);
text-decoration: none;
font-size: 13px;
padding: 4px 0;
border-bottom: 2px solid transparent;
}
.app-nav a:hover {
color: var(--text);
}
.app-nav a.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.app-main {
flex: 1;
overflow: hidden;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
// 后端 API 封装。统一 fetch + 错误处理,返回类型化结果。
// 开发期通过 vite proxy 走 /api(同源),生产期由 FastAPI 同源托管。
import type {
ApiError,
BacktestRequest,
BacktestResult,
Bar,
Category,
PortfolioBacktestRequest,
StrategiesResponse,
TaskState,
TaskSubmitResponse,
} from './types'
const BASE = '/api/v1'
/** 把未知错误格式化为用户可读的消息(网络错误给友好提示)。 */
export function formatError(e: unknown): string {
if (e instanceof TypeError && e.message.includes('fetch')) {
return '网络错误:无法连接后端服务,请确认 easy-tdx serve 已启动'
}
return e instanceof Error ? e.message : String(e)
}
/** 把 Response 解析为 ApiError 抛出(后端统一错误格式 {error, detail})。 */
async function throwError(resp: Response): Promise<never> {
let detail = `${resp.status} ${resp.statusText}`
try {
const body = (await resp.json()) as ApiError
if (body?.detail) detail = body.detail
} catch {
// 非 JSON 错误体,用 statusText
}
throw new Error(detail)
}
/** 枚举预置策略 + 参数 schema。 */
export async function fetchStrategies(): Promise<StrategiesResponse> {
const resp = await fetch(`${BASE}/backtest/strategies`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as StrategiesResponse
}
/**
* 按标的取 K 线行情(OHLCV)。
*
* 后端 /bars 仅支持按 count 取数(上限 800 根,约 3.2 年日线),不支持日期范围。
* 这里固定拉满 800 根,由调用方按日期范围在前端过滤。
* 可选 startDate/endDate 对结果做闭区间过滤(ISO 日期字符串,如 "2024-01-01")。
*/
export async function fetchBars(
market: string,
code: string,
category: Category,
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
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
}
/** 把后端 bars 的单条记录归一化为统一 Bardatetime 字段)。 */
function normalizeBar(row: Record<string, unknown>): Bar {
const raw = (row.datetime ?? row.date) as string | undefined
if (!raw) throw new Error('行情数据缺少 datetime/date 字段')
return {
datetime: raw.slice(0, 19).replace(' ', 'T'),
open: Number(row.open),
high: Number(row.high),
low: Number(row.low),
close: Number(row.close),
vol: Number(row.vol),
amount: Number(row.amount),
}
}
/** 同步回测(内联 OHLCV,快速)。 */
export async function runBacktest(req: BacktestRequest): Promise<BacktestResult> {
const resp = await fetch(`${BASE}/backtest/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as BacktestResult
}
/** 提交后台回测任务,返回 task_id。 */
export async function submitBacktestTask(req: BacktestRequest): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 提交组合回测后台任务,返回 task_id。 */
export async function submitPortfolioTask(
req: PortfolioBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/portfolio/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 查询后台任务状态(轮询用)。 */
export async function fetchTask(taskId: string): Promise<TaskState> {
const resp = await fetch(`${BASE}/backtest/tasks/${taskId}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskState
}
/**
* 提交后台任务并轮询直到 done/failed。
* @param req 回测请求
* @param onPoll 每次轮询回调(可选,用于更新 UI 进度)
* @param intervalMs 轮询间隔(默认 300ms
* @param timeoutMs 总超时(默认 120s
*/
export async function runBacktestWithPolling(
req: BacktestRequest,
onPoll?: (state: TaskState) => void,
intervalMs = 300,
timeoutMs = 120_000,
): Promise<TaskState> {
const { task_id } = await submitBacktestTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
onPoll?.(state)
if (state.status === 'done' || state.status === 'failed') return state
if (Date.now() - start > timeoutMs) {
throw new Error(`回测任务超时(${timeoutMs / 1000}s`)
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
// 净值曲线 + 回撤双轴图(ECharts line)。
// 左轴:净值总额(total);右轴:回撤百分比(drawdown_pct,取负值向下显示)。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '../echarts-setup'
import type { EquityPoint } from '../types'
const props = defineProps<{
equity: EquityPoint[]
}>()
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value || props.equity.length === 0) return
chart ??= echarts.init(container.value, 'dark')
chart.setOption(buildOption(), true)
}
function buildOption(): echarts.EChartsCoreOption {
const dates = props.equity.map((e) => e.datetime.slice(0, 10))
const totals = props.equity.map((e) => e.total)
// 回撤百分比:后端 drawdown_pct 为正值(如 0.05),显示为 -5% 更直观
const drawdowns = props.equity.map((e) => -e.drawdown_pct * 100)
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['净值', '回撤%'], top: 0 },
grid: { left: '8%', right: '8%', top: 30, bottom: 50 },
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { onZero: false },
},
yAxis: [
{
type: 'value',
name: '净值',
scale: true,
position: 'left',
splitLine: { lineStyle: { color: '#2a2e3a' } },
},
{
type: 'value',
name: '回撤%',
position: 'right',
splitLine: { show: false },
},
],
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [
{
name: '净值',
type: 'line',
data: totals,
smooth: false,
symbol: 'none',
lineStyle: { width: 1.5, color: '#4a9eff' },
areaStyle: { opacity: 0.1 },
},
{
name: '回撤%',
type: 'line',
data: drawdowns,
yAxisIndex: 1,
symbol: 'none',
lineStyle: { width: 1, color: '#ef4146' },
areaStyle: { opacity: 0.15, color: '#ef4146' },
},
],
}
}
function resize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.equity, render)
</script>
<template>
<div ref="container" class="equity-chart"></div>
</template>
<style scoped>
.equity-chart {
width: 100%;
height: 300px;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<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>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
// 19 项绩效指标表。按金融惯例格式化:比率类→百分比,保留小数。
import { computed } from 'vue'
import type { Performance } from '../types'
const props = defineProps<{
perf: Performance
}>()
interface MetricRow {
key: keyof Performance
label: string
/** percent = 显示为百分比;ratio = 原值保留小数;int = 整数;days = 天 */
format: 'percent' | 'ratio' | 'int' | 'days'
group: string
}
// 按业务分组排列:收益 / 风险 / 交易
const METRICS: MetricRow[] = [
{ key: 'total_return', label: '总收益率', format: 'percent', group: '收益' },
{ key: 'annual_return', label: '年化收益', format: 'percent', group: '收益' },
{ key: 'sharpe', label: '夏普比率', format: 'ratio', group: '收益' },
{ key: 'sortino', label: '索提诺比率', format: 'ratio', group: '收益' },
{ key: 'calmar', label: '卡玛比率', format: 'ratio', group: '收益' },
{ key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' },
{ key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' },
{ key: 'volatility', label: '波动率', format: 'percent', group: '风险' },
{ key: 'total_trades', label: '总交易数', format: 'int', group: '交易' },
{ key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' },
{ key: 'lose_trades', label: '亏损次数', format: 'int', group: '交易' },
{ key: 'win_rate', label: '胜率', format: 'percent', group: '交易' },
{ key: 'profit_factor', label: '盈亏比', format: 'ratio', group: '交易' },
{ key: 'avg_win', label: '平均盈利', format: 'percent', group: '交易' },
{ key: 'avg_loss', label: '平均亏损', format: 'percent', group: '交易' },
{ key: 'max_win', label: '最大盈利', format: 'percent', group: '交易' },
{ key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' },
{ key: 'avg_holding_days', label: '平均持仓天数', format: 'ratio', group: '交易' },
{ key: 'rejected_trades', label: '拒单数', format: 'int', group: '交易' },
]
function formatVal(row: MetricRow, v: number): string {
if (!Number.isFinite(v)) return '-'
if (row.format === 'percent') return `${(v * 100).toFixed(2)}%`
if (row.format === 'int') return String(Math.round(v))
if (row.format === 'days') return `${v.toFixed(0)}`
return v.toFixed(3)
}
// 分组渲染
const groups = computed(() => {
const map = new Map<string, MetricRow[]>()
for (const m of METRICS) {
if (!map.has(m.group)) map.set(m.group, [])
map.get(m.group)!.push(m)
}
return Array.from(map.entries())
})
// 收益相关指标着色
function valueClass(row: MetricRow): string {
if (row.key === 'max_drawdown' || row.key === 'avg_loss' || row.key === 'max_loss') {
return props.perf[row.key] !== 0 ? 'neg' : ''
}
if (row.key === 'total_return' || row.key === 'annual_return') {
return props.perf[row.key] > 0 ? 'pos' : 'neg'
}
// win_rate 是 0-1 分数(>=0.5 视为正向);profit_factor 是绝对比值(>=1 正向)
if (row.key === 'win_rate') {
return props.perf[row.key] >= 0.5 ? 'pos' : ''
}
if (row.key === 'profit_factor') {
return props.perf[row.key] >= 1 ? 'pos' : ''
}
return ''
}
</script>
<template>
<div class="metric-grid">
<div v-for="[group, rows] in groups" :key="group" class="metric-group">
<h4 class="group-title">{{ group }}</h4>
<div class="metric-rows">
<div v-for="row in rows" :key="row.key" class="metric-row">
<span class="metric-label">{{ row.label }}</span>
<span class="metric-value" :class="valueClass(row)">
{{ formatVal(row, perf[row.key]) }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.metric-grid {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.metric-group {
flex: 1;
min-width: 180px;
}
.group-title {
font-size: 12px;
color: var(--text-dim);
font-weight: 600;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.metric-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.metric-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
}
.metric-label {
color: var(--text-muted);
}
.metric-value {
font-family: var(--font-mono);
font-weight: 600;
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
// 各标的净值叠加对比图(归一化为初始=1,方便对比走势)。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '../echarts-setup'
import type { BacktestResult } from '../types'
const props = defineProps<{
results: Record<string, BacktestResult>
}>()
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value) return
const series = buildSeries()
if (series.length === 0) return
chart ??= echarts.init(container.value, 'dark')
chart.setOption(buildOption(series), true)
}
function buildSeries(): Array<{ name: string; dates: string[]; values: number[] }> {
const out: Array<{ name: string; dates: string[]; values: number[] }> = []
for (const [key, res] of Object.entries(props.results)) {
const ec = res.equity_curve
if (ec.length === 0) continue
const initial = ec[0].total || 1
out.push({
name: key,
dates: ec.map((e) => e.datetime.slice(0, 10)),
values: ec.map((e) => e.total / initial),
})
}
return out
}
function buildOption(
series: Array<{ name: string; dates: string[]; values: number[] }>,
): echarts.EChartsCoreOption {
// 取最长日期序列作 x 轴(各标的日期可能不同,取并集近似)
const allDates = Array.from(new Set(series.flatMap((s) => s.dates))).sort()
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
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: '归一化净值' },
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: series.map((s) => {
// 按 allDates 对齐(缺失日期 forward-fill
const valMap = new Map(s.dates.map((d, i) => [d, s.values[i]]))
let last = 1
const aligned = allDates.map((d) => {
const v = valMap.get(d)
if (v !== undefined) last = v
return last
})
return {
name: s.name,
type: 'line',
data: aligned,
symbol: 'none',
lineStyle: { width: 1.5 },
}
}),
}
}
function resize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.results, render)
</script>
<template>
<div ref="container" class="compare-chart"></div>
</template>
<style scoped>
.compare-chart {
width: 100%;
height: 360px;
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
// 各标的绩效横向对比表。
import type { BacktestResult } from '../types'
defineProps<{
results: Record<string, BacktestResult>
allocation: Record<string, number>
}>()
function pct(v: number): string {
return Number.isFinite(v) ? `${(v * 100).toFixed(2)}%` : '-'
}
function num(v: number, d = 2): string {
return Number.isFinite(v) ? v.toFixed(d) : '-'
}
</script>
<template>
<table class="summary-table">
<thead>
<tr>
<th>标的</th>
<th class="num">资金占比</th>
<th class="num">总收益</th>
<th class="num">最大回撤</th>
<th class="num">夏普</th>
<th class="num">交易数</th>
<th class="num">胜率</th>
</tr>
</thead>
<tbody>
<tr v-for="(res, key) in results" :key="key">
<td class="sym">{{ key }}</td>
<td class="num muted">{{ pct(allocation[key] || 0) }}</td>
<td class="num" :class="res.performance.total_return > 0 ? 'pos' : 'neg'">
{{ pct(res.performance.total_return) }}
</td>
<td class="num neg">{{ pct(res.performance.max_drawdown) }}</td>
<td class="num">{{ num(res.performance.sharpe) }}</td>
<td class="num">{{ res.performance.total_trades }}</td>
<td class="num">{{ pct(res.performance.win_rate) }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.summary-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.summary-table th,
.summary-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.summary-table th {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.num {
text-align: right;
font-family: var(--font-mono);
}
.sym {
font-family: var(--font-mono);
font-weight: 600;
}
.muted {
color: var(--text-dim);
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
// 多标的输入(组合回测用)。逐个添加 市场:代码,可删除。
import { ref } from 'vue'
const props = defineProps<{
modelValue: string[]
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const market = ref('SZ')
const code = ref('')
function add() {
if (!/^\d{6}$/.test(code.value)) return
const sym = `${market.value}:${code.value}`
if (!props.modelValue.includes(sym)) {
emit('update:modelValue', [...props.modelValue, sym])
}
code.value = ''
}
function remove(sym: string) {
emit('update:modelValue', props.modelValue.filter((s) => s !== sym))
}
</script>
<template>
<div class="stocks-picker">
<div class="row">
<select v-model="market">
<option value="SZ">深市</option>
<option value="SH">沪市</option>
<option value="BJ">北交所</option>
</select>
<input
v-model="code"
maxlength="6"
placeholder="6位代码"
@keyup.enter="add"
/>
<button @click="add">添加</button>
</div>
<div v-if="modelValue.length" class="stock-list">
<span v-for="s in modelValue" :key="s" class="stock-tag">
{{ s }}
<button class="remove" @click="remove(s)">×</button>
</span>
</div>
<p v-else class="hint">至少添加 1 只标的</p>
</div>
</template>
<style scoped>
.row {
display: flex;
gap: 6px;
}
.row select {
width: auto;
}
.row input {
flex: 1;
}
.stock-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.stock-tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-mono);
}
.remove {
border: none;
background: none;
color: var(--text-dim);
padding: 0 2px;
font-size: 14px;
line-height: 1;
}
.remove:hover {
color: var(--up);
}
.hint {
color: var(--text-dim);
font-size: 11px;
margin-top: 8px;
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
// 策略选择 + 动态参数表单。
// 核心能力:选中策略后,按后端 params schema 动态渲染表单控件
// int/float→number、str+choices→select、bool→checkbox),带 min/max/default。
import { computed, watch } from 'vue'
import type { ParamSchema, StrategySchema } from '../types'
const props = defineProps<{
strategies: StrategySchema[]
strategy: string
params: Record<string, number | string | boolean>
}>()
const emit = defineEmits<{
'update:strategy': [value: string]
'update:params': [value: Record<string, number | string | boolean>]
}>()
// 当前选中策略的 schema(用于渲染参数表单)
const selectedSchema = computed(
() => props.strategies.find((s) => s.name === props.strategy) ?? null,
)
// 切换策略时重置参数为默认值
watch(
selectedSchema,
(schema) => {
if (!schema) return
const defaults: Record<string, number | string | boolean> = {}
for (const p of schema.params) defaults[p.name] = p.default
emit('update:params', defaults)
},
{ immediate: true },
)
function paramValue(p: ParamSchema) {
return props.params[p.name] ?? p.default
}
function updateParam(p: ParamSchema, raw: string | boolean) {
let v: number | string | boolean = raw
if (p.type === 'int' || p.type === 'float') {
// 空字符串(用户清空输入框中):不 emit,保留旧值,避免把 0 回填打断输入
if (raw === '') return
const num = Number(raw)
if (!Number.isFinite(num)) return // 非法中间态(如 "1."、"1e"):不更新
v = num
}
emit('update:params', { ...props.params, [p.name]: v })
}
</script>
<template>
<div class="strategy-picker">
<div class="field">
<label>策略</label>
<select
:value="strategy"
@change="
emit(
'update:strategy',
($event.target as HTMLSelectElement).value,
)
"
>
<option v-for="s in strategies" :key="s.name" :value="s.name">
{{ s.label }}{{ s.name }}
</option>
</select>
</div>
<p v-if="selectedSchema" class="desc">{{ selectedSchema.description }}</p>
<!-- 动态参数表单 schema 渲染 -->
<div v-if="selectedSchema?.params.length" class="params">
<div v-for="p in selectedSchema.params" :key="p.name" class="field">
<label>{{ p.label }}</label>
<!-- str + choices 下拉 -->
<select
v-if="p.type === 'str' && p.choices"
:value="String(paramValue(p))"
@change="updateParam(p, ($event.target as HTMLSelectElement).value)"
>
<option v-for="c in p.choices" :key="c" :value="c">{{ c }}</option>
</select>
<!-- bool 复选 -->
<input
v-else-if="p.type === 'bool'"
type="checkbox"
:checked="Boolean(paramValue(p))"
@change="updateParam(p, ($event.target as HTMLInputElement).checked)"
/>
<!-- int/float 数字输入 min/max -->
<input
v-else
type="number"
:value="paramValue(p)"
:min="p.min_value"
:max="p.max_value"
:step="p.type === 'float' ? 'any' : '1'"
@input="updateParam(p, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.desc {
color: var(--text-muted);
font-size: 12px;
margin: -4px 0 12px;
line-height: 1.4;
}
.params {
display: flex;
flex-direction: column;
}
input[type='checkbox'] {
width: auto;
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 选标的 + 取行情(按日期范围)。
// 后端 /bars 仅支持 count(上限 800,约 3.2 年),固定拉满后前端按日期过滤。
// 默认:结束日=今天(最近交易日),开始日=3年前。
import { ref } from 'vue'
import { fetchBars, formatError } from '../api'
import { useBacktestStore } from '../stores/backtest'
import type { Category } from '../types'
const store = useBacktestStore()
const market = ref('SZ')
const code = ref('000001')
const category = ref<Category>('DAY')
// 日期默认:结束=今天,开始=3年前
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const endDate = ref(isoDaysFromNow(0))
const startDate = ref(isoDaysFromNow(-365 * 3))
const loading = ref(false)
const error = ref('')
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60']
async function loadBars() {
// 基本校验
if (!/^\d{6}$/.test(code.value)) {
error.value = '股票代码必须是 6 位数字'
return
}
if (startDate.value >= endDate.value) {
error.value = '开始日期必须早于结束日期'
return
}
loading.value = true
error.value = ''
try {
const bars = await fetchBars(
market.value,
code.value,
category.value,
startDate.value,
endDate.value,
)
if (bars.length < 2) {
error.value = `该日期范围内仅取到 ${bars.length} 根 K 线,不足以回测`
return
}
const range = `${startDate.value} ~ ${endDate.value}`
store.setOhlcv(bars, `${market.value}:${code.value} ${category.value} ${range}`)
store.clearResult()
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="symbol-picker">
<div class="row">
<div class="field">
<label>市场</label>
<select v-model="market">
<option value="SZ">深市</option>
<option value="SH">沪市</option>
<option value="BJ">北交所</option>
</select>
</div>
<div class="field code-field">
<label>代码</label>
<input
v-model="code"
maxlength="6"
placeholder="6位代码"
@keyup.enter="loadBars"
/>
</div>
</div>
<div class="field">
<label>周期</label>
<select v-model="category">
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="row">
<div class="field">
<label>开始日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="field">
<label>结束日期</label>
<input v-model="endDate" type="date" />
</div>
</div>
<button class="primary" :disabled="loading" @click="loadBars">
{{ loading ? '取行情中' : '取行情' }}
</button>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="store.barsSource" class="ok">
已加载{{ store.barsSource }}{{ store.ohlcv.length }}
</p>
</div>
</template>
<style scoped>
.code-field {
flex: 2;
}
.err {
color: var(--up);
font-size: 12px;
margin-top: 8px;
}
.ok {
color: var(--down);
font-size: 12px;
margin-top: 8px;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
// 成交记录表。展示每笔成交的方向/数量/价格/费用/盈亏。
import type { Trade } from '../types'
defineProps<{
trades: Trade[]
}>()
function fmtDate(s: string): string {
return s.slice(0, 10)
}
function fmtNum(v: number, digits = 2): string {
return Number.isFinite(v) ? v.toFixed(digits) : '-'
}
</script>
<template>
<div class="trade-table-wrap">
<p v-if="trades.length === 0" class="empty">无成交记录</p>
<table v-else class="trade-table">
<thead>
<tr>
<th>日期</th>
<th>方向</th>
<th class="num">数量</th>
<th class="num">价格</th>
<th class="num">手续费</th>
<th class="num">盈亏</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in trades" :key="i" :class="{ rejected: t.rejected }">
<td>{{ fmtDate(t.datetime) }}</td>
<td :class="t.direction">{{ t.direction }}</td>
<td class="num">{{ fmtNum(t.size, 0) }}</td>
<td class="num">{{ fmtNum(t.price, 3) }}</td>
<td class="num muted">{{ fmtNum(t.commission, 2) }}</td>
<td class="num" :class="{ pos: t.pnl > 0, neg: t.pnl < 0 }">
{{ t.pnl === 0 ? '-' : fmtNum(t.pnl, 2) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.trade-table-wrap {
overflow-x: auto;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 16px 0;
}
.trade-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.trade-table th,
.trade-table td {
padding: 7px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.trade-table th {
color: var(--text-dim);
font-weight: 600;
font-size: 12px;
position: sticky;
top: 0;
background: var(--bg-panel);
}
.num {
text-align: right;
font-family: var(--font-mono);
}
.muted {
color: var(--text-dim);
}
.BUY {
color: var(--up);
font-weight: 600;
}
.SELL {
color: var(--down);
font-weight: 600;
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
.rejected td {
opacity: 0.4;
text-decoration: line-through;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
// ECharts 按需引入。只注册 MVP-A 用到的图表类型,避免全量引入(~1MB → ~400KB)。
// 用到的:candlestickK线)、line(净值/回撤曲线)、markPoint(买卖点标注)。
import * as echarts from 'echarts/core'
import { BarChart, CandlestickChart, LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
MarkPointComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([
CanvasRenderer,
CandlestickChart,
LineChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
MarkPointComponent,
])
// A股惯例:红涨绿跌
export const UP_COLOR = '#ef4146'
export const DOWN_COLOR = '#18a058'
// 注册内联 dark 主题(echarts/core 不预置 'dark' 主题数据)。
// 覆盖坐标轴文字、分割线等默认浅色样式,适配深色背景。
echarts.registerTheme('dark', {
backgroundColor: 'transparent',
textStyle: { color: '#8b919e' },
title: { textStyle: { color: '#e6e8eb' }, subtextStyle: { color: '#8b919e' } },
legend: { textStyle: { color: '#8b919e' } },
tooltip: {
backgroundColor: 'rgba(26,29,38,0.95)',
borderColor: '#2a2e3a',
textStyle: { color: '#e6e8eb' },
},
categoryAxis: {
axisLine: { lineStyle: { color: '#2a2e3a' } },
axisLabel: { color: '#5c6370' },
splitLine: { show: false },
},
valueAxis: {
axisLine: { lineStyle: { color: '#2a2e3a' } },
axisLabel: { color: '#5c6370' },
splitLine: { lineStyle: { color: '#2a2e3a' } },
},
})
export default echarts
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+15
View File
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import BacktestView from './views/BacktestView.vue'
import PortfolioView from './views/PortfolioView.vue'
// 单标的回测(/)+ 组合回测(/portfolio)。参数寻优/结果对比留待 Phase 4-5。
const routes = [
{ path: '/', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
+129
View File
@@ -0,0 +1,129 @@
// 回测状态管理(Pinia)。
// 持有:策略列表、当前 OHLCV、回测结果、运行状态、错误信息。
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchStrategies, formatError, runBacktest, submitPortfolioTask, fetchTask } from '../api'
import type {
BacktestRequest,
BacktestResult,
Bar,
PortfolioBacktestRequest,
PortfolioResult,
StrategySchema,
} from '../types'
export const useBacktestStore = defineStore('backtest', () => {
// ── 策略 ─────────────────────────────────────────────────────────────────
const strategies = ref<StrategySchema[]>([])
const strategiesLoaded = ref(false)
async function loadStrategies() {
if (strategiesLoaded.value) return
const resp = await fetchStrategies()
strategies.value = resp.strategies
strategiesLoaded.value = true
}
// ── OHLCV 行情(前端始终持有,回测与 K 线共用) ───────────────────────────
const ohlcv = ref<Bar[]>([])
const barsSource = ref<string>('') // 来源描述,如 "SZ:000001 DAY×250"
function setOhlcv(bars: Bar[], source: string) {
ohlcv.value = bars
barsSource.value = source
}
const hasBars = computed(() => ohlcv.value.length >= 2)
// ── 回测结果 ──────────────────────────────────────────────────────────────
const result = ref<BacktestResult | null>(null)
const running = ref(false)
const error = ref<string>('')
/** 运行同步回测(内联 OHLCV)。 */
async function run(req: Omit<BacktestRequest, 'ohlcv'>) {
if (!hasBars.value) {
error.value = '请先取行情数据或粘贴 OHLCV'
return
}
running.value = true
error.value = ''
try {
const fullReq: BacktestRequest = { ...req, ohlcv: ohlcv.value }
result.value = await runBacktest(fullReq)
} catch (e) {
error.value = formatError(e)
result.value = null
} finally {
running.value = false
}
}
function clearResult() {
result.value = null
error.value = ''
}
// ── 组合回测(Phase 3) ───────────────────────────────────────────────────
const portfolioResult = ref<PortfolioResult | null>(null)
const portfolioRunning = ref(false)
/** 提交组合回测后台任务并轮询直到完成。 */
async function runPortfolio(req: PortfolioBacktestRequest) {
portfolioRunning.value = true
error.value = ''
portfolioResult.value = null
try {
const { task_id } = await submitPortfolioTask(req)
// 轮询
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
if (state.status === 'done' && state.result) {
portfolioResult.value = state.result as PortfolioResult
break
}
if (state.status === 'failed') {
throw new Error(state.error || '组合回测失败')
}
if (Date.now() - start > 120_000) throw new Error('组合回测超时(120s')
await new Promise((r) => setTimeout(r, 300))
}
} catch (e) {
error.value = formatError(e)
portfolioResult.value = null
} finally {
portfolioRunning.value = false
}
}
function clearPortfolio() {
portfolioResult.value = null
error.value = ''
}
return {
// state
strategies,
strategiesLoaded,
ohlcv,
barsSource,
result,
running,
error,
portfolioResult,
portfolioRunning,
// getters
hasBars,
// actions
loadStrategies,
setOhlcv,
run,
clearResult,
runPortfolio,
clearPortfolio,
}
})
+106
View File
@@ -0,0 +1,106 @@
/* 全局深色金融主题。MVP-A 不引 UI 组件库,用 CSS 变量统一设计 token。 */
:root {
--bg: #0f1117;
--bg-elevated: #1a1d26;
--bg-panel: #161922;
--border: #2a2e3a;
--text: #e6e8eb;
--text-muted: #8b919e;
--text-dim: #5c6370;
--accent: #4a9eff;
--up: #ef4146; /* A股惯例:红涨 */
--down: #18a058; /* 绿跌 */
--warn: #f0a020;
--radius: 6px;
--font-mono: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#app {
height: 100%;
background: var(--bg);
color: var(--text);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei',
Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
button {
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
padding: 6px 14px;
border-radius: var(--radius);
font-size: 13px;
transition: all 0.15s;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
button.primary:disabled {
background: var(--bg-elevated);
color: var(--text-dim);
}
input,
select,
textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius);
font-size: 13px;
width: 100%;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
label {
display: block;
color: var(--text-muted);
font-size: 12px;
margin-bottom: 4px;
}
.field {
margin-bottom: 12px;
}
.row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.row > * {
flex: 1;
}
a {
color: var(--accent);
}
+171
View File
@@ -0,0 +1,171 @@
// 后端 API 的 TypeScript 类型镜像。
// 与 src/easy_tdx/web/backtest_schemas.py 及 backtest router 的响应保持一致。
// 后端是唯一事实源;这里只做类型契约。
// ── 策略 schemaGET /api/v1/backtest/strategies ───────────────────────────
export type ParamType = 'int' | 'float' | 'bool' | 'str'
export interface ParamSchema {
name: string
type: ParamType
default: number | string | boolean
label: string
min_value?: number
max_value?: number
choices?: string[]
description?: string
}
export interface StrategySchema {
name: string
label: string
description: string
params: ParamSchema[]
}
export interface StrategiesResponse {
strategies: StrategySchema[]
count: number
}
// ── OHLCV 行情(GET /api/v1/bars) ────────────────────────────────────────────
export interface Bar {
datetime: string
open: number
high: number
low: number
close: number
vol: number
amount: number
}
export interface DataFrameResponse {
data: Record<string, unknown>[]
count: number
}
// ── 回测请求(POST /api/v1/backtest/run ─────────────────────────────────────
export type ExecutionMode = 'next_open' | 'next_close' | 'this_close' | 'worst' | 'best'
export type Category = 'DAY' | 'WEEK' | 'MONTH' | 'MIN_5' | 'MIN_15' | 'MIN_30' | 'MIN_60'
export interface BacktestRequest {
strategy: string
params?: Record<string, number | string | boolean>
cash?: number
commission?: number
min_commission?: number
stamp_tax?: number
slippage?: number
execution?: ExecutionMode
ohlcv?: Bar[]
symbol?: string
category?: Category
count?: number
}
// ── 回测结果 ──────────────────────────────────────────────────────────────────
export interface Performance {
total_return: number
annual_return: number
max_drawdown: number
max_dd_duration: number
sharpe: number
sortino: number
calmar: number
total_trades: number
win_trades: number
lose_trades: number
rejected_trades: number
win_rate: number
profit_factor: number
avg_win: number
avg_loss: number
max_win: number
max_loss: number
avg_holding_days: number
volatility: number
}
export interface EquityPoint {
datetime: string
cash: number
position_value: number
total: number
drawdown: number
drawdown_pct: number
}
export interface Trade {
datetime: string
direction: 'BUY' | 'SELL'
size: number
price: number
commission: number
slippage: number
pnl: number
rejected: boolean
}
export interface BacktestResult {
performance: Performance
equity_curve: EquityPoint[]
trades: Trade[]
positions: Record<string, unknown>[]
config: Record<string, unknown>
}
// ── 后台任务(POST /api/v1/backtest/run/async + GET /tasks/{id} ─────────────
export interface TaskSubmitResponse {
task_id: string
status: 'pending' | 'running'
}
export type TaskStatus = 'pending' | 'running' | 'done' | 'failed'
export interface TaskState {
task_id: string
status: TaskStatus
result: BacktestResult | PortfolioResult | null
error: string | null
description: string
elapsed: number
}
// ── 组合回测(Phase 3) ───────────────────────────────────────────────────────
export interface PortfolioBacktestRequest {
strategy: string
params?: Record<string, number | string | boolean>
cash?: number
commission?: number
slippage?: number
execution?: ExecutionMode
stocks: string[]
category?: Category
start_date?: string
end_date?: string
}
export interface PortfolioResult {
total_performance: {
total_return: number
annual_return: number
total_stocks: number
total_cash: number
}
individual_results: Record<string, BacktestResult>
equity_allocation: Record<string, number>
combined_equity: EquityPoint[]
}
// ── 错误响应(后端 ApiErrorResponse) ─────────────────────────────────────────
export interface ApiError {
error: string
detail: string
}
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
// 回测主页面:左配置面板 / 右报告面板。
// 编排:取行情 → 选策略+参数 → 回测 → 展示 K线+净值+指标+成交。
import { onMounted, ref } from 'vue'
import EquityChart from '../components/EquityChart.vue'
import KlineChart from '../components/KlineChart.vue'
import MetricTable from '../components/MetricTable.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue'
import TradeTable from '../components/TradeTable.vue'
import type { ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
// 表单状态(v-model 给子组件)
const strategy = ref('ma_cross')
const params = ref<Record<string, number | string | boolean>>({})
const cash = ref(100000)
const commission = ref(0.0003)
const slippage = ref(0)
const execution = ref<ExecutionMode>('next_open')
const EXECUTIONS: ExecutionMode[] = ['next_open', 'next_close', 'this_close', 'worst', 'best']
onMounted(() => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
})
async function onRun() {
await store.run({
strategy: strategy.value,
params: params.value,
cash: cash.value,
commission: commission.value,
slippage: slippage.value,
execution: execution.value,
})
}
</script>
<template>
<div class="backtest-view">
<!-- 左栏配置 -->
<aside class="config-panel">
<section class="panel-section">
<h3>行情数据</h3>
<SymbolPicker />
</section>
<section class="panel-section">
<h3>策略</h3>
<StrategyPicker
v-if="store.strategies.length"
:strategies="store.strategies"
v-model:strategy="strategy"
v-model:params="params"
/>
<p v-else class="loading-text">加载策略中</p>
</section>
<section class="panel-section">
<h3>资金与成本</h3>
<div class="field">
<label>初始资金</label>
<input v-model.number="cash" type="number" min="1000" step="10000" />
</div>
<div class="row">
<div class="field">
<label>佣金率</label>
<input v-model.number="commission" type="number" min="0" step="0.0001" />
</div>
<div class="field">
<label>滑点</label>
<input v-model.number="slippage" type="number" min="0" step="0.001" />
</div>
</div>
<div class="field">
<label>成交模式</label>
<select v-model="execution">
<option v-for="e in EXECUTIONS" :key="e" :value="e">{{ e }}</option>
</select>
</div>
</section>
<button
class="primary run-btn"
:disabled="store.running || !store.hasBars"
@click="onRun"
>
{{ store.running ? '回测中…' : '开始回测' }}
</button>
<p v-if="!store.hasBars" class="hint">请先取行情数据</p>
</aside>
<!-- 右栏报告 -->
<main class="report-panel">
<div v-if="store.error" class="error-banner"> {{ store.error }}</div>
<div v-if="!store.result && !store.running && !store.error" class="placeholder">
<p>选择标的取行情配置策略后点击开始回测</p>
</div>
<div v-if="store.result" class="report-content">
<section class="report-section">
<h3>K线 + 买卖点</h3>
<KlineChart :bars="store.ohlcv" :trades="store.result.trades" />
</section>
<section class="report-section">
<h3>净值曲线与回撤</h3>
<EquityChart :equity="store.result.equity_curve" />
</section>
<section class="report-section">
<h3>绩效指标</h3>
<MetricTable :perf="store.result.performance" />
</section>
<section class="report-section">
<h3>成交记录{{ store.result.trades.length }} </h3>
<TradeTable :trades="store.result.trades" />
</section>
</div>
</main>
</div>
</template>
<style scoped>
.backtest-view {
display: flex;
height: 100%;
}
/* 左栏配置面板 */
.config-panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-of-type {
border-bottom: none;
}
.panel-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
}
.loading-text {
color: var(--text-dim);
font-size: 12px;
}
.run-btn {
margin-top: auto;
width: 100%;
padding: 10px;
font-size: 14px;
}
.hint {
color: var(--text-dim);
font-size: 11px;
text-align: center;
margin-top: 6px;
}
/* 右栏报告面板 */
.report-panel {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.report-section {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.report-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
// 组合回测主页面:左配置(多标的 + 策略 + 日期)/ 右报告(组合净值 + 各标的对比)。
import { onMounted, ref } from 'vue'
import EquityChart from '../components/EquityChart.vue'
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import StocksPicker from '../components/StocksPicker.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
const stocks = ref<string[]>(['SZ:000001', 'SH:600519'])
const strategy = ref('ma_cross')
const params = ref<Record<string, number | string | boolean>>({})
const cash = ref(200000)
const category = ref<Category>('DAY')
const execution = ref<ExecutionMode>('next_open')
const EXECUTIONS: ExecutionMode[] = ['next_open', 'next_close', 'this_close', 'worst', 'best']
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60']
// 日期默认(复用单标的逻辑)
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const startDate = ref(isoDaysFromNow(-365 * 3))
const endDate = ref(isoDaysFromNow(0))
onMounted(() => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
})
async function onRun() {
await store.runPortfolio({
strategy: strategy.value,
params: params.value,
cash: cash.value,
execution: execution.value,
stocks: stocks.value,
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
})
}
</script>
<template>
<div class="portfolio-view">
<aside class="config-panel">
<section class="panel-section">
<h3>标的列表</h3>
<StocksPicker v-model="stocks" />
</section>
<section class="panel-section">
<h3>策略</h3>
<StrategyPicker
v-if="store.strategies.length"
v-model:strategy="strategy"
v-model:params="params"
:strategies="store.strategies"
/>
<p v-else class="loading-text">加载策略中</p>
</section>
<section class="panel-section">
<h3>周期与日期</h3>
<div class="field">
<label>周期</label>
<select v-model="category">
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="row">
<div class="field">
<label>开始</label>
<input v-model="startDate" type="date" />
</div>
<div class="field">
<label>结束</label>
<input v-model="endDate" type="date" />
</div>
</div>
</section>
<section class="panel-section">
<h3>资金</h3>
<div class="field">
<label>组合总资金</label>
<input v-model.number="cash" type="number" min="1000" step="10000" />
</div>
<div class="field">
<label>成交模式</label>
<select v-model="execution">
<option v-for="e in EXECUTIONS" :key="e" :value="e">{{ e }}</option>
</select>
</div>
</section>
<button
class="primary run-btn"
:disabled="store.portfolioRunning || stocks.length === 0"
@click="onRun"
>
{{ store.portfolioRunning ? '组合回测中…' : '开始组合回测' }}
</button>
</aside>
<main class="report-panel">
<div v-if="store.error" class="error-banner"> {{ store.error }}</div>
<div
v-if="!store.portfolioResult && !store.portfolioRunning && !store.error"
class="placeholder"
>
<p>添加多只标的选择策略后点击开始组合回测</p>
</div>
<div v-if="store.portfolioResult" class="report-content">
<section class="report-section">
<h3>组合整体绩效</h3>
<div class="perf-summary">
<div class="perf-item">
<span class="label">组合总收益</span>
<span
class="value"
:class="store.portfolioResult.total_performance.total_return > 0 ? 'pos' : 'neg'"
>
{{ (store.portfolioResult.total_performance.total_return * 100).toFixed(2) }}%
</span>
</div>
<div class="perf-item">
<span class="label">标的数量</span>
<span class="value">{{ store.portfolioResult.total_performance.total_stocks }}</span>
</div>
<div class="perf-item">
<span class="label">组合总资金</span>
<span class="value">{{ store.portfolioResult.total_performance.total_cash.toFixed(0) }}</span>
</div>
</div>
</section>
<section class="report-section">
<h3>组合净值曲线</h3>
<EquityChart :equity="store.portfolioResult.combined_equity" />
</section>
<section class="report-section">
<h3>各标的绩效对比</h3>
<PortfolioSummaryTable
:results="store.portfolioResult.individual_results"
:allocation="store.portfolioResult.equity_allocation"
/>
</section>
<section class="report-section">
<h3>各标的净值叠加归一化</h3>
<PortfolioCompareChart :results="store.portfolioResult.individual_results" />
</section>
</div>
</main>
</div>
</template>
<style scoped>
.portfolio-view {
display: flex;
height: 100%;
}
.config-panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-of-type {
border-bottom: none;
}
.panel-section h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.loading-text {
color: var(--text-dim);
font-size: 12px;
}
.run-btn {
width: 100%;
padding: 10px;
font-size: 14px;
}
.report-panel {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.report-section {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.report-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
}
.perf-summary {
display: flex;
gap: 32px;
}
.perf-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.perf-item .label {
font-size: 12px;
color: var(--text-dim);
}
.perf-item .value {
font-size: 20px;
font-weight: 600;
font-family: var(--font-mono);
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
</style>
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
// dev server 把 /api 请求代理到后端 FastAPI(默认 8000 端口),
// 解决开发期跨域;生产环境由 FastAPI 同源托管 dist。
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})