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,255 @@
|
|
|
1
|
+
"""strategy_cli.runtime.cache: 本地缓存 (ADR-015 ~/.hamuna/data_cache)。
|
|
2
|
+
|
|
3
|
+
格式:
|
|
4
|
+
- K 线 (大体积, 频繁): parquet
|
|
5
|
+
- 其他 (基本面/股本/股东/StockCode): JSON
|
|
6
|
+
|
|
7
|
+
TTL 按 endpoint 粒度; 文件 mtime 超 TTL 视为 miss。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# 默认缓存根: ~/.hamuna/data_cache (符合 ADR-015 §303)
|
|
18
|
+
_CACHE_ROOT = Path.home() / '.hamuna' / 'data_cache'
|
|
19
|
+
|
|
20
|
+
# endpoint 粒度 TTL (秒)
|
|
21
|
+
# K 线 = 准实时 (5min); 财务 = 当日有效 (1d); StockCode = 当日有效 (1d)
|
|
22
|
+
_TTL: dict[str, int] = {
|
|
23
|
+
'StockBars': 300, # 5 min
|
|
24
|
+
'RealTime': 60, # 1 min
|
|
25
|
+
'StockHq': 300,
|
|
26
|
+
'StockFinancial': 86400, # 1 day
|
|
27
|
+
'StockAnnualReport': 86400,
|
|
28
|
+
'StockQuarterlyReport': 86400,
|
|
29
|
+
'StockShareholder': 86400,
|
|
30
|
+
'StockShareCapital': 86400,
|
|
31
|
+
'StockBasicInfo': 86400,
|
|
32
|
+
'StockBond': 86400,
|
|
33
|
+
'ETFInfo': 86400,
|
|
34
|
+
'StockCode': 86400,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_PARQUET_ENDPOINTS = {'StockBars', 'RealTime', 'StockHq'}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cache_dir(endpoint: str) -> Path:
|
|
41
|
+
p = _CACHE_ROOT / endpoint
|
|
42
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cache_key(params: dict[str, Any]) -> str:
|
|
47
|
+
"""参数 dict → 稳定 hash (sorted keys, urlencoded-style)."""
|
|
48
|
+
items = sorted((k, str(v)) for k, v in params.items() if v is not None)
|
|
49
|
+
raw = '&'.join(f'{k}={v}' for k, v in items)
|
|
50
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_fresh(path: Path, endpoint: str) -> bool:
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return False
|
|
56
|
+
ttl = _TTL.get(endpoint, 3600)
|
|
57
|
+
return (time.time() - path.stat().st_mtime) < ttl
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read(endpoint: str, params: dict[str, Any]) -> dict | list | None:
|
|
61
|
+
"""命中返回解析后 dict/list; miss/过期返回 None。"""
|
|
62
|
+
key = cache_key(params)
|
|
63
|
+
if endpoint in _PARQUET_ENDPOINTS:
|
|
64
|
+
path = cache_dir(endpoint) / f'{key}.parquet'
|
|
65
|
+
if not is_fresh(path, endpoint):
|
|
66
|
+
return None
|
|
67
|
+
return _read_parquet(path)
|
|
68
|
+
path = cache_dir(endpoint) / f'{key}.json'
|
|
69
|
+
if not is_fresh(path, endpoint):
|
|
70
|
+
return None
|
|
71
|
+
with path.open(encoding='utf-8') as f:
|
|
72
|
+
return json.load(f)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write(endpoint: str, params: dict[str, Any], data: dict | list) -> None:
|
|
76
|
+
key = cache_key(params)
|
|
77
|
+
if endpoint in _PARQUET_ENDPOINTS and isinstance(data, list):
|
|
78
|
+
path = cache_dir(endpoint) / f'{key}.parquet'
|
|
79
|
+
_write_parquet(path, data)
|
|
80
|
+
else:
|
|
81
|
+
path = cache_dir(endpoint) / f'{key}.json'
|
|
82
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
|
|
83
|
+
encoding='utf-8')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear(endpoint: str | None = None) -> int:
|
|
87
|
+
"""清除缓存; endpoint=None 清除全部。返回删除文件数。"""
|
|
88
|
+
n = 0
|
|
89
|
+
targets = [_CACHE_ROOT / endpoint] if endpoint else [_CACHE_ROOT]
|
|
90
|
+
for d in targets:
|
|
91
|
+
if not d.exists():
|
|
92
|
+
continue
|
|
93
|
+
for p in d.glob('**/*'):
|
|
94
|
+
if p.is_file():
|
|
95
|
+
p.unlink()
|
|
96
|
+
n += 1
|
|
97
|
+
return n
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---- 回测数据集 (无 TTL, 历史数据不变) ----
|
|
101
|
+
|
|
102
|
+
# 数据集根: ~/.hamuna/data_cache/datasets/<code>_<DateType>_fq<FuQuanType>.parquet
|
|
103
|
+
# 与 5min 缓存 (StockBars/<hash>.parquet) 分开 — 数据集是"回测专用完整历史",
|
|
104
|
+
# 不随单次请求 TTL 过期。key 只含 (code, DateType, FuQuanType), 窗口内切片由读取方做。
|
|
105
|
+
_DATASET_DIR = _CACHE_ROOT / 'datasets'
|
|
106
|
+
|
|
107
|
+
# 回测数据集最小 bar 数 — 用户规约 "至少 3 年 1000 根"。A 股年约 240 交易日,
|
|
108
|
+
# 3 年 ≈ 750 根, 到不了 1000; 故以 1000 根为硬门槛 (约 4.1 年), 自然覆盖 3 年。
|
|
109
|
+
# 转债例外: 历史天然短 (2017 后起量, 多数上市 1-2 年), 硬套 1000 根会让几乎
|
|
110
|
+
# 全部转债每次回测都重建数据集 (慢到不可用)。转债用 240 根 (约 1 年)。
|
|
111
|
+
DATASET_MIN_BARS = 1000
|
|
112
|
+
DATASET_MIN_BARS_CB = 240
|
|
113
|
+
|
|
114
|
+
# 短样本数据集 TTL (秒): 次新股/新转债历史天然不足 DATASET_MIN_BARS, 若"不足即 miss"
|
|
115
|
+
# 会导致每次回测都重建 (实测 889 只转债里 296 只 <240 根, 反复重建 ~74s)。
|
|
116
|
+
# 构建过一次就临时复用, TTL 到期重建以拉取新累积的历史 — 短样本数据在上市初期
|
|
117
|
+
# 会变长, 不能像达标样本那样无 TTL 永久复用。
|
|
118
|
+
DATASET_SHORT_TTL = 7 * 86400 # 7 天
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_convertible(stock_code: str) -> bool:
|
|
122
|
+
bare = stock_code.split('.')[0]
|
|
123
|
+
return bare.startswith(('11', '12'))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _min_bars(stock_code: str, date_type: str = 'D') -> int:
|
|
127
|
+
"""按品种+周期取最小 bar 门槛。
|
|
128
|
+
|
|
129
|
+
分钟 (DateType M*) 不走根数门槛 — 分钟 dataset 构建目标 = 回测窗口本身,
|
|
130
|
+
存在即用, 窗口切片由调用方做。日线才用根数门槛 (保证"至少3年/1年"样本)。
|
|
131
|
+
"""
|
|
132
|
+
if date_type.startswith('M'):
|
|
133
|
+
return 1
|
|
134
|
+
if _is_convertible(stock_code):
|
|
135
|
+
return DATASET_MIN_BARS_CB
|
|
136
|
+
return DATASET_MIN_BARS
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dataset_path(stock_code: str, date_type: str, fu_quan_type: int) -> Path:
|
|
140
|
+
"""code 含后缀 → 剥 (dataset 内部统一裸 6 位, 与容维一致)。"""
|
|
141
|
+
bare = stock_code.split('.')[0]
|
|
142
|
+
return _DATASET_DIR / f'{bare}_{date_type}_fq{fu_quan_type}.parquet'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def bundle_path(universe_code: str, date_type: str = 'D') -> Path:
|
|
146
|
+
"""server 内置数据集整包落盘缓存 (无 TTL, 历史不变)。
|
|
147
|
+
|
|
148
|
+
布局: <data_cache>/datasets/__bundle__<universe>_<DateType>.parquet
|
|
149
|
+
与单股 dataset (裸 6 位前缀) 区分 — __bundle__ 前缀不会撞股票代码。
|
|
150
|
+
整包下载一次, 同进程内多只股票从同一份抽取, 不重复下载/解析。
|
|
151
|
+
"""
|
|
152
|
+
return _DATASET_DIR / f'__bundle__{universe_code}_{date_type}.parquet'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_dataset(stock_code: str, date_type: str, fu_quan_type: int,
|
|
156
|
+
start: str, end: str) -> list[dict] | None:
|
|
157
|
+
"""读取数据集切片。数据集根数足够 → 返升序窗口切片;否则 None (视为 miss, 重建)。
|
|
158
|
+
|
|
159
|
+
"存在"判定只看数据集根数 >= DATASET_MIN_BARS。切片过滤后为空 (如 start/end
|
|
160
|
+
恰为非交易日) 是正常结果, 不代表数据集缺失 — 若把空切片当 miss, 回测逐 bar
|
|
161
|
+
调非交易日窗口会反复触发重建 (每次卡几秒网络)。
|
|
162
|
+
|
|
163
|
+
无 TTL — 历史 K 线不变。性能: 全量 parquet 用 lru_cache 进程内缓存,
|
|
164
|
+
切片在内存做 — 回测逐 bar 请求不再反复读盘/pyarrow 加载。
|
|
165
|
+
"""
|
|
166
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
167
|
+
if not path.exists():
|
|
168
|
+
return None
|
|
169
|
+
rows = list(_read_dataset_full(stock_code, date_type, fu_quan_type))
|
|
170
|
+
if len(rows) < _min_bars(stock_code, date_type):
|
|
171
|
+
# 短样本 (次新股/新转债历史不足) 不立即重建: 用 TTL 临时复用,
|
|
172
|
+
# 避免每次回测都重新翻页构建 (296 只转债反复重建 ~74s)。到期重建拉新历史。
|
|
173
|
+
if _is_convertible(stock_code) and time.time() - path.stat().st_mtime < DATASET_SHORT_TTL:
|
|
174
|
+
pass # 临时复用短样本
|
|
175
|
+
else:
|
|
176
|
+
return None
|
|
177
|
+
if start:
|
|
178
|
+
# 日线 bar 有 date (YYYYMMDD); 分钟 bar 有 datetime (YYYYMMDDHHMM), 切片按前 8 位
|
|
179
|
+
date_of = lambda r: r.get('date') or r.get('datetime', '')[:8] # noqa: E731
|
|
180
|
+
# 覆盖检查: 缓存最末日期 < 请求 start → 缓存陈旧 (e.g. 之前构建时只拉到旧数据,
|
|
181
|
+
# 后来回测窗口更新), 视为 miss 重建。次新/停更标的例外: 上游日线已到尽头
|
|
182
|
+
# (历史只到某日后停更, e.g. 转债摘牌/退市), 构建拉到最早一根后上游无更多数据,
|
|
183
|
+
# last < start 是正常结果, 每次重建只会拉到同样的旧数据 — 若不放行会死循环重建。
|
|
184
|
+
if _dataset_is_complete(stock_code, date_type, fu_quan_type, rows):
|
|
185
|
+
pass # 已拉满上游 (最早根就是 dataset 首根) → 放行, 不再重建
|
|
186
|
+
else:
|
|
187
|
+
last = date_of(rows[-1])
|
|
188
|
+
if last < start:
|
|
189
|
+
return None
|
|
190
|
+
rows = [r for r in rows if start <= date_of(r) <= end]
|
|
191
|
+
return rows # 可为空 list (非交易日切片); 调用方不应据此重建
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# 上游已拉满标记: dataset 构建拉到最早一根 (次新/停更标的) 时写一个侧车文件。
|
|
195
|
+
# 若无此标记, 覆盖检查对这类标的永远 last < start → 死循环重建 (实测 128044 8x)。
|
|
196
|
+
_COMPLETE_SUFFIX = '.complete'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _complete_path(stock_code: str, date_type: str, fu_quan_type: int) -> Path:
|
|
200
|
+
return Path(str(_dataset_path(stock_code, date_type, fu_quan_type)) + _COMPLETE_SUFFIX)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _dataset_is_complete(stock_code: str, date_type: str, fu_quan_type: int,
|
|
204
|
+
rows: list[dict]) -> bool:
|
|
205
|
+
"""数据集是否已拉满上游 (构建时翻页无更早数据即视为完整)。
|
|
206
|
+
|
|
207
|
+
无 complete 标记 → False (回落旧覆盖检查)。标记由 build_bars_dataset 在
|
|
208
|
+
翻页 `not fresh: break` (上游无更早数据) 时写入。
|
|
209
|
+
"""
|
|
210
|
+
if not rows:
|
|
211
|
+
return False
|
|
212
|
+
return _complete_path(stock_code, date_type, fu_quan_type).exists()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
from functools import lru_cache # noqa: E402 (import 放函数后, 保持文件顶部简洁)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@lru_cache(maxsize=64)
|
|
219
|
+
def _read_dataset_full(stock_code: str, date_type: str, fu_quan_type: int) -> tuple:
|
|
220
|
+
"""读全量数据集 parquet → tuple (可 hash, 供 lru_cache)。调用方转回 list。"""
|
|
221
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
222
|
+
rows = _read_parquet(path)
|
|
223
|
+
return tuple(rows)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def write_dataset(stock_code: str, date_type: str, fu_quan_type: int,
|
|
227
|
+
rows: list[dict]) -> None:
|
|
228
|
+
"""写入/覆盖数据集 (升序 bars)。"""
|
|
229
|
+
_DATASET_DIR.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
231
|
+
_write_parquet(path, rows)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---- parquet (依赖 pyarrow/pandas, 可选) ----
|
|
235
|
+
|
|
236
|
+
def _read_parquet(path: Path) -> list[dict]:
|
|
237
|
+
try:
|
|
238
|
+
import pandas as pd
|
|
239
|
+
except ImportError:
|
|
240
|
+
return []
|
|
241
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
242
|
+
# 0 字节/不存在 → 损坏或半截写入 (build 中断/磁盘满) → 当 miss, 调用方重建。
|
|
243
|
+
# 若让 pd.read_parquet 抛 ArrowInvalid, 回测逐 bar 读同一文件会硬崩 (实测 001358)。
|
|
244
|
+
return []
|
|
245
|
+
return pd.read_parquet(path).to_dict(orient='records')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_parquet(path: Path, data: list[dict]) -> None:
|
|
249
|
+
try:
|
|
250
|
+
import pandas as pd
|
|
251
|
+
except ImportError:
|
|
252
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
|
|
253
|
+
encoding='utf-8')
|
|
254
|
+
return
|
|
255
|
+
pd.DataFrame(data).to_parquet(path)
|
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
# ---- 聚合入口 ------
|
|
287
|
+
def check_discipline(source: str, config: dict) -> list[DisciplineError]:
|
|
288
|
+
"""返 0~N 条 DisciplineError; 空 list = 通过. cmd_run 在 backtest.run 之前调."""
|
|
289
|
+
try:
|
|
290
|
+
tree = ast.parse(source)
|
|
291
|
+
except SyntaxError as e:
|
|
292
|
+
return [DisciplineError(
|
|
293
|
+
rule='syntax_error',
|
|
294
|
+
line=e.lineno or 1,
|
|
295
|
+
msg=f'parse failed: {e.msg}',
|
|
296
|
+
)]
|
|
297
|
+
|
|
298
|
+
errs: list[DisciplineError] = []
|
|
299
|
+
errs += _rule_coding_utf8(source)
|
|
300
|
+
errs += _rule_akquant_strategy_subclass(tree)
|
|
301
|
+
errs += _rule_no_qmt_globals(tree)
|
|
302
|
+
errs += _rule_no_handlebar(tree)
|
|
303
|
+
errs += _rule_no_init_contextinfo(tree)
|
|
304
|
+
errs += _rule_bar_field_uses_timestamp(tree)
|
|
305
|
+
errs += _rule_get_history_batched(tree)
|
|
306
|
+
errs += _rule_universe_param_style(tree)
|
|
307
|
+
return errs
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _selfcheck() -> None:
|
|
311
|
+
"""v2 discipline._selfcheck: 跑一份合规 + 一份违规, 4 个 buy-and-hold 策略.
|
|
312
|
+
|
|
313
|
+
合规: class BuyHold(Strategy): on_bar(self, bar): self.buy(bar.symbol, 100)
|
|
314
|
+
违规: def handlebar(bar): passorder(...) → 报 qmt_global_leaked + handlebar_not_akquant
|
|
315
|
+
"""
|
|
316
|
+
good = '''
|
|
317
|
+
from akquant import Strategy
|
|
318
|
+
|
|
319
|
+
class BuyHold(Strategy):
|
|
320
|
+
warmup_period = 1
|
|
321
|
+
def on_bar(self, bar):
|
|
322
|
+
if self.get_position(bar.symbol) == 0:
|
|
323
|
+
self.buy(bar.symbol, 100)
|
|
324
|
+
'''
|
|
325
|
+
errs = check_discipline(good, {})
|
|
326
|
+
assert not errs, f'合规策略不应被拦: {errs}'
|
|
327
|
+
print(f'OK: 合规策略 0 违规')
|
|
328
|
+
|
|
329
|
+
bad = '''# coding: gbk
|
|
330
|
+
def init(ContextInfo):
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
def handlebar(ContextInfo):
|
|
334
|
+
passorder(23, 1101, '600000.SH', 0, 0, 100, 0, '', 'remark')
|
|
335
|
+
quickTrade = 2
|
|
336
|
+
'''
|
|
337
|
+
errs = check_discipline(bad, {})
|
|
338
|
+
rules = {e.rule for e in errs}
|
|
339
|
+
assert 'coding_not_utf8' in rules, f'应拦 GBK: {errs}'
|
|
340
|
+
assert 'qmt_global_leaked' in rules, f'应拦 QMT globals: {errs}'
|
|
341
|
+
assert 'init_contextinfo_form' in rules, f'应拦 init(ContextInfo): {errs}'
|
|
342
|
+
assert 'handlebar_not_akquant' in rules, f'应拦 handlebar: {errs}'
|
|
343
|
+
assert 'missing_akquant_strategy_subclass' in rules, f'应拦无 Strategy: {errs}'
|
|
344
|
+
print(f'OK: 违规策略拦 {len(errs)} 条 (rules={sorted(rules)})')
|
|
345
|
+
|
|
346
|
+
# bar.time 陷阱
|
|
347
|
+
bad_time = '''
|
|
348
|
+
from akquant import Strategy
|
|
349
|
+
class MyStrat(Strategy):
|
|
350
|
+
def on_bar(self, bar):
|
|
351
|
+
d = bar.time # 实际 attr 是 timestamp
|
|
352
|
+
'''
|
|
353
|
+
errs = check_discipline(bad_time, {})
|
|
354
|
+
assert any(e.rule == 'bar_field_alias_trap' for e in errs), f'应拦 bar.time: {errs}'
|
|
355
|
+
print(f'OK: bar.time 陷阱拦住')
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == '__main__':
|
|
359
|
+
_selfcheck()
|