feat: multi-factor combo backtest engine (v1.9.0)

- Add backtest/combo.py: CombinationRunner, extract_factor_signals, combine_masks
- Signal merge modes: AND / OR / MAJORITY (majority default)
- CLI: --combo-strategies and --combo-mode for easy-tdx backtest
- run_all_strategies.py: --combo 2 --combo 3 auto-screen best combos
- Fix MyTT MFI/CR divide-by-zero RuntimeWarning
- 14 new unit tests, 328 total passing
This commit is contained in:
Justin Gu
2026-06-10 01:37:28 +08:00
parent 7f1bc645c2
commit 1e99feb7c2
14 changed files with 1695 additions and 578 deletions
+153 -22
View File
@@ -59,6 +59,97 @@ def _map_trade_values(trades_df: Any, equity: Any, initial_cash: float) -> list[
return result_vals
def _run_combo_screen(
strategy_files: list[Path],
df: Any,
cash: float,
commission: float,
combo_sizes: tuple[int, ...],
combo_mode: str,
) -> None:
"""运行多因子组合回测并输出排名。"""
import importlib.util
from easy_tdx.backtest.combo import CombinationRunner
from easy_tdx.backtest.strategy import Strategy
# 加载所有策略类
strategy_classes: list[type[Strategy]] = []
for sf in strategy_files:
spec = importlib.util.spec_from_file_location("strategy_module", sf)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for attr_name in dir(module):
obj = getattr(module, attr_name)
try:
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
strategy_classes.append(obj)
break
except TypeError:
pass
if len(strategy_classes) < 2:
click.echo("[!] 策略数量不足 2 个,跳过组合回测")
return
from math import comb
for size in combo_sizes:
total = comb(len(strategy_classes), size)
click.echo("\n" + "=" * 80)
click.echo(f"[*] {size}因子组合回测 (共{total}组, 模式={combo_mode})")
click.echo("=" * 80)
runner = CombinationRunner(
strategy_classes=strategy_classes,
df=df,
cash=cash,
commission=commission,
)
results = runner.screen(combo_sizes=(size,), mode=combo_mode.upper())
if not results:
click.echo(" 无有效交易组合(所有组合均为零交易)")
continue
# 表头
click.echo(
f"{'排名':>4} {'因子组合':<50} {'总收益率':>10} {'年化收益':>10} "
f"{'最大回撤':>10} {'夏普':>8} {'胜率':>8} {'交易':>6}"
)
click.echo("-" * 120)
for i, r in enumerate(results[:20], 1): # 只显示 top 20
medal = " *1*" if i == 1 else " *2*" if i == 2 else " *3*" if i == 3 else " "
perf = r.result.performance
click.echo(
f"{medal}{i:>2} {r.name:<50} "
f"{perf.get('total_return', 0):>9.2%} "
f"{perf.get('annual_return', 0):>9.2%} "
f"{perf.get('max_drawdown', 0):>9.2%} "
f"{perf.get('sharpe', 0):>8.2f} "
f"{perf.get('win_rate', 0):>7.1%} "
f"{perf.get('total_trades', 0):>6}"
)
if len(results) > 20:
click.echo(f" ... 共 {len(results)} 个有效组合,仅显示前 20")
# 最佳组合详细报告
best = results[0]
bp = best.result.performance
click.echo(f"\n[BEST {size}因子] {best.name}")
click.echo(f" 总收益率: {bp.get('total_return', 0):.2%}")
click.echo(f" 年化收益: {bp.get('annual_return', 0):.2%}")
click.echo(f" 最大回撤: {bp.get('max_drawdown', 0):.2%}")
click.echo(f" 夏普比率: {bp.get('sharpe', 0):.2f}")
click.echo(f" 胜率: {bp.get('win_rate', 0):.1%}")
def _show_best_chart(
df: Any,
result: Any,
@@ -95,8 +186,7 @@ def _show_best_chart(
eq_dates = equity["datetime"]
eq_values = equity["total"].values / initial_cash
ax2 = ax1.twinx()
ax2.plot(eq_dates, eq_values, color="crimson", linewidth=1.5,
label=f"策略: {strategy_name}")
ax2.plot(eq_dates, eq_values, color="crimson", linewidth=1.5, label=f"策略: {strategy_name}")
ax2.set_ylabel("资金曲线 (归一化)", color="crimson", fontsize=11)
ax2.tick_params(axis="y", labelcolor="crimson")
@@ -109,13 +199,23 @@ def _show_best_chart(
ax2.scatter(
buy_trades["datetime"].values,
_map_trade_values(buy_trades, equity, initial_cash),
marker="^", color="green", s=30, alpha=0.7, zorder=5, label="买入",
marker="^",
color="green",
s=30,
alpha=0.7,
zorder=5,
label="买入",
)
if not sell_trades.empty:
ax2.scatter(
sell_trades["datetime"].values,
_map_trade_values(sell_trades, equity, initial_cash),
marker="v", color="orange", s=30, alpha=0.7, zorder=5, label="卖出",
marker="v",
color="orange",
s=30,
alpha=0.7,
zorder=5,
label="卖出",
)
# 标题:股票代码 + 名称 + 策略绩效
@@ -150,6 +250,20 @@ def _show_best_chart(
@click.option("--commission", default=0.0003, type=float, help="佣金率")
@click.option("--adjust", default="QFQ", help="复权: NONE/QFQ/HFQ")
@click.option("--period", default="DAILY", help="K线周期")
@click.option(
"--combo",
"combo_sizes",
multiple=True,
type=int,
help="多因子组合回测(可多次指定,如 --combo 2 --combo 3",
)
@click.option(
"--combo-mode",
"combo_mode",
default="MAJORITY",
type=click.Choice(["AND", "OR", "MAJORITY"], case_sensitive=False),
help="多因子信号合并模式(默认 MAJORITY)",
)
@click.option("--show", "show_chart", is_flag=True, help="显示最佳策略资金曲线 vs 股价对比图")
def run_all(
market: str,
@@ -159,6 +273,8 @@ def run_all(
commission: float,
adjust: str,
period: str,
combo_sizes: tuple[int, ...],
combo_mode: str,
show_chart: bool,
) -> None:
"""批量运行 strategies/ 目录下所有策略并比较结果。"""
@@ -253,27 +369,31 @@ def run_all(
perf = result.performance
click.echo(f" 完成 ({elapsed:.1f}s)")
results.append({
"strategy": strategy_name,
"total_return": perf.get("total_return", 0),
"annual_return": perf.get("annual_return", 0),
"max_drawdown": perf.get("max_drawdown", 0),
"sharpe": perf.get("sharpe", 0),
"sortino": perf.get("sortino", 0),
"calmar": perf.get("calmar", 0),
"win_rate": perf.get("win_rate", 0),
"total_trades": perf.get("total_trades", 0),
"profit_factor": perf.get("profit_factor", 0),
"volatility": perf.get("volatility", 0),
})
results.append(
{
"strategy": strategy_name,
"total_return": perf.get("total_return", 0),
"annual_return": perf.get("annual_return", 0),
"max_drawdown": perf.get("max_drawdown", 0),
"sharpe": perf.get("sharpe", 0),
"sortino": perf.get("sortino", 0),
"calmar": perf.get("calmar", 0),
"win_rate": perf.get("win_rate", 0),
"total_trades": perf.get("total_trades", 0),
"profit_factor": perf.get("profit_factor", 0),
"volatility": perf.get("volatility", 0),
}
)
backtest_results[strategy_name] = result
except Exception as e:
elapsed = time.perf_counter() - t0
click.echo(f" 错误 ({elapsed:.1f}s): {e}")
results.append({
"strategy": strategy_name,
"error": str(e),
})
results.append(
{
"strategy": strategy_name,
"error": str(e),
}
)
# 4. 输出排名
click.echo("\n" + "=" * 80)
@@ -400,7 +520,18 @@ def run_all(
for r in errored:
click.echo(f" {r['strategy']}: {r['error']}")
# 5. 展示最佳策略曲线图
# 5. 多因子组合回测
if combo_sizes:
_run_combo_screen(
strategy_files=strategy_files,
df=df,
cash=cash,
commission=commission,
combo_sizes=combo_sizes,
combo_mode=combo_mode,
)
# 6. 展示最佳策略曲线图
if show_chart and valid:
best_name = valid[0]["strategy"]
if best_name in backtest_results: