diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index 9191e30..5d339ee 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -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 } diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index c7a41aa..40a78eb 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -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(null) const equityCurve = ref([]) +const benchmarkCurve = ref([]) +const drawdownCurve = ref([]) const stocks = ref([]) const trades = ref([]) const metrics = ref(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() +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 { @@ -115,12 +153,15 @@ async function loadResult(tid: string): Promise { 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 {
夏普比率
{{ fmtNum(metrics?.sharpe) }}
日胜率
{{ fmtPct(metrics?.win_rate_daily) }}
交易胜率
{{ fmtPct(metrics?.win_rate_trade) }}
+
年化波动
{{ fmtPct(metrics?.annual_volatility) }}
+
Sortino
{{ fmtNum(metrics?.sortino) }}
+
Calmar
{{ fmtNum(metrics?.calmar) }}
+
基准收益
{{ fmtPct(metrics?.benchmark_return) }}
+
超额收益
{{ fmtPct(metrics?.excess_return) }}
+
Alpha / Beta
{{ fmtNum(metrics?.alpha, 2) }} / {{ fmtNum(metrics?.beta, 2) }}
- +
+ + +
+ + @@ -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; } diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index bf51964..5ccac50 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -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", []), } diff --git a/sanguo_orchestrator/portfolio_worker.py b/sanguo_orchestrator/portfolio_worker.py index d758139..cb50805 100644 --- a/sanguo_orchestrator/portfolio_worker.py +++ b/sanguo_orchestrator/portfolio_worker.py @@ -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, diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index 5f3602f..1e42f0f 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -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。 取最后一日的非零持仓作为选股名单。""" diff --git a/tests/portfolio/test_runner_curves.py b/tests/portfolio/test_runner_curves.py new file mode 100644 index 0000000..1c5b0b1 --- /dev/null +++ b/tests/portfolio/test_runner_curves.py @@ -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([], []) == {}