hamuna-quant-cli 0.1.0.dev93__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|