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.
@@ -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()