hamuna-quant-cli 0.1.0__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 +297 -0
- hamuna_quant_cli/akquant_runner.py +681 -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 +461 -0
- hamuna_quant_cli/live/runner.py +570 -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 +560 -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.dist-info/METADATA +156 -0
- hamuna_quant_cli-0.1.0.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""strategy_cli.discipline (v2) — akquant API 静态审查.
|
|
2
|
+
|
|
3
|
+
v1 discipline.py 检查 QMT-style (passorder / handlebar / m_strRemark / quickTrade / is_last_bar / init+ContextInfo 形参).
|
|
4
|
+
v2 改检查 akquant 0.3.x 风格, 共 8 条 rule:
|
|
5
|
+
- 必继承 akquant.Strategy 或 (Strategy) 或 (HamunaStrategy) — HamunaStrategy 是 v2 推荐基类
|
|
6
|
+
(自身继承 akquant.Strategy, 默认 no-op compute_factors/filter_symbols, 见 base_strategy.py)
|
|
7
|
+
- 文件编码 # coding: utf-8 (v1 走 QMT 的 GBK, v2 走 UTF-8)
|
|
8
|
+
- 无 QMT globals (passorder / set_basket / get_basket / m_strRemark / quickTrade /
|
|
9
|
+
is_last_bar / subscribe_quote / run_time / after_init / XtQuantTrader / xttrader / xtdata / ContextInfo)
|
|
10
|
+
- 不混 def handlebar(...) (QMT 形态) — v2 走 akquant def on_bar(self, bar: Bar)
|
|
11
|
+
- 不混 def init(ContextInfo) (QMT 形态) — v2 走 akquant def __init__(self)
|
|
12
|
+
- bar.<field> 不取 bar.time / bar.date (akquant 0.3.x REPR ALIAS, getattr 返 None;
|
|
13
|
+
真实字段是 bar.timestamp, int ns) — 实战踩过的 bug
|
|
14
|
+
- Round 1 (0.3.x 加速原语): 同 (count, sym) 多次 get_history 不同字段应合并 get_history_multi
|
|
15
|
+
- Round 1 (0.3.x 强制): __init__ 形参含 universe 走老注入风格应改 ListParam 内联字段
|
|
16
|
+
|
|
17
|
+
调用方: __main__.py cmd_run 在 backtest.run 之前 source = strategy.py.read_text()
|
|
18
|
+
然后 check_discipline(source, cfg) → 若 list 非空, abort 退出码 3.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DisciplineError:
|
|
28
|
+
rule: str # 短代码 (e.g. "bar_field_alias_trap")
|
|
29
|
+
line: int # 触发处行号 (1-indexed)
|
|
30
|
+
msg: str # 人读错误信息
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return f'ERROR: rule={self.rule} line={self.line} {self.msg}'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# QMT 残留标识符 (出现即报 — v2 走 akquant, 任何 QMT-specific 调用都该走 v1)
|
|
37
|
+
_QMT_GLOBALS: dict[str, str] = {
|
|
38
|
+
'passorder': 'QMT 报单函数 (akquant 用 self.buy / self.sell)',
|
|
39
|
+
'set_basket': 'QMT 组合函数 (akquant 用 self 内部状态)',
|
|
40
|
+
'get_basket': 'QMT 组合函数 (akquant 用 self.get_position)',
|
|
41
|
+
'm_strRemark': 'QMT 订单字段 (akquant 不支持, 改 remark=...)',
|
|
42
|
+
'quickTrade': 'QMT subscribe_quote 参数 (akquant on_bar 自动)',
|
|
43
|
+
'is_last_bar': 'QMT handlebar gate (akquant daily 全是 last-bar)',
|
|
44
|
+
'subscribe_quote': 'QMT tick 订阅 (akquant 0.3.x on_bar 一次性)',
|
|
45
|
+
'run_time': 'QMT 定时器 (akquant enable_timer 但 API 不同)',
|
|
46
|
+
'after_init': 'QMT 初始化回调 (akquant 用 on_start / __init__ 子类)',
|
|
47
|
+
'XtQuantTrader': 'QMT native API class (v2 不引入)',
|
|
48
|
+
'XtQuantTraderCallback': 'QMT native API callback',
|
|
49
|
+
'xttrader': 'QMT native API module',
|
|
50
|
+
'xtdata': 'QMT native API module',
|
|
51
|
+
'ContextInfo': 'QMT 编辑器形参 (akquant 用 self + bar)',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---- Rule 1: 文件编码 UTF-8 ------
|
|
56
|
+
def _rule_coding_utf8(source: str) -> list[DisciplineError]:
|
|
57
|
+
"""检测文件顶部是否有 # coding: gbk / cp936 — v2 走 akquant, UTF-8 是契约."""
|
|
58
|
+
errs: list[DisciplineError] = []
|
|
59
|
+
for i, line in enumerate(source.splitlines()[:3], 1):
|
|
60
|
+
stripped = line.strip().lower()
|
|
61
|
+
if not (stripped.startswith('# -*- coding:') or stripped.startswith('# coding:')):
|
|
62
|
+
continue
|
|
63
|
+
if stripped.startswith('# -*- coding:'):
|
|
64
|
+
enc = stripped.split(':', 1)[1].strip().rstrip(' -*-').strip()
|
|
65
|
+
else:
|
|
66
|
+
enc = stripped.split(':', 1)[1].strip()
|
|
67
|
+
if enc not in ('utf-8', 'utf8'):
|
|
68
|
+
errs.append(DisciplineError(
|
|
69
|
+
rule='coding_not_utf8',
|
|
70
|
+
line=i,
|
|
71
|
+
msg=f'文件编码 = {enc!r}; v2 走 akquant, UTF-8 是契约 (QMT 旧 GBK 策略需先转码)',
|
|
72
|
+
))
|
|
73
|
+
return errs
|
|
74
|
+
return errs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---- Rule 2: 必继承 akquant.Strategy / HamunaStrategy ------
|
|
78
|
+
def _rule_akquant_strategy_subclass(tree: ast.Module) -> list[DisciplineError]:
|
|
79
|
+
"""必须有一个 class Foo(akquant.Strategy) / class Foo(Strategy) /
|
|
80
|
+
class Foo(HamunaStrategy) (后者从 strategy_cli.references.base_strategy 导入,
|
|
81
|
+
HamunaStrategy 自身继承 akquant.Strategy, v2 skill 推荐基类)."""
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if not isinstance(node, ast.ClassDef):
|
|
84
|
+
continue
|
|
85
|
+
for base in node.bases:
|
|
86
|
+
bname = None
|
|
87
|
+
if isinstance(base, ast.Attribute):
|
|
88
|
+
bname = base.attr
|
|
89
|
+
elif isinstance(base, ast.Name):
|
|
90
|
+
bname = base.id
|
|
91
|
+
if bname in ('Strategy', 'HamunaStrategy'):
|
|
92
|
+
return []
|
|
93
|
+
return [DisciplineError(
|
|
94
|
+
rule='missing_akquant_strategy_subclass',
|
|
95
|
+
line=1,
|
|
96
|
+
msg='未找到 `class Xxx(akquant.Strategy)` / `class Xxx(Strategy)` / '
|
|
97
|
+
'`class Xxx(HamunaStrategy)` 子类; v2 策略必须继承 akquant.Strategy 或 HamunaStrategy',
|
|
98
|
+
)]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- Rule 3: 无 QMT globals ------
|
|
102
|
+
def _rule_no_qmt_globals(tree: ast.Module) -> list[DisciplineError]:
|
|
103
|
+
"""扫描所有 Name / Attribute / Import, 命中 QMT 标识符即报."""
|
|
104
|
+
errs: list[DisciplineError] = []
|
|
105
|
+
seen: set[str] = set() # 防同一标识符多行刷屏
|
|
106
|
+
|
|
107
|
+
def _check(ident: str, line: int) -> None:
|
|
108
|
+
if ident in _QMT_GLOBALS and ident not in seen:
|
|
109
|
+
seen.add(ident)
|
|
110
|
+
errs.append(DisciplineError(
|
|
111
|
+
rule='qmt_global_leaked',
|
|
112
|
+
line=line,
|
|
113
|
+
msg=f'QMT 标识符 {ident!r}: {_QMT_GLOBALS[ident]}',
|
|
114
|
+
))
|
|
115
|
+
|
|
116
|
+
for node in ast.walk(tree):
|
|
117
|
+
if isinstance(node, ast.Name):
|
|
118
|
+
_check(node.id, node.lineno)
|
|
119
|
+
elif isinstance(node, ast.Attribute):
|
|
120
|
+
_check(node.attr, node.lineno)
|
|
121
|
+
elif isinstance(node, ast.Import):
|
|
122
|
+
for alias in node.names:
|
|
123
|
+
_check(alias.name.split('.')[0], node.lineno)
|
|
124
|
+
elif isinstance(node, ast.ImportFrom):
|
|
125
|
+
if node.module:
|
|
126
|
+
_check(node.module.split('.')[0], node.lineno)
|
|
127
|
+
for alias in node.names:
|
|
128
|
+
_check(alias.name, node.lineno)
|
|
129
|
+
return errs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---- Rule 4: 不混 def handlebar(...) (QMT 形态) ------
|
|
133
|
+
def _rule_no_handlebar(tree: ast.Module) -> list[DisciplineError]:
|
|
134
|
+
"""v2 走 akquant on_bar(bar); def handlebar(...) 是 QMT 形态, 报错."""
|
|
135
|
+
errs: list[DisciplineError] = []
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if not isinstance(node, ast.FunctionDef):
|
|
138
|
+
continue
|
|
139
|
+
if node.name == 'handlebar':
|
|
140
|
+
errs.append(DisciplineError(
|
|
141
|
+
rule='handlebar_not_akquant',
|
|
142
|
+
line=node.lineno,
|
|
143
|
+
msg='def handlebar(...) 是 QMT 形态; v2 走 akquant, 用 def on_bar(self, bar: Bar): ...',
|
|
144
|
+
))
|
|
145
|
+
return errs
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---- Rule 5: 不混 def init(ContextInfo) ------
|
|
149
|
+
def _rule_no_init_contextinfo(tree: ast.Module) -> list[DisciplineError]:
|
|
150
|
+
"""QMT 形态 init(ContextInfo) vs akquant 形态 __init__(self) — 不混用."""
|
|
151
|
+
errs: list[DisciplineError] = []
|
|
152
|
+
for node in ast.walk(tree):
|
|
153
|
+
if not isinstance(node, ast.FunctionDef):
|
|
154
|
+
continue
|
|
155
|
+
if node.name != 'init':
|
|
156
|
+
continue
|
|
157
|
+
if not node.args.args:
|
|
158
|
+
continue
|
|
159
|
+
first = node.args.args[0].arg
|
|
160
|
+
if first == 'ContextInfo':
|
|
161
|
+
errs.append(DisciplineError(
|
|
162
|
+
rule='init_contextinfo_form',
|
|
163
|
+
line=node.lineno,
|
|
164
|
+
msg='def init(ContextInfo) 是 QMT 形态; v2 走 akquant, '
|
|
165
|
+
'用 __init__(self) + self.subscribe + warmup_period',
|
|
166
|
+
))
|
|
167
|
+
return errs
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- Rule 6: bar.time / bar.date 是 akquant 0.3.x REPR ALIAS (实战踩过) ------
|
|
171
|
+
def _rule_bar_field_uses_timestamp(tree: ast.Module) -> list[DisciplineError]:
|
|
172
|
+
"""akquant 0.3.x Bar 字段名陷阱: bar.time / bar.date 是 REPR ALIAS, getattr 返 None.
|
|
173
|
+
检 on_bar 体内 bar.<field> 访问, 不允许 bar.time / bar.date 取日期.
|
|
174
|
+
真实字段是 bar.timestamp (int ns), 自定义 date 字段若存在也被拦 — 避免混用.
|
|
175
|
+
"""
|
|
176
|
+
errs: list[DisciplineError] = []
|
|
177
|
+
for node in ast.walk(tree):
|
|
178
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
179
|
+
continue
|
|
180
|
+
if node.name != 'on_bar':
|
|
181
|
+
continue
|
|
182
|
+
for child in ast.walk(node):
|
|
183
|
+
if not isinstance(child, ast.Attribute):
|
|
184
|
+
continue
|
|
185
|
+
if not isinstance(child.value, ast.Name):
|
|
186
|
+
continue
|
|
187
|
+
if child.value.id == 'bar' and child.attr in ('time', 'date'):
|
|
188
|
+
errs.append(DisciplineError(
|
|
189
|
+
rule='bar_field_alias_trap',
|
|
190
|
+
line=child.lineno,
|
|
191
|
+
msg=f'bar.{child.attr} 是 akquant 0.3.x REPR ALIAS, getattr 返 None — '
|
|
192
|
+
f'用 bar.timestamp (int ns) 转 datetime.fromtimestamp(ts/1e9).date()',
|
|
193
|
+
))
|
|
194
|
+
return errs
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---- Rule 7 (Round 1): 0.3.x 起 get_history 多次应改 get_history_multi -----
|
|
200
|
+
def _rule_get_history_batched(tree: ast.Module) -> list[DisciplineError]:
|
|
201
|
+
"""akquant 0.3.x 提供 `self.get_history_multi(count, sym, fields=...)`, 一次 FFI 拉多字段.
|
|
202
|
+
|
|
203
|
+
检 on_bar / on_timer 体内 self.get_history(...) 调用, 同一 (sym, count) 出现 >= 2 个
|
|
204
|
+
不同 field → 报建议合并为 get_history_multi. 单字段 / 多 sym 是正常的, 不报.
|
|
205
|
+
|
|
206
|
+
ponytail: 启发式, 只看字面 call, 不追变量 (e.g. sym = bar.symbol; c1 = self.get_history(n, sym, "close")
|
|
207
|
+
+ c2 = self.get_history(n, sym, "volume") -> 报). 实测 false-positive 极低 (罕有人写多 sym 但同 (n,sym,field)).
|
|
208
|
+
"""
|
|
209
|
+
errs: list[DisciplineError] = []
|
|
210
|
+
target_funcs = {"on_bar", "on_timer", "on_cross_section"}
|
|
211
|
+
for node in ast.walk(tree):
|
|
212
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
continue
|
|
214
|
+
if node.name not in target_funcs:
|
|
215
|
+
continue
|
|
216
|
+
# 收集 (count, sym) -> {field}
|
|
217
|
+
calls: dict[tuple, set] = {}
|
|
218
|
+
for child in ast.walk(node):
|
|
219
|
+
if not isinstance(child, ast.Call):
|
|
220
|
+
continue
|
|
221
|
+
func = child.func
|
|
222
|
+
if not (isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name)):
|
|
223
|
+
continue
|
|
224
|
+
if func.value.id != "self" or func.attr != "get_history":
|
|
225
|
+
continue
|
|
226
|
+
args = child.args
|
|
227
|
+
if len(args) < 3:
|
|
228
|
+
continue
|
|
229
|
+
count_key = ast.dump(args[0])
|
|
230
|
+
sym_key = ast.dump(args[1])
|
|
231
|
+
field_key = ast.dump(args[2])
|
|
232
|
+
k = (count_key, sym_key)
|
|
233
|
+
calls.setdefault(k, set()).add(field_key)
|
|
234
|
+
for (_ck, _sk), fields in calls.items():
|
|
235
|
+
if len(fields) >= 2:
|
|
236
|
+
errs.append(DisciplineError(
|
|
237
|
+
rule="get_history_not_batched",
|
|
238
|
+
line=node.lineno,
|
|
239
|
+
msg=(f"{node.name} 体内同一 (count, sym) 多次 self.get_history(... field=...) "
|
|
240
|
+
f"(字段数 {len(fields)}); akquant 0.3.x 提供 self.get_history_multi(count, sym, "
|
|
241
|
+
f"fields=('close', 'volume', ...)) 一次 FFI 拉多字段 - 跨 sym N 调用时省 "
|
|
242
|
+
f"50~80% FFI 跨越"),
|
|
243
|
+
))
|
|
244
|
+
return errs
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- Rule 8 (Round 1): 0.3.x 起 universe 必须走 ParamModel 风格 -----
|
|
248
|
+
def _rule_universe_param_style(tree: ast.Module) -> list[DisciplineError]:
|
|
249
|
+
"""akquant 0.3.x 弃用 `__init__(self, universe=None)` 注入风格, 必须用:
|
|
250
|
+
|
|
251
|
+
class S(Strategy):
|
|
252
|
+
universe: list = ListParam(default=[])
|
|
253
|
+
|
|
254
|
+
检策略类的 __init__ 是否收 `universe` 形参; 若收 -> 报迁移提示 (0.3 strict 拒收).
|
|
255
|
+
|
|
256
|
+
ponytail: 仅警告 (severity=warn), 不强制 abort - 因为 0.3 也允许 0.2 老写法
|
|
257
|
+
(engine 调 __init__ 时 TypeError + warning 不会 fail backtest); 但 runner 检测
|
|
258
|
+
不到 universe 字段, 不会注入 -> 静默 0 trades. 显式报让用户立即感知.
|
|
259
|
+
"""
|
|
260
|
+
errs: list[DisciplineError] = []
|
|
261
|
+
for node in ast.walk(tree):
|
|
262
|
+
if not isinstance(node, ast.ClassDef):
|
|
263
|
+
continue
|
|
264
|
+
is_strat = False
|
|
265
|
+
for base in node.bases:
|
|
266
|
+
base_str = ast.unparse(base) if hasattr(ast, "unparse") else ""
|
|
267
|
+
if "Strategy" in base_str:
|
|
268
|
+
is_strat = True
|
|
269
|
+
break
|
|
270
|
+
if not is_strat:
|
|
271
|
+
continue
|
|
272
|
+
for child in node.body:
|
|
273
|
+
if isinstance(child, ast.FunctionDef) and child.name == "__init__":
|
|
274
|
+
for arg in child.args.args:
|
|
275
|
+
if arg.arg == "universe":
|
|
276
|
+
errs.append(DisciplineError(
|
|
277
|
+
rule="universe_init_style_deprecated",
|
|
278
|
+
line=child.lineno,
|
|
279
|
+
msg=(f"{node.name}.__init__ 形参含 `universe` - akquant 0.3.x 严格拒收 "
|
|
280
|
+
f"老风格 (TypeError); 改 `universe: list = ListParam(default=[])` "
|
|
281
|
+
f"类字段, 读 `self.params.universe`, runner 自动注入"),
|
|
282
|
+
))
|
|
283
|
+
break
|
|
284
|
+
return errs
|
|
285
|
+
|
|
286
|
+
# ---- Rule 9: 指标规范 — 已有指标用 akquant.talib, 自定义指标向量化 -----
|
|
287
|
+
# 常见手写指标模式 (有 akquant.talib 内置替代):
|
|
288
|
+
# closes[-n:].mean() / pd.rolling(n).mean() → talib.SMA
|
|
289
|
+
# closes[-n:].std() / pd.rolling(n).std() → talib.STDDEV
|
|
290
|
+
# np.mean(closes[-n:]) / np.std(closes[-n:]) → talib.SMA/STDDEV
|
|
291
|
+
_HANDROLLED_INDICATOR_PATTERNS = [
|
|
292
|
+
# (匹配 AST 特征, 提示信息)
|
|
293
|
+
("rolling", "手写 rolling().mean()/std() 指标 - 用 akquant.talib.SMA/STDDEV"),
|
|
294
|
+
("mean_slice", "手写 closes[-n:].mean() - 用 akquant.talib.SMA"),
|
|
295
|
+
("std_slice", "手写 closes[-n:].std() - 用 akquant.talib.STDDEV"),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _rule_indicator_uses_builtin(tree: ast.Module) -> list[DisciplineError]:
|
|
300
|
+
"""指标规范: 已有指标必须用 akquant.talib, 禁止手写; 自定义指标必须向量化.
|
|
301
|
+
|
|
302
|
+
拦:
|
|
303
|
+
1. `.rolling(...)` 手写滚动指标 (有 talib 替代)
|
|
304
|
+
2. `closes[-n:].mean()` / `.std()` 手写切片统计 (有 talib 替代)
|
|
305
|
+
3. `for i in range(len(...))` 逐根 Python 循环算指标
|
|
306
|
+
|
|
307
|
+
不拦: 自定义指标用向量化 rolling/cumsum/shift 且无 talib 替代 (正常).
|
|
308
|
+
"""
|
|
309
|
+
errs: list[DisciplineError] = []
|
|
310
|
+
for node in ast.walk(tree):
|
|
311
|
+
# 1) .rolling( 调用 (手写滚动指标)
|
|
312
|
+
if isinstance(node, ast.Call):
|
|
313
|
+
func = node.func
|
|
314
|
+
if isinstance(func, ast.Attribute) and func.attr == "rolling":
|
|
315
|
+
errs.append(DisciplineError(
|
|
316
|
+
rule="indicator_not_using_builtin",
|
|
317
|
+
line=node.lineno,
|
|
318
|
+
msg="手写 .rolling() 滚动指标 - akquant.talib 有内置 (SMA/STDDEV/BBANDS 等), "
|
|
319
|
+
"用 `talib.SMA(closes, n, as_series=True)` 替代 (Rust 加速, 见 references/indicators.md)",
|
|
320
|
+
))
|
|
321
|
+
# 2) mean/std 切片 (closes[-n:].mean())
|
|
322
|
+
if isinstance(func, ast.Attribute) and func.attr in ("mean", "std"):
|
|
323
|
+
# 调用者是 Subscript (closes[-n:]) → 手写切片统计
|
|
324
|
+
if isinstance(node.func.value, ast.Subscript):
|
|
325
|
+
errs.append(DisciplineError(
|
|
326
|
+
rule="indicator_not_using_builtin",
|
|
327
|
+
line=node.lineno,
|
|
328
|
+
msg=f"手写切片 .{func.attr}() - 用 akquant.talib."
|
|
329
|
+
f"{'SMA' if func.attr == 'mean' else 'STDDEV'}(closes, n, as_series=True) 替代",
|
|
330
|
+
))
|
|
331
|
+
# 3) 逐根 Python 循环算指标 (for i in range(len(closes)))
|
|
332
|
+
if isinstance(node, ast.For):
|
|
333
|
+
# 循环变量是 range(len(...)) → 疑似逐根算
|
|
334
|
+
iter_node = node.iter
|
|
335
|
+
if (isinstance(iter_node, ast.Call)
|
|
336
|
+
and isinstance(iter_node.func, ast.Name)
|
|
337
|
+
and iter_node.func.id == "range"):
|
|
338
|
+
# 粗略: range(len( 出现即提示 (可能是合法循环, 但指标计算场景多为逐根)
|
|
339
|
+
# 仅在循环体内有下标访问 closes[i] 时更可能是指标
|
|
340
|
+
has_subscript = any(
|
|
341
|
+
isinstance(c, ast.Subscript) and isinstance(c.value, ast.Name)
|
|
342
|
+
for c in ast.walk(node)
|
|
343
|
+
)
|
|
344
|
+
if has_subscript:
|
|
345
|
+
errs.append(DisciplineError(
|
|
346
|
+
rule="indicator_not_using_builtin",
|
|
347
|
+
line=node.lineno,
|
|
348
|
+
msg="逐根 Python 循环算指标 (for i in range(len(closes))) - 必须向量化 "
|
|
349
|
+
"(rolling/cumsum/shift) + akquant.talib 原语组合, 见 references/indicators.md",
|
|
350
|
+
))
|
|
351
|
+
return errs
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---- Rule 10 (2026-08-25 用户实战踩坑 + 实测): get_history 返回值必须做 NaN 完整性检查 ----
|
|
355
|
+
def _rule_get_history_nan_guard(tree: ast.Module) -> list[DisciplineError]:
|
|
356
|
+
"""akquant 0.3.x `get_history(N, sym, field)` 返回**固定长度 N** 的 ndarray, 历史不足时
|
|
357
|
+
用 **NaN 填充** (实测: warmup_period=3 + get_history(20) → len=20 但前 17 个是 NaN).
|
|
358
|
+
因此 `len(closes) < N: return` guard **无效** (len 恒等于 N) — 必须检查 NaN 才算数.
|
|
359
|
+
|
|
360
|
+
拦: on_bar / on_timer / on_cross_section 体内, get_history 结果被当有效数据用
|
|
361
|
+
(调 .mean() / .std() / 下标切片 / 进排序), 但同一作用域内**没有**对该变量的
|
|
362
|
+
NaN 完整性检查 (`np.isnan(...)` / `.isna()` / `np.isfinite(...)` / `!= arr`).
|
|
363
|
+
|
|
364
|
+
不拦: 结果被 NaN 检查保护过 / 只赋不读 / 单字段直接透传 (e.g. bar 字段直用).
|
|
365
|
+
|
|
366
|
+
ponytail: AST 启发式, 检测同一函数体内是否存在对目标变量的 nan 检查调用.
|
|
367
|
+
误报极低 — 正常策略对 get_history 结果做计算前必有 warmup guard 或 NaN 检查.
|
|
368
|
+
"""
|
|
369
|
+
errs: list[DisciplineError] = []
|
|
370
|
+
target_funcs = {"on_bar", "on_timer", "on_cross_section"}
|
|
371
|
+
for node in ast.walk(tree):
|
|
372
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
373
|
+
continue
|
|
374
|
+
if node.name not in target_funcs:
|
|
375
|
+
continue
|
|
376
|
+
# 收集 get_history 赋值: var -> 是否被 NaN 检查保护
|
|
377
|
+
assigned: dict[str, list[ast.Assign | ast.AnnAssign]] = {}
|
|
378
|
+
for child in ast.walk(node):
|
|
379
|
+
if not isinstance(child, (ast.Assign, ast.AnnAssign)):
|
|
380
|
+
continue
|
|
381
|
+
target = child.targets[0] if isinstance(child, ast.Assign) else child.target
|
|
382
|
+
if not isinstance(target, ast.Name):
|
|
383
|
+
continue
|
|
384
|
+
val = child.value
|
|
385
|
+
if not isinstance(val, ast.Call):
|
|
386
|
+
continue
|
|
387
|
+
func = val.func
|
|
388
|
+
if not (isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name)
|
|
389
|
+
and func.value.id == "self" and func.attr == "get_history"):
|
|
390
|
+
continue
|
|
391
|
+
assigned.setdefault(target.id, []).append(child)
|
|
392
|
+
if not assigned:
|
|
393
|
+
continue
|
|
394
|
+
# 收集函数体内对每个 var 的 NaN 检查
|
|
395
|
+
protected: set[str] = set()
|
|
396
|
+
for child in ast.walk(node):
|
|
397
|
+
if isinstance(child, ast.Call):
|
|
398
|
+
func = child.func
|
|
399
|
+
# np.isnan(x) / x.isna() / np.isfinite(x) / x != x
|
|
400
|
+
if isinstance(func, ast.Attribute) and func.attr in ("isna", "isnan", "isfinite"):
|
|
401
|
+
for a in child.args:
|
|
402
|
+
if isinstance(a, ast.Name) and a.id in assigned:
|
|
403
|
+
protected.add(a.id)
|
|
404
|
+
if isinstance(func, ast.Name) and func.id in ("isnan", "isfinite"):
|
|
405
|
+
for a in child.args:
|
|
406
|
+
if isinstance(a, ast.Name) and a.id in assigned:
|
|
407
|
+
protected.add(a.id)
|
|
408
|
+
# x != x (NaN 自比较惯用法)
|
|
409
|
+
for c in ast.walk(child):
|
|
410
|
+
if isinstance(c, ast.Compare) and isinstance(c.ops[0], ast.NotEq):
|
|
411
|
+
for comp in c.comparators:
|
|
412
|
+
if isinstance(comp, ast.Name) and comp.id in assigned:
|
|
413
|
+
protected.add(comp.id)
|
|
414
|
+
# 检测 get_history 结果被当有效数据用但未保护
|
|
415
|
+
for var_name, assigns in assigned.items():
|
|
416
|
+
if var_name in protected:
|
|
417
|
+
continue
|
|
418
|
+
# 找这个 var 被消费的地方: .mean() / .std() / [slice] / np.mean(x) / sorted(x)
|
|
419
|
+
consumed = False
|
|
420
|
+
for c in ast.walk(node):
|
|
421
|
+
if isinstance(c, ast.Call):
|
|
422
|
+
cf = c.func
|
|
423
|
+
# 形态 A: closes.mean() / closes.std() (attr 调自身)
|
|
424
|
+
if isinstance(cf, ast.Attribute) and isinstance(cf.value, ast.Name):
|
|
425
|
+
if cf.value.id == var_name and cf.attr in ("mean", "std", "min", "max", "sum", "iloc", "tolist"):
|
|
426
|
+
consumed = True
|
|
427
|
+
break
|
|
428
|
+
# 形态 B: np.mean(closes) / sorted(closes) (var 作参数)
|
|
429
|
+
if any(isinstance(a, ast.Name) and a.id == var_name for a in c.args):
|
|
430
|
+
# 排除把 var 传给 isna/isfinite 类检查 (那是保护, 不是消费)
|
|
431
|
+
fn_name = ""
|
|
432
|
+
if isinstance(cf, ast.Name):
|
|
433
|
+
fn_name = cf.id
|
|
434
|
+
elif isinstance(cf, ast.Attribute):
|
|
435
|
+
fn_name = cf.attr
|
|
436
|
+
if fn_name not in ("isna", "isnan", "isfinite", "all", "any", "len"):
|
|
437
|
+
consumed = True
|
|
438
|
+
break
|
|
439
|
+
if isinstance(c, ast.Subscript) and isinstance(c.value, ast.Name) and c.value.id == var_name:
|
|
440
|
+
consumed = True
|
|
441
|
+
break
|
|
442
|
+
if consumed:
|
|
443
|
+
errs.append(DisciplineError(
|
|
444
|
+
rule="get_history_nan_guard",
|
|
445
|
+
line=assigns[0].lineno,
|
|
446
|
+
msg=(f"{node.name} 内 `{var_name} = self.get_history(...)` 结果被当有效数据用 "
|
|
447
|
+
f"(mean/std/切片/排序), 但无 NaN 完整性检查. akquant 0.3.x get_history "
|
|
448
|
+
f"返固定长度 N, 历史不足时 NaN 填充 (len<N guard 无效!) — "
|
|
449
|
+
f"先 `if np.isnan({var_name}).any(): return` 或 `valid = "
|
|
450
|
+
f"{var_name}[~np.isnan({var_name})]`"),
|
|
451
|
+
))
|
|
452
|
+
return errs
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---- 聚合入口 ------
|
|
456
|
+
def check_discipline(source: str, config: dict) -> list[DisciplineError]:
|
|
457
|
+
"""返 0~N 条 DisciplineError; 空 list = 通过. cmd_run 在 backtest.run 之前调."""
|
|
458
|
+
try:
|
|
459
|
+
tree = ast.parse(source)
|
|
460
|
+
except SyntaxError as e:
|
|
461
|
+
return [DisciplineError(
|
|
462
|
+
rule='syntax_error',
|
|
463
|
+
line=e.lineno or 1,
|
|
464
|
+
msg=f'parse failed: {e.msg}',
|
|
465
|
+
)]
|
|
466
|
+
|
|
467
|
+
errs: list[DisciplineError] = []
|
|
468
|
+
errs += _rule_coding_utf8(source)
|
|
469
|
+
errs += _rule_akquant_strategy_subclass(tree)
|
|
470
|
+
errs += _rule_no_qmt_globals(tree)
|
|
471
|
+
errs += _rule_no_handlebar(tree)
|
|
472
|
+
errs += _rule_no_init_contextinfo(tree)
|
|
473
|
+
errs += _rule_bar_field_uses_timestamp(tree)
|
|
474
|
+
errs += _rule_get_history_batched(tree)
|
|
475
|
+
errs += _rule_universe_param_style(tree)
|
|
476
|
+
errs += _rule_indicator_uses_builtin(tree)
|
|
477
|
+
errs += _rule_get_history_nan_guard(tree)
|
|
478
|
+
return errs
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _selfcheck() -> None:
|
|
482
|
+
"""v2 discipline._selfcheck: 跑一份合规 + 一份违规, 4 个 buy-and-hold 策略.
|
|
483
|
+
|
|
484
|
+
合规: class BuyHold(Strategy): on_bar(self, bar): self.buy(bar.symbol, 100)
|
|
485
|
+
违规: def handlebar(bar): passorder(...) → 报 qmt_global_leaked + handlebar_not_akquant
|
|
486
|
+
"""
|
|
487
|
+
good = '''
|
|
488
|
+
from akquant import Strategy
|
|
489
|
+
|
|
490
|
+
class BuyHold(Strategy):
|
|
491
|
+
warmup_period = 1
|
|
492
|
+
def on_bar(self, bar):
|
|
493
|
+
if self.get_position(bar.symbol) == 0:
|
|
494
|
+
self.buy(bar.symbol, 100)
|
|
495
|
+
'''
|
|
496
|
+
errs = check_discipline(good, {})
|
|
497
|
+
assert not errs, f'合规策略不应被拦: {errs}'
|
|
498
|
+
print(f'OK: 合规策略 0 违规')
|
|
499
|
+
|
|
500
|
+
bad = '''# coding: gbk
|
|
501
|
+
def init(ContextInfo):
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
def handlebar(ContextInfo):
|
|
505
|
+
passorder(23, 1101, '600000.SH', 0, 0, 100, 0, '', 'remark')
|
|
506
|
+
quickTrade = 2
|
|
507
|
+
'''
|
|
508
|
+
errs = check_discipline(bad, {})
|
|
509
|
+
rules = {e.rule for e in errs}
|
|
510
|
+
assert 'coding_not_utf8' in rules, f'应拦 GBK: {errs}'
|
|
511
|
+
assert 'qmt_global_leaked' in rules, f'应拦 QMT globals: {errs}'
|
|
512
|
+
assert 'init_contextinfo_form' in rules, f'应拦 init(ContextInfo): {errs}'
|
|
513
|
+
assert 'handlebar_not_akquant' in rules, f'应拦 handlebar: {errs}'
|
|
514
|
+
assert 'missing_akquant_strategy_subclass' in rules, f'应拦无 Strategy: {errs}'
|
|
515
|
+
print(f'OK: 违规策略拦 {len(errs)} 条 (rules={sorted(rules)})')
|
|
516
|
+
|
|
517
|
+
# bar.time 陷阱
|
|
518
|
+
bad_time = '''
|
|
519
|
+
from akquant import Strategy
|
|
520
|
+
class MyStrat(Strategy):
|
|
521
|
+
def on_bar(self, bar):
|
|
522
|
+
d = bar.time # 实际 attr 是 timestamp
|
|
523
|
+
'''
|
|
524
|
+
errs = check_discipline(bad_time, {})
|
|
525
|
+
assert any(e.rule == 'bar_field_alias_trap' for e in errs), f'应拦 bar.time: {errs}'
|
|
526
|
+
print(f'OK: bar.time 陷阱拦住')
|
|
527
|
+
|
|
528
|
+
# Rule 10: get_history 返回值无 NaN 检查被当有效数据用 → 拦
|
|
529
|
+
bad_nan = '''
|
|
530
|
+
from akquant import Strategy
|
|
531
|
+
import numpy as np
|
|
532
|
+
class MyStrat(Strategy):
|
|
533
|
+
warmup_period = 21
|
|
534
|
+
def on_bar(self, bar):
|
|
535
|
+
closes = self.get_history(20, bar.symbol, 'close')
|
|
536
|
+
fast_ma = np.mean(closes) # NaN 填充时静默 NaN
|
|
537
|
+
'''
|
|
538
|
+
errs = check_discipline(bad_nan, {})
|
|
539
|
+
assert any(e.rule == 'get_history_nan_guard' for e in errs), f'应拦 NaN guard: {errs}'
|
|
540
|
+
print(f'OK: get_history NaN guard 拦 (结果被 mean 用但无检查)')
|
|
541
|
+
|
|
542
|
+
# 有 NaN 检查 → 放行
|
|
543
|
+
good_nan = '''
|
|
544
|
+
from akquant import Strategy
|
|
545
|
+
import numpy as np
|
|
546
|
+
class MyStrat(Strategy):
|
|
547
|
+
warmup_period = 21
|
|
548
|
+
def on_bar(self, bar):
|
|
549
|
+
closes = self.get_history(20, bar.symbol, 'close')
|
|
550
|
+
if np.isnan(closes).any():
|
|
551
|
+
return
|
|
552
|
+
fast_ma = np.mean(closes)
|
|
553
|
+
'''
|
|
554
|
+
errs = check_discipline(good_nan, {})
|
|
555
|
+
assert not any(e.rule == 'get_history_nan_guard' for e in errs), f'有 NaN 检查应放行: {errs}'
|
|
556
|
+
print(f'OK: NaN guard 放行 (有 np.isnan 检查)')
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == '__main__':
|
|
560
|
+
_selfcheck()
|