feat(portfolio): 组合回测结果增强: 基准对比曲线(对齐交易日+归一化)+回撤序列+扩展指标(波动/Sortino/Calmar/超额/Alpha/Beta), worker与API透传, 结果页净值对比+回撤图 [vps]
This commit is contained in:
@@ -51,6 +51,24 @@ export interface PortfolioMetrics {
|
||||
win_rate_daily: number | null
|
||||
win_rate_trade: number | null
|
||||
trading_days: number | null
|
||||
// 扩展指标(后端 _compute_extended_metrics,可能缺失)
|
||||
annual_volatility?: number | null
|
||||
sortino?: number | null
|
||||
calmar?: number | null
|
||||
benchmark_return?: number | null
|
||||
excess_return?: number | null
|
||||
alpha?: number | null
|
||||
beta?: number | null
|
||||
}
|
||||
|
||||
export interface BenchmarkPoint {
|
||||
date: string
|
||||
benchmark: number
|
||||
}
|
||||
|
||||
export interface DrawdownPoint {
|
||||
date: string
|
||||
drawdown: number
|
||||
}
|
||||
|
||||
export interface PortfolioBacktestResult {
|
||||
@@ -60,6 +78,8 @@ export interface PortfolioBacktestResult {
|
||||
trades: PortfolioTrade[]
|
||||
equity_curve: EquityPoint[]
|
||||
metrics: PortfolioMetrics
|
||||
benchmark_curve?: BenchmarkPoint[]
|
||||
drawdown_curve?: DrawdownPoint[]
|
||||
raw_summary?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type StockPicked,
|
||||
type PortfolioTrade,
|
||||
type PortfolioMetrics,
|
||||
type BenchmarkPoint,
|
||||
type DrawdownPoint,
|
||||
} from '@/api/portfolio'
|
||||
|
||||
// 两态:表单(发起) / 查看(route.query.task_id 历史结果)。任务跟踪统一在「历史任务」页(任务中心)。
|
||||
@@ -21,6 +23,8 @@ const router = useRouter()
|
||||
const viewTaskId = computed(() => (route.query.task_id as string) || '')
|
||||
const result = ref<PortfolioBacktestResult | null>(null)
|
||||
const equityCurve = ref<EquityPoint[]>([])
|
||||
const benchmarkCurve = ref<BenchmarkPoint[]>([])
|
||||
const drawdownCurve = ref<DrawdownPoint[]>([])
|
||||
const stocks = ref<StockPicked[]>([])
|
||||
const trades = ref<PortfolioTrade[]>([])
|
||||
const metrics = ref<PortfolioMetrics | null>(null)
|
||||
@@ -91,23 +95,57 @@ const { setOption: setEquityOption } = useChart(equityEl)
|
||||
function renderEquity(): void {
|
||||
if (!equityCurve.value.length) return
|
||||
const dates = equityCurve.value.map((p) => p.date)
|
||||
const values = equityCurve.value.map((p) => p.equity)
|
||||
// 策略净值归一化(首日=1),与基准同轴对比
|
||||
const base = equityCurve.value[0]?.equity || 1
|
||||
const values = equityCurve.value.map((p) => p.equity / base)
|
||||
const hasBench = benchmarkCurve.value.length === equityCurve.value.length
|
||||
const series: EChartsCoreOption['series'] = [
|
||||
{
|
||||
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values.map((v) => Number(v.toFixed(4))),
|
||||
},
|
||||
]
|
||||
if (hasBench) {
|
||||
series.push({
|
||||
type: 'line', name: '基准净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#4b7bce', width: 1.4, type: 'dashed' },
|
||||
data: benchmarkCurve.value.map((p) => Number((p.benchmark ?? 1).toFixed(4))),
|
||||
})
|
||||
}
|
||||
const option: EChartsCoreOption = {
|
||||
title: darkTitle('净值曲线'),
|
||||
tooltip: darkTooltip(),
|
||||
title: darkTitle(hasBench ? '净值对比(归一化)' : '净值曲线'),
|
||||
tooltip: { ...darkTooltip(), trigger: 'axis' },
|
||||
legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } },
|
||||
grid: darkGrid(),
|
||||
xAxis: { type: 'category', data: dates, ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, name: '净值(元)', ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, name: '净值', ...darkAxis() },
|
||||
series,
|
||||
}
|
||||
setEquityOption(option)
|
||||
}
|
||||
|
||||
const ddEl = ref<HTMLDivElement>()
|
||||
const { setOption: setDdOption } = useChart(ddEl)
|
||||
function renderDrawdown(): void {
|
||||
if (!drawdownCurve.value.length) return
|
||||
const option: EChartsCoreOption = {
|
||||
title: darkTitle('回撤(%)'),
|
||||
tooltip: { ...darkTooltip(), trigger: 'axis' },
|
||||
grid: darkGrid(),
|
||||
xAxis: { type: 'category', data: drawdownCurve.value.map((p) => p.date), ...darkAxis() },
|
||||
yAxis: { type: 'value', name: '回撤%', ...darkAxis() },
|
||||
series: [
|
||||
{
|
||||
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values,
|
||||
type: 'line', name: '回撤', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#6f42c1', width: 1.4 },
|
||||
areaStyle: { color: '#6f42c1', opacity: 0.18 },
|
||||
data: drawdownCurve.value.map((p) => Number((p.drawdown ?? 0).toFixed(2))),
|
||||
},
|
||||
],
|
||||
}
|
||||
setEquityOption(option)
|
||||
setDdOption(option)
|
||||
}
|
||||
|
||||
async function loadResult(tid: string): Promise<void> {
|
||||
@@ -115,12 +153,15 @@ async function loadResult(tid: string): Promise<void> {
|
||||
const r = await getPortfolioResult(tid)
|
||||
result.value = r
|
||||
equityCurve.value = r.equity_curve || []
|
||||
benchmarkCurve.value = r.benchmark_curve || []
|
||||
drawdownCurve.value = r.drawdown_curve || []
|
||||
stocks.value = r.stocks_selected || []
|
||||
trades.value = r.trades || []
|
||||
metrics.value = r.metrics || null
|
||||
period.value = r.period || null
|
||||
await nextTick()
|
||||
renderEquity()
|
||||
renderDrawdown()
|
||||
} catch {
|
||||
ElMessage.error('加载结果失败')
|
||||
}
|
||||
@@ -154,6 +195,7 @@ onMounted(() => {
|
||||
if (viewTaskId.value) loadResult(viewTaskId.value)
|
||||
})
|
||||
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
|
||||
watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' })
|
||||
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
@@ -269,14 +311,25 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
<div class="metric-cell"><div class="metric-label">夏普比率</div><div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">日胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">交易胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">年化波动</div><div class="metric-value">{{ fmtPct(metrics?.annual_volatility) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Sortino</div><div class="metric-value">{{ fmtNum(metrics?.sortino) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Calmar</div><div class="metric-value">{{ fmtNum(metrics?.calmar) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">基准收益</div><div class="metric-value" :class="(metrics?.benchmark_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.benchmark_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">超额收益</div><div class="metric-value" :class="(metrics?.excess_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.excess_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Alpha / Beta</div><div class="metric-value">{{ fmtNum(metrics?.alpha, 2) }} / {{ fmtNum(metrics?.beta, 2) }}</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">净值曲线</span></template>
|
||||
<template #header><span class="section-title">净值对比</span></template>
|
||||
<div ref="equityEl" class="chart-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result && drawdownCurve.length" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">回撤曲线</span></template>
|
||||
<div ref="ddEl" class="chart-box dd-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">选股名单(末日持仓)</span></template>
|
||||
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
|
||||
@@ -313,6 +366,7 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
.submit-bar { padding: 4px 0 8px; }
|
||||
.result-actions { display: flex; gap: 8px; padding: 0 0 4px; }
|
||||
.chart-box { width: 100%; height: 360px; }
|
||||
.dd-box { height: 240px; }
|
||||
.metric-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||
.metric-cell { background: var(--bg-hover); border: 1px solid var(--border-2); border-radius: 6px; padding: 12px 14px; }
|
||||
.metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||
|
||||
@@ -104,6 +104,8 @@ def get_portfolio_result(task_id: str):
|
||||
"equity_curve": _df_to_records(r.equity_curve),
|
||||
"trades": _df_to_records(r.trades),
|
||||
"stocks_selected": stocks_selected,
|
||||
"benchmark_curve": stats.get("benchmark_curve", []),
|
||||
"drawdown_curve": stats.get("drawdown_curve", []),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@ def run_portfolio_task(spec: dict) -> Any:
|
||||
"metrics": data.get("metrics", {}),
|
||||
"raw_summary": data.get("raw_summary", {}),
|
||||
"stocks_selected": data.get("stocks_selected", []),
|
||||
"benchmark_curve": data.get("benchmark_curve", []),
|
||||
"drawdown_curve": data.get("drawdown_curve", []),
|
||||
"period": period,
|
||||
},
|
||||
equity_curve=pd.DataFrame(equity_list) if equity_list else None,
|
||||
|
||||
@@ -288,6 +288,14 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
result = engine.run()
|
||||
print("[runner] RUN_DONE type=%s" % type(result).__name__, flush=True)
|
||||
|
||||
# 引擎不把基准序列放进 results——这里带出(引擎已按区间加载 benchmark_data)
|
||||
try:
|
||||
bd = getattr(engine, "benchmark_data", None)
|
||||
if bd is not None and len(bd) and isinstance(result, dict):
|
||||
result["benchmark_curve"] = _extract_benchmark_curve(bd)
|
||||
except Exception as exc:
|
||||
logger.warning("提取基准曲线失败: %s", exc)
|
||||
|
||||
# 输出结果摘要到 markdown(JSON 模式时 result_file="" 跳过)
|
||||
if getattr(args, "result_file", ""):
|
||||
_write_result_md(result, args.result_file, args)
|
||||
@@ -380,6 +388,11 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 净值曲线:daily_records 是 DataFrame,index=date,列含 total_value
|
||||
equity_curve = _extract_equity_curve(raw.get("daily_records"))
|
||||
|
||||
# 基准曲线(对齐策略交易日、归一化) + 回撤序列 + 扩展指标
|
||||
benchmark_curve = _align_benchmark(raw.get("benchmark_curve"), equity_curve)
|
||||
drawdown_curve = _extract_drawdown(equity_curve)
|
||||
metrics.update(_compute_extended_metrics(equity_curve, benchmark_curve))
|
||||
|
||||
# 选股(末日持仓):daily_positions 最后一日
|
||||
stocks_selected = _extract_last_positions(raw.get("daily_positions"))
|
||||
|
||||
@@ -397,6 +410,8 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"stocks_selected": stocks_selected,
|
||||
"trades": trades,
|
||||
"equity_curve": equity_curve,
|
||||
"benchmark_curve": benchmark_curve,
|
||||
"drawdown_curve": drawdown_curve,
|
||||
"metrics": metrics,
|
||||
"raw_summary": summary,
|
||||
}
|
||||
@@ -454,6 +469,128 @@ def _extract_equity_curve(daily_records: Any) -> list[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _extract_benchmark_curve(bd: Any) -> list[Dict[str, Any]]:
|
||||
"""engine.benchmark_data → [{date, close}]。jq 风格 DataFrame(index=date,含 close)或 Series。"""
|
||||
out: list[Dict[str, Any]] = []
|
||||
try:
|
||||
import pandas as pd # type: ignore
|
||||
if isinstance(bd, pd.Series):
|
||||
df = bd.to_frame(name="close").reset_index()
|
||||
df.columns = ["date", "close"]
|
||||
elif isinstance(bd, pd.DataFrame) and "close" in bd.columns:
|
||||
df = bd[["close"]].reset_index()
|
||||
df.columns = ["date", "close"]
|
||||
else:
|
||||
return out
|
||||
for _, row in df.iterrows():
|
||||
d = row["date"]
|
||||
close = row["close"]
|
||||
if close is None or str(close) == "nan":
|
||||
continue
|
||||
out.append({
|
||||
"date": getattr(d, "strftime", lambda f: str(d))("%Y-%m-%d"),
|
||||
"close": float(close),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("解析 benchmark_curve 失败: %s", exc)
|
||||
return out
|
||||
|
||||
|
||||
def _align_benchmark(benchmark: Any, equity_curve: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
||||
"""基准收盘对齐策略交易日(前向填充)并归一化为净值 1.0 起。
|
||||
|
||||
基准日历(指数)与策略交易日历基本一致;不一致时用最近一日基准价填充,
|
||||
首日之前无基准则从首个可得日起以该日为 1.0。
|
||||
"""
|
||||
if not benchmark or not equity_curve:
|
||||
return []
|
||||
close_by_date: Dict[str, float] = {}
|
||||
for p in benchmark:
|
||||
try:
|
||||
close_by_date[p["date"]] = float(p["close"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
out: list[Dict[str, Any]] = []
|
||||
last_close: float | None = None
|
||||
base: float | None = None
|
||||
for point in equity_curve:
|
||||
d = point["date"]
|
||||
c = close_by_date.get(d)
|
||||
if c is None or c <= 0:
|
||||
c = last_close
|
||||
else:
|
||||
last_close = c
|
||||
if c is None or c <= 0:
|
||||
out.append({"date": d, "benchmark": 1.0}) # 基准缺头几天:先垫 1.0
|
||||
continue
|
||||
if base is None:
|
||||
base = c
|
||||
out.append({"date": d, "benchmark": c / base})
|
||||
return out
|
||||
|
||||
|
||||
def _extract_drawdown(equity_curve: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
||||
"""净值 → 回撤序列(%,负值):dd = equity/历史峰值 - 1。"""
|
||||
out: list[Dict[str, Any]] = []
|
||||
peak: float | None = None
|
||||
for point in equity_curve:
|
||||
v = float(point.get("equity", 0) or 0)
|
||||
if peak is None or v > peak:
|
||||
peak = v
|
||||
dd = (v / peak - 1) * 100 if peak else 0.0
|
||||
out.append({"date": point["date"], "drawdown": dd})
|
||||
return out
|
||||
|
||||
|
||||
def _compute_extended_metrics(
|
||||
equity_curve: list[Dict[str, Any]],
|
||||
benchmark_curve: list[Dict[str, Any]],
|
||||
) -> Dict[str, float]:
|
||||
"""从净值/基准序列算扩展指标(纯 python,不引 numpy)。
|
||||
|
||||
惯例与 _extract_metrics 一致:比率类原值、百分比类用百分数字面值。
|
||||
"""
|
||||
out: Dict[str, float] = {}
|
||||
vals = [float(p["equity"]) for p in equity_curve]
|
||||
if len(vals) < 2:
|
||||
return out
|
||||
rets = [vals[i] / vals[i - 1] - 1 for i in range(1, len(vals)) if vals[i - 1] > 0]
|
||||
n = len(rets)
|
||||
if n == 0:
|
||||
return out
|
||||
mean = sum(rets) / n
|
||||
var = sum((r - mean) ** 2 for r in rets) / max(n - 1, 1)
|
||||
vol = (var ** 0.5) * (252 ** 0.5)
|
||||
out["annual_volatility"] = vol * 100
|
||||
downside = [r for r in rets if r < 0]
|
||||
if downside:
|
||||
dstd = (sum(r * r for r in downside) / len(downside)) ** 0.5
|
||||
if dstd > 0:
|
||||
out["sortino"] = (mean / dstd) * (252 ** 0.5)
|
||||
days = len(equity_curve)
|
||||
ann_s = (vals[-1] / vals[0]) ** (252 / days) - 1 if vals[0] > 0 and vals[-1] > 0 else None
|
||||
total_dd = min(p["drawdown"] for p in _extract_drawdown(equity_curve)) if days else None
|
||||
if total_dd is not None and total_dd < 0 and ann_s is not None:
|
||||
out["calmar"] = ann_s / abs(total_dd / 100)
|
||||
|
||||
bench = [float(p.get("benchmark", 1.0) or 1.0) for p in benchmark_curve] if benchmark_curve else []
|
||||
if len(bench) == days and bench[0] > 0:
|
||||
brets = [bench[i] / bench[i - 1] - 1 for i in range(1, len(bench)) if bench[i - 1] > 0]
|
||||
if brets:
|
||||
out["benchmark_return"] = (bench[-1] - 1) * 100
|
||||
out["excess_return"] = (vals[-1] / vals[0] - 1) * 100 - out["benchmark_return"]
|
||||
bmean = sum(brets) / len(brets)
|
||||
bvar = sum((r - bmean) ** 2 for r in brets) / max(len(brets) - 1, 1)
|
||||
if bvar > 0:
|
||||
cov = sum((rets[i] - mean) * (brets[i] - bmean) for i in range(min(n, len(brets)))) / max(min(n, len(brets)) - 1, 1)
|
||||
beta = cov / bvar
|
||||
out["beta"] = beta
|
||||
ann_b = (bench[-1] / bench[0]) ** (252 / days) - 1
|
||||
if ann_s is not None:
|
||||
out["alpha"] = (ann_s - beta * ann_b) * 100
|
||||
return out
|
||||
|
||||
|
||||
def _extract_last_positions(daily_positions: Any) -> list[Dict[str, Any]]:
|
||||
"""daily_positions: DataFrame,列含 date/code/amount/avg_cost/price/value。
|
||||
取最后一日的非零持仓作为选股名单。"""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""runner_backtest 基准对齐/回撤/扩展指标 纯函数单测(B1)。"""
|
||||
import pytest
|
||||
|
||||
from sanguo_portfolio.runner_backtest import (
|
||||
_align_benchmark,
|
||||
_compute_extended_metrics,
|
||||
_extract_drawdown,
|
||||
)
|
||||
|
||||
|
||||
EQ = [
|
||||
{"date": "2024-01-01", "equity": 100.0},
|
||||
{"date": "2024-01-02", "equity": 110.0},
|
||||
{"date": "2024-01-03", "equity": 99.0},
|
||||
{"date": "2024-01-04", "equity": 120.0},
|
||||
{"date": "2024-01-05", "equity": 90.0},
|
||||
]
|
||||
BD = [
|
||||
{"date": "2024-01-01", "close": 200.0},
|
||||
{"date": "2024-01-02", "close": 220.0},
|
||||
{"date": "2024-01-03", "close": 210.0},
|
||||
{"date": "2024-01-04", "close": 260.0},
|
||||
{"date": "2024-01-05", "close": 208.0},
|
||||
]
|
||||
|
||||
|
||||
def test_drawdown_series():
|
||||
dd = _extract_drawdown(EQ)
|
||||
vals = [p["drawdown"] for p in dd]
|
||||
assert vals[0] == 0.0 and vals[1] == 0.0 and vals[3] == 0.0
|
||||
assert vals[2] == pytest.approx(-10.0)
|
||||
assert vals[4] == pytest.approx(-25.0)
|
||||
|
||||
|
||||
def test_align_benchmark_normalizes_and_matches_length():
|
||||
bench = _align_benchmark(BD, EQ)
|
||||
assert len(bench) == len(EQ)
|
||||
assert bench[0]["benchmark"] == pytest.approx(1.0)
|
||||
assert bench[4]["benchmark"] == pytest.approx(1.04)
|
||||
|
||||
|
||||
def test_align_benchmark_ffill_missing_dates():
|
||||
bench = _align_benchmark(BD[:2], EQ)
|
||||
# 后 3 天无基准数据 → 前向填充 1.1
|
||||
assert [p["benchmark"] for p in bench] == [1.0, 1.1, 1.1, 1.1, 1.1]
|
||||
|
||||
|
||||
def test_align_benchmark_empty_inputs():
|
||||
assert _align_benchmark([], EQ) == []
|
||||
assert _align_benchmark(BD, []) == []
|
||||
|
||||
|
||||
def test_extended_metrics_values():
|
||||
bench = _align_benchmark(BD, EQ)
|
||||
m = _compute_extended_metrics(EQ, bench)
|
||||
assert m["benchmark_return"] == pytest.approx(4.0)
|
||||
assert m["excess_return"] == pytest.approx(-14.0) # -10% 策略 - +4% 基准
|
||||
assert m["beta"] == pytest.approx(1.0874, abs=1e-3)
|
||||
assert "annual_volatility" in m
|
||||
assert "sortino" in m
|
||||
assert "calmar" in m
|
||||
|
||||
|
||||
def test_extended_metrics_short_series():
|
||||
assert _compute_extended_metrics([{"date": "d", "equity": 1.0}], []) == {}
|
||||
assert _compute_extended_metrics([], []) == {}
|
||||
Reference in New Issue
Block a user