hamuna-quant-cli 0.1.0.dev93__tar.gz → 0.1.1__tar.gz
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-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/PKG-INFO +3 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/__init__.py +1 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/__main__.py +6 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_data_adapter.py +3 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_runner.py +87 -26
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/qmt_market.py +16 -3
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/runner.py +163 -14
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/discipline.py +201 -0
- hamuna_quant_cli-0.1.1/hamuna_quant_cli/scripts/server.json +4 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/PKG-INFO +3 -1
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/requires.txt +2 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/pyproject.toml +3 -1
- hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/scripts/server.json +0 -4
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/README.md +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/qmt_broker.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/backtest.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/server_client.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/tests/test_live_safety.py +0 -0
- {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/tests/test_skeleton.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hamuna-quant-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范.
|
|
5
5
|
Author-email: Hamuna Team <team@hamuna.example>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,8 @@ Requires-Dist: numpy>=1.23
|
|
|
25
25
|
Requires-Dist: pyarrow>=10
|
|
26
26
|
Requires-Dist: pyyaml>=6
|
|
27
27
|
Requires-Dist: requests>=2.28
|
|
28
|
+
Requires-Dist: boto3>=1.34
|
|
29
|
+
Requires-Dist: cryptography>=41
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
32
|
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
@@ -800,6 +800,9 @@ def cmd_live_run(args) -> int:
|
|
|
800
800
|
return 3
|
|
801
801
|
try:
|
|
802
802
|
symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
|
|
803
|
+
params = json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
|
|
804
|
+
# 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
|
|
805
|
+
sp = (params or {}).get("strategy_params")
|
|
803
806
|
run_live(
|
|
804
807
|
spec,
|
|
805
808
|
mode=args.mode,
|
|
@@ -812,6 +815,7 @@ def cmd_live_run(args) -> int:
|
|
|
812
815
|
log_file=args.log_file,
|
|
813
816
|
market_broker=args.market_broker,
|
|
814
817
|
replay_days=args.replay_days,
|
|
818
|
+
params=sp if isinstance(sp, dict) else None,
|
|
815
819
|
)
|
|
816
820
|
except LiveRunError as e:
|
|
817
821
|
print(f"[error] live run 失败: {e}", file=sys.stderr)
|
|
@@ -950,6 +954,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
950
954
|
help="独立行情 broker (e.g. qmt_market)")
|
|
951
955
|
run_p.add_argument("--symbols", default=None,
|
|
952
956
|
help="标的列表, 逗号分隔")
|
|
957
|
+
run_p.add_argument("--params", default=None,
|
|
958
|
+
help="params.json 路径 (固定 schema, strategy_params 段作策略构造参数)")
|
|
953
959
|
run_p.add_argument("--duration", default="1h",
|
|
954
960
|
help="运行时长 (akquant 解析)")
|
|
955
961
|
run_p.add_argument("--class-name", default="Strategy",
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_data_adapter.py
RENAMED
|
@@ -124,7 +124,9 @@ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
|
|
|
124
124
|
f'prebuilt 缺 OHLCV 列 {missing} (R2: schema 不兼容). 实际列: {list(df.columns)[:10]}...')
|
|
125
125
|
for c in _DROP_COLS:
|
|
126
126
|
df = df.drop(columns=[c], errors='ignore')
|
|
127
|
-
|
|
127
|
+
# time 字段兼容 8 位 (YYYYMMDD, 个股) / 14 位 (YYYYMMDDHHMMSS, ETF/可转债) / ISO (YYYY-MM-DD)
|
|
128
|
+
_date_str = df['date'].astype(str).str[:10].str.replace('-', '', regex=False)
|
|
129
|
+
df['date'] = pd.to_datetime(_date_str, format='%Y%m%d')
|
|
128
130
|
df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']]
|
|
129
131
|
return df.reset_index(drop=True)
|
|
130
132
|
|
|
@@ -203,14 +203,24 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
|
|
|
203
203
|
't_plus_one': bool(cfg.get('t_plus_one', True)),
|
|
204
204
|
'show_progress': False,
|
|
205
205
|
}
|
|
206
|
-
#
|
|
207
|
-
# -
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
#
|
|
206
|
+
# strategy_params 注入 (0.3.x ParamModel 字段):
|
|
207
|
+
# - universe: 多标/横截面策略必需, cfg['universe']/['pool'] 归一化后注入.
|
|
208
|
+
# - exclude_symbols: 策略声明 `exclude_symbols = ListParam(default=[])` 时,
|
|
209
|
+
# cfg['exclude_symbols'] (裸码 list) 归一化后并列注入. 策略在 on_bar 里
|
|
210
|
+
# 拿 self.params.exclude_symbols 和 bar.symbol 比对 (两者同带后缀形态).
|
|
211
|
+
# 只有策略声明该字段才注入 — 未声明注入 = engine strict 模式 unknown key 崩溃.
|
|
212
|
+
# ponytail: 回测侧 exclude_symbols 仅来自 cfg; 实盘 QMT「排除已持仓」需策略在
|
|
213
|
+
# on_bar 里自己调 broker query_positions() (runner 不透传持仓, 那是动态状态).
|
|
214
|
+
accepted_fields = _strategy_param_fields(strat_cls)
|
|
215
|
+
strategy_params: dict[str, Any] = {}
|
|
216
|
+
if 'universe' in accepted_fields:
|
|
217
|
+
strategy_params['universe'] = universe
|
|
218
|
+
if 'exclude_symbols' in accepted_fields:
|
|
219
|
+
exclude_codes = cfg.get('exclude_symbols') or []
|
|
220
|
+
strategy_params['exclude_symbols'] = [
|
|
221
|
+
normalize_symbol(s) for s in exclude_codes]
|
|
222
|
+
if strategy_params:
|
|
223
|
+
kwargs['strategy_params'] = strategy_params
|
|
214
224
|
|
|
215
225
|
# ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
|
|
216
226
|
# 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
|
|
@@ -393,34 +403,44 @@ def _akquant_supports_commission_policy() -> bool:
|
|
|
393
403
|
return _akquant_kwarg_supported('commission_policy')
|
|
394
404
|
|
|
395
405
|
|
|
396
|
-
def
|
|
397
|
-
"""
|
|
406
|
+
def _strategy_param_fields(strat_cls: type) -> set[str]:
|
|
407
|
+
"""返回 strategy 声明的 0.3.x ParamModel 字段名集合.
|
|
398
408
|
|
|
399
|
-
0.3.x: `universe: list = ListParam(default=[])`
|
|
400
|
-
0.2.x: `__init__(self, universe=None)` —
|
|
409
|
+
0.3.x: `universe: list = ListParam(default=[])` 类字段 → model_fields.
|
|
410
|
+
0.2.x: `__init__(self, universe=None)` — 无 model_fields, 返空集 (runner 跳过注入).
|
|
401
411
|
|
|
402
|
-
|
|
403
|
-
runner 不替用户策略注入; grid/WFO 子命令会单独处理.
|
|
412
|
+
空集 = 策略没用 ParamModel (老 __init__ 风格), 不是「豁免」— 0.3.x 严格拒收.
|
|
404
413
|
"""
|
|
405
414
|
try:
|
|
406
|
-
# pydantic-based ParamModel: 实例化后 inst.params.<name> 存在
|
|
407
415
|
with warnings.catch_warnings():
|
|
408
416
|
warnings.simplefilter('ignore') # 0.3.x 对老 __init__ 形参的 UserWarning
|
|
409
417
|
inst = strat_cls()
|
|
410
418
|
if not hasattr(inst, 'params'):
|
|
411
|
-
return
|
|
419
|
+
return set()
|
|
412
420
|
params_obj = getattr(inst, 'params')
|
|
413
|
-
|
|
414
|
-
fields
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return True
|
|
421
|
-
return False
|
|
421
|
+
fields = getattr(params_obj, 'model_fields', None) # pydantic v2
|
|
422
|
+
if fields:
|
|
423
|
+
return set(fields)
|
|
424
|
+
fields_v1 = getattr(params_obj, '__fields__', None) # pydantic v1 fallback
|
|
425
|
+
if fields_v1:
|
|
426
|
+
return set(fields_v1)
|
|
427
|
+
return set()
|
|
422
428
|
except Exception: # noqa: BLE001
|
|
423
|
-
return
|
|
429
|
+
return set()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _strategy_accepts_universe_param(strat_cls: type) -> bool:
|
|
433
|
+
"""检测 strategy 是否声明 `universe` 为 0.3.x 的 ParamModel 字段."""
|
|
434
|
+
return 'universe' in _strategy_param_fields(strat_cls)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _strategy_accepts_exclude_symbols_param(strat_cls: type) -> bool:
|
|
438
|
+
"""检测 strategy 是否声明 `exclude_symbols` 为 0.3.x 的 ParamModel 字段.
|
|
439
|
+
|
|
440
|
+
仅当策略显式声明才注入 — 未声明字段注入会在 strict_strategy_params=True 下
|
|
441
|
+
被 engine 判 unknown key → TypeError (用户 2026-08-25 实际踩过).
|
|
442
|
+
"""
|
|
443
|
+
return 'exclude_symbols' in _strategy_param_fields(strat_cls)
|
|
424
444
|
|
|
425
445
|
|
|
426
446
|
def _selfcheck() -> None:
|
|
@@ -537,6 +557,47 @@ class UsesUniverse(Strategy):
|
|
|
537
557
|
'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
|
|
538
558
|
print(f'OK: universe 注入路径生效 (0.3 ParamModel → strategy_params; 旧 __init__ 风格跳过)')
|
|
539
559
|
|
|
560
|
+
# ===== exclude_symbols 注入 (2026-08-25, 用户实战踩坑): =====
|
|
561
|
+
# 仅当策略声明 `exclude_symbols = ListParam(...)` 才注入; 未声明注入 =
|
|
562
|
+
# engine strict 模式 unknown key → TypeError. 注入值归一化为带后缀形态.
|
|
563
|
+
strat4 = Path(tmp) / 'uses_exclude.py'
|
|
564
|
+
strat4.write_text('''
|
|
565
|
+
from akquant import Strategy, ListParam
|
|
566
|
+
|
|
567
|
+
class UsesExclude(Strategy):
|
|
568
|
+
"""0.3.x 新 ParamModel 风格: universe + exclude_symbols 双字段."""
|
|
569
|
+
universe = ListParam(item_type=str, default=[])
|
|
570
|
+
exclude_symbols = ListParam(item_type=str, default=[])
|
|
571
|
+
def on_bar(self, bar):
|
|
572
|
+
pass
|
|
573
|
+
''', encoding='utf-8')
|
|
574
|
+
captured.clear()
|
|
575
|
+
_akq.run_backtest = _capture
|
|
576
|
+
try:
|
|
577
|
+
cfg_ex = {**cfg, 'exclude_symbols': ['600036']}
|
|
578
|
+
_ = run_akquant_backtest(strat4, cfg_ex)
|
|
579
|
+
assert captured.get('strategy_params') == {
|
|
580
|
+
'universe': ['600000.SH'],
|
|
581
|
+
'exclude_symbols': ['600036.SH'],
|
|
582
|
+
}, f'UsesExclude 注入错 (应归一化为带后缀): {captured.get("strategy_params")}'
|
|
583
|
+
finally:
|
|
584
|
+
_akq.run_backtest = _orig_run
|
|
585
|
+
# MACross (无 ParamModel) 不应注入 exclude_symbols — strict 模式会崩
|
|
586
|
+
captured.clear()
|
|
587
|
+
_akq.run_backtest = _capture
|
|
588
|
+
try:
|
|
589
|
+
_ = run_akquant_backtest(strat_path, cfg_ex) # MACross 走 0.2 __init__ 风格
|
|
590
|
+
assert 'strategy_params' not in captured, (
|
|
591
|
+
f'MACross 不应注入 exclude_symbols: {captured.get("strategy_params")}')
|
|
592
|
+
finally:
|
|
593
|
+
_akq.run_backtest = _orig_run
|
|
594
|
+
_uses_excl = _load_akquant_strategy(strat4)
|
|
595
|
+
assert _strategy_accepts_exclude_symbols_param(_uses_excl), (
|
|
596
|
+
'UsesExclude (ListParam) 应 _strategy_accepts_exclude_symbols_param=True')
|
|
597
|
+
assert not _strategy_accepts_exclude_symbols_param(_macross_cls), (
|
|
598
|
+
'MACross (无 exclude_symbols 字段) 应 False')
|
|
599
|
+
print('OK: exclude_symbols 注入 (仅声明注入 + 归一化带后缀 + 未声明跳过)')
|
|
600
|
+
|
|
540
601
|
# ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
|
|
541
602
|
# 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
|
|
542
603
|
# (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)
|
|
@@ -220,9 +220,9 @@ def _make_market_gateway() -> Any:
|
|
|
220
220
|
if not isinstance(payload, dict):
|
|
221
221
|
continue
|
|
222
222
|
tick = dict(payload)
|
|
223
|
-
# QMT sym (e.g. 600000.XSHG) →
|
|
224
|
-
|
|
225
|
-
tick.setdefault("symbol",
|
|
223
|
+
# QMT sym (e.g. 600000.XSHG) → akquant 标准 (600000.SH) 匹配 instruments
|
|
224
|
+
std_sym = _from_qmt_symbol(qmt_sym)
|
|
225
|
+
tick.setdefault("symbol", std_sym)
|
|
226
226
|
ticks.append(tick)
|
|
227
227
|
return ticks
|
|
228
228
|
|
|
@@ -256,6 +256,19 @@ def _to_qmt_symbol(symbol: str) -> str:
|
|
|
256
256
|
return s.upper()
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def _from_qmt_symbol(symbol: str) -> str:
|
|
260
|
+
"""QMT 内部 symbol (600000.XSHG / 000001.XSHE / 830001.XBJE) → akquant 标准
|
|
261
|
+
(600000.SH / 000001.SZ / 830001.BJ). 与 instruments 的 symbol 匹配, 否则
|
|
262
|
+
on_tick/on_bar 回调因 symbol 不匹配收不到.
|
|
263
|
+
"""
|
|
264
|
+
s = str(symbol).strip()
|
|
265
|
+
if "." not in s:
|
|
266
|
+
return s
|
|
267
|
+
code, _, ex = s.partition(".")
|
|
268
|
+
ex_map = {"XSHG": "SH", "XSHE": "SZ", "XBJE": "BJ", "SH": "SH", "SZ": "SZ", "BJ": "BJ"}
|
|
269
|
+
return f"{code}.{ex_map.get(ex.upper(), ex.upper())}"
|
|
270
|
+
|
|
271
|
+
|
|
259
272
|
def _unwrap_envelope(resp: dict[str, Any]) -> Any:
|
|
260
273
|
"""bridge_server envelope {ok, value, request_id, ...} → 业务数据 value 字段.
|
|
261
274
|
|
|
@@ -75,28 +75,139 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
|
|
|
75
75
|
# envelope {ok, value} 或裸 dict — 兼容两种
|
|
76
76
|
if isinstance(payload, dict) and "value" in payload:
|
|
77
77
|
payload = payload["value"]
|
|
78
|
+
# QMT bridge 常见 dataframe 形态: {dtype: "dataframe", columns: [...], records: [[...], ...]}
|
|
79
|
+
# 转成 list[dict] 供上层 DataFrame 消费.
|
|
80
|
+
if isinstance(payload, dict) and payload.get("dtype") == "dataframe":
|
|
81
|
+
cols = payload.get("columns") or []
|
|
82
|
+
recs = payload.get("records") or []
|
|
83
|
+
payload = [dict(zip(cols, row)) for row in recs]
|
|
78
84
|
if not isinstance(payload, list):
|
|
79
85
|
raise LiveRunError(
|
|
80
|
-
f"bridge /data/history 响应 shape 异常 ({sym}): 期望 list[bar],
|
|
81
|
-
|
|
86
|
+
f"bridge /data/history 响应 shape 异常 ({sym}): 期望 list[bar] 或 dataframe dict, "
|
|
87
|
+
f"实得 {type(payload).__name__}"
|
|
88
|
+
)
|
|
82
89
|
return payload
|
|
83
90
|
|
|
84
91
|
|
|
92
|
+
def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str = "1d",
|
|
93
|
+
count: int = 30, timeout: float = 45.0) -> dict[str, list[dict[str, Any]]]:
|
|
94
|
+
"""GET /data/history?securities=...&multi=true → {sym: [bar dict]} 批量获取.
|
|
95
|
+
|
|
96
|
+
优先使用 bridge_server 的批量 multi 接口 (一次 HTTP 拉多只), 大幅减少
|
|
97
|
+
逐只 HTTP 开销 (300 只从 ~27s 降到 ~1s). 失败时返回 {} 让调用方回退逐只.
|
|
98
|
+
"""
|
|
99
|
+
from urllib.parse import urlencode
|
|
100
|
+
from urllib.request import urlopen, Request
|
|
101
|
+
import json as _json
|
|
102
|
+
# 裸码 (bridge 不识别 .SH/.SZ 后缀)
|
|
103
|
+
qmt_codes = []
|
|
104
|
+
for sym in symbols:
|
|
105
|
+
c = sym.split(".")[0]
|
|
106
|
+
if c.lower().startswith(("sh", "sz")):
|
|
107
|
+
c = c[2:]
|
|
108
|
+
qmt_codes.append(c)
|
|
109
|
+
q = urlencode({
|
|
110
|
+
"securities": ",".join(qmt_codes),
|
|
111
|
+
"period": period, "count": str(count), "fq": "None", "multi": "true",
|
|
112
|
+
})
|
|
113
|
+
url = f"{bridge_url.rstrip('/')}/data/history?{q}"
|
|
114
|
+
try:
|
|
115
|
+
with urlopen(Request(url), timeout=timeout) as resp:
|
|
116
|
+
if resp.status != 200:
|
|
117
|
+
return {}
|
|
118
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
119
|
+
payload = _json.loads(raw)
|
|
120
|
+
except Exception:
|
|
121
|
+
return {}
|
|
122
|
+
# envelope {ok, value}
|
|
123
|
+
if isinstance(payload, dict) and "value" in payload:
|
|
124
|
+
payload = payload["value"]
|
|
125
|
+
# multi 结构: {dtype: "multi", symbols: {sym: dataframe}}
|
|
126
|
+
if isinstance(payload, dict) and payload.get("dtype") == "multi":
|
|
127
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
128
|
+
for sym, df_payload in (payload.get("symbols") or {}).items():
|
|
129
|
+
cols = df_payload.get("columns") or []
|
|
130
|
+
recs = df_payload.get("records") or []
|
|
131
|
+
out[sym] = [dict(zip(cols, row)) for row in recs]
|
|
132
|
+
return out
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# 分片并发批量拉取: 每片最多 50 只标的 (避免单次 HTTP 过大 + 并发提速)
|
|
137
|
+
_BATCH_CHUNK_SIZE = 50
|
|
138
|
+
|
|
85
139
|
def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d",
|
|
86
|
-
count: int =
|
|
140
|
+
count: int = 30, timeout: float = 45.0,
|
|
141
|
+
chunk_size: int = _BATCH_CHUNK_SIZE,
|
|
142
|
+
max_workers: int | None = None) -> dict[str, Any]:
|
|
87
143
|
"""实盘 startup: 拉 N sym × N bar 历史, 拼成 {sym: DataFrame} 喂 compute_factors.
|
|
88
144
|
|
|
89
145
|
实盘只支持日线 (1d) — QMT 5m/1m/tick 走 market_broker=qmt_market 的实时 tick,
|
|
90
146
|
不在 compute_factors 启动期预计算范围.
|
|
147
|
+
|
|
148
|
+
策略:
|
|
149
|
+
1. 大量标的按每片最多 chunk_size (默认 50) 分片
|
|
150
|
+
2. 每片用批量 multi 接口 (一次 HTTP 拉 ≤50 只)
|
|
151
|
+
3. 多片 ThreadPoolExecutor 并发执行 (默认 min(32, 片数))
|
|
152
|
+
4. 单只失败的片回退逐只 (兼容旧 bridge_server)
|
|
91
153
|
"""
|
|
92
154
|
import pandas as _pd
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
155
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
156
|
+
|
|
157
|
+
if not universe:
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
# 分片: 每片最多 chunk_size
|
|
161
|
+
chunks = [universe[i:i + chunk_size] for i in range(0, len(universe), chunk_size)]
|
|
162
|
+
if max_workers is None:
|
|
163
|
+
max_workers = min(32, len(chunks))
|
|
164
|
+
|
|
165
|
+
def _norm_sym(sym: str) -> str:
|
|
166
|
+
"""裸码 → 标准 symbol (批量返回可能是 000001 而非 000001.SZ)."""
|
|
167
|
+
s = str(sym)
|
|
168
|
+
if "." not in s and len(s) == 6 and s.isdigit():
|
|
169
|
+
return f"{s}.SH" if s.startswith(("6", "9")) else f"{s}.SZ"
|
|
170
|
+
return s
|
|
171
|
+
|
|
172
|
+
def _fetch_chunk(chunk: list[str]) -> dict[str, _pd.DataFrame]:
|
|
173
|
+
"""拉一片: 批量优先, 失败逐只. 批量返回的裸码 key 会归一化到标准 symbol."""
|
|
174
|
+
out: dict[str, _pd.DataFrame] = {}
|
|
175
|
+
batch = _fetch_bridge_history_batch(bridge_url, chunk, period, count, timeout=timeout)
|
|
176
|
+
if batch:
|
|
177
|
+
# 归一化批量返回的 key (裸码 → 标准), 并只保留本片请求的
|
|
178
|
+
wanted = set(chunk)
|
|
179
|
+
for sym, bars in batch.items():
|
|
180
|
+
std = _norm_sym(sym)
|
|
181
|
+
if std in wanted and std not in out:
|
|
182
|
+
out[std] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
183
|
+
# 批量可能漏掉部分 (返回不足), 漏的逐只补
|
|
184
|
+
missing = [s for s in chunk if s not in out]
|
|
98
185
|
else:
|
|
99
|
-
|
|
186
|
+
missing = chunk
|
|
187
|
+
for sym in missing:
|
|
188
|
+
bars = _fetch_bridge_history(bridge_url, sym, period, count, timeout=timeout)
|
|
189
|
+
out[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
factors: dict[str, _pd.DataFrame] = {}
|
|
193
|
+
if len(chunks) == 1:
|
|
194
|
+
factors.update(_fetch_chunk(chunks[0]))
|
|
195
|
+
return factors
|
|
196
|
+
|
|
197
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
198
|
+
futures = {pool.submit(_fetch_chunk, c): c for c in chunks}
|
|
199
|
+
for fut in as_completed(futures):
|
|
200
|
+
try:
|
|
201
|
+
factors.update(fut.result())
|
|
202
|
+
except Exception as e:
|
|
203
|
+
# 片内失败: 逐只兜底 (不阻塞整体)
|
|
204
|
+
chunk = futures[fut]
|
|
205
|
+
for sym in chunk:
|
|
206
|
+
try:
|
|
207
|
+
bars = _fetch_bridge_history(bridge_url, sym, period, count, timeout=timeout)
|
|
208
|
+
factors[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
209
|
+
except Exception:
|
|
210
|
+
factors[sym] = _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
100
211
|
return factors
|
|
101
212
|
|
|
102
213
|
|
|
@@ -168,17 +279,30 @@ def configure_logging(level: str, log_file: Path | None) -> None:
|
|
|
168
279
|
akquant.configure_logging(LogConfig(**cfg_kwargs))
|
|
169
280
|
|
|
170
281
|
|
|
171
|
-
def _with_live_history_depth(cls: type) -> type:
|
|
282
|
+
def _with_live_history_depth(cls: type, params: dict | None = None) -> type:
|
|
172
283
|
"""class mode 注入 set_history_depth — live 引擎不像回测 (engine.py:4637) 那样
|
|
173
284
|
自动开 history tracking, 策略 on_bar 里 get_history 直接抛
|
|
174
285
|
"History tracking is not enabled" (2026-08-19 实测). depth 取策略 on_start 设的
|
|
175
286
|
warmup_period, 未设默认 120.
|
|
176
287
|
|
|
288
|
+
params: 用户实盘参数 (params.json strategy_params) — 作构造 kwargs 注入.
|
|
289
|
+
live 端 _build_strategy_instance 是 `cls()` 无参实例化, 不传的话 Strategy.__new__
|
|
290
|
+
走 `model_cls()` 空模型, 用户配置的参数不生效 (2026-08-25 实测缺口).
|
|
291
|
+
注入必须盖 `__new__`: Strategy 的参数入口在 __new__ 末尾 `model_cls(**kwargs)`,
|
|
292
|
+
盖 __init__ 传 kwargs 不回灌给已结束的 __new__ (无参构造时 params 丢失).
|
|
293
|
+
|
|
177
294
|
ponytail: 只包 class mode; functional mode 用户自己管 get_history (ctx 侧).
|
|
178
295
|
"""
|
|
179
296
|
from typing import Any as _Any
|
|
180
297
|
|
|
181
298
|
class _Wrapped(cls):
|
|
299
|
+
_user_params: dict = params or {}
|
|
300
|
+
|
|
301
|
+
def __new__(cls, *a: _Any, **kw: _Any) -> _Any:
|
|
302
|
+
merged = dict(cls._user_params)
|
|
303
|
+
merged.update(kw)
|
|
304
|
+
return super().__new__(cls, *a, **merged)
|
|
305
|
+
|
|
182
306
|
def on_start(self) -> _Any:
|
|
183
307
|
ret = super().on_start()
|
|
184
308
|
depth = getattr(self, "warmup_period", None) or 120
|
|
@@ -234,6 +358,7 @@ def run_live(
|
|
|
234
358
|
log_file: Path | None,
|
|
235
359
|
market_broker: str | None,
|
|
236
360
|
replay_days: int = 30,
|
|
361
|
+
params: dict | None = None,
|
|
237
362
|
) -> None:
|
|
238
363
|
"""翻译 + 转发到 akquant.run_live.
|
|
239
364
|
|
|
@@ -247,9 +372,13 @@ def run_live(
|
|
|
247
372
|
duration: 字符串 ("30s" / "1h" / "2d"), akquant 自己解析
|
|
248
373
|
gateway_options_raw: CLI 字符串, parse 成 dict 后透传
|
|
249
374
|
initial_cash: 回传给 run_live (None = akquant 默认)
|
|
375
|
+
params: params.json 的 strategy_params (用户实盘配置) — 校验后作构造
|
|
376
|
+
kwargs 注入策略. None = 用策略代码内默认值.
|
|
250
377
|
"""
|
|
251
378
|
gateway_options = parse_kv_pairs(gateway_options_raw)
|
|
252
379
|
duration = _live_safety_defaults(mode, broker, gateway_options, duration)
|
|
380
|
+
# replay broker 的 bars 数据 — 供 v2 architecture compute_factors 复用
|
|
381
|
+
replay_bars_df = None
|
|
253
382
|
|
|
254
383
|
# replay broker 必须配 trading_mode="paper" — 提前给出清晰错误, 而不是让 akquant 在深处抛
|
|
255
384
|
if broker == "replay" and mode == "broker_live":
|
|
@@ -262,6 +391,7 @@ def run_live(
|
|
|
262
391
|
# bounded_event_total 让回放完自动停, 不依赖 --duration 墙钟.
|
|
263
392
|
if broker == "replay":
|
|
264
393
|
bars_df, replay_src = _recent_real_bars(symbols, days=replay_days)
|
|
394
|
+
replay_bars_df = bars_df # 供 v2 architecture compute_factors 复用
|
|
265
395
|
instruments = _build_instruments(sorted(bars_df["symbol"].unique()), normalize=True)
|
|
266
396
|
gateway_options["bars"] = bars_df
|
|
267
397
|
print(
|
|
@@ -323,7 +453,10 @@ def run_live(
|
|
|
323
453
|
# functional mode / 无 bridge_url 跳过 (兼容老策略).
|
|
324
454
|
if spec.mode == "class" and spec.strategy_cls is not None:
|
|
325
455
|
qmt_base_url = gateway_options.get("qmt_base_url")
|
|
326
|
-
|
|
456
|
+
# replay (paper trading) 用本地 bars 数据; qmt 用 bridge /data/history.
|
|
457
|
+
# 两者都走 compute_factors / filter_symbols (v2 architecture).
|
|
458
|
+
has_replay_data = replay_bars_df is not None
|
|
459
|
+
if qmt_base_url or has_replay_data:
|
|
327
460
|
try:
|
|
328
461
|
strat_inst = spec.strategy_cls()
|
|
329
462
|
except Exception as e:
|
|
@@ -354,8 +487,12 @@ def run_live(
|
|
|
354
487
|
factors: dict[str, Any] = {}
|
|
355
488
|
if hasattr(strat_inst, "compute_factors"):
|
|
356
489
|
# 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
|
|
490
|
+
# qmt: bridge /data/history; replay: 本地 prebuilt bars (paper trading).
|
|
357
491
|
try:
|
|
358
|
-
|
|
492
|
+
if has_replay_data:
|
|
493
|
+
raw_factors = replay_bars_df
|
|
494
|
+
else:
|
|
495
|
+
raw_factors = _fetch_live_factors(qmt_base_url, symbols)
|
|
359
496
|
except LiveRunError:
|
|
360
497
|
raise
|
|
361
498
|
except Exception as e:
|
|
@@ -406,7 +543,7 @@ def run_live(
|
|
|
406
543
|
instruments = _build_instruments(filtered)
|
|
407
544
|
else:
|
|
408
545
|
print(
|
|
409
|
-
"[info] class mode
|
|
546
|
+
"[info] class mode 无 qmt_base_url / replay bars; 跳过 compute_factors / filter_symbols",
|
|
410
547
|
flush=True,
|
|
411
548
|
)
|
|
412
549
|
elif spec.mode == "functional":
|
|
@@ -434,7 +571,19 @@ def run_live(
|
|
|
434
571
|
|
|
435
572
|
# class mode vs functional mode — run_live 入参不同
|
|
436
573
|
if spec.mode == "class":
|
|
437
|
-
|
|
574
|
+
# params.json 用户配置 → 校验 (未知字段/越界抛 LiveRunError) + 注入构造 kwargs.
|
|
575
|
+
# live 端 _build_strategy_instance 是 `cls()` 无参实例化, 必须在包装类里把
|
|
576
|
+
# params 传进 __new__ → ParamModel(**kwargs) 才生效 (2026-08-25 实测缺口).
|
|
577
|
+
sp = None
|
|
578
|
+
if params:
|
|
579
|
+
try:
|
|
580
|
+
from akquant import validate_strategy_params
|
|
581
|
+
sp = validate_strategy_params(spec.strategy_cls, params)
|
|
582
|
+
except Exception as e:
|
|
583
|
+
raise LiveRunError(
|
|
584
|
+
f"params.json 校验失败 (strategy_params 与 IntParam/ListParam 字段不符): {e}"
|
|
585
|
+
) from e
|
|
586
|
+
kwargs["strategy_cls"] = _with_live_history_depth(spec.strategy_cls, sp)
|
|
438
587
|
else:
|
|
439
588
|
cbs = spec.callbacks or {}
|
|
440
589
|
kwargs["strategy_cls"] = cbs["on_bar"]
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/discipline.py
RENAMED
|
@@ -283,6 +283,175 @@ def _rule_universe_param_style(tree: ast.Module) -> list[DisciplineError]:
|
|
|
283
283
|
break
|
|
284
284
|
return errs
|
|
285
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
|
+
|
|
286
455
|
# ---- 聚合入口 ------
|
|
287
456
|
def check_discipline(source: str, config: dict) -> list[DisciplineError]:
|
|
288
457
|
"""返 0~N 条 DisciplineError; 空 list = 通过. cmd_run 在 backtest.run 之前调."""
|
|
@@ -304,6 +473,8 @@ def check_discipline(source: str, config: dict) -> list[DisciplineError]:
|
|
|
304
473
|
errs += _rule_bar_field_uses_timestamp(tree)
|
|
305
474
|
errs += _rule_get_history_batched(tree)
|
|
306
475
|
errs += _rule_universe_param_style(tree)
|
|
476
|
+
errs += _rule_indicator_uses_builtin(tree)
|
|
477
|
+
errs += _rule_get_history_nan_guard(tree)
|
|
307
478
|
return errs
|
|
308
479
|
|
|
309
480
|
|
|
@@ -354,6 +525,36 @@ class MyStrat(Strategy):
|
|
|
354
525
|
assert any(e.rule == 'bar_field_alias_trap' for e in errs), f'应拦 bar.time: {errs}'
|
|
355
526
|
print(f'OK: bar.time 陷阱拦住')
|
|
356
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
|
+
|
|
357
558
|
|
|
358
559
|
if __name__ == '__main__':
|
|
359
560
|
_selfcheck()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hamuna-quant-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范.
|
|
5
5
|
Author-email: Hamuna Team <team@hamuna.example>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,8 @@ Requires-Dist: numpy>=1.23
|
|
|
25
25
|
Requires-Dist: pyarrow>=10
|
|
26
26
|
Requires-Dist: pyyaml>=6
|
|
27
27
|
Requires-Dist: requests>=2.28
|
|
28
|
+
Requires-Dist: boto3>=1.34
|
|
29
|
+
Requires-Dist: cryptography>=41
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
32
|
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hamuna-quant-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范."
|
|
9
9
|
readme = "hamuna_quant_cli/README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -32,6 +32,8 @@ dependencies = [
|
|
|
32
32
|
"pyarrow>=10",
|
|
33
33
|
"pyyaml>=6",
|
|
34
34
|
"requests>=2.28",
|
|
35
|
+
"boto3>=1.34",
|
|
36
|
+
"cryptography>=41",
|
|
35
37
|
]
|
|
36
38
|
|
|
37
39
|
[project.optional-dependencies]
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"api_base": "http://localhost:8080",
|
|
3
|
-
"_comment": "部署配置层 — Round 14 注入方式: pip install hamuna-quant-cli 后, http_client.py 自动按优先级 1) HAMUNA_SERVER env 2) HAMUNA_SERVER_JSON env 3) ./scripts/server.json 4) <包根>/scripts/server.json (本文件) 5) ./server.json 6) 默认 localhost:8080 解析. 部署方只需改这一个文件 + 重装包即可切 server."
|
|
4
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_market_fallback.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_test_akquant_parity.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_schema_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/cross_sectional_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_downloader.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_resolver.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/__init__.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/backtest.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/http_client.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/s3client.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/server_client.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|