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,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,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()
|