quant_test / web_development /frontend /src /views /BacktestView.vue
lucky-loster's picture
Upload folder using huggingface_hub
590a501 verified
Raw
History Blame Contribute Delete
14.1 kB
<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>