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.
Files changed (41) hide show
  1. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/PKG-INFO +3 -1
  2. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/__main__.py +6 -0
  4. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_data_adapter.py +3 -1
  5. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_runner.py +87 -26
  6. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/qmt_market.py +16 -3
  7. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/runner.py +163 -14
  8. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/discipline.py +201 -0
  9. hamuna_quant_cli-0.1.1/hamuna_quant_cli/scripts/server.json +4 -0
  10. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/PKG-INFO +3 -1
  11. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/requires.txt +2 -0
  12. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/pyproject.toml +3 -1
  13. hamuna_quant_cli-0.1.0.dev93/hamuna_quant_cli/scripts/server.json +0 -4
  14. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/MANIFEST.in +0 -0
  15. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/README.md +0 -0
  16. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/README.md +0 -0
  17. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_market_fallback.py +0 -0
  18. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_metrics_15.py +0 -0
  19. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  20. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  21. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/base_strategy.py +0 -0
  22. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  23. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/__init__.py +0 -0
  24. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/loader.py +0 -0
  25. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/live/qmt_broker.py +0 -0
  26. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  27. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  28. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/qmt_translator.py +0 -0
  29. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/__init__.py +0 -0
  30. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/backtest.py +0 -0
  31. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/cache.py +0 -0
  32. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/http_client.py +0 -0
  33. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/s3client.py +0 -0
  34. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli/runtime/server_client.py +0 -0
  35. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  36. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  37. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  38. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  39. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/setup.cfg +0 -0
  40. {hamuna_quant_cli-0.1.0.dev93 → hamuna_quant_cli-0.1.1}/tests/test_live_safety.py +0 -0
  41. {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.0.dev93
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"
@@ -14,4 +14,4 @@ from __future__ import annotations
14
14
 
15
15
  # 版本号由 scripts/bump_version.py 每次 build 自动更新 (基于 git 提交数).
16
16
  # 格式: 0.1.0.dev<git提交数> (PEP 440). 手动改这里会被下一次 build 覆盖.
17
- __version__ = "0.1.0.dev93"
17
+ __version__ = "0.1.1"
@@ -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",
@@ -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
- df['date'] = pd.to_datetime(df['date'].astype(str), format='%Y%m%d')
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
- # 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.
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 _strategy_accepts_universe_param(strat_cls: type) -> bool:
397
- """检测 strategy 是否声明 `universe` 为 0.3.x ParamModel 字段.
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=[])` 类字段, 通过 strategy_params 注入.
400
- 0.2.x: `__init__(self, universe=None)` — 0.3.x 严格拒收 (TypeError), 这里返 False 跳过.
409
+ 0.3.x: `universe: list = ListParam(default=[])` 类字段 model_fields.
410
+ 0.2.x: `__init__(self, universe=None)` — model_fields, 返空集 (runner 跳过注入).
401
411
 
402
- ponytail: 仅检测 'universe' 字段. 其它字段 (lots/fast/slow ) ParamModel 自身规则,
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 False
419
+ return set()
412
420
  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
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 False
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) → canonical 6 (e.g. 600000)
224
- bare = qmt_sym.split(".")[0] if "." in qmt_sym else qmt_sym
225
- tick.setdefault("symbol", bare)
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], 实得 {type(payload).__name__}"
81
- ) from e
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 = 60, timeout: float = 30.0) -> dict[str, Any]:
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
- factors: dict[str, _pd.DataFrame] = {}
94
- for sym in universe:
95
- bars = _fetch_bridge_history(bridge_url, sym, period, count, timeout=timeout)
96
- if not bars:
97
- factors[sym] = _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
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
- factors[sym] = _pd.DataFrame(bars)
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
- if qmt_base_url:
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
- raw_factors = _fetch_live_factors(qmt_base_url, symbols)
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 但无 qmt_base_url (gateway_options); 跳过 compute_factors / filter_symbols",
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
- kwargs["strategy_cls"] = _with_live_history_depth(spec.strategy_cls)
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"]
@@ -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()
@@ -0,0 +1,4 @@
1
+ {
2
+ "api_base": "https://sapi.ai.z-compute.cn",
3
+ "_comment": "部署配置层 — 后端 API 端点。环境变量 HAMUNA_SERVER 可覆盖。"
4
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.0.dev93
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,6 +4,8 @@ numpy>=1.23
4
4
  pyarrow>=10
5
5
  pyyaml>=6
6
6
  requests>=2.28
7
+ boto3>=1.34
8
+ cryptography>=41
7
9
 
8
10
  [backtest-extra]
9
11
  matplotlib>=3.7
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hamuna-quant-cli"
7
- version = "0.1.0.dev93"
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
- }