hamuna-quant-cli 0.1.0.dev93__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/PKG-INFO +3 -1
  2. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__init__.py +1 -3
  3. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__main__.py +49 -1
  4. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_data_adapter.py +13 -7
  5. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_runner.py +245 -37
  6. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_schema_adapter.py +117 -8
  7. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_broker.py +100 -40
  8. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_market.py +17 -4
  9. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/runner.py +277 -18
  10. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_downloader.py +31 -10
  11. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_resolver.py +66 -27
  12. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/backtest.py +2 -2
  13. hamuna_quant_cli-0.1.2/hamuna_quant_cli/runtime/discipline.py +878 -0
  14. hamuna_quant_cli-0.1.2/hamuna_quant_cli/scripts/server.json +4 -0
  15. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/PKG-INFO +3 -1
  16. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/requires.txt +2 -0
  17. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/pyproject.toml +3 -1
  18. hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/runtime/discipline.py +0 -359
  19. hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/scripts/server.json +0 -4
  20. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/MANIFEST.in +0 -0
  21. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/README.md +0 -0
  22. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/README.md +0 -0
  23. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_market_fallback.py +0 -0
  24. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_metrics_15.py +0 -0
  25. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  26. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/base_strategy.py +0 -0
  27. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  28. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/__init__.py +0 -0
  29. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/loader.py +0 -0
  30. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/qmt_translator.py +0 -0
  31. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/__init__.py +0 -0
  32. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/cache.py +0 -0
  33. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/http_client.py +0 -0
  34. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/s3client.py +0 -0
  35. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/server_client.py +0 -0
  36. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  37. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  38. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  39. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  40. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/setup.cfg +0 -0
  41. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/tests/test_live_safety.py +0 -0
  42. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/tests/test_skeleton.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.0.dev93
3
+ Version: 0.1.2
4
4
  Summary: Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范.
5
5
  Author-email: Hamuna Team <team@hamuna.example>
6
6
  License: MIT
@@ -25,6 +25,8 @@ Requires-Dist: numpy>=1.23
25
25
  Requires-Dist: pyarrow>=10
26
26
  Requires-Dist: pyyaml>=6
27
27
  Requires-Dist: requests>=2.28
28
+ Requires-Dist: boto3>=1.34
29
+ Requires-Dist: cryptography>=41
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: pytest>=7; extra == "dev"
30
32
  Requires-Dist: pytest-cov>=4; extra == "dev"
@@ -12,6 +12,4 @@ server (13-key metrics schema). 全部走 akquant 0.3.x 引擎.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- # 版本号由 scripts/bump_version.py 每次 build 自动更新 (基于 git 提交数).
16
- # 格式: 0.1.0.dev<git提交数> (PEP 440). 手动改这里会被下一次 build 覆盖.
17
- __version__ = "0.1.0.dev93"
15
+ __version__ = "0.1.2"
@@ -69,6 +69,35 @@ def cmd_run(args) -> int:
69
69
  f"用 --skip-discipline 显式跳过", file=sys.stderr)
70
70
  return 3
71
71
 
72
+ # --params (params.json 固定 schema) → strategy_params 段注入 cfg,
73
+ # akquant_runner 展开注入 engine kwargs. 与 `live run --params` 同源:
74
+ # 同一份 params.json 驱动回测 + 实盘, 参数约定不再分裂 (Round 18.13).
75
+ if getattr(args, "params", None):
76
+ params_path = Path(args.params)
77
+ if not params_path.exists():
78
+ print(f"--params 不存在: {args.params}", file=sys.stderr)
79
+ return 2
80
+ import json as _json
81
+ try:
82
+ params_doc = _json.loads(params_path.read_text(encoding="utf-8"))
83
+ except _json.JSONDecodeError as e:
84
+ print(f"--params 解析失败: {args.params} ({e})", file=sys.stderr)
85
+ return 2
86
+ sp = (params_doc or {}).get("strategy_params")
87
+ if isinstance(sp, dict):
88
+ cfg["strategy_params"] = sp
89
+ else:
90
+ print("[warn] --params 无 strategy_params 段, 忽略 (仅接受固定 schema)", file=sys.stderr)
91
+ # Round 18.14: risk_config 段 → cfg['risk_config'] → runner 透传
92
+ # run_backtest(risk_config={"max_position_pct": 0.1}) 原生消费 (账户级风控,
93
+ # 引擎 hasattr 校验 + 未知字段仅 warning). skill 桌面编辑的实盘风控真正生效.
94
+ rc = (params_doc or {}).get("risk_config")
95
+ if isinstance(rc, dict):
96
+ # 与既有 cfg['risk_config'] 合并 (显式 cfg 优先)
97
+ merged = dict(cfg.get("risk_config") or {})
98
+ merged.update(rc)
99
+ cfg["risk_config"] = merged
100
+
72
101
  if getattr(args, "dataset", None):
73
102
  manifest_path = Path(args.dataset)
74
103
  if not manifest_path.exists():
@@ -82,7 +111,16 @@ def cmd_run(args) -> int:
82
111
  return 2
83
112
  cfg["_dataset_manifest"] = manifest
84
113
 
85
- result = runtime.backtest.run(strategy_path, cfg)
114
+ try:
115
+ result = runtime.backtest.run(strategy_path, cfg)
116
+ except Exception as e: # noqa: BLE001 — 错误分级: StrategyParamsError → exit 3; 其余 runner 错 → exit 4
117
+ from .akquant_runner import StrategyParamsError
118
+ if isinstance(e, StrategyParamsError):
119
+ # Round 18.13: --params strategy_params 预校验失败 (validate_strategy_params) 走 exit 3,
120
+ # 与纪律档位一致 — 不带全 Traceback, 一行明确 msg (用户改 params.json).
121
+ print(f"策略参数校验失败: {e}", file=sys.stderr)
122
+ return 3
123
+ raise
86
124
 
87
125
  if args.output:
88
126
  import json
@@ -799,7 +837,11 @@ def cmd_live_run(args) -> int:
799
837
  print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
800
838
  return 3
801
839
  try:
840
+ import json as _json
802
841
  symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
842
+ params = _json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
843
+ # 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
844
+ sp = (params or {}).get("strategy_params")
803
845
  run_live(
804
846
  spec,
805
847
  mode=args.mode,
@@ -812,6 +854,7 @@ def cmd_live_run(args) -> int:
812
854
  log_file=args.log_file,
813
855
  market_broker=args.market_broker,
814
856
  replay_days=args.replay_days,
857
+ params=sp if isinstance(sp, dict) else None,
815
858
  )
816
859
  except LiveRunError as e:
817
860
  print(f"[error] live run 失败: {e}", file=sys.stderr)
@@ -838,6 +881,9 @@ def build_parser() -> argparse.ArgumentParser:
838
881
  r.add_argument("--config", required=True, help="CONFIG JSON 路径")
839
882
  r.add_argument("--output", help="result 落盘 JSON 路径 (默认 stdout)")
840
883
  r.add_argument("--dataset", help="固化 manifest JSON 路径")
884
+ r.add_argument("--params", default=None,
885
+ help="params.json 路径 (固定 schema; strategy_params 段作策略构造参数, "
886
+ "与 `live run --params` 同源 — 回测/实盘参数一致)")
841
887
  r.add_argument("--skip-discipline", action="store_true",
842
888
  help="跳过 akquant API 静态审查 (qa / 旧策略兼容)")
843
889
  r.add_argument("--upload", action="store_true",
@@ -950,6 +996,8 @@ def build_parser() -> argparse.ArgumentParser:
950
996
  help="独立行情 broker (e.g. qmt_market)")
951
997
  run_p.add_argument("--symbols", default=None,
952
998
  help="标的列表, 逗号分隔")
999
+ run_p.add_argument("--params", default=None,
1000
+ help="params.json 路径 (固定 schema, strategy_params 段作策略构造参数)")
953
1001
  run_p.add_argument("--duration", default="1h",
954
1002
  help="运行时长 (akquant 解析)")
955
1003
  run_p.add_argument("--class-name", default="Strategy",
@@ -84,14 +84,14 @@ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
84
84
 
85
85
  universe: 标的代码 list, e.g. ['600000.SH', '000001.SZ']
86
86
  start/end: 'YYYYMMDD' 字符串
87
- period: '1d' (Phase B 仅日线)
87
+ period: '1d' | '5m' (2026-08-27 打通 5m; 需本地 5m bundle, 见 prefet resolver).
88
88
 
89
89
  数据源: prebuilt_resolver.resolve (pyarrow filter pushdown, fast-path).
90
- prebuilt 找不到 → raise FileNotFoundError + 引导用户跑 `dataset fetch`.
90
+ prebuilt 找不到 → raise FileNotFoundError + 引导用户跑 `dataset fetch --period 5m`.
91
91
  """
92
- if period != '1d':
92
+ if period not in ('1d', '5m'):
93
93
  raise NotImplementedError(
94
- f'akquant_data_adapter 仅支持 1d (Phase B), period={period} 待 akquant Phase C')
94
+ f'akquant_data_adapter 仅支持 1d/5m, period={period} 待 akquant Phase C')
95
95
  codes = list(universe)
96
96
  if not codes:
97
97
  raise ValueError('universe 不能为空')
@@ -124,7 +124,15 @@ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
124
124
  f'prebuilt 缺 OHLCV 列 {missing} (R2: schema 不兼容). 实际列: {list(df.columns)[:10]}...')
125
125
  for c in _DROP_COLS:
126
126
  df = df.drop(columns=[c], errors='ignore')
127
- df['date'] = pd.to_datetime(df['date'].astype(str), format='%Y%m%d')
127
+ # date 列: resolver 已归一 datetime64 (日线 = 当日 00:00, 5m = 已带时分秒).
128
+ # 若仍是字符串 (老 single 路径), 按 period 粒度解析:
129
+ # '5m' 保留时分秒 (YYYYMMDDHHMMSS), '1d' 取 8 位日期.
130
+ if not pd.api.types.is_datetime64_any_dtype(df['date']):
131
+ _date_str = df['date'].astype(str).str[:14].str.replace('-', '', regex=False)
132
+ if period == '5m':
133
+ df['date'] = pd.to_datetime(_date_str, format='%Y%m%d%H%M%S', errors='coerce')
134
+ else:
135
+ df['date'] = pd.to_datetime(_date_str.str[:8], format='%Y%m%d', errors='coerce')
128
136
  df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']]
129
137
  return df.reset_index(drop=True)
130
138
 
@@ -186,8 +194,6 @@ def _try_prebuilt_parquet(universe: Iterable[str], start: str, end: str,
186
194
 
187
195
  返回 None 表示无 prebuilt 命中 (调用方 fallback: 报错 + 引导下载).
188
196
  """
189
- if period != '1d':
190
- return None
191
197
  from .prebuilt_resolver import resolve # B5 搬入同包
192
198
  df, _src = resolve(list(universe), start, end, period=period)
193
199
  return df
@@ -36,7 +36,79 @@ from pathlib import Path
36
36
  from typing import Any
37
37
 
38
38
 
39
+ class StrategyParamsError(RuntimeError):
40
+ """--params strategy_params 预校验失败 (validate_strategy_params).
41
+
42
+ cmd_run 用此类型区分 exit 3 (参数错, 改 params.json) 与 runner 其它 RuntimeError (exit 4).
43
+ Round 18.13.
44
+ """
45
+
46
+
39
47
  # ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
48
+ def _with_backtest_timer_rebalance(cls: type) -> type:
49
+ """回测端周五 schedule + on_timer 注入 — 与实盘 replay 同触发路径.
50
+
51
+ Round 18.32: 之前回测横截面策略走 `on_cross_section`, 实盘 replay 走
52
+ `on_timer` (runner 外部日历注入 schedule) — 两端路径不同, 可比性差 (实盘丢
53
+ 07-03 批 / 因子一次性 vs 逐 bar 演进). 本 wrapper 让**回测也走 on_timer**:
54
+
55
+ - `on_start` 时 `_trading_days` 未注入 (engine.py:3107 on_start 在 :3502 注入
56
+ 之前), `self.trading_days` 恒空 — 所以**延迟到首个 on_bar** 再注册.
57
+ - 首个 bar 时从 `self.trading_days` 筛周五, 逐周注册 `schedule(周五14:55, 'rebalance')`
58
+ → 引擎按 bar 边界触发 `on_timer('rebalance')`, ctx.current_time 即该周五.
59
+ - 策略已定义 on_timer 处理 'rebalance' → 与实盘完全同路径; 未定义 → base no-op, 无害.
60
+ - 只包有 on_timer 的策略 (无 on_timer 时 on_cross_section 仍是唯一回测调仓钩子,
61
+ 注入 schedule 白费且可能引发无预期 on_timer 调用).
62
+
63
+ ponytail: 与 live 端 `_with_live_history_depth` 互补 — live 用外部日历注入
64
+ (replay bars / bridge 历史), 回测用引擎 trading_days. 两端最终都注册
65
+ `schedule(周五14:55, 'rebalance')` → `on_timer`, 真正同机制.
66
+ """
67
+ import datetime as _dt
68
+
69
+ if cls is None or "on_timer" not in getattr(cls, "__dict__", {}):
70
+ return cls
71
+ from akquant.strategy_scheduler import schedule as _schedule
72
+
73
+ class _BacktestTimerRebalance(cls):
74
+ _bt_timer_registered = False
75
+
76
+ def on_bar(self, bar) -> Any:
77
+ ret_val = super().on_bar(bar)
78
+ if not type(self)._bt_timer_registered:
79
+ type(self)._bt_timer_registered = True
80
+ try:
81
+ fridays = [ts for ts in self.trading_days if ts.weekday() == 4]
82
+ for ts in fridays:
83
+ _schedule(self, ts.replace(hour=14, minute=55), "rebalance")
84
+ print(
85
+ f"[info] 回测周五 schedule+on_timer 注入: {len(fridays)} 个 "
86
+ f"({[ts.strftime('%m-%d') for ts in fridays]})",
87
+ file=sys.stderr,
88
+ )
89
+ except Exception as e: # noqa: BLE001 — 注入失败不崩, 回测走原路径
90
+ print(
91
+ f"[warn] 回测周五 schedule 注入失败: {e}",
92
+ file=sys.stderr,
93
+ )
94
+ return ret_val
95
+
96
+ def on_cross_section(self, trading_date, timestamp) -> Any:
97
+ """吞掉 on_cross_section — schedule 定时器已接管调仓 (Round 18.32).
98
+
99
+ 策略类同时有 on_cross_section (回测旧路径) 和 on_timer (实盘新路径) 时,
100
+ 同一个周五**两个钩子都会触发** → 重复下单 (实测: 07-06 批 20→22 trades,
101
+ 000006/000035 各重复买一次). 本 wrapper 把 on_cross_section 变 no-op,
102
+ 让回测统一走 schedule+on_timer, 与实盘完全同路径. 若策略没有
103
+ on_cross_section, 本方法不干扰 (未重写 → 走父类 no-op, 同样无害).
104
+ """
105
+ return None
106
+
107
+ _BacktestTimerRebalance.__name__ = cls.__name__
108
+ _BacktestTimerRebalance.__qualname__ = cls.__qualname__
109
+ return _BacktestTimerRebalance
110
+
111
+
40
112
  def _load_akquant_strategy(strategy_path: str) -> Any:
41
113
  """加载 strategy .py, 提取第一个 akquant.Strategy 子类.
42
114
 
@@ -101,13 +173,15 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
101
173
  pass
102
174
 
103
175
  # data — Q1 兜底默认开 (cfg['price_limit_clamp']=False 可关)
176
+ # period: cfg['period'] 支持 '1d'|'5m' (2026-08-27) — 5m 走 __bundle__*_5m
104
177
  use_clamp = bool(cfg.get('price_limit_clamp', True))
178
+ _period = cfg.get('period', '1d')
105
179
  if use_clamp:
106
180
  from .akquant_data_adapter import load_prebuilt_to_akquant_with_limits # B4
107
181
  df = load_prebuilt_to_akquant_with_limits(
108
- universe, cfg['backtest_start'], cfg['backtest_end'])
182
+ universe, cfg['backtest_start'], cfg['backtest_end'], period=_period)
109
183
  else:
110
- df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'])
184
+ df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'], period=_period)
111
185
  if df.empty:
112
186
  raise FileNotFoundError(
113
187
  f'未取到 bar 数据 (universe={universe}, {cfg["backtest_start"]}-{cfg["backtest_end"]})')
@@ -119,6 +193,9 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
119
193
 
120
194
  # strategy class
121
195
  strat_cls = _load_akquant_strategy(strategy_path)
196
+ # Round 18.32: 回测也走 schedule+on_timer (与实盘 replay 同触发路径) — 在 class
197
+ # 层面 wrap, 必须在此处 (kwargs['strategy'] 在下方构建, wrap 需在构建前生效).
198
+ strat_cls = _with_backtest_timer_rebalance(strat_cls)
122
199
 
123
200
  # ===== 0.3 架构变更 (2026-08-18 简化): compute_factors / filter_symbols 接入引擎 =====
124
201
  # 流程: 实例化 → compute_factors (直接传 prebuilt df, 引擎不包装 BacktestContext) →
@@ -203,14 +280,54 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
203
280
  't_plus_one': bool(cfg.get('t_plus_one', True)),
204
281
  'show_progress': False,
205
282
  }
206
- # universe 注入 (Q1: 多标/横截面策略必需):
207
- # - 0.3.x 新 API: strategy 用 `universe = ListParam(default=[])` 类字段, runner 通过
208
- # `strategy_params={'universe': universe}` 注入 (引擎走 .params.universe).
209
- # - 0.2.x 旧 API: strategy `__init__(self, universe=None)`, runner 通过 kwargs 注入.
210
- # **0.3.x 严格拒收** (raise TypeError), 所以检测到旧风格时只能跳过, 让用户迁移.
211
- if _strategy_accepts_universe_param(strat_cls):
212
- kwargs['strategy_params'] = {'universe': universe}
213
- # 不再 fallback 0.2 旧路径 — 见上方注释; 旧 strategy 走 0.3 必须先迁移到 ParamModel.
283
+ # strategy_params 注入 (0.3.x ParamModel 字段):
284
+ # - universe: 多标/横截面策略必需, cfg['universe']/['pool'] 归一化后注入.
285
+ # - exclude_symbols: 策略声明 `exclude_symbols = ListParam(default=[])` 时,
286
+ # cfg['exclude_symbols'] (裸码 list) 归一化后并列注入. 策略在 on_bar
287
+ # 拿 self.params.exclude_symbols bar.symbol 比对 (两者同带后缀形态).
288
+ # 只有策略声明该字段才注入 — 未声明注入 = engine strict 模式 unknown key 崩溃.
289
+ # ponytail: 回测侧 exclude_symbols 仅来自 cfg; 实盘 QMT「排除已持仓」需策略在
290
+ # on_bar 里自己调 broker query_positions() (runner 不透传持仓, 那是动态状态).
291
+ # ===== strategy 调参展开注入 (0.3.x 原生 kwargs 路径, Round 18.13) =====
292
+ # 之前把 universe/exclude_symbols 打包成 kwargs['strategy_params'] dict — 但
293
+ # akquant.run_backtest 签名原生**没有** strategy_params (只有 **kwargs + strict_strategy_params=True),
294
+ # engine 把 kwargs 里未知 key 拼进 strategy(**kwargs) 构造. 现在把参数**展开**进 kwargs:
295
+ # - universe/exclude_symbols: cfg 归一化后直接展开 (engine 经 **kwargs → strategy(**merged) 注入)
296
+ # - strategy_params 段: cmd_run --params 读 params.json 的 strategy_params (Round 18.13),
297
+ # 与 live run --params 同源, 回测/实盘参数完全一致
298
+ # engine strict_strategy_params=True 会对未声明字段抛 TypeError (老 __init__ 风格已有 _strategy_param_fields 豁免).
299
+ accepted_fields = _strategy_param_fields(strat_cls)
300
+ if 'universe' in accepted_fields:
301
+ kwargs['universe'] = universe
302
+ if 'exclude_symbols' in accepted_fields:
303
+ exclude_codes = cfg.get('exclude_symbols') or []
304
+ kwargs['exclude_symbols'] = [
305
+ normalize_symbol(s) for s in exclude_codes]
306
+ # params.json strategy_params 段 → 展开注入 (仅声明字段; 未声明 → strict 模式 engine 拒收)
307
+ user_params = (cfg.get('strategy_params') or {}).get('strategy_params') \
308
+ if isinstance(cfg.get('strategy_params'), dict) and \
309
+ isinstance((cfg.get('strategy_params') or {}).get('strategy_params'), dict) \
310
+ else (cfg.get('strategy_params') if isinstance(cfg.get('strategy_params'), dict) else None)
311
+ user_params = user_params if isinstance(user_params, dict) else None
312
+ if user_params:
313
+ # 先 validate_strategy_params 预校验 (与 live run --params 同语义) — 未声明字段/
314
+ # 越界值在回测启动前抛明确错误, 而不是 engine 深处 TypeError.
315
+ try:
316
+ from akquant import validate_strategy_params
317
+ validated = validate_strategy_params(strat_cls, user_params)
318
+ except Exception as e:
319
+ raise StrategyParamsError(
320
+ f"params.json strategy_params 校验失败 (与 IntParam/ListParam 字段不符): {e}"
321
+ ) from e
322
+ if validated:
323
+ for k, v in validated.items():
324
+ if k not in accepted_fields:
325
+ continue
326
+ # 不覆盖 runner 已注入的 universe/exclude_symbols (它们来自 cfg, 与
327
+ # params.json 的 backtest 段同源; validate 返回的默认 [] 会误覆盖).
328
+ if k in kwargs:
329
+ continue
330
+ kwargs[k] = v
214
331
 
215
332
  # ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
216
333
  # 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
@@ -393,34 +510,44 @@ def _akquant_supports_commission_policy() -> bool:
393
510
  return _akquant_kwarg_supported('commission_policy')
394
511
 
395
512
 
396
- def _strategy_accepts_universe_param(strat_cls: type) -> bool:
397
- """检测 strategy 是否声明 `universe` 为 0.3.x ParamModel 字段.
513
+ def _strategy_param_fields(strat_cls: type) -> set[str]:
514
+ """返回 strategy 声明的 0.3.x ParamModel 字段名集合.
398
515
 
399
- 0.3.x: `universe: list = ListParam(default=[])` 类字段, 通过 strategy_params 注入.
400
- 0.2.x: `__init__(self, universe=None)` — 0.3.x 严格拒收 (TypeError), 这里返 False 跳过.
516
+ 0.3.x: `universe: list = ListParam(default=[])` 类字段 model_fields.
517
+ 0.2.x: `__init__(self, universe=None)` — model_fields, 返空集 (runner 跳过注入).
401
518
 
402
- ponytail: 仅检测 'universe' 字段. 其它字段 (lots/fast/slow ) ParamModel 自身规则,
403
- runner 不替用户策略注入; grid/WFO 子命令会单独处理.
519
+ 空集 = 策略没用 ParamModel ( __init__ 风格), 不是「豁免」— 0.3.x 严格拒收.
404
520
  """
405
521
  try:
406
- # pydantic-based ParamModel: 实例化后 inst.params.<name> 存在
407
522
  with warnings.catch_warnings():
408
523
  warnings.simplefilter('ignore') # 0.3.x 对老 __init__ 形参的 UserWarning
409
524
  inst = strat_cls()
410
525
  if not hasattr(inst, 'params'):
411
- return False
526
+ return set()
412
527
  params_obj = getattr(inst, 'params')
413
- # pydantic v2: model_fields dict[str, FieldInfo]
414
- fields = getattr(params_obj, 'model_fields', None)
415
- if fields and 'universe' in fields:
416
- return True
417
- # pydantic v1 fallback
418
- fields_v1 = getattr(params_obj, '__fields__', None)
419
- if fields_v1 and 'universe' in fields_v1:
420
- return True
421
- return False
528
+ fields = getattr(params_obj, 'model_fields', None) # pydantic v2
529
+ if fields:
530
+ return set(fields)
531
+ fields_v1 = getattr(params_obj, '__fields__', None) # pydantic v1 fallback
532
+ if fields_v1:
533
+ return set(fields_v1)
534
+ return set()
422
535
  except Exception: # noqa: BLE001
423
- return False
536
+ return set()
537
+
538
+
539
+ def _strategy_accepts_universe_param(strat_cls: type) -> bool:
540
+ """检测 strategy 是否声明 `universe` 为 0.3.x 的 ParamModel 字段."""
541
+ return 'universe' in _strategy_param_fields(strat_cls)
542
+
543
+
544
+ def _strategy_accepts_exclude_symbols_param(strat_cls: type) -> bool:
545
+ """检测 strategy 是否声明 `exclude_symbols` 为 0.3.x 的 ParamModel 字段.
546
+
547
+ 仅当策略显式声明才注入 — 未声明字段注入会在 strict_strategy_params=True 下
548
+ 被 engine 判 unknown key → TypeError (用户 2026-08-25 实际踩过).
549
+ """
550
+ return 'exclude_symbols' in _strategy_param_fields(strat_cls)
424
551
 
425
552
 
426
553
  def _selfcheck() -> None:
@@ -489,9 +616,10 @@ class MACross(Strategy):
489
616
  print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
490
617
 
491
618
  # Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
492
- # 把 universe 注入 strategy_params (否则 on_start 空订阅 → 0 trades, 高胜率
493
- # 策略实际踩过). 0.3.x 严格拒收老 __init__ 形参, 所以检测 注入路径改了.
494
- # monkeypatch akquant.run_backtest 捕获 kwargs, 断言 strategy_params 在.
619
+ # 把 universe 注入 (否则 on_start 空订阅 → 0 trades, 高胜率策略实际踩过).
620
+ # Round 18.13: 注入从 strategy_params dict 改为**展开进 kwargs 顶层** —
621
+ # run_backtest 签名无 strategy_params (只有 **kwargs + strict_strategy_params),
622
+ # engine 把未知 key 拼进 strategy(**kwargs). 断言 universe 直接展开.
495
623
  import akquant as _akq
496
624
  captured: dict = {}
497
625
  _orig_run = _akq.run_backtest
@@ -504,9 +632,9 @@ class MACross(Strategy):
504
632
  _akq.run_backtest = _capture
505
633
  try:
506
634
  _ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
507
- # MACross 不收 universe → 不注入 strategy_params (0.2.x 旧路径已被 0.3 移除)
508
- assert 'strategy_params' not in captured, (
509
- f'MACross 不应注入 strategy_params: {captured.get("strategy_params")}')
635
+ # MACross 不收 universe → kwargs 顶层不应有 universe / strategy_params
636
+ assert 'universe' not in captured and 'strategy_params' not in captured, (
637
+ f'MACross 不应注入 universe: {captured.get("universe")!r}')
510
638
  finally:
511
639
  _akq.run_backtest = _orig_run
512
640
  captured.clear()
@@ -523,8 +651,8 @@ class UsesUniverse(Strategy):
523
651
  pass
524
652
  ''', encoding='utf-8')
525
653
  _ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
526
- assert captured.get('strategy_params') == {'universe': ['600000.SH']}, (
527
- f'UsesUniverse (0.3 ParamModel) 注入错: {captured.get("strategy_params")}')
654
+ assert captured.get('universe') == ['600000.SH'], (
655
+ f'UsesUniverse (0.3 ParamModel) 展开注入错: {captured.get("universe")!r}')
528
656
  finally:
529
657
  _akq.run_backtest = _orig_run
530
658
  # 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
@@ -535,7 +663,87 @@ class UsesUniverse(Strategy):
535
663
  'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
536
664
  assert _strategy_accepts_universe_param(_uses_cls), (
537
665
  'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
538
- print(f'OK: universe 注入路径生效 (0.3 ParamModel → strategy_params; 旧 __init__ 风格跳过)')
666
+ print(f'OK: universe 注入路径生效 (0.3 ParamModel → kwargs 展开; 旧 __init__ 风格跳过)')
667
+
668
+ # ===== exclude_symbols 注入 (2026-08-25, 用户实战踩坑): =====
669
+ # 仅当策略声明 `exclude_symbols = ListParam(...)` 才注入; 未声明注入 =
670
+ # engine strict 模式 unknown key → TypeError. 注入值归一化为带后缀形态.
671
+ strat4 = Path(tmp) / 'uses_exclude.py'
672
+ strat4.write_text('''
673
+ from akquant import Strategy, ListParam
674
+
675
+ class UsesExclude(Strategy):
676
+ """0.3.x 新 ParamModel 风格: universe + exclude_symbols 双字段."""
677
+ universe = ListParam(item_type=str, default=[])
678
+ exclude_symbols = ListParam(item_type=str, default=[])
679
+ def on_bar(self, bar):
680
+ pass
681
+ ''', encoding='utf-8')
682
+ captured.clear()
683
+ _akq.run_backtest = _capture
684
+ try:
685
+ cfg_ex = {**cfg, 'exclude_symbols': ['600036']}
686
+ _ = run_akquant_backtest(strat4, cfg_ex)
687
+ assert captured.get('universe') == ['600000.SH'] and \
688
+ captured.get('exclude_symbols') == ['600036.SH'], (
689
+ f'UsesExclude 展开注入错 (应归一化带后缀 + 顶层 key): '
690
+ f'u={captured.get("universe")!r} ex={captured.get("exclude_symbols")!r}')
691
+ finally:
692
+ _akq.run_backtest = _orig_run
693
+ # MACross (无 ParamModel) 不应注入 exclude_symbols — strict 模式会崩
694
+ captured.clear()
695
+ _akq.run_backtest = _capture
696
+ try:
697
+ _ = run_akquant_backtest(strat_path, cfg_ex) # MACross 走 0.2 __init__ 风格
698
+ assert 'exclude_symbols' not in captured and 'strategy_params' not in captured, (
699
+ f'MACross 不应注入 exclude_symbols: {captured.get("exclude_symbols")!r}')
700
+ finally:
701
+ _akq.run_backtest = _orig_run
702
+ _uses_excl = _load_akquant_strategy(strat4)
703
+ assert _strategy_accepts_exclude_symbols_param(_uses_excl), (
704
+ 'UsesExclude (ListParam) 应 _strategy_accepts_exclude_symbols_param=True')
705
+ assert not _strategy_accepts_exclude_symbols_param(_macross_cls), (
706
+ 'MACross (无 exclude_symbols 字段) 应 False')
707
+ print('OK: exclude_symbols 注入 (仅声明注入 + 归一化带后缀 + 未声明跳过)')
708
+
709
+ # ===== Round 18.13: params.json strategy_params 展开注入 (回测 --params) =====
710
+ # cmd_run --params 读 params.json 的 strategy_params 段注入 cfg['strategy_params'],
711
+ # runner 展开进 kwargs 顶层 (红线: 必须 validate_strategy_params 预校验).
712
+ strat5 = Path(tmp) / 'params_poly.py'
713
+ strat5.write_text('''
714
+ from akquant import Strategy, IntParam, ListParam
715
+
716
+ class ParamsPoly(Strategy):
717
+ fast = IntParam(10, ge=2, le=200)
718
+ slow = IntParam(20, ge=3, le=500)
719
+ universe: list = ListParam(default=[])
720
+ def on_bar(self, bar):
721
+ pass
722
+ ''', encoding='utf-8')
723
+ captured.clear()
724
+ _akq.run_backtest = _capture
725
+ try:
726
+ cfg_p = {**cfg, 'strategy_params': {'fast': 5, 'slow': 33}}
727
+ _ = run_akquant_backtest(strat5, cfg_p)
728
+ assert captured.get('fast') == 5 and captured.get('slow') == 33, (
729
+ f'params strategy_params 应展开注入 kwargs 顶层: '
730
+ f'fast={captured.get("fast")!r} slow={captured.get("slow")!r}')
731
+ assert captured.get('universe') == ['600000.SH'], (
732
+ f'universe 应继续展开注入: {captured.get("universe")!r}')
733
+ finally:
734
+ _akq.run_backtest = _orig_run
735
+ # 未知字段 → StrategyParamsError 启动前抛 (cmd_run 转 exit 3), 不是 engine 深处 TypeError
736
+ captured.clear()
737
+ _akq.run_backtest = _capture
738
+ try:
739
+ try:
740
+ _ = run_akquant_backtest(strat5, {**cfg_p, 'strategy_params': {'bogus': 1}})
741
+ raise AssertionError('未知字段应被 validate_strategy_params 拦')
742
+ except StrategyParamsError as e:
743
+ assert '校验失败' in str(e), f'应报校验失败: {e}'
744
+ finally:
745
+ _akq.run_backtest = _orig_run
746
+ print('OK: params strategy_params 展开注入 (validate 预校验 + 未知字段拦在启动前)')
539
747
 
540
748
  # ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
541
749
  # 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15