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,620 @@
|
|
|
1
|
+
"""ADR-0040 Phase B — akquant runner 单入口 (v2 自有版本, B7).
|
|
2
|
+
|
|
3
|
+
来源: 原仓根 `hamuna_quant_cli/references/akquant_runner.py`.
|
|
4
|
+
本文件是 wholesale copy, 算法/CFG 不变. 迁移原因: v2 skill 独立分发.
|
|
5
|
+
唯一改动: 所有 `from references.X` → `from .X` (同包 import).
|
|
6
|
+
|
|
7
|
+
公开 API:
|
|
8
|
+
run_akquant_backtest(strategy_path, cfg) -> dict
|
|
9
|
+
|
|
10
|
+
strategy_path: 含 `akquant.Strategy` 子类的 .py 文件路径 (on_bar API, NOT QMT handlebar).
|
|
11
|
+
cfg: 同 `hamuna_quant_cli run` 用的 CONFIG dict (backtest_start/end, pool, init_capital, ...).
|
|
12
|
+
|
|
13
|
+
返回: hamuna `backtest_result.json` 形状 dict (13 顶层 key, 15 metrics; 见
|
|
14
|
+
strategy_cli/runtime/driver.py:113-128 + references/backtest-result.md).
|
|
15
|
+
|
|
16
|
+
锁定 akquant 0.3.x schema (bench_20260813_201349.json). 升级前先跑
|
|
17
|
+
references/_test_akquant_parity.py 验证 5 strategy 容差在阈值内.
|
|
18
|
+
|
|
19
|
+
失败模式:
|
|
20
|
+
FileNotFoundError: 数据集缺失 (universe 在 prebuilt 不存在, 或 start/end 在 window 外)
|
|
21
|
+
ImportError: akquant 未安装 (pip install akquant)
|
|
22
|
+
ValueError: cfg 缺必需 key / akquant.run_backtest 报参数错
|
|
23
|
+
|
|
24
|
+
Ponytail ceiling: 当前只支持日线 + 单标 / 命名池.
|
|
25
|
+
5m/tick 走 v1 driver 路径 (`strategy_cli run`).
|
|
26
|
+
多 symbol 走循环跑 (R3 mitigation, 但 runner 入口暂不阻断 — 等 _probe_multi_symbol 测).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib.util
|
|
31
|
+
import inspect
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import warnings
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
|
|
40
|
+
def _load_akquant_strategy(strategy_path: str) -> Any:
|
|
41
|
+
"""加载 strategy .py, 提取第一个 akquant.Strategy 子类.
|
|
42
|
+
|
|
43
|
+
不复用 driver._load_strategy — 那是 QMT 路径 (mod.init / mod.handlebar / mod.CONFIG),
|
|
44
|
+
akquant 是类化 strategy (class MyStrategy(akquant.Strategy): on_bar(bar)).
|
|
45
|
+
|
|
46
|
+
ad-hoc 加载: spec_from_file_location, sys.path.insert 父目录 (兼容同目录 helper).
|
|
47
|
+
"""
|
|
48
|
+
p = Path(strategy_path).resolve()
|
|
49
|
+
parent = str(p.parent)
|
|
50
|
+
if parent not in sys.path:
|
|
51
|
+
sys.path.insert(0, parent)
|
|
52
|
+
spec = importlib.util.spec_from_file_location('_hamuna_akquant_strat', strategy_path)
|
|
53
|
+
if spec is None or spec.loader is None:
|
|
54
|
+
raise ImportError(f'cannot load strategy at {strategy_path}')
|
|
55
|
+
mod = importlib.util.module_from_spec(spec)
|
|
56
|
+
sys.modules['_hamuna_akquant_strat'] = mod
|
|
57
|
+
spec.loader.exec_module(mod)
|
|
58
|
+
|
|
59
|
+
# 找 akquant.Strategy 子类 (用户可能放 class MyStrategy(akquant.Strategy) 或 (Strategy))
|
|
60
|
+
try:
|
|
61
|
+
import akquant
|
|
62
|
+
except ImportError as e:
|
|
63
|
+
raise ImportError(f'akquant 未安装: {e}. pip install akquant>=0.3.41,<0.4') from e
|
|
64
|
+
base_cls = akquant.Strategy
|
|
65
|
+
|
|
66
|
+
found = None
|
|
67
|
+
for name in dir(mod):
|
|
68
|
+
obj = getattr(mod, name)
|
|
69
|
+
if isinstance(obj, type) and issubclass(obj, base_cls) and obj is not base_cls:
|
|
70
|
+
found = obj
|
|
71
|
+
break
|
|
72
|
+
if found is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f'{strategy_path} 未找到 akquant.Strategy 子类 (期望 `class Xxx(akquant.Strategy): on_bar(...)`)')
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- main entry ----
|
|
79
|
+
def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
|
|
80
|
+
"""跑 akquant 回测, 返回 hamuna `backtest_result.json` 形状 dict.
|
|
81
|
+
|
|
82
|
+
0.3.x 加速: 见 SKILL.md 顶部 / engine-and-data.md §6 — runner 自动接
|
|
83
|
+
history_depth / start_time / lot_size / commission_policy, 用户 strategy 不用改.
|
|
84
|
+
"""
|
|
85
|
+
import akquant
|
|
86
|
+
|
|
87
|
+
# cfg 必需 keys
|
|
88
|
+
for k in ('backtest_start', 'backtest_end'):
|
|
89
|
+
if k not in cfg:
|
|
90
|
+
raise ValueError(f'cfg 缺必需 key: {k}')
|
|
91
|
+
|
|
92
|
+
# universe 展开 (与 schema_adapter._cfg_universe 一致)
|
|
93
|
+
from .akquant_data_adapter import load_prebuilt_to_akquant # B4
|
|
94
|
+
from .akquant_schema_adapter import to_hamuna_result, HAMUNA_METRICS_15, normalize_symbol # B1
|
|
95
|
+
universe = [normalize_symbol(s) for s in _universe_from_cfg(cfg)] # 裸码 → 带后缀 (bundle stockCode 形态)
|
|
96
|
+
if not universe:
|
|
97
|
+
raise ValueError(f'cfg.universe / cfg.pool 为空: {cfg.get("pool")}')
|
|
98
|
+
if len(universe) > 1:
|
|
99
|
+
# R3 mitigation: akquant 多 symbol 实测前先 warn + 跑循环. parity test 会实测.
|
|
100
|
+
# 暂时让 akquant.run_backtest 自己决定 (实测接受 multi-symbol DataFrame).
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# data — Q1 兜底默认开 (cfg['price_limit_clamp']=False 可关)
|
|
104
|
+
use_clamp = bool(cfg.get('price_limit_clamp', True))
|
|
105
|
+
if use_clamp:
|
|
106
|
+
from .akquant_data_adapter import load_prebuilt_to_akquant_with_limits # B4
|
|
107
|
+
df = load_prebuilt_to_akquant_with_limits(
|
|
108
|
+
universe, cfg['backtest_start'], cfg['backtest_end'])
|
|
109
|
+
else:
|
|
110
|
+
df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'])
|
|
111
|
+
if df.empty:
|
|
112
|
+
raise FileNotFoundError(
|
|
113
|
+
f'未取到 bar 数据 (universe={universe}, {cfg["backtest_start"]}-{cfg["backtest_end"]})')
|
|
114
|
+
|
|
115
|
+
# symbol_names: 从 prebuilt bundle 聚合 {stockCode: stockName}, 注入 cfg
|
|
116
|
+
# 让 schema_adapter.trades 填 symbol_name (对齐 hamuna_strategy driver._lookup_symbol_name)
|
|
117
|
+
if not cfg.get('_symbol_names'):
|
|
118
|
+
cfg['_symbol_names'] = _load_symbol_names_from_bundle(df)
|
|
119
|
+
|
|
120
|
+
# strategy class
|
|
121
|
+
strat_cls = _load_akquant_strategy(strategy_path)
|
|
122
|
+
|
|
123
|
+
# ===== 0.3 架构变更 (2026-08-18 简化): compute_factors / filter_symbols 接入引擎 =====
|
|
124
|
+
# 流程: 实例化 → compute_factors (直接传 prebuilt df, 引擎不包装 BacktestContext) →
|
|
125
|
+
# filter_symbols → 修整 df + symbols 喂 akquant.run_backtest.
|
|
126
|
+
# 老策略 (无 compute_factors / filter_symbols) 走默认 no-op, 行为不变 — 有 hasattr 检测.
|
|
127
|
+
# akquant 0.3.x 已一等支持 DataFrame/polars/pyarrow/dict 输入 — 不下沉 sub-adapter.
|
|
128
|
+
import pandas as _pd # noqa: F401
|
|
129
|
+
try:
|
|
130
|
+
strat_inst = strat_cls()
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise RuntimeError(f"实例化策略 {strat_cls.__name__} 失败: {e}") from e
|
|
133
|
+
|
|
134
|
+
factors: dict[str, _pd.DataFrame] = {}
|
|
135
|
+
if hasattr(strat_inst, "compute_factors"):
|
|
136
|
+
try:
|
|
137
|
+
factors = strat_inst.compute_factors(df) or {}
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"compute_factors 异常: {e} (用户原话: 策略出错直接报错停止, 不走 mock 兜底)"
|
|
141
|
+
) from e
|
|
142
|
+
if not isinstance(factors, dict):
|
|
143
|
+
raise TypeError(
|
|
144
|
+
f"compute_factors 应返 dict[str, DataFrame], 实得 {type(factors).__name__}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
filtered: list[str] = list(universe)
|
|
148
|
+
if hasattr(strat_inst, "filter_symbols"):
|
|
149
|
+
try:
|
|
150
|
+
user_filtered = strat_inst.filter_symbols(factors)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise RuntimeError(f"filter_symbols 异常: {e}") from e
|
|
153
|
+
if not isinstance(user_filtered, (list, tuple)):
|
|
154
|
+
raise TypeError(
|
|
155
|
+
f"filter_symbols 应返 list[str], 实得 {type(user_filtered).__name__}"
|
|
156
|
+
)
|
|
157
|
+
user_filtered = [str(s) for s in user_filtered]
|
|
158
|
+
if not user_filtered:
|
|
159
|
+
warnings.warn(f"filter_symbols 返空, 退到 cfg.universe ({len(universe)} syms)")
|
|
160
|
+
else:
|
|
161
|
+
# 校验: filter_symbols 返的 sym 必须 universe 子集
|
|
162
|
+
known = set(universe)
|
|
163
|
+
bad = [s for s in user_filtered if s not in known]
|
|
164
|
+
if bad:
|
|
165
|
+
warnings.warn(
|
|
166
|
+
f"filter_symbols 返了 universe 外的 sym {bad[:5]}{'...' if len(bad) > 5 else ''}, 已剔除"
|
|
167
|
+
)
|
|
168
|
+
user_filtered = [s for s in user_filtered if s in known]
|
|
169
|
+
filtered = user_filtered
|
|
170
|
+
print(
|
|
171
|
+
f"[v2-architecture] filter_symbols: {len(universe)} → {len(filtered)} symbols",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 切 df 到 filtered universe (空 df 立即报错, 不让 akquant 引擎跑出空结果)
|
|
176
|
+
if filtered != universe:
|
|
177
|
+
df = df[df["symbol"].isin(filtered)] if "symbol" in df.columns else df
|
|
178
|
+
if df.empty:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"filter_symbols 限定后 df 为空 (filtered={filtered[:5]}...)"
|
|
181
|
+
)
|
|
182
|
+
universe = filtered
|
|
183
|
+
|
|
184
|
+
# run_backtest kwargs (锁定 akquant 0.3.x schema; 见 SKILL.md 顶部 0.3 加速原语段)
|
|
185
|
+
initial_cash = float(cfg.get('init_capital', 1_000_000.0))
|
|
186
|
+
# slippage: akquant 0.3 已 deprecate bare float, 强制 dict 形态
|
|
187
|
+
# 接受 cfg['slippage']=float 或 cfg['slippage']={'type':..., 'value':...}
|
|
188
|
+
_raw_slip = cfg.get('slippage', 0.001)
|
|
189
|
+
if isinstance(_raw_slip, dict):
|
|
190
|
+
slippage_kwarg = _raw_slip
|
|
191
|
+
else:
|
|
192
|
+
slippage_kwarg = {'type': 'percent', 'value': float(_raw_slip)}
|
|
193
|
+
kwargs: dict[str, Any] = {
|
|
194
|
+
'strategy': strat_cls,
|
|
195
|
+
'data': df,
|
|
196
|
+
'symbols': universe,
|
|
197
|
+
'initial_cash': initial_cash,
|
|
198
|
+
'commission_rate': float(cfg.get('commission_rate', 0.0003)),
|
|
199
|
+
'stamp_tax_rate': float(cfg.get('stamp_tax_rate', 0.001)),
|
|
200
|
+
'min_commission': float(cfg.get('min_commission', 5.0)),
|
|
201
|
+
'slippage': slippage_kwarg,
|
|
202
|
+
'volume_limit_pct': float(cfg.get('volume_limit_pct', 0.25)),
|
|
203
|
+
't_plus_one': bool(cfg.get('t_plus_one', True)),
|
|
204
|
+
'show_progress': False,
|
|
205
|
+
}
|
|
206
|
+
# universe 注入 (Q1: 多标/横截面策略必需):
|
|
207
|
+
# - 0.3.x 新 API: strategy 用 `universe = ListParam(default=[])` 类字段, runner 通过
|
|
208
|
+
# `strategy_params={'universe': universe}` 注入 (引擎走 .params.universe).
|
|
209
|
+
# - 0.2.x 旧 API: strategy `__init__(self, universe=None)`, runner 通过 kwargs 注入.
|
|
210
|
+
# **0.3.x 严格拒收** (raise TypeError), 所以检测到旧风格时只能跳过, 让用户迁移.
|
|
211
|
+
if _strategy_accepts_universe_param(strat_cls):
|
|
212
|
+
kwargs['strategy_params'] = {'universe': universe}
|
|
213
|
+
# 不再 fallback 到 0.2 旧路径 — 见上方注释; 旧 strategy 走 0.3 必须先迁移到 ParamModel.
|
|
214
|
+
|
|
215
|
+
# ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
|
|
216
|
+
# 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
|
|
217
|
+
# 长窗策略 (lookback > 30) 实测 -20~40% elapsed; 短窗无害.
|
|
218
|
+
hd = _infer_history_depth(strat_cls, cfg)
|
|
219
|
+
if hd is not None and hd > 0:
|
|
220
|
+
kwargs['history_depth'] = hd
|
|
221
|
+
|
|
222
|
+
# 2) start_time / end_time — 引擎内切片, 比 Python 端 iloc 省内存且正确处理 warmup.
|
|
223
|
+
# cfg['backtest_start'] 是 'YYYYMMDD' (legacy v1 格式), akquant 0.3 期望 ISO 'YYYY-MM-DD'.
|
|
224
|
+
# 兼容 cfg 显式 start_time/end_time (优先级更高).
|
|
225
|
+
if 'start_time' not in cfg:
|
|
226
|
+
cfg['start_time'] = _ymd_to_iso(cfg['backtest_start'])
|
|
227
|
+
if 'end_time' not in cfg:
|
|
228
|
+
cfg['end_time'] = _ymd_to_iso(cfg['backtest_end'])
|
|
229
|
+
kwargs['start_time'] = cfg['start_time']
|
|
230
|
+
kwargs['end_time'] = cfg['end_time']
|
|
231
|
+
|
|
232
|
+
# 3) lot_size — A 股默认 100 整手. 老 akquant (<0.3.30) 不接受 (会被 TypeError) → 启动期探测.
|
|
233
|
+
# 接受 cfg['lot_size']=int (统一) 或 dict (per-symbol); 默认 100.
|
|
234
|
+
ls = cfg.get('lot_size', 100)
|
|
235
|
+
if _akquant_supports_lot_size() and ls:
|
|
236
|
+
kwargs['lot_size'] = ls
|
|
237
|
+
|
|
238
|
+
# 4) commission_policy — 0.3+ 推荐 dict 形态; 若 cfg 显式给 commission_policy 优先.
|
|
239
|
+
# 否则从 commission_rate 构造成 dict (行为等价但走 0.3+ 内部 commission pipeline).
|
|
240
|
+
# commission_rate 与 commission_policy 二选一 (0.3 strict 模式同时传会报错, 所以
|
|
241
|
+
# 构造 commission_policy 后立刻从 kwargs 里 pop commission_rate).
|
|
242
|
+
if _akquant_supports_commission_policy():
|
|
243
|
+
cp = cfg.get('commission_policy')
|
|
244
|
+
if cp is None:
|
|
245
|
+
cp = {
|
|
246
|
+
'type': 'percent',
|
|
247
|
+
'value': float(cfg.get('commission_rate', 0.0003)),
|
|
248
|
+
}
|
|
249
|
+
cfg['commission_policy'] = cp
|
|
250
|
+
kwargs.pop('commission_rate', None)
|
|
251
|
+
kwargs['commission_policy'] = cp
|
|
252
|
+
# 0.2.x 老路径: 只 commission_rate, 不动.
|
|
253
|
+
|
|
254
|
+
# 可选 kwargs (存在性探测后再传; 防老 akquant 跑 0.3 only kwargs 抛 TypeError)
|
|
255
|
+
for optional in ('analyzer_plugins', 'risk_config'):
|
|
256
|
+
if optional in cfg and _akquant_kwarg_supported(optional):
|
|
257
|
+
kwargs[optional] = cfg[optional]
|
|
258
|
+
|
|
259
|
+
akquant_r = akquant.run_backtest(**kwargs)
|
|
260
|
+
|
|
261
|
+
return to_hamuna_result(akquant_r, cfg)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _universe_from_cfg(cfg: dict) -> list[str]:
|
|
265
|
+
"""从 cfg['pool'] 展开标的代码 list. 与 schema_adapter._cfg_universe 同语义."""
|
|
266
|
+
pool = cfg.get('pool') or cfg.get('universe') or []
|
|
267
|
+
if isinstance(pool, list):
|
|
268
|
+
return list(pool)
|
|
269
|
+
out: list[str] = []
|
|
270
|
+
for v in pool.values() if isinstance(pool, dict) else []:
|
|
271
|
+
if isinstance(v, dict):
|
|
272
|
+
codes = v.get('codes') or v.get('sub_universe') or []
|
|
273
|
+
out.extend(codes)
|
|
274
|
+
elif isinstance(v, list):
|
|
275
|
+
out.extend(v)
|
|
276
|
+
return sorted(set(out))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _load_symbol_names_from_bundle(df: pd.DataFrame) -> dict[str, str]:
|
|
280
|
+
"""从 input df 聚合 {symbol: stockName}, 失败/缺列 → {} (trades 端 fallback None).
|
|
281
|
+
|
|
282
|
+
备选: 直读 prebuilt parquet 的 stockName 列 (data_adapter 已 drop).
|
|
283
|
+
这里走 df 自身: data_adapter._DROP_COLS 含 stockName, 但 _PRICE_LIMIT 路径
|
|
284
|
+
仍带 stockName; 若上游已 drop → 空 dict.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
if 'stockName' not in df.columns:
|
|
288
|
+
return {}
|
|
289
|
+
names = df.dropna(subset=['stockName']).drop_duplicates('symbol').set_index('symbol')['stockName'].to_dict()
|
|
290
|
+
return {k: str(v) for k, v in names.items() if k}
|
|
291
|
+
except Exception: # noqa: BLE001
|
|
292
|
+
return {}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---- 0.3.x 加速原语 helper (Round 1) ----
|
|
296
|
+
|
|
297
|
+
# window-like 参数名启发式: __init__ 默认值取 max + warmup_period.
|
|
298
|
+
# 不取 lot_size / universe / period (它们不是窗口).
|
|
299
|
+
_WINDOW_PARAM_HINTS = (
|
|
300
|
+
'window', 'lookback', 'slow', 'fast', 'long', 'short',
|
|
301
|
+
'period', 'span', 'length', 'n_', '_n', 'days', 'periods',
|
|
302
|
+
'ma', 'sma', 'ema', 'rsi', 'atr', 'roc',
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _infer_history_depth(strat_cls: type, cfg: dict) -> int | None:
|
|
307
|
+
"""从 strategy.__init__ 默认值 + warmup_period + cfg['history_depth'] 推断 history_depth.
|
|
308
|
+
|
|
309
|
+
返回 None = 留空, akquant 走引擎默认 (0, 不预分配).
|
|
310
|
+
返回 int > 0 = 预分配 Rust history buffer, 长窗策略 -20~40% elapsed.
|
|
311
|
+
|
|
312
|
+
启发式:
|
|
313
|
+
1) cfg['history_depth'] 显式值 (最高优先级)
|
|
314
|
+
2) strategy.__init__ 默认值里匹配 window 关键字 + 数值 → 取 max
|
|
315
|
+
3) 类属性 warmup_period
|
|
316
|
+
4) 加 10 padding 防 off-by-one (rolling-N 在第 N+1 bar 才完整)
|
|
317
|
+
"""
|
|
318
|
+
explicit = cfg.get('history_depth')
|
|
319
|
+
if explicit is not None:
|
|
320
|
+
try:
|
|
321
|
+
return max(0, int(explicit))
|
|
322
|
+
except (TypeError, ValueError):
|
|
323
|
+
pass # 显式值非法 → 走推断
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
sig = inspect.signature(strat_cls.__init__)
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
sig = None
|
|
329
|
+
|
|
330
|
+
candidates: list[int] = []
|
|
331
|
+
if sig is not None:
|
|
332
|
+
for pname, param in sig.parameters.items():
|
|
333
|
+
if param.default is inspect.Parameter.empty:
|
|
334
|
+
continue
|
|
335
|
+
v = param.default
|
|
336
|
+
# 跳过 None / bool / 非数值
|
|
337
|
+
if not isinstance(v, (int, float)):
|
|
338
|
+
continue
|
|
339
|
+
if isinstance(v, bool):
|
|
340
|
+
continue
|
|
341
|
+
iv = int(v)
|
|
342
|
+
if iv <= 0:
|
|
343
|
+
continue
|
|
344
|
+
# 启发式: 名字命中 window-like 才采纳 (避免 lot_size=100 / universe_size=500 误判)
|
|
345
|
+
plower = pname.lower()
|
|
346
|
+
if any(h in plower for h in _WINDOW_PARAM_HINTS):
|
|
347
|
+
candidates.append(iv)
|
|
348
|
+
|
|
349
|
+
# 类属性 warmup_period
|
|
350
|
+
wp = getattr(strat_cls, 'warmup_period', None)
|
|
351
|
+
if isinstance(wp, (int, float)) and not isinstance(wp, bool) and wp > 0:
|
|
352
|
+
candidates.append(int(wp))
|
|
353
|
+
|
|
354
|
+
if not candidates:
|
|
355
|
+
return None
|
|
356
|
+
return max(candidates) + 10
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ymd_to_iso(ymd: str) -> str:
|
|
360
|
+
"""'YYYYMMDD' (v1 cfg 格式) → 'YYYY-MM-DD' (akquant 0.3 start_time 格式).
|
|
361
|
+
|
|
362
|
+
已是 ISO 形态直接透传, 避免破坏既有 cfg['start_time'] 用法.
|
|
363
|
+
"""
|
|
364
|
+
s = str(ymd).strip()
|
|
365
|
+
if len(s) == 8 and s.isdigit():
|
|
366
|
+
return f'{s[:4]}-{s[4:6]}-{s[6:8]}'
|
|
367
|
+
return s
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# akquant 版本探测: 启动期一次性 inspect 签名, 防 <0.3.30 / 0.3.x 行为差异.
|
|
371
|
+
_AKQUANT_SUPPORT_CACHE: dict[str, bool] = {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _akquant_kwarg_supported(name: str) -> bool:
|
|
375
|
+
"""查 akquant.run_backtest 是否接受给定 kwarg (避免老版本抛 TypeError)."""
|
|
376
|
+
if name in _AKQUANT_SUPPORT_CACHE:
|
|
377
|
+
return _AKQUANT_SUPPORT_CACHE[name]
|
|
378
|
+
try:
|
|
379
|
+
import akquant
|
|
380
|
+
sig = inspect.signature(akquant.run_backtest)
|
|
381
|
+
ok = name in sig.parameters
|
|
382
|
+
except Exception: # noqa: BLE001
|
|
383
|
+
ok = False
|
|
384
|
+
_AKQUANT_SUPPORT_CACHE[name] = ok
|
|
385
|
+
return ok
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _akquant_supports_lot_size() -> bool:
|
|
389
|
+
return _akquant_kwarg_supported('lot_size')
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _akquant_supports_commission_policy() -> bool:
|
|
393
|
+
return _akquant_kwarg_supported('commission_policy')
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _strategy_accepts_universe_param(strat_cls: type) -> bool:
|
|
397
|
+
"""检测 strategy 是否声明 `universe` 为 0.3.x 的 ParamModel 字段.
|
|
398
|
+
|
|
399
|
+
0.3.x: `universe: list = ListParam(default=[])` 类字段, 通过 strategy_params 注入.
|
|
400
|
+
0.2.x: `__init__(self, universe=None)` — 0.3.x 严格拒收 (TypeError), 这里返 False 跳过.
|
|
401
|
+
|
|
402
|
+
ponytail: 仅检测 'universe' 字段. 其它字段 (lots/fast/slow 等) 走 ParamModel 自身规则,
|
|
403
|
+
runner 不替用户策略注入; grid/WFO 子命令会单独处理.
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
# pydantic-based ParamModel: 实例化后 inst.params.<name> 存在
|
|
407
|
+
with warnings.catch_warnings():
|
|
408
|
+
warnings.simplefilter('ignore') # 0.3.x 对老 __init__ 形参的 UserWarning
|
|
409
|
+
inst = strat_cls()
|
|
410
|
+
if not hasattr(inst, 'params'):
|
|
411
|
+
return False
|
|
412
|
+
params_obj = getattr(inst, 'params')
|
|
413
|
+
# pydantic v2: model_fields 是 dict[str, FieldInfo]
|
|
414
|
+
fields = getattr(params_obj, 'model_fields', None)
|
|
415
|
+
if fields and 'universe' in fields:
|
|
416
|
+
return True
|
|
417
|
+
# pydantic v1 fallback
|
|
418
|
+
fields_v1 = getattr(params_obj, '__fields__', None)
|
|
419
|
+
if fields_v1 and 'universe' in fields_v1:
|
|
420
|
+
return True
|
|
421
|
+
return False
|
|
422
|
+
except Exception: # noqa: BLE001
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _selfcheck() -> None:
|
|
427
|
+
"""端到端冒烟: 写 tmpfile 含 akquant.Strategy 子类 → 跑最小 backtest.
|
|
428
|
+
|
|
429
|
+
失败模式:
|
|
430
|
+
akquant 未装 → SKIP.
|
|
431
|
+
数据缺 (no prebuilt) → SKIP, 不 fail.
|
|
432
|
+
数据有 → 跑通, 验 15 metrics key + Q1 兜底已启用.
|
|
433
|
+
"""
|
|
434
|
+
if os.environ.get('AKQUANT_AVAILABLE', '1') != '1':
|
|
435
|
+
print('SKIP: akquant self-check (env AKQUANT_AVAILABLE=0)')
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
import akquant # noqa: F401
|
|
439
|
+
except ImportError:
|
|
440
|
+
print('SKIP: akquant not installed')
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
import tempfile
|
|
444
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
445
|
+
old = os.environ.get('HAMUNA_STRATEGIES_ROOT')
|
|
446
|
+
os.environ['HAMUNA_STRATEGIES_ROOT'] = tmp
|
|
447
|
+
try:
|
|
448
|
+
strat_path = Path(tmp) / 'ma_strategy.py'
|
|
449
|
+
strat_path.write_text('''
|
|
450
|
+
from akquant import Strategy
|
|
451
|
+
|
|
452
|
+
class MACross(Strategy):
|
|
453
|
+
warmup_period = 5
|
|
454
|
+
def __init__(self, fast=3, slow=5):
|
|
455
|
+
self.fast = fast
|
|
456
|
+
self.slow = slow
|
|
457
|
+
def on_bar(self, bar):
|
|
458
|
+
closes = self.get_history(self.slow, bar.symbol, "close")
|
|
459
|
+
if len(closes) < self.slow:
|
|
460
|
+
return
|
|
461
|
+
fast_ma = float(sum(closes[-self.fast:]) / self.fast)
|
|
462
|
+
slow_ma = float(sum(closes[-self.slow:]) / self.slow)
|
|
463
|
+
pos = self.get_position(bar.symbol)
|
|
464
|
+
if fast_ma > slow_ma and pos == 0:
|
|
465
|
+
self.buy(bar.symbol, 100)
|
|
466
|
+
elif fast_ma < slow_ma and pos > 0:
|
|
467
|
+
self.sell(bar.symbol, pos)
|
|
468
|
+
''', encoding='utf-8')
|
|
469
|
+
|
|
470
|
+
cfg = {
|
|
471
|
+
'backtest_start': '20240701',
|
|
472
|
+
'backtest_end': '20241231',
|
|
473
|
+
'pool': {'a': {'codes': ['600000.SH']}},
|
|
474
|
+
'init_capital': 100_000.0,
|
|
475
|
+
}
|
|
476
|
+
try:
|
|
477
|
+
r = run_akquant_backtest(strat_path, cfg)
|
|
478
|
+
except FileNotFoundError:
|
|
479
|
+
print('SKIP: prebuilt dataset 缺失 (无 600000.SH 数据); runner 入口已通, 装数据后可跑')
|
|
480
|
+
return
|
|
481
|
+
except NotImplementedError as e:
|
|
482
|
+
print(f'SKIP: {e}')
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
from .akquant_schema_adapter import HAMUNA_METRICS_15 # B1
|
|
486
|
+
assert set(r['metrics'].keys()) == HAMUNA_METRICS_15, (
|
|
487
|
+
f'15 metrics key 不齐: 差 {set(r["metrics"].keys()) ^ HAMUNA_METRICS_15}')
|
|
488
|
+
n_eq = len(r['equity_curve'])
|
|
489
|
+
print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
|
|
490
|
+
|
|
491
|
+
# Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
|
|
492
|
+
# 把 universe 注入 strategy_params (否则 on_start 空订阅 → 0 trades, 高胜率
|
|
493
|
+
# 策略实际踩过). 0.3.x 严格拒收老 __init__ 形参, 所以检测 → 注入路径改了.
|
|
494
|
+
# monkeypatch akquant.run_backtest 捕获 kwargs, 断言 strategy_params 在.
|
|
495
|
+
import akquant as _akq
|
|
496
|
+
captured: dict = {}
|
|
497
|
+
_orig_run = _akq.run_backtest
|
|
498
|
+
|
|
499
|
+
def _capture(**kw):
|
|
500
|
+
captured.update(kw)
|
|
501
|
+
# 真实跑 (返回真 BacktestResult), 让 to_hamuna_result 也走通
|
|
502
|
+
return _orig_run(**kw)
|
|
503
|
+
|
|
504
|
+
_akq.run_backtest = _capture
|
|
505
|
+
try:
|
|
506
|
+
_ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
|
|
507
|
+
# MACross 不收 universe → 不注入 strategy_params (0.2.x 旧路径已被 0.3 移除)
|
|
508
|
+
assert 'strategy_params' not in captured, (
|
|
509
|
+
f'MACross 不应注入 strategy_params: {captured.get("strategy_params")}')
|
|
510
|
+
finally:
|
|
511
|
+
_akq.run_backtest = _orig_run
|
|
512
|
+
captured.clear()
|
|
513
|
+
_akq.run_backtest = _capture
|
|
514
|
+
try:
|
|
515
|
+
strat3 = Path(tmp) / 'uses_universe.py'
|
|
516
|
+
strat3.write_text('''
|
|
517
|
+
from akquant import Strategy, ListParam
|
|
518
|
+
|
|
519
|
+
class UsesUniverse(Strategy):
|
|
520
|
+
"""0.3.x 新 ParamModel 风格: universe 字段声明为 ListParam."""
|
|
521
|
+
universe: list = ListParam(default=[])
|
|
522
|
+
def on_bar(self, bar):
|
|
523
|
+
pass
|
|
524
|
+
''', encoding='utf-8')
|
|
525
|
+
_ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
|
|
526
|
+
assert captured.get('strategy_params') == {'universe': ['600000.SH']}, (
|
|
527
|
+
f'UsesUniverse (0.3 ParamModel) 注入错: {captured.get("strategy_params")}')
|
|
528
|
+
finally:
|
|
529
|
+
_akq.run_backtest = _orig_run
|
|
530
|
+
# 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
|
|
531
|
+
# 加载临时类 (用 _load_akquant_strategy, 沿用 selfcheck 已写好的 tmpfile).
|
|
532
|
+
_macross_cls = _load_akquant_strategy(strat_path)
|
|
533
|
+
_uses_cls = _load_akquant_strategy(strat3)
|
|
534
|
+
assert not _strategy_accepts_universe_param(_macross_cls), (
|
|
535
|
+
'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
|
|
536
|
+
assert _strategy_accepts_universe_param(_uses_cls), (
|
|
537
|
+
'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
|
|
538
|
+
print(f'OK: universe 注入路径生效 (0.3 ParamModel → strategy_params; 旧 __init__ 风格跳过)')
|
|
539
|
+
|
|
540
|
+
# ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
|
|
541
|
+
# 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
|
|
542
|
+
# (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)
|
|
543
|
+
hd = _infer_history_depth(_macross_cls, cfg)
|
|
544
|
+
assert hd == 15, f'history_depth 推断应 15 (max(fast=3, slow=5) + 10), 实际 {hd}'
|
|
545
|
+
# 类属性 warmup_period 单独存在时也应采纳
|
|
546
|
+
class _WarmupOnly(akquant.Strategy):
|
|
547
|
+
warmup_period = 42
|
|
548
|
+
def on_bar(self, bar): pass
|
|
549
|
+
assert _infer_history_depth(_WarmupOnly, cfg) == 52, 'warmup_period 单独推断失败'
|
|
550
|
+
# cfg 显式覆盖
|
|
551
|
+
assert _infer_history_depth(_macross_cls, {**cfg, 'history_depth': 80}) == 80, (
|
|
552
|
+
'cfg[history_depth] 显式覆盖失败')
|
|
553
|
+
# 非法值 → 走推断
|
|
554
|
+
assert _infer_history_depth(_macross_cls, {**cfg, 'history_depth': 'xxx'}) == 15, (
|
|
555
|
+
'非法 history_depth 应 fallthrough 到推断')
|
|
556
|
+
# 无窗口 / 无 warmup → None (留空, 走引擎默认)
|
|
557
|
+
class _Empty(akquant.Strategy):
|
|
558
|
+
def __init__(self, foo='bar', flag=True):
|
|
559
|
+
self.foo = foo
|
|
560
|
+
def on_bar(self, bar): pass
|
|
561
|
+
assert _infer_history_depth(_Empty, cfg) is None, '无窗口应 None'
|
|
562
|
+
print(f'OK: history_depth 推断 (max+10 padding + warmup + cfg override + fallthrough)')
|
|
563
|
+
|
|
564
|
+
# 2) _ymd_to_iso: legacy YYYYMMDD → ISO, 已 ISO → 透传
|
|
565
|
+
assert _ymd_to_iso('20240701') == '2024-07-01'
|
|
566
|
+
assert _ymd_to_iso('2024-07-01') == '2024-07-01' # 透传
|
|
567
|
+
assert _ymd_to_iso(' 20240701 ') == '2024-07-01' # 容错 trim
|
|
568
|
+
assert _ymd_to_iso('') == ''
|
|
569
|
+
print('OK: _ymd_to_iso 边界 (YYYYMMDD/ISO/trim/empty)')
|
|
570
|
+
|
|
571
|
+
# 3) akquant 版本探测: lot_size / commission_policy 在 0.3.41 存在; <0.3.30 不存在.
|
|
572
|
+
# selfcheck 跑在 0.3.41 环境下, 期望 True; 若用户跑 <0.3.30, 仍可加载 (False 走老路径).
|
|
573
|
+
assert _akquant_supports_lot_size(), (
|
|
574
|
+
f'当前 akquant={akquant.__version__} 不支持 lot_size; 升 >=0.3.41 拿到自动整手约束')
|
|
575
|
+
assert _akquant_supports_commission_policy(), (
|
|
576
|
+
f'当前 akquant={akquant.__version__} 不支持 commission_policy; 升 >=0.3 拿推荐写法')
|
|
577
|
+
assert _akquant_kwarg_supported('history_depth'), 'history_depth 应是 0.3 标配'
|
|
578
|
+
assert not _akquant_kwarg_supported('nonexistent_kwarg_xxx'), '不存在的 kwarg 应 False'
|
|
579
|
+
print(f'OK: akquant {akquant.__version__} 0.3 加速 kwarg 探测 (lot_size/commission_policy/history_depth)')
|
|
580
|
+
|
|
581
|
+
# 4) 端到端: 跑一次真 backtest, 验 kwargs 真传过去且 metrics 不回归.
|
|
582
|
+
# monkeypatch 拿 kwargs, 验 history_depth/start_time/end_time 在 (0.3+).
|
|
583
|
+
captured2: dict = {}
|
|
584
|
+
_orig2 = _akq.run_backtest
|
|
585
|
+
def _cap2(**kw):
|
|
586
|
+
captured2.update(kw)
|
|
587
|
+
return _orig2(**kw)
|
|
588
|
+
_akq.run_backtest = _cap2
|
|
589
|
+
try:
|
|
590
|
+
_ = run_akquant_backtest(strat_path, cfg)
|
|
591
|
+
finally:
|
|
592
|
+
_akq.run_backtest = _orig2
|
|
593
|
+
# history_depth 应被自动算出 (≥15, 视 MACross 默认值)
|
|
594
|
+
assert captured2.get('history_depth', 0) >= 15, (
|
|
595
|
+
f'history_depth 未自动传: {captured2.get("history_depth")}')
|
|
596
|
+
# start_time / end_time 应是 ISO 格式
|
|
597
|
+
assert captured2.get('start_time') == '2024-07-01', (
|
|
598
|
+
f'start_time ISO 转换错: {captured2.get("start_time")}')
|
|
599
|
+
assert captured2.get('end_time') == '2024-12-31', (
|
|
600
|
+
f'end_time ISO 转换错: {captured2.get("end_time")}')
|
|
601
|
+
# commission_policy 应替代 commission_rate
|
|
602
|
+
assert 'commission_policy' in captured2, (
|
|
603
|
+
f'0.3+ commission_policy 未传: keys={list(captured2.keys())[:15]}...')
|
|
604
|
+
assert 'commission_rate' not in captured2, (
|
|
605
|
+
'commission_policy 与 commission_rate 不应同时传')
|
|
606
|
+
# lot_size 应传 100 (A 股整手)
|
|
607
|
+
assert captured2.get('lot_size') == 100, (
|
|
608
|
+
f'lot_size 应默认 100, 实际 {captured2.get("lot_size")}')
|
|
609
|
+
print(f'OK: 端到端 kwargs 注入 (history_depth={captured2["history_depth"]}, '
|
|
610
|
+
f'start={captured2["start_time"]}, end={captured2["end_time"]}, '
|
|
611
|
+
f'lot_size={captured2["lot_size"]}, commission_policy={captured2["commission_policy"]})')
|
|
612
|
+
finally:
|
|
613
|
+
if old is None:
|
|
614
|
+
os.environ.pop('HAMUNA_STRATEGIES_ROOT', None)
|
|
615
|
+
else:
|
|
616
|
+
os.environ['HAMUNA_STRATEGIES_ROOT'] = old
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
if __name__ == '__main__':
|
|
620
|
+
_selfcheck()
|