hamuna-quant-cli 0.1.1__tar.gz → 0.1.6__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.6}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/__init__.py +1 -3
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/__main__.py +73 -56
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_data_adapter.py +13 -9
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_runner.py +171 -22
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_schema_adapter.py +117 -8
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/qmt_broker.py +100 -40
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/qmt_market.py +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/runner.py +120 -10
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/prebuilt_downloader.py +31 -10
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/prebuilt_resolver.py +66 -27
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/qmt_translator.py +3 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/backtest.py +2 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/discipline.py +320 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/server_client.py +26 -72
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -2
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/pyproject.toml +1 -1
- hamuna_quant_cli-0.1.1/tests/test_skeleton.py +0 -154
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/README.md +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/tests/test_live_safety.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.6"
|
|
@@ -7,7 +7,7 @@ Usage:
|
|
|
7
7
|
hamuna_quant_cli create --name ... --config ...
|
|
8
8
|
hamuna_quant_cli parity [--strategies ...]
|
|
9
9
|
hamuna_quant_cli qmt-translate <strategy.py> <cfg.json>
|
|
10
|
-
hamuna_quant_cli commit <strategy_id> --strategy ... --result ...
|
|
10
|
+
hamuna_quant_cli commit <strategy_id> --strategy ... --result ... --config ... --params ...
|
|
11
11
|
hamuna_quant_cli dataset {list|fetch|manifest}
|
|
12
12
|
hamuna_quant_cli live run <strategy.py> --mode paper --broker qmt ...
|
|
13
13
|
|
|
@@ -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
|
|
@@ -250,14 +288,20 @@ def cmd_qmt_translate(args) -> int:
|
|
|
250
288
|
print(f"QMT body saved → {out}", file=sys.stderr)
|
|
251
289
|
import json
|
|
252
290
|
print(json.dumps({"qmt_body_path": str(out), "note":
|
|
253
|
-
"本地 stub
|
|
291
|
+
"本地 stub — Round 18.42 起 cloud 不再渲染 .qmt.py, 实盘由 desktop 落盘原始 .py → bridge_server 执行"},
|
|
254
292
|
ensure_ascii=False))
|
|
255
293
|
return 0
|
|
256
294
|
|
|
257
295
|
|
|
258
296
|
def cmd_commit(args) -> int:
|
|
259
|
-
"""commiter
|
|
260
|
-
|
|
297
|
+
"""commiter 3 步打包上传 (bundle + PUT result + POST code).
|
|
298
|
+
|
|
299
|
+
Round 18.42: POST /strategies/:id/export 端点已废, 不再生成 .qmt.py shell —
|
|
300
|
+
server 仅存策略源码. QMT 实盘由 desktop deploy_strategy 落盘原始 .py →
|
|
301
|
+
bridge_server 扫描执行; hamuna-quant-cli 下单走 bridge_server HTTP.
|
|
302
|
+
bundle = 4 文件 (strategy + result + config + params). multipart params 字段
|
|
303
|
+
= 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
|
|
304
|
+
"""
|
|
261
305
|
from . import runtime
|
|
262
306
|
import json
|
|
263
307
|
import tarfile
|
|
@@ -276,35 +320,26 @@ def cmd_commit(args) -> int:
|
|
|
276
320
|
if cfg_path and not cfg_path.exists():
|
|
277
321
|
print(f"config.json 不存在: {cfg_path}", file=sys.stderr)
|
|
278
322
|
return 2
|
|
279
|
-
|
|
280
|
-
if
|
|
281
|
-
print(f"
|
|
282
|
-
|
|
323
|
+
params_path = Path(args.params) if args.params else None
|
|
324
|
+
if params_path and not params_path.exists():
|
|
325
|
+
print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
|
|
326
|
+
return 2
|
|
283
327
|
|
|
284
328
|
bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
|
|
285
329
|
bundle_out.parent.mkdir(parents=True, exist_ok=True)
|
|
286
330
|
|
|
287
|
-
print("=== Step 1/
|
|
288
|
-
try:
|
|
289
|
-
qmt_body_path = write_qmt_body(strategy_path, cfg_path, spec_path)
|
|
290
|
-
except NotImplementedError as e:
|
|
291
|
-
print(f" ✗ 不支持: {e}", file=sys.stderr)
|
|
292
|
-
return 3
|
|
293
|
-
print(f" ✓ qmt_body = {qmt_body_path}", file=sys.stderr)
|
|
294
|
-
|
|
295
|
-
print("=== Step 2/5: 打包 bundle (strategy + qmt_body + metrics + spec) ===",
|
|
331
|
+
print("=== Step 1/3: 打包 bundle (strategy + result + config + params) ===",
|
|
296
332
|
file=sys.stderr)
|
|
297
333
|
with tarfile.open(bundle_out, "w:gz") as tar:
|
|
298
334
|
tar.add(strategy_path, arcname=strategy_path.name)
|
|
299
|
-
tar.add(qmt_body_path, arcname=qmt_body_path.name)
|
|
300
335
|
tar.add(result_path, arcname=result_path.name)
|
|
301
|
-
if spec_path:
|
|
302
|
-
tar.add(spec_path, arcname=spec_path.name)
|
|
303
336
|
if cfg_path:
|
|
304
337
|
tar.add(cfg_path, arcname=cfg_path.name)
|
|
338
|
+
if params_path:
|
|
339
|
+
tar.add(params_path, arcname=params_path.name)
|
|
305
340
|
print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
|
|
306
341
|
|
|
307
|
-
print("=== Step 3
|
|
342
|
+
print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
|
|
308
343
|
try:
|
|
309
344
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
310
345
|
except json.JSONDecodeError as e:
|
|
@@ -317,47 +352,27 @@ def cmd_commit(args) -> int:
|
|
|
317
352
|
return 4
|
|
318
353
|
print(f" ✓ metrics uploaded (resp keys: {sorted(resp.keys())[:5]})", file=sys.stderr)
|
|
319
354
|
|
|
320
|
-
print("=== Step
|
|
355
|
+
print("=== Step 3/3: POST /strategies/:id/code (multipart body + params) ===",
|
|
321
356
|
file=sys.stderr)
|
|
322
|
-
params: dict =
|
|
323
|
-
if
|
|
324
|
-
params
|
|
325
|
-
if cfg_path:
|
|
326
|
-
params["config"] = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
357
|
+
params: dict | None = None
|
|
358
|
+
if params_path:
|
|
359
|
+
params = json.loads(params_path.read_text(encoding="utf-8"))
|
|
327
360
|
try:
|
|
328
|
-
resp_code = runtime.server_client.upload_strategy_code(sid, str(
|
|
329
|
-
params=params
|
|
361
|
+
resp_code = runtime.server_client.upload_strategy_code(sid, str(strategy_path),
|
|
362
|
+
params=params)
|
|
330
363
|
except runtime.server_client.ServerError as e:
|
|
331
364
|
print(f" ✗ 上传源码失败: {e}", file=sys.stderr)
|
|
332
365
|
return 4
|
|
333
366
|
print(f" ✓ body uploaded (body_hash={resp_code.get('body_hash', '?')[:16]})",
|
|
334
367
|
file=sys.stderr)
|
|
335
368
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
print("=== Step 5/5: POST /strategies/:id/export (拉 .qmt.py shell) ===",
|
|
339
|
-
file=sys.stderr)
|
|
340
|
-
export_out = Path(args.export_out) if args.export_out else Path(
|
|
341
|
-
f"runs/{sid}/{sid}.qmt.py"
|
|
342
|
-
)
|
|
343
|
-
export_out.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
-
try:
|
|
345
|
-
resp_exp = runtime.server_client.export_strategy(sid, str(export_out))
|
|
346
|
-
except runtime.server_client.ServerError as e:
|
|
347
|
-
print(f" ✗ QMT export 失败: {e}", file=sys.stderr)
|
|
348
|
-
return 4
|
|
349
|
-
export_path = str(export_out)
|
|
350
|
-
print(f" ✓ shell saved → {export_path} "
|
|
351
|
-
f"({resp_exp.get('shell_bytes', '?')} bytes)", file=sys.stderr)
|
|
352
|
-
else:
|
|
353
|
-
print("=== Step 5/5: SKIPPED (--skip-export) ===", file=sys.stderr)
|
|
369
|
+
# Round 18.42: step 4 (POST /strategies/:id/export) 已废. QMT shell 不再
|
|
370
|
+
# server 渲染, 无 export_path 产出. 实盘落盘由 desktop 负责.
|
|
354
371
|
|
|
355
372
|
print('', file=sys.stderr)
|
|
356
373
|
print(json.dumps({
|
|
357
374
|
"strategy_id": sid,
|
|
358
375
|
"bundle_path": str(bundle_out),
|
|
359
|
-
"qmt_body_path": str(qmt_body_path),
|
|
360
|
-
"export_path": export_path,
|
|
361
376
|
"verdict": "OK",
|
|
362
377
|
}, ensure_ascii=False, indent=2))
|
|
363
378
|
return 0
|
|
@@ -799,8 +814,9 @@ def cmd_live_run(args) -> int:
|
|
|
799
814
|
print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
|
|
800
815
|
return 3
|
|
801
816
|
try:
|
|
817
|
+
import json as _json
|
|
802
818
|
symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
|
|
803
|
-
params =
|
|
819
|
+
params = _json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
|
|
804
820
|
# 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
|
|
805
821
|
sp = (params or {}).get("strategy_params")
|
|
806
822
|
run_live(
|
|
@@ -842,6 +858,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
842
858
|
r.add_argument("--config", required=True, help="CONFIG JSON 路径")
|
|
843
859
|
r.add_argument("--output", help="result 落盘 JSON 路径 (默认 stdout)")
|
|
844
860
|
r.add_argument("--dataset", help="固化 manifest JSON 路径")
|
|
861
|
+
r.add_argument("--params", default=None,
|
|
862
|
+
help="params.json 路径 (固定 schema; strategy_params 段作策略构造参数, "
|
|
863
|
+
"与 `live run --params` 同源 — 回测/实盘参数一致)")
|
|
845
864
|
r.add_argument("--skip-discipline", action="store_true",
|
|
846
865
|
help="跳过 akquant API 静态审查 (qa / 旧策略兼容)")
|
|
847
866
|
r.add_argument("--upload", action="store_true",
|
|
@@ -886,16 +905,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
886
905
|
qt.add_argument("--output", help="QMT body 落盘路径")
|
|
887
906
|
qt.set_defaults(func=cmd_qmt_translate)
|
|
888
907
|
|
|
889
|
-
cm = sub.add_parser("commit", help="commiter
|
|
908
|
+
cm = sub.add_parser("commit", help="commiter 3 步打包上传 (bundle + PUT result + POST code)")
|
|
890
909
|
cm.add_argument("strategy_id", help="server 端 strategy_id")
|
|
891
910
|
cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
|
|
892
911
|
cm.add_argument("--result", required=True, help="metrics result.json 路径")
|
|
893
912
|
cm.add_argument("--config", help="config.json 路径")
|
|
894
|
-
cm.add_argument("--
|
|
913
|
+
cm.add_argument("--params", default=None,
|
|
914
|
+
help="params.json 路径 (固定 schema; 整个内容作 multipart params 字段, desktop 实盘配置源)")
|
|
895
915
|
cm.add_argument("--bundle-out", help="bundle tar.gz 落盘路径")
|
|
896
|
-
cm.add_argument("--skip-export", action="store_true",
|
|
897
|
-
help="跳过 step 5 (POST /strategies/:id/export)")
|
|
898
|
-
cm.add_argument("--export-out", help="step 5 输出 .qmt.py 路径")
|
|
899
916
|
cm.set_defaults(func=cmd_commit)
|
|
900
917
|
|
|
901
918
|
d = sub.add_parser("dataset", help="构建/列出 prebuilt 数据集")
|
|
@@ -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,48 @@ 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
|
+
# 空 list 时允许 params.json 覆盖 — 否则 --params strategy_params.exclude_symbols
|
|
329
|
+
# 永远被 cfg 空注入跳过, 隔离参数静默失效 (Round 18.35 真回测踩坑).
|
|
330
|
+
if kwargs.get(k):
|
|
331
|
+
continue
|
|
332
|
+
kwargs[k] = v
|
|
224
333
|
|
|
225
334
|
# ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
|
|
226
335
|
# 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
|
|
@@ -509,9 +618,10 @@ class MACross(Strategy):
|
|
|
509
618
|
print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
|
|
510
619
|
|
|
511
620
|
# Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
|
|
512
|
-
# 把 universe 注入
|
|
513
|
-
#
|
|
514
|
-
#
|
|
621
|
+
# 把 universe 注入 (否则 on_start 空订阅 → 0 trades, 高胜率策略实际踩过).
|
|
622
|
+
# Round 18.13: 注入从 strategy_params dict 改为**展开进 kwargs 顶层** —
|
|
623
|
+
# run_backtest 签名无 strategy_params (只有 **kwargs + strict_strategy_params),
|
|
624
|
+
# engine 把未知 key 拼进 strategy(**kwargs). 断言 universe 直接展开.
|
|
515
625
|
import akquant as _akq
|
|
516
626
|
captured: dict = {}
|
|
517
627
|
_orig_run = _akq.run_backtest
|
|
@@ -524,9 +634,9 @@ class MACross(Strategy):
|
|
|
524
634
|
_akq.run_backtest = _capture
|
|
525
635
|
try:
|
|
526
636
|
_ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
|
|
527
|
-
# MACross 不收 universe →
|
|
528
|
-
assert 'strategy_params' not in captured, (
|
|
529
|
-
f'MACross 不应注入
|
|
637
|
+
# MACross 不收 universe → kwargs 顶层不应有 universe / strategy_params
|
|
638
|
+
assert 'universe' not in captured and 'strategy_params' not in captured, (
|
|
639
|
+
f'MACross 不应注入 universe: {captured.get("universe")!r}')
|
|
530
640
|
finally:
|
|
531
641
|
_akq.run_backtest = _orig_run
|
|
532
642
|
captured.clear()
|
|
@@ -543,8 +653,8 @@ class UsesUniverse(Strategy):
|
|
|
543
653
|
pass
|
|
544
654
|
''', encoding='utf-8')
|
|
545
655
|
_ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
|
|
546
|
-
assert captured.get('
|
|
547
|
-
f'UsesUniverse (0.3 ParamModel)
|
|
656
|
+
assert captured.get('universe') == ['600000.SH'], (
|
|
657
|
+
f'UsesUniverse (0.3 ParamModel) 展开注入错: {captured.get("universe")!r}')
|
|
548
658
|
finally:
|
|
549
659
|
_akq.run_backtest = _orig_run
|
|
550
660
|
# 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
|
|
@@ -555,7 +665,7 @@ class UsesUniverse(Strategy):
|
|
|
555
665
|
'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
|
|
556
666
|
assert _strategy_accepts_universe_param(_uses_cls), (
|
|
557
667
|
'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
|
|
558
|
-
print(f'OK: universe 注入路径生效 (0.3 ParamModel →
|
|
668
|
+
print(f'OK: universe 注入路径生效 (0.3 ParamModel → kwargs 展开; 旧 __init__ 风格跳过)')
|
|
559
669
|
|
|
560
670
|
# ===== exclude_symbols 注入 (2026-08-25, 用户实战踩坑): =====
|
|
561
671
|
# 仅当策略声明 `exclude_symbols = ListParam(...)` 才注入; 未声明注入 =
|
|
@@ -576,10 +686,10 @@ class UsesExclude(Strategy):
|
|
|
576
686
|
try:
|
|
577
687
|
cfg_ex = {**cfg, 'exclude_symbols': ['600036']}
|
|
578
688
|
_ = run_akquant_backtest(strat4, cfg_ex)
|
|
579
|
-
assert captured.get('
|
|
580
|
-
'
|
|
581
|
-
|
|
582
|
-
|
|
689
|
+
assert captured.get('universe') == ['600000.SH'] and \
|
|
690
|
+
captured.get('exclude_symbols') == ['600036.SH'], (
|
|
691
|
+
f'UsesExclude 展开注入错 (应归一化带后缀 + 顶层 key): '
|
|
692
|
+
f'u={captured.get("universe")!r} ex={captured.get("exclude_symbols")!r}')
|
|
583
693
|
finally:
|
|
584
694
|
_akq.run_backtest = _orig_run
|
|
585
695
|
# MACross (无 ParamModel) 不应注入 exclude_symbols — strict 模式会崩
|
|
@@ -587,8 +697,8 @@ class UsesExclude(Strategy):
|
|
|
587
697
|
_akq.run_backtest = _capture
|
|
588
698
|
try:
|
|
589
699
|
_ = 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("
|
|
700
|
+
assert 'exclude_symbols' not in captured and 'strategy_params' not in captured, (
|
|
701
|
+
f'MACross 不应注入 exclude_symbols: {captured.get("exclude_symbols")!r}')
|
|
592
702
|
finally:
|
|
593
703
|
_akq.run_backtest = _orig_run
|
|
594
704
|
_uses_excl = _load_akquant_strategy(strat4)
|
|
@@ -598,6 +708,45 @@ class UsesExclude(Strategy):
|
|
|
598
708
|
'MACross (无 exclude_symbols 字段) 应 False')
|
|
599
709
|
print('OK: exclude_symbols 注入 (仅声明注入 + 归一化带后缀 + 未声明跳过)')
|
|
600
710
|
|
|
711
|
+
# ===== Round 18.13: params.json strategy_params 展开注入 (回测 --params) =====
|
|
712
|
+
# cmd_run --params 读 params.json 的 strategy_params 段注入 cfg['strategy_params'],
|
|
713
|
+
# runner 展开进 kwargs 顶层 (红线: 必须 validate_strategy_params 预校验).
|
|
714
|
+
strat5 = Path(tmp) / 'params_poly.py'
|
|
715
|
+
strat5.write_text('''
|
|
716
|
+
from akquant import Strategy, IntParam, ListParam
|
|
717
|
+
|
|
718
|
+
class ParamsPoly(Strategy):
|
|
719
|
+
fast = IntParam(10, ge=2, le=200)
|
|
720
|
+
slow = IntParam(20, ge=3, le=500)
|
|
721
|
+
universe: list = ListParam(default=[])
|
|
722
|
+
def on_bar(self, bar):
|
|
723
|
+
pass
|
|
724
|
+
''', encoding='utf-8')
|
|
725
|
+
captured.clear()
|
|
726
|
+
_akq.run_backtest = _capture
|
|
727
|
+
try:
|
|
728
|
+
cfg_p = {**cfg, 'strategy_params': {'fast': 5, 'slow': 33}}
|
|
729
|
+
_ = run_akquant_backtest(strat5, cfg_p)
|
|
730
|
+
assert captured.get('fast') == 5 and captured.get('slow') == 33, (
|
|
731
|
+
f'params strategy_params 应展开注入 kwargs 顶层: '
|
|
732
|
+
f'fast={captured.get("fast")!r} slow={captured.get("slow")!r}')
|
|
733
|
+
assert captured.get('universe') == ['600000.SH'], (
|
|
734
|
+
f'universe 应继续展开注入: {captured.get("universe")!r}')
|
|
735
|
+
finally:
|
|
736
|
+
_akq.run_backtest = _orig_run
|
|
737
|
+
# 未知字段 → StrategyParamsError 启动前抛 (cmd_run 转 exit 3), 不是 engine 深处 TypeError
|
|
738
|
+
captured.clear()
|
|
739
|
+
_akq.run_backtest = _capture
|
|
740
|
+
try:
|
|
741
|
+
try:
|
|
742
|
+
_ = run_akquant_backtest(strat5, {**cfg_p, 'strategy_params': {'bogus': 1}})
|
|
743
|
+
raise AssertionError('未知字段应被 validate_strategy_params 拦')
|
|
744
|
+
except StrategyParamsError as e:
|
|
745
|
+
assert '校验失败' in str(e), f'应报校验失败: {e}'
|
|
746
|
+
finally:
|
|
747
|
+
_akq.run_backtest = _orig_run
|
|
748
|
+
print('OK: params strategy_params 展开注入 (validate 预校验 + 未知字段拦在启动前)')
|
|
749
|
+
|
|
601
750
|
# ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
|
|
602
751
|
# 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
|
|
603
752
|
# (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)
|