mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
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:
@@ -0,0 +1,245 @@
|
||||
"""多策略组合回测引擎(资金分仓 / 并行制)。
|
||||
|
||||
与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine` 的区别:
|
||||
- 后者是「**一个**策略 × **多只**股票」,资金按股票均分。
|
||||
- 本引擎是「**多个**策略 × **各自**原标的」,资金按策略均分,每个策略独立回测,
|
||||
各自的净值曲线按日期对齐后求和,得到组合整体净值曲线。
|
||||
|
||||
典型场景:用户在策略库勾选若干「好策略」,各跑在它保存时的标的上,看综合表现。
|
||||
|
||||
用法::
|
||||
|
||||
engine = MultiStrategyEngine(
|
||||
strategies=[
|
||||
StrategySlot(label="双均线交叉", symbol="SH:601088", strategy=strat_a, df=df_a),
|
||||
StrategySlot(label="RSI反转", symbol="SZ:000001", strategy=strat_b, df=df_b),
|
||||
],
|
||||
total_cash=1_000_000,
|
||||
)
|
||||
result = engine.run()
|
||||
print(result.total_performance)
|
||||
|
||||
输出结构与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioResult` 一致,便于
|
||||
前端复用组合页的净值曲线 / 对比表 / 叠加图组件。``individual_results`` 的 key 形如
|
||||
``"双均线交叉@SH:601088"``(既能区分同标的不同策略,又一眼看清跑哪个票)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
from easy_tdx.backtest.strategy import Strategy
|
||||
from easy_tdx.backtest.types import BacktestResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategySlot:
|
||||
"""单个策略槽位:一个已构造的策略实例 + 它要跑的标的标识与 K 线。
|
||||
|
||||
Attributes:
|
||||
label: 策略展示名(如 "双均线交叉"),用于拼 individual_results 的 key。
|
||||
symbol: 标的完整代码(如 "SH:601088"),仅用于标识与展示。
|
||||
strategy: 已构造(带参数)的策略实例。
|
||||
df: 该标的的 K 线 DataFrame。
|
||||
"""
|
||||
|
||||
label: str
|
||||
symbol: str
|
||||
strategy: Strategy
|
||||
df: pd.DataFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiStrategyResult:
|
||||
"""多策略组合回测结果(字段语义与 PortfolioResult 对齐,便于前端复用)。
|
||||
|
||||
Attributes:
|
||||
total_performance: 组合整体绩效(资金加权收益率 + 策略数 + 总资金)。
|
||||
individual_results: 每个策略槽位的独立回测结果,key 形如 "{label}@{symbol}"。
|
||||
equity_allocation: 每个槽位的资金分配比例(均分时各 1/N)。
|
||||
combined_equity: 组合整体净值曲线(各槽位按日期并集 ffill 对齐后求和),
|
||||
列: datetime / total / drawdown / drawdown_pct。
|
||||
"""
|
||||
|
||||
total_performance: dict[str, float]
|
||||
individual_results: dict[str, BacktestResult]
|
||||
equity_allocation: dict[str, float]
|
||||
combined_equity: pd.DataFrame
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"total_performance": self.total_performance,
|
||||
"individual_results": {k: v.to_dict() for k, v in self.individual_results.items()},
|
||||
"equity_allocation": self.equity_allocation,
|
||||
"combined_equity": self.combined_equity.to_dict(orient="records"),
|
||||
}
|
||||
|
||||
|
||||
class MultiStrategyEngine:
|
||||
"""多策略资金分仓组合回测引擎。
|
||||
|
||||
把总资金按策略数均分,每个策略在各自的 K 线上独立回测(各跑各的),
|
||||
再把各净值曲线按日期对齐求和,得到组合整体净值。资金分配方式固定为
|
||||
"equal"(均分)——多策略组合的目标是"看综合表现",均分是最直接的基线。
|
||||
|
||||
参数与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine`
|
||||
对齐(``strategy``/``stocks`` 换成 ``strategies``),便于复用资金/成本配置。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategies: list[StrategySlot],
|
||||
total_cash: float = 1_000_000.0,
|
||||
commission: float = 0.0003,
|
||||
min_commission: float = 5.0,
|
||||
stamp_tax: float = 0.001,
|
||||
slippage: float = 0.0,
|
||||
execution: str = "next_open",
|
||||
) -> None:
|
||||
self._strategies = strategies
|
||||
self._total_cash = total_cash
|
||||
self._commission = commission
|
||||
self._min_commission = min_commission
|
||||
self._stamp_tax = stamp_tax
|
||||
self._slippage = slippage
|
||||
self._execution = execution
|
||||
|
||||
def _compute_allocations(self) -> dict[str, float]:
|
||||
"""资金均分:每个策略槽位拿 total_cash / N。"""
|
||||
n = len(self._strategies)
|
||||
if n == 0:
|
||||
return {}
|
||||
per = self._total_cash / n
|
||||
return {self._key(s): per for s in self._strategies}
|
||||
|
||||
@staticmethod
|
||||
def _key(s: StrategySlot) -> str:
|
||||
"""individual_results / allocation 的统一 key:"{label}@{symbol}"。"""
|
||||
return f"{s.label}@{s.symbol}"
|
||||
|
||||
def run(self) -> MultiStrategyResult:
|
||||
"""逐策略独立回测,再汇总成组合整体绩效与合并净值曲线。"""
|
||||
allocations = self._compute_allocations()
|
||||
individual_results: dict[str, BacktestResult] = {}
|
||||
|
||||
for slot in self._strategies:
|
||||
key = self._key(slot)
|
||||
cash = allocations.get(key, 0)
|
||||
engine = BacktestEngine(
|
||||
strategy=slot.strategy,
|
||||
cash=cash,
|
||||
commission=self._commission,
|
||||
min_commission=self._min_commission,
|
||||
stamp_tax=self._stamp_tax,
|
||||
slippage=self._slippage,
|
||||
execution=self._execution,
|
||||
)
|
||||
individual_results[key] = engine.run(slot.df)
|
||||
|
||||
total_alloc = sum(allocations.values())
|
||||
equity_pct = {k: v / total_alloc if total_alloc > 0 else 0 for k, v in allocations.items()}
|
||||
combined_equity = self._build_combined_equity(individual_results, allocations)
|
||||
total_perf = self._aggregate_performance(individual_results, allocations, combined_equity)
|
||||
|
||||
return MultiStrategyResult(
|
||||
total_performance=total_perf,
|
||||
individual_results=individual_results,
|
||||
equity_allocation=equity_pct,
|
||||
combined_equity=combined_equity,
|
||||
)
|
||||
|
||||
def _aggregate_performance(
|
||||
self,
|
||||
results: dict[str, BacktestResult],
|
||||
allocations: dict[str, float],
|
||||
combined_equity: pd.DataFrame,
|
||||
) -> dict[str, float]:
|
||||
"""组合整体绩效:基于合并净值曲线 + 汇总成交算完整 19 项指标。
|
||||
|
||||
与 PortfolioBacktestEngine 仅给 4 个字段不同,这里把合并净值曲线和所有
|
||||
槽位的成交汇总,喂给 PerformanceAnalyzer,得到与单标的回测同口径的完整
|
||||
指标(夏普/回撤/胜率/盈亏比等),便于前端复用 MetricTable 展示。
|
||||
"""
|
||||
from easy_tdx.backtest.performance import PerformanceAnalyzer
|
||||
|
||||
total_cash = sum(allocations.values())
|
||||
base: dict[str, float] = {
|
||||
"total_stocks": float(len(results)), # 字段名沿用 PortfolioResult
|
||||
"total_cash": total_cash,
|
||||
}
|
||||
if not results or len(combined_equity) < 2:
|
||||
base.update({"total_return": 0.0, "annual_return": 0.0})
|
||||
return base
|
||||
|
||||
# 汇总所有槽位的成交(concat 成一张表,PerformanceAnalyzer 据此算
|
||||
# 胜率/盈亏比/平均盈亏等交易类指标)。所有策略均无成交时给空表兜底。
|
||||
trade_frames = [r.trades for r in results.values() if len(r.trades) > 0]
|
||||
all_trades = (
|
||||
pd.concat(trade_frames, ignore_index=True)
|
||||
if trade_frames
|
||||
else pd.DataFrame(columns=["direction", "pnl", "rejected"])
|
||||
)
|
||||
|
||||
analyzer = PerformanceAnalyzer(equity_curve=combined_equity, trades=all_trades)
|
||||
metrics = analyzer.compute()
|
||||
metrics["total_stocks"] = float(len(results))
|
||||
metrics["total_cash"] = total_cash
|
||||
return metrics
|
||||
|
||||
def _build_combined_equity(
|
||||
self,
|
||||
results: dict[str, BacktestResult],
|
||||
allocations: dict[str, float],
|
||||
) -> pd.DataFrame:
|
||||
"""把各策略独立净值曲线按日期并集 ffill 对齐后求和。
|
||||
|
||||
算法与 ``PortfolioBacktestEngine._build_combined_equity`` 一致:
|
||||
各策略回测日期范围可能不同(取数差异、停牌),取 datetime 并集,
|
||||
每个策略的 total 列 forward-fill 对齐到并集后求和得组合总净值,
|
||||
再算回撤。
|
||||
"""
|
||||
del allocations # 资金分配不参与曲线形状(各策略独立 full cash 回测,
|
||||
# 合并的是 normalized 的净值贡献;保持签名与 Portfolio 版一致便于对照)
|
||||
empty = pd.DataFrame(columns=["datetime", "total", "drawdown", "drawdown_pct"])
|
||||
if not results:
|
||||
return empty
|
||||
|
||||
series_list: list[pd.Series] = []
|
||||
for key, result in results.items():
|
||||
ec = result.equity_curve
|
||||
if len(ec) == 0:
|
||||
continue
|
||||
dt = ec["datetime"]
|
||||
if dt.dtype.kind in "iu": # int YYYYMMDD
|
||||
dt = pd.to_datetime(dt.astype(str), format="%Y%m%d")
|
||||
elif dt.dtype != "datetime64[ns]":
|
||||
dt = pd.to_datetime(dt)
|
||||
s = pd.Series(ec["total"].to_numpy(), index=dt, name=key)
|
||||
series_list.append(s)
|
||||
|
||||
if not series_list:
|
||||
return empty
|
||||
|
||||
aligned = pd.concat(series_list, axis=1).sort_index()
|
||||
aligned = aligned.ffill().fillna(0)
|
||||
total = aligned.sum(axis=1)
|
||||
|
||||
# 回撤:用正值约定(peak - total),与单标的 PortfolioTracker.equity_curve
|
||||
# 及 PerformanceAnalyzer 一致;EquityChart 也按正值展示(前端取负向下画)。
|
||||
peak = total.cummax()
|
||||
drawdown = peak - total
|
||||
initial = peak.iloc[0] if len(peak) > 0 and peak.iloc[0] != 0 else 1.0
|
||||
drawdown_pct = drawdown / initial
|
||||
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": total.index,
|
||||
"total": total.to_numpy(),
|
||||
"drawdown": drawdown.to_numpy(),
|
||||
"drawdown_pct": drawdown_pct.to_numpy(),
|
||||
}
|
||||
).reset_index(drop=True)
|
||||
Reference in New Issue
Block a user