release: v1.8.0 - backtest engine with batch strategy comparison

- Add backtest section to README with CLI usage and run_all_strategies.py demo
- Update all version numbers to 1.8.0 (pyproject.toml, __init__.py, cli/__init__.py, docs/conf.py)
- Fix turtle_breakout strategy: TAQ returns 3 values (UP, MID, DOWN)
- Add run_all_strategies.py batch comparison script
- Update README intro to highlight backtest feature
- Add backtest to CLI command table and architecture tree

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
GitHub
2026-06-09 20:35:38 +08:00
co-authored by Claude Opus 4.8
parent 70c69c8a66
commit b5b5d0dc5b
10 changed files with 401 additions and 44 deletions
+231
View File
@@ -0,0 +1,231 @@
"""批量回测脚本:依次运行 strategies/ 目录下所有策略并比较结果。
用法::
python run_all_strategies.py SZ 300308 --count 2000 --cash 1000000 --adjust QFQ
输出每个策略的绩效指标,并按总收益率排名。
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
# 确保 easy_tdx 可导入
sys.path.insert(0, str(Path(__file__).parent / "src"))
import click
@click.command()
@click.argument("market")
@click.argument("code")
@click.option("--count", default=2000, type=int, help="K线数量")
@click.option("--cash", default=1000000.0, type=float, help="初始资金")
@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线周期")
def run_all(
market: str,
code: str,
count: int,
cash: float,
commission: float,
adjust: str,
period: str,
) -> None:
"""批量运行 strategies/ 目录下所有策略并比较结果。"""
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.cli.parsers import parse_adjust, parse_market, parse_period
from easy_tdx.mac.client import MacClient
# 1. 发现策略文件
strategies_dir = Path(__file__).parent / "strategies"
strategy_files = sorted(strategies_dir.glob("*.py"))
if not strategy_files:
click.echo("未找到策略文件 (strategies/*.py)", err=True)
raise SystemExit(1)
click.echo(f"发现 {len(strategy_files)} 个策略文件")
click.echo(f"标的: {market} {code} | K线: {count} | 资金: {cash:,.0f} | 复权: {adjust}")
click.echo("=" * 80)
# 2. 获取数据(所有策略共享同一份数据)
mkt = parse_market(market)
click.echo("正在获取行情数据...")
client = MacClient.from_best_host()
client.connect()
try:
df = client.get_stock_kline(
mkt,
code,
period=parse_period(period),
start=0,
count=count,
adjust=parse_adjust(adjust),
)
finally:
client.close()
click.echo(f"获取到 {len(df)} 条K线数据")
click.echo("=" * 80)
# 3. 逐个运行策略
results: list[dict] = []
for sf in strategy_files:
strategy_name = sf.stem
click.echo(f"\n>> 运行策略: {strategy_name} ...", nl=False)
# 加载策略类
import importlib.util
spec = importlib.util.spec_from_file_location("strategy_module", sf)
if spec is None or spec.loader is None:
click.echo(" [加载失败]")
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 查找 Strategy 子类
strategy_cls = None
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_cls = obj
break
except TypeError:
pass
if strategy_cls is None:
click.echo(" [未找到 Strategy 子类]")
continue
# 运行回测
t0 = time.perf_counter()
try:
engine = BacktestEngine(
strategy=strategy_cls,
cash=cash,
commission=commission,
)
result = engine.run(df)
elapsed = time.perf_counter() - t0
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),
})
except Exception as e:
elapsed = time.perf_counter() - t0
click.echo(f" 错误 ({elapsed:.1f}s): {e}")
results.append({
"strategy": strategy_name,
"error": str(e),
})
# 4. 输出排名
click.echo("\n" + "=" * 80)
click.echo("[*] 策略绩效排名 (按总收益率降序)")
click.echo("=" * 80)
# 过滤掉有错误的策略
valid = [r for r in results if "error" not in r]
errored = [r for r in results if "error" in r]
if not valid:
click.echo("所有策略均运行失败!")
for r in errored:
click.echo(f" {r['strategy']}: {r['error']}")
raise SystemExit(1)
# 按总收益率排序
valid.sort(key=lambda x: x["total_return"], reverse=True)
# 表头
click.echo(
f"{'排名':>4} {'策略':<22} {'总收益率':>10} {'年化收益':>10} "
f"{'最大回撤':>10} {'夏普':>8} {'胜率':>8} {'交易次数':>8} {'盈亏比':>8}"
)
click.echo("-" * 100)
for i, r in enumerate(valid, 1):
medal = " *1*" if i == 1 else " *2*" if i == 2 else " *3*" if i == 3 else " "
click.echo(
f"{medal}{i:>2} {r['strategy']:<22} "
f"{r['total_return']:>9.2%} "
f"{r['annual_return']:>9.2%} "
f"{r['max_drawdown']:>9.2%} "
f"{r['sharpe']:>8.2f} "
f"{r['win_rate']:>7.1%} "
f"{r['total_trades']:>8} "
f"{r['profit_factor']:>8.2f}"
)
# 最佳策略详细报告
best = valid[0]
click.echo("\n" + "=" * 80)
click.echo(f"[BEST] 最佳策略: {best['strategy']}")
click.echo("=" * 80)
click.echo(f" 总收益率: {best['total_return']:.2%}")
click.echo(f" 年化收益: {best['annual_return']:.2%}")
click.echo(f" 最大回撤: {best['max_drawdown']:.2%}")
click.echo(f" 夏普比率: {best['sharpe']:.2f}")
click.echo(f" 索提诺: {best['sortino']:.2f}")
click.echo(f" 卡玛比率: {best['calmar']:.2f}")
click.echo(f" 胜率: {best['win_rate']:.1%}")
click.echo(f" 交易次数: {best['total_trades']}")
click.echo(f" 盈亏比: {best['profit_factor']:.2f}")
click.echo(f" 年化波动: {best['volatility']:.4f}")
# 综合评分(综合夏普、收益率、回撤)
click.echo("\n" + "=" * 80)
click.echo("[*] 综合评分排名 (Sharpe*0.4 + Ret/DD*0.3 + WinRate*0.3)")
click.echo("=" * 80)
scored = []
for r in valid:
# 避免除以零
ret_dd_ratio = r["annual_return"] / r["max_drawdown"] if r["max_drawdown"] > 1e-6 else 999.0
score = r["sharpe"] * 0.4 + ret_dd_ratio * 0.3 + r["win_rate"] * 100 * 0.3
scored.append((r, score))
scored.sort(key=lambda x: x[1], reverse=True)
click.echo(
f"{'排名':>4} {'策略':<22} {'综合评分':>10} {'夏普':>8} {'收益/回撤':>10} {'胜率':>8}"
)
click.echo("-" * 70)
for i, (r, score) in enumerate(scored, 1):
ret_dd_ratio = r["annual_return"] / r["max_drawdown"] if r["max_drawdown"] > 1e-6 else 999.0
medal = " *1*" if i == 1 else " *2*" if i == 2 else " *3*" if i == 3 else " "
click.echo(
f"{medal}{i:>2} {r['strategy']:<22} {score:>10.2f} "
f"{r['sharpe']:>8.2f} {ret_dd_ratio:>10.2f} {r['win_rate']:>7.1%}"
)
# 报告错误
if errored:
click.echo("\n[!] 以下策略运行失败:")
for r in errored:
click.echo(f" {r['strategy']}: {r['error']}")
if __name__ == "__main__":
run_all()