hamuna-quant-cli 0.1.0.dev93__py3-none-any.whl

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.
@@ -0,0 +1,978 @@
1
+ """hamuna_quant_cli CLI 入口 — 合并回测 + 实盘子命令.
2
+
3
+ Usage:
4
+ hamuna_quant_cli run <strategy.py> --config <cfg.json> --output result.json
5
+ hamuna_quant_cli check <strategy.py> --config <cfg.json>
6
+ hamuna_quant_cli upload <strategy_id> --result result.json
7
+ hamuna_quant_cli create --name ... --config ...
8
+ hamuna_quant_cli parity [--strategies ...]
9
+ hamuna_quant_cli qmt-translate <strategy.py> <cfg.json>
10
+ hamuna_quant_cli commit <strategy_id> --strategy ... --result ...
11
+ hamuna_quant_cli dataset {list|fetch|manifest}
12
+ hamuna_quant_cli live run <strategy.py> --mode paper --broker qmt ...
13
+
14
+ PYTHONPATH: 不需要 (pip install hamuna-quant-cli 后全局可用).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+
24
+
25
+ # ============================================================
26
+ # 回测子命令 (从原 v2 skill `skills/hamuna-strategy-v2/strategy_cli/__main__.py` 搬, runtime/ 已搬进 hamuna_quant_cli.runtime; Round 14 后 v2 skill 不再带代码)
27
+ # ============================================================
28
+ def _load_config(path: str) -> dict:
29
+ import json
30
+ return json.loads(Path(path).read_text(encoding="utf-8"))
31
+
32
+
33
+ def _parse_symbols(s: str) -> list[str]:
34
+ """CSV symbols → 裸码 list (A 股 dataset stockCode 不带 .SH/.SZ/.BJ 后缀)."""
35
+ out: list[str] = []
36
+ for raw in s.split(","):
37
+ x = raw.strip()
38
+ if not x:
39
+ continue
40
+ if x[:2].lower() in ("sh", "sz", "bj"):
41
+ x = x[2:]
42
+ x = x.split(".")[0]
43
+ if x.isdigit() and len(x) == 6:
44
+ out.append(x)
45
+ else:
46
+ print(f" ⚠ 跳过非法 symbol: {raw!r} (期望 6 位数字裸码)", file=sys.stderr)
47
+ return out
48
+
49
+
50
+ def _now_iso() -> str:
51
+ from datetime import datetime, timezone
52
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
53
+
54
+
55
+ def cmd_run(args) -> int:
56
+ """跑 akquant backtest → result.json."""
57
+ from . import runtime
58
+
59
+ cfg = _load_config(args.config)
60
+ strategy_path = Path(args.strategy).resolve()
61
+
62
+ if not getattr(args, "skip_discipline", False):
63
+ source = strategy_path.read_text(encoding="utf-8")
64
+ errs = runtime.discipline.check_discipline(source, cfg)
65
+ if errs:
66
+ for e in errs:
67
+ print(str(e), file=sys.stderr)
68
+ print(f"纪律 self-check 未通过 ({len(errs)} 条); "
69
+ f"用 --skip-discipline 显式跳过", file=sys.stderr)
70
+ return 3
71
+
72
+ if getattr(args, "dataset", None):
73
+ manifest_path = Path(args.dataset)
74
+ if not manifest_path.exists():
75
+ print(f"--dataset manifest 不存在: {args.dataset}", file=sys.stderr)
76
+ return 2
77
+ import json as _json
78
+ try:
79
+ manifest = _json.loads(manifest_path.read_text(encoding="utf-8"))
80
+ except _json.JSONDecodeError as e:
81
+ print(f"manifest 解析失败: {args.dataset} ({e})", file=sys.stderr)
82
+ return 2
83
+ cfg["_dataset_manifest"] = manifest
84
+
85
+ result = runtime.backtest.run(strategy_path, cfg)
86
+
87
+ if args.output:
88
+ import json
89
+ Path(args.output).write_text(
90
+ json.dumps(result, indent=2, ensure_ascii=False, default=str),
91
+ encoding="utf-8",
92
+ )
93
+ print(f"result saved → {args.output}", file=sys.stderr)
94
+ else:
95
+ import json
96
+ print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
97
+
98
+ if getattr(args, "upload", False):
99
+ return _upload_after_run(args, cfg, result)
100
+ return 0
101
+
102
+
103
+ def _upload_after_run(args, cfg: dict, result: dict) -> int:
104
+ from . import runtime
105
+ strategy_id = getattr(args, "strategy_id", None)
106
+ name = getattr(args, "name", None)
107
+ code = getattr(args, "code", None)
108
+ if not strategy_id and name:
109
+ from .runtime.server_client import create_strategy, ServerError
110
+ try:
111
+ code_str = Path(code).read_text(encoding="utf-8") if code else ""
112
+ rec = create_strategy(name=name, code=code_str, params=cfg)
113
+ strategy_id = rec.get("_id") or rec.get("id")
114
+ if not strategy_id:
115
+ raise ServerError(f"create_strategy 返没 _id: {rec}")
116
+ print(f" ✓ 新建 strategy: {name} → _id={strategy_id}", file=sys.stderr)
117
+ except ServerError as e:
118
+ print(f" ✗ create_strategy 失败: {e}", file=sys.stderr)
119
+ return 4
120
+ if not strategy_id:
121
+ print(" ✗ --upload 需 strategy_id (位置) 或 --name (auto-auto-create)",
122
+ file=sys.stderr)
123
+ return 2
124
+ try:
125
+ resp = runtime.server_client.upload_backtest_result(strategy_id, result)
126
+ except runtime.server_client.ServerError as e:
127
+ print(f" ✗ 上传失败: {e}", file=sys.stderr)
128
+ return 4
129
+ import json
130
+ print(json.dumps(resp, indent=2, ensure_ascii=False))
131
+ return 0
132
+
133
+
134
+ def cmd_check(args) -> int:
135
+ from . import runtime
136
+
137
+ source = Path(args.strategy).read_text(encoding="utf-8")
138
+ cfg = _load_config(args.config)
139
+ errs = runtime.discipline.check_discipline(source, cfg)
140
+ if errs:
141
+ for e in errs:
142
+ print(str(e), file=sys.stderr)
143
+ print(f"纪律 self-check 未通过 ({len(errs)} 条)", file=sys.stderr)
144
+ return 3
145
+ print("纪律 self-check 通过 (0 条)", file=sys.stderr)
146
+ return 0
147
+
148
+
149
+ def cmd_upload(args) -> int:
150
+ """PUT 已跑通的 result.json 到 strategy_id (auto-auto-create 若无 strategy_id)."""
151
+ from . import runtime
152
+ import json
153
+
154
+ result_path = Path(args.result)
155
+ if not result_path.exists():
156
+ print(f"本地回测结果不存在: {args.result} — "
157
+ f"请先跑 `hamuna_quant_cli run` 产出 result.json, 再上传", file=sys.stderr)
158
+ return 2
159
+ try:
160
+ result = json.loads(result_path.read_text(encoding="utf-8"))
161
+ except json.JSONDecodeError as e:
162
+ print(f"result.json 解析失败: {args.result} ({e})", file=sys.stderr)
163
+ return 2
164
+
165
+ strategy_id = getattr(args, "strategy_id", None)
166
+ name = getattr(args, "name", None)
167
+ cfg_path = getattr(args, "config", None)
168
+
169
+ if not strategy_id:
170
+ if not name or not cfg_path:
171
+ print("upload 缺 strategy_id: 必填 --name + --config (auto-create) "
172
+ "或 strategy_id (位置) 直接上传", file=sys.stderr)
173
+ return 2
174
+ cfg = _load_config(cfg_path)
175
+ code_str = ""
176
+ if getattr(args, "code", None):
177
+ code_path = Path(args.code)
178
+ if not code_path.exists():
179
+ print(f"--code 文件不存在: {args.code}", file=sys.stderr)
180
+ return 2
181
+ code_str = code_path.read_text(encoding="utf-8")
182
+ try:
183
+ rec = runtime.server_client.create_strategy(name=name, code=code_str, params=cfg)
184
+ except runtime.server_client.ServerError as e:
185
+ print(f"create_strategy 失败: {e}", file=sys.stderr)
186
+ return 4
187
+ strategy_id = rec.get("_id") or rec.get("id")
188
+ if not strategy_id:
189
+ print(f"create_strategy 返没 _id: {rec}", file=sys.stderr)
190
+ return 4
191
+ print(f" ✓ 新建 strategy: {name} → _id={strategy_id}", file=sys.stderr)
192
+
193
+ try:
194
+ resp = runtime.server_client.upload_backtest_result(strategy_id, result)
195
+ except runtime.server_client.ServerError as e:
196
+ print(f"上传失败: {e}", file=sys.stderr)
197
+ return 4
198
+ print(json.dumps(resp, indent=2, ensure_ascii=False))
199
+ return 0
200
+
201
+
202
+ def cmd_create(args) -> int:
203
+ """新建 strategy 拿 strategy_id."""
204
+ from . import runtime
205
+ import json
206
+
207
+ name = args.name
208
+ cfg = _load_config(args.config)
209
+ code_str = ""
210
+ if getattr(args, "code", None):
211
+ code_path = Path(args.code)
212
+ if not code_path.exists():
213
+ print(f"--code 文件不存在: {args.code}", file=sys.stderr)
214
+ return 2
215
+ code_str = code_path.read_text(encoding="utf-8")
216
+
217
+ try:
218
+ rec = runtime.server_client.create_strategy(name=name, code=code_str, params=cfg)
219
+ except runtime.server_client.ServerError as e:
220
+ print(f"create_strategy 失败: {e}", file=sys.stderr)
221
+ return 4
222
+
223
+ sid = rec.get("_id") or rec.get("id")
224
+ if not sid:
225
+ print(f"create_strategy 返没 _id: {rec}", file=sys.stderr)
226
+ return 4
227
+
228
+ print(json.dumps(rec, indent=2, ensure_ascii=False))
229
+ print(f"\n → strategy_id={sid}", file=sys.stderr)
230
+ print(f" → 用此 id 跑: "
231
+ f"hamuna_quant_cli upload {sid} --result result.json",
232
+ file=sys.stderr)
233
+ return 0
234
+
235
+
236
+ def cmd_qmt_translate(args) -> int:
237
+ """akquant Strategy → QMT body 翻译 (本地 stub; 真值由 cloud render)."""
238
+ from .qmt_translator import write_qmt_body
239
+
240
+ spec_arg = getattr(args, "spec", None)
241
+ output = getattr(args, "output", None)
242
+ try:
243
+ out = write_qmt_body(args.strategy, args.config, spec_arg, output)
244
+ except FileNotFoundError as e:
245
+ print(f"输入文件不存在: {e}", file=sys.stderr)
246
+ return 2
247
+ except NotImplementedError as e:
248
+ print(f"QMT 转换不支持 (本地 stub 范围): {e}", file=sys.stderr)
249
+ return 3
250
+ print(f"QMT body saved → {out}", file=sys.stderr)
251
+ import json
252
+ print(json.dumps({"qmt_body_path": str(out), "note":
253
+ "本地 stub; cloud export 端点会重新渲染 + 签 cert + 嵌入 embedded API key"},
254
+ ensure_ascii=False))
255
+ return 0
256
+
257
+
258
+ def cmd_commit(args) -> int:
259
+ """commiter 5 步打包上传 (qmt-translate + bundle + PUT result + POST code + POST export)."""
260
+ from .qmt_translator import write_qmt_body
261
+ from . import runtime
262
+ import json
263
+ import tarfile
264
+
265
+ sid = args.strategy_id
266
+
267
+ result_path = Path(args.result)
268
+ if not result_path.exists():
269
+ print(f"result.json 不存在: {args.result}", file=sys.stderr)
270
+ return 2
271
+ strategy_path = Path(args.strategy) if args.strategy else Path(f"{sid}.py")
272
+ if not strategy_path.exists():
273
+ print(f"strategy .py 不存在: {strategy_path}", file=sys.stderr)
274
+ return 2
275
+ cfg_path = Path(args.config) if args.config else None
276
+ if cfg_path and not cfg_path.exists():
277
+ print(f"config.json 不存在: {cfg_path}", file=sys.stderr)
278
+ return 2
279
+ spec_path = Path(args.spec) if args.spec else None
280
+ if spec_path and not spec_path.exists():
281
+ print(f"spec_strategy.json 不存在: {spec_path} (--spec 可选)", file=sys.stderr)
282
+ spec_path = None
283
+
284
+ bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
285
+ bundle_out.parent.mkdir(parents=True, exist_ok=True)
286
+
287
+ print("=== Step 1/5: akquant → QMT body 翻译 (本地 stub) ===", file=sys.stderr)
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) ===",
296
+ file=sys.stderr)
297
+ with tarfile.open(bundle_out, "w:gz") as tar:
298
+ tar.add(strategy_path, arcname=strategy_path.name)
299
+ tar.add(qmt_body_path, arcname=qmt_body_path.name)
300
+ tar.add(result_path, arcname=result_path.name)
301
+ if spec_path:
302
+ tar.add(spec_path, arcname=spec_path.name)
303
+ if cfg_path:
304
+ tar.add(cfg_path, arcname=cfg_path.name)
305
+ print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
306
+
307
+ print("=== Step 3/5: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
308
+ try:
309
+ result = json.loads(result_path.read_text(encoding="utf-8"))
310
+ except json.JSONDecodeError as e:
311
+ print(f" ✗ result.json 解析失败: {e}", file=sys.stderr)
312
+ return 2
313
+ try:
314
+ resp = runtime.server_client.upload_backtest_result(sid, result)
315
+ except runtime.server_client.ServerError as e:
316
+ print(f" ✗ 上传 metrics 失败: {e}", file=sys.stderr)
317
+ return 4
318
+ print(f" ✓ metrics uploaded (resp keys: {sorted(resp.keys())[:5]})", file=sys.stderr)
319
+
320
+ print("=== Step 4/5: POST /strategies/:id/code (multipart body + params) ===",
321
+ file=sys.stderr)
322
+ params: dict = {}
323
+ if spec_path:
324
+ params["spec_strategy"] = json.loads(spec_path.read_text(encoding="utf-8"))
325
+ if cfg_path:
326
+ params["config"] = json.loads(cfg_path.read_text(encoding="utf-8"))
327
+ try:
328
+ resp_code = runtime.server_client.upload_strategy_code(sid, str(qmt_body_path),
329
+ params=params or None)
330
+ except runtime.server_client.ServerError as e:
331
+ print(f" ✗ 上传源码失败: {e}", file=sys.stderr)
332
+ return 4
333
+ print(f" ✓ body uploaded (body_hash={resp_code.get('body_hash', '?')[:16]})",
334
+ file=sys.stderr)
335
+
336
+ export_path = None
337
+ if not args.skip_export:
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)
354
+
355
+ print('', file=sys.stderr)
356
+ print(json.dumps({
357
+ "strategy_id": sid,
358
+ "bundle_path": str(bundle_out),
359
+ "qmt_body_path": str(qmt_body_path),
360
+ "export_path": export_path,
361
+ "verdict": "OK",
362
+ }, ensure_ascii=False, indent=2))
363
+ return 0
364
+
365
+
366
+ def cmd_parity(args) -> int:
367
+ """跑 akquant parity test (5 内置 strategy × 1 universe × 1 period)."""
368
+ from datetime import datetime as _dt
369
+ from . import _test_akquant_parity as _pt # type: ignore
370
+ import json
371
+
372
+ strategies = None
373
+ if getattr(args, "strategies", None):
374
+ strategies = [s.strip() for s in args.strategies.split(",") if s.strip()]
375
+
376
+ universe_raw = getattr(args, "universe", None)
377
+ universe = None
378
+ if universe_raw:
379
+ universe = [_normalize_to_full(x) for x in _parse_symbols(universe_raw)]
380
+
381
+ start = getattr(args, "start", None) or "20230101"
382
+ end = getattr(args, "end", None) or "20251231"
383
+
384
+ out = _pt.run_parity_test(strategies=strategies, universe=universe,
385
+ start=start, end=end)
386
+ import akquant
387
+ out["_engine"] = f"akquant-{akquant.__version__}"
388
+ out["_invoked_by"] = "hamuna_quant_cli parity"
389
+
390
+ if getattr(args, "report", None):
391
+ report_path = Path(args.report)
392
+ report_path.parent.mkdir(parents=True, exist_ok=True)
393
+ report_path.write_text(
394
+ json.dumps(out, indent=2, ensure_ascii=False, default=str),
395
+ encoding="utf-8",
396
+ )
397
+ print(f"parity report saved → {report_path}", file=sys.stderr)
398
+ else:
399
+ print(f"akquant parity @ {_dt.now().isoformat(timespec='seconds')}", file=sys.stderr)
400
+ print(f" strategies: {[s['name'] for s in _pt.STRATEGY_DEFS]}", file=sys.stderr)
401
+ print(f"summary:", file=sys.stderr)
402
+ for r in out["runs"]:
403
+ status = "OK" if not r.get("error") else f"FAIL ({r.get('error','')[:60]})"
404
+ n = r.get("trades_count", 0)
405
+ print(f" {r['strategy']:24s} {r['engine']:10s} {status:20s} "
406
+ f"trades={n:3d} ({r.get('elapsed_sec',0)}s)",
407
+ file=sys.stderr)
408
+
409
+ return 0 if all(not r.get("error") for r in out["runs"]) else 4
410
+
411
+
412
+ def _normalize_to_full(bare: str) -> str:
413
+ """6 位裸码 → 容维 stockCode (带 .SH/.SZ/.BJ 后缀)."""
414
+ from .akquant_schema_adapter import normalize_symbol
415
+ return normalize_symbol(bare)
416
+
417
+
418
+ def cmd_dataset(args) -> int:
419
+ """构建 / 列出 prebuilt 数据集."""
420
+ if args.subcmd == "list":
421
+ from .prebuilt_downloader import main as pd_main # type: ignore
422
+ return pd_main(["list"])
423
+
424
+ if args.subcmd == "fetch":
425
+ if not args.symbols:
426
+ print("cmd_dataset fetch --symbols 必填 (逗号分隔裸码 e.g. 600000,600036)",
427
+ file=sys.stderr)
428
+ return 2
429
+ syms = _parse_symbols(args.symbols)
430
+ from .prebuilt_downloader import download_single # type: ignore
431
+ ok, fail = [], []
432
+ for sym in syms:
433
+ try:
434
+ p = download_single(sym, period=args.period,
435
+ start=args.start, end=args.end)
436
+ ok.append({"symbol": sym, "path": str(p)})
437
+ print(f" ✓ {sym} → {p}", file=sys.stderr)
438
+ except Exception as e:
439
+ fail.append({"symbol": sym, "error": str(e)})
440
+ print(f" ✗ {sym}: {e}", file=sys.stderr)
441
+ out = {"fetched": ok, "failed": fail,
442
+ "count_ok": len(ok), "count_fail": len(fail)}
443
+ import json
444
+ print(json.dumps(out, indent=2, ensure_ascii=False))
445
+ return 0 if not fail else 4
446
+
447
+ if args.subcmd == "manifest":
448
+ if not args.symbols or not args.start or not args.end:
449
+ print("cmd_dataset manifest --symbols/--start/--end 必填",
450
+ file=sys.stderr)
451
+ return 2
452
+ syms = _parse_symbols(args.symbols)
453
+ if not syms:
454
+ print("cmd_dataset manifest --symbols 解析后为空 (非法输入)",
455
+ file=sys.stderr)
456
+ return 2
457
+ from .akquant_data_adapter import ( # type: ignore
458
+ load_prebuilt_to_akquant_with_limits,
459
+ )
460
+ try:
461
+ df = load_prebuilt_to_akquant_with_limits(syms, args.start, args.end)
462
+ except FileNotFoundError as e:
463
+ print(f"manifest 构建失败: {e}", file=sys.stderr)
464
+ return 4
465
+ if df is None or df.empty:
466
+ print(f"manifest 数据空: symbols={syms} window={args.start}-{args.end}",
467
+ file=sys.stderr)
468
+ return 4
469
+ actual_symbols = sorted(set(df["symbol"].tolist())) if "symbol" in df.columns else []
470
+ manifest = {
471
+ "symbols": actual_symbols,
472
+ "start": args.start,
473
+ "end": args.end,
474
+ "rows": int(len(df)),
475
+ "columns": sorted(df.columns.tolist()),
476
+ "created_at": _now_iso(),
477
+ "schema": "hamuna_quant_cli/v1",
478
+ }
479
+ if args.output:
480
+ import json
481
+ Path(args.output).write_text(
482
+ json.dumps(manifest, indent=2, ensure_ascii=False),
483
+ encoding="utf-8",
484
+ )
485
+ print(f"manifest saved → {args.output} "
486
+ f"({manifest['rows']} rows, {len(actual_symbols)} symbols)",
487
+ file=sys.stderr)
488
+ else:
489
+ import json
490
+ print(json.dumps(manifest, indent=2, ensure_ascii=False))
491
+ return 0
492
+
493
+ print(f"未知 dataset subcmd: {args.subcmd}", file=sys.stderr)
494
+ return 2
495
+
496
+
497
+ def _bridge_kline(server: str, symbols: list[str], days: int) -> list[dict]:
498
+ """QMT bridge /data/history 日线 → kline JSON 列表 (本地 bundle 无数据时回退).
499
+
500
+ columns: [amount, close, high, low, open, ..., time(ms 时间戳), volume].
501
+ """
502
+ import json
503
+ import urllib.parse
504
+ import urllib.request
505
+ from datetime import datetime
506
+
507
+ out: list[dict] = []
508
+ for sym in symbols:
509
+ url = (
510
+ f"{server}/data/history?security={urllib.parse.quote(sym)}"
511
+ f"&period=1d&count={days}&fq=None"
512
+ )
513
+ try:
514
+ with urllib.request.urlopen(url, timeout=8) as resp:
515
+ p = json.load(resp)
516
+ except Exception as exc: # noqa: BLE001
517
+ raise RuntimeError(f"bridge 不可达 ({url}): {exc}") from exc
518
+ if not p.get("ok"):
519
+ raise RuntimeError(f"bridge: {p.get('message') or p.get('error') or p}")
520
+ value = p.get("value") or {}
521
+ cols = value.get("columns") or []
522
+ recs = value.get("records") or []
523
+ if not cols:
524
+ continue
525
+ idx = {c: i for i, c in enumerate(cols)}
526
+ for r in recs:
527
+ ts = r[idx["time"]]
528
+ date = datetime.fromtimestamp(ts / 1000).strftime("%Y-%m-%d")
529
+ out.append(
530
+ {
531
+ "date": date,
532
+ "open": round(float(r[idx["open"]]), 4),
533
+ "high": round(float(r[idx["high"]]), 4),
534
+ "low": round(float(r[idx["low"]]), 4),
535
+ "close": round(float(r[idx["close"]]), 4),
536
+ "volume": float(r[idx["volume"]]),
537
+ }
538
+ )
539
+ out.sort(key=lambda b: b["date"])
540
+ return out
541
+
542
+
543
+ def cmd_kline(args) -> int:
544
+ """最近 N 天日线 OHLCV → stdout JSON (详情页 K 线图数据源).
545
+
546
+ 优先本地 prebuilt bundle (与回测同源真实数据, 离线可用); 本地无该标的
547
+ (ETF/可转债/新股等不在 bundle) 时回退 QMT bridge /data/history —
548
+ 2026-08-19 标的池全量改 QMT 后 K 线也要能覆盖 QMT 标的.
549
+ """
550
+ import json
551
+ from datetime import datetime, timedelta
552
+
553
+ from .prebuilt_resolver import resolve
554
+
555
+ syms = [s.strip() for s in args.symbols.split(",") if s.strip()]
556
+ if not syms:
557
+ print("[error] kline 需要 --symbols", file=sys.stderr)
558
+ return 2
559
+ end = datetime.now().strftime("%Y%m%d")
560
+ start = (datetime.now() - timedelta(days=args.days)).strftime("%Y%m%d")
561
+ df, src = resolve(syms, start, end)
562
+ if df is None or len(df) == 0:
563
+ # 回退 QMT bridge (本地 bundle 无此标的)
564
+ try:
565
+ bars = _bridge_kline(args.server, syms, args.days)
566
+ except RuntimeError as exc:
567
+ print(
568
+ f"[error] 本地 bundle 无数据 (universe={syms}, src={src}) 且 bridge 回退失败: {exc}",
569
+ file=sys.stderr,
570
+ )
571
+ return 5
572
+ if not bars:
573
+ print(
574
+ f"[error] 无 bar 数据 (universe={syms}, {start}~{end}, src={src}) — "
575
+ f"先 `hamuna_quant_cli dataset fetch --symbols {','.join(syms)}` 或确认 QMT bridge 在线",
576
+ file=sys.stderr,
577
+ )
578
+ return 4
579
+ print(json.dumps(bars, ensure_ascii=False))
580
+ return 0
581
+ df = df.copy()
582
+ df["date"] = df["date"].astype(str) # datetime64 → 'YYYY-MM-DD'
583
+ bars = [
584
+ {
585
+ "date": r.date,
586
+ "open": round(float(r.open), 4),
587
+ "high": round(float(r.high), 4),
588
+ "low": round(float(r.low), 4),
589
+ "close": round(float(r.close), 4),
590
+ "volume": float(r.volume),
591
+ }
592
+ for r in df.itertuples()
593
+ ]
594
+ print(json.dumps(bars, ensure_ascii=False))
595
+ return 0
596
+
597
+
598
+ def _resolve_names(codes: list[str], server: str, cache_key: str) -> dict[str, str]:
599
+ """bridge /data/stock_name 逐个补证券名称 + 本地缓存 (全类别统一走 QMT).
600
+
601
+ 返回 {code: name}。缺名 (沪市可转债 get_instrument_detail 返回空) 留 ""。
602
+ 缓存 ~/.hamuna/data_cache/universe_names_<cache_key>.json, 首次并发 16 查,
603
+ 之后秒出。
604
+ """
605
+ import json
606
+ import urllib.parse
607
+ import urllib.request
608
+ from concurrent.futures import ThreadPoolExecutor
609
+ from pathlib import Path
610
+
611
+ cache_dir = Path.home() / ".hamuna" / "data_cache"
612
+ path = cache_dir / f"universe_names_{cache_key}.json"
613
+ names: dict[str, str] = {}
614
+ if path.exists():
615
+ try:
616
+ names = json.loads(path.read_text(encoding="utf-8"))
617
+ except Exception: # noqa: BLE001
618
+ names = {}
619
+ missing = [c for c in codes if c not in names]
620
+ if missing:
621
+
622
+ def fetch(c: str) -> tuple[str, str]:
623
+ try:
624
+ url = f"{server}/data/stock_name?security={urllib.parse.quote(c)}"
625
+ with urllib.request.urlopen(url, timeout=6) as resp:
626
+ p = json.load(resp)
627
+ return c, (p.get("value") or {}).get("name") or ""
628
+ except Exception: # noqa: BLE001
629
+ return c, ""
630
+
631
+ with ThreadPoolExecutor(max_workers=16) as ex:
632
+ for c, n in ex.map(fetch, missing):
633
+ names[c] = n
634
+ cache_dir.mkdir(parents=True, exist_ok=True)
635
+ path.write_text(json.dumps(names, ensure_ascii=False), encoding="utf-8")
636
+ return names
637
+
638
+
639
+ def cmd_universe(args) -> int:
640
+ """列 universe 标的列表 [code, name] → stdout JSON — 全部从 QMT bridge 拉取.
641
+
642
+ 2026-08-19 用户要求"都从 QMT 拉": 成分列表走 /data/index_stocks
643
+ (all_a→沪深A股板块 / index→指数成分 / etf→双基金过滤 / cb→沪深转债),
644
+ 名称走 /data/stock_name (并发 + ~/.hamuna/data_cache 缓存).
645
+ 依赖 QMT bridge 在线 (离线不可用, 报错提示).
646
+ """
647
+ import json
648
+
649
+ if args.type == "index":
650
+ if not args.index:
651
+ print("[error] --type index 需要 --index (指数代码, 如 000300)", file=sys.stderr)
652
+ return 2
653
+ pool = args.index
654
+ cache_key = f"index_{args.index}"
655
+ else:
656
+ pool = {"all_a": "all_a", "etf": "etf", "convertible_bond": "cb"}[args.type]
657
+ cache_key = args.type
658
+ try:
659
+ codes = _fetch_pool_codes(args.server, pool)
660
+ except RuntimeError as exc:
661
+ # bridge 不可达 → 用落盘缓存降级 (QMT 之前拉过的池秒出; 从未拉过才报错)
662
+ cached = _read_pool_cache("items", pool)
663
+ if cached:
664
+ print(json.dumps(cached, ensure_ascii=False))
665
+ print(
666
+ f"[warn] bridge 不可达, 使用缓存标的池 ({len(cached)} 个) — 启动 QMT 后刷新",
667
+ file=sys.stderr,
668
+ )
669
+ return 0
670
+ print(
671
+ f"[error] {exc} — 标的池数据来自 QMT bridge, 请确认 QMT 已启动 (bridge 在线)",
672
+ file=sys.stderr,
673
+ )
674
+ return 5
675
+ names = _resolve_names(codes, args.server, cache_key)
676
+ items = [{"code": c, "name": names.get(c, "")} for c in codes]
677
+ _write_pool_cache("items", pool, items)
678
+ print(json.dumps(items, ensure_ascii=False))
679
+ return 0
680
+
681
+
682
+ _ETF_PREFIXES = (
683
+ "510", "511", "512", "513", "515", "516", "517", "518",
684
+ "560", "561", "562", "563", "588", "589", # 沪市 ETF
685
+ "159", # 深市 ETF
686
+ )
687
+ # QMT 板块名 (get_stock_list_in_sector): 沪深A股 / 沪深转债 / 沪市基金 / 深市基金
688
+ _POOL_SECTORS = {"all_a": "沪深A股", "cb": "沪深转债", "etf": None}
689
+
690
+
691
+ def _pool_cache_path(kind: str, pool: str):
692
+ """标的池列表落盘缓存 — ~/.hamuna/data_cache/universe_{kind}_{pool}.json.
693
+ QMT 拉取成功后写, bridge 不可达时读缓存降级 (标的池是低频静态数据).
694
+ kind: items (universe 带名称) / codes (index-stocks 裸码).
695
+ """
696
+ from pathlib import Path
697
+
698
+ return Path.home() / ".hamuna" / "data_cache" / f"universe_{kind}_{pool}.json"
699
+
700
+
701
+ def _write_pool_cache(kind: str, pool: str, data) -> None:
702
+ import json
703
+
704
+ try:
705
+ p = _pool_cache_path(kind, pool)
706
+ p.parent.mkdir(parents=True, exist_ok=True)
707
+ p.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
708
+ except Exception: # noqa: BLE001 — 缓存失败不影响主流程
709
+ pass
710
+
711
+
712
+ def _read_pool_cache(kind: str, pool: str):
713
+ import json
714
+
715
+ try:
716
+ p = _pool_cache_path(kind, pool)
717
+ if p.exists():
718
+ return json.loads(p.read_text(encoding="utf-8"))
719
+ except Exception: # noqa: BLE001
720
+ pass
721
+ return None
722
+
723
+
724
+ def _fetch_pool_codes(server: str, pool: str) -> list[str]:
725
+ """读 bridge 股票池 → 6 位裸码列表 (与 cmd_index_stocks 共用).
726
+
727
+ pool: 指数代码 / all_a / cb (板块直查) / etf (沪市+深市基金合并后代码段过滤).
728
+ Raises: RuntimeError — bridge 不可达或返回错误.
729
+ """
730
+ import json
731
+ import urllib.parse
732
+ import urllib.request
733
+
734
+ def fetch(params: str) -> list[str]:
735
+ url = f"{server}/data/index_stocks?{params}"
736
+ try:
737
+ with urllib.request.urlopen(url, timeout=8) as resp:
738
+ payload = json.load(resp)
739
+ except Exception as exc: # noqa: BLE001
740
+ raise RuntimeError(f"bridge 不可达 ({url}): {exc}") from exc
741
+ if not payload.get("ok"):
742
+ raise RuntimeError(f"bridge: {payload.get('message') or payload.get('error') or payload}")
743
+ stocks = payload.get("value", {}).get("stocks") or []
744
+ codes: list[str] = []
745
+ for s in stocks:
746
+ c = str(s).split(".")[0]
747
+ if c and c not in codes:
748
+ codes.append(c)
749
+ return codes
750
+
751
+ if pool in _POOL_SECTORS:
752
+ sector = _POOL_SECTORS[pool]
753
+ if sector is None: # etf: 合并沪/深基金 + 代码段过滤
754
+ raw = fetch("sector=" + urllib.parse.quote("沪市基金"))
755
+ raw += fetch("sector=" + urllib.parse.quote("深市基金"))
756
+ return [c for c in raw if c.startswith(_ETF_PREFIXES)]
757
+ return fetch("sector=" + urllib.parse.quote(sector))
758
+ return fetch("index_symbol=" + urllib.parse.quote(pool))
759
+
760
+
761
+ def cmd_index_stocks(args) -> int:
762
+ """读 QMT bridge 股票池 → stdout JSON (6 位裸码列表).
763
+
764
+ 池标识 --pool: 指数代码 (000016/000300/000905/000852 等, 走 index_symbol)
765
+ 或预置池: all_a (沪深A股全部) / cb (沪深转债) / etf (场内 ETF,
766
+ 沪市基金+深市基金合并后按代码段过滤, 排除 LOF/REITs/分级).
767
+ """
768
+ import json
769
+
770
+ try:
771
+ codes = _fetch_pool_codes(args.server, args.pool)
772
+ except RuntimeError as exc:
773
+ # bridge 不可达 → 落盘缓存降级 (与 universe 共用 pool 标识)
774
+ cached = _read_pool_cache("codes", args.pool)
775
+ if cached:
776
+ print(json.dumps(cached, ensure_ascii=False))
777
+ print(
778
+ f"[warn] bridge 不可达, 使用缓存标的池 ({len(cached)} 个) — 启动 QMT 后刷新",
779
+ file=sys.stderr,
780
+ )
781
+ return 0
782
+ print(f"[error] {exc}", file=sys.stderr)
783
+ return 5
784
+ _write_pool_cache("codes", args.pool, codes)
785
+ print(json.dumps(codes, ensure_ascii=False))
786
+ return 0
787
+
788
+
789
+ # ============================================================
790
+ # 实盘子命令 (从 desktop hamuna_strategy.py 搬)
791
+ # ============================================================
792
+ def cmd_live_run(args) -> int:
793
+ """实盘 / 仿真运行策略 (包装 akquant.run_live)."""
794
+ from .live import load_strategy, run_live, StrategyLoadError, LiveRunError
795
+
796
+ try:
797
+ spec = load_strategy(Path(args.strategy), class_name=args.class_name)
798
+ except StrategyLoadError as e:
799
+ print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
800
+ return 3
801
+ try:
802
+ symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
803
+ run_live(
804
+ spec,
805
+ mode=args.mode,
806
+ broker=args.broker,
807
+ symbols=symbols,
808
+ duration=args.duration,
809
+ gateway_options_raw=args.gateway_options,
810
+ initial_cash=args.initial_cash,
811
+ log_level=args.log_level,
812
+ log_file=args.log_file,
813
+ market_broker=args.market_broker,
814
+ replay_days=args.replay_days,
815
+ )
816
+ except LiveRunError as e:
817
+ print(f"[error] live run 失败: {e}", file=sys.stderr)
818
+ return 4
819
+ return 0
820
+
821
+
822
+ # ============================================================
823
+ # argparse 路由 + main()
824
+ # ============================================================
825
+ def build_parser() -> argparse.ArgumentParser:
826
+ p = argparse.ArgumentParser(
827
+ prog="hamuna_quant_cli",
828
+ description="Hamuna 回测 + 实盘统一 CLI (akquant 0.3.x) — "
829
+ "`hamuna_quant_cli run <strategy.py>` 或 `live run <strategy.py>`",
830
+ )
831
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
832
+
833
+ sub = p.add_subparsers(dest="cmd", required=True)
834
+
835
+ # —— 回测 ——
836
+ r = sub.add_parser("run", help="跑 akquant backtest → result.json")
837
+ r.add_argument("strategy", help="strategy .py 路径 (含 akquant.Strategy 子类)")
838
+ r.add_argument("--config", required=True, help="CONFIG JSON 路径")
839
+ r.add_argument("--output", help="result 落盘 JSON 路径 (默认 stdout)")
840
+ r.add_argument("--dataset", help="固化 manifest JSON 路径")
841
+ r.add_argument("--skip-discipline", action="store_true",
842
+ help="跳过 akquant API 静态审查 (qa / 旧策略兼容)")
843
+ r.add_argument("--upload", action="store_true",
844
+ help="跑完直接上传 server (与 --name 联用 auto-create)")
845
+ r.add_argument("--strategy-id", help="--upload 时直接给 strategy_id")
846
+ r.add_argument("--name", help="--upload auto-create 时的策略名")
847
+ r.add_argument("--code", help="--upload auto-create 时的策略源码 (.py 路径)")
848
+ r.set_defaults(func=cmd_run)
849
+
850
+ k = sub.add_parser("check", help="纯静态纪律 self-check (不跑回测)")
851
+ k.add_argument("strategy", help="strategy .py 路径")
852
+ k.add_argument("--config", required=True, help="CONFIG JSON 路径")
853
+ k.set_defaults(func=cmd_check)
854
+
855
+ u = sub.add_parser("upload", help="PUT 本地 result.json → server")
856
+ u.add_argument("strategy_id", nargs="?",
857
+ help="server 端 strategy_id (缺则 --name + --config 必填)")
858
+ u.add_argument("--result", required=True, help="本地 result.json 路径")
859
+ u.add_argument("--name", help="auto-create 时的策略名")
860
+ u.add_argument("--config", help="auto-create 时的 CONFIG JSON")
861
+ u.add_argument("--code", help="auto-create 时的策略源码 (.py 路径)")
862
+ u.set_defaults(func=cmd_upload)
863
+
864
+ c = sub.add_parser("create", help="新建 strategy 拿 strategy_id")
865
+ c.add_argument("--name", required=True, help="策略名")
866
+ c.add_argument("--config", required=True, help="CONFIG JSON 路径")
867
+ c.add_argument("--code", help="策略源码 .py 路径")
868
+ c.set_defaults(func=cmd_create)
869
+
870
+ pa = sub.add_parser("parity", help="跑 akquant parity test")
871
+ pa.add_argument("--strategies", help="CSV strategy 名 (默认全跑)")
872
+ pa.add_argument("--universe", help="6 位裸码 CSV (默认 600000)")
873
+ pa.add_argument("--start", help="YYYYMMDD (默认 20230101)")
874
+ pa.add_argument("--end", help="YYYYMMDD (默认 20251231)")
875
+ pa.add_argument("--report", help="落盘 JSON 路径 (默认 stdout 摘要)")
876
+ pa.set_defaults(func=cmd_parity)
877
+
878
+ qt = sub.add_parser("qmt-translate", help="akquant Strategy → QMT body 翻译 (本地 stub)")
879
+ qt.add_argument("strategy", help="akquant strategy .py 路径")
880
+ qt.add_argument("config", help="CONFIG JSON 路径")
881
+ qt.add_argument("--spec", help="spec_strategy.json 路径 (可选)")
882
+ qt.add_argument("--output", help="QMT body 落盘路径")
883
+ qt.set_defaults(func=cmd_qmt_translate)
884
+
885
+ cm = sub.add_parser("commit", help="commiter 5 步打包上传")
886
+ cm.add_argument("strategy_id", help="server 端 strategy_id")
887
+ cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
888
+ cm.add_argument("--result", required=True, help="metrics result.json 路径")
889
+ cm.add_argument("--config", help="config.json 路径")
890
+ cm.add_argument("--spec", help="spec_strategy.json 路径")
891
+ cm.add_argument("--bundle-out", help="bundle tar.gz 落盘路径")
892
+ cm.add_argument("--skip-export", action="store_true",
893
+ help="跳过 step 5 (POST /strategies/:id/export)")
894
+ cm.add_argument("--export-out", help="step 5 输出 .qmt.py 路径")
895
+ cm.set_defaults(func=cmd_commit)
896
+
897
+ d = sub.add_parser("dataset", help="构建/列出 prebuilt 数据集")
898
+ dsub = d.add_subparsers(dest="subcmd", required=True)
899
+ dsub.add_parser("list", help="列 server 内置预构建池")
900
+ df = dsub.add_parser("fetch", help="下载 prebuilt")
901
+ df.add_argument("--symbols", required=True,
902
+ help="逗号分隔 6 位裸码 (后缀自动剥)")
903
+ df.add_argument("--period", default="1d")
904
+ df.add_argument("--start", help="YYYYMMDD")
905
+ df.add_argument("--end", help="YYYYMMDD")
906
+ dm = dsub.add_parser("manifest", help="固化 manifest JSON")
907
+ dm.add_argument("--symbols", required=True)
908
+ dm.add_argument("--start", required=True)
909
+ dm.add_argument("--end", required=True)
910
+ dm.add_argument("--output", help="manifest 落盘路径")
911
+ d.set_defaults(func=cmd_dataset)
912
+
913
+ # —— K 线数据 (详情页图表) ——
914
+ kl = sub.add_parser("kline", help="最近 N 天日线 OHLCV → stdout JSON (本地 prebuilt, 缺失回退 QMT bridge)")
915
+ kl.add_argument("--symbols", required=True, help="逗号分隔 6 位裸码")
916
+ kl.add_argument("--days", type=int, default=90, help="最近 N 个自然日 (默认 90)")
917
+ kl.add_argument("--server", default="http://127.0.0.1:9000",
918
+ help="bridge 地址 (回退时用, 默认 http://127.0.0.1:9000)")
919
+ kl.set_defaults(func=cmd_kline)
920
+
921
+ # —— 标的池批量添加 (详情页) ——
922
+ uv = sub.add_parser("universe", help="列 universe 标的列表 → stdout JSON (代码+名称)")
923
+ uv.add_argument("--type", required=True,
924
+ choices=["all_a", "etf", "convertible_bond", "index"],
925
+ help="类别: all_a / etf / convertible_bond / index (指数成分)")
926
+ uv.add_argument("--index", default=None,
927
+ help="--type index 时的指数代码 (000300/000905 等; 名称用本地 bundle 补)")
928
+ uv.add_argument("--server", default="http://127.0.0.1:9000",
929
+ help="bridge 地址 (本地无 bundle 回退时用, 默认 http://127.0.0.1:9000)")
930
+ uv.set_defaults(func=cmd_universe)
931
+
932
+ # —— 股票池 (指数/板块成分, 详情页一键导入) ——
933
+ ix = sub.add_parser("index-stocks", help="读 QMT bridge 股票池 → stdout JSON (6 位裸码)")
934
+ ix.add_argument("--pool", required=True,
935
+ help="池标识: 指数代码 (000016 上证50 / 000300 沪深300 / 000905 中证500 / "
936
+ "000852 中证1000) 或 all_a (沪深A股) / cb (沪深转债) / etf (场内ETF)")
937
+ ix.add_argument("--server", default="http://127.0.0.1:9000", help="bridge 地址 (默认 http://127.0.0.1:9000)")
938
+ ix.set_defaults(func=cmd_index_stocks)
939
+
940
+ # —— 实盘 ——
941
+ live_p = sub.add_parser("live", help="实盘 / 仿真运行策略 (包装 akquant.run_live)")
942
+ live_sub = live_p.add_subparsers(dest="subcmd", required=True)
943
+ run_p = live_sub.add_parser("run", help="运行一个 strategy.py")
944
+ run_p.add_argument("strategy", type=Path, help="strategy.py 路径")
945
+ run_p.add_argument("--mode", choices=["paper", "broker_live"], default="paper",
946
+ help="trading_mode (default: paper)")
947
+ run_p.add_argument("--broker", default="replay",
948
+ help="broker 名称: ctp / qmf / replay / qmt (default: replay)")
949
+ run_p.add_argument("--market-broker", default=None,
950
+ help="独立行情 broker (e.g. qmt_market)")
951
+ run_p.add_argument("--symbols", default=None,
952
+ help="标的列表, 逗号分隔")
953
+ run_p.add_argument("--duration", default="1h",
954
+ help="运行时长 (akquant 解析)")
955
+ run_p.add_argument("--class-name", default="Strategy",
956
+ help="strategy.py 里的类名")
957
+ run_p.add_argument("--gateway-options", default=None,
958
+ help="k=v 字典, 透传给 run_live")
959
+ run_p.add_argument("--replay-days", type=int, default=30,
960
+ help="broker=replay 时取最近 N 天真实日线 (默认 30; "
961
+ "策略 warmup 超窗口需加大, e.g. 90)")
962
+ run_p.add_argument("--initial-cash", type=float, default=None)
963
+ run_p.add_argument("--log-level", default="INFO",
964
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"])
965
+ run_p.add_argument("--log-file", type=Path, default=None)
966
+ run_p.set_defaults(func=cmd_live_run)
967
+
968
+ return p
969
+
970
+
971
+ def main(argv: list[str] | None = None) -> int:
972
+ parser = build_parser()
973
+ args = parser.parse_args(argv)
974
+ return args.func(args)
975
+
976
+
977
+ if __name__ == "__main__":
978
+ sys.exit(main())