mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
879 lines
30 KiB
Python
879 lines
30 KiB
Python
"""回测 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(
|
||
"--auto-fees",
|
||
"auto_fees",
|
||
is_flag=True,
|
||
help="按标的品种自动解析费率(ETF/可转债免印花税等;显式 --commission 优先)",
|
||
)
|
||
@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")
|
||
@click.option(
|
||
"--wf",
|
||
"walk_forward",
|
||
is_flag=True,
|
||
help="附加 Walk-Forward 样本外验证(默认 7 窗,每窗独立开仓)",
|
||
)
|
||
@click.option("--wf-windows", "wf_windows", default=7, type=int, help="Walk-Forward 窗口数")
|
||
@click.option(
|
||
"--evaluate",
|
||
"full_evaluate",
|
||
is_flag=True,
|
||
help="一条龙评估:回测+WF+适配性+综合评分+S-D评级+买入持有基准对比(覆盖常规输出)",
|
||
)
|
||
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,
|
||
auto_fees: bool,
|
||
execution: str,
|
||
period: str,
|
||
adjust: str,
|
||
count: int,
|
||
indicators: str | None,
|
||
chanlun_level: str | None,
|
||
use_table: bool,
|
||
output_fmt: str,
|
||
walk_forward: bool,
|
||
wf_windows: int,
|
||
full_evaluate: bool,
|
||
) -> 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 300308 --strategy-file ma_cross.py --wf --wf-windows 7
|
||
|
||
easy-tdx backtest SZ 300308 --strategy-file ma_cross.py --evaluate
|
||
|
||
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 and walk_forward:
|
||
click.echo(
|
||
"警告: --wf(Walk-Forward 样本外验证)暂不支持 --combo-strategies 组合模式,已忽略",
|
||
err=True,
|
||
)
|
||
if is_combo and full_evaluate:
|
||
click.echo(
|
||
"警告: --evaluate(一条龙评估)暂不支持 --combo-strategies 组合模式,已忽略", err=True
|
||
)
|
||
|
||
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
|
||
|
||
# 一条龙评估:覆盖常规输出(含回测本身,无需重复跑)
|
||
if full_evaluate:
|
||
import json as _json
|
||
|
||
from ..backtest.benchmark import evaluate_strategy
|
||
|
||
report = evaluate_strategy(
|
||
strategy=strategy_cls,
|
||
df=df,
|
||
cash=cash,
|
||
commission=commission,
|
||
execution=execution,
|
||
symbol=f"{market}:{code}",
|
||
auto_fees=auto_fees,
|
||
n_windows=wf_windows,
|
||
)
|
||
click.echo(_json.dumps(report, ensure_ascii=False, default=str))
|
||
return
|
||
|
||
engine = BacktestEngine(
|
||
strategy=strategy_cls,
|
||
cash=cash,
|
||
commission=commission,
|
||
execution=execution,
|
||
chanlun_level=chanlun_level,
|
||
symbol=f"{market}:{code}",
|
||
auto_fees=auto_fees,
|
||
)
|
||
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())
|
||
|
||
# 6. 附加 Walk-Forward 样本外验证(--wf)
|
||
if walk_forward and not is_combo:
|
||
import json as _json
|
||
|
||
from ..backtest.walkforward import WalkForwardEngine
|
||
|
||
assert strategy_cls is not None
|
||
wf = WalkForwardEngine(
|
||
strategy=strategy_cls,
|
||
n_windows=wf_windows,
|
||
cash=cash,
|
||
commission=commission,
|
||
execution=execution,
|
||
symbol=f"{market}:{code}",
|
||
auto_fees=auto_fees,
|
||
)
|
||
wf_report = {"walkforward": wf.run(df).to_dict()}
|
||
click.echo(_json.dumps(wf_report, ensure_ascii=False, default=str))
|
||
|
||
|
||
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)}")
|
||
# 深度风险指标(v1.28 新增;老结果缺键时跳过,不输出 0 假值)
|
||
if perf.get("ulcer_index") is not None:
|
||
click.echo(f"Ulcer 指数: {perf.get('ulcer_index', 0):.4f}")
|
||
click.echo(f"日 VaR(95%): {perf.get('var_95', 0):.2%}")
|
||
click.echo(f"日 CVaR(95%): {perf.get('cvar_95', 0):.2%}")
|
||
click.echo(f"SQN 系统质量: {perf.get('sqn', 0):.2f}")
|
||
click.echo(
|
||
f"最大连胜/连亏: {perf.get('max_consecutive_wins', 0)} / "
|
||
f"{perf.get('max_consecutive_losses', 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(
|
||
"--auto-fees",
|
||
"auto_fees",
|
||
is_flag=True,
|
||
help="按标的品种自动解析费率(ETF/可转债免印花税等;显式 --commission 优先)",
|
||
)
|
||
@click.option("--wf", "walk_forward", is_flag=True, help="附加组合级 Walk-Forward 样本外验证")
|
||
@click.option("--wf-windows", "wf_windows", default=7, type=int, help="Walk-Forward 窗口数")
|
||
@click.option(
|
||
"--evaluate",
|
||
"full_evaluate",
|
||
is_flag=True,
|
||
help="组合级一条龙评估:组合回测+组合WF+适配性+综合评分+组合评级+等权买入持有基准对比(覆盖常规输出)",
|
||
)
|
||
@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,
|
||
auto_fees: bool,
|
||
walk_forward: bool,
|
||
wf_windows: int,
|
||
full_evaluate: bool,
|
||
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
|
||
|
||
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
|
||
--strategy-file ma_cross.py --wf --wf-windows 7
|
||
|
||
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
|
||
--strategy-file ma_cross.py --evaluate
|
||
"""
|
||
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. 组合级一条龙评估:覆盖常规输出(含组合回测本身,无需重复跑)
|
||
if full_evaluate:
|
||
from .benchmark import evaluate_portfolio
|
||
|
||
report = evaluate_portfolio(
|
||
strategy=strategy_cls,
|
||
stocks=stock_data_list,
|
||
total_cash=cash,
|
||
commission=commission,
|
||
execution=execution,
|
||
chanlun_level=chanlun_level,
|
||
auto_fees=auto_fees,
|
||
n_windows=wf_windows,
|
||
)
|
||
click.echo(json.dumps(report, ensure_ascii=False, default=str))
|
||
return
|
||
|
||
# 5. 组合级 Walk-Forward 样本外验证(--wf)
|
||
if walk_forward:
|
||
from .walkforward import PortfolioWalkForwardEngine
|
||
|
||
wf = PortfolioWalkForwardEngine(
|
||
strategy=strategy_cls,
|
||
stocks=stock_data_list,
|
||
n_windows=wf_windows,
|
||
total_cash=cash,
|
||
commission=commission,
|
||
execution=execution,
|
||
chanlun_level=chanlun_level,
|
||
auto_fees=auto_fees,
|
||
)
|
||
click.echo(json.dumps({"walkforward": wf.run().to_dict()}, ensure_ascii=False, default=str))
|
||
return
|
||
|
||
# 6. 常规组合回测
|
||
engine = PortfolioBacktestEngine(
|
||
strategy=strategy_cls,
|
||
stocks=stock_data_list,
|
||
total_cash=cash,
|
||
allocation=allocation,
|
||
commission=commission,
|
||
execution=execution,
|
||
chanlun_level=chanlun_level,
|
||
auto_fees=auto_fees,
|
||
)
|
||
result = engine.run()
|
||
|
||
# 7. 输出结果
|
||
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()
|
||
|
||
|
||
# ── strategies 内置策略列表命令 ──────────────────────────────────────────────
|
||
|
||
|
||
@click.command("strategies")
|
||
@click.option("--output", "output_fmt", type=click.Choice(["json", "table"]), default="table")
|
||
def strategies(output_fmt: str) -> None:
|
||
"""列出内置策略注册表:名称、参数定义与预设寻优网格。
|
||
|
||
策略名可直接用于 optimize --strategy / Web API /backtest/run 的 strategy 字段。
|
||
|
||
示例:
|
||
|
||
easy-tdx strategies
|
||
|
||
easy-tdx strategies --output json
|
||
"""
|
||
import json
|
||
|
||
from .strategies import get_registry
|
||
|
||
entries = get_registry().all()
|
||
|
||
if output_fmt == "json":
|
||
click.echo(json.dumps([e.to_schema() for e in entries], ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
click.echo(f"=== 内置策略({len(entries)} 个)===\n")
|
||
for entry in entries:
|
||
schema = entry.to_schema()
|
||
params_desc = ", ".join(f"{p['name']}={p['default']}" for p in schema["params"])
|
||
grid = schema.get("preset_grid") or {}
|
||
points = 1
|
||
for vals in grid.values():
|
||
points *= len(vals)
|
||
grid_desc = " × ".join(f"{k}:{len(v)}" for k, v in grid.items()) if grid else "无"
|
||
click.echo(f" {entry.name} — {entry.label}")
|
||
click.echo(f" 参数: {params_desc or '无'}")
|
||
click.echo(f" 预设网格: {grid_desc}({points} 点)")
|
||
if entry.description:
|
||
click.echo(f" 说明: {entry.description}")
|
||
click.echo()
|
||
|
||
|
||
# ── optimize 参数网格寻优命令 ────────────────────────────────────────────────
|
||
|
||
|
||
def _coerce_param_value(raw: str) -> Any:
|
||
"""把字符串参数值尽量转为 int/float,失败保留字符串。"""
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
return float(raw)
|
||
except ValueError:
|
||
return raw
|
||
|
||
|
||
def _parse_param_grid(pairs: tuple[str, ...]) -> dict[str, list[Any]]:
|
||
"""解析 --param fast=5,10,15 形式的自定义网格。"""
|
||
grid: dict[str, list[Any]] = {}
|
||
for item in pairs:
|
||
name, sep, raw = item.partition("=")
|
||
values = [_coerce_param_value(v.strip()) for v in raw.split(",") if v.strip()]
|
||
if not sep or not name.strip() or not values:
|
||
click.echo(f"错误: --param 格式应为 参数名=值1,值2,收到: {item}", err=True)
|
||
raise SystemExit(1)
|
||
grid[name.strip()] = values
|
||
return grid
|
||
|
||
|
||
@click.command()
|
||
@click.argument("market")
|
||
@click.argument("code")
|
||
@click.option(
|
||
"--strategy",
|
||
"strategy_name",
|
||
default=None,
|
||
help="注册表策略名(见 strategies 命令;网格取 --param 或该策略预设)",
|
||
)
|
||
@click.option(
|
||
"--all",
|
||
"optimize_all",
|
||
is_flag=True,
|
||
help="一键寻优所有内置策略:逐策略按预设网格寻优,输出全局排名",
|
||
)
|
||
@click.option(
|
||
"--param",
|
||
"param_pairs",
|
||
multiple=True,
|
||
help="自定义参数网格,如 --param fast=5,10,15 --param slow=20,60(覆盖预设)",
|
||
)
|
||
@click.option("--cash", default=1_000_000.0, type=float, help="初始资金")
|
||
@click.option("--commission", default=0.0003, type=float, help="佣金率")
|
||
@click.option("--slippage", default=0.0, type=float, help="滑点")
|
||
@click.option(
|
||
"--execution",
|
||
default="next_open",
|
||
type=click.Choice(["next_open", "next_close"]),
|
||
help="成交价规则",
|
||
)
|
||
@click.option(
|
||
"--workers",
|
||
default=1,
|
||
type=int,
|
||
help="并行进程数:1=串行+指标缓存(默认);2+=进程级并行",
|
||
)
|
||
@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("--top", default=15, type=int, help="表格输出显示前 N 行")
|
||
@click.option("--table", "use_table", is_flag=True, help="表格输出")
|
||
@click.option("--output", "output_fmt", type=click.Choice(["json", "table"]), default="json")
|
||
def optimize(
|
||
market: str,
|
||
code: str,
|
||
strategy_name: str | None,
|
||
optimize_all: bool,
|
||
param_pairs: tuple[str, ...],
|
||
cash: float,
|
||
commission: float,
|
||
slippage: float,
|
||
execution: str,
|
||
workers: int,
|
||
period: str,
|
||
adjust: str,
|
||
count: int,
|
||
top: int,
|
||
use_table: bool,
|
||
output_fmt: str,
|
||
) -> None:
|
||
"""参数网格寻优:单策略网格搜索,或 --all 一键寻优所有内置策略。
|
||
|
||
示例:
|
||
|
||
easy-tdx optimize SZ 000001 --strategy ma_cross
|
||
|
||
easy-tdx optimize SZ 000001 --strategy ma_cross \\
|
||
--param fast=5,10,15 --param slow=20,60
|
||
|
||
easy-tdx optimize SZ 000001 --all --workers 4 --table
|
||
"""
|
||
from ..cli.conn import get_mac_client
|
||
from ..cli.parsers import parse_adjust, parse_market, parse_period
|
||
from .strategies import get_registry
|
||
from .strategies.presets import get_preset
|
||
|
||
registry = get_registry()
|
||
|
||
# 1. 校验模式与策略(联网取数之前,快速失败)
|
||
if optimize_all == (strategy_name is not None):
|
||
click.echo("错误: --all 与 --strategy 二选一", err=True)
|
||
raise SystemExit(1)
|
||
|
||
custom_grid = _parse_param_grid(param_pairs) if param_pairs else None
|
||
if optimize_all and custom_grid is not None:
|
||
# --all 逐策略使用各自预设网格,--param 无处安放:显式告警而非静默忽略(审查修复)
|
||
click.echo(
|
||
"警告: --param 在 --all 模式下被忽略(一键寻优逐策略使用各自预设网格;"
|
||
"如需自定义网格请指定 --strategy 单策略寻优)",
|
||
err=True,
|
||
)
|
||
if not optimize_all:
|
||
assert strategy_name is not None
|
||
try:
|
||
entry = registry.get(strategy_name)
|
||
except KeyError as exc:
|
||
click.echo(f"错误: {exc}", err=True)
|
||
raise SystemExit(1) from None
|
||
if custom_grid is not None:
|
||
declared = {p.name for p in entry.params}
|
||
unknown = set(custom_grid) - declared
|
||
if unknown:
|
||
click.echo(
|
||
f"错误: 未知参数 {sorted(unknown)},"
|
||
f"'{strategy_name}' 可用参数: {sorted(declared)}",
|
||
err=True,
|
||
)
|
||
raise SystemExit(1)
|
||
else:
|
||
custom_grid = get_preset(strategy_name)
|
||
if not custom_grid:
|
||
first_param = entry.params[0].name if entry.params else "参数名"
|
||
click.echo(
|
||
f"错误: 策略 '{strategy_name}' 未登记预设网格,请用 --param 指定(如 "
|
||
f"--param {first_param}=5,10,20)",
|
||
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. 寻优
|
||
fmt = "table" if use_table else output_fmt
|
||
import json
|
||
|
||
if optimize_all:
|
||
from .optimizer import optimize_all_strategies
|
||
|
||
report = optimize_all_strategies(
|
||
df,
|
||
cash=cash,
|
||
commission=commission,
|
||
slippage=slippage,
|
||
execution=execution,
|
||
workers=workers,
|
||
)
|
||
if fmt == "table":
|
||
_print_optimize_all_table(report, top)
|
||
else:
|
||
click.echo(json.dumps(report, ensure_ascii=False, default=str))
|
||
return
|
||
|
||
from .optimizer import ParamGridOptimizer
|
||
|
||
assert strategy_name is not None and custom_grid is not None
|
||
optimizer = ParamGridOptimizer(
|
||
strategy_name=strategy_name,
|
||
param_grid=custom_grid,
|
||
df=df,
|
||
cash=cash,
|
||
commission=commission,
|
||
slippage=slippage,
|
||
execution=execution,
|
||
workers=workers,
|
||
)
|
||
result = optimizer.run()
|
||
if fmt == "table":
|
||
_print_optimize_table(result.to_dict(), top)
|
||
else:
|
||
click.echo(json.dumps(result.to_dict(), ensure_ascii=False, default=str))
|
||
|
||
|
||
def _fmt_grid_point(params: dict[str, Any]) -> str:
|
||
"""把参数字典格式化为 fast=10, slow=20 形式。"""
|
||
return ", ".join(f"{k}={v}" for k, v in params.items())
|
||
|
||
|
||
def _print_optimize_table(report: dict[str, Any], top: int) -> None:
|
||
"""以表格形式输出单策略寻优结果。"""
|
||
results = report.get("results") or []
|
||
best = report.get("best")
|
||
click.echo(f"=== 参数寻优: {report.get('strategy')}({len(results)} 个有效网格点)===\n")
|
||
click.echo(
|
||
f"{'排名':<4} {'参数':<28} {'总收益率':>8} {'夏普':>6} {'最大回撤':>8} "
|
||
f"{'交易':>4} {'胜率':>6}"
|
||
)
|
||
for i, r in enumerate(results[:top], 1):
|
||
click.echo(
|
||
f"{i:<4} {_fmt_grid_point(r['params']):<28} {r['total_return']:>8.2%} "
|
||
f"{r['sharpe']:>6.2f} {r['max_drawdown']:>8.2%} "
|
||
f"{r['total_trades']:>4} {r['win_rate']:>6.1%}"
|
||
)
|
||
if best:
|
||
click.echo(f"\n最佳参数: {_fmt_grid_point(best['params'])}")
|
||
if len(results) > top:
|
||
click.echo(f"(仅显示前 {top} 行,完整结果用 --output json)")
|
||
|
||
|
||
def _print_optimize_all_table(report: dict[str, Any], top: int) -> None:
|
||
"""以表格形式输出 --all 全策略寻优排名。"""
|
||
ranking = report.get("ranking") or []
|
||
click.echo(
|
||
f"=== 一键寻优所有策略({len(ranking)} 个策略,"
|
||
f"共 {report.get('total_grid_points', 0)} 网格点)===\n"
|
||
)
|
||
click.echo(
|
||
f"{'排名':<4} {'策略':<20} {'最佳参数':<28} {'总收益率':>8} {'夏普':>6} "
|
||
f"{'最大回撤':>8} {'交易':>4} {'胜率':>6}"
|
||
)
|
||
for i, r in enumerate(ranking[:top], 1):
|
||
label = f"{r['strategy']} {r.get('strategy_label', '')}"
|
||
click.echo(
|
||
f"{i:<4} {label:<20} {_fmt_grid_point(r['params']):<28} "
|
||
f"{r['total_return']:>8.2%} {r['sharpe']:>6.2f} "
|
||
f"{r['max_drawdown']:>8.2%} {r['total_trades']:>4} {r['win_rate']:>6.1%}"
|
||
)
|
||
if report.get("skipped"):
|
||
click.echo(f"\n跳过(未注册): {', '.join(report['skipped'])}")
|
||
if ranking:
|
||
click.echo(f"\n全局最优: {ranking[0]['strategy']} {_fmt_grid_point(ranking[0]['params'])}")
|
||
if len(ranking) > top:
|
||
click.echo(f"(仅显示前 {top} 行,完整结果用 --output json)")
|