release: v1.17.11 — Web UI 策略库(SQLite 持久化)+ 多策略资金分仓组合回测

新增两层能力:
1. 策略库:单标的/组合回测结果可保存到本地 SQLite 单文件
   (~/.easy_tdx/strategies.db),策略库页可载入回填、重跑、删除。
2. 多策略组合回测:勾选 N 个单标的策略,各拿 1/N 资金、各跑原标的,
   净值曲线按日期并集对齐求和,组合结果含 19 项完整绩效指标 + 持仓表。

后端:strategy_store.py(SQLite CRUD) + multi_strategy_engine.py(资金分仓引擎)
+ routers/strategies.py + /backtest/multi-strategy/run/async。
前端:StrategiesView.vue + 保存策略按钮 + 复用组合页图表组件。

895 单测全绿(+24 新增),ruff/mypy strict/前端 vue-tsc 全通过。
This commit is contained in:
Justin Gu
2026-07-04 20:40:57 +08:00
parent 4db7104209
commit 05dc9a74af
19 changed files with 2742 additions and 11 deletions
+211
View File
@@ -0,0 +1,211 @@
"""单元测试:多策略资金分仓组合回测引擎(MultiStrategyEngine)。
覆盖:
- 基本多策略回测(2~3 个策略,各跑各的 df,合并曲线)
- 资金均分(1/N
- individual_results 的 key 格式 "{label}@{symbol}"
- 合并净值曲线列结构 + 日期并集对齐
- 空策略列表兜底
- 同标的不同策略可区分
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.backtest.multi_strategy_engine import (
MultiStrategyEngine,
StrategySlot,
)
from easy_tdx.backtest.strategy import Strategy
class SimpleBuyStrategy(Strategy):
"""简单策略:bar 5 买入,bar 30 卖出。"""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0)
elif self._bar_index == 30 and self.position["size"] > 0:
self.sell(size=0)
class HoldStrategy(Strategy):
"""从不交易的策略(净值曲线恒等于初始资金)。"""
def init(self) -> None:
pass
def next(self) -> None:
pass
def _make_df(n: int = 100, seed: int = 42, start: str = "2024-01-01") -> pd.DataFrame:
"""生成随机 OHLCV DataFrame(与 test_portfolio_engine 同构造方式)。"""
rng = np.random.default_rng(seed)
close = 100.0 + np.cumsum(rng.normal(0, 1, n))
high = close + rng.uniform(0, 1, n)
low = close - rng.uniform(0, 1, n)
open_ = low + rng.uniform(0, high - low, n)
vol = rng.integers(1_000_000, 10_000_000, n).astype(float)
return pd.DataFrame(
{
"datetime": pd.date_range(start, periods=n, freq="D"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
class TestMultiStrategyEngine:
def test_basic_run_two_strategies(self) -> None:
"""两个策略各跑各的 df,应产出合并结果。"""
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# individual_results 的 key 形如 "{label}@{symbol}"
assert set(result.individual_results.keys()) == {
"双均线@SH:601088",
"RSI@SZ:000001",
}
# 整体绩效含基本字段
assert "total_return" in result.total_performance
assert result.total_performance["total_stocks"] == 2
assert result.total_performance["total_cash"] == 1_000_000
def test_total_performance_has_full_metrics(self) -> None:
"""组合整体绩效应含完整 19 项指标(夏普/回撤/胜率/盈亏比等),与单标的同口径。"""
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
]
perf = MultiStrategyEngine(slots, total_cash=1_000_000).run().total_performance
# 关键指标都应在(来自 PerformanceAnalyzer
for key in [
"total_return",
"annual_return",
"sharpe",
"sortino",
"calmar",
"max_drawdown",
"max_dd_duration",
"volatility",
"total_trades",
"win_trades",
"lose_trades",
"win_rate",
"profit_factor",
"avg_win",
"avg_loss",
"max_win",
"max_loss",
]:
assert key in perf, f"缺少指标 {key}"
# max_drawdown 用正值约定(与单标的一致),介于 0~1
assert 0 <= perf["max_drawdown"] <= 1
# 合并净值曲线的 drawdown 也应是正值
result = MultiStrategyEngine(slots, total_cash=1_000_000).run()
assert (result.combined_equity["drawdown"] >= 0).all()
def test_capital_split_equal(self) -> None:
"""资金按策略数均分:每个槽位 1/N。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), _make_df(50, seed=2)),
StrategySlot("C", "SZ:000002", SimpleBuyStrategy(), _make_df(50, seed=3)),
]
engine = MultiStrategyEngine(slots, total_cash=900_000)
allocs = engine._compute_allocations() # noqa: SLF001 — 测试内部均分逻辑
assert len(allocs) == 3
assert all(v == 300_000 for v in allocs.values())
# equity_allocation 是占比,各 1/3
result = engine.run()
assert all(abs(v - 1 / 3) < 1e-9 for v in result.equity_allocation.values())
def test_combined_equity_has_expected_columns(self) -> None:
"""合并净值曲线应有 datetime/total/drawdown/drawdown_pct 列。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(60, seed=7)),
]
engine = MultiStrategyEngine(slots, total_cash=500_000)
result = engine.run()
cols = set(result.combined_equity.columns)
assert {"datetime", "total", "drawdown", "drawdown_pct"} <= cols
assert len(result.combined_equity) > 0
def test_combined_equity_aligns_disjoint_dates(self) -> None:
"""两个策略日期范围不同时,合并曲线应按并集对齐(ffill)。"""
# 策略 A 跑 2024-01 起 60 根,策略 B 跑 2024-03 起 60 根
df_a = _make_df(60, seed=1, start="2024-01-01")
df_b = _make_df(60, seed=2, start="2024-03-01")
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), df_a),
StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), df_b),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# 合并曲线长度应至少覆盖两个范围的最晚结束日(并集)
assert len(result.combined_equity) >= 60
def test_empty_strategies_returns_empty_result(self) -> None:
"""空策略列表应返回空结果,不抛异常。"""
engine = MultiStrategyEngine([], total_cash=1_000_000)
result = engine.run()
assert result.individual_results == {}
assert result.total_performance["total_return"] == 0.0
# combined_equity 为带表头的空 DataFrame
assert len(result.combined_equity) == 0
assert set(result.combined_equity.columns) == {
"datetime",
"total",
"drawdown",
"drawdown_pct",
}
def test_same_symbol_different_strategies_distinguished(self) -> None:
"""同标的不同策略应能区分(key 含 label)。"""
df = _make_df(60, seed=5)
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), df.copy()),
StrategySlot("RSI", "SH:601088", HoldStrategy(), df.copy()),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# 两个 key 不同,都带同一 symbol
assert "双均线@SH:601088" in result.individual_results
assert "RSI@SH:601088" in result.individual_results
def test_hold_strategy_keeps_initial_capital(self) -> None:
"""从不交易的策略,其净值曲线末值应等于初始分得资金。"""
slots = [
StrategySlot("Hold", "SH:601088", HoldStrategy(), _make_df(40, seed=1)),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
ec = result.individual_results["Hold@SH:601088"].equity_curve
# 不交易 → 末值 ≈ 初始资金 1_000_000(单策略拿全部)
assert abs(ec["total"].iloc[-1] - 1_000_000) < 1.0
def test_to_dict_serializable(self) -> None:
"""to_dict 应产出 JSON 兼容结构(含 individual_results / combined_equity)。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
]
result = MultiStrategyEngine(slots, total_cash=500_000).run()
d = result.to_dict()
assert "total_performance" in d
assert "individual_results" in d
assert "combined_equity" in d
assert isinstance(d["individual_results"]["A@SH:601088"], dict)