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,609 @@
1
+ """akquant Strategy → QMT body 转换器 (本地 stub, 真值由 cloud export endpoint 渲染).
2
+
3
+ 设计意图 (per `skills/hamuna-strategy-v2/references/qmt-export.md` §转换):
4
+ - body 用 QMT 原生签名 (`def init(C)` + `def handlebar(C)` + `passorder`/`C.get_market_data_ex`)
5
+ - CLI 端 driver / Context 适配 (v2 时代由 akquant 适配) — 双跑靠 "body 零翻译 + shim 状态代理"
6
+ - 本地 translator **仅产出 audit/对照版 QMT body**, 真值由 server 端
7
+ `POST /api/v1/strategies/:id/export` 重新渲染 shell + 嵌入 embedded API key + 签 cert
8
+
9
+ **支持的 API 映射** (8 类, 够覆盖 BuyHold / DualMA / LowVolTopK / CrossSection 主流形态):
10
+ - `self.subscribe(sym)` → `C.subscribe(sym)`
11
+ - `self.buy(sym, qty)` / `self.sell(sym, qty)` → `passorder(23|24, 1101, "", sym, 11, -1, qty, "", 0, "buy|sel", C)`
12
+ - `self.get_position(sym)` → `C.holdings.get(sym, {}).get('qty', 0)`
13
+ - `self.get_history(n, sym, field)` → `C.get_market_data_ex(...)` + iloc 取 numpy
14
+ - `self.order_target_percent(pct, symbol=s)` → 计算 qty + 双向 passorder (helper function)
15
+ - `self.add_daily_timer('HH:MM:SS', 'name')` → `C.run_time('name', '1d', 'HH:MM:SS')`
16
+ - `bar.timestamp` → `timetag_to_datetime(C.get_bar_timetag(C.barpos), '%Y%m%d%H%M%S')`
17
+ - `bar.symbol` → `bar.code` (QMT Bar 字段差异)
18
+
19
+ **不支持的形态** → raise NotImplementedError (exit 3):
20
+ - on_order / on_trade / on_stop (复杂事件 hook)
21
+ - lambda / starred args / kwargs spread / walrus operator
22
+ - get_history 之外的 akquant 数据 API (e.g. get_history_df)
23
+
24
+ **designer-spec 驱动 CONFIG**:
25
+ - `_build_config_py` 从 spec_strategy.json 的 state_attrs / data_dependencies 启发式
26
+ 提取默认值 (`默认 X` / `default X` / `= X` / `top X` 等)
27
+ - CONFIG 块含 audit trail (perf_arch.mode / direction_id) — QMT 端不用, 仅 diff 对照
28
+
29
+ **py3.6 硬约束** (per qmt-export.md §QMT runtime):
30
+ - 头部 `# -*- coding: gbk -*-`
31
+ - 不写 f-string 3.7+ 特性
32
+ - 不写 `list[...]`/`dict[...]` 注解 (3.9+; 改 typing.List / typing.Dict)
33
+ - 不写 walrus `:=` / 3.8+ positional-only / 3.10+ match
34
+
35
+ ADR-0023 (cloud QMT export shell) + ADR-0025 (CONFIG block) 契约:
36
+ - body 顶部 `CONFIG = {...}` 由 spec_strategy.json + config.json 拼
37
+ - 顶部 `# === HAMUNA_APIV1 === <api_key> === END ===` 由 cloud 端 export endpoint 注入, 本地不加
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import ast
42
+ import json
43
+ import re
44
+ import sys
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+
49
+ # ===== 模板片段 =====
50
+
51
+ QMT_HEADER = '''# -*- coding: gbk -*-
52
+ # Auto-generated by strategy_cli qmt-translate (本地 stub; 真值由 cloud hamuna export 渲染).
53
+ # ADR-0023: cloud QMT shell 是 source of truth, body 由 cloud 端 init() 时拉取.
54
+ # 本文件 = audit/对照版. 不要直接放入 QMT (无 embedded API key + cert, init() 会失败).
55
+ CONFIG = {config_py}
56
+
57
+
58
+ '''
59
+
60
+ QMT_INIT_TEMPLATE = '''def init(C):
61
+ C = _wrap_context(C) if globals().get("_wrap_context") else C
62
+ # ===== init body (translated from akquant on_start) =====
63
+ {init_body}
64
+
65
+ '''
66
+
67
+
68
+ QMT_HANDLEBAR_TEMPLATE = '''def handlebar(C):
69
+ if not C.is_last_bar():
70
+ return
71
+ # ===== handlebar body (translated from akquant on_bar) =====
72
+ {handlebar_body}
73
+
74
+ '''
75
+
76
+
77
+ # ===== API 映射规则 =====
78
+
79
+ # akquant self.<method> → QMT <expression> (lambda 形式, 接受 self + args)
80
+ API_MAP: dict[str, str] = {
81
+ "subscribe": "C.subscribe({args[0]!r})",
82
+ # buy/sell 在 handlebar 上下文里要拆 qty + price, 见 _translate_call 特殊处理
83
+ }
84
+
85
+
86
+ def _walk_call(node: ast.Call) -> tuple[str, list[tuple[str, bool]]]:
87
+ """递归把 ast.Call 转成 ('func_name', [(arg_repr, is_literal), ...]).
88
+
89
+ is_literal=True 表示原 arg 是 ast.Constant (字符串/数字/None); caller 用 !r quote.
90
+ is_literal=False 表示 arg 是 Name / Attribute / Call (e.g. 变量 sym); caller 直插.
91
+
92
+ 只支持简单形: f(a, b, ...) 其中 f 是名字 / 简单 attribute, a/b 是常量 / 名字 / attribute.
93
+ """
94
+ func = node.func
95
+ if isinstance(func, ast.Name):
96
+ name = func.id
97
+ elif isinstance(func, ast.Attribute):
98
+ name = func.attr # e.g. self.buy → 'buy' (caller already routed by method)
99
+ else:
100
+ raise NotImplementedError(
101
+ f'QMT 转换器不支持嵌套调用: {ast.unparse(func)!r} ('
102
+ f'用户手写 QMT body 或简化)'
103
+ )
104
+ args: list[tuple[str, bool]] = []
105
+ for arg in node.args:
106
+ if isinstance(arg, ast.Constant):
107
+ args.append((repr(arg.value), True))
108
+ else:
109
+ args.append((ast.unparse(arg), False))
110
+ return name, args
111
+
112
+
113
+ def _q(arg: tuple[str, bool]) -> str:
114
+ """透传: literal 在 _walk_call 里已用 repr() quote, expr 直接 unparse."""
115
+ return arg[0]
116
+
117
+
118
+ def _translate_buy_sell(node: ast.Call, action: str) -> str:
119
+ """self.buy(sym, qty) / self.sell(sym, qty) → passorder 行.
120
+
121
+ QMT passorder(op, orderType, accountid, code, prType, price, qty, strategyName, quickTrade, remark, C)
122
+ op=23 buy / op=24 sell
123
+ orderType=1101 (限价) / 1102 (市价) — 用 1101 + prType=11 LATEST
124
+ prType=11 (最新价) — QMT 自动取最新成交价, 不需算 price
125
+ quickTrade=0 (handlebar 末根生效) — 与 CLI T+1 settle 对齐
126
+ remark < 24 chars
127
+ """
128
+ _, args = _walk_call(node)
129
+ if len(args) != 2:
130
+ raise NotImplementedError(
131
+ f'buy/sell 仅支持 (sym, qty) 形式; 当前 args={args}'
132
+ )
133
+ sym_expr, qty_expr = args[0][0], args[1][0]
134
+ op = 23 if action == "buy" else 24
135
+ return (
136
+ f'passorder({op}, 1101, "", {sym_expr}, 11, -1, '
137
+ f'{qty_expr}, "", 0, "{action[:3]}", C)'
138
+ )
139
+
140
+
141
+ def _translate_get_position(node: ast.Call) -> str:
142
+ """self.get_position(sym) → C.holdings.get(sym, {}).get('qty', 0)."""
143
+ _, args = _walk_call(node)
144
+ if len(args) != 1:
145
+ raise NotImplementedError(f'get_position 仅 (sym); 当前 args={args}')
146
+ sym_expr = args[0][0]
147
+ return f"C.holdings.get({sym_expr}, {{}}).get('qty', 0)"
148
+
149
+
150
+ def _translate_get_history(node: ast.Call) -> str:
151
+ """self.get_history(n, sym, field) → _hamuna_get_history(n, sym, field).
152
+
153
+ 输出调用 helper (QMT body 顶部生成 _hamuna_get_history 函数):
154
+ from akquant-style signature → C.get_market_data_ex → iloc 取 numpy 数组.
155
+
156
+ QMT 端 get_market_data_ex 返 {sym: DataFrame}; iloc[-n:] 取最后 n 行; .values 返 numpy —
157
+ 兼容 akquant np.mean / np.std 等.
158
+ """
159
+ _, args = _walk_call(node)
160
+ if len(args) != 3:
161
+ raise NotImplementedError(
162
+ f'get_history 仅支持 (n, sym, field); 当前 args={args}'
163
+ )
164
+ n_expr, sym_expr = args[0][0], args[1][0]
165
+ field_expr = _q(args[2])
166
+ return (
167
+ f'_hamuna_get_history({n_expr}, {sym_expr}, {field_expr}, '
168
+ f'CONFIG["period"], CONFIG["backtest_start"], CONFIG["backtest_end"])'
169
+ )
170
+
171
+
172
+ def _translate_order_target_percent(node: ast.Call) -> str:
173
+ """self.order_target_percent(pct, symbol=s) → _hamuna_order_target_percent.
174
+
175
+ QMT body 顶部生成 _hamuna_order_target_percent helper: 算 target_value + last price
176
+ + qty (100 股取整) + 双向 passorder (qty>0 buy / qty<0 sell).
177
+
178
+ Args 解析:
179
+ pct = positional[0]
180
+ symbol = kwargs['symbol'] (或 positional[1])
181
+ """
182
+ if not node.args:
183
+ raise NotImplementedError(f'order_target_percent 缺 pct; 当前 {ast.unparse(node)}')
184
+ pct_expr = ast.unparse(node.args[0])
185
+ sym_kw = next((kw for kw in node.keywords if kw.arg == "symbol"), None)
186
+ if sym_kw is None:
187
+ raise NotImplementedError(
188
+ f'order_target_percent 缺 symbol= kwarg; 当前 {ast.unparse(node)}'
189
+ )
190
+ sym_expr = ast.unparse(sym_kw.value)
191
+ return (
192
+ f'_hamuna_order_target_percent({pct_expr}, {sym_expr}, '
193
+ f'C, CONFIG["init_capital"])'
194
+ )
195
+
196
+
197
+ def _translate_add_daily_timer(node: ast.Call) -> str:
198
+ """self.add_daily_timer('HH:MM:SS', 'name') → C.run_time('name', '1d', 'HH:MM:SS').
199
+
200
+ QMT run_time 是日级定时器 (per qmt_coding_spec §11 / §10): 第三个参 'HH:MM:SS'.
201
+ akquant add_daily_timer 第一参是时间, 第二参是 name (回调 key) — 翻译时换位.
202
+ """
203
+ _, args = _walk_call(node)
204
+ if len(args) != 2:
205
+ raise NotImplementedError(
206
+ f'add_daily_timer 仅支持 (time_str, name); 当前 args={args}'
207
+ )
208
+ time_quoted = _q(args[0])
209
+ name_quoted = _q(args[1])
210
+ return f'C.run_time({name_quoted}, "1d", {time_quoted})'
211
+
212
+
213
+ def _translate_call(node: ast.Call) -> str:
214
+ """translate 一个 ast.Call 节点到 QMT 代码片段."""
215
+ func = node.func
216
+ # 非 self.<method> 调用 (numpy array.mean() / pd.Series() / print(...) 等) →
217
+ # 递归翻译 value, 保留 method 链. numpy/pandas 自带 API QMT 端兼容.
218
+ if isinstance(func, ast.Attribute) and not (
219
+ isinstance(func.value, ast.Name) and func.value.id == "self"
220
+ ):
221
+ receiver = _translate_expr(func.value)
222
+ args_str = ", ".join(_translate_expr(a) for a in node.args)
223
+ kwargs_str = ", ".join(
224
+ f"{kw.arg}={_translate_expr(kw.value)}" for kw in node.keywords
225
+ )
226
+ all_args = ", ".join(filter(None, [args_str, kwargs_str]))
227
+ return f"{receiver}.{func.attr}({all_args})"
228
+ if not isinstance(func, ast.Attribute):
229
+ raise NotImplementedError(f'不支持非 attribute 调用: {ast.unparse(node.func)}')
230
+ method = func.attr
231
+ if method == "buy":
232
+ return _translate_buy_sell(node, "buy")
233
+ if method == "sell":
234
+ return _translate_buy_sell(node, "sell")
235
+ if method == "get_position":
236
+ return _translate_get_position(node)
237
+ if method == "subscribe":
238
+ _, args = _walk_call(node)
239
+ sym_expr = args[0][0]
240
+ return f"C.subscribe({sym_expr})"
241
+ if method == "get_history":
242
+ return _translate_get_history(node)
243
+ if method == "order_target_percent":
244
+ return _translate_order_target_percent(node)
245
+ if method == "add_daily_timer":
246
+ return _translate_add_daily_timer(node)
247
+ raise NotImplementedError(
248
+ f'QMT 转换器未实现 self.{method}(...) ('
249
+ f'用户手写 QMT body 或等 cloud export endpoint 完整渲染)'
250
+ )
251
+
252
+
253
+ def _translate_expr(node: ast.AST) -> str:
254
+ """递归 translate 一个表达式节点 (含 self.xxx 调用替换 + 其它原样)."""
255
+ if isinstance(node, ast.Call):
256
+ return _translate_call(node)
257
+ if isinstance(node, ast.Constant):
258
+ return repr(node.value)
259
+ if isinstance(node, ast.Name):
260
+ # self / bar / C / CONFIG → 直译 (handlebar 内 C 已是参数名)
261
+ return node.id
262
+ if isinstance(node, ast.Attribute):
263
+ # bar.symbol / bar.close 等 — QMT Bar 字段名一致, 直译
264
+ return ast.unparse(node)
265
+ if isinstance(node, ast.Compare):
266
+ # left op1 cmp1 op2 cmp2 ... — 递归翻译 left + comparators
267
+ left = _translate_expr(node.left)
268
+ parts = [left]
269
+ for op, comp in zip(node.ops, node.comparators):
270
+ op_str = {ast.Eq: '==', ast.NotEq: '!=', ast.Lt: '<', ast.LtE: '<=',
271
+ ast.Gt: '>', ast.GtE: '>='}.get(type(op), ast.unparse(op))
272
+ parts.append(op_str)
273
+ parts.append(_translate_expr(comp))
274
+ return ' '.join(parts)
275
+ if isinstance(node, ast.BoolOp):
276
+ op_str = 'and' if isinstance(node.op, ast.And) else 'or'
277
+ return f" {op_str} ".join(_translate_expr(v) for v in node.values)
278
+ if isinstance(node, ast.BinOp):
279
+ return ast.unparse(node)
280
+ if isinstance(node, ast.Subscript):
281
+ return ast.unparse(node)
282
+ raise NotImplementedError(f'QMT 转换器不支持表达式: {ast.unparse(node)}')
283
+
284
+
285
+ def _translate_stmt(stmt: ast.stmt, indent: int = 0) -> str:
286
+ """translate 一个 statement (if / for / expr / assign / return / pass).
287
+
288
+ indent = 当前 statement 所在层的首行缩进. 复合语句 (if/for) 内 body 按 indent+4 继续.
289
+ caller 给 init/handlebar 顶层 stmt 传 4, 嵌套 _translate_stmt 内部 indent+4.
290
+ """
291
+ pad = " " * indent
292
+ body_pad = " " * (indent + 4)
293
+ if isinstance(stmt, ast.If):
294
+ test = _translate_expr(stmt.test)
295
+ body_strs = [_translate_stmt(s, indent + 4) for s in stmt.body]
296
+ body = "\n".join(body_strs)
297
+ else_body = ""
298
+ if stmt.orelse:
299
+ else_body_strs = [_translate_stmt(s, indent + 4) for s in stmt.orelse]
300
+ else_body_inner = "\n".join(else_body_strs)
301
+ else_body = f"\n{pad}else:\n{else_body_inner}"
302
+ return f"{pad}if {test}:\n{body}{else_body}"
303
+ if isinstance(stmt, ast.For):
304
+ target = ast.unparse(stmt.target)
305
+ if isinstance(stmt.iter, ast.List):
306
+ iter_expr = "[" + ", ".join(_translate_expr(e) for e in stmt.iter.elts) + "]"
307
+ elif isinstance(stmt.iter, ast.Name) and stmt.iter.id == "self":
308
+ iter_expr = ast.unparse(stmt.iter)
309
+ else:
310
+ iter_expr = _translate_expr(stmt.iter)
311
+ body_strs = [_translate_stmt(s, indent + 4) for s in stmt.body]
312
+ body = "\n".join(body_strs)
313
+ return f"{pad}for {target} in {iter_expr}:\n{body}"
314
+ if isinstance(stmt, ast.Expr):
315
+ return f"{pad}{_translate_expr(stmt.value)}"
316
+ if isinstance(stmt, ast.Assign):
317
+ tgt = ast.unparse(stmt.targets[0])
318
+ val = _translate_expr(stmt.value)
319
+ return f"{pad}{tgt} = {val}"
320
+ if isinstance(stmt, ast.Return):
321
+ if stmt.value is None:
322
+ return f"{pad}return"
323
+ return f"{pad}return {_translate_expr(stmt.value)}"
324
+ if isinstance(stmt, ast.Pass):
325
+ return f"{pad}pass"
326
+ raise NotImplementedError(f'QMT 转换器不支持语句: {ast.unparse(stmt)}')
327
+
328
+
329
+ def _class_body_attrs(tree: ast.Module, class_name: str) -> dict[str, Any]:
330
+ """抽 class 的类属性 (warmup_period 等) + 方法体."""
331
+ out: dict[str, Any] = {"attrs": {}, "methods": {}}
332
+ for node in tree.body:
333
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
334
+ for item in node.body:
335
+ if isinstance(item, ast.Assign) and len(item.targets) == 1 and isinstance(item.targets[0], ast.Name):
336
+ try:
337
+ out["attrs"][item.targets[0].id] = ast.literal_eval(item.value)
338
+ except Exception:
339
+ out["attrs"][item.targets[0].id] = ast.unparse(item.value)
340
+ elif isinstance(item, ast.FunctionDef):
341
+ out["methods"][item.name] = item
342
+ return out
343
+
344
+
345
+ def _build_config_py(spec: dict, cfg: dict) -> str:
346
+ """从 spec_strategy.json + config.json 拼 CONFIG = {...} 块.
347
+
348
+ designer-spec 驱动启发式:
349
+ - state_attrs._qty 注释里 `默认 X` / `default X` / `= X` → 取整数
350
+ - state_attrs._top_k 注释里 `top X` / `默认 X` / `= X` → 取整数
351
+ - state_attrs._rebalance_time 注释里 `'HH:MM:SS'` → 取字符串
352
+ - state_attrs._period 注释里 `'1d'/'5m'/...` → 取字符串
353
+ - data_dependencies.history_window.length → history_window int
354
+ - data_dependencies.history_window.field → history_field str
355
+ - perf_arch.mode → _audit.perf_arch_mode (QMT 端不用)
356
+
357
+ ADR-0025 CONFIG 必含: stocks / stock / period / init_capital / backtest_start /
358
+ backtest_end / qty + history_window / history_field / warmup_period.
359
+ """
360
+ pool = cfg.get("pool", {})
361
+ stocks: list[str] = []
362
+ for _universe, body in pool.items():
363
+ codes = body.get("codes", []) if isinstance(body, dict) else []
364
+ stocks.extend(codes)
365
+ warmup = spec.get("warmup_period", 1)
366
+ state = spec.get("state_attrs", {})
367
+ data_dep = spec.get("data_dependencies", {})
368
+
369
+ # 启发式: state_attrs 注释里 grep 默认值 (int / str)
370
+ def _gauge_int(comment: str, default: int) -> int:
371
+ m = re.search(r'(?:默认|default)\s*[:=]?\s*(\d+)', str(comment), re.IGNORECASE)
372
+ if m:
373
+ return int(m.group(1))
374
+ m = re.search(r'(?:top\s+)(\d+)', str(comment), re.IGNORECASE)
375
+ if m:
376
+ return int(m.group(1))
377
+ m = re.search(r'=\s*(\d+)', str(comment))
378
+ if m:
379
+ return int(m.group(1))
380
+ return default
381
+
382
+ def _gauge_str(comment: str, default: str, pattern: str) -> str:
383
+ m = re.search(pattern, str(comment))
384
+ return m.group(1) if m else default
385
+
386
+ qty = _gauge_int(state.get("_qty", ""), 100)
387
+ top_k = _gauge_int(state.get("_top_k", ""), 5)
388
+ rebalance_time = _gauge_str(state.get("_rebalance_time", ""), "14:55:00",
389
+ r"['\"](\d{2}:\d{2}:\d{2})['\"]")
390
+ period_cfg = _gauge_str(state.get("_period", ""), "1d",
391
+ r"['\"](\d+[dm])['\"]")
392
+
393
+ # data_dependencies 取数契约
394
+ history_window = 1
395
+ history_field = "close"
396
+ hw = data_dep.get("history_window") if isinstance(data_dep, dict) else None
397
+ if isinstance(hw, dict):
398
+ history_window = int(hw.get("length", 1))
399
+ history_field = str(hw.get("field", "close"))
400
+
401
+ cfg_dict = {
402
+ # === ADR-0025: 用户可在 QMT 编辑器顶部改这些值, cert 仍有效 ===
403
+ "stocks": stocks or ["600000.SH"],
404
+ "stock": (stocks or ["600000.SH"])[0],
405
+ "period": period_cfg,
406
+ "init_capital": cfg.get("init_capital", 1_000_000.0),
407
+ "backtest_start": cfg.get("backtest_start", "20240101"),
408
+ "backtest_end": cfg.get("backtest_end", "20241231"),
409
+ "qty": qty,
410
+ "top_k": top_k,
411
+ "rebalance_time": rebalance_time,
412
+ "history_window": history_window,
413
+ "history_field": history_field,
414
+ "warmup_period": warmup,
415
+ # === _audit: QMT 端不用, 仅为 audit trail (跟 designer spec diff 对照) ===
416
+ "_audit": {
417
+ "direction_id": spec.get("direction_id", ""),
418
+ "perf_arch_mode": spec.get("perf_arch", {}).get("mode", ""),
419
+ "scenario": spec.get("scenario", "")[:80],
420
+ },
421
+ }
422
+ return json.dumps(cfg_dict, ensure_ascii=False, separators=(", ", ": "))
423
+
424
+
425
+ # ===== QMT body 顶部注入的 helper functions =====
426
+
427
+ HELPER_FUNCTIONS = '''
428
+ # ===== _hamuna_helpers (translated from akquant API surface) =====
429
+
430
+ def _hamuna_get_history(n, sym, field, period, start, end):
431
+ """akquant self.get_history(n, sym, field) → numpy 数组.
432
+
433
+ QMT get_market_data_ex 返 {sym: DataFrame}; iloc[-n:] 取最后 n 行; .values 返 numpy
434
+ 兼容 akquant np.mean / np.std 调用.
435
+ """
436
+ import pandas as _pd
437
+ _df = C.get_market_data_ex([field], [sym], period, start, end, subscribe=False)
438
+ if not _df:
439
+ return _pd.Series([], dtype=float).values
440
+ _data = _df.get(sym)
441
+ if _data is None or len(_data) < n:
442
+ return _pd.Series([], dtype=float).values
443
+ return _data[field].iloc[-n:].values
444
+
445
+
446
+ def _hamuna_order_target_percent(pct, sym, _C, init_capital):
447
+ """akquant self.order_target_percent(pct, symbol=s) → passorder 双向调仓.
448
+
449
+ 算法:
450
+ target_value = pct * 总权益 (默认 init_capital; 真值用 C.account.total_asset)
451
+ current_value = C.holdings.get(sym, {}).get('market_value', 0)
452
+ delta = target_value - current_value
453
+ last = 取最新一根 close
454
+ qty = int(abs(delta) / last / 100) * 100 (100 股取整)
455
+ delta > 0: buy; delta < 0: sell
456
+ """
457
+ _total = getattr(_C, "account", {}).get("total_asset", init_capital) if hasattr(_C, "account") else init_capital
458
+ _target = pct * _total
459
+ _current = _C.holdings.get(sym, {}).get("market_value", 0)
460
+ _delta = _target - _current
461
+ if abs(_delta) < init_capital * 0.001:
462
+ return # 阈值防抖动
463
+ _bar_date = timetag_to_datetime(_C.get_bar_timetag(_C.barpos), "%Y%m%d")
464
+ _df = _C.get_market_data_ex(["close"], [sym], CONFIG["period"], _bar_date, _bar_date, subscribe=False)
465
+ _last = float(_df[sym]["close"].iloc[-1]) if _df and _df.get(sym) is not None and len(_df[sym]) else 0
466
+ if _last <= 0:
467
+ return
468
+ _qty = int(abs(_delta) / _last / 100) * 100
469
+ if _qty == 0:
470
+ return
471
+ if _delta > 0:
472
+ passorder(23, 1101, "", sym, 11, -1, _qty, "", 0, "buy", _C)
473
+ else:
474
+ passorder(24, 1101, "", sym, 11, -1, _qty, "", 0, "sel", _C)
475
+
476
+
477
+ '''
478
+
479
+
480
+ def translate(
481
+ akquant_path: str | Path,
482
+ config_path: str | Path,
483
+ spec_path: str | Path | None,
484
+ ) -> str:
485
+ """读 akquant strategy.py → QMT body 字符串.
486
+
487
+ 错误: akquant 写法不支持时 → raise NotImplementedError (含具体不支持的 API).
488
+ """
489
+ akquant_src = Path(akquant_path).read_text(encoding="utf-8")
490
+ cfg = json.loads(Path(config_path).read_text(encoding="utf-8"))
491
+ spec: dict[str, Any] = {}
492
+ if spec_path and Path(spec_path).exists():
493
+ spec = json.loads(Path(spec_path).read_text(encoding="utf-8"))
494
+
495
+ tree = ast.parse(akquant_src, filename=str(akquant_path))
496
+ # 找 class Foo(akquant.Strategy) 子类 (or Strategy)
497
+ class_name = None
498
+ for node in tree.body:
499
+ if isinstance(node, ast.ClassDef):
500
+ bases = [ast.unparse(b) for b in node.bases]
501
+ if any("Strategy" in b for b in bases):
502
+ class_name = node.name
503
+ break
504
+ if class_name is None:
505
+ raise NotImplementedError(
506
+ "未找到 akquant.Strategy 子类 (期望 `class Foo(akquant.Strategy)`)"
507
+ )
508
+
509
+ info = _class_body_attrs(tree, class_name)
510
+ on_start = info["methods"].get("on_start")
511
+ on_bar = info["methods"].get("on_bar")
512
+ on_timer = info["methods"].get("on_timer")
513
+ # 至少要 on_bar 或 on_timer — QMT body 形态不同
514
+ if on_bar is None and on_timer is None:
515
+ raise NotImplementedError(
516
+ "akquant Strategy 必须有 on_bar(self, bar) 或 on_timer(self, payload) (QMT body 缺调度入口)"
517
+ )
518
+
519
+ # init body = translate on_start + subscribe / init 声明 (空时只保留空行)
520
+ init_lines: list[str] = []
521
+ if on_start is not None:
522
+ try:
523
+ init_lines = [_translate_stmt(s, 4) for s in on_start.body]
524
+ except NotImplementedError as e:
525
+ raise NotImplementedError(f'on_start 不支持: {e}') from e
526
+ init_body = "\n".join(init_lines) if init_lines else " pass"
527
+
528
+ # handlebar body — on_bar 翻译; 若只有 on_timer (无 on_bar) → handlebar 仅调度闸
529
+ if on_bar is not None:
530
+ try:
531
+ handlebar_lines = [_translate_stmt(s, 4) for s in on_bar.body]
532
+ except NotImplementedError as e:
533
+ raise NotImplementedError(f'on_bar 不支持: {e}') from e
534
+ handlebar_body = "\n".join(handlebar_lines) if handlebar_lines else " pass"
535
+ else:
536
+ handlebar_body = " pass # on_timer 策略: handlebar 仅调度闸"
537
+
538
+ # on_timer body → 翻译成 callback 函数 (QMT run_time 注册 'rebalance' 默认;
539
+ # 用户改 CONFIG["rebalance_time"] + 函数名同步). 仅 on_timer-only 策略生成.
540
+ callback_body = ""
541
+ if on_timer is not None and on_bar is None:
542
+ try:
543
+ timer_lines = [_translate_stmt(s, 4) for s in on_timer.body]
544
+ except NotImplementedError as e:
545
+ raise NotImplementedError(f'on_timer 不支持: {e}') from e
546
+ timer_str = "\n".join(timer_lines) if timer_lines else " pass"
547
+ callback_body = (
548
+ "\n\n"
549
+ "def rebalance(C):\n"
550
+ ' """on_timer 翻译产物 (callback 名 \'rebalance\' 默认; 用户改 CONFIG["rebalance_time"] 同步)"""\n'
551
+ f"{timer_str}\n"
552
+ )
553
+
554
+ config_py = _build_config_py(spec, cfg)
555
+
556
+ body = (
557
+ QMT_HEADER.format(config_py=config_py)
558
+ + HELPER_FUNCTIONS
559
+ + QMT_INIT_TEMPLATE.format(init_body=init_body)
560
+ + QMT_HANDLEBAR_TEMPLATE.format(handlebar_body=handlebar_body)
561
+ + callback_body
562
+ )
563
+
564
+ # ===== post-pass: akquant Bar 字段 / timestamp → QMT 字段差异 =====
565
+ # akquant 用 bar.symbol / bar.timestamp; QMT 原生 Bar 用 bar.code (无 timestamp —
566
+ # 用 C.get_bar_timetag(C.barpos) + timetag_to_datetime 拼日期).
567
+ body = body.replace("bar.symbol", "bar.code")
568
+ body = body.replace(
569
+ "bar.timestamp",
570
+ 'timetag_to_datetime(C.get_bar_timetag(C.barpos), "%Y%m%d%H%M%S")'
571
+ )
572
+ # bar.time / bar.date 在 akquant 是 REPR ALIAS (getattr 返 None) — 不替换, 让用户手改.
573
+ return body
574
+
575
+
576
+ def write_qmt_body(
577
+ akquant_path: str | Path,
578
+ config_path: str | Path,
579
+ spec_path: str | Path | None,
580
+ output_path: str | Path | None = None,
581
+ ) -> Path:
582
+ """translate + 落盘. 返回输出路径."""
583
+ body = translate(akquant_path, config_path, spec_path)
584
+ if output_path is None:
585
+ stem = Path(akquant_path).stem
586
+ output_path = Path(akquant_path).parent / f"_qmt_{stem}.py"
587
+ out = Path(output_path)
588
+ out.write_text(body, encoding="utf-8") # 落盘 utf-8 (gbk 仅 QMT 加载时解码)
589
+ return out
590
+
591
+
592
+ if __name__ == "__main__":
593
+ # CLI 入口: `hamuna_quant_cli qmt-translate <akquant.py> <cfg.json> [spec.json]`
594
+ # Round 14 前: `python -m strategy_cli.references.qmt_translate ...` (仓内 v2 skill 子目录)
595
+ # Round 14 后: 走顶层独立 pip 包 `hamuna-quant-cli`, qmt-translate 子命令统一入口
596
+ if len(sys.argv) < 3:
597
+ print(
598
+ "usage: hamuna_quant_cli qmt-translate "
599
+ "<akquant_strategy.py> <config.json> [spec_strategy.json]",
600
+ file=sys.stderr,
601
+ )
602
+ sys.exit(2)
603
+ spec_arg = sys.argv[3] if len(sys.argv) > 3 else None
604
+ try:
605
+ out = write_qmt_body(sys.argv[1], sys.argv[2], spec_arg)
606
+ print(f"QMT body saved → {out}")
607
+ except NotImplementedError as e:
608
+ print(f"QMT 转换不支持: {e}", file=sys.stderr)
609
+ sys.exit(3)
@@ -0,0 +1,2 @@
1
+ """hamuna_quant_cli.runtime — discipline / backtest / server / http / cache."""
2
+ from . import discipline, backtest, server_client, http_client, cache # noqa: F401
@@ -0,0 +1,38 @@
1
+ """v2 backtest — 委托 hamuna_quant_cli.references.akquant_runner.run_akquant_backtest.
2
+
3
+ 单入口: run_akquant_backtest(strategy_path, cfg) → hamuna 13-key dict (同 v1 driver schema).
4
+ v2 不重复实现 akquant 引擎 / 数据加载 / metrics 计算 / schema 折叠.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+
11
+ def run(strategy_path: Path, cfg: dict) -> dict:
12
+ """跑 akquant backtest, 落 hamuna 13-key dict (与 v1 driver.run_backtest 输出同 schema).
13
+
14
+ strategy_path: 含 akquant.Strategy 子类的 .py 路径 (on_bar API, NOT QMT handlebar).
15
+ cfg: 同 v1 run 的 CONFIG dict (backtest_start/end, pool / universe, init_capital, ...).
16
+
17
+ 失败模式 / 已知坑 → hamuna_quant_cli.references.akquant_runner.run_akquant_backtest:
18
+ FileNotFoundError: 数据集缺失 (universe 在 prebuilt 不存在, 或 start/end 在 window 外)
19
+ ImportError: akquant 未安装 (pip install akquant)
20
+ ValueError: cfg 缺必需 key / akquant.run_backtest 报参数错
21
+ """
22
+ from ..akquant_runner import run_akquant_backtest # Round 14: 同包 (从 strategy_cli 命名空间退出)
23
+ return run_akquant_backtest(str(strategy_path), cfg)
24
+
25
+
26
+ def _selfcheck() -> None:
27
+ """v2 backtest._selfcheck: 验 import + 调通 hamuna_quant_cli runner.
28
+
29
+ 实跑 BuyHold 2 标的 6mo 已在 cmd_run smoke 覆盖 (smoke step 3b 通过).
30
+ 这里只验: from .backtest import run 在 v2 路径下能 import.
31
+ """
32
+ from . import backtest as m
33
+ assert callable(m.run), 'backtest.run not callable'
34
+ print('OK: backtest._selfcheck (import + run callable)')
35
+
36
+
37
+ if __name__ == '__main__':
38
+ _selfcheck()