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.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -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())
|