hamuna-quant-cli 0.1.2__tar.gz → 0.1.7__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.2 → hamuna_quant_cli-0.1.7}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/__init__.py +1 -1
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/__main__.py +75 -66
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_runner.py +5 -3
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/qmt_translator.py +3 -2
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/server_client.py +26 -72
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -2
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/pyproject.toml +1 -1
- hamuna_quant_cli-0.1.2/tests/test_skeleton.py +0 -154
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/README.md +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/qmt_broker.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/qmt_market.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/runner.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/backtest.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/discipline.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/tests/test_live_safety.py +0 -0
|
@@ -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
|
|
|
@@ -30,6 +30,37 @@ def _load_config(path: str) -> dict:
|
|
|
30
30
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _load_params_arg(arg: str) -> dict | None:
|
|
34
|
+
"""Round 18.48+: --params 既支持 inline JSON (以 '{' 开头) 又支持文件路径.
|
|
35
|
+
|
|
36
|
+
历史 CLI / 回测脚本传文件路径 (`hamuna_quant_cli run --params ./params.json`).
|
|
37
|
+
desktop Rust 调 live run 时通过 `read_to_string` 把 `<name>.params.json` 读成
|
|
38
|
+
String 后 inline 传过来 (`--params '<json>'`), 旧 cli 把 inline 当文件路径
|
|
39
|
+
直接 `Path.read_text()` → FileNotFoundError (本轮 bug 报告).
|
|
40
|
+
|
|
41
|
+
协议: 以 '{' (允许前导空白) 开头 → inline JSON parse; 否则 → 文件路径 read.
|
|
42
|
+
Caller 决定 None vs raise: cmd_commit 容忍 None + 走 schema warning,
|
|
43
|
+
cmd_live_run 期望非空否则 raise FileNotFoundError.
|
|
44
|
+
"""
|
|
45
|
+
if not arg:
|
|
46
|
+
return None
|
|
47
|
+
stripped = arg.lstrip()
|
|
48
|
+
if stripped.startswith("{"):
|
|
49
|
+
import json as _json
|
|
50
|
+
try:
|
|
51
|
+
return _json.loads(arg)
|
|
52
|
+
except _json.JSONDecodeError as e:
|
|
53
|
+
raise ValueError(f"--params inline JSON 解析失败: {e}") from e
|
|
54
|
+
p = Path(arg)
|
|
55
|
+
if not p.exists():
|
|
56
|
+
raise FileNotFoundError(f"--params 不存在: {arg}")
|
|
57
|
+
import json as _json
|
|
58
|
+
try:
|
|
59
|
+
return _json.loads(p.read_text(encoding="utf-8"))
|
|
60
|
+
except _json.JSONDecodeError as e:
|
|
61
|
+
raise ValueError(f"--params 解析失败: {arg} ({e})") from e
|
|
62
|
+
|
|
63
|
+
|
|
33
64
|
def _parse_symbols(s: str) -> list[str]:
|
|
34
65
|
"""CSV symbols → 裸码 list (A 股 dataset stockCode 不带 .SH/.SZ/.BJ 后缀)."""
|
|
35
66
|
out: list[str] = []
|
|
@@ -72,18 +103,17 @@ def cmd_run(args) -> int:
|
|
|
72
103
|
# --params (params.json 固定 schema) → strategy_params 段注入 cfg,
|
|
73
104
|
# akquant_runner 展开注入 engine kwargs. 与 `live run --params` 同源:
|
|
74
105
|
# 同一份 params.json 驱动回测 + 实盘, 参数约定不再分裂 (Round 18.13).
|
|
106
|
+
# Round 18.48+: 既支持文件路径也支持 inline JSON (见 _load_params_arg).
|
|
75
107
|
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
108
|
try:
|
|
82
|
-
params_doc =
|
|
83
|
-
except
|
|
84
|
-
print(f"--params
|
|
109
|
+
params_doc = _load_params_arg(args.params) or {}
|
|
110
|
+
except FileNotFoundError as e:
|
|
111
|
+
print(f"--params 不存在: {e}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
except ValueError as e:
|
|
114
|
+
print(f"--params 解析失败: {e}", file=sys.stderr)
|
|
85
115
|
return 2
|
|
86
|
-
sp =
|
|
116
|
+
sp = params_doc.get("strategy_params")
|
|
87
117
|
if isinstance(sp, dict):
|
|
88
118
|
cfg["strategy_params"] = sp
|
|
89
119
|
else:
|
|
@@ -91,7 +121,7 @@ def cmd_run(args) -> int:
|
|
|
91
121
|
# Round 18.14: risk_config 段 → cfg['risk_config'] → runner 透传
|
|
92
122
|
# run_backtest(risk_config={"max_position_pct": 0.1}) 原生消费 (账户级风控,
|
|
93
123
|
# 引擎 hasattr 校验 + 未知字段仅 warning). skill 桌面编辑的实盘风控真正生效.
|
|
94
|
-
rc =
|
|
124
|
+
rc = params_doc.get("risk_config")
|
|
95
125
|
if isinstance(rc, dict):
|
|
96
126
|
# 与既有 cfg['risk_config'] 合并 (显式 cfg 优先)
|
|
97
127
|
merged = dict(cfg.get("risk_config") or {})
|
|
@@ -288,14 +318,20 @@ def cmd_qmt_translate(args) -> int:
|
|
|
288
318
|
print(f"QMT body saved → {out}", file=sys.stderr)
|
|
289
319
|
import json
|
|
290
320
|
print(json.dumps({"qmt_body_path": str(out), "note":
|
|
291
|
-
"本地 stub
|
|
321
|
+
"本地 stub — Round 18.42 起 cloud 不再渲染 .qmt.py, 实盘由 desktop 落盘原始 .py → bridge_server 执行"},
|
|
292
322
|
ensure_ascii=False))
|
|
293
323
|
return 0
|
|
294
324
|
|
|
295
325
|
|
|
296
326
|
def cmd_commit(args) -> int:
|
|
297
|
-
"""commiter
|
|
298
|
-
|
|
327
|
+
"""commiter 3 步打包上传 (bundle + PUT result + POST code).
|
|
328
|
+
|
|
329
|
+
Round 18.42: POST /strategies/:id/export 端点已废, 不再生成 .qmt.py shell —
|
|
330
|
+
server 仅存策略源码. QMT 实盘由 desktop deploy_strategy 落盘原始 .py →
|
|
331
|
+
bridge_server 扫描执行; hamuna-quant-cli 下单走 bridge_server HTTP.
|
|
332
|
+
bundle = 4 文件 (strategy + result + config + params). multipart params 字段
|
|
333
|
+
= 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
|
|
334
|
+
"""
|
|
299
335
|
from . import runtime
|
|
300
336
|
import json
|
|
301
337
|
import tarfile
|
|
@@ -314,35 +350,26 @@ def cmd_commit(args) -> int:
|
|
|
314
350
|
if cfg_path and not cfg_path.exists():
|
|
315
351
|
print(f"config.json 不存在: {cfg_path}", file=sys.stderr)
|
|
316
352
|
return 2
|
|
317
|
-
|
|
318
|
-
if
|
|
319
|
-
print(f"
|
|
320
|
-
|
|
353
|
+
params_path = Path(args.params) if args.params else None
|
|
354
|
+
if params_path and not params_path.exists():
|
|
355
|
+
print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
|
|
356
|
+
return 2
|
|
321
357
|
|
|
322
358
|
bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
|
|
323
359
|
bundle_out.parent.mkdir(parents=True, exist_ok=True)
|
|
324
360
|
|
|
325
|
-
print("=== Step 1/
|
|
326
|
-
try:
|
|
327
|
-
qmt_body_path = write_qmt_body(strategy_path, cfg_path, spec_path)
|
|
328
|
-
except NotImplementedError as e:
|
|
329
|
-
print(f" ✗ 不支持: {e}", file=sys.stderr)
|
|
330
|
-
return 3
|
|
331
|
-
print(f" ✓ qmt_body = {qmt_body_path}", file=sys.stderr)
|
|
332
|
-
|
|
333
|
-
print("=== Step 2/5: 打包 bundle (strategy + qmt_body + metrics + spec) ===",
|
|
361
|
+
print("=== Step 1/3: 打包 bundle (strategy + result + config + params) ===",
|
|
334
362
|
file=sys.stderr)
|
|
335
363
|
with tarfile.open(bundle_out, "w:gz") as tar:
|
|
336
364
|
tar.add(strategy_path, arcname=strategy_path.name)
|
|
337
|
-
tar.add(qmt_body_path, arcname=qmt_body_path.name)
|
|
338
365
|
tar.add(result_path, arcname=result_path.name)
|
|
339
|
-
if spec_path:
|
|
340
|
-
tar.add(spec_path, arcname=spec_path.name)
|
|
341
366
|
if cfg_path:
|
|
342
367
|
tar.add(cfg_path, arcname=cfg_path.name)
|
|
368
|
+
if params_path:
|
|
369
|
+
tar.add(params_path, arcname=params_path.name)
|
|
343
370
|
print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
|
|
344
371
|
|
|
345
|
-
print("=== Step 3
|
|
372
|
+
print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
|
|
346
373
|
try:
|
|
347
374
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
348
375
|
except json.JSONDecodeError as e:
|
|
@@ -355,47 +382,27 @@ def cmd_commit(args) -> int:
|
|
|
355
382
|
return 4
|
|
356
383
|
print(f" ✓ metrics uploaded (resp keys: {sorted(resp.keys())[:5]})", file=sys.stderr)
|
|
357
384
|
|
|
358
|
-
print("=== Step
|
|
385
|
+
print("=== Step 3/3: POST /strategies/:id/code (multipart body + params) ===",
|
|
359
386
|
file=sys.stderr)
|
|
360
|
-
params: dict =
|
|
361
|
-
if
|
|
362
|
-
params
|
|
363
|
-
if cfg_path:
|
|
364
|
-
params["config"] = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
387
|
+
params: dict | None = None
|
|
388
|
+
if params_path:
|
|
389
|
+
params = json.loads(params_path.read_text(encoding="utf-8"))
|
|
365
390
|
try:
|
|
366
|
-
resp_code = runtime.server_client.upload_strategy_code(sid, str(
|
|
367
|
-
params=params
|
|
391
|
+
resp_code = runtime.server_client.upload_strategy_code(sid, str(strategy_path),
|
|
392
|
+
params=params)
|
|
368
393
|
except runtime.server_client.ServerError as e:
|
|
369
394
|
print(f" ✗ 上传源码失败: {e}", file=sys.stderr)
|
|
370
395
|
return 4
|
|
371
396
|
print(f" ✓ body uploaded (body_hash={resp_code.get('body_hash', '?')[:16]})",
|
|
372
397
|
file=sys.stderr)
|
|
373
398
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
print("=== Step 5/5: POST /strategies/:id/export (拉 .qmt.py shell) ===",
|
|
377
|
-
file=sys.stderr)
|
|
378
|
-
export_out = Path(args.export_out) if args.export_out else Path(
|
|
379
|
-
f"runs/{sid}/{sid}.qmt.py"
|
|
380
|
-
)
|
|
381
|
-
export_out.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
-
try:
|
|
383
|
-
resp_exp = runtime.server_client.export_strategy(sid, str(export_out))
|
|
384
|
-
except runtime.server_client.ServerError as e:
|
|
385
|
-
print(f" ✗ QMT export 失败: {e}", file=sys.stderr)
|
|
386
|
-
return 4
|
|
387
|
-
export_path = str(export_out)
|
|
388
|
-
print(f" ✓ shell saved → {export_path} "
|
|
389
|
-
f"({resp_exp.get('shell_bytes', '?')} bytes)", file=sys.stderr)
|
|
390
|
-
else:
|
|
391
|
-
print("=== Step 5/5: SKIPPED (--skip-export) ===", file=sys.stderr)
|
|
399
|
+
# Round 18.42: step 4 (POST /strategies/:id/export) 已废. QMT shell 不再
|
|
400
|
+
# server 渲染, 无 export_path 产出. 实盘落盘由 desktop 负责.
|
|
392
401
|
|
|
393
402
|
print('', file=sys.stderr)
|
|
394
403
|
print(json.dumps({
|
|
395
404
|
"strategy_id": sid,
|
|
396
405
|
"bundle_path": str(bundle_out),
|
|
397
|
-
"qmt_body_path": str(qmt_body_path),
|
|
398
|
-
"export_path": export_path,
|
|
399
406
|
"verdict": "OK",
|
|
400
407
|
}, ensure_ascii=False, indent=2))
|
|
401
408
|
return 0
|
|
@@ -837,9 +844,13 @@ def cmd_live_run(args) -> int:
|
|
|
837
844
|
print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
|
|
838
845
|
return 3
|
|
839
846
|
try:
|
|
840
|
-
import json as _json
|
|
841
847
|
symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
|
|
842
|
-
|
|
848
|
+
# Round 18.48+: --params 既支持 inline JSON 也支持文件路径 (desktop rust read_to_string 传 inline)
|
|
849
|
+
try:
|
|
850
|
+
params = _load_params_arg(args.params) if args.params else None
|
|
851
|
+
except (FileNotFoundError, ValueError) as e:
|
|
852
|
+
print(f"[error] --params: {e}", file=sys.stderr)
|
|
853
|
+
return 2
|
|
843
854
|
# 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
|
|
844
855
|
sp = (params or {}).get("strategy_params")
|
|
845
856
|
run_live(
|
|
@@ -928,16 +939,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
928
939
|
qt.add_argument("--output", help="QMT body 落盘路径")
|
|
929
940
|
qt.set_defaults(func=cmd_qmt_translate)
|
|
930
941
|
|
|
931
|
-
cm = sub.add_parser("commit", help="commiter
|
|
942
|
+
cm = sub.add_parser("commit", help="commiter 3 步打包上传 (bundle + PUT result + POST code)")
|
|
932
943
|
cm.add_argument("strategy_id", help="server 端 strategy_id")
|
|
933
944
|
cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
|
|
934
945
|
cm.add_argument("--result", required=True, help="metrics result.json 路径")
|
|
935
946
|
cm.add_argument("--config", help="config.json 路径")
|
|
936
|
-
cm.add_argument("--
|
|
947
|
+
cm.add_argument("--params", default=None,
|
|
948
|
+
help="params.json 路径 (固定 schema; 整个内容作 multipart params 字段, desktop 实盘配置源)")
|
|
937
949
|
cm.add_argument("--bundle-out", help="bundle tar.gz 落盘路径")
|
|
938
|
-
cm.add_argument("--skip-export", action="store_true",
|
|
939
|
-
help="跳过 step 5 (POST /strategies/:id/export)")
|
|
940
|
-
cm.add_argument("--export-out", help="step 5 输出 .qmt.py 路径")
|
|
941
950
|
cm.set_defaults(func=cmd_commit)
|
|
942
951
|
|
|
943
952
|
d = sub.add_parser("dataset", help="构建/列出 prebuilt 数据集")
|
|
@@ -323,9 +323,11 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
|
|
|
323
323
|
for k, v in validated.items():
|
|
324
324
|
if k not in accepted_fields:
|
|
325
325
|
continue
|
|
326
|
-
# 不覆盖 runner 已注入的 universe/exclude_symbols (它们来自 cfg,
|
|
327
|
-
# params.json 的 backtest 段同源; validate 返回的默认 [] 会误覆盖).
|
|
328
|
-
|
|
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):
|
|
329
331
|
continue
|
|
330
332
|
kwargs[k] = v
|
|
331
333
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
设计意图 (per `skills/hamuna-strategy-v2/references/qmt-export.md` §转换):
|
|
4
4
|
- body 用 QMT 原生签名 (`def init(C)` + `def handlebar(C)` + `passorder`/`C.get_market_data_ex`)
|
|
5
5
|
- CLI 端 driver / Context 适配 (v2 时代由 akquant 适配) — 双跑靠 "body 零翻译 + shim 状态代理"
|
|
6
|
-
- 本地 translator **仅产出 audit/对照版 QMT body
|
|
7
|
-
|
|
6
|
+
- 本地 translator **仅产出 audit/对照版 QMT body**. Round 18.42 起 server 端
|
|
7
|
+
`/strategies/:id/export` 端点已删除 — 实盘由 desktop `deploy_strategy` 落盘
|
|
8
|
+
原始 .py → bridge_server 扫描执行, 下单由 hamuna-quant-cli 调 bridge_server.
|
|
8
9
|
|
|
9
10
|
**支持的 API 映射** (8 类, 够覆盖 BuyHold / DualMA / LowVolTopK / CrossSection 主流形态):
|
|
10
11
|
- `self.subscribe(sym)` → `C.subscribe(sym)`
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""strategy_cli.runtime.server_client (hamuna-strategy-v2): CLI → server 上传 (backtest result).
|
|
2
2
|
|
|
3
3
|
v2 精简底座: 仅保留 _api_call + upload_backtest_result + create_strategy;
|
|
4
|
-
扩展部分 (commiter
|
|
4
|
+
扩展部分 (commiter 3 步打包上传需要):
|
|
5
5
|
- upload_strategy_code POST /strategies/{id}/code (multipart body + params)
|
|
6
|
-
- export_strategy POST /strategies/{id}/export (拉 server 端 .qmt.py shell)
|
|
7
6
|
|
|
8
|
-
不进 server_client 的 (不在 skill 端做): pyarmor /
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
不进 server_client 的 (不在 skill 端做): pyarmor / QMT shell 渲染 — 这些都跑在
|
|
8
|
+
server 端 (ADR-0025), cloud 是 single source of truth. 本地仅做 multipart 拼 +
|
|
9
|
+
提交, 不碰加密 / 不读 shell. Round 18.40: 无 embedded API key / 无 cert 签 —
|
|
10
|
+
策略体明文上传 + 服务器 inline 渲染 (base64 in template). Round 18.42 起: server
|
|
11
|
+
不再生成 QMT shell (.qmt.py), desktop 落盘原始 .py → bridge_server 执行 →
|
|
12
|
+
hamuna-quant-cli 调 bridge_server 下单; commiter 4 步 → 3 步 (去掉 export).
|
|
11
13
|
|
|
12
14
|
底层鉴权 / envelope 解构 / NaN 清洗与 v1 完全一致 (wholesale copy → 极简裁剪, 见 v1
|
|
13
15
|
server_client.py git blame); 服务端契约 backend/internal/model/model.go::BacktestResult
|
|
@@ -83,9 +85,10 @@ def upload_backtest_result(strategy_id: str, result: dict) -> dict:
|
|
|
83
85
|
def create_strategy(name: str, code: str = '', params: dict | None = None) -> dict:
|
|
84
86
|
"""POST /api/v1/strategies → 新建 strategy (供 _upload_after_run --name auto-create 用).
|
|
85
87
|
|
|
86
|
-
name 必填 (id 一致性锚点, 与 v1 server_client 同); code 可选 (
|
|
87
|
-
|
|
88
|
-
BacktestResult.params 落库). 返 server DataValue
|
|
88
|
+
name 必填 (id 一致性锚点, 与 v1 server_client 同); code 可选 (Round 18.41+
|
|
89
|
+
后端落 body 字段 UTF-8 verbatim, 无 envelope, v2 --upload --code 路径才填);
|
|
90
|
+
params 默认 {} (作 BacktestResult.params 落库). 返 server DataValue
|
|
91
|
+
(含 _id / id / name).
|
|
89
92
|
"""
|
|
90
93
|
body: dict = {'name': name, 'code': code}
|
|
91
94
|
if params is not None:
|
|
@@ -100,19 +103,21 @@ def upload_strategy_code(
|
|
|
100
103
|
) -> dict:
|
|
101
104
|
"""POST /api/v1/strategies/{id}/code (multipart) → 上传策略源码 + params.
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
Round 18.43+: 明文上传 UTF-8 verbatim, server 存到 `body` 字段. Round 18.42
|
|
107
|
+
的 GBK 编码约定已废 (用户 grill: 「明文上传」字面理解应是 UTF-8, 不应在
|
|
108
|
+
client/server 间夹 GBK 转码; 「明文」指不加密, 编码另说). 老 doc (Round
|
|
109
|
+
18.42 之前上传) 走 CodeObfuscated GBK fallback, server 读时 GBK→UTF-8 解码.
|
|
110
|
+
ADR-0025: multipart 含 `params` 字段 (JSON 字符串), server 落库覆盖
|
|
111
|
+
strategies.params (CONFIG 块渲染源).
|
|
107
112
|
|
|
108
|
-
body bytes = UTF-8 源
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
body bytes = UTF-8 源 verbatim (per `backend/internal/handler/qmt_export.go::storeBody`
|
|
114
|
+
Round 18.43+: "客户端直传 UTF-8 (code.encode('utf-8')), server passthrough
|
|
115
|
+
存到 `body` 字段; 不再做 GBK 转码").
|
|
111
116
|
|
|
112
117
|
返 {body_hash, ok} (per handler::UploadStrategyCode 响应 shape).
|
|
113
118
|
"""
|
|
114
119
|
code = Path(strategy_path).read_text(encoding='utf-8')
|
|
115
|
-
|
|
120
|
+
utf8 = code.encode('utf-8')
|
|
116
121
|
boundary = f'----hamuna-{uuid.uuid4().hex}'
|
|
117
122
|
filename = Path(strategy_path).name
|
|
118
123
|
ctype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
|
@@ -121,7 +126,7 @@ def upload_strategy_code(
|
|
|
121
126
|
(f'--{boundary}\r\n'
|
|
122
127
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
123
128
|
f'Content-Type: {ctype}\r\n\r\n').encode('utf-8'),
|
|
124
|
-
|
|
129
|
+
utf8,
|
|
125
130
|
]
|
|
126
131
|
if params is not None:
|
|
127
132
|
# multipart 表单字段: params = JSON 字符串 (per ADR-0025 + handler::storeBody)
|
|
@@ -196,61 +201,10 @@ def s3_credentials(dataset_id: str, key: str) -> dict:
|
|
|
196
201
|
)
|
|
197
202
|
|
|
198
203
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
API key + cert 签) 全在 server 端. 本函数仅下载 + 落盘, 不读 shell 内容.
|
|
204
|
-
|
|
205
|
-
返 dict: {shell_path, shell_bytes} (shell_path = output_path 或 None).
|
|
206
|
-
"""
|
|
207
|
-
token = _load_token()
|
|
208
|
-
url = f'{_resolve_server()}/api/v1/strategies/{strategy_id}/export'
|
|
209
|
-
req = Request(
|
|
210
|
-
url, data=b'', method='POST',
|
|
211
|
-
headers={
|
|
212
|
-
'Authorization': f'Bearer {token}',
|
|
213
|
-
'Accept': 'application/octet-stream, application/json',
|
|
214
|
-
},
|
|
215
|
-
)
|
|
216
|
-
try:
|
|
217
|
-
with urlopen(req, timeout=60) as resp:
|
|
218
|
-
# server 既可返 raw .qmt.py 也可返 JSON envelope — 读 Content-Type 判
|
|
219
|
-
ctype = resp.headers.get('Content-Type', '')
|
|
220
|
-
raw = resp.read()
|
|
221
|
-
if 'json' in ctype.lower():
|
|
222
|
-
envelope = json.loads(raw.decode('utf-8'))
|
|
223
|
-
if isinstance(envelope, dict) and 'DataValue' in envelope:
|
|
224
|
-
if not envelope.get('Result', False):
|
|
225
|
-
raise ServerError(
|
|
226
|
-
f'QMT export 业务失败: Error={envelope.get("Error")} '
|
|
227
|
-
f'Message={envelope.get("Message")}'
|
|
228
|
-
)
|
|
229
|
-
# DataValue 可能是 base64 编码的 shell 字节
|
|
230
|
-
import base64
|
|
231
|
-
shell_bytes = base64.b64decode(envelope['DataValue'])
|
|
232
|
-
else:
|
|
233
|
-
shell_bytes = raw
|
|
234
|
-
else:
|
|
235
|
-
shell_bytes = raw
|
|
236
|
-
except HTTPError as e:
|
|
237
|
-
resp_body = e.read().decode('utf-8', errors='replace')[:500]
|
|
238
|
-
if e.code == 401:
|
|
239
|
-
raise ServerError(
|
|
240
|
-
f'QMT export 失败: HTTP 401 — API key 无效或已失效'
|
|
241
|
-
) from e
|
|
242
|
-
if e.code == 402:
|
|
243
|
-
raise ServerError(
|
|
244
|
-
f'QMT export 失败: HTTP 402 — 订阅未激活 (subscription gate)'
|
|
245
|
-
) from e
|
|
246
|
-
raise ServerError(
|
|
247
|
-
f'QMT export 失败: HTTP {e.code} {e.reason}; body={resp_body}'
|
|
248
|
-
) from e
|
|
249
|
-
|
|
250
|
-
if output_path:
|
|
251
|
-
Path(output_path).write_bytes(shell_bytes)
|
|
252
|
-
return {'shell_path': str(output_path), 'shell_bytes': len(shell_bytes)}
|
|
253
|
-
return {'shell_path': None, 'shell_bytes': len(shell_bytes)}
|
|
204
|
+
# Round 18.42: export_strategy 删 — /strategies/:id/export 端点已废.
|
|
205
|
+
# QMT shell 渲染整体从 server 撤出: desktop deploy_strategy 落盘原始 .py →
|
|
206
|
+
# bridge_server 扫描执行; 实盘下单走 hamuna-quant-cli → bridge_server HTTP.
|
|
207
|
+
# 如需 audit 渲染, 走 skill 镜像步骤(本仓库不维护 client).
|
|
254
208
|
|
|
255
209
|
|
|
256
210
|
def _sanitize_for_json(obj):
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hamuna-quant-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范."
|
|
9
9
|
readme = "hamuna_quant_cli/README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
"""ADR-0029 §10 验证清单 — Phase 2.3 自检。"""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import sys
|
|
5
|
-
import numpy as np
|
|
6
|
-
|
|
7
|
-
sys.path.insert(0, 'D:/Coding/hamuna-strategy-platform')
|
|
8
|
-
|
|
9
|
-
from strategy_cli.runtime.context import Context
|
|
10
|
-
from strategy_cli.runtime.driver import run_backtest
|
|
11
|
-
from strategy_cli.runtime.score import winsorize, zscore, composite_score
|
|
12
|
-
from strategy_cli.fundamental.data import pool, derived
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
# ---- §10 验证 1: init() 幂等 ----
|
|
16
|
-
_BASE_CFG = {
|
|
17
|
-
'stock': None, 'period': '1d', 'account_type': 'STOCK',
|
|
18
|
-
'init_capital': 1_000_000, 'backtest_start': '20240101', 'backtest_end': '20240131',
|
|
19
|
-
'pool': {'a_share': {'enabled': True, 'sub_universe': 'hs300'},
|
|
20
|
-
'convertible_bond': {'enabled': False}, 'etf': {'enabled': False}},
|
|
21
|
-
'rebalance_freq': 'weekly', 'rebalance_dow': 4, 'rebalance_dom': -1,
|
|
22
|
-
'filters': [], 'factors': [], 'scoring': {'top_n': 5},
|
|
23
|
-
'cost': {'commission_bps': 30, 'stamp_tax_bps': 100, 'min_commission': 5,
|
|
24
|
-
'slippage_bps': 5, 'volume_cap_pct': 0.10},
|
|
25
|
-
'risk': {'single_holding_cap_pct': 0.20, 'sector_cap_pct': 0.30,
|
|
26
|
-
'stop_loss_pct': -0.08, 'max_drawdown_pct': -0.20},
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
_BODY_PATH = 'D:/Coding/hamuna-strategy-platform/examples/strategies/pe_pb_mom_top10.py'
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _load_body(config: dict):
|
|
33
|
-
"""通过 driver 加载 body, 注入 CONFIG。"""
|
|
34
|
-
import importlib.util
|
|
35
|
-
import sys as _sys
|
|
36
|
-
spec = importlib.util.spec_from_file_location('_hamuna_strat', _BODY_PATH)
|
|
37
|
-
if spec is None or spec.loader is None:
|
|
38
|
-
raise ImportError(f'cannot load {_BODY_PATH}')
|
|
39
|
-
mod = importlib.util.module_from_spec(spec)
|
|
40
|
-
_sys.modules['_hamuna_strat'] = mod
|
|
41
|
-
spec.loader.exec_module(mod)
|
|
42
|
-
mod.__dict__['CONFIG'] = config
|
|
43
|
-
return mod
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_init_idempotent():
|
|
47
|
-
cfg = dict(_BASE_CFG)
|
|
48
|
-
C1 = Context(period='1d', start='20240101', end='20240131')
|
|
49
|
-
C1._trading_days = ['20240102', '20240103']
|
|
50
|
-
body = _load_body(cfg)
|
|
51
|
-
body.init(C1)
|
|
52
|
-
snap1 = dict(C1.snapshot)
|
|
53
|
-
|
|
54
|
-
C2 = Context(period='1d', start='20240101', end='20240131')
|
|
55
|
-
C2._trading_days = ['20240102', '20240103']
|
|
56
|
-
body.init(C2)
|
|
57
|
-
snap2 = dict(C2.snapshot)
|
|
58
|
-
|
|
59
|
-
assert snap1 == snap2, 'init() 应幂等'
|
|
60
|
-
assert len(C1.pool_a) == 20, f'池大小异常: {len(C1.pool_a)}'
|
|
61
|
-
print(' [OK] init() 幂等 + 池大小正确')
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# ---- §10 验证 2: 非 rebalance bar 不下单 ----
|
|
65
|
-
def test_handlebar_no_order_on_non_rebalance():
|
|
66
|
-
cfg = dict(_BASE_CFG)
|
|
67
|
-
cfg['rebalance_freq'] = 'monthly'
|
|
68
|
-
C = Context(period='1d', start='20240101', end='20240131')
|
|
69
|
-
C._trading_days = ['20240102', '20240103', '20240131']
|
|
70
|
-
body = _load_body(cfg)
|
|
71
|
-
body.init(C)
|
|
72
|
-
C.cur_bar_idx = 0
|
|
73
|
-
C.cur_date = '20240102'
|
|
74
|
-
pending_before = len(getattr(C, '_pending_orders', []))
|
|
75
|
-
body.handlebar(C)
|
|
76
|
-
pending_after = len(getattr(C, '_pending_orders', []))
|
|
77
|
-
assert pending_after == pending_before, '非 rebalance bar 不应产生 pending_orders'
|
|
78
|
-
print(' [OK] 非 rebalance bar 不下单')
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# ---- §10 验证 3: winsorize + zscore 不抛 ----
|
|
82
|
-
def test_score_no_crash_on_all_nan():
|
|
83
|
-
arr = np.array([np.nan, np.nan, np.nan])
|
|
84
|
-
out = winsorize(arr)
|
|
85
|
-
assert np.isnan(out).all()
|
|
86
|
-
z = zscore(arr)
|
|
87
|
-
assert (z == 0).all(), f'全 NaN zscore 应为 0, got {z}'
|
|
88
|
-
factors = [{'name': 'x', 'direction': 'low', 'weight': 1.0}]
|
|
89
|
-
score = composite_score({'x': arr}, factors)
|
|
90
|
-
assert (score == 0).all()
|
|
91
|
-
print(' [OK] 全 NaN winsorize/zscore 不抛')
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# ---- §10 验证 4: _normalize_weights 上限生效 ----
|
|
95
|
-
def test_normalize_cap():
|
|
96
|
-
"""权重归一化 + cap 截断 + 二次归一化; 测试每阶段值。"""
|
|
97
|
-
from examples.strategies.pe_pb_mom_top10 import _normalize_weights
|
|
98
|
-
top = [('A', 0.5), ('B', 0.3), ('C', 0.2)]
|
|
99
|
-
# cap=0.4: A 截断到 0.4; sum(capped)=0.9; 二次归一化 0.4/0.9=0.444, 0.3/0.9=0.333
|
|
100
|
-
w = _normalize_weights(top, cap_pct=0.4)
|
|
101
|
-
assert abs(w['A'] - 0.444) < 1e-3, w
|
|
102
|
-
assert abs(sum(w.values()) - 1.0) < 1e-9, sum(w.values())
|
|
103
|
-
# cap=0.2: 全部被截断, A=B=C=1/3
|
|
104
|
-
w = _normalize_weights(top, cap_pct=0.2)
|
|
105
|
-
assert abs(w['A'] - 1/3) < 1e-9, w
|
|
106
|
-
assert abs(sum(w.values()) - 1.0) < 1e-9, sum(w.values())
|
|
107
|
-
print(f' [OK] cap 生效 (cap=0.4): A={w["A"]:.3f} B={w["B"]:.3f} C={w["C"]:.3f}')
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
# ---- §10 验证 5: suspended 守门 ----
|
|
111
|
-
def test_suspended_skip():
|
|
112
|
-
cfg = dict(_BASE_CFG)
|
|
113
|
-
C = Context(period='1d', start='20240101', end='20240131')
|
|
114
|
-
C._trading_days = ['20240102', '20240131']
|
|
115
|
-
body = _load_body(cfg)
|
|
116
|
-
body.init(C)
|
|
117
|
-
C.suspended = True
|
|
118
|
-
C.cur_bar_idx = 1
|
|
119
|
-
C.cur_date = '20240131'
|
|
120
|
-
pending_before = len(getattr(C, '_pending_orders', []))
|
|
121
|
-
body.handlebar(C)
|
|
122
|
-
pending_after = len(getattr(C, '_pending_orders', []))
|
|
123
|
-
assert pending_after == pending_before, 'suspended=True 时 handlebar 必须 return'
|
|
124
|
-
print(' [OK] suspended=True handlebar 守门')
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# ---- 端到端: 跑一次完整回测 ----
|
|
128
|
-
def test_e2e_run():
|
|
129
|
-
cfg = dict(_BASE_CFG)
|
|
130
|
-
cfg['filters'] = [
|
|
131
|
-
{'field': 'derived.pe_dynamic', 'op': 'gt', 'value': 0},
|
|
132
|
-
{'field': 'derived.pe_dynamic', 'op': 'lt', 'value': 50},
|
|
133
|
-
]
|
|
134
|
-
cfg['factors'] = [
|
|
135
|
-
{'name': 'pe', 'field': 'derived.pe_dynamic', 'direction': 'low', 'weight': 0.4},
|
|
136
|
-
{'name': 'pb', 'field': 'derived.pb_ratio', 'direction': 'low', 'weight': 0.3},
|
|
137
|
-
{'name': 'mom', 'field': 'market.close_20d_change', 'direction': 'high', 'weight': 0.3},
|
|
138
|
-
]
|
|
139
|
-
result = run_backtest(_BODY_PATH, cfg)
|
|
140
|
-
print(f' [OK] e2e 跑通: trading_days={result["trading_days"]}, final_nav={result["final_nav"]:.0f}')
|
|
141
|
-
assert result['trading_days'] > 0
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if __name__ == '__main__':
|
|
145
|
-
print('ADR-0029 §10 验证清单')
|
|
146
|
-
print('-' * 40)
|
|
147
|
-
test_init_idempotent()
|
|
148
|
-
test_handlebar_no_order_on_non_rebalance()
|
|
149
|
-
test_score_no_crash_on_all_nan()
|
|
150
|
-
test_normalize_cap()
|
|
151
|
-
test_suspended_skip()
|
|
152
|
-
test_e2e_run()
|
|
153
|
-
print('-' * 40)
|
|
154
|
-
print('全部通过 [OK]')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_schema_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/cross_sectional_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|