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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,449 @@
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
+ if not isinstance(payload, list):
79
+ raise LiveRunError(
80
+ f"bridge /data/history 响应 shape 异常 ({sym}): 期望 list[bar], 实得 {type(payload).__name__}"
81
+ ) from e
82
+ return payload
83
+
84
+
85
+ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d",
86
+ count: int = 60, timeout: float = 30.0) -> dict[str, Any]:
87
+ """实盘 startup: 拉 N sym × N bar 历史, 拼成 {sym: DataFrame} 喂 compute_factors.
88
+
89
+ 实盘只支持日线 (1d) — QMT 5m/1m/tick 走 market_broker=qmt_market 的实时 tick,
90
+ 不在 compute_factors 启动期预计算范围.
91
+ """
92
+ 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"])
98
+ else:
99
+ factors[sym] = _pd.DataFrame(bars)
100
+ return factors
101
+
102
+
103
+ def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> list[Any] | None:
104
+ """symbols 列表 → akquant.Instrument 列表. None → None (全市场, 让策略自行 subscribe).
105
+
106
+ normalize=True: 裸码 → 带后缀 (replay 数据来自 prebuilt, stockCode 是 '600000.SH'
107
+ 形态, instrument symbol 必须一致才匹配 ReplayMarketGateway 的订阅过滤).
108
+ """
109
+ if not symbols:
110
+ return None
111
+ # 延迟 import akquant — 让 loader 在 akquant 未装环境也能跑通
112
+ from akquant import AssetType, Instrument
113
+
114
+ def _sym(s: str) -> str:
115
+ if normalize:
116
+ from ..akquant_schema_adapter import normalize_symbol
117
+ return normalize_symbol(s)
118
+ return s
119
+
120
+ return [Instrument(symbol=_sym(s), asset_type=AssetType.Stock) for s in symbols if s.strip()]
121
+
122
+
123
+ def _recent_real_bars(symbols: list[str], days: int = 30) -> tuple[Any, str]:
124
+ """最近 N 天真实日线 → replay bars DataFrame (akquant dataframe_to_bars 格式).
125
+
126
+ 数据源 = 本地 prebuilt bundle (真实历史, 与回测同源; bundle 覆盖到最新交易日).
127
+ 返 (df, src): df 列 = date/open/high/low/close/volume/symbol + "股票代码" (多标的
128
+ 识别必需, dataframe_to_bars normalize.py 只认这个列名).
129
+
130
+ Raises:
131
+ LiveRunError: 无 --symbols / 数据不可达 (提示先 dataset fetch).
132
+ """
133
+ from datetime import datetime, timedelta
134
+
135
+ from ..prebuilt_resolver import resolve
136
+
137
+ if not symbols:
138
+ raise LiveRunError(
139
+ "broker=replay 需要 --symbols 限定回放标的 (从本地 prebuilt 取最近真实数据)"
140
+ )
141
+ end = datetime.now().strftime("%Y%m%d")
142
+ start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d")
143
+ df, src = resolve(symbols, start, end)
144
+ if df is None or len(df) == 0:
145
+ raise LiveRunError(
146
+ f"最近 {days} 天无 bar 数据 (universe={symbols}, {start}~{end}, src={src}). "
147
+ f"先 `hamuna_quant_cli dataset fetch --symbols {','.join(symbols)}` 下载 prebuilt"
148
+ )
149
+ out = df.copy()
150
+ out["股票代码"] = out["symbol"]
151
+ return out, src
152
+
153
+
154
+ def configure_logging(level: str, log_file: Path | None) -> None:
155
+ """调 akquant.configure_logging — profile=live 让 on_order/on_trade/网关 warning 进同一套输出."""
156
+ try:
157
+ import akquant
158
+ from akquant import LogConfig
159
+ except ImportError:
160
+ # 没装 akquant 也不致命 — 子命令会在 run_live 处抛更具体的 ImportError,
161
+ # 用户能从那条错误知道装包; 这里只警告
162
+ print(f"[warn] akquant 未安装, 跳过日志配置 (level={level})", flush=True)
163
+ return
164
+ cfg_kwargs: dict[str, Any] = {"profile": "live", "level": level, "console": True}
165
+ if log_file:
166
+ cfg_kwargs["file_json"] = True
167
+ cfg_kwargs["filename"] = str(log_file)
168
+ akquant.configure_logging(LogConfig(**cfg_kwargs))
169
+
170
+
171
+ def _with_live_history_depth(cls: type) -> type:
172
+ """class mode 注入 set_history_depth — live 引擎不像回测 (engine.py:4637) 那样
173
+ 自动开 history tracking, 策略 on_bar 里 get_history 直接抛
174
+ "History tracking is not enabled" (2026-08-19 实测). depth 取策略 on_start 设的
175
+ warmup_period, 未设默认 120.
176
+
177
+ ponytail: 只包 class mode; functional mode 用户自己管 get_history (ctx 侧).
178
+ """
179
+ from typing import Any as _Any
180
+
181
+ class _Wrapped(cls):
182
+ def on_start(self) -> _Any:
183
+ ret = super().on_start()
184
+ depth = getattr(self, "warmup_period", None) or 120
185
+ try:
186
+ self.set_history_depth(depth)
187
+ except Exception as e: # noqa: BLE001 — depth 失败不应让 on_start 崩
188
+ print(f"[warn] set_history_depth({depth}) 失败: {e}", flush=True)
189
+ return ret
190
+
191
+ _Wrapped.__name__ = cls.__name__
192
+ _Wrapped.__qualname__ = cls.__qualname__
193
+ return _Wrapped
194
+
195
+
196
+ def _live_safety_defaults(
197
+ mode: str,
198
+ broker: str,
199
+ gateway_options: dict[str, str],
200
+ duration: str,
201
+ ) -> str:
202
+ """P0-1/P0-2 实盘安全默认: broker_live+qmt 强制真实下单 + 永久运行.
203
+
204
+ 原地改 gateway_options (注入 qmt_paper=0 或报错), 返回修正后的 duration.
205
+ """
206
+ if mode == "broker_live" and broker == "qmt":
207
+ paper_val = gateway_options.get("qmt_paper")
208
+ if paper_val is None:
209
+ gateway_options["qmt_paper"] = "0"
210
+ print("[info] broker_live 未显式 qmt_paper → 强制 0 (真实下单)", flush=True)
211
+ elif str(paper_val).strip().lower() in ("1", "true", "yes", "on"):
212
+ raise LiveRunError(
213
+ "broker_live + qmt_paper=1 矛盾: 真实盘模式不能 paper 下单. "
214
+ "要去掉 qmt_paper=1 或改用 --mode paper."
215
+ )
216
+
217
+ if duration == "1h": # CLI 默认值 — 替换为永久
218
+ print("[info] broker_live 默认 duration=0 (永久运行); 如需限时显式传 --duration", flush=True)
219
+ return "0"
220
+ print(f"[warn] broker_live 使用显式 duration={duration} — 到期自动停止", flush=True)
221
+ return duration
222
+
223
+
224
+ def run_live(
225
+ spec: StrategySpec,
226
+ *,
227
+ mode: str,
228
+ broker: str,
229
+ symbols: list[str] | None,
230
+ duration: str,
231
+ gateway_options_raw: str | None,
232
+ initial_cash: float | None,
233
+ log_level: str,
234
+ log_file: Path | None,
235
+ market_broker: str | None,
236
+ replay_days: int = 30,
237
+ ) -> None:
238
+ """翻译 + 转发到 akquant.run_live.
239
+
240
+ spec: loader 加载出的 StrategySpec (class 或 functional)
241
+ mode: "paper" / "broker_live"
242
+ broker: "ctp" / "qmf" / "replay" / 自定义 broker 名字
243
+ market_broker: 独立行情 broker id (e.g. "qmt_market"); None=单 broker
244
+ 当 market_broker 设了, akquant 要求 trader_broker 也设 (二者成对, broker 字段被忽略).
245
+ 我们固定配对: market_broker="qmt_market" + trader_broker="qmt".
246
+ symbols: CLI --symbols 拆分后的字符串列表, None = 全市场
247
+ duration: 字符串 ("30s" / "1h" / "2d"), akquant 自己解析
248
+ gateway_options_raw: CLI 字符串, parse 成 dict 后透传
249
+ initial_cash: 回传给 run_live (None = akquant 默认)
250
+ """
251
+ gateway_options = parse_kv_pairs(gateway_options_raw)
252
+ duration = _live_safety_defaults(mode, broker, gateway_options, duration)
253
+
254
+ # replay broker 必须配 trading_mode="paper" — 提前给出清晰错误, 而不是让 akquant 在深处抛
255
+ if broker == "replay" and mode == "broker_live":
256
+ raise LiveRunError(
257
+ "broker=replay + trading_mode=broker_live 不兼容 — replay 只有行情, "
258
+ "无交易通道, 用 broker_live 会抛 ValueError. 改用 mode=paper 或换 broker."
259
+ )
260
+
261
+ # broker=replay → 自动注入最近 N 天真实数据 (本地 prebuilt), paper 撮合走引擎.
262
+ # bounded_event_total 让回放完自动停, 不依赖 --duration 墙钟.
263
+ if broker == "replay":
264
+ bars_df, replay_src = _recent_real_bars(symbols, days=replay_days)
265
+ instruments = _build_instruments(sorted(bars_df["symbol"].unique()), normalize=True)
266
+ gateway_options["bars"] = bars_df
267
+ print(
268
+ f"[info] replay 数据: {replay_src} · {len(bars_df)} bars · "
269
+ f"{sorted(bars_df['symbol'].unique())} (最近 {replay_days} 天)",
270
+ flush=True,
271
+ )
272
+ if len(bars_df) < 60:
273
+ print(
274
+ f"[warn] bars < 60 — 有 warmup 的策略可能全程不触发 on_bar (0 交易). "
275
+ f"可加 --replay-days 扩大窗口 (e.g. --replay-days 180)",
276
+ flush=True,
277
+ )
278
+ else:
279
+ instruments = _build_instruments(symbols)
280
+ # run_live 始终要求至少 1 个 instrument (不是可选) — 不传 --symbols 时
281
+ # 用占位 sh600000, paper 模式 broker=qmt 不真下单, smoke 能跑通.
282
+ if not instruments:
283
+ instruments = _build_instruments(["sh600000"])
284
+ configure_logging(log_level, log_file)
285
+
286
+ # broker="qmt" → import 触发 register_broker("qmt", ...), 校验 qmt_account_id 必填.
287
+ # qmt_* 配置已经在 gateway_options dict 里 — akquant.run_live 会展开 dict 给 builder,
288
+ # 不要把 qmt_* 当成顶层 kwarg (run_live 显式签名, 会 TypeError).
289
+ if broker == "qmt":
290
+ try:
291
+ from . import qmt_broker # noqa: F401 — 触发 register_broker("qmt")
292
+ except ImportError as e:
293
+ raise LiveRunError(
294
+ f"broker=qmt 需要 hamuna_quant_cli.live.qmt_broker (包内自带). 原因: {e}"
295
+ ) from e
296
+ if "qmt_account_id" not in gateway_options:
297
+ raise LiveRunError(
298
+ "broker=qmt 需要 qmt_account_id — 透传 --gateway-options "
299
+ "'qmt_account_id=8888888888,qmt_base_url=http://127.0.0.1:9000'"
300
+ )
301
+
302
+ # market_broker=qmt_market → import 触发 register_broker("qmt_market", ...).
303
+ # 自动配对 trader_broker=qmt: 我们约定 qmt_market + qmt 是 v2 QMT 行情+交易对 (行情
304
+ # 走 /data/snapshot+history, 交易走 5 交易端点, 同 bridge_server 同进程).
305
+ if market_broker == "qmt_market":
306
+ try:
307
+ from . import qmt_market # noqa: F401 — 触发 register_broker("qmt_market")
308
+ except ImportError as e:
309
+ raise LiveRunError(
310
+ f"--market-broker=qmt_market 需要 hamuna_quant_cli.live.qmt_market (包内自带). 原因: {e}"
311
+ ) from e
312
+
313
+ # 延迟 import — 让 CLI 能在 akquant 未装环境跑 --help
314
+ try:
315
+ from akquant import run_live as _akquant_run_live
316
+ except ImportError as e:
317
+ raise LiveRunError(
318
+ f"akquant 未安装 — `pip install 'akquant>=0.3.41'`. 原因: {e}"
319
+ ) from e
320
+
321
+ # ===== v2 architecture (2026-08-18 简化): class mode 接入 compute_factors / filter_symbols =====
322
+ # 仅 class mode + 有 qmt_base_url (走 bridge_server /data/history) 时启用.
323
+ # functional mode / 无 bridge_url 跳过 (兼容老策略).
324
+ if spec.mode == "class" and spec.strategy_cls is not None:
325
+ qmt_base_url = gateway_options.get("qmt_base_url")
326
+ if qmt_base_url:
327
+ try:
328
+ strat_inst = spec.strategy_cls()
329
+ except Exception as e:
330
+ raise LiveRunError(
331
+ f"实例化策略 {spec.strategy_cls.__name__} 失败: {e}"
332
+ ) from e
333
+
334
+ # 只有策略真有 compute_factors / filter_symbols 才需要 --symbols 限定 universe;
335
+ # 无因子 hook 的策略 (e.g. 双均线 on_bar 直算) 直接跑, 不拦 (desktop 启动器不传
336
+ # --symbols, 2026-08-19 实测: 无因子策略被误拦 → LiveRunError).
337
+ wants_precompute = (
338
+ hasattr(strat_inst, "compute_factors")
339
+ or hasattr(strat_inst, "filter_symbols")
340
+ )
341
+ if wants_precompute and not symbols:
342
+ raise LiveRunError(
343
+ "实盘 compute_factors 必须传 --symbols 限定 universe (避免全市场 N×HTTP 超时). "
344
+ "如不需要预计算因子, 走 functional mode 兼容路径."
345
+ )
346
+
347
+ if not wants_precompute:
348
+ print(
349
+ f"[info] class mode 无 compute_factors/filter_symbols — 跳过预计算, "
350
+ f"instrument={symbols or '<占位 sh600000>'}",
351
+ flush=True,
352
+ )
353
+ else:
354
+ factors: dict[str, Any] = {}
355
+ if hasattr(strat_inst, "compute_factors"):
356
+ # 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
357
+ try:
358
+ raw_factors = _fetch_live_factors(qmt_base_url, symbols)
359
+ except LiveRunError:
360
+ raise
361
+ except Exception as e:
362
+ raise LiveRunError(
363
+ f"实盘拉历史失败: {type(e).__name__}: {e} "
364
+ f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
365
+ ) from e
366
+ try:
367
+ factors = strat_inst.compute_factors(raw_factors) or {}
368
+ except Exception as e:
369
+ raise LiveRunError(
370
+ f"实盘 compute_factors 异常: {e} (用户原话: 出错直接报错停止, 不走 mock 兜底)"
371
+ ) from e
372
+ if not isinstance(factors, dict):
373
+ raise LiveRunError(
374
+ f"compute_factors 应返 dict[str, DataFrame], 实得 {type(factors).__name__}"
375
+ )
376
+
377
+ filtered: list[str] = list(symbols)
378
+ if hasattr(strat_inst, "filter_symbols"):
379
+ try:
380
+ user_filtered = strat_inst.filter_symbols(factors)
381
+ except Exception as e:
382
+ raise LiveRunError(f"filter_symbols 异常: {e}") from e
383
+ if not isinstance(user_filtered, (list, tuple)):
384
+ raise LiveRunError(
385
+ f"filter_symbols 应返 list[str], 实得 {type(user_filtered).__name__}"
386
+ )
387
+ user_filtered = [str(s) for s in user_filtered]
388
+ if not user_filtered:
389
+ import warnings as _w
390
+ _w.warn(f"filter_symbols 返空, 退到 --symbols ({len(symbols)} syms)")
391
+ else:
392
+ known = set(symbols)
393
+ bad = [s for s in user_filtered if s not in known]
394
+ if bad:
395
+ import warnings as _w
396
+ _w.warn(
397
+ f"filter_symbols 返了 --symbols 外的 sym {bad[:5]}{'...' if len(bad) > 5 else ''}, 已剔除"
398
+ )
399
+ user_filtered = [s for s in user_filtered if s in known]
400
+ filtered = user_filtered
401
+ print(
402
+ f"[v2-architecture-live] filter_symbols: {len(symbols)} → {len(filtered)} symbols",
403
+ flush=True,
404
+ )
405
+
406
+ instruments = _build_instruments(filtered)
407
+ else:
408
+ print(
409
+ "[info] class mode 但无 qmt_base_url (gateway_options); 跳过 compute_factors / filter_symbols",
410
+ flush=True,
411
+ )
412
+ elif spec.mode == "functional":
413
+ print(
414
+ "[info] functional mode 跳过 compute_factors / filter_symbols (兼容路径)",
415
+ flush=True,
416
+ )
417
+
418
+ kwargs: dict[str, Any] = {
419
+ "broker": broker,
420
+ "trading_mode": mode,
421
+ "instruments": instruments,
422
+ "gateway_options": gateway_options,
423
+ "duration": duration,
424
+ "show_progress": False,
425
+ }
426
+ if initial_cash is not None:
427
+ kwargs["cash"] = initial_cash # akquant 0.3.x run_live 参数名是 cash
428
+
429
+ # 行情/交易分家 — 必须成对: market_broker + trader_broker 同时给, akquant 忽略 broker.
430
+ if market_broker:
431
+ kwargs["market_broker"] = market_broker
432
+ # qmt_market 自动配对 trader_broker=qmt (v2 约定); 其它行情 broker 用户自己负责
433
+ kwargs["trader_broker"] = broker if market_broker == "qmt_market" else broker
434
+
435
+ # class mode vs functional mode — run_live 入参不同
436
+ if spec.mode == "class":
437
+ kwargs["strategy_cls"] = _with_live_history_depth(spec.strategy_cls)
438
+ else:
439
+ cbs = spec.callbacks or {}
440
+ kwargs["strategy_cls"] = cbs["on_bar"]
441
+ for hook in ("initialize", "on_order", "on_trade", "on_timer", "on_broker_connected"):
442
+ if hook in cbs:
443
+ kwargs[hook] = cbs[hook]
444
+
445
+ print(f"[info] hamuna_quant_cli live run: mode={mode} broker={broker} "
446
+ f"market_broker={market_broker or '<single>'} "
447
+ f"symbols={symbols or '<all>'} duration={duration}", flush=True)
448
+ print(f"[info] gateway_options={gateway_options}", flush=True)
449
+ _akquant_run_live(**kwargs)