Files
easy_tdx_max/src/easy_tdx/backtest/cli.py
T
GitHub a67e5588d0 fix(types): v1.20.2 修复 v1.20.1 引入的 CI mypy 失败
v1.20.1 把 BacktestResult.performance 扩大为 dict[str, float | str]
(为塞 diagnostic_warning 字符串),破坏 6 处下游消费方类型契约,
CI mypy job 转红。重构为更干净的设计:

- 诊断信息走独立的 BacktestResult.diagnostic 字段(performance.py
  PerformanceAnalyzer.diagnostic 属性 → engine 透出 → to_dict 含该字段
  → CLI 显示),performance 字典回归 dict[str, float](保留别名键)。
- optimizer.py 补 npt.NDArray 标注,消除 3 个既有 ndarray type-arg 错误。

测试:932 passed;mypy backtest/+portfolio/ 零错误;ruff 全绿。
2026-07-09 20:34:23 +08:00

437 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回测 CLI 命令。"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
import click
@click.command()
@click.argument("market")
@click.argument("code")
@click.option("--strategy", "strategy_str", default=None, help="DSL 策略表达式 (P1)")
@click.option("--strategy-file", "strategy_file", default=None, help="Python 策略文件路径")
@click.option(
"--combo-strategies",
"combo_strategies",
default=None,
help="多因子组合:逗号分隔的策略文件路径(如 strats/a.py,strats/b.py,strats/c.py",
)
@click.option(
"--combo-mode",
"combo_mode",
default="MAJORITY",
type=click.Choice(["AND", "OR", "MAJORITY"], case_sensitive=False),
help="多因子信号合并模式(默认 MAJORITY",
)
@click.option("--cash", default=100000.0, type=float, help="初始资金")
@click.option("--commission", default=0.0003, type=float, help="佣金率")
@click.option(
"--execution",
default="next_open",
type=click.Choice(["next_open", "next_close"]),
help="成交价规则",
)
@click.option("--period", default="DAILY", help="K线周期")
@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ")
@click.option("--count", default=500, type=int, help="K线数量")
@click.option("--indicators", default=None, help="预计算指标(逗号分隔)")
@click.option(
"--chanlun-level",
"chanlun_level",
default=None,
help="自动计算缠论分析并注入策略(如 DAILY/30MIN",
)
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def backtest(
market: str,
code: str,
strategy_str: str | None,
strategy_file: str | None,
combo_strategies: str | None,
combo_mode: str,
cash: float,
commission: float,
execution: str,
period: str,
adjust: str,
count: int,
indicators: str | None,
chanlun_level: str | None,
use_table: bool,
output_fmt: str,
) -> None:
"""回测引擎:执行策略并返回绩效报告。
示例:
easy-tdx backtest SZ 000001 --strategy-file my_strategy.py
easy-tdx backtest SH 600519 --strategy-file ma_cross.py --table
easy-tdx backtest SZ 000001 --strategy-file my_strategy.py --indicators MACD,KDJ
easy-tdx backtest SZ 000001 --strategy-file chanlun_strategy.py --chanlun-level DAILY
easy-tdx backtest SZ 000001 \
--combo-strategies strategies/macd_cross.py,strategies/rsi_reversal.py \
--combo-mode MAJORITY --table
"""
from ..backtest.engine import BacktestEngine
from ..cli.conn import get_mac_client
from ..cli.parsers import parse_adjust, parse_market, parse_period
from ..indicator import compute_indicators
# 1. 加载策略(单策略 or 多因子组合)
is_combo = combo_strategies is not None
if is_combo:
assert combo_strategies is not None # narrowed by is_combo
combo_classes = _load_combo_strategies(combo_strategies)
else:
strategy_cls = _load_strategy(strategy_str, strategy_file)
if strategy_cls is None:
click.echo("错误: 必须指定 --strategy-file / --combo-strategies / --strategy", err=True)
raise SystemExit(1)
# 2. 获取数据
mkt = parse_market(market)
with get_mac_client() as client:
df = client.get_stock_kline(
mkt,
code,
period=parse_period(period),
start=0,
count=count,
adjust=parse_adjust(adjust),
)
# 3. 预计算指标
if indicators:
indicator_list = [ind.strip() for ind in indicators.split(",")]
df = compute_indicators(df, indicator_list)
# 4. 创建引擎并运行
if is_combo:
from ..backtest.combo import CombinationRunner
runner = CombinationRunner(
strategy_classes=combo_classes,
df=df,
cash=cash,
commission=commission,
execution=execution,
)
result = runner.run_combination(
indices=list(range(len(combo_classes))),
mode=combo_mode.upper(),
)
else:
assert strategy_cls is not None # guarded above by SystemExit
engine = BacktestEngine(
strategy=strategy_cls,
cash=cash,
commission=commission,
execution=execution,
chanlun_level=chanlun_level,
)
result = engine.run(df)
# 5. 输出结果
fmt = "table" if use_table else output_fmt
if fmt == "json":
click.echo(result.to_json())
elif fmt == "table":
_print_table(result)
else:
click.echo(result.to_json())
def _load_strategy(strategy_str: str | None, strategy_file: str | None) -> type | None:
"""加载策略类。
优先从 Python 文件加载,其次从 DSL 表达式加载(未实现)。
Args:
strategy_str: DSL 策略表达式
strategy_file: Python 策略文件路径
Returns:
Strategy 子类
"""
if strategy_file:
return _load_strategy_from_file(strategy_file)
if strategy_str:
click.echo("错误: DSL 策略表达式尚未实现", err=True)
return None
return None
def _load_strategy_from_file(path: str) -> type:
"""从 Python 文件加载 Strategy 子类。
Args:
path: Python 文件路径
Returns:
Strategy 子类
"""
from ..backtest.strategy import Strategy
file_path = Path(path)
if not file_path.exists():
click.echo(f"错误: 文件不存在: {path}", err=True)
raise SystemExit(1)
spec = importlib.util.spec_from_file_location("strategy_module", file_path)
if spec is None or spec.loader is None:
click.echo(f"错误: 无法加载文件: {path}", err=True)
raise SystemExit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 查找 Strategy 子类
strategy_classes = []
for name in dir(module):
obj = getattr(module, name)
try:
if isinstance(obj, type) and issubclass(obj, Strategy) and obj is not Strategy:
strategy_classes.append(obj)
except TypeError:
pass
if not strategy_classes:
click.echo(f"错误: 文件中未找到 Strategy 子类: {path}", err=True)
raise SystemExit(1)
if len(strategy_classes) > 1:
click.echo(f"警告: 文件包含多个 Strategy 子类,使用第一个: {path}", err=True)
return strategy_classes[0]
def _load_combo_strategies(combo_strategies: str) -> list[type]:
"""从逗号分隔的路径列表加载多个策略类。
Args:
combo_strategies: 逗号分隔的策略文件路径
Returns:
Strategy 子类列表
"""
paths = [p.strip() for p in combo_strategies.split(",") if p.strip()]
if len(paths) < 2:
click.echo("错误: --combo-strategies 至少需要 2 个策略文件", err=True)
raise SystemExit(1)
classes: list[type] = []
for p in paths:
cls = _load_strategy_from_file(p)
classes.append(cls)
names = [c.__name__ for c in classes]
click.echo(f"[*] 多因子组合 ({len(classes)} 因子): {' + '.join(names)}")
return classes
def _print_table(result: Any) -> None:
"""以表格形式输出回测结果。"""
perf = result.performance
config = result.config
click.echo("=== 回测绩效概要 ===")
click.echo(f"总收益率: {perf.get('total_return', 0):.2%}")
click.echo(f"年化收益: {perf.get('annual_return', 0):.2%}")
click.echo(f"最大回撤: {perf.get('max_drawdown', 0):.2%}")
click.echo(f"夏普比率: {perf.get('sharpe', 0):.2f}")
click.echo(f"胜率: {perf.get('win_rate', 0):.2%}")
click.echo(f"交易次数: {perf.get('total_trades', 0)}")
click.echo()
if getattr(result, "diagnostic", None):
click.echo(f"⚠ 诊断: {result.diagnostic}")
click.echo()
click.echo("=== 配置参数 ===")
click.echo(f"初始资金: {config.get('cash', 0):.2f}")
click.echo(f"佣金率: {config.get('commission', 0):.4f}")
click.echo(f"成交规则: {config.get('execution', 'next_open')}")
if config.get("chanlun_level"):
click.echo(f"缠论级别: {config.get('chanlun_level')}")
click.echo()
if config.get("future_leak_warning"):
click.echo("!!! 警告: 策略可能存在未来函数(使用未来数据)")
click.echo()
if not result.trades.empty:
click.echo("=== 最近交易记录 ===")
recent_trades = result.trades.tail(10)
for idx, trade in recent_trades.iterrows():
direction = "买入" if trade["direction"] == "BUY" else "卖出"
status = "拒绝" if trade["rejected"] else "成交"
click.echo(
f" [{trade['datetime']}] {direction} "
f"数量={trade['size']:.0f} 价格={trade['price']:.2f} "
f"盈亏={trade['pnl']:.2f} [{status}]"
)
else:
click.echo("无交易记录")
# ── portfolio 多标的组合回测命令 ─────────────────────────────────────────────
@click.command()
@click.option(
"--stocks",
required=True,
help="股票列表:逗号分隔的 市场:代码(如 SZ:000001,SH:600519,SH:600036",
)
@click.option("--strategy-file", "strategy_file", required=True, help="Python 策略文件路径")
@click.option("--cash", default=200_000.0, type=float, help="总资金(默认 20 万)")
@click.option("--commission", default=0.0003, type=float, help="佣金率")
@click.option(
"--execution",
default="next_open",
type=click.Choice(["next_open", "next_close"]),
help="成交价规则",
)
@click.option("--period", default="DAILY", help="K线周期")
@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ")
@click.option("--count", default=500, type=int, help="K线数量")
@click.option(
"--allocation",
default="equal",
type=click.Choice(["equal"], case_sensitive=False),
help="资金分配方式(默认 equal 均等分配)",
)
@click.option(
"--chanlun-level",
"chanlun_level",
default=None,
help="自动计算缠论分析并注入策略(如 DAILY/30MIN",
)
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def portfolio(
stocks: str,
strategy_file: str,
cash: float,
commission: float,
execution: str,
period: str,
adjust: str,
count: int,
allocation: str,
chanlun_level: str | None,
use_table: bool,
output_fmt: str,
) -> None:
"""多标的组合回测:共享资金池,独立产生信号,统一管理仓位。
对多只股票同时回测,按均等比例分配资金,汇总组合整体绩效。
示例:
easy-tdx portfolio --stocks SZ:000001,SH:600519 --strategy-file ma_cross.py
easy-tdx portfolio --stocks SZ:000001,SH:600519,SH:600036 \\
--strategy-file my_strategy.py --cash 500000 --table
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
--strategy-file chanlun_strat.py --chanlun-level DAILY
"""
import json
from ..cli.conn import get_mac_client
from ..cli.parsers import parse_adjust, parse_market, parse_period
from .portfolio_engine import PortfolioBacktestEngine, StockData
# 1. 加载策略
strategy_cls = _load_strategy_from_file(strategy_file)
strategy_name = strategy_cls.__name__
# 2. 解析股票列表
stock_list = []
for item in stocks.split(","):
item = item.strip()
if ":" not in item:
click.echo(f"错误: 股票格式应为 市场:代码,如 SZ:000001,收到: {item}", err=True)
raise SystemExit(1)
mkt_str, code = item.split(":", 1)
stock_list.append((mkt_str.strip().upper(), code.strip()))
if not stock_list:
click.echo("错误: 未指定股票", err=True)
raise SystemExit(1)
click.echo(f"策略: {strategy_name} | 标的: {len(stock_list)} 只 | 资金: {cash:,.0f}", err=True)
# 3. 获取数据
stock_data_list: list[StockData] = []
with get_mac_client() as client:
for mkt_str, code in stock_list:
mkt = parse_market(mkt_str)
df = client.get_stock_kline(
mkt,
code,
period=parse_period(period),
start=0,
count=count,
adjust=parse_adjust(adjust),
)
stock_data_list.append(StockData(code=code, market=mkt_str, df=df))
# 4. 创建引擎并运行
engine = PortfolioBacktestEngine(
strategy=strategy_cls,
stocks=stock_data_list,
total_cash=cash,
allocation=allocation,
commission=commission,
execution=execution,
chanlun_level=chanlun_level,
)
result = engine.run()
# 5. 输出结果
fmt = "table" if use_table else output_fmt
if fmt == "table":
_print_portfolio_table(result)
else:
click.echo(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
def _print_portfolio_table(result: Any) -> None:
"""以表格形式输出组合回测结果。"""
perf = result.total_performance
click.echo("=== 组合回测绩效概要 ===")
click.echo(f"标的数量: {perf.get('total_stocks', 0)}")
click.echo(f"总资金: {perf.get('total_cash', 0):,.0f}")
click.echo(f"组合收益率: {perf.get('total_return', 0):.2%}")
click.echo(f"组合年化: {perf.get('annual_return', 0):.2%}")
click.echo()
click.echo("── 各标的详情 ──")
for key, stock_result in result.individual_results.items():
sp = stock_result.performance
alloc = result.equity_allocation.get(key, 0)
click.echo(
f" {key}: 收益={sp.get('total_return', 0):.2%} "
f"夏普={sp.get('sharpe', 0):.2f} "
f"回撤={sp.get('max_drawdown', 0):.2%} "
f"分配={alloc:.0%} "
f"交易={sp.get('total_trades', 0)}"
)
click.echo()