hamuna-quant-cli 0.1.1__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.1 → hamuna_quant_cli-0.1.2}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__init__.py +1 -3
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/__main__.py +44 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_data_adapter.py +13 -9
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_runner.py +169 -22
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_schema_adapter.py +117 -8
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_broker.py +100 -40
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/qmt_market.py +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/runner.py +120 -10
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_downloader.py +31 -10
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/prebuilt_resolver.py +66 -27
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/backtest.py +2 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/discipline.py +320 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/pyproject.toml +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/README.md +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/runtime/server_client.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/tests/test_live_safety.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/tests/test_skeleton.py +0 -0
|
@@ -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.1"
|
|
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,8 +837,9 @@ 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
|
|
803
|
-
params =
|
|
842
|
+
params = _json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
|
|
804
843
|
# 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
|
|
805
844
|
sp = (params or {}).get("strategy_params")
|
|
806
845
|
run_live(
|
|
@@ -842,6 +881,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
842
881
|
r.add_argument("--config", required=True, help="CONFIG JSON 路径")
|
|
843
882
|
r.add_argument("--output", help="result 落盘 JSON 路径 (默认 stdout)")
|
|
844
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` 同源 — 回测/实盘参数一致)")
|
|
845
887
|
r.add_argument("--skip-discipline", action="store_true",
|
|
846
888
|
help="跳过 akquant API 静态审查 (qa / 旧策略兼容)")
|
|
847
889
|
r.add_argument("--upload", action="store_true",
|
|
@@ -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,9 +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
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
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')
|
|
130
136
|
df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']]
|
|
131
137
|
return df.reset_index(drop=True)
|
|
132
138
|
|
|
@@ -188,8 +194,6 @@ def _try_prebuilt_parquet(universe: Iterable[str], start: str, end: str,
|
|
|
188
194
|
|
|
189
195
|
返回 None 表示无 prebuilt 命中 (调用方 fallback: 报错 + 引导下载).
|
|
190
196
|
"""
|
|
191
|
-
if period != '1d':
|
|
192
|
-
return None
|
|
193
197
|
from .prebuilt_resolver import resolve # B5 搬入同包
|
|
194
198
|
df, _src = resolve(list(universe), start, end, period=period)
|
|
195
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) →
|
|
@@ -211,16 +288,46 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
|
|
|
211
288
|
# 只有策略声明该字段才注入 — 未声明注入 = engine strict 模式 unknown key 崩溃.
|
|
212
289
|
# ponytail: 回测侧 exclude_symbols 仅来自 cfg; 实盘 QMT「排除已持仓」需策略在
|
|
213
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 豁免).
|
|
214
299
|
accepted_fields = _strategy_param_fields(strat_cls)
|
|
215
|
-
strategy_params: dict[str, Any] = {}
|
|
216
300
|
if 'universe' in accepted_fields:
|
|
217
|
-
|
|
301
|
+
kwargs['universe'] = universe
|
|
218
302
|
if 'exclude_symbols' in accepted_fields:
|
|
219
303
|
exclude_codes = cfg.get('exclude_symbols') or []
|
|
220
|
-
|
|
304
|
+
kwargs['exclude_symbols'] = [
|
|
221
305
|
normalize_symbol(s) for s in exclude_codes]
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
224
331
|
|
|
225
332
|
# ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
|
|
226
333
|
# 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
|
|
@@ -509,9 +616,10 @@ class MACross(Strategy):
|
|
|
509
616
|
print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
|
|
510
617
|
|
|
511
618
|
# Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
|
|
512
|
-
# 把 universe 注入
|
|
513
|
-
#
|
|
514
|
-
#
|
|
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 直接展开.
|
|
515
623
|
import akquant as _akq
|
|
516
624
|
captured: dict = {}
|
|
517
625
|
_orig_run = _akq.run_backtest
|
|
@@ -524,9 +632,9 @@ class MACross(Strategy):
|
|
|
524
632
|
_akq.run_backtest = _capture
|
|
525
633
|
try:
|
|
526
634
|
_ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
|
|
527
|
-
# MACross 不收 universe →
|
|
528
|
-
assert 'strategy_params' not in captured, (
|
|
529
|
-
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}')
|
|
530
638
|
finally:
|
|
531
639
|
_akq.run_backtest = _orig_run
|
|
532
640
|
captured.clear()
|
|
@@ -543,8 +651,8 @@ class UsesUniverse(Strategy):
|
|
|
543
651
|
pass
|
|
544
652
|
''', encoding='utf-8')
|
|
545
653
|
_ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
|
|
546
|
-
assert captured.get('
|
|
547
|
-
f'UsesUniverse (0.3 ParamModel)
|
|
654
|
+
assert captured.get('universe') == ['600000.SH'], (
|
|
655
|
+
f'UsesUniverse (0.3 ParamModel) 展开注入错: {captured.get("universe")!r}')
|
|
548
656
|
finally:
|
|
549
657
|
_akq.run_backtest = _orig_run
|
|
550
658
|
# 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
|
|
@@ -555,7 +663,7 @@ class UsesUniverse(Strategy):
|
|
|
555
663
|
'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
|
|
556
664
|
assert _strategy_accepts_universe_param(_uses_cls), (
|
|
557
665
|
'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
|
|
558
|
-
print(f'OK: universe 注入路径生效 (0.3 ParamModel →
|
|
666
|
+
print(f'OK: universe 注入路径生效 (0.3 ParamModel → kwargs 展开; 旧 __init__ 风格跳过)')
|
|
559
667
|
|
|
560
668
|
# ===== exclude_symbols 注入 (2026-08-25, 用户实战踩坑): =====
|
|
561
669
|
# 仅当策略声明 `exclude_symbols = ListParam(...)` 才注入; 未声明注入 =
|
|
@@ -576,10 +684,10 @@ class UsesExclude(Strategy):
|
|
|
576
684
|
try:
|
|
577
685
|
cfg_ex = {**cfg, 'exclude_symbols': ['600036']}
|
|
578
686
|
_ = run_akquant_backtest(strat4, cfg_ex)
|
|
579
|
-
assert captured.get('
|
|
580
|
-
'
|
|
581
|
-
|
|
582
|
-
|
|
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}')
|
|
583
691
|
finally:
|
|
584
692
|
_akq.run_backtest = _orig_run
|
|
585
693
|
# MACross (无 ParamModel) 不应注入 exclude_symbols — strict 模式会崩
|
|
@@ -587,8 +695,8 @@ class UsesExclude(Strategy):
|
|
|
587
695
|
_akq.run_backtest = _capture
|
|
588
696
|
try:
|
|
589
697
|
_ = run_akquant_backtest(strat_path, cfg_ex) # MACross 走 0.2 __init__ 风格
|
|
590
|
-
assert 'strategy_params' not in captured, (
|
|
591
|
-
f'MACross 不应注入 exclude_symbols: {captured.get("
|
|
698
|
+
assert 'exclude_symbols' not in captured and 'strategy_params' not in captured, (
|
|
699
|
+
f'MACross 不应注入 exclude_symbols: {captured.get("exclude_symbols")!r}')
|
|
592
700
|
finally:
|
|
593
701
|
_akq.run_backtest = _orig_run
|
|
594
702
|
_uses_excl = _load_akquant_strategy(strat4)
|
|
@@ -598,6 +706,45 @@ class UsesExclude(Strategy):
|
|
|
598
706
|
'MACross (无 exclude_symbols 字段) 应 False')
|
|
599
707
|
print('OK: exclude_symbols 注入 (仅声明注入 + 归一化带后缀 + 未声明跳过)')
|
|
600
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 预校验 + 未知字段拦在启动前)')
|
|
747
|
+
|
|
601
748
|
# ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
|
|
602
749
|
# 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
|
|
603
750
|
# (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)
|
{hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.2}/hamuna_quant_cli/akquant_schema_adapter.py
RENAMED
|
@@ -52,6 +52,7 @@ Ponytail ceiling: 不实现 benchmark alpha/beta/IR 单独导出.
|
|
|
52
52
|
from __future__ import annotations
|
|
53
53
|
|
|
54
54
|
import math
|
|
55
|
+
from pathlib import Path
|
|
55
56
|
from typing import Any
|
|
56
57
|
|
|
57
58
|
import numpy as np
|
|
@@ -281,20 +282,102 @@ def normalize_symbol(symbol: str) -> str:
|
|
|
281
282
|
"""6 位裸码 → 交易所带后缀形态 (bundle stockCode '600000.SH').
|
|
282
283
|
|
|
283
284
|
幂等: 已带 .SH/.SZ/.BJ 后缀 / SH600000 前缀形态 → 原样返回.
|
|
284
|
-
与 __main__._normalize_to_full
|
|
285
|
+
与 __main__._normalize_to_full 同映射.
|
|
286
|
+
|
|
287
|
+
用户硬约束 (2026-08-29 "统一实盘回测市场代码, 所有股票必须带市场代码避免错误"):
|
|
288
|
+
config/CLI 的 universe / --symbols **必须带 .SH/.SZ/.BJ 后缀** (e.g. "600000.SH"),
|
|
289
|
+
禁止裸 6 位码。裸码虽然兼容 (走探测/静态回退), 但**发告警** — 探测靠本地 prebuilt
|
|
290
|
+
bundle, 无 bundle 或静态回退对 51/15/88 等会误判交易所 → 静默少标的 / 归错市场.
|
|
291
|
+
新写策略/配置一律带后缀, 别依赖补全.
|
|
292
|
+
|
|
293
|
+
映射优先级 (2026-08-27 真 bug 修复):
|
|
294
|
+
1) 数据驱动: 探本地 prebuilt bundle (__bundle__etf_D / __bundle__all_a_D 等),
|
|
295
|
+
code.SH / code.SZ / code.BJ 哪个实际存在就用哪个. 静态规则曾把 510050
|
|
296
|
+
(沪市 ETF, bundle 里是 510050.SH) 归成 .SZ → 血亏: 回测静默少 1 标的.
|
|
297
|
+
2) 探不到 → 静态回退: 60/68/11/13/51/58/56 → SH, 15/16 → SZ, 8/4 → BJ
|
|
298
|
+
(2026-08-29 补 ETF/北交所前缀, 关死裸码误判窗口).
|
|
285
299
|
"""
|
|
286
300
|
s = symbol.strip().upper()
|
|
287
301
|
if s.endswith(('.SH', '.SZ', '.BJ')):
|
|
288
302
|
return symbol
|
|
289
303
|
if len(s) > 2 and s[:2] in ('SH', 'SZ', 'BJ'):
|
|
290
|
-
s = s[2:]
|
|
304
|
+
s = s[2:] # 前缀形态 (SH600000) 也视为已带市场代码 — 先剥, 不告警
|
|
291
305
|
if len(s) != 6 or not s.isdigit():
|
|
292
306
|
return symbol
|
|
293
|
-
|
|
307
|
+
import warnings as _w
|
|
308
|
+
if not _has_market_code_prefix(symbol):
|
|
309
|
+
_w.warn(
|
|
310
|
+
f"裸码 {symbol!r} 未带市场代码 — 探测本地 bundle 补后缀, 可能误判. "
|
|
311
|
+
"用户规范: 所有股票必须带 .SH/.SZ/.BJ (e.g. '600000.SH'), 别依赖补全.",
|
|
312
|
+
stacklevel=2,
|
|
313
|
+
)
|
|
314
|
+
probed = _probe_exchange_suffix(s)
|
|
315
|
+
if probed:
|
|
316
|
+
return f'{s}{probed}'
|
|
317
|
+
if s.startswith(('60', '68', '11', '13', '51', '58', '56')):
|
|
294
318
|
return f'{s}.SH'
|
|
319
|
+
if s.startswith(('15', '16')):
|
|
320
|
+
return f'{s}.SZ'
|
|
321
|
+
if s.startswith(('43', '83', '87', '88', '889', '8', '4', '92')):
|
|
322
|
+
return f'{s}.BJ'
|
|
295
323
|
return f'{s}.SZ'
|
|
296
324
|
|
|
297
325
|
|
|
326
|
+
def _has_market_code_prefix(symbol: str) -> bool:
|
|
327
|
+
"""原始输入是否已带市场代码 (后缀 .SH/.SZ/.BJ 或前缀 SH600000).
|
|
328
|
+
|
|
329
|
+
决定 normalize_symbol 是否发裸码告警 — 前缀形态也是合法写法, 不告警.
|
|
330
|
+
"""
|
|
331
|
+
s = (symbol or '').strip().upper()
|
|
332
|
+
if not s:
|
|
333
|
+
return False
|
|
334
|
+
if s.endswith(('.SH', '.SZ', '.BJ')):
|
|
335
|
+
return True
|
|
336
|
+
return len(s) > 2 and s[:2] in ('SH', 'SZ', 'BJ')
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
_CACHE_ROOT: str | None = None # 缓存目录 (测试可注入)
|
|
340
|
+
_BUNDLE_SUFFIX_BY_CODE: dict[str, str] | None = None # {裸码: '.SH'} session 级一次性缓存
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _probe_exchange_suffix(code: str) -> str | None:
|
|
344
|
+
"""查本地 prebuilt bundle 里 code 实际挂哪个交易所后缀. 无 bundle / 无命中 → None.
|
|
345
|
+
|
|
346
|
+
性能: 每个 bundle 只读一次 stockCode 列 (session 级 _BUNDLE_SUFFIX_BY_CODE 缓存),
|
|
347
|
+
config 解析 5004 syms 也就扫一趟, 不会 5004 次读盘.
|
|
348
|
+
"""
|
|
349
|
+
global _BUNDLE_SUFFIX_BY_CODE
|
|
350
|
+
if _BUNDLE_SUFFIX_BY_CODE is None:
|
|
351
|
+
_BUNDLE_SUFFIX_BY_CODE = _scan_bundle_suffixes()
|
|
352
|
+
return _BUNDLE_SUFFIX_BY_CODE.get(code)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _scan_bundle_suffixes() -> dict[str, str]:
|
|
356
|
+
"""扫所有 __bundle__*D* parquet 的 stockCode 列, 建 {裸码: 后缀} 全表."""
|
|
357
|
+
root = _CACHE_ROOT or (Path.home() / '.hamuna' / 'data_cache' / 'datasets')
|
|
358
|
+
out: dict[str, str] = {}
|
|
359
|
+
try:
|
|
360
|
+
bundles = sorted(root.glob('__bundle__*'))
|
|
361
|
+
except Exception:
|
|
362
|
+
return out
|
|
363
|
+
for bundle in bundles:
|
|
364
|
+
if not bundle.name.endswith('.parquet'):
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
import pyarrow.parquet as pq
|
|
368
|
+
f = pq.ParquetFile(bundle)
|
|
369
|
+
if 'stockCode' not in f.schema.names:
|
|
370
|
+
continue
|
|
371
|
+
codes = f.read(columns=['stockCode']).column('stockCode').to_pylist()
|
|
372
|
+
for code in codes:
|
|
373
|
+
code, dot, suf = code.partition('.')
|
|
374
|
+
if dot and code not in out:
|
|
375
|
+
out[code] = '.' + suf
|
|
376
|
+
except Exception:
|
|
377
|
+
continue
|
|
378
|
+
return out
|
|
379
|
+
|
|
380
|
+
|
|
298
381
|
def _cfg_universe(cfg: dict) -> list[str]:
|
|
299
382
|
"""cfg['pool'] / cfg['universe'] → 展开标的代码 list.
|
|
300
383
|
|
|
@@ -336,15 +419,32 @@ def _extra(akquant_r: Any) -> dict:
|
|
|
336
419
|
if isinstance(v, pd.DataFrame):
|
|
337
420
|
try:
|
|
338
421
|
# 限定大小, 避免 params 膨胀
|
|
339
|
-
v = v.head(200)
|
|
340
|
-
out[name] = v.to_dict(orient='records')
|
|
422
|
+
v = v.head(200).to_dict(orient='records')
|
|
341
423
|
except Exception: # noqa: BLE001
|
|
342
424
|
pass
|
|
343
|
-
|
|
344
|
-
|
|
425
|
+
if isinstance(v, (list, dict)):
|
|
426
|
+
# 多分支都过 NaN 清理 —— orders_df 常是 list[dict], 不走 DataFrame 分支
|
|
427
|
+
out[name] = _json_safe(v)
|
|
345
428
|
return out
|
|
346
429
|
|
|
347
430
|
|
|
431
|
+
def _json_safe(o: Any) -> Any:
|
|
432
|
+
"""递归把 NaN/Inf 归一成 None — JSON 标准不允许 NaN/Inf 字面量.
|
|
433
|
+
|
|
434
|
+
场景: akquant orders_df 等原始曲面的 limit_price/stop_price 常见 NaN
|
|
435
|
+
(市价单/未触发单). 直接进 result 会让后端 json.loads 崩.
|
|
436
|
+
Round 18.16 真数据验证抓到的真实泄漏 (Round 3 NaN 待办真载体).
|
|
437
|
+
"""
|
|
438
|
+
import math
|
|
439
|
+
if isinstance(o, float):
|
|
440
|
+
return None if (math.isnan(o) or math.isinf(o)) else o
|
|
441
|
+
if isinstance(o, dict):
|
|
442
|
+
return {k: _json_safe(v) for k, v in o.items()}
|
|
443
|
+
if isinstance(o, list):
|
|
444
|
+
return [_json_safe(x) for x in o]
|
|
445
|
+
return o
|
|
446
|
+
|
|
447
|
+
|
|
348
448
|
def _monthly_bars(nav_curve: list[dict]) -> list[dict]:
|
|
349
449
|
"""委托 `hamuna_quant_cli.references._metrics_15.monthly_bars` (R-metrics-dedup)."""
|
|
350
450
|
from ._metrics_15 import monthly_bars as _m15_monthly_bars
|
|
@@ -406,6 +506,9 @@ def _selfcheck() -> None:
|
|
|
406
506
|
metrics = _MockMetrics()
|
|
407
507
|
trades_df = _MockTradesDF()._df
|
|
408
508
|
equity_curve = _MockEquityCurve()
|
|
509
|
+
# NaN 泄漏回归: orders_df 的 limit_price 常态 NaN (市价单), 必须被 _json_safe 清掉
|
|
510
|
+
orders_df = [{'symbol': '600000.SH', 'limit_price': float('nan'),
|
|
511
|
+
'stop_price': float('nan'), 'filled': 100}]
|
|
409
512
|
|
|
410
513
|
cfg = {
|
|
411
514
|
'backtest_start': '20240101',
|
|
@@ -436,7 +539,13 @@ def _selfcheck() -> None:
|
|
|
436
539
|
# initial_capital / final_capital
|
|
437
540
|
assert result['initial_capital'] == 100_000.0
|
|
438
541
|
assert result['final_capital'] == 1_050_000.0
|
|
439
|
-
|
|
542
|
+
# NaN 泄漏回归 (Round 18.16): _akquant_extra 内 NaN/Inf 必须清理为 None
|
|
543
|
+
odf = result['params']['_akquant_extra']['orders_df']
|
|
544
|
+
assert odf[0]['limit_price'] is None, f'limit_price 应 None, got {odf[0]["limit_price"]!r}'
|
|
545
|
+
assert odf[0]['stop_price'] is None
|
|
546
|
+
import json
|
|
547
|
+
json.dumps(result) # strict JSON 序列化 — NaN 会 raise ValueError
|
|
548
|
+
print('OK: akquant_schema_adapter 15 metrics + 13 顶层 key + 7 trades fields + NaN 清理 全对齐')
|
|
440
549
|
|
|
441
550
|
|
|
442
551
|
if __name__ == '__main__':
|