hamuna-quant-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
1
+ """hamuna_quant_cli.live.runner — 实盘运行 (akquant.run_live 薄壳 wrapper).
2
+
3
+ 不引入新业务逻辑 — 撮合 / 下单 / 风控 全部在 akquant 内.
4
+ 本模块只做:
5
+ 1. CLI 参数 → akquant.run_live kwargs 翻译
6
+ 2. 实盘 startup: 拉 N sym × N bar 历史 (走 bridge_server /data/history) 拼 {sym: df} 喂 compute_factors
7
+ 3. broker=qmt 时触发 hamuna_qmt_broker / hamuna_qmt_market 注册
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .loader import StrategySpec
16
+
17
+
18
+ class LiveRunError(Exception):
19
+ """live run 启动 / 配置失败."""
20
+
21
+
22
+ def parse_kv_pairs(raw: str | None) -> dict[str, str]:
23
+ """CLI 通用: `--gateway-options k1=v1,k2=v2` → dict.
24
+
25
+ 值不解析类型 — akquant 自己做类型转换 (gateway_options 多数是字符串字段).
26
+ """
27
+ if not raw:
28
+ return {}
29
+ out: dict[str, str] = {}
30
+ for pair in raw.split(","):
31
+ pair = pair.strip()
32
+ if not pair:
33
+ continue
34
+ if "=" not in pair:
35
+ raise LiveRunError(f"--gateway-options 项 '{pair}' 不是 k=v 形式")
36
+ k, v = pair.split("=", 1)
37
+ out[k.strip()] = v.strip()
38
+ return out
39
+
40
+
41
+ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
42
+ timeout: float = 30.0) -> list[dict[str, Any]]:
43
+ """GET /data/history?security=<sym>&period=<p>&count=<n>&fq=None → list of bar dict (旧→新).
44
+
45
+ bridge 不通 / 超时 / 非 200 直接 raise LiveRunError, 不静默退化 (用户原话:
46
+ 策略出错直接报错停止, 不走 mock 兜底).
47
+ """
48
+ from urllib.parse import urlencode
49
+ from urllib.request import urlopen, Request
50
+ # bridge_server 对 sh/sz 前缀不识别, strip 到 6 位 canonical
51
+ canonical = sym.split(".")[0]
52
+ if canonical.lower().startswith(("sh", "sz")):
53
+ canonical = canonical[2:]
54
+ q = urlencode({
55
+ "security": canonical, "period": period, "count": str(count), "fq": "None",
56
+ })
57
+ url = f"{bridge_url.rstrip('/')}/data/history?{q}"
58
+ try:
59
+ with urlopen(Request(url), timeout=timeout) as resp:
60
+ if resp.status != 200:
61
+ raise LiveRunError(f"bridge /data/history HTTP {resp.status}: {url}")
62
+ raw = resp.read().decode("utf-8", errors="replace")
63
+ except Exception as e:
64
+ raise LiveRunError(
65
+ f"bridge /data/history 失败 ({sym}): {type(e).__name__}: {e} "
66
+ f"(url={url}, timeout={timeout}s). 用户原话: 实盘出错直接报错停止, 不走 mock 兜底"
67
+ ) from e
68
+ try:
69
+ import json as _json
70
+ payload = _json.loads(raw)
71
+ except Exception as e:
72
+ raise LiveRunError(
73
+ f"bridge /data/history 响应非 JSON ({sym}): {e}. body 头 200 字: {raw[:200]!r}"
74
+ ) from e
75
+ # envelope {ok, value} 或裸 dict — 兼容两种
76
+ if isinstance(payload, dict) and "value" in payload:
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]
84
+ if not isinstance(payload, list):
85
+ raise LiveRunError(
86
+ f"bridge /data/history 响应 shape 异常 ({sym}): 期望 list[bar] 或 dataframe dict, "
87
+ f"实得 {type(payload).__name__}"
88
+ )
89
+ return payload
90
+
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
+
139
+ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d",
140
+ count: int = 30, timeout: float = 45.0,
141
+ chunk_size: int = _BATCH_CHUNK_SIZE,
142
+ max_workers: int | None = None) -> dict[str, Any]:
143
+ """实盘 startup: 拉 N sym × N bar 历史, 拼成 {sym: DataFrame} 喂 compute_factors.
144
+
145
+ 实盘只支持日线 (1d) — QMT 5m/1m/tick 走 market_broker=qmt_market 的实时 tick,
146
+ 不在 compute_factors 启动期预计算范围.
147
+
148
+ 策略:
149
+ 1. 大量标的按每片最多 chunk_size (默认 50) 分片
150
+ 2. 每片用批量 multi 接口 (一次 HTTP 拉 ≤50 只)
151
+ 3. 多片 ThreadPoolExecutor 并发执行 (默认 min(32, 片数))
152
+ 4. 单只失败的片回退逐只 (兼容旧 bridge_server)
153
+ """
154
+ import pandas as _pd
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]
185
+ else:
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"])
211
+ return factors
212
+
213
+
214
+ def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> list[Any] | None:
215
+ """symbols 列表 → akquant.Instrument 列表. None → None (全市场, 让策略自行 subscribe).
216
+
217
+ normalize=True: 裸码 → 带后缀 (replay 数据来自 prebuilt, stockCode 是 '600000.SH'
218
+ 形态, instrument symbol 必须一致才匹配 ReplayMarketGateway 的订阅过滤).
219
+ """
220
+ if not symbols:
221
+ return None
222
+ # 延迟 import akquant — 让 loader 在 akquant 未装环境也能跑通
223
+ from akquant import AssetType, Instrument
224
+
225
+ def _sym(s: str) -> str:
226
+ if normalize:
227
+ from ..akquant_schema_adapter import normalize_symbol
228
+ return normalize_symbol(s)
229
+ return s
230
+
231
+ return [Instrument(symbol=_sym(s), asset_type=AssetType.Stock) for s in symbols if s.strip()]
232
+
233
+
234
+ def _recent_real_bars(symbols: list[str], days: int = 30) -> tuple[Any, str]:
235
+ """最近 N 天真实日线 → replay bars DataFrame (akquant dataframe_to_bars 格式).
236
+
237
+ 数据源 = 本地 prebuilt bundle (真实历史, 与回测同源; bundle 覆盖到最新交易日).
238
+ 返 (df, src): df 列 = date/open/high/low/close/volume/symbol + "股票代码" (多标的
239
+ 识别必需, dataframe_to_bars normalize.py 只认这个列名).
240
+
241
+ Raises:
242
+ LiveRunError: 无 --symbols / 数据不可达 (提示先 dataset fetch).
243
+ """
244
+ from datetime import datetime, timedelta
245
+
246
+ from ..prebuilt_resolver import resolve
247
+
248
+ if not symbols:
249
+ raise LiveRunError(
250
+ "broker=replay 需要 --symbols 限定回放标的 (从本地 prebuilt 取最近真实数据)"
251
+ )
252
+ end = datetime.now().strftime("%Y%m%d")
253
+ start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d")
254
+ df, src = resolve(symbols, start, end)
255
+ if df is None or len(df) == 0:
256
+ raise LiveRunError(
257
+ f"最近 {days} 天无 bar 数据 (universe={symbols}, {start}~{end}, src={src}). "
258
+ f"先 `hamuna_quant_cli dataset fetch --symbols {','.join(symbols)}` 下载 prebuilt"
259
+ )
260
+ out = df.copy()
261
+ out["股票代码"] = out["symbol"]
262
+ return out, src
263
+
264
+
265
+ def configure_logging(level: str, log_file: Path | None) -> None:
266
+ """调 akquant.configure_logging — profile=live 让 on_order/on_trade/网关 warning 进同一套输出."""
267
+ try:
268
+ import akquant
269
+ from akquant import LogConfig
270
+ except ImportError:
271
+ # 没装 akquant 也不致命 — 子命令会在 run_live 处抛更具体的 ImportError,
272
+ # 用户能从那条错误知道装包; 这里只警告
273
+ print(f"[warn] akquant 未安装, 跳过日志配置 (level={level})", flush=True)
274
+ return
275
+ cfg_kwargs: dict[str, Any] = {"profile": "live", "level": level, "console": True}
276
+ if log_file:
277
+ cfg_kwargs["file_json"] = True
278
+ cfg_kwargs["filename"] = str(log_file)
279
+ akquant.configure_logging(LogConfig(**cfg_kwargs))
280
+
281
+
282
+ def _with_live_history_depth(cls: type) -> type:
283
+ """class mode 注入 set_history_depth — live 引擎不像回测 (engine.py:4637) 那样
284
+ 自动开 history tracking, 策略 on_bar 里 get_history 直接抛
285
+ "History tracking is not enabled" (2026-08-19 实测). depth 取策略 on_start 设的
286
+ warmup_period, 未设默认 120.
287
+
288
+ ponytail: 只包 class mode; functional mode 用户自己管 get_history (ctx 侧).
289
+ """
290
+ from typing import Any as _Any
291
+
292
+ class _Wrapped(cls):
293
+ def on_start(self) -> _Any:
294
+ ret = super().on_start()
295
+ depth = getattr(self, "warmup_period", None) or 120
296
+ try:
297
+ self.set_history_depth(depth)
298
+ except Exception as e: # noqa: BLE001 — depth 失败不应让 on_start 崩
299
+ print(f"[warn] set_history_depth({depth}) 失败: {e}", flush=True)
300
+ return ret
301
+
302
+ _Wrapped.__name__ = cls.__name__
303
+ _Wrapped.__qualname__ = cls.__qualname__
304
+ return _Wrapped
305
+
306
+
307
+ def _live_safety_defaults(
308
+ mode: str,
309
+ broker: str,
310
+ gateway_options: dict[str, str],
311
+ duration: str,
312
+ ) -> str:
313
+ """P0-1/P0-2 实盘安全默认: broker_live+qmt 强制真实下单 + 永久运行.
314
+
315
+ 原地改 gateway_options (注入 qmt_paper=0 或报错), 返回修正后的 duration.
316
+ """
317
+ if mode == "broker_live" and broker == "qmt":
318
+ paper_val = gateway_options.get("qmt_paper")
319
+ if paper_val is None:
320
+ gateway_options["qmt_paper"] = "0"
321
+ print("[info] broker_live 未显式 qmt_paper → 强制 0 (真实下单)", flush=True)
322
+ elif str(paper_val).strip().lower() in ("1", "true", "yes", "on"):
323
+ raise LiveRunError(
324
+ "broker_live + qmt_paper=1 矛盾: 真实盘模式不能 paper 下单. "
325
+ "要去掉 qmt_paper=1 或改用 --mode paper."
326
+ )
327
+
328
+ if duration == "1h": # CLI 默认值 — 替换为永久
329
+ print("[info] broker_live 默认 duration=0 (永久运行); 如需限时显式传 --duration", flush=True)
330
+ return "0"
331
+ print(f"[warn] broker_live 使用显式 duration={duration} — 到期自动停止", flush=True)
332
+ return duration
333
+
334
+
335
+ def run_live(
336
+ spec: StrategySpec,
337
+ *,
338
+ mode: str,
339
+ broker: str,
340
+ symbols: list[str] | None,
341
+ duration: str,
342
+ gateway_options_raw: str | None,
343
+ initial_cash: float | None,
344
+ log_level: str,
345
+ log_file: Path | None,
346
+ market_broker: str | None,
347
+ replay_days: int = 30,
348
+ ) -> None:
349
+ """翻译 + 转发到 akquant.run_live.
350
+
351
+ spec: loader 加载出的 StrategySpec (class 或 functional)
352
+ mode: "paper" / "broker_live"
353
+ broker: "ctp" / "qmf" / "replay" / 自定义 broker 名字
354
+ market_broker: 独立行情 broker id (e.g. "qmt_market"); None=单 broker
355
+ 当 market_broker 设了, akquant 要求 trader_broker 也设 (二者成对, broker 字段被忽略).
356
+ 我们固定配对: market_broker="qmt_market" + trader_broker="qmt".
357
+ symbols: CLI --symbols 拆分后的字符串列表, None = 全市场
358
+ duration: 字符串 ("30s" / "1h" / "2d"), akquant 自己解析
359
+ gateway_options_raw: CLI 字符串, parse 成 dict 后透传
360
+ initial_cash: 回传给 run_live (None = akquant 默认)
361
+ """
362
+ gateway_options = parse_kv_pairs(gateway_options_raw)
363
+ duration = _live_safety_defaults(mode, broker, gateway_options, duration)
364
+ # replay broker 的 bars 数据 — 供 v2 architecture compute_factors 复用
365
+ replay_bars_df = None
366
+
367
+ # replay broker 必须配 trading_mode="paper" — 提前给出清晰错误, 而不是让 akquant 在深处抛
368
+ if broker == "replay" and mode == "broker_live":
369
+ raise LiveRunError(
370
+ "broker=replay + trading_mode=broker_live 不兼容 — replay 只有行情, "
371
+ "无交易通道, 用 broker_live 会抛 ValueError. 改用 mode=paper 或换 broker."
372
+ )
373
+
374
+ # broker=replay → 自动注入最近 N 天真实数据 (本地 prebuilt), paper 撮合走引擎.
375
+ # bounded_event_total 让回放完自动停, 不依赖 --duration 墙钟.
376
+ if broker == "replay":
377
+ bars_df, replay_src = _recent_real_bars(symbols, days=replay_days)
378
+ replay_bars_df = bars_df # 供 v2 architecture compute_factors 复用
379
+ instruments = _build_instruments(sorted(bars_df["symbol"].unique()), normalize=True)
380
+ gateway_options["bars"] = bars_df
381
+ print(
382
+ f"[info] replay 数据: {replay_src} · {len(bars_df)} bars · "
383
+ f"{sorted(bars_df['symbol'].unique())} (最近 {replay_days} 天)",
384
+ flush=True,
385
+ )
386
+ if len(bars_df) < 60:
387
+ print(
388
+ f"[warn] bars < 60 — 有 warmup 的策略可能全程不触发 on_bar (0 交易). "
389
+ f"可加 --replay-days 扩大窗口 (e.g. --replay-days 180)",
390
+ flush=True,
391
+ )
392
+ else:
393
+ instruments = _build_instruments(symbols)
394
+ # run_live 始终要求至少 1 个 instrument (不是可选) — 不传 --symbols 时
395
+ # 用占位 sh600000, paper 模式 broker=qmt 不真下单, smoke 能跑通.
396
+ if not instruments:
397
+ instruments = _build_instruments(["sh600000"])
398
+ configure_logging(log_level, log_file)
399
+
400
+ # broker="qmt" → import 触发 register_broker("qmt", ...), 校验 qmt_account_id 必填.
401
+ # qmt_* 配置已经在 gateway_options dict 里 — akquant.run_live 会展开 dict 给 builder,
402
+ # 不要把 qmt_* 当成顶层 kwarg (run_live 显式签名, 会 TypeError).
403
+ if broker == "qmt":
404
+ try:
405
+ from . import qmt_broker # noqa: F401 — 触发 register_broker("qmt")
406
+ except ImportError as e:
407
+ raise LiveRunError(
408
+ f"broker=qmt 需要 hamuna_quant_cli.live.qmt_broker (包内自带). 原因: {e}"
409
+ ) from e
410
+ if "qmt_account_id" not in gateway_options:
411
+ raise LiveRunError(
412
+ "broker=qmt 需要 qmt_account_id — 透传 --gateway-options "
413
+ "'qmt_account_id=8888888888,qmt_base_url=http://127.0.0.1:9000'"
414
+ )
415
+
416
+ # market_broker=qmt_market → import 触发 register_broker("qmt_market", ...).
417
+ # 自动配对 trader_broker=qmt: 我们约定 qmt_market + qmt 是 v2 QMT 行情+交易对 (行情
418
+ # 走 /data/snapshot+history, 交易走 5 交易端点, 同 bridge_server 同进程).
419
+ if market_broker == "qmt_market":
420
+ try:
421
+ from . import qmt_market # noqa: F401 — 触发 register_broker("qmt_market")
422
+ except ImportError as e:
423
+ raise LiveRunError(
424
+ f"--market-broker=qmt_market 需要 hamuna_quant_cli.live.qmt_market (包内自带). 原因: {e}"
425
+ ) from e
426
+
427
+ # 延迟 import — 让 CLI 能在 akquant 未装环境跑 --help
428
+ try:
429
+ from akquant import run_live as _akquant_run_live
430
+ except ImportError as e:
431
+ raise LiveRunError(
432
+ f"akquant 未安装 — `pip install 'akquant>=0.3.41'`. 原因: {e}"
433
+ ) from e
434
+
435
+ # ===== v2 architecture (2026-08-18 简化): class mode 接入 compute_factors / filter_symbols =====
436
+ # 仅 class mode + 有 qmt_base_url (走 bridge_server /data/history) 时启用.
437
+ # functional mode / 无 bridge_url 跳过 (兼容老策略).
438
+ if spec.mode == "class" and spec.strategy_cls is not None:
439
+ qmt_base_url = gateway_options.get("qmt_base_url")
440
+ # replay (paper trading) 用本地 bars 数据; qmt 用 bridge /data/history.
441
+ # 两者都走 compute_factors / filter_symbols (v2 architecture).
442
+ has_replay_data = replay_bars_df is not None
443
+ if qmt_base_url or has_replay_data:
444
+ try:
445
+ strat_inst = spec.strategy_cls()
446
+ except Exception as e:
447
+ raise LiveRunError(
448
+ f"实例化策略 {spec.strategy_cls.__name__} 失败: {e}"
449
+ ) from e
450
+
451
+ # 只有策略真有 compute_factors / filter_symbols 才需要 --symbols 限定 universe;
452
+ # 无因子 hook 的策略 (e.g. 双均线 on_bar 直算) 直接跑, 不拦 (desktop 启动器不传
453
+ # --symbols, 2026-08-19 实测: 无因子策略被误拦 → LiveRunError).
454
+ wants_precompute = (
455
+ hasattr(strat_inst, "compute_factors")
456
+ or hasattr(strat_inst, "filter_symbols")
457
+ )
458
+ if wants_precompute and not symbols:
459
+ raise LiveRunError(
460
+ "实盘 compute_factors 必须传 --symbols 限定 universe (避免全市场 N×HTTP 超时). "
461
+ "如不需要预计算因子, 走 functional mode 兼容路径."
462
+ )
463
+
464
+ if not wants_precompute:
465
+ print(
466
+ f"[info] class mode 无 compute_factors/filter_symbols — 跳过预计算, "
467
+ f"instrument={symbols or '<占位 sh600000>'}",
468
+ flush=True,
469
+ )
470
+ else:
471
+ factors: dict[str, Any] = {}
472
+ if hasattr(strat_inst, "compute_factors"):
473
+ # 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
474
+ # qmt: bridge /data/history; replay: 本地 prebuilt bars (paper trading).
475
+ try:
476
+ if has_replay_data:
477
+ raw_factors = replay_bars_df
478
+ else:
479
+ raw_factors = _fetch_live_factors(qmt_base_url, symbols)
480
+ except LiveRunError:
481
+ raise
482
+ except Exception as e:
483
+ raise LiveRunError(
484
+ f"实盘拉历史失败: {type(e).__name__}: {e} "
485
+ f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
486
+ ) from e
487
+ try:
488
+ factors = strat_inst.compute_factors(raw_factors) or {}
489
+ except Exception as e:
490
+ raise LiveRunError(
491
+ f"实盘 compute_factors 异常: {e} (用户原话: 出错直接报错停止, 不走 mock 兜底)"
492
+ ) from e
493
+ if not isinstance(factors, dict):
494
+ raise LiveRunError(
495
+ f"compute_factors 应返 dict[str, DataFrame], 实得 {type(factors).__name__}"
496
+ )
497
+
498
+ filtered: list[str] = list(symbols)
499
+ if hasattr(strat_inst, "filter_symbols"):
500
+ try:
501
+ user_filtered = strat_inst.filter_symbols(factors)
502
+ except Exception as e:
503
+ raise LiveRunError(f"filter_symbols 异常: {e}") from e
504
+ if not isinstance(user_filtered, (list, tuple)):
505
+ raise LiveRunError(
506
+ f"filter_symbols 应返 list[str], 实得 {type(user_filtered).__name__}"
507
+ )
508
+ user_filtered = [str(s) for s in user_filtered]
509
+ if not user_filtered:
510
+ import warnings as _w
511
+ _w.warn(f"filter_symbols 返空, 退到 --symbols ({len(symbols)} syms)")
512
+ else:
513
+ known = set(symbols)
514
+ bad = [s for s in user_filtered if s not in known]
515
+ if bad:
516
+ import warnings as _w
517
+ _w.warn(
518
+ f"filter_symbols 返了 --symbols 外的 sym {bad[:5]}{'...' if len(bad) > 5 else ''}, 已剔除"
519
+ )
520
+ user_filtered = [s for s in user_filtered if s in known]
521
+ filtered = user_filtered
522
+ print(
523
+ f"[v2-architecture-live] filter_symbols: {len(symbols)} → {len(filtered)} symbols",
524
+ flush=True,
525
+ )
526
+
527
+ instruments = _build_instruments(filtered)
528
+ else:
529
+ print(
530
+ "[info] class mode 无 qmt_base_url / replay bars; 跳过 compute_factors / filter_symbols",
531
+ flush=True,
532
+ )
533
+ elif spec.mode == "functional":
534
+ print(
535
+ "[info] functional mode 跳过 compute_factors / filter_symbols (兼容路径)",
536
+ flush=True,
537
+ )
538
+
539
+ kwargs: dict[str, Any] = {
540
+ "broker": broker,
541
+ "trading_mode": mode,
542
+ "instruments": instruments,
543
+ "gateway_options": gateway_options,
544
+ "duration": duration,
545
+ "show_progress": False,
546
+ }
547
+ if initial_cash is not None:
548
+ kwargs["cash"] = initial_cash # akquant 0.3.x run_live 参数名是 cash
549
+
550
+ # 行情/交易分家 — 必须成对: market_broker + trader_broker 同时给, akquant 忽略 broker.
551
+ if market_broker:
552
+ kwargs["market_broker"] = market_broker
553
+ # qmt_market 自动配对 trader_broker=qmt (v2 约定); 其它行情 broker 用户自己负责
554
+ kwargs["trader_broker"] = broker if market_broker == "qmt_market" else broker
555
+
556
+ # class mode vs functional mode — run_live 入参不同
557
+ if spec.mode == "class":
558
+ kwargs["strategy_cls"] = _with_live_history_depth(spec.strategy_cls)
559
+ else:
560
+ cbs = spec.callbacks or {}
561
+ kwargs["strategy_cls"] = cbs["on_bar"]
562
+ for hook in ("initialize", "on_order", "on_trade", "on_timer", "on_broker_connected"):
563
+ if hook in cbs:
564
+ kwargs[hook] = cbs[hook]
565
+
566
+ print(f"[info] hamuna_quant_cli live run: mode={mode} broker={broker} "
567
+ f"market_broker={market_broker or '<single>'} "
568
+ f"symbols={symbols or '<all>'} duration={duration}", flush=True)
569
+ print(f"[info] gateway_options={gateway_options}", flush=True)
570
+ _akquant_run_live(**kwargs)