mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
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:
@@ -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>
|
||||
@@ -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 的单条记录归一化为统一 Bar(datetime 字段)。 */
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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线 index,price 定位 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,57 @@
|
||||
// ECharts 按需引入。只注册 MVP-A 用到的图表类型,避免全量引入(~1MB → ~400KB)。
|
||||
// 用到的:candlestick(K线)、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
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 后端 API 的 TypeScript 类型镜像。
|
||||
// 与 src/easy_tdx/web/backtest_schemas.py 及 backtest router 的响应保持一致。
|
||||
// 后端是唯一事实源;这里只做类型契约。
|
||||
|
||||
// ── 策略 schema(GET /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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user