feat(frontend): S1 回测核心页(新建/进度/结果 + ECharts 图表)

- api/strategy.ts、api/backtest.ts(含类型)
- 回测-新建(策略下拉+动态参数表单+日期+提交)
- useTask 组合式(轮询+WS 实时阶段)+ 进度页
- 结果页:统计全表 + 资金曲线 + 每日盈亏(红涨绿跌) + 成交表 + K线买卖点
- build 通过;result 接口扩 symbol/start/end 供 K线调用
This commit is contained in:
2026-07-07 06:13:10 +08:00
parent 3a0e75fdc1
commit 80d7f58589
11 changed files with 531 additions and 7 deletions
+89
View File
@@ -0,0 +1,89 @@
import { apiClient } from './client'
export interface CtaSubmit {
symbol: string
strategy: string
params: Record<string, unknown>
start: string
end: string
}
export interface TaskStatus {
task_id: string
status: string
stage: string
}
export interface EquityPoint {
date: string
balance: number
}
export interface PnlPoint {
date: string
pnl: number
}
export interface Trade {
datetime: string
direction: string
offset: string
price: number
volume: number
vt_symbol?: string
}
export interface KlineBar {
datetime: string
open: number
high: number
low: number
close: number
volume: number
}
export async function submitCta(req: CtaSubmit): Promise<string> {
const { data } = await apiClient.post<{ task_id: string }>('/backtest/cta', req)
return data.task_id
}
export async function getStatus(taskId: string): Promise<TaskStatus> {
const { data } = await apiClient.get<TaskStatus>(`/task/${taskId}`)
return data
}
export interface BacktestResultInfo {
task_id: string
statistics: Record<string, unknown>
symbol: string
start: string
end: string
strategy: string
params: Record<string, unknown>
status: string
}
export async function getResult(taskId: string): Promise<BacktestResultInfo> {
const { data } = await apiClient.get<BacktestResultInfo>(`/task/${taskId}/result`)
return data
}
export async function getEquityCurve(taskId: string): Promise<EquityPoint[]> {
const { data } = await apiClient.get<{ equity_curve: EquityPoint[] }>(`/task/${taskId}/equity-curve`)
return data.equity_curve
}
export async function getDailyPnl(taskId: string): Promise<PnlPoint[]> {
const { data } = await apiClient.get<{ daily_pnl: PnlPoint[] }>(`/task/${taskId}/daily-pnl`)
return data.daily_pnl
}
export async function getTrades(taskId: string): Promise<Trade[]> {
const { data } = await apiClient.get<{ trades: Trade[] }>(`/task/${taskId}/trades`)
return data.trades
}
export async function getKline(symbol: string, start: string, end: string): Promise<KlineBar[]> {
const { data } = await apiClient.get<{ kline: KlineBar[] }>('/kline', { params: { symbol, start, end } })
return data.kline
}
+21
View File
@@ -0,0 +1,21 @@
import { apiClient } from './client'
export interface StrategyItem {
name: string
class_name: string
}
export interface StrategyParams {
parameters: string[]
defaults: Record<string, unknown>
}
export async function getStrategies(): Promise<StrategyItem[]> {
const { data } = await apiClient.get<{ strategies: StrategyItem[] }>('/strategy/list')
return data.strategies
}
export async function getParams(name: string): Promise<StrategyParams> {
const { data } = await apiClient.get<StrategyParams>(`/strategy/${name}/params`)
return data
}
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { Trade } from '@/api/backtest'
defineProps<{ trades: Trade[] }>()
</script>
<template>
<el-table :data="trades" stripe size="small" empty-text="无成交">
<el-table-column prop="datetime" label="时间" width="220" />
<el-table-column prop="direction" label="方向" width="80" />
<el-table-column prop="offset" label="开平" width="80" />
<el-table-column prop="price" label="价格" width="100" />
<el-table-column prop="volume" label="数量" width="100" />
<el-table-column prop="vt_symbol" label="标的" />
</el-table>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import type { PnlPoint } from '@/api/backtest'
const props = defineProps<{ data: PnlPoint[] }>()
const el = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render(): void {
if (!chart || !props.data.length) return
chart.setOption({
title: { text: '每日盈亏', left: 'center' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: props.data.map((p) => p.date) },
yAxis: { type: 'value', name: '盈亏' },
series: [{
type: 'bar',
// A 股惯例:红涨绿跌
data: props.data.map((p) => ({ value: p.pnl, itemStyle: { color: p.pnl >= 0 ? '#ee6666' : '#91cc75' } })),
}],
}, true)
}
function resize(): void { chart?.resize() }
onMounted(() => {
if (el.value) chart = echarts.init(el.value)
render()
window.addEventListener('resize', resize)
})
watch(() => props.data, render, { deep: true })
onUnmounted(() => { window.removeEventListener('resize', resize); chart?.dispose() })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 280px; }</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import type { EquityPoint } from '@/api/backtest'
const props = defineProps<{ data: EquityPoint[] }>()
const el = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render(): void {
if (!chart || !props.data.length) return
chart.setOption({
title: { text: '资金曲线', left: 'center' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: props.data.map((p) => p.date) },
yAxis: { type: 'value', scale: true, name: '权益' },
series: [{
type: 'line', name: '权益', smooth: true, areaStyle: { opacity: 0.1 },
data: props.data.map((p) => p.balance),
}],
}, true)
}
function resize(): void { chart?.resize() }
onMounted(() => {
if (el.value) chart = echarts.init(el.value)
render()
window.addEventListener('resize', resize)
})
watch(() => props.data, render, { deep: true })
onUnmounted(() => { window.removeEventListener('resize', resize); chart?.dispose() })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 320px; }</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import type { KlineBar, Trade } from '@/api/backtest'
const props = defineProps<{ kline: KlineBar[]; trades: Trade[] }>()
const el = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function dateOf(dt: string): string {
return String(dt).slice(0, 10)
}
function render(): void {
if (!chart || !props.kline.length) return
const dates = props.kline.map((k) => dateOf(k.datetime))
const markPoints = props.trades
.map((t) => ({ t, idx: dates.indexOf(dateOf(t.datetime)) }))
.filter((x) => x.idx >= 0)
.map(({ t }) => ({
coord: [dateOf(t.datetime), t.price],
value: `${t.offset === '开' ? '买' : '卖'}${t.volume}`,
itemStyle: { color: t.offset === '开' ? '#ee6666' : '#91cc75' },
symbol: 'triangle',
symbolSize: 14,
}))
chart.setOption({
title: { text: 'K线 + 买卖点', left: 'center' },
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false },
yAxis: { scale: true },
series: [{
type: 'candlestick',
// ECharts order: [open, close, lowest, highest]
data: props.kline.map((k) => [k.open, k.close, k.low, k.high]),
markPoint: { data: markPoints, symbol: 'triangle', symbolSize: 14 },
}],
}, true)
}
function resize(): void { chart?.resize() }
onMounted(() => {
if (el.value) chart = echarts.init(el.value)
render()
window.addEventListener('resize', resize)
})
watch(() => [props.kline, props.trades], render, { deep: true })
onUnmounted(() => { window.removeEventListener('resize', resize); chart?.dispose() })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 420px; }</style>
+55
View File
@@ -0,0 +1,55 @@
import { ref, onUnmounted } from 'vue'
import { getStatus } from '@/api/backtest'
import { useAuthStore } from '@/stores/auth'
export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'unknown'
/**
* Track a task's status + stage via polling (2s) and WebSocket (real-time).
* Auto-stops on unmount.
*/
export function useTask(taskId: string) {
const status = ref<TaskState>('unknown')
const stage = ref('')
let timer: ReturnType<typeof setInterval> | null = null
let ws: WebSocket | null = null
async function poll(): Promise<void> {
try {
const s = await getStatus(taskId)
status.value = s.status as TaskState
stage.value = s.stage
} catch {
/* transient — keep last known state */
}
}
function start(): void {
poll()
timer = setInterval(poll, 2000)
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const auth = useAuthStore()
const url = `${proto}://${window.location.host}/api/v1/ws/task/${taskId}?token=${auth.token}`
try {
ws = new WebSocket(url)
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg.stage) stage.value = msg.stage
if (msg.status) status.value = msg.status
} catch {
/* ignore non-JSON keepalive frames */
}
}
} catch {
/* WS optional — polling covers it */
}
}
onUnmounted(() => {
if (timer) clearInterval(timer)
if (ws) ws.close()
})
return { status, stage, start }
}
+105 -2
View File
@@ -1,4 +1,107 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
import { submitCta } from '@/api/backtest'
const router = useRouter()
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const paramsList = ref<string[]>([])
const form = reactive({
strategy: '',
symbol: '600000',
start: '2024-01-01',
end: '2024-06-30',
})
const paramValues = reactive<Record<string, string>>({})
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
if (strategies.value.length && !form.strategy) {
form.strategy = strategies.value[0].name
}
} catch {
ElMessage.error('策略列表加载失败')
} finally {
loading.value = false
}
})
watch(() => form.strategy, async (name) => {
if (!name) return
try {
const p = await getParams(name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
paramValues[k] = String(p.defaults[k] ?? '')
})
} catch {
paramsList.value = []
}
})
async function onSubmit(): Promise<void> {
if (!form.strategy || !form.symbol) {
ElMessage.warning('请选择策略并填写标的')
return
}
submitting.value = true
try {
const params: Record<string, unknown> = {}
paramsList.value.forEach((k) => {
const raw = paramValues[k]
params[k] = raw !== '' && !isNaN(Number(raw)) ? Number(raw) : raw
})
const tid = await submitCta({
symbol: form.symbol,
strategy: form.strategy,
params,
start: form.start,
end: form.end,
})
ElMessage.success('回测已提交')
router.push(`/backtest/progress/${tid}`)
} catch {
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<el-empty description="回测 - 新建(S1 切片实现)" />
<el-card v-loading="loading">
<template #header>
<h3>新建回测</h3>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 280px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item v-for="k in paramsList" :key="k" :label="k">
<el-input v-model="paramValues[k]" style="width: 220px" />
</el-form-item>
<el-form-item label="标的代码">
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 220px" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交回测</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
+39 -2
View File
@@ -1,4 +1,41 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTask } from '@/composables/useTask'
const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const { status, stage, start } = useTask(taskId)
start()
watch(status, (s) => {
if (s === 'done') router.push(`/backtest/result/${taskId}`)
})
function pct(): number {
if (status.value === 'done') return 100
if (status.value === 'running') return 60
if (status.value === 'failed') return 100
return 20
}
</script>
<template>
<el-empty description="回测 - 进度(S1 切片实现)" />
<el-card>
<h3>回测进行中</h3>
<p>任务 ID{{ taskId }}</p>
<p>
状态
<el-tag :type="status === 'done' ? 'success' : status === 'failed' ? 'danger' : 'warning'">
{{ status }}
</el-tag>
</p>
<p>阶段{{ stage || '—' }}</p>
<el-progress
:percentage="pct()"
:status="status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
/>
</el-card>
</template>
+76 -2
View File
@@ -1,4 +1,78 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
} from '@/api/backtest'
import EquityChart from '@/components/charts/EquityChart.vue'
import DailyPnlChart from '@/components/charts/DailyPnlChart.vue'
import KlineChart from '@/components/charts/KlineChart.vue'
import TradesTable from '@/components/TradesTable.vue'
const route = useRoute()
const taskId = String(route.params.id)
const loading = ref(true)
const statistics = ref<Record<string, unknown>>({})
const equity = ref<EquityPoint[]>([])
const pnl = ref<PnlPoint[]>([])
const trades = ref<Trade[]>([])
const kline = ref<KlineBar[]>([])
const statEntries = computed(() =>
Object.entries(statistics.value)
.map(([k, v]) => ({
key: k,
value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
}))
)
onMounted(async () => {
try {
const info = await getResult(taskId)
statistics.value = info.statistics || {}
const [eq, p, tr] = await Promise.all([
getEquityCurve(taskId), getDailyPnl(taskId), getTrades(taskId),
])
equity.value = eq
pnl.value = p
trades.value = tr
if (info.symbol && info.start && info.end) {
try {
kline.value = await getKline(info.symbol, info.start, info.end)
} catch {
kline.value = []
}
}
} finally {
loading.value = false
}
})
</script>
<template>
<el-empty description="回测 - 结果(S1 切片实现)" />
<div v-loading="loading">
<el-card>
<template #header><h3>统计指标</h3></template>
<el-descriptions :column="4" border>
<el-descriptions-item v-for="e in statEntries" :key="e.key" :label="e.key">
{{ e.value }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12"><el-card><EquityChart :data="equity" /></el-card></el-col>
<el-col :span="12"><el-card><DailyPnlChart :data="pnl" /></el-card></el-col>
</el-row>
<el-card style="margin-top: 16px">
<KlineChart :kline="kline" :trades="trades" />
</el-card>
<el-card style="margin-top: 16px">
<template #header><h3>成交记录</h3></template>
<TradesTable :trades="trades" />
</el-card>
</div>
</template>
+10 -1
View File
@@ -119,7 +119,16 @@ def get_result(task_id: str):
r = get_orchestrator().get_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
return {"task_id": task_id, "statistics": r.statistics}
return {
"task_id": task_id,
"statistics": r.statistics,
"symbol": r.symbol,
"start": r.start,
"end": r.end,
"strategy": r.strategy,
"params": r.params,
"status": r.status,
}
@router.websocket("/ws/task/{task_id}")