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.
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/PKG-INFO +3 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__init__.py +1 -3
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__main__.py +49 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_data_adapter.py +13 -7
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_runner.py +245 -37
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_schema_adapter.py +117 -8
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_broker.py +100 -40
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_market.py +17 -4
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/runner.py +277 -18
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_downloader.py +31 -10
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_resolver.py +66 -27
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/backtest.py +2 -2
- hamuna_quant_cli-0.1.2/hamuna_quant_cli/runtime/discipline.py +878 -0
- hamuna_quant_cli-0.1.2/hamuna_quant_cli/scripts/server.json +4 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/PKG-INFO +3 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/requires.txt +2 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/pyproject.toml +3 -1
- hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/runtime/discipline.py +0 -359
- hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/scripts/server.json +0 -4
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/README.md +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/server_client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/tests/test_live_safety.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_data_adapter.py
RENAMED
|
@@ -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' (
|
|
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
|
|
92
|
+
if period not in ('1d', '5m'):
|
|
93
93
|
raise NotImplementedError(
|
|
94
|
-
f'akquant_data_adapter 仅支持 1d
|
|
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
|
-
|
|
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
|
-
#
|
|
207
|
-
# -
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
#
|
|
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
|
|
397
|
-
"""
|
|
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=[])`
|
|
400
|
-
0.2.x: `__init__(self, universe=None)` —
|
|
516
|
+
0.3.x: `universe: list = ListParam(default=[])` 类字段 → model_fields.
|
|
517
|
+
0.2.x: `__init__(self, universe=None)` — 无 model_fields, 返空集 (runner 跳过注入).
|
|
401
518
|
|
|
402
|
-
|
|
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
|
|
526
|
+
return set()
|
|
412
527
|
params_obj = getattr(inst, 'params')
|
|
413
|
-
|
|
414
|
-
fields
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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 注入
|
|
493
|
-
#
|
|
494
|
-
#
|
|
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 →
|
|
508
|
-
assert 'strategy_params' not in captured, (
|
|
509
|
-
f'MACross 不应注入
|
|
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('
|
|
527
|
-
f'UsesUniverse (0.3 ParamModel)
|
|
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 →
|
|
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
|