feat(frontend): S1 回测核心页(新建/进度/结果 + ECharts 图表)
- api/strategy.ts、api/backtest.ts(含类型) - 回测-新建(策略下拉+动态参数表单+日期+提交) - useTask 组合式(轮询+WS 实时阶段)+ 进度页 - 结果页:统计全表 + 资金曲线 + 每日盈亏(红涨绿跌) + 成交表 + K线买卖点 - build 通过;result 接口扩 symbol/start/end 供 K线调用
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user