| <template> |
| <div> |
| <div class="mk-card" style="padding: 16px 20px; margin-bottom: 16px;"> |
| <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;"> |
| <el-select v-model="form.symbol" size="small" filterable style="width: 180px;"> |
| <el-option v-for="c in store.symbolList" :key="c.symbol" :label="`${c.name} (${c.exchange})`" :value="c.symbol" /> |
| </el-select> |
| <div class="interval-group"> |
| <button v-for="tf in timeframes" :key="tf.v" :class="['interval-btn', { active: form.interval === tf.v }]" @click="form.interval = tf.v">{{ tf.l }}</button> |
| </div> |
| <el-input-number v-model="form.capital" :min="10000" :step="100000" size="small" style="width: 150px;" /> |
| <el-input-number v-model="form.commission" :min="0" :max="0.01" :step="0.0001" :precision="4" size="small" style="width: 120px;" /> |
| <el-button type="success" size="small" @click="runBacktest" :loading="running" style="font-weight:600;"> |
| <el-icon><CaretRight /></el-icon> 运行回测 |
| </el-button> |
| <el-select v-model="selectedTemplate" size="small" style="width: 200px;" placeholder="选择模板" @change="loadTemplate"> |
| <el-option v-for="(t, key) in templates" :key="key" :label="t.name" :value="key" /> |
| </el-select> |
| </div> |
| </div> |
| |
| <div style="display:grid; grid-template-columns: 1.2fr 1fr; gap:16px; margin-bottom:16px;"> |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">策略代码</div> |
| <div style="height: 360px;"> |
| <Codemirror v-model="form.code" :extensions="cmExtensions" :style="{ height: '100%', fontSize: '13px' }" /> |
| </div> |
| </div> |
| <div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:10px;"> |
| <div v-for="m in metricCards" :key="m.label" class="mk-stat" style="padding: 10px 12px;"> |
| <p style="font-size:11px; color:var(--text-light); margin-bottom:4px;">{{ m.label }}</p> |
| <h5 :style="{ fontSize: '18px', fontWeight: 700, color: m.color || 'var(--text-dark)' }">{{ m.value }}</h5> |
| </div> |
| </div> |
| </div> |
| |
| <div v-if="error" class="mk-card" style="padding: 12px 16px; border-left: 4px solid var(--red); margin-bottom: 16px;"> |
| <strong style="color: var(--red);">回测错误:</strong> |
| <pre style="font-size:12px; margin-top:6px; white-space:pre-wrap;">{{ error }}</pre> |
| </div> |
| |
| <div v-if="result" style="display:grid; gap:16px;"> |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Cumulative Returns</div> |
| <v-chart :option="equityChartOpt" autoresize style="height: 300px; padding:6px;" /> |
| </div> |
| |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Drawdown (Max: {{ result.metrics?.max_drawdown }}%)</div> |
| <v-chart :option="drawdownChartOpt" autoresize style="height: 260px; padding:6px;" /> |
| </div> |
| |
| <div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;"> |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Weekly Return Distribution</div> |
| <v-chart :option="distChartOpt" autoresize style="height: 260px; padding:6px;" /> |
| </div> |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Rolling Stats</div> |
| <v-chart :option="rollingChartOpt" autoresize style="height: 260px; padding:6px;" /> |
| </div> |
| </div> |
| |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Turnover Rate (Avg: {{ result.metrics?.turnover_rate || 0 }}%)</div> |
| <v-chart :option="turnoverChartOpt" autoresize style="height: 220px; padding:6px;" /> |
| </div> |
| |
| <div class="mk-card" style="padding:0; overflow:hidden;"> |
| <div style="padding:10px 16px; border-bottom:1px solid var(--border-light); font-weight:700;">Trade Log</div> |
| <el-table :data="result?.trades || []" style="width: 100%;" size="small" :max-height="260"> |
| <el-table-column prop="timestamp" label="时间" width="160"><template #default="{ row }">{{ row.timestamp?.substring(0, 16) }}</template></el-table-column> |
| <el-table-column label="操作" width="110"><template #default="{ row }"><span :class="['badge-pill', row.action.includes('LONG') || row.action === 'BUY' ? 'green' : 'red']">{{ row.action }}</span></template></el-table-column> |
| <el-table-column label="价格" width="90"><template #default="{ row }">{{ row.price }}</template></el-table-column> |
| <el-table-column label="数量" width="70"><template #default="{ row }">{{ row.quantity }}</template></el-table-column> |
| <el-table-column label="盈亏"><template #default="{ row }"><span :class="row.pnl >= 0 ? 'price-up' : 'price-down'" style="font-weight:600;">{{ row.pnl?.toFixed(2) }}</span></template></el-table-column> |
| <el-table-column label="资金"><template #default="{ row }">{{ row.capital?.toFixed ? row.capital.toFixed(2) : row.capital }}</template></el-table-column> |
| </el-table> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup> |
| import { ref, computed, onMounted } from 'vue' |
| import { Codemirror } from 'vue-codemirror' |
| import { python } from '@codemirror/lang-python' |
| import { oneDark } from '@codemirror/theme-one-dark' |
| import VChart from 'vue-echarts' |
| import { use } from 'echarts/core' |
| import { LineChart, BarChart } from 'echarts/charts' |
| import { GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, LegendComponent } from 'echarts/components' |
| import { CanvasRenderer } from 'echarts/renderers' |
| import { useTradingStore } from '../stores/trading' |
| import { backtestApi } from '../api' |
| import { ElMessage } from 'element-plus' |
| |
| use([LineChart, BarChart, GridComponent, TooltipComponent, DataZoomComponent, MarkLineComponent, LegendComponent, CanvasRenderer]) |
| |
| const store = useTradingStore() |
| const cmExtensions = [python(), oneDark] |
| const templates = ref({}) |
| const selectedTemplate = ref('') |
| const running = ref(false) |
| const result = ref(null) |
| const error = ref('') |
| |
| const form = ref({ |
| symbol: '螺纹钢', |
| interval: '1d', |
| capital: 1000000, |
| commission: 0.0003, |
| code: '', |
| }) |
| |
| const timeframes = [ |
| { l: '15分', v: '15m' }, { l: '30分', v: '30m' }, { l: '1时', v: '1h' }, |
| { l: '4时', v: '4h' }, { l: '日线', v: '1d' }, { l: '周线', v: '1w' }, |
| ] |
| |
| const metricCards = computed(() => { |
| const m = result.value?.metrics |
| if (!m) return [] |
| return [ |
| { label: '总收益率', value: m.total_return + '%', color: m.total_return >= 0 ? 'var(--green)' : 'var(--red)' }, |
| { label: '最大回撤', value: m.max_drawdown + '%', color: 'var(--red)' }, |
| { label: 'Sharpe Ratio', value: m.sharpe_ratio?.toString(), color: m.sharpe_ratio > 1 ? 'var(--green)' : 'var(--text-dark)' }, |
| { label: '胜率', value: m.win_rate + '%', color: m.win_rate > 50 ? 'var(--green)' : 'var(--red)' }, |
| { label: '盈亏比', value: m.profit_factor?.toString(), color: m.profit_factor > 1 ? 'var(--green)' : 'var(--red)' }, |
| { label: '总交易次数', value: m.total_trades?.toString() }, |
| ] |
| }) |
| |
| function loadTemplate(key) { |
| if (templates.value[key]) form.value.code = templates.value[key].code |
| } |
| |
| async function runBacktest() { |
| if (!form.value.code.trim()) { ElMessage.warning('请输入策略代码'); return } |
| running.value = true; error.value = ''; result.value = null |
| try { |
| const { data } = await backtestApi.run({ |
| code: form.value.code, symbol: form.value.symbol, interval: form.value.interval, |
| limit: 500, initial_capital: form.value.capital, commission: form.value.commission, |
| }) |
| if (data.status === 'error') { error.value = data.error } |
| else { result.value = data; ElMessage.success(`回测完成: ${data.metrics.total_trades} 笔交易, 收益 ${data.metrics.total_return}%`) } |
| } catch (e) { error.value = e.response?.data?.detail || e.message } |
| running.value = false |
| } |
| |
| const chartColors = { line: '#e9ecef', text: '#8392ab', green: '#4caf50', red: '#f44336', blue: '#1a73e8', gray: '#9e9e9e', area: 'rgba(76,175,80,0.1)' } |
| |
| const equityChartOpt = computed(() => { |
| const eq = result.value?.equity_curve |
| const bench = result.value?.benchmark_curve |
| const excess = result.value?.excess_curve |
| if (!eq || !bench || !excess) return {} |
| const ts = eq.timestamps.map(t => t.substring(0, 10)) |
| const base = result.value.metrics?.initial_capital || 1 |
| const eqNorm = eq.values.map(v => v / base) |
| const bmNorm = bench.values.map(v => v / base) |
| const exNorm = excess.values.map(v => v / base + 1) |
| return { |
| tooltip: { trigger: 'axis' }, |
| legend: { data: ['Strategy', 'Benchmark', 'Excess'], textStyle: { color: chartColors.text }, top: 4 }, |
| grid: { left: '10%', right: '4%', top: '8%', bottom: '15%' }, |
| xAxis: { type: 'category', data: ts, axisLine: { lineStyle: { color: chartColors.line } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| series: [ |
| { name: 'Strategy', type: 'line', data: eqNorm, smooth: true, lineStyle: { color: chartColors.blue, width: 2 }, symbol: 'none' }, |
| { name: 'Benchmark', type: 'line', data: bmNorm, smooth: true, lineStyle: { color: chartColors.gray, width: 1.5, type: 'dashed' }, symbol: 'none' }, |
| { name: 'Excess', type: 'line', data: exNorm, smooth: true, lineStyle: { color: chartColors.green, width: 2 }, symbol: 'none' }, |
| ], |
| } |
| }) |
| |
| const drawdownChartOpt = computed(() => { |
| const dd = result.value?.drawdown_curve; if (!dd) return {} |
| const ts = dd.timestamps.map(t => t.substring(5, 10)) |
| return { |
| tooltip: { trigger: 'axis' }, |
| grid: { left: '10%', right: '4%', top: '8%', bottom: '15%' }, |
| xAxis: { type: 'category', data: ts, axisLine: { lineStyle: { color: chartColors.line } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: chartColors.text, fontSize: 10, formatter: v => v + '%' } }, |
| series: [{ type: 'line', data: dd.values, smooth: true, lineStyle: { color: chartColors.red, width: 2 }, areaStyle: { color: 'rgba(244,67,54,0.15)' }, symbol: 'none' }], |
| } |
| }) |
| |
| const distChartOpt = computed(() => { |
| const d = result.value?.returns_distribution; if (!d) return {} |
| const labels = d.edges.slice(0, -1).map((e, i) => ((e + d.edges[i + 1]) / 2).toFixed(2) + '%') |
| const colors = d.edges.slice(0, -1).map((e, i) => ((e + d.edges[i + 1]) / 2) >= 0 ? 'rgba(76,175,80,0.7)' : 'rgba(244,67,54,0.65)') |
| return { |
| tooltip: { trigger: 'axis' }, |
| grid: { left: '10%', right: '4%', top: '8%', bottom: '15%' }, |
| xAxis: { type: 'category', data: labels, axisLine: { lineStyle: { color: chartColors.line } }, axisLabel: { color: chartColors.text, fontSize: 9, rotate: 45 } }, |
| yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| series: [{ name: 'Strategy', type: 'bar', barWidth: '75%', data: d.counts.map((c, i) => ({ value: c, itemStyle: { color: colors[i] } })) }], |
| } |
| }) |
| |
| const rollingChartOpt = computed(() => { |
| const rolling = result.value?.rolling_stats |
| if (!rolling) return {} |
| const ts = rolling.timestamps.map(t => t.substring(0, 10)) |
| return { |
| legend: { data: ['Rolling Sharpe', 'Rolling Volatility'], textStyle: { color: chartColors.text }, top: 4 }, |
| tooltip: { trigger: 'axis' }, |
| grid: { left: '10%', right: '8%', top: '15%', bottom: '15%' }, |
| xAxis: { type: 'category', data: ts, axisLine: { lineStyle: { color: chartColors.line } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| yAxis: [ |
| { type: 'value', name: 'Sharpe', splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| { type: 'value', name: 'Vol', position: 'right', axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| ], |
| series: [ |
| { name: 'Rolling Sharpe', type: 'line', yAxisIndex: 0, data: rolling.rolling_sharpe, smooth: true, lineStyle: { color: '#f9a825', width: 2 }, symbol: 'none' }, |
| { name: 'Rolling Volatility', type: 'line', yAxisIndex: 1, data: rolling.rolling_volatility, smooth: true, lineStyle: { color: chartColors.green, width: 2 }, symbol: 'none' }, |
| ], |
| } |
| }) |
| |
| const turnoverChartOpt = computed(() => { |
| const turn = result.value?.turnover_curve |
| if (!turn) return {} |
| const ts = turn.timestamps.map(t => t.substring(0, 10)) |
| const avg = (result.value.metrics?.turnover_rate || 0) / 100 |
| return { |
| tooltip: { trigger: 'axis' }, |
| grid: { left: '10%', right: '4%', top: '10%', bottom: '15%' }, |
| xAxis: { type: 'category', data: ts, axisLine: { lineStyle: { color: chartColors.line } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f0f2f5' } }, axisLabel: { color: chartColors.text, fontSize: 10 } }, |
| series: [ |
| { type: 'bar', data: turn.values, itemStyle: { color: 'rgba(33,150,243,0.25)' }, barWidth: '70%' }, |
| { type: 'line', data: ts.map(() => avg), symbol: 'none', lineStyle: { color: '#d32f2f', type: 'dashed', width: 2 } }, |
| ], |
| } |
| }) |
| |
| onMounted(async () => { |
| await store.fetchContracts() |
| try { |
| const { data } = await backtestApi.getTemplates() |
| templates.value = data |
| const first = Object.keys(data)[0] |
| if (first) { selectedTemplate.value = first; form.value.code = data[first].code } |
| } catch (e) { console.error(e) } |
| }) |
| </script> |
| |