chore: init qmt_bridge repo (HTTP+WS bridge, MCP endpoint, docs, references)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
本项目遵循 [Keep a Changelog](https://keepachangelog.com/) 和 [语义化版本](https://semver.org/)。
|
||||
|
||||
## [0.2.2] - 2026-08-19
|
||||
|
||||
### 修复
|
||||
|
||||
- **server_error 污染后续查询**(Issue #43):`_last_server_error` 是实例状态,但每个成功响应都会读取它,而只有下单路径会重置。一次静默拒绝的委托会把错误盖到之后**所有** ping 和查询上,直到下一次下单。改为在 `handle()` 中每请求清空,且清空发生在方法校验之前,因此被拒绝的方法也不会携带上一次的诊断。
|
||||
- **order_remark 匹配的模糊兜底**(Issue #41):那段 `stock_code + action` 的兜底并非用于*识别*委托,而是**告警闸门**——问题比报告描述的更严重。`order_tag` 是我们生成的唯一 id,匹配不上即真未进系统;模糊兜底唯一的作用是**压制真实告警**:账户中若有一笔无关的同股票同方向委托(手动下的或上一笔未成交的),会导致 `order_sys_id` 未回填、`server_error` 为空,客户端看到一次干净的成功,而该委托从未进入系统。已移除。
|
||||
- **order_stock_async 阻塞 QMT 主线程**(Issue #44):`_handle_submit_order` 中的 `sleep(0.5)` 在 adjust 主线程执行(下单方法不在 `listener_methods` 中,走 deferred 路径),使其余请求串行等待,吞吐上限约 2 单/秒。改为**推迟响应而非推迟工作**:提交后登记 `OrderSettlement` 并停放响应,由每次 adjust drain 重试查询,委托号就绪即在同一 tick 内回复(零 sleep)。后台线程方案不可行——`get_trade_detail_data` 在非主策略线程返回空,会把每笔都误判为静默拒绝。
|
||||
- **order_stock_async 未立即返回**(Issue #50):客户端内部同步调用 `order_stock`,阻塞整个 RPC 往返,加上 #44 后的结算等待,每笔 0.5~1 秒。服务端新增 `wait_settlement` 参数(false 时 passorder 一返回即回复,委托号由 `order_callback` 推送);客户端提交移至工作线程,`order_stock_async` 不碰网络直接返回 seq。`on_order_error` 现在也携带 `seq`,此前无法判断是哪一笔异步委托失败。
|
||||
- **未复权下载实为空跑**(Issue #47,亦是 #39 的真正原因):服务端下载此前只在请求复权时执行,未复权路径仅调用 `get_market_data_ex` 读取已有数据,却照常通过 callback 报告 `{finished: N}`——为一件没发生的事显示进度。而 1d/tick 默认即 `dividend_type="none"`。现在所有 `dividend_type` 都执行下载。实盘验证:601398.SH 本地日线从 0 根变为有数据。
|
||||
- **query_stock_orders 缺少 order_time**(Issue #48):大 QMT 的 ORDER 行提供报单日期与时间,但三层均未读取。已贯通 `OrderSnapshot` → `order_bigqmt` → `_order_from_dict`,按 MiniQMT `XtOrder.order_time` 语义输出 Unix 秒。实盘验证:11 笔真实委托全部有值。
|
||||
|
||||
### 新增
|
||||
|
||||
- **qmt_launcher**(Issue #45):`open` / `close` / `restart` / `status` 四个命令管理 QMT 终端。按 `bin.x64` 路径隔离(同机多实例并存时不会误关其他账户)、以 FormulaServer 端口可连接为就绪判据而非固定 sleep、窗口标题前缀匹配(不再写死版本号)、先优雅终止 20 秒后才强杀。登录路径用 `SendMessage` 投递窗口句柄,不依赖窗口置于前台。
|
||||
- **get_market_data_ex 分批**(Issue #47 评论):宽 `stock_list` 此前共用一个 RPC 超时,要么装得下要么整批丢失。改为按 100 个代码一批,单批失败只损失自身代码,全部失败才抛异常。`chunk_size=0` 恢复原行为。
|
||||
- **bar driver 观测埋点**:`adjust()` 按触发来源分别计数、`tick_app` 全量耗时直方图、init 报告策略品种/周期/订阅能力。用于定位 RPC 读延迟的来源。
|
||||
|
||||
### 变更
|
||||
|
||||
- `AssetSnapshot` 补齐 `frozen_cash` / `market_value`,对齐 MiniQMT `XtAsset`;`market_value` 优先取 `m_dInstrumentValue`,仅在服务端未上报时才推导(推导会扣除冻结金额,此前未扣导致市值虚高)。
|
||||
- ZMQ 传输改为精确绑定配置端口,冲突时报错而非向上扫描——端口静默漂移会让客户端连不上。
|
||||
|
||||
### 已知限制
|
||||
|
||||
- Issue #44 / #50 的实盘下单验证尚未完成(单测已量化非阻塞行为:drain < 0.2s、20 单 < 1.0s)。
|
||||
- Issue #47 评论所述的 `get_market_data_ex` 超时未能复现;三组压测(300 只 × count=3、300 只 × 全历史、50 只 × 1m 全天)最慢 718ms,远在默认 6s 超时内。分批目前是防御性改动。
|
||||
- RPC 读延迟受 QMT 主线程 GIL 制约,延迟 ≈ 基础 + N × `schedule_adjust_interval`。实测该间隔 200ms → 100ms 可使 p50 从 374ms 降至 172ms,代价是 CPU 占用上升。
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1] - 2026-08-17
|
||||
|
||||
### 修复
|
||||
|
||||
- **正常下单误报 on_order_error(-1)**(Issue #38):passorder 提交成功但委托号异步分配,客户端把「暂无 order_sys_id」误判为失败。服务端 `_handle_submit_order` 按唯一 `user_order_id`(remark) 匹配并回填 `order_sys_id`;顺带修掉校验代码对无 `.get()` 方法的 `OrderSnapshot` 调 `.get()` 的死代码(server_error 之前从未生效)。客户端 `call()` 不再丢弃 `server_error`,委托未进系统时转成异常,`order_stock_async` 携带真实原因回调 `on_order_error`。实盘验证:async 下单回调带真实委托号、提交阶段零误报(302 个测试通过,新增 5 个)。
|
||||
- **query_stock_orders 查不到委托**(strategy_name 陷阱):客户端别名默认 `"bigqmt_signal_trader"` 与服务端默认 `""` 不一致,改用其他策略名下单后别名查询返回空。默认改 `""`(返回全部)并对齐测试。
|
||||
|
||||
### 新增
|
||||
|
||||
- **qmt-trader skill 首次部署引导**:客户端装包、QMT 端文件同步、私有配置模板、入口启动验证、部署排错速查,零上下文也能从零跑通。
|
||||
- **PyPI 发布**:`BIGQMT_REDIS_DRYRUN` 入口模块补进 py-modules,`pip install xtquant-big-convert` 即可获得完整包(wheel/sdist 均通过 twine check)。
|
||||
|
||||
### 变更
|
||||
|
||||
- README 头部加 PyPI / Python 版本 / License 徽章,新增「AI 助手 Skill:qmt-trader」专节(启用方式、命令概览、安全设计)。
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-08-15
|
||||
|
||||
### 新增(Features)
|
||||
|
||||
- **qmt-trader skill**:统一 CLI 驱动全部 QMT API(`qmt-trader/scripts/qmt.py`),46 个子命令覆盖行情/持仓/委托/下单/撤单/财务/期权/两融/北向/龙虎榜等,含通用 `rpc` 兜底命令 + 25 个高频快捷命令。
|
||||
- **异步回报回调**:`XtQuantTraderCallback` 全链路(`on_account_status` / `on_order_stock_async_response` / `on_stock_order` / `on_stock_trade` / `on_order_error` / `on_cancel_error` / `on_cancel_order_stock_async_response`),对齐 MiniQMT 原生语义,实盘验证。
|
||||
- **全推行情订阅**(`subscribe_whole_quote` 真推送):服务端引用计数管理 + PUB/SUB 数据面通道(redis/zmq)+ 客户端心跳 + 推送静默检测 + 服务端重启恢复。
|
||||
- **完整 xtconstant 枚举**:91 个常量全量覆盖(账号类型/委托类型-股票期货信用期权/报价类型/委托状态/账号状态/`ORDER_TYPE_SET`),值对齐原生 MiniQMT。
|
||||
- **文件日志系统**(`logging_setup.py`):TimedRotatingFileHandler 按天轮转、保留 7 天(`BIGQMT_LOG_RETENTION_DAYS` 可配),双输出(文件 + QMT 面板),线程安全。
|
||||
- **启动自动诊断**:`init()` 打印服务状态、关键函数绑定、行情链路,方便排错。
|
||||
- **server_error 字段**:`submit_order` 校验委托是否进系统,静默失败时返回原因给客户端。
|
||||
- **统一测试入口**:`run_all_tests.py` 分组跑全部测试(signal_trader 274 + backtest 16)。
|
||||
- **端到端测试**:`test_all_apis.py` 验证真实 QMT 返回(transport 一致性/持仓空/委托空/下单未进系统/server_error)。
|
||||
- **生产失败场景单元测试**:7 个测试覆盖返回空/全 0/拒绝的 QMT 边界(非 happy-path)。
|
||||
- **官方交易查询函数**:`get_value_by_order_id` / `get_last_order_id` / `get_ipo_data` / `get_new_purchase_limit` / `get_history_trade_detail_data` / 融资融券 5 个 / 期权持仓 2 个 / 港股通汇率。
|
||||
- **无 redis 版本**(`bigqmt_no_redis/`):自包含 ZMQ transport + 无 redis DRYRUN,解决 QMT 沙箱 `import redis` 报错。
|
||||
- **多账号使用文档**:README 加「多账号使用」章节(多策略实例 + 多 client)。
|
||||
- **MiniQMT→BigQMT 转换 skill**:docs + scripts + templates(PR #37)。
|
||||
|
||||
### 修复(Bug Fixes)
|
||||
|
||||
- **QMT 自动退出**:`ZmqQuotePushChannel.stop()` 跨线程关 SUB socket 触发 Windows signaler abort → 进程崩溃。改为订阅线程自己关 socket。
|
||||
- **QMT 自动退出(系列)**:`_adjust_phase` 无 except(redis 故障崩策略)、`_publish_response` 逃出、deal_callback/forward_order_event/forward_trade_event/sync_positions_app 无防护、pending 队列满(queue.Full)、init() 无防护、socket_timeout=None 永久阻塞主线程、reset_app 不清理 quote-push/whole-quote(重启泄漏)、exec 事件每次回调新建 redis client(连接池泄漏)。
|
||||
- **download_history_data 下载不了**(Issue #32):`download_history_data` 是 QMT 全局函数不是 ContextInfo 方法,改走 `qmt_api` 注入。
|
||||
- **复权数据返回全 0**(front/back):服务端需先下载原始数据 + 除权因子。下载类(`download_history_data2`)自动预下载;读取类(`get_market_data_ex`/`get_market_data`)自愈(检测全 0 → 服务端下载 → 重试)。
|
||||
- **卖出方向误判**(exec_events):QMT 回调 `m_nDirection` 恒为 48,改仲裁链(offset_flag > direction > op_type)。
|
||||
- **query_orders/query_trades 返回空**:`strategy_name` 过滤不匹配,默认改 `""` 返回全部。
|
||||
- **get_financial_data 返回 None**:参数顺序错误(stock_list/table_list 反了)。
|
||||
- **position_events 内存无限增长**(Issue #21):xadd 无 maxlen,加 maxlen=2000。
|
||||
- **异步回调签名错误**:`on_order_stock_async_response`/`on_cancel_order_stock_async_response` 原生签名 1 参数(response 带 seq),之前传 2 参数导致 TypeError 被吞。
|
||||
- **order_stock 返回 -1**:`order_stock_async` 调 `result.get()` 崩,改为触发 `on_order_error`。
|
||||
- **客户端 transport 不匹配**(Issue #24):`query_stock_asset` 返回 None 的根因是客户端 redis / 服务端 zmq 不匹配。
|
||||
- **DRYRUN 硬编码路径**:`_known_qmt_python_dir` 改 sys.path 扫描(paste-run 模式)。
|
||||
- **ZMQ bind 冲突提示**:加端口占用检测 + 解决步骤提示。
|
||||
|
||||
### 变更(Changed)
|
||||
|
||||
- 包发布:`pip install xtquant-big-convert`(pyproject.toml 完善元数据 + LICENSE)。
|
||||
- README 重写:依赖安装分客户端/服务端、API 总览、传输层对比、FormulaServer 直连、异步回调、无 redis 版本、日志排错、多账号、复权陷阱等章节。
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-07-02
|
||||
|
||||
初始版本:Big QMT Redis RPC 桥接 + MiniQMT 兼容层。
|
||||
|
||||
### 新增
|
||||
|
||||
- Redis RPC 服务(rpush/blpop/brpop)+ 可插拔传输层(redis/zmq/mysql/shm)。
|
||||
- 客户端兼容层(`xtquant_compat`):`xt_trader` / `xtdata` 方法名映射。
|
||||
- 行情/持仓/委托/下单基础 RPC 接口。
|
||||
- `BIGQMT_REDIS_DRYRUN.py` QMT 编辑器入口。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 litaolemo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,998 @@
|
||||
# xtquant_big_convert
|
||||
|
||||
[](https://pypi.org/project/xtquant-big-convert/)
|
||||
[](https://pypi.org/project/xtquant-big-convert/)
|
||||
[](LICENSE)
|
||||
|
||||
大 QMT 运行环境里的 RPC 桥接包:把大 QMT 内置 Python(行情查询、交易、持仓)封装成**可远程调用的服务**,并兼容一组 MiniQMT 方法名,让外部程序无需 XtQuantServer 权限就能驱动大 QMT。
|
||||
|
||||
支持 **Redis / ZMQ / MySQL / 共享内存** 四种可插拔传输,切换只需改一个配置字段。
|
||||
|
||||
已发布 PyPI,客户端一行安装:`pip install xtquant-big-convert`(详见下文「环境要求与依赖安装」)。
|
||||
|
||||
另附 [qmt-trader skill](qmt-trader/):让 Claude Code / ZCode / Cursor 等 AI 助手通过统一 CLI(46 个子命令)直接查行情、查持仓、下单撤单,详见下文「AI 助手 Skill:qmt-trader」。
|
||||
|
||||
---
|
||||
|
||||
## 功能一览
|
||||
|
||||
### RPC 接口(远程可调用)
|
||||
|
||||
通过 RPC 可调用的大 QMT 能力(**白名单 117 个只读方法 + 2 个下单方法 + 12 个 MiniQMT 风格别名**,覆盖官方文档全部交易/查询函数):
|
||||
|
||||
| 类别 | 方法 |
|
||||
|------|------|
|
||||
| **系统** | `ping` |
|
||||
| **行情快照** | `get_ticks` / `get_full_tick`(五档盘口)|| **合约/品种** | `get_instrument` / `get_instrument_type` / `get_stock_name` / `get_stock_type` / `get_last_close` / `get_last_volume` / `get_open_date` / `get_contract_expire_date` / `get_contract_multiplier` / `get_float_caps` / `get_total_share` / `get_turn_over_rate` / `get_weight_in_index` / `get_svol` / `get_bvol` / `get_risk_free_rate` / `is_stock_type` / `get_cb_info` |
|
||||
| **K线/历史** | `get_market_data` / `get_market_data_ex` / `get_local_data` / `get_close_price` / `get_index_weight` |
|
||||
| **L2 行情** | `get_l2_quote` / `get_l2_order` / `get_l2_transaction` / `subscribe_l2thousand`(需 L2 权限)|
|
||||
| **板块** | `get_stock_list_in_sector` / `get_sector_list`* / `get_sector_info` / `create_sector` / `add_sector` / `remove_sector` |
|
||||
| **交易日历/时段** | `get_trading_dates` / `get_holidays`* / `get_markets`* / `get_market_last_trade_date`* / `get_date_location` / `get_trading_calendar` / `get_trade_times` |
|
||||
| **数据下载** | `download_history_data` / `download_history_data2` / `download_holiday_data` / `download_etf_info` / `download_cb_data` / `download_history_contracts` / `download_index_weight` / `download_sector_data` |
|
||||
| **财务/因子** | `get_financial_data` / `download_financial_data` / `download_financial_data2` / `get_raw_financial_data` / `get_factor_data` |
|
||||
| **ETF/期权/期货** | `get_etf_info` / `get_ipo_info` / `get_option_list` / `get_his_option_list` / `get_his_option_list_batch` / `get_option_detail_data` / `get_option_undl_data` / `get_option_undl` / `get_ETF_list` / `get_main_contract` / `get_his_contract_list` |
|
||||
| **期权定价** | `bsm_price` / `bsm_iv` / `get_option_iv` |
|
||||
| **龙虎榜/股东** | `get_longhubang` / `get_top10_share_holder` / `get_holder_num` / `get_turnover_rate`(区间换手率)/ `get_industry` / `get_his_st_data` / `get_his_index_data` |
|
||||
| **资金流** | `get_north_finance_change`(北向)/ `get_hkt_statistics`(港股通)/ `get_hkt_details` / `get_hkt_exchange_rate` |
|
||||
| **因子/模型** | `call_formula` / `subscribe_formula` / `unsubscribe_formula` / `get_formula_result` / `gen_factor_index` |
|
||||
| **时间转换** | `datetime_to_timetag` / `timetag_to_datetime` / `timetagToDateTime`(纯本地计算)|
|
||||
| **账户查询** | `get_asset`(资金)/ `get_positions`(持仓)/ `query_stock_position`(单股持仓)/ `query_orders`(委托)/ `query_trades`(成交)/ `get_history_trade_detail_data`(历史成交)/ `get_value_by_order_id` / `get_last_order_id` |
|
||||
| **新股/打新** | `get_ipo_data` / `get_new_purchase_limit` |
|
||||
| **融资融券** | `get_assure_contract`(担保品)/ `get_enable_short_contract`(融券标的)/ `get_unclosed_compacts`(未平仓)/ `get_closed_compacts`(已平仓)/ `get_debt_contract`(负债)—— 需两融权限,普通账户降级为空 |
|
||||
| **期权持仓** | `get_option_subject_position`(标的持仓)/ `get_comb_option`(组合期权)|
|
||||
| **持仓同步** | `sync_positions`(写回 Redis 供客户端缓存)|
|
||||
| **下单/撤单** | `submit_order` / `cancel_order`(默认关闭,需显式开启)|
|
||||
|
||||
> 客户端兼容层 `BigQmtXtData` 对常用方法有显式封装(`xtdata.get_longhubang(...)`、`xtdata.bsm_price(...)` 等),其余通过万能入口 `xtdata.call_method("get_float_caps", stockcode="000001.SZ")` 调用。
|
||||
|
||||
> `*` 标记的方法在大 QMT(完整交易端)环境下用 **fallback** 实现(非原生数据):`get_sector_list` 返回常用板块名清单,`get_holidays` 从交易日历反推,`get_markets` 返回固定市场集合,`get_market_last_trade_date` 从日历派生。详见 [docs/RPC_API_REFERENCE.md](docs/RPC_API_REFERENCE.md) 第 8 节「大 QMT 环境的能力边界」。
|
||||
|
||||
### 客户端兼容层
|
||||
|
||||
- `bigqmt_signal_trader.xtquant_compat`:把旧代码的 `xt_trader` / `xtdata` 调用转成 RPC,无需改业务代码。
|
||||
- 兼容 MiniQMT 方法名:`query_stock_asset` / `query_stock_positions` / `query_stock_orders` / `get_full_tick` / `order_stock` 等。
|
||||
- **完整 xtconstant 枚举**(91 个常量,对齐原生 MiniQMT):账号类型、委托类型(股票/期货/信用/期权)、报价类型、委托状态、账号状态、`ORDER_TYPE_SET`。
|
||||
|
||||
```python
|
||||
# 旧代码零改动(自动命中 shim)
|
||||
from xtquant.xtconstant import STOCK_BUY, FIX_PRICE, ORDER_SUCCEEDED
|
||||
|
||||
# 或直接从 compat 导入
|
||||
from bigqmt_signal_trader.xtquant_compat import (
|
||||
SECURITY_ACCOUNT, STOCK_BUY, FIX_PRICE, CREDIT_FIN_BUY,
|
||||
FUTURE_OPEN, ACCOUNT_STATUS_OK, ORDER_SUCCEEDED,
|
||||
)
|
||||
```
|
||||
|
||||
### 异步回报回调(MiniQMT 风格,实盘验证)
|
||||
|
||||
客户端注册 `XtQuantTraderCallback` 子类,`connect()`/`subscribe()` 后实时接收委托/成交/错误回报(通过 Redis pubsub 推送):
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import (
|
||||
StockAccount, XtQuantTraderCallback, configure, xt_trader,
|
||||
)
|
||||
|
||||
class MyCallback(XtQuantTraderCallback):
|
||||
def on_stock_order(self, order):
|
||||
print("委托回报:", order.stock_code, order.order_status, order.order_sysid)
|
||||
|
||||
def on_stock_trade(self, trade):
|
||||
print("成交回报:", trade.stock_code, trade.order_id, trade.traded_volume, trade.traded_price)
|
||||
|
||||
def on_order_error(self, order_error):
|
||||
print("委托失败:", order_error.order_id, order_error.error_id, order_error.error_msg)
|
||||
|
||||
def on_cancel_error(self, cancel_error):
|
||||
print("撤单失败:", cancel_error.order_id, cancel_error.error_id, cancel_error.error_msg)
|
||||
|
||||
def on_order_stock_async_response(self, response):
|
||||
print("异步下单回报:", response.account_id, response.order_id, response.seq)
|
||||
|
||||
def on_account_status(self, status):
|
||||
print("账户状态:", status.account_id, status.account_type, status.status)
|
||||
|
||||
configure()
|
||||
xt_trader.register_callback(MyCallback())
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
xt_trader.connect()
|
||||
xt_trader.subscribe(acc)
|
||||
|
||||
# 异步下单(返回 seq,回报走回调)
|
||||
seq = xt_trader.order_stock_async(acc, "600654.SH", 23, 100, 11, 2.95, "rpc_test", "备注")
|
||||
```
|
||||
|
||||
**完整的回调链**(对齐 MiniQMT 原生语义,实盘验证):
|
||||
|
||||
| 回调 | 触发时机 | 已验证 |
|
||||
|------|---------|--------|
|
||||
| `on_account_status` | `connect()`/`subscribe()` 后 | ✅ |
|
||||
| `on_order_stock_async_response(seq, resp)` | 异步下单提交成功 | ✅(实盘)|
|
||||
| `on_stock_order(order)` | 委托状态变化(已报 50 / 已成 56 / 废单 57)| ✅(实盘)|
|
||||
| `on_stock_trade(trade)` | 成交回报 | ✅ |
|
||||
| `on_order_error(err)` | 废单/拒单(服务端检测 status=57 推送)| ✅(实盘)|
|
||||
| `on_cancel_error(err)` | 撤单失败 | ✅ |
|
||||
| `on_cancel_order_stock_async_response` | 异步撤单回报 | ✅ |
|
||||
|
||||
**`*_async` 查询方法**(对齐 MiniQMT 签名,callback 可选):
|
||||
|
||||
```python
|
||||
# 方式 1:callback 接收结果(MiniQMT 原生语义,返回 None)
|
||||
xt_trader.query_stock_asset_async(acc, lambda asset: print(asset.cash, asset.total_asset))
|
||||
xt_trader.query_stock_positions_async(acc, lambda positions: print(len(positions)))
|
||||
|
||||
# 方式 2:不传 callback,返回 seq(我们的扩展)
|
||||
seq = xt_trader.query_stock_orders_async(acc)
|
||||
```
|
||||
|
||||
**注意**:QMT 必须运行在**实盘模式**(非模拟/模型交易)才能收到完整回报。模拟模式下委托进 QMT 界面但不在真实委托队列,`query_orders` 查不到、`order_stock` 返回 -1(触发 `on_order_error`)。
|
||||
|
||||
### 全推行情订阅(subscribe_whole_quote 真推送)
|
||||
|
||||
`subscribe_whole_quote` 是**服务端真推送**——对齐 MiniQMT 全推行情订阅。服务端引用计数管理 `ContextInfo.subscribe_whole_quote` 回调,通过独立 PUB/SUB 通道向客户端**增量推送**行情(不是一次性快照):
|
||||
|
||||
**架构(三通道)**:
|
||||
1. **控制面 RPC**——`subscribe_whole_quote` / `unsubscribe_whole_quote` / `quote_keepalive` 方法(复用现有 transport)
|
||||
2. **数据面推送**——`QuotePushChannel` 单向 PUB/SUB(redis pub/sub 或 zmq PUB/SUB,按部署 transport 选择;msgpack 编码 + json 兜底)
|
||||
3. **Big-QMT 行情源**——`QuoteSubscriptionManager` 按组合键归一化共享(大写/去空格/排序),多客户端共享一个底层订阅
|
||||
|
||||
**关键设计**:
|
||||
- **组合键去重**:不同客户端订阅相同标的组合,只占一个 big-QMT 订阅
|
||||
- **引用计数**:按 `(client_id, sub_id)` 计数,全部退订或 30s keepalive 超时才销毁
|
||||
- **客户端心跳**:周期 `quote_keepalive`;检测推送静默(默认 10 轮心跳)自动重放订阅,**服务端重启后自动恢复**
|
||||
- **初始快照**:客户端用 `get_full_tick` 预拉快照(big-QMT 回调是增量的)
|
||||
|
||||
**用法**:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import configure, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
# 订阅全推行情(callback 收到增量推送)
|
||||
def on_quote(data):
|
||||
for code, tick in data.items():
|
||||
print(code, tick.get("lastPrice"))
|
||||
|
||||
seq = xtdata.subscribe_whole_quote(["600000.SH", "000001.SZ"], callback=on_quote)
|
||||
|
||||
# 退订
|
||||
xtdata.unsubscribe_quote(seq)
|
||||
```
|
||||
|
||||
**验证**:实盘交易日验证 1/20/50/100 只标的,3s 推送节奏稳定,零丢失零乱序;多客户端共享/退订隔离/同客户端多 sub_id 全过;服务端重启恢复(42s 中断后验证两次)。详见 [docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md](docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md) 和 [docs/SUBSCRIBE_WHOLE_QUOTE_LIVE_VERIFICATION.md](docs/SUBSCRIBE_WHOLE_QUOTE_LIVE_VERIFICATION.md)。
|
||||
|
||||
### 可插拔传输层
|
||||
|
||||
| 传输 | 同机 p50 | 跨机 | 适用场景 |
|
||||
|------|---------|------|---------|
|
||||
| **redis**(默认)| ~13ms | ✅ | 生产默认,稳定 |
|
||||
| **zmq** | ~0.7ms* | ✅ | 同机低延迟 |
|
||||
| **mysql** | ~105ms | ✅ | 兼容兜底 |
|
||||
| **shm** | — | ❌ | 接口预留(未实现)|
|
||||
|
||||
*zmq fast-path;约 30% 请求会撞 QMT 的 GIL 调度尖峰(~500ms)。
|
||||
|
||||
### FormulaServer 直连快速路径(只读行情,默认开启)
|
||||
|
||||
大 QMT 的 `58600` 端口是 **FormulaServer**——QMT 内置的 C++ 行情/参考数据服务(端口取自
|
||||
`config/formulaserver/formulaserver.ini` 的 `[server_formula] address`)。QMT 自带 Python
|
||||
的 `qmt_api` 包就是它的客户端。
|
||||
|
||||
客户端对这些方法会**绕开整条 RPC 链路**(不经过 QMT 的 python 策略线程,也不抢 GIL),
|
||||
实测 **p50 0.07ms**,穿过完整客户端栈是 **0.145ms/次**:
|
||||
|
||||
| 对比 | p50 |
|
||||
|------|-----|
|
||||
| redis RPC | ~13ms |
|
||||
| zmq RPC | ~0.7ms(30% 撞 500ms GIL 尖峰)|
|
||||
| **FormulaServer 直连** | **0.07ms**(无 GIL 竞争)|
|
||||
|
||||
直连覆盖 10 个方法:`get_instrument` / `get_instrument_detail` / `get_instrumentdetail` /
|
||||
`get_last_volume` / `get_total_share` / `get_contract_multiplier` / `get_main_contract` /
|
||||
`get_weight_in_index` / `get_stock_list_in_sector` / `get_market_data_ex`。
|
||||
|
||||
**能力边界(重要)**:FormulaServer 只有行情/参考数据。所有账户、持仓、委托、成交、下单
|
||||
方法一律返回 `ErrorID 200005 未找到该服务`,`getFullTick`/`getQuote` 也不存在。所以它是
|
||||
**只读快速路径,不是 RPC 桥的替代品**——交易、账户查询、五档盘口仍然走 RPC。
|
||||
|
||||
以下方法**刻意不走**直连,因为参数语义与我们的调用方不一致,宁慢勿错:
|
||||
|
||||
- `get_trading_dates` —— FormulaServer 要**股票代码**(`000001.SZ`),传市场代码(`SH`)静默返回 `[]`,而我们的调用方传的是市场。
|
||||
- `get_divid_factors` / `get_risk_free_rate` —— 参数语义不同(区间 vs 单日、index vs timetag)。
|
||||
- **复权 K 线** —— 实测 `dividendType` 传 `none` 和 `front` 返回完全相同,复权未生效。因此只有
|
||||
`dividend_type="none"` 才走直连,其余回退 RPC,避免静默返回未复权价格。
|
||||
(复权数据还需**先在服务端下载原始数据**,见下文「复权数据下载陷阱」。)
|
||||
|
||||
配置(客户端侧,默认就是开启,通常不用写):
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"formula_server": {
|
||||
"enabled": True, # 或环境变量 BIGQMT_FORMULA_ENABLED=0 关闭
|
||||
# "host": "127.0.0.1", # 默认本机;FormulaServer 绑 0.0.0.0,跨机需放行防火墙
|
||||
# "port": 58600, # 不写则从 qmt_root 的 ini 读,再退回 58600
|
||||
# "qmt_root": r"D:\国金证券QMT交易端",
|
||||
# "timeout_seconds": 3.0,
|
||||
# "methods": ["get_instrument"], # 只路由白名单里的方法
|
||||
# "failure_cooldown_seconds": 30.0, # 连不上后停用多久再重试
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**失败一律自动回退 RPC**:方法未映射、参数translate 不了、服务没起、连接断——都退回原路径,
|
||||
所以连不上 58600 的客户端行为与改动前完全一致。BSON 编解码内置了无依赖实现(可选用
|
||||
pymongo 的 `bson`,两者输出实测逐字节一致),客户端不需要额外装包。
|
||||
|
||||
### QMT 启停 / 自动重启(qmt_launcher)
|
||||
|
||||
大 QMT 基本每天早上要重启一次,卡点在登录框。两条路绕过它:
|
||||
|
||||
```bash
|
||||
python -m bigqmt_signal_trader.qmt_launcher status --dir "D:\国金证券QMT交易端_lemo"
|
||||
python -m bigqmt_signal_trader.qmt_launcher restart --dir "D:\国金证券QMT交易端_lemo"
|
||||
```
|
||||
|
||||
| mode | 做什么 | 需要登录框交互 |
|
||||
|------|--------|---------------|
|
||||
| `linkmini`(默认优先)| `XtMiniQmt.exe linkMini`,MiniQMT 免密启动 | 否 |
|
||||
| `bat` | 跑指定批处理(如 `免密登录qmt.bat`)| 否 |
|
||||
| `exe` | 直接起 `XtItClient.exe`,靠终端自身恢复会话 | 否 |
|
||||
| `login` | 起 exe 后向登录框输入账号密码 | 是,需 pywin32 |
|
||||
|
||||
**关于「pywinauto/pyautogui 要求 Windows 处于登录状态」**:`login` 模式用的是
|
||||
`win32api.SendMessage` 直接投递到窗口句柄,不是 pyautogui 那种按屏幕坐标重放物理输入。
|
||||
前者不要求窗口置于前台,锁屏下也能工作(会话还在即可,完全注销则不行)。密码从
|
||||
环境变量 `BIGQMT_LOGIN_USER` / `BIGQMT_LOGIN_PASSWORD` 读,不走命令行参数——argv
|
||||
对同机任何进程可见。
|
||||
|
||||
两个设计要点:
|
||||
|
||||
- **按安装目录隔离**。同机常并行跑多个 QMT,`taskkill /im XtItClient.exe` 会误杀别人的
|
||||
实盘。这里只终结 `--dir` 对应 `bin.x64` 下的进程;拿不到 exe 路径的进程直接跳过而不是
|
||||
猜。
|
||||
- **等就绪而不是 sleep 固定秒数**。启动完成的判据是 FormulaServer 端口(58600)能接受连接,
|
||||
超时抛 `QmtLauncherError` 而不是静默返回,避免定时任务在没起来的终端上继续跑。
|
||||
|
||||
`restart` 默认在关闭后等 5 秒再启动:ZMQ 传输是精确绑定配置端口(不扫描),socket 没
|
||||
完全释放就重启会绑定失败。
|
||||
|
||||
### 独立 ZMQ 回测桥接
|
||||
|
||||
`bigqmt_backtest` 与实盘 RPC 桥接完全分离,提供两个明确隔离的后端:
|
||||
|
||||
- `QMT_NATIVE`:`BIGQMT_ZMQ_BACKTEST.py` 运行在 QMT 回测进程内。QMT 负责历史
|
||||
行情推进、资金持仓、`passorder/cancel` 和原生撮合;ZMQ 只桥接 Bar、订单意图及
|
||||
QMT 委托/成交结果。
|
||||
- `LOCAL_SIM`:端口 `16661` 的独立 CSV 工具,仅用于脱离 QMT 验证协议和策略逻辑,
|
||||
使用本地撮合并输出本地结果文件。
|
||||
|
||||
QMT 原生入口使用独立端口 `16662`、独立 `run_id/client_id`,强制验证
|
||||
`ContextInfo.do_back_test=true`,固定 `live_ready=false`,不会导入或修改
|
||||
`bigqmt_signal_trader`。
|
||||
|
||||
启动 CSV 独立测试服务:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
python -m bigqmt_backtest.server `
|
||||
--data examples/backtest_bars.example.csv `
|
||||
--config examples/backtest_config.example.json `
|
||||
--run-id demo-001 `
|
||||
--bind tcp://127.0.0.1:16661
|
||||
```
|
||||
|
||||
另开一个终端运行外部策略:
|
||||
|
||||
```powershell
|
||||
python examples/zmq_backtest_strategy.py `
|
||||
--endpoint tcp://127.0.0.1:16661 `
|
||||
--run-id demo-001 `
|
||||
--symbol 600000.SH `
|
||||
--fast 2 `
|
||||
--slow 3
|
||||
```
|
||||
|
||||
QMT 原生安装、逐 Bar 同步协议、CSV 备用模式和安全边界见
|
||||
[docs/ZMQ_BACKTEST_BRIDGE.md](docs/ZMQ_BACKTEST_BRIDGE.md)。
|
||||
|
||||
### 无 redis 版本(QMT 沙箱拒绝 import redis 时用)
|
||||
|
||||
如果你的 QMT 环境**拒绝 `import redis`**(券商白名单拦截),用 `bigqmt_no_redis/` 目录下的无 redis 版本:
|
||||
|
||||
- `bigqmt_no_redis/zmq_transport.py` — 自包含的 ZMQ transport,内联所有编码函数,**完全不 import redis_common/redis_rpc**,去掉 redis 服务发现(用静态派生端口)
|
||||
- `bigqmt_no_redis/DRYRUN_no_redis.py` — 无 redis 的 DRYRUN 入口,强制 `transport=zmq` + `background_threads=True`,只加载 zmq transport
|
||||
|
||||
**用法**:QMT 策略编辑器加载 `BIGQMT_DRYRUN_NO_REDIS.py`(同步到 QMT 目录时用这个文件名),RPC 走纯 ZMQ,零 redis 依赖。其余功能(行情/交易/持仓查询)与标准版一致。
|
||||
|
||||
### 委托/成交查询的 strategy_name 陷阱(重要)
|
||||
|
||||
`get_trade_detail_data` 按 `strategy_name` 过滤委托/成交——**下单时用的 strategy_name 必须和查询时一致**,否则查不到。
|
||||
|
||||
- 下单时传 `strategy_name="rpc_test"` → 委托记在 `rpc_test` 下
|
||||
- 查询时传 `strategy_name="bigqmt_signal_trader"` → 返回空(不匹配)
|
||||
|
||||
**修复**:`query_orders` / `query_trades` 默认传**空字符串 `""`**,返回该账户的**全部**委托/成交(不按 strategy_name 过滤)。如需过滤,显式传 `strategy_name`。
|
||||
|
||||
实测验证(`get_trade_detail_data` 探测):
|
||||
- `st=""` → ORDER=9, DEAL=9(全部)
|
||||
- `st="rpc_test"` → ORDER=3, DEAL=1(只有 rpc_test 的)
|
||||
- `st="bigqmt_signal_trader"` → ORDER=0, DEAL=0(空)
|
||||
|
||||
### 复权数据下载陷阱(重要)
|
||||
|
||||
**前/后复权 K 线必须先在服务端下载原始数据,否则返回全 0**。
|
||||
|
||||
Big QMT 的复权(`dividend_type='front'`/`'back'`)是**服务端现场计算**的——需要原始 K 线 + 除权因子已经在服务端存在。直接请求 front 而服务端没下载过原始数据时,返回的 close 全是 `0.0`(只有最后一根有价)。
|
||||
|
||||
实测复现(600654.SH / 600227.SH):
|
||||
- 直接 `get_market_data_ex(dividend_type='front')` → 634 行全 0
|
||||
- 先 `download_history_data` 后再请求 → 真实复权价(front ≠ none,复权生效)
|
||||
|
||||
**已修复**:`xtdata.download_history_data2(codes, period, dividend_type='front')` 现在会**自动先触发服务端原始数据下载**(拉原始 K 线 + 除权因子),再拉复权数据到本地缓存。用法不变:
|
||||
|
||||
```python
|
||||
# 前复权下载(自动先服务端下载原始数据 + 除权因子)
|
||||
xtdata.download_history_data2(["600654.SH"], period="1d",
|
||||
start_time="20240101", dividend_type="front")
|
||||
|
||||
# 之后本地读取(零 RPC)
|
||||
xtdata.get_local_data(["close"], ["600654.SH"], period="1d",
|
||||
start_time="20240101", dividend_type="front")
|
||||
```
|
||||
|
||||
**读取类 API 也自愈**:`get_market_data_ex` / `get_market_data` 带复权参数时,若检测到返回全 0(服务端缺原始数据),会自动触发服务端下载、等待落盘、重试一次,拿到真实复权价。`get_local_data` 的 fallback 拉取同样受益。无需手动等待。
|
||||
|
||||
注意:QMT 服务端下载是**异步落盘**的,自愈路径内置了等待 + 一次重试;极端大区间若一次重试仍全 0,可稍后重读或先显式 `download_history_data2`。
|
||||
|
||||
### 实盘卖出方向误判修复(exec_events)
|
||||
|
||||
实盘发现:QMT 回调里 `m_nDirection` **恒为 48**(即使是卖出),导致卖出被误判为买入。
|
||||
|
||||
修复(`exec_events._extract_direction`)改为仲裁链:
|
||||
1. `m_nOffsetFlag`(最可靠,匹配 `query_orders`)
|
||||
2. `m_nDirection`(传统 EEntrustBS,但实盘可能恒为 48)
|
||||
3. 当 direction≠offset(期货:卖+开仓=49+48),用 `m_nOpType`(23=买/24=卖)仲裁
|
||||
4. `m_nOpType`/`order_type`(兜底)
|
||||
|
||||
对股票现货,direction=offset(48=买/49=卖);对期货,direction≠offset,仲裁保正确。
|
||||
|
||||
### 多账号使用(股票+期货 / 普通+信用)
|
||||
|
||||
当前架构是**单账号单实例**——一个 QMT 策略进程绑定一个账号,RPC channel 按 `account_id` 隔离(`bigqmt:rpc:req:{account_id}`)。多账号场景(如股票+期货、普通+信用账户同时交易)的推荐方案是**在 QMT 里跑多个策略实例**,每个实例绑一个账号。
|
||||
|
||||
#### 方案:多策略实例(推荐,不改代码)
|
||||
|
||||
**服务端(QMT 内)**:为每个账号创建一个独立的配置文件和 DRYRUN 入口。
|
||||
|
||||
```python
|
||||
# bigqmt_signal_trader_local_config_stock.py — 股票账号
|
||||
BIGQMT_ACCOUNT_ID = "你的股票账号"
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "...", "port": 6379, "db": 5, "password": "...",
|
||||
"transport": "redis", # 或 "zmq"
|
||||
"account_type": "STOCK", # 股票
|
||||
# ...
|
||||
}
|
||||
|
||||
# bigqmt_signal_trader_local_config_credit.py — 信用账号
|
||||
BIGQMT_ACCOUNT_ID = "你的信用账号"
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "...", "port": 6379, "db": 5, "password": "...",
|
||||
"transport": "redis",
|
||||
"account_type": "CREDIT", # 信用(两融)
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
然后在 QMT 策略编辑器里加载两个 DRYRUN 文件(每个指向不同的配置),分别运行。两个实例的 RPC channel 自动隔离(按 account_id)。
|
||||
|
||||
> **zmq 模式注意**:每个实例的 zmq 端口从 account_id 派生(`15560 + account_id mod 100`),不同账号自动不冲突。
|
||||
|
||||
**客户端(外部程序)**:为每个账号创建独立的 client/trader 对象。
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import BigQmtRpcClient, BigQmtXtTrader, StockAccount
|
||||
|
||||
# 股票账号
|
||||
stock_client = BigQmtRpcClient(account_id="股票账号", redis_config={...})
|
||||
stock_trader = BigQmtXtTrader(account_id="股票账号", redis_client=stock_client.redis_client)
|
||||
stock_acc = StockAccount("股票账号", "STOCK")
|
||||
|
||||
# 信用账号
|
||||
credit_client = BigQmtRpcClient(account_id="信用账号", redis_config={...})
|
||||
credit_trader = BigQmtXtTrader(account_id="信用账号", redis_client=credit_client.redis_client)
|
||||
credit_acc = StockAccount("信用账号", "CREDIT")
|
||||
|
||||
# 分别查询/下单
|
||||
stock_asset = stock_trader.query_stock_asset(stock_acc)
|
||||
credit_positions = credit_trader.query_stock_positions(credit_acc)
|
||||
```
|
||||
|
||||
> **跨账号隔离**:每个账号的 RPC channel、持仓查询、委托回报完全隔离(按 `account_id` 路由),互不影响。
|
||||
|
||||
---
|
||||
|
||||
## 环境要求与依赖安装
|
||||
|
||||
本系统分两部分,各自需要自己的 Python 环境和依赖:
|
||||
|
||||
| 部分 | 运行位置 | Python | 装什么 |
|
||||
|------|---------|--------|--------|
|
||||
| **客户端**(外部程序)| 你的开发机 | 3.8+(推荐)| `pip install xtquant-big-convert` |
|
||||
| **服务端**(QMT 内)| QMT 的 `bin.x64/python.exe` | 3.6(QMT 自带)| 按传输装 1 个包 |
|
||||
|
||||
### A. 客户端(外部程序,推荐 pip 安装)
|
||||
|
||||
客户端就是**写策略/调接口的那台电脑**(也叫「开发机」)。直接 pip 安装:
|
||||
|
||||
```powershell
|
||||
# 基础安装(含 pyzmq,zmq 传输必需)
|
||||
pip install xtquant-big-convert
|
||||
|
||||
# 含 redis 支持(redis 传输)
|
||||
pip install xtquant-big-convert[redis]
|
||||
|
||||
# 含 mysql 支持(mysql 传输)
|
||||
pip install xtquant-big-convert[mysql]
|
||||
|
||||
# 开发环境(含测试工具)
|
||||
pip install xtquant-big-convert[dev]
|
||||
|
||||
# 从源码安装(开发模式)
|
||||
git clone https://github.com/litaolemo/xtquant_big_convert.git
|
||||
cd xtquant_big_convert
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
安装后可直接 import:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import configure, xt_trader, xtdata
|
||||
from bigqmt_signal_trader.transports.factory import build_transport
|
||||
|
||||
configure()
|
||||
print(xtdata.get_full_tick(["000001.SZ"]))
|
||||
```
|
||||
|
||||
### B. 服务端(QMT 内 Python 3.6)
|
||||
|
||||
QMT 自带 Python 3.6(`bin.x64/python.exe`),**只需按你选的传输装对应依赖**:
|
||||
|
||||
| 传输 | 服务端需要的包 | 客户端需要的包 |
|
||||
|------|--------------|--------------|
|
||||
| **redis**(默认)| `redis`(QMT 通常已内置)| `redis` |
|
||||
| **zmq** | `pyzmq` | `pyzmq`(基础安装已含)|
|
||||
| **mysql** | `pymysql` + `DBUtils` | `pymysql` + `DBUtils` |
|
||||
|
||||
> ⚠️ **用 redis 传输就不需要装 pyzmq / pymysql / DBUtils**——下面的安装说明是按需的,你用什么传输装什么。
|
||||
|
||||
**安装到 QMT 的 Python(以 zmq / mysql 为例):**
|
||||
|
||||
QMT 的 Python 3.6 用旧 OpenSSL,pip 直连 HTTPS 镜像会报 SSL 错误。有两种方法:
|
||||
|
||||
```powershell
|
||||
# 方法 A:从开发机拷贝纯 Python 包(推荐,绕过 SSL 问题)
|
||||
# pymysql / DBUtils 是纯 Python,可直接拷贝;在开发机(已装这些包)执行:
|
||||
$QMT_SITE = "D:\国金证券QMT交易端\bin.x64\Lib\site-packages"
|
||||
Copy-Item -Recurse "C:\Users\<你>\anaconda3\Lib\site-packages\pymysql" "$QMT_SITE\pymysql"
|
||||
Copy-Item -Recurse "C:\Users\<你>\anaconda3\Lib\site-packages\dbutils" "$QMT_SITE\dbutils"
|
||||
|
||||
# 方法 B:用 QMT python pip 装(可能因 SSL 失败,需配置信任)
|
||||
cd D:\国金证券QMT交易端
|
||||
.\bin.x64\python.exe -m pip install --trusted-host mirrors.aliyun.com pymysql DBUtils
|
||||
```
|
||||
|
||||
验证安装:
|
||||
```powershell
|
||||
.\bin.x64\python.exe -c "import pymysql; from dbutils.pooled_db import PooledDB; print('OK')"
|
||||
```
|
||||
|
||||
> **pyzmq 特殊说明**:包含 C 扩展,不能直接拷贝。Python 3.6 需装 `pyzmq==19.0.2`(最后一个支持 3.6 的版本)。如果 SSL 装不上,可下载对应 wheel 手动 `pip install xxx.whl`。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
> 前置:客户端已按上面「A. 客户端」装好包;服务端按「B. 服务端」装好所选传输的依赖。下面是从零跑通整套流程的步骤。
|
||||
|
||||
### 第 1 步:同步代码到 QMT 的 python 目录
|
||||
|
||||
把以下内容复制到大 QMT 的 `python` 目录(如 `D:\国金证券QMT交易端\python\`):
|
||||
|
||||
```
|
||||
src/bigqmt_signal_trader/ (整个核心包,含 transports/)
|
||||
src/bigqmt_signal_trader_strategy.py
|
||||
src/bigqmt_signal_trader_redis_rpc_runtime.py
|
||||
src/BIGQMT_REDIS_DRYRUN.py (★ QMT 编辑器入口,GBK 编码,在 QMT 里加载这个)
|
||||
```
|
||||
|
||||
> **在 QMT 策略编辑器里只加载 `BIGQMT_REDIS_DRYRUN.py` 一个文件**。它会自动 import 上面其余文件。其余 `.py`(`bigqmt_signal_trader_*`)是它依赖的模块,不是直接运行的入口。
|
||||
|
||||
### 第 2 步:创建 QMT 端私有配置
|
||||
|
||||
在 QMT 的 `python` 目录创建 `bigqmt_signal_trader_local_config.py`(**不要提交此文件**):
|
||||
|
||||
```python
|
||||
# coding: utf-8
|
||||
BIGQMT_ACCOUNT_ID = "你的资金账号" # 如 "1234567890"
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "你的Redis地址", # 如 "192.168.1.100"
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"password": "你的Redis密码",
|
||||
|
||||
# === 传输选择(默认 redis,生产推荐)===
|
||||
# "transport": "redis", # 不写就是 redis
|
||||
# 切 zmq(同机低延迟,实测 p50~0.3ms):装了 pyzmq 后只需这一行。
|
||||
# 非 redis 传输会自动开 background_threads;端口按账号派生 127.0.0.1:1556x。
|
||||
# "transport": "zmq",
|
||||
# 切 mysql(兼容兜底):需装 pymysql+DBUtils,同样自动开 background_threads。
|
||||
# "transport": "mysql",
|
||||
# "mysql": {"driver":"pymysql","host":"...","port":3306,"user":"root",
|
||||
# "password":"...","database":"bigqmt_rpc","charset":"utf8mb4"},
|
||||
|
||||
"rpc_allow_order_methods": False, # 下单默认关闭
|
||||
"rpc_process_in_listener": True, # 只读请求在收包线程直接处理(低延迟)
|
||||
"rpc_listener_methods": ("*",), # * = 所有只读方法
|
||||
"rpc_background_threads": False, # redis 用 QMT adjust 线程 drain
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "500nMilliSecond",
|
||||
}
|
||||
```
|
||||
|
||||
> **重要**:切到 zmq 或 mysql 时,必须同时设 `"rpc_background_threads": True`(这两种传输用自己的后台线程,不走 QMT 回调 drain)。
|
||||
|
||||
### 第 3 步:在 QMT 里运行策略(BIGQMT_REDIS_DRYRUN.py)
|
||||
|
||||
**入口文件是 `src/BIGQMT_REDIS_DRYRUN.py`**(GBK 编码,QMT 友好)。在 QMT 策略编辑器加载并运行它。
|
||||
|
||||
#### 这个文件做什么
|
||||
|
||||
它是 QMT 编辑器入口的"外壳"(shell),按顺序做 5 件事:
|
||||
|
||||
1. **定位 python 目录**:把 QMT 的 `python` 目录加到 `sys.path`,让 `bigqmt_signal_trader` 包能 import。
|
||||
2. **reload 模块**:`importlib.reload` 刷新 `redis_common` / `redis_rpc` / `strategy` / `runtime` —— QMT 在编辑器里重跑策略时,进程不退出,reload 确保新代码立即生效。
|
||||
3. **注入 Redis 配置**:读 `bigqmt_signal_trader_local_config.py` 里的 `BIGQMT_REDIS_CONFIG`,调 `configure_runtime_redis()`。
|
||||
4. **注入账号**:读 `BIGQMT_ACCOUNT_ID`,调 `configure_runtime_account()`。如果配置没给,fallback 用 QMT 全局变量 `account`。
|
||||
5. **绑定 QMT 原生 API**:把 QMT 内置的 `passorder` / `cancel` / `get_trade_detail_data` 函数绑进 runtime(用 `try/except NameError` 包住,因为这些名字只在大 QMT 进程内存在)。
|
||||
6. **导出 QMT 回调**:`init = _runtime.init` / `handlebar = _runtime.handlebar` / `adjust = _runtime.adjust` 等,让 QMT 能回调到我们的策略逻辑。
|
||||
|
||||
#### ⚠️ 硬编码路径(重要)
|
||||
|
||||
`BIGQMT_REDIS_DRYRUN.py` 里有**一处写死的 QMT python 目录路径**,作为 `__file__` 找不到时的 fallback:
|
||||
|
||||
```python
|
||||
def _known_qmt_python_dir():
|
||||
root = "".join(chr(value) for value in (0x56fd, 0x91d1, 0x8bc1, 0x5238)) # 国金证券
|
||||
suffix = "".join(chr(value) for value in (0x4ea4, 0x6613, 0x7aef)) # 交易端
|
||||
return "D:\\" + root + "QMT" + suffix + "\\python"
|
||||
# 解码后 = D:\国金证券QMT交易端\python
|
||||
```
|
||||
|
||||
- **`chr()` 编码**是为了规避 QMT 用 GBK 保存策略文件时中文乱码(用 Unicode 码点拼出"国金证券交易端")。
|
||||
- **路径优先级**:先用 `__file__` 所在目录(脚本实际位置),找不到才用这个硬编码 fallback。
|
||||
- **如果你的 QMT 装在别的路径**(比如 `D:\华泰QMT\python`):通常不用改,因为 `__file__` 优先。但如果你用 `exec` 方式加载(`__file__` 未定义),需要把 `_known_qmt_python_dir()` 改成你的路径,或直接硬编码:
|
||||
```python
|
||||
def _known_qmt_python_dir():
|
||||
return r"D:\你的券商QMT\python"
|
||||
```
|
||||
|
||||
#### 启动成功标志(QMT 输出面板)
|
||||
|
||||
```
|
||||
[bigqmt_shell] reload entry paths=['D:\\国金证券QMT交易端\\python']
|
||||
[bigqmt_shell] local redis config loaded keys=['host', 'port', 'db', ...]
|
||||
[bigqmt_shell] local account config loaded=True
|
||||
[bigqmt_rpc] transport=redis mode process_in_listener=True listener_methods=('*',) ...
|
||||
[bigqmt_rpc] started channel=bigqmt:rpc:req:你的账号
|
||||
[bigqmt_signal_trader] init ok
|
||||
```
|
||||
|
||||
> **为什么是 GBK 编码?** QMT 的策略编辑器用本地代码页(中文 Windows 是 GBK)保存文件。文件头 `#coding:gbk` 声明编码,避免 QMT 保存时破坏 UTF-8 内容。源码本身是 ASCII(中文用 `chr()` 拼),所以实际不会乱码。
|
||||
|
||||
> **为什么不直接用 `bigqmt_signal_trader_redis_rpc_runtime.py`?** 那个文件是纯逻辑入口,不包含 reload 和 QMT API 绑定。`BIGQMT_REDIS_DRYRUN.py` 是给 QMT 编辑器专用的外壳,处理了 QMT 进程不退出导致模块缓存、API 绑定等坑。在 QMT 里**只加载 `BIGQMT_REDIS_DRYRUN.py`**。
|
||||
|
||||
### 第 4 步:客户端调用
|
||||
|
||||
**方式 A:用兼容层(推荐,旧代码零改动)**
|
||||
|
||||
客户端创建配置文件 `bigqmt_signal_trader_client_config.py`(与上面类似但用客户端视角),然后:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
|
||||
# 行情
|
||||
ticks = xtdata.get_full_tick(["000001.SZ"])
|
||||
print(ticks["000001.SZ"]["lastPrice"])
|
||||
|
||||
# 持仓 / 资金
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
asset = xt_trader.query_stock_asset(acc)
|
||||
print(asset.cash, asset.total_asset)
|
||||
|
||||
# K线(自动还原成 pandas DataFrame)
|
||||
klines = xtdata.get_market_data_ex(
|
||||
field_list=["close"], stock_list=["000001.SZ"], period="1d", count=5
|
||||
)
|
||||
```
|
||||
|
||||
**方式 B:直接 RPC 调用**
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
import redis
|
||||
|
||||
r = redis.Redis(host="192.168.1.100", port=6379, db=5, password="...")
|
||||
resp = call_redis_rpc(r, "你的账号", "get_full_tick", {"codes": ["000001.SZ"]})
|
||||
print(resp["data"]["000001.SZ"]["lastPrice"])
|
||||
```
|
||||
|
||||
**方式 C:无缝替换旧 xtquant(最终切换)**
|
||||
|
||||
把仓库 `src` 放到 `PYTHONPATH` 最前面,旧代码的 `from xtquant import xtdata` 自动命中本仓库 shim:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "D:\gjzqqmt\xtquant_big_convert\src;$env:PYTHONPATH"
|
||||
```
|
||||
|
||||
```python
|
||||
# 旧代码完全不改
|
||||
from xtquant import xtdata
|
||||
ticks = xtdata.get_full_tick(["600000.SH"]) # 走 RPC 到大 QMT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 切换传输层
|
||||
|
||||
### 只需改一个字段
|
||||
|
||||
服务端 + 客户端的配置文件里,`transport` 字段保持一致即可:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"transport": "zmq", # redis / zmq / mysql / shm
|
||||
"zmq": {"host": "127.0.0.1"}, # 各传输子配置
|
||||
# redis 配置保留(zmq 服务发现、mysql 不需要时的 fallback 都用它)
|
||||
}
|
||||
```
|
||||
|
||||
### 各传输配置示例
|
||||
|
||||
**Redis(默认)**:
|
||||
```python
|
||||
{"transport": "redis"} # 或省略 transport 字段
|
||||
```
|
||||
|
||||
**ZMQ**(同机低延迟,需 pyzmq):
|
||||
```python
|
||||
{
|
||||
"transport": "zmq",
|
||||
"rpc_background_threads": True, # 必须!
|
||||
"zmq": {
|
||||
"host": "127.0.0.1", # 默认端口从 account_id 派生
|
||||
# "port": 5560, # 可显式指定
|
||||
# 端口冲突时自动找空闲端口 + 通过 Redis 服务发现告知客户端
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**MySQL**(兼容兜底,需 pymysql + DBUtils):
|
||||
```python
|
||||
{
|
||||
"transport": "mysql",
|
||||
"rpc_background_threads": True, # 必须!
|
||||
"mysql": {
|
||||
"driver": "pymysql",
|
||||
"host": "192.168.1.100", "port": 3306,
|
||||
"user": "root", "password": "...",
|
||||
"database": "bigqmt_rpc", "charset": "utf8mb4",
|
||||
"poll_interval_seconds": 0.01,
|
||||
"pool_config": {"mincached": 1, "maxcached": 3, "maxshared": 0, "maxconnections": 4},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### ZMQ 端口与服务发现
|
||||
|
||||
- 默认端口从 account_id 派生:`15560 + (账号数字 mod 100)`,不同账号自动不冲突。
|
||||
- 端口被占时,server 自动往上扫描找空闲端口,把真实地址写到 Redis key `bigqmt:zmq:addr:{account_id}`(TTL 300s)。
|
||||
- 客户端连接时按优先级解析地址:显式 `connect_address` > Redis 服务发现 > 默认派生端口。
|
||||
- server 退出时自动清理 discovery key。
|
||||
- 服务发现是可选的(没配 Redis client 时退化为静态派生端口)。
|
||||
|
||||
完整传输层文档见 [docs/RPC_TRANSPORTS.md](docs/RPC_TRANSPORTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 实测延迟对比(真实直连 QMT)
|
||||
|
||||
三种传输全部实测,端到端连接真实 QMT 进程,n=15/方法:
|
||||
|
||||
| 传输 | ping p50 | get_full_tick p50 | 成功率 | 尖峰来源 |
|
||||
|------|---------|------------------|--------|---------|
|
||||
| **Redis** | 13ms | 15ms | 100% | 偶发 245ms(网络抖动)|
|
||||
| **ZMQ** | 0.7ms* | 0.7ms* | 100% | 30% 撞 500ms(QMT adjust GIL)|
|
||||
| **MySQL** | 104ms | 110ms | 100% | 轮询开销 |
|
||||
|
||||
*ZMQ fast-path(避开 GIL 尖峰的请求);overall p90 ~498ms。
|
||||
|
||||
**生产推荐 Redis**:稳定、跨机、无 GIL 问题、QMT 端零额外依赖。ZMQ 理论最快但受 QMT 主线程 GIL 调度影响。MySQL 仅作兜底。
|
||||
|
||||
复现基准:
|
||||
```powershell
|
||||
python bench_latency.py # Redis 单传输延迟
|
||||
python bench_transports.py -n 100 # Redis vs ZMQ 对比
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/bigqmt_signal_trader/
|
||||
├── transports/ 可插拔传输层
|
||||
│ ├── base.py RpcTransport 抽象接口
|
||||
│ ├── redis_transport.py Redis(默认,rpush/blpop/brpop)
|
||||
│ ├── zmq_transport.py ZMQ(ROUTER/DEALER + 服务发现)
|
||||
│ ├── mysql_transport.py MySQL(轮询 + DBUtils 连接池)
|
||||
│ ├── shm_transport.py 共享内存(stub)
|
||||
│ └── factory.py build_transport 工厂
|
||||
├── adapters/ QMT API 适配器
|
||||
│ ├── market_bigqmt.py 行情(ContextInfo 封装)
|
||||
│ ├── order_bigqmt.py 下单(passorder)
|
||||
│ ├── position_bigqmt.py 持仓(get_trade_detail_data)
|
||||
│ └── redis_common.py Redis 连接/编解码
|
||||
├── redis_rpc.py RPC 服务(handlers + service + transport 集成)
|
||||
├── xtquant_compat.py 客户端兼容层(xt_trader / xtdata + 异步回调)
|
||||
├── exec_events.py 委托/成交/错误事件推送(Redis pubsub)
|
||||
├── quote_push_channel.py 全推行情推送通道(redis/zmq PUB/SUB)
|
||||
├── quote_subscription_manager.py 服务端全推订阅管理(引用计数 + 组合键去重)
|
||||
├── whole_quote_session.py 客户端全推订阅会话(心跳 + 重启恢复)
|
||||
├── full_tick_cache.py 全市场行情快照缓存(可选降载)
|
||||
├── strategy.py 之类 策略骨架、风控、价格引擎等
|
||||
bigqmt_no_redis/ 无 redis 版本(QMT 沙箱拒绝 import redis 时用)
|
||||
│ ├── zmq_transport.py 自包含 ZMQ transport(内联编码,零 redis 依赖)
|
||||
│ └── DRYRUN_no_redis.py 无 redis DRYRUN 入口
|
||||
src/xtquant/ 可选 xtquant import shim
|
||||
src/bigqmt_signal_trader_strategy.py 策略入口(init/handlebar/adjust + 启动诊断)
|
||||
src/bigqmt_signal_trader_redis_rpc_runtime.py Redis RPC runtime 入口
|
||||
src/BIGQMT_REDIS_DRYRUN.py QMT 编辑器加载入口(GBK)
|
||||
src/BIGQMT_ZMQ_BACKTEST.py 独立 QMT 回测 ZMQ 入口(GBK)
|
||||
src/bigqmt_backtest/ 独立历史驱动、模拟撮合、ZMQ 协议与客户端
|
||||
tests/bigqmt_signal_trader/ 单元测试(无 QMT 环境可跑)
|
||||
tests/bigqmt_backtest/ 回测、确定性、隔离和 ZMQ 往返测试
|
||||
qmt-trader/ AI 助手 Skill(大模型直接操作 QMT,见下文专节)
|
||||
│ ├── SKILL.md skill 说明书(命令速查 + 工作流 + 安全须知)
|
||||
│ ├── scripts/qmt.py 统一 CLI(46 子命令 + rpc 兜底)
|
||||
│ └── references/api_reference.md 完整 API 参考
|
||||
docs/ 详细文档
|
||||
test_all_apis.py 端到端 API 测试(发现生产问题)
|
||||
bench_latency.py / bench_transports.py 延迟基准脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本地测试
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/bigqmt_signal_trader/ -q
|
||||
```
|
||||
|
||||
当前覆盖 **199 个用例**(含传输层往返、Redis RPC、客户端兼容、持仓/行情/下单 handlers、异步回调、执行事件)。
|
||||
|
||||
### 端到端 API 测试(发现生产问题)
|
||||
|
||||
`test_all_apis.py` 是**端到端验证**测试——不只测「调用成功」,还测「结果正确」,能发现这些生产问题:
|
||||
|
||||
| 验证项 | 检测什么 | 为什么重要 |
|
||||
|--------|---------|-----------|
|
||||
| **客户端/服务端一致性** | ping 超时 → transport 不匹配 | Issue #24 根因:客户端 redis / 服务端 zmq 连不上 |
|
||||
| **持仓查询** | `get_positions` 返回空但账户有持仓 | 容错设计把「失败返回空」当成「正常」 |
|
||||
| **委托查询** | `query_orders` 返回空 | strategy_name 不匹配(默认应为 `""` 返回全部) |
|
||||
| **买入/卖出** | `submit_order` 成功但委托没进系统 | 静默失败(passorder 被 QMT 拒绝但没报错) |
|
||||
| **server_error** | 显示 QMT 端拒绝原因 | 委托被 QMT 静默拒绝时返回具体原因 |
|
||||
|
||||
**用法**:
|
||||
```powershell
|
||||
# 方式 A:用环境变量
|
||||
$env:BIGQMT_ACCOUNT_ID="你的账号"
|
||||
$env:BIGQMT_REDIS_HOST="你的Redis地址"
|
||||
$env:BIGQMT_REDIS_PORT="6379"
|
||||
$env:BIGQMT_REDIS_DB="5"
|
||||
$env:BIGQMT_REDIS_PASSWORD="你的密码"
|
||||
python test_all_apis.py
|
||||
|
||||
# 方式 B:用 QMT 端配置(需 bigqmt_signal_trader_local_config.py 在 PYTHONPATH)
|
||||
$env:PYTHONPATH="D:\国金证券QMT交易端\python;$env:PYTHONPATH"
|
||||
python test_all_apis.py
|
||||
```
|
||||
|
||||
**示例输出**(发现问题时):
|
||||
```
|
||||
--- 端到端验证: 客户端/服务端一致性 ---
|
||||
客户端配置 transport: redis
|
||||
❌ ping 失败: redis rpc timeout: ping
|
||||
可能原因: 客户端 transport 和服务端不匹配
|
||||
- 客户端配置 transport=redis
|
||||
- 如果服务端是 zmq, 客户端也要设 transport=zmq
|
||||
|
||||
--- 端到端验证: 持仓查询 ---
|
||||
⚠️ get_positions 返回空 — 账户可能真的没持仓, 或查询失败 (检查 QMT 上下文)
|
||||
|
||||
--- 端到端验证: 买入/卖出 ---
|
||||
✅ submit_order OK
|
||||
❌ 委托没进系统 — submit_order 成功但 query_orders 找不到
|
||||
这是静默失败 (passorder 被 QMT 拒绝但没报错)
|
||||
检查: 1) 价格是否超出范围 2) 账户权限 3) QMT 风控
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 日志与排错(出错去哪看)
|
||||
|
||||
系统自带**文件日志**——所有报错/异常同时写 QMT 输出面板和本地日志文件,重启/崩溃后也能回溯。
|
||||
|
||||
### 日志位置
|
||||
|
||||
| 环境 | 日志文件 |
|
||||
|------|---------|
|
||||
| **QMT 内(服务端)** | `<QMT python 目录>\logs\bigqmt.log`(如 `D:\国金证券QMT交易端_lemo\python\logs\bigqmt.log`)|
|
||||
| **外部客户端** | `~\.cache\bigqmt\logs\bigqmt.log`(用户目录下)|
|
||||
|
||||
- **按天轮转**(午夜),**默认保留最近 7 天**。
|
||||
- 每行带时间戳 + 级别 + 模块标签:`2026-08-14 21:45:59 [ERROR] [bigqmt.quote_push] publisher start failed: ...`
|
||||
|
||||
### 查看方式
|
||||
|
||||
```powershell
|
||||
# 实时跟踪日志
|
||||
Get-Content "D:\国金证券QMT交易端\lempython\logs\bigqmt.log" -Wait -Tail 50
|
||||
|
||||
# 只看错误
|
||||
Get-Content "D:\...\python\logs\bigqmt.log" | Select-String "ERROR|WARN"
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|---------|------|------|
|
||||
| `BIGQMT_LOG_ENABLED` | `1` | 置 `0` 关闭文件日志 |
|
||||
| `BIGQMT_LOG_TO_STDOUT` | `1` | 置 `0` 不输出到 QMT 面板 |
|
||||
| `BIGQMT_LOG_RETENTION_DAYS` | `7` | 日志保留天数 |
|
||||
|
||||
> **排错首选看日志文件**:QMT 面板内容重启/清空后丢失,日志文件保留 7 天,包含启动诊断(`[bigqmt_diag]`)、崩溃原因、端口冲突等。
|
||||
|
||||
---
|
||||
|
||||
## 安全默认值
|
||||
|
||||
- `rpc_allow_order_methods` 默认 `False`:远程 `order_stock` / `cancel_order` 被拒绝。确认接入方、账号、风控后再显式开启。
|
||||
- 回测桥接永久 `live_ready=false`,协议中没有真实账户和实盘下单方法。
|
||||
- 配置文件含资金账号和密码,`bigqmt_signal_trader_local_config.py` / `bigqmt_signal_trader_client_config.py` 已在 `.gitignore`,**不要提交**。
|
||||
- 请求负载经过 base64 + 数字混淆编码(`encode_rpc_request_payload`),避免 QMT 的 Redis 客户端拦截含股票代码的明文。
|
||||
|
||||
---
|
||||
|
||||
## AI 助手 Skill:qmt-trader(大模型直接操作 QMT)
|
||||
|
||||
仓库内置一个 **Agent Skill**——[qmt-trader/](qmt-trader/),让支持 SKILL.md 约定的 AI 编程助手(Claude Code / ZCode / Cursor / Codex 等)**直接用命令行驱动 QMT 的全部交易与行情能力**,无需每次现场写 Python 调用代码。人也可以脱离 AI 手动执行其中的 CLI 脚本。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
qmt-trader/
|
||||
├── SKILL.md skill 说明书(触发条件 + 命令速查 + 典型工作流 + 安全须知)
|
||||
├── scripts/qmt.py 统一 CLI 入口(46 个子命令 + 通用 rpc 兜底,约 1000 行)
|
||||
└── references/api_reference.md 完整 API 参考(参数/返回值/常量/已知陷阱)
|
||||
```
|
||||
|
||||
### 工作原理
|
||||
|
||||
- AI 助手匹配到 `SKILL.md` 里的 `description`("查行情 / 查持仓 / 下单 / 龙虎榜 / 北向资金…时触发")后自动加载本 skill;
|
||||
- 之后助手调用 `python qmt-trader/scripts/qmt.py <子命令>` 执行**确定性命令**,不再临时生成 RPC 调用代码,避免参数写错;
|
||||
- 所有命令默认输出 JSON(`ok` / `data` / `ts` 三字段,便于模型解析),加 `--table` 切换人类可读表格;出错时返回 `ok: false` + `error` / `detail` / `code`,退出码 1;
|
||||
- `qmt.py` 自动把仓库 `src/` 加入 `sys.path`(开发模式免 pip install),并自动发现 QMT 的 python 目录读取客户端配置。
|
||||
|
||||
### 启用方式
|
||||
|
||||
**方式 A:安装到 AI 助手的 skills 目录**(推荐,全局生效):
|
||||
|
||||
```powershell
|
||||
# Claude Code
|
||||
cp -r qmt-trader ~/.claude/skills/qmt-trader
|
||||
# ZCode / 其他遵循 agents skills 约定的助手
|
||||
cp -r qmt-trader ~/.agents/skills/qmt-trader
|
||||
```
|
||||
|
||||
安装后正常提需求即可,例如"帮我看下工商银行最近的走势""我账户现在什么持仓",助手会自动触发。
|
||||
|
||||
**方式 B:不安装,对话里显式指定**:
|
||||
|
||||
> 阅读 qmt-trader/SKILL.md,之后用里面的 qmt.py 命令帮我查行情 / 持仓 / 下单。
|
||||
|
||||
**方式 C:纯手动**(不经过 AI,人直接当 CLI 用):
|
||||
|
||||
```powershell
|
||||
python qmt-trader/scripts/qmt.py ping
|
||||
python qmt-trader/scripts/qmt.py snapshot --table
|
||||
```
|
||||
|
||||
### 前置条件
|
||||
|
||||
与「快速开始」的客户端一致:
|
||||
|
||||
1. QMT 端 RPC 服务已启动(`BIGQMT_REDIS_DRYRUN.py` 运行中,输出面板/日志看到启动诊断 OK);
|
||||
2. 客户端配置就绪——环境变量(`BIGQMT_ACCOUNT_ID` / `BIGQMT_REDIS_HOST` / `BIGQMT_REDIS_PORT` / `BIGQMT_REDIS_DB` / `BIGQMT_REDIS_PASSWORD`)或配置文件;
|
||||
3. 先 `ping` 确认连通:redis 约 13ms / zmq 约 0.7ms 为正常,超时说明 transport 或配置不匹配。
|
||||
|
||||
### 一分钟上手
|
||||
|
||||
```powershell
|
||||
# 0. 连通性检测(含延迟测量)
|
||||
python qmt-trader/scripts/qmt.py ping
|
||||
|
||||
# 1. 账户全景:资产 + 持仓 + 委托 + 成交(一次往返)
|
||||
python qmt-trader/scripts/qmt.py snapshot
|
||||
|
||||
# 2. 实时五档盘口(含涨跌幅)
|
||||
python qmt-trader/scripts/qmt.py tick 600000.SH
|
||||
|
||||
# 3. 前复权日 K 60 根(含 MA5/20/60 统计)
|
||||
python qmt-trader/scripts/qmt.py kline 600000.SH --period 1d --count 60 --dividend front
|
||||
|
||||
# 4. 干跑下单(只打印不提交,确认参数)
|
||||
python qmt-trader/scripts/qmt.py buy 600000.SH 100 --price 7.50 --dry-run
|
||||
```
|
||||
|
||||
### 命令概览
|
||||
|
||||
| 分类 | 命令 |
|
||||
|------|------|
|
||||
| **连通/全景** | `ping` / `snapshot` |
|
||||
| **账户** | `account`(资产)/ `positions`(持仓含浮动盈亏)/ `orders`(委托含语义化状态)/ `trades`(成交) |
|
||||
| **行情** | `tick` / `kline` / `instrument` / `sector` / `trading-dates` / `north`(北向)/ `longhubang`(龙虎榜)/ `financial`(财务)/ `download`(历史数据下载)/ `quote-subscribe`(全推订阅) |
|
||||
| **扩展查询(25 个快捷命令)** | `holiday` / `stock-name` / `instrument-type` / `divid-factors` / `market-times` / `trading-calendar` / `option-list` / `bsm-price` / `bsm-iv` / `hkt-stats` / `hkt-details` / `hkt-rate` / `top10-holder` / `holder-num` / `ipo` / `ipo-limit` / `credit-assure` / `credit-short` / `credit-debt` / `his-st` / `index-weight` / `industry` / `sector-info` / `local-data` / `timetag2dt` / `dt2timetag` |
|
||||
| **交易** | `buy` / `sell` / `cancel`(均支持 `--dry-run`,buy/sell 支持 `--latest` / `--strategy` / `--remark`) |
|
||||
| **通用兜底** | `rpc <method> [json]` — 调用白名单内**任意**方法(如 `rpc get_l2_quote '{"stock_code":"600000.SH"}'`),未列出的方法都能这样调 |
|
||||
|
||||
### 安全设计
|
||||
|
||||
- 下单三命令(`buy` / `sell` / `cancel`)受服务端白名单控制,`rpc_allow_order_methods` 默认 `False`,未显式开启时返回 `ORDER_DISABLED`;
|
||||
- 下单前先用 `tick` 看价 + `--dry-run` 确认参数;
|
||||
- 报 `ORDER_TIMEOUT` 时**不要直接重试**,先 `orders` 查询确认委托是否已进系统,避免重复下单;
|
||||
- 下单的 `--strategy` 与查询的 `--strategy` 需一致;查全部委托用 `orders --strategy ""`(空 = 不过滤)。
|
||||
|
||||
完整命令表、四个典型工作流(行情分析 / 持仓监控 / 下单交易 / 批量分析)和 API 参数细节见 [qmt-trader/SKILL.md](qmt-trader/SKILL.md) 与 [qmt-trader/references/api_reference.md](qmt-trader/references/api_reference.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) — **版本变更记录**(新增/修复/变更)
|
||||
- [docs/RPC_API_REFERENCE.md](docs/RPC_API_REFERENCE.md) — **全部 RPC 方法参考**(参数、返回值、别名、大 QMT 能力边界)
|
||||
- [docs/FORMULA_SERVER_FASTPATH.md](docs/FORMULA_SERVER_FASTPATH.md) — FormulaServer(58600) 直连快速路径:协议、映射表、能力边界与回退行为
|
||||
- [docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md](docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md) — 全推行情订阅推送机制设计
|
||||
- [docs/SUBSCRIBE_WHOLE_QUOTE_LIVE_VERIFICATION.md](docs/SUBSCRIBE_WHOLE_QUOTE_LIVE_VERIFICATION.md) — 全推行情实盘验证报告
|
||||
- [docs/BIG_QMT_REDIS_RPC.md](docs/BIG_QMT_REDIS_RPC.md) — Redis RPC 协议与入口脚本详解
|
||||
- [docs/RPC_TRANSPORTS.md](docs/RPC_TRANSPORTS.md) — 可插拔传输层完整说明
|
||||
- [docs/XTQUANT_COMPAT_REPLACEMENT.md](docs/XTQUANT_COMPAT_REPLACEMENT.md) — 用兼容层替换旧 xtquant 的步骤
|
||||
- [docs/BIG_QMT_SIGNAL_TRADER_RUNBOOK.md](docs/BIG_QMT_SIGNAL_TRADER_RUNBOOK.md) — 信号交易运行手册
|
||||
- [docs/ZMQ_BACKTEST_BRIDGE.md](docs/ZMQ_BACKTEST_BRIDGE.md) — 独立 ZMQ 回测协议、撮合规则和 QMT 入口
|
||||
- [qmt-trader/](qmt-trader/) — **QMT Trader skill**:AI 助手统一 CLI 驱动全部 QMT API(46 子命令 + 通用 rpc 兜底),用法见上文「AI 助手 Skill:qmt-trader」专节
|
||||
|
||||
---
|
||||
|
||||
## 为什么不直接连大 QMT
|
||||
|
||||
官方 `xtquant.xttrader.XtQuantTrader` 依赖客户端侧 XtQuantServer 通道。当前国金大 QMT 环境中直接连 `connect()` 返回 `-1`,**交易能力**因此必须放在大 QMT 内部策略进程里,外部通过 RPC 驱动。
|
||||
|
||||
**但只读行情不必走 RPC。** `58600` 是 FormulaServer,它同时就是行情/参考数据服务——QMT 自带 Python 里的 `qmt_api` 包(`bin.x64/Lib/site-packages/qmt_api`)正是它的客户端。本仓库已接入这条直连快速路径,见上文「FormulaServer 直连快速路径」。
|
||||
|
||||
如果后续券商开通 XtQuantServer 权限且 `connect()==0`,可再加交易直连模式。
|
||||
@@ -0,0 +1,107 @@
|
||||
# coding: utf-8
|
||||
"""BigQMT Redis RPC latency benchmark.
|
||||
|
||||
Measures end-to-end latency for ping (no QMT API call) and get_full_tick
|
||||
(real ContextInfo call) to separate transport cost from API cost.
|
||||
"""
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import redis
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
|
||||
|
||||
def _load_redis_config():
|
||||
"""Pull connection details from the local client config or env vars.
|
||||
|
||||
Never hardcode secrets in the repo.
|
||||
"""
|
||||
try:
|
||||
from bigqmt_signal_trader.xtquant_compat import load_client_config
|
||||
|
||||
cfg = load_client_config()
|
||||
rc = dict(cfg.get("redis_config") or {})
|
||||
rc.setdefault("host", os.environ.get("BIGQMT_REDIS_HOST", "127.0.0.1"))
|
||||
rc.setdefault("port", int(os.environ.get("BIGQMT_REDIS_PORT", "6379")))
|
||||
rc.setdefault("db", int(os.environ.get("BIGQMT_REDIS_DB", "5")))
|
||||
return {
|
||||
"host": rc.get("host"),
|
||||
"port": int(rc.get("port")),
|
||||
"db": int(rc.get("db")),
|
||||
"username": rc.get("username") or None,
|
||||
"password": rc.get("password") or None,
|
||||
"socket_timeout": 8,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"host": os.environ.get("BIGQMT_REDIS_HOST", "127.0.0.1"),
|
||||
"port": int(os.environ.get("BIGQMT_REDIS_PORT", "6379")),
|
||||
"db": int(os.environ.get("BIGQMT_REDIS_DB", "5")),
|
||||
"socket_timeout": 8,
|
||||
}
|
||||
|
||||
|
||||
ACCOUNT = os.environ.get("BIGQMT_ACCOUNT_ID", "")
|
||||
REDIS = _load_redis_config()
|
||||
|
||||
|
||||
def bench(r, method, params, n=20, timeout=6):
|
||||
lats = []
|
||||
errors = 0
|
||||
for i in range(n):
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = call_redis_rpc(r, ACCOUNT, method, params, timeout_seconds=timeout)
|
||||
dt = (time.time() - t0) * 1000
|
||||
if resp.get("ok"):
|
||||
lats.append(dt)
|
||||
else:
|
||||
errors += 1
|
||||
if errors <= 2:
|
||||
print(" %s #%d error: %s" % (method, i, resp.get("error", "")[:120]))
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 2:
|
||||
print(" %s #%d exc: %s" % (method, i, e))
|
||||
if not lats:
|
||||
print("%-18s: ALL FAILED (%d errors)" % (method, errors))
|
||||
return
|
||||
lats.sort()
|
||||
p50 = statistics.median(lats)
|
||||
p95 = lats[int(len(lats) * 0.95)] if len(lats) >= 20 else lats[-1]
|
||||
print(
|
||||
"%-18s: n=%d ok=%d fail=%d min=%.0f p50=%.0f p95=%.0f max=%.0f avg=%.0f ms"
|
||||
% (
|
||||
method,
|
||||
len(lats),
|
||||
len(lats),
|
||||
errors,
|
||||
min(lats),
|
||||
p50,
|
||||
p95,
|
||||
max(lats),
|
||||
statistics.mean(lats),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
r = redis.Redis(**REDIS)
|
||||
# warmup
|
||||
try:
|
||||
call_redis_rpc(r, ACCOUNT, "ping", {}, timeout_seconds=6)
|
||||
print("warmup ping ok\n")
|
||||
except Exception as e:
|
||||
print("warmup FAILED: %s\n" % e)
|
||||
return
|
||||
|
||||
print("=== latency benchmark (20 calls each) ===")
|
||||
bench(r, "ping", {}, n=20)
|
||||
bench(r, "get_full_tick", {"codes": ["000001.SZ"]}, n=20)
|
||||
bench(r, "get_full_tick", {"codes": ["000001.SZ", "600000.SH", "000333.SZ"]}, n=20)
|
||||
bench(r, "get_instrument", {"code": "000001.SZ"}, n=20)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,151 @@
|
||||
# coding: utf-8
|
||||
"""Compare end-to-end RPC latency across transports.
|
||||
|
||||
Runs the same ping workload through:
|
||||
* Redis (real server, the production path) via call_redis_rpc
|
||||
* ZMQ (local tcp loopback, the low-latency path) via ZmqTransport
|
||||
|
||||
Prints a side-by-side min/p50/p90/p99/max comparison. The ZMQ leg spins up a
|
||||
local in-process server so no QMT process is needed for the comparison.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import statistics
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import redis
|
||||
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
from bigqmt_signal_trader.transports.zmq_transport import ZmqTransport
|
||||
|
||||
|
||||
def _load_redis_config():
|
||||
"""Pull connection details from the local client config or env vars."""
|
||||
try:
|
||||
from bigqmt_signal_trader.xtquant_compat import load_client_config
|
||||
|
||||
cfg = load_client_config()
|
||||
rc = dict(cfg.get("redis_config") or {})
|
||||
rc.setdefault("host", os.environ.get("BIGQMT_REDIS_HOST", "127.0.0.1"))
|
||||
rc.setdefault("port", int(os.environ.get("BIGQMT_REDIS_PORT", "6379")))
|
||||
rc.setdefault("db", int(os.environ.get("BIGQMT_REDIS_DB", "5")))
|
||||
return {
|
||||
"host": rc.get("host"),
|
||||
"port": int(rc.get("port")),
|
||||
"db": int(rc.get("db")),
|
||||
"username": rc.get("username") or None,
|
||||
"password": rc.get("password") or None,
|
||||
"socket_timeout": 8,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"host": os.environ.get("BIGQMT_REDIS_HOST", "127.0.0.1"),
|
||||
"port": int(os.environ.get("BIGQMT_REDIS_PORT", "6379")),
|
||||
"db": int(os.environ.get("BIGQMT_REDIS_DB", "5")),
|
||||
"socket_timeout": 8,
|
||||
}
|
||||
|
||||
|
||||
REDIS = _load_redis_config()
|
||||
ACCOUNT = os.environ.get("BIGQMT_ACCOUNT_ID", "")
|
||||
|
||||
|
||||
def _free_port():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
def _stats(name, lats):
|
||||
lats = sorted(lats)
|
||||
n = len(lats)
|
||||
print(
|
||||
"%-8s n=%d min=%.2f p50=%.2f p90=%.2f p99=%.2f max=%.2f avg=%.2f ms"
|
||||
% (
|
||||
name,
|
||||
n,
|
||||
min(lats),
|
||||
statistics.median(lats),
|
||||
lats[int(n * 0.9)],
|
||||
lats[int(n * 0.99)] if n > 1 else lats[-1],
|
||||
max(lats),
|
||||
statistics.mean(lats),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def bench_redis(n):
|
||||
r = redis.Redis(**REDIS)
|
||||
# warmup + connectivity
|
||||
try:
|
||||
call_redis_rpc(r, ACCOUNT, "ping", {}, timeout_seconds=6)
|
||||
except Exception as e:
|
||||
print("Redis server not reachable, skipping redis leg: %s" % e)
|
||||
return
|
||||
lats = []
|
||||
for _ in range(n):
|
||||
t0 = time.time()
|
||||
call_redis_rpc(r, ACCOUNT, "ping", {}, timeout_seconds=6)
|
||||
lats.append((time.time() - t0) * 1000)
|
||||
_stats("redis", lats)
|
||||
|
||||
|
||||
def bench_zmq(n):
|
||||
port = _free_port()
|
||||
addr = "tcp://127.0.0.1:%d" % port
|
||||
|
||||
def on_req(req):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"request_id": req["request_id"],
|
||||
"account_id": "zmq",
|
||||
"method": req["method"],
|
||||
"ok": True,
|
||||
"data": {"pong": True},
|
||||
"error": "",
|
||||
"handled_at": "now",
|
||||
}
|
||||
|
||||
server = ZmqTransport(bind_address=addr, account_id="zmq", recv_timeout_seconds=0.3)
|
||||
server.start_receiving(on_req, background_threads=True)
|
||||
time.sleep(0.3)
|
||||
client = ZmqTransport(connect_address=addr, account_id="zmq")
|
||||
time.sleep(0.2)
|
||||
lats = []
|
||||
for _ in range(n):
|
||||
req = {
|
||||
"schema_version": 1,
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"account_id": "zmq",
|
||||
"method": "ping",
|
||||
"params": {},
|
||||
"reply_channel": "",
|
||||
"reply_list": "",
|
||||
"reply_key": "",
|
||||
"ttl_seconds": 5,
|
||||
}
|
||||
t0 = time.time()
|
||||
client.send_request(req, timeout_seconds=3.0)
|
||||
lats.append((time.time() - t0) * 1000)
|
||||
_stats("zmq", lats)
|
||||
server.stop()
|
||||
client.stop()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("-n", "--count", type=int, default=100, help="requests per transport")
|
||||
ap.add_argument("--skip-redis", action="store_true", help="skip the redis leg")
|
||||
args = ap.parse_args()
|
||||
print("=== transport latency comparison (n=%d each) ===" % args.count)
|
||||
if not args.skip_redis:
|
||||
bench_redis(args.count)
|
||||
bench_zmq(args.count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
# coding: utf-8
|
||||
"""Measure ZMQ GIL-spike rate at different request rates.
|
||||
|
||||
Sends N get_full_tick requests at a fixed interval, records per-call
|
||||
latency, reports how many exceed thresholds (50/200/500/1000ms).
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, r"D:\gjzqqmt\xtquant_big_convert\src")
|
||||
sys.path.insert(0, r"D:\国金证券QMT交易端_lemo\python")
|
||||
|
||||
import bigqmt_signal_trader.xtquant_compat as compat
|
||||
|
||||
compat.configure()
|
||||
client = compat.get_default_client()
|
||||
print("account:", client.account_id, "| transport:", client.transport_name)
|
||||
print("=" * 60)
|
||||
|
||||
N = 40
|
||||
INTERVAL_MS = 50 # 请求间隔 50ms = 20 QPS
|
||||
|
||||
latencies = []
|
||||
for i in range(N):
|
||||
t0 = time.time()
|
||||
try:
|
||||
client.call("get_full_tick", {"codes": ["000001.SZ"]})
|
||||
ms = (time.time() - t0) * 1000
|
||||
latencies.append(ms)
|
||||
except Exception as e:
|
||||
ms = (time.time() - t0) * 1000
|
||||
latencies.append(ms)
|
||||
print(" [%2d] FAIL %.0fms %s" % (i, ms, str(e)[:40]))
|
||||
# 控制频率
|
||||
elapsed = (time.time() - t0)
|
||||
sleep = max(0, INTERVAL_MS / 1000.0 - elapsed)
|
||||
if sleep > 0:
|
||||
time.sleep(sleep)
|
||||
|
||||
latencies.sort()
|
||||
n = len(latencies)
|
||||
print("\n=== %d requests @ %dms interval (%.0f QPS) ===" % (n, INTERVAL_MS, 1000.0/INTERVAL_MS))
|
||||
print("min=%.1f p50=%.1f p90=%.1f p99=%.1f max=%.1f" % (
|
||||
latencies[0], latencies[n//2], latencies[int(n*0.9)], latencies[int(n*0.99)], latencies[-1]))
|
||||
|
||||
# 尖峰分布
|
||||
thresholds = [10, 50, 100, 200, 500, 1000]
|
||||
print("\n=== 延迟分布 ===")
|
||||
for t in thresholds:
|
||||
cnt = sum(1 for l in latencies if l > t)
|
||||
print(" >%5dms : %2d / %d (%.0f%%)" % (t, cnt, n, 100.0*cnt/n))
|
||||
@@ -0,0 +1,252 @@
|
||||
#coding:gbk
|
||||
"""QMT bridge entry (no-redis version).
|
||||
|
||||
Same file-loader pattern as BIGQMT_REDIS_DRYRUN, but the RPC transport is ZMQ
|
||||
only -- no redis imports anywhere. This version loads the no-redis zmq transport
|
||||
(bigqmt_no_redis/zmq_transport.py) which inlines all encoding helpers and drops
|
||||
redis-based service discovery, so it loads cleanly in QMT sandboxes that reject
|
||||
`import redis` or any redis-named module.
|
||||
|
||||
Use this when your QMT environment cannot import the redis package (e.g. broker
|
||||
whitelist blocks it) or when you want zero redis dependency.
|
||||
|
||||
Config: set "transport": "zmq" in bigqmt_signal_trader_local_config.py (the
|
||||
no-redis runtime forces zmq regardless). Redis config fields are ignored.
|
||||
"""
|
||||
import builtins as _builtins
|
||||
import importlib as _importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
_LOCAL_ROOTS = (
|
||||
"bigqmt_signal_trader",
|
||||
"bigqmt_signal_trader_strategy",
|
||||
"bigqmt_signal_trader_redis_rpc_runtime",
|
||||
"bigqmt_signal_trader_local_config",
|
||||
"bigqmt_no_redis",
|
||||
)
|
||||
_ORIGINAL_IMPORT = _builtins.__import__
|
||||
_ORIGINAL_IMPORT_MODULE = _importlib.import_module
|
||||
_ORIGINAL_RELOAD = _importlib.reload
|
||||
|
||||
|
||||
def _known_qmt_python_dir():
|
||||
# Find the QMT python dir from sys.path instead of a hardcoded path, so
|
||||
# the bridge loads regardless of broker install location or launch mode.
|
||||
for p in sys.path:
|
||||
if p and r"\python" in p and os.path.isdir(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
_SOURCE_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
except Exception:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
if not _SOURCE_ROOT:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
|
||||
|
||||
def _is_local_module(name):
|
||||
return any(name == root or name.startswith(root + ".") for root in _LOCAL_ROOTS)
|
||||
|
||||
|
||||
def _resolve_name(name, module_globals, level):
|
||||
if not level:
|
||||
return name
|
||||
package = (module_globals or {}).get("__package__") or (module_globals or {}).get("__name__", "")
|
||||
if not package:
|
||||
raise ImportError("relative import without package")
|
||||
for unused in range(level - 1):
|
||||
if "." not in package:
|
||||
raise ImportError("relative import beyond top-level package")
|
||||
package = package.rsplit(".", 1)[0]
|
||||
return package + ("." + name if name else "")
|
||||
|
||||
|
||||
def _find_local_source(name):
|
||||
relative = name.replace(".", os.sep)
|
||||
dirs = []
|
||||
if _SOURCE_ROOT:
|
||||
dirs.append(_SOURCE_ROOT)
|
||||
for p in sys.path:
|
||||
if p and os.path.isdir(p) and p not in dirs:
|
||||
dirs.append(p)
|
||||
for d in dirs:
|
||||
package_init = os.path.join(d, relative, "__init__.py")
|
||||
if os.path.isfile(package_init):
|
||||
return package_init, True
|
||||
module_file = os.path.join(d, relative + ".py")
|
||||
if os.path.isfile(module_file):
|
||||
return module_file, False
|
||||
raise ModuleNotFoundError("local source not found: %s" % name, name=name)
|
||||
|
||||
|
||||
def _set_parent_attribute(name, module):
|
||||
if "." not in name:
|
||||
return
|
||||
parent_name, child_name = name.rsplit(".", 1)
|
||||
parent = _load_local_module(parent_name)
|
||||
setattr(parent, child_name, module)
|
||||
|
||||
|
||||
def _load_local_module(name):
|
||||
existing = sys.modules.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
source_path, is_package = _find_local_source(name)
|
||||
if "." in name:
|
||||
_load_local_module(name.rsplit(".", 1)[0])
|
||||
module = types.ModuleType(name)
|
||||
module.__file__ = source_path
|
||||
module.__package__ = name if is_package else name.rpartition(".")[0]
|
||||
if is_package:
|
||||
module.__path__ = [os.path.dirname(source_path)]
|
||||
module_builtins = dict(_builtins.__dict__)
|
||||
module_builtins["__import__"] = _local_import
|
||||
module.__dict__["__builtins__"] = module_builtins
|
||||
module.__dict__["__bigqmt_load_local_module"] = _load_local_module
|
||||
sys.modules[name] = module
|
||||
if name == "bigqmt_signal_trader":
|
||||
return module
|
||||
try:
|
||||
with open(source_path, "rb") as source_file:
|
||||
source = source_file.read()
|
||||
exec(compile(source, source_path, "exec"), module.__dict__)
|
||||
except Exception:
|
||||
sys.modules.pop(name, None)
|
||||
raise
|
||||
_set_parent_attribute(name, module)
|
||||
return module
|
||||
|
||||
|
||||
def _local_import(name, module_globals=None, module_locals=None, fromlist=(), level=0):
|
||||
absolute_name = _resolve_name(name, module_globals, level)
|
||||
if not _is_local_module(absolute_name):
|
||||
return _ORIGINAL_IMPORT(name, module_globals, module_locals, fromlist, level)
|
||||
module = _load_local_module(absolute_name)
|
||||
for child in fromlist or ():
|
||||
if child != "*":
|
||||
try:
|
||||
_load_local_module(absolute_name + "." + child)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
if fromlist:
|
||||
return module
|
||||
return _load_local_module(absolute_name.split(".", 1)[0])
|
||||
|
||||
|
||||
def _local_import_module(name, package=None):
|
||||
if _is_local_module(name):
|
||||
return _load_local_module(name)
|
||||
return _ORIGINAL_IMPORT_MODULE(name, package)
|
||||
|
||||
|
||||
def _local_reload(module):
|
||||
if _is_local_module(getattr(module, "__name__", "")):
|
||||
return _load_local_module(module.__name__)
|
||||
return _ORIGINAL_RELOAD(module)
|
||||
|
||||
|
||||
def _clear_local_modules():
|
||||
for name in list(sys.modules):
|
||||
if _is_local_module(name):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def _stop_previous_rpc_service():
|
||||
"""Release the previous QMT strategy's socket before clearing its module."""
|
||||
previous = sys.modules.get("bigqmt_signal_trader_strategy")
|
||||
reset = getattr(previous, "reset_app", None)
|
||||
if not callable(reset):
|
||||
return
|
||||
try:
|
||||
reset()
|
||||
print("[bigqmt_shell] previous rpc service stopped")
|
||||
except Exception as exc:
|
||||
print("[bigqmt_shell] previous rpc service stop failed: %s" % exc)
|
||||
|
||||
|
||||
_stop_previous_rpc_service()
|
||||
_clear_local_modules()
|
||||
_importlib.import_module = _local_import_module
|
||||
_importlib.reload = _local_reload
|
||||
print("[bigqmt_shell] importlib entry source_root=%s" % _SOURCE_ROOT)
|
||||
|
||||
|
||||
def _fallback_account_id():
|
||||
for name in ("BIGQMT_ACCOUNT_ID", "account", "account_id", "accountID"):
|
||||
value = globals().get(name)
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
_local_import("bigqmt_signal_trader.adapters.market_bigqmt", globals(), fromlist=("*",))
|
||||
_local_import("bigqmt_signal_trader.adapters.order_bigqmt", globals(), fromlist=("*",))
|
||||
_local_import("bigqmt_signal_trader.adapters.position_bigqmt", globals(), fromlist=("*",))
|
||||
_strategy = _local_import("bigqmt_signal_trader_strategy", globals(), fromlist=("*",))
|
||||
_strategy.reset_app()
|
||||
except Exception as bridge_preload_error:
|
||||
print("[bigqmt_shell] bridge preload failed: %s" % bridge_preload_error)
|
||||
|
||||
_runtime = _local_import("bigqmt_signal_trader_redis_rpc_runtime", globals(), fromlist=("*",))
|
||||
|
||||
|
||||
def _load_local_config():
|
||||
return _local_import("bigqmt_signal_trader_local_config", globals(), fromlist=("*",))
|
||||
|
||||
|
||||
try:
|
||||
_config = _load_local_config()
|
||||
BIGQMT_REDIS_CONFIG = getattr(_config, "BIGQMT_REDIS_CONFIG", {})
|
||||
# Force zmq transport (this is the no-redis version).
|
||||
BIGQMT_REDIS_CONFIG = dict(BIGQMT_REDIS_CONFIG or {})
|
||||
BIGQMT_REDIS_CONFIG["transport"] = "zmq"
|
||||
BIGQMT_REDIS_CONFIG["rpc_background_threads"] = True
|
||||
print("[bigqmt_shell] no-redis mode: transport=zmq background_threads=True")
|
||||
_runtime.configure_runtime_redis(BIGQMT_REDIS_CONFIG)
|
||||
except Exception as redis_config_error:
|
||||
print("[bigqmt_shell] local redis config load failed: %s" % redis_config_error)
|
||||
|
||||
try:
|
||||
_config = _load_local_config()
|
||||
BIGQMT_ACCOUNT_ID = getattr(_config, "BIGQMT_ACCOUNT_ID", "")
|
||||
print("[bigqmt_shell] local account config loaded=%s" % bool(BIGQMT_ACCOUNT_ID))
|
||||
_runtime.configure_runtime_account(BIGQMT_ACCOUNT_ID)
|
||||
except Exception as account_config_error:
|
||||
print("[bigqmt_shell] local account config load failed: %s" % account_config_error)
|
||||
account_id = _fallback_account_id()
|
||||
if account_id:
|
||||
_runtime.configure_runtime_account(account_id)
|
||||
|
||||
try:
|
||||
qmt_extra = {}
|
||||
for function_name in (
|
||||
"get_history_trade_detail_data", "get_value_by_order_id", "get_last_order_id",
|
||||
"get_ipo_data", "get_new_purchase_limit", "get_assure_contract",
|
||||
"get_enable_short_contract", "get_unclosed_compacts", "get_closed_compacts",
|
||||
"get_debt_contract", "get_option_subject_position", "get_comb_option",
|
||||
"get_hkt_exchange_rate", "down_history_data",
|
||||
):
|
||||
if function_name in globals():
|
||||
qmt_extra[function_name] = globals()[function_name]
|
||||
print("[bigqmt_shell] down_history_data bound=%s" % ("down_history_data" in qmt_extra))
|
||||
_runtime.bind_runtime_api(
|
||||
passorder_func=globals().get("passorder"),
|
||||
cancel_func=globals().get("cancel"),
|
||||
get_trade_detail_data_func=globals().get("get_trade_detail_data"),
|
||||
extra_funcs=qmt_extra or None,
|
||||
)
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
|
||||
init = _runtime.init
|
||||
handlebar = _runtime.handlebar
|
||||
adjust = _runtime.adjust
|
||||
order_callback = _runtime.order_callback
|
||||
deal_callback = _runtime.deal_callback
|
||||
@@ -0,0 +1,459 @@
|
||||
"""ZeroMQ transport for the BigQMT RPC bridge (no-redis version).
|
||||
|
||||
Same as bigqmt_signal_trader.transports.zmq_transport, but with the redis
|
||||
dependencies inlined and the redis-based service discovery removed. This lets
|
||||
the module load in QMT sandboxes that reject `import redis` or any module
|
||||
whose name mentions redis.
|
||||
|
||||
Design (unchanged from the redis version):
|
||||
|
||||
* **Server** binds a ``ROUTER`` socket. Each inbound message arrives as
|
||||
``[identity, payload]``; the server remembers ``identity`` keyed by
|
||||
``request_id`` and replies with ``[identity, payload]`` so ZMQ routes the
|
||||
response back to the originating client automatically.
|
||||
* **Client** connects a ``DEALER`` socket (with a unique random identity), sends
|
||||
``[payload]``, then ``poll``/``recv`` for the response.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inlined encoding helpers (originally from bigqmt_signal_trader.adapters.
|
||||
# redis_common and bigqmt_signal_trader.redis_rpc). Kept here so this module
|
||||
# has zero imports from any redis-named module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAFE_B64_PREFIX = "b64s:"
|
||||
SAFE_B64_DIGIT_ENCODE = str.maketrans("0123456789", "!#$%&()*~?")
|
||||
SAFE_B64_DIGIT_DECODE = str.maketrans("!#$%&()*~?", "0123456789")
|
||||
|
||||
|
||||
def decode_text(value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
def encode_rpc_request_payload(request):
|
||||
"""Encode request JSON so patched QMT clients do not inspect stock-code text."""
|
||||
raw = json.dumps(request, ensure_ascii=False).encode("utf-8")
|
||||
encoded = base64.b64encode(raw).decode("ascii").translate(SAFE_B64_DIGIT_ENCODE)
|
||||
return SAFE_B64_PREFIX + encoded
|
||||
|
||||
|
||||
def decode_rpc_request_payload(text):
|
||||
text = str(text)
|
||||
if not text.startswith(SAFE_B64_PREFIX):
|
||||
return text
|
||||
encoded = text[len(SAFE_B64_PREFIX):].translate(SAFE_B64_DIGIT_DECODE)
|
||||
return base64.b64decode(encoded.encode("ascii")).decode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TransportError / TransportTimeout (inlined from transports.base -- kept here
|
||||
# so this module is fully self-contained for QMT sandbox loading).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TransportTimeout(TransportError):
|
||||
pass
|
||||
|
||||
|
||||
class RpcTransport:
|
||||
"""Minimal transport base (inlined subset of transports.base)."""
|
||||
|
||||
def __init__(self, account_id="", print_prefix="[bigqmt_rpc]"):
|
||||
self.account_id = str(account_id or "")
|
||||
self.print_prefix = str(print_prefix or "[bigqmt_rpc]")
|
||||
self._on_request = None
|
||||
self._running = False
|
||||
|
||||
def start_receiving(self, on_request):
|
||||
self._on_request = on_request
|
||||
self._running = True
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
self._on_request = None
|
||||
|
||||
def deliver(self, request):
|
||||
callback = self._on_request
|
||||
if callback is None:
|
||||
return None
|
||||
try:
|
||||
response = callback(request)
|
||||
except Exception as exc:
|
||||
import datetime as _dt
|
||||
response = {
|
||||
"schema_version": 1,
|
||||
"request_id": str((request or {}).get("request_id") or ""),
|
||||
"account_id": str((request or {}).get("account_id") or self.account_id or ""),
|
||||
"method": str((request or {}).get("method") or ""),
|
||||
"ok": False,
|
||||
"data": None,
|
||||
"error": "%s: %s" % (exc.__class__.__name__, exc),
|
||||
"handled_at": _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
if response is not None:
|
||||
try:
|
||||
self.send_response(request, response)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ZMQ transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ZMQ does not support ipc:// on Windows (it trips a signaler abort), so the
|
||||
# default endpoint is tcp loopback. The port is derived from the account_id so
|
||||
# distinct accounts don't collide on the same port; override via config when
|
||||
# needed. Base 15560 keeps it clear of common dev ports.
|
||||
DEFAULT_ZMQ_HOST = "127.0.0.1"
|
||||
DEFAULT_ZMQ_BASE_PORT = 15560
|
||||
DEFAULT_ZMQ_PORT_RANGE = 100 # derived port = base + (account_id_int mod range)
|
||||
|
||||
|
||||
def _default_zmq_port(account_id):
|
||||
"""Derive a stable port from account_id so each account gets its own socket."""
|
||||
text = str(account_id or "")
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
try:
|
||||
offset = int(digits) % DEFAULT_ZMQ_PORT_RANGE if digits else 0
|
||||
except ValueError:
|
||||
offset = 0
|
||||
return DEFAULT_ZMQ_BASE_PORT + offset
|
||||
|
||||
|
||||
def _default_zmq_address(account_id, host=None):
|
||||
host = host or DEFAULT_ZMQ_HOST
|
||||
return "tcp://%s:%d" % (host, _default_zmq_port(account_id))
|
||||
|
||||
|
||||
def _loads(raw):
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
text = decode_text(raw)
|
||||
text = decode_rpc_request_payload(text)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
class ZmqTransport(RpcTransport):
|
||||
"""ZMQ ROUTER/DEALER transport (no-redis version).
|
||||
|
||||
The same instance plays both roles depending on method called:
|
||||
``send_request`` acts as a client (DEALER connect), ``start_receiving`` +
|
||||
``send_response`` act as a server (ROUTER bind). A deployment normally uses
|
||||
one instance per role (the QMT process is the server; the external client
|
||||
is the client).
|
||||
|
||||
Unlike the redis version, this one does NOT use redis-based service
|
||||
discovery. The server binds the configured address exactly; the client
|
||||
connects to the configured or derived address directly.
|
||||
"""
|
||||
|
||||
name = "zmq"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bind_address=None,
|
||||
connect_address=None,
|
||||
host=None,
|
||||
port=None,
|
||||
account_id="",
|
||||
print_prefix="[bigqmt_rpc]",
|
||||
io_threads=1,
|
||||
recv_timeout_seconds=1.0,
|
||||
server_hwm=10000,
|
||||
client_linger_ms=0,
|
||||
):
|
||||
super(ZmqTransport, self).__init__(account_id=account_id, print_prefix=print_prefix)
|
||||
resolved_host = host or DEFAULT_ZMQ_HOST
|
||||
if port is not None:
|
||||
resolved_port = int(port)
|
||||
else:
|
||||
resolved_port = _default_zmq_port(account_id)
|
||||
default_addr = "tcp://%s:%d" % (resolved_host, resolved_port)
|
||||
self.bind_address = bind_address or default_addr
|
||||
self.connect_address = connect_address
|
||||
self.bind_host = resolved_host
|
||||
self.base_port = resolved_port
|
||||
self.io_threads = int(io_threads)
|
||||
self.recv_timeout_seconds = float(recv_timeout_seconds)
|
||||
self.server_hwm = int(server_hwm)
|
||||
self.client_linger_ms = int(client_linger_ms)
|
||||
|
||||
self._zmq = None # imported lazily
|
||||
self._ctx = None
|
||||
# server state
|
||||
self._router = None
|
||||
self._router_thread = None
|
||||
self._actual_bind_address = None # set after start_receiving()
|
||||
self._pending_identities = {} # request_id -> client identity bytes
|
||||
self._identity_lock = threading.Lock()
|
||||
self._response_queue = queue.Queue()
|
||||
self._queued_response_count = 0
|
||||
self._sent_response_count = 0
|
||||
# client state
|
||||
self._dealer = None
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
# -- construction helper ----------------------------------------------
|
||||
@classmethod
|
||||
def from_config(cls, config, account_id="", print_prefix="[bigqmt_rpc]"):
|
||||
config = dict(config or {})
|
||||
return cls(
|
||||
bind_address=config.get("bind_address"),
|
||||
connect_address=config.get("connect_address"),
|
||||
host=config.get("host"),
|
||||
port=config.get("port"),
|
||||
account_id=config.get("account_id", account_id),
|
||||
print_prefix=print_prefix,
|
||||
io_threads=int(config.get("io_threads", 1)),
|
||||
recv_timeout_seconds=float(config.get("recv_timeout_seconds", 1.0)),
|
||||
server_hwm=int(config.get("server_hwm", 10000)),
|
||||
client_linger_ms=int(config.get("client_linger_ms", 0)),
|
||||
)
|
||||
|
||||
# -- shared zmq context -----------------------------------------------
|
||||
def _ensure_zmq(self):
|
||||
if self._zmq is None:
|
||||
try:
|
||||
import zmq # noqa: F401
|
||||
except ImportError as exc: # pragma: no cover - depends on env
|
||||
raise TransportError(
|
||||
"pyzmq is required for the zmq transport: %s" % exc
|
||||
)
|
||||
self._zmq = zmq
|
||||
if self._ctx is None:
|
||||
self._ctx = self._zmq.Context.instance(self.io_threads)
|
||||
return self._zmq, self._ctx
|
||||
|
||||
# -- server side ------------------------------------------------------
|
||||
def _bind_configured_address(self):
|
||||
"""Bind exactly one configured address and reject duplicate servers."""
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
sock = ctx.socket(zmq.ROUTER)
|
||||
sock.setsockopt(zmq.RCVHWM, self.server_hwm)
|
||||
sock.setsockopt(zmq.SNDHWM, self.server_hwm)
|
||||
sock.setsockopt(zmq.RCVTIMEO, int(self.recv_timeout_seconds * 1000))
|
||||
try:
|
||||
sock.bind(self.bind_address)
|
||||
except self._zmq.ZMQError as exc:
|
||||
try:
|
||||
sock.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(exc, "errno", None) == zmq.EADDRINUSE:
|
||||
raise TransportError(
|
||||
"ZMQ_BIND_CONFLICT address=%s; another bridge instance "
|
||||
"already owns the configured endpoint" % self.bind_address
|
||||
)
|
||||
raise
|
||||
self._router = sock
|
||||
self._actual_bind_address = self.bind_address
|
||||
|
||||
def start_receiving(self, on_request, background_threads=True):
|
||||
super(ZmqTransport, self).start_receiving(on_request)
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
self._bind_configured_address()
|
||||
bound = self._actual_bind_address or self.bind_address
|
||||
if not background_threads:
|
||||
print(
|
||||
"%s zmq bound=%s background_threads=False"
|
||||
% (self.print_prefix, bound)
|
||||
)
|
||||
return
|
||||
self._router_thread = threading.Thread(
|
||||
target=self._router_loop, name="bigqmt-zmq-rpc", daemon=True
|
||||
)
|
||||
self._router_thread.start()
|
||||
print(
|
||||
"%s zmq started bound=%s" % (self.print_prefix, self.bind_address)
|
||||
)
|
||||
|
||||
def _router_loop(self):
|
||||
try:
|
||||
while self._running:
|
||||
self._drain_response_queue()
|
||||
request = self._receive_request()
|
||||
if request is not None:
|
||||
self._deliver_request(request)
|
||||
finally:
|
||||
# Close the ROUTER socket on the thread that owns it. On Windows,
|
||||
# closing a ZMQ socket from a different thread trips a signaler
|
||||
# assertion (abort); closing it here is safe because this thread
|
||||
# created and exclusively used it.
|
||||
try:
|
||||
self._router.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
self._router = None
|
||||
|
||||
def _receive_request(self, flags=0):
|
||||
try:
|
||||
frames = self._router.recv_multipart(flags=flags)
|
||||
except self._zmq.Again:
|
||||
return None
|
||||
except Exception as exc:
|
||||
if self._running:
|
||||
print("%s zmq recv failed: %s" % (self.print_prefix, exc))
|
||||
if not flags:
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
if len(frames) < 2:
|
||||
return None
|
||||
identity, payload = frames[0], frames[-1]
|
||||
try:
|
||||
request = _loads(payload)
|
||||
except Exception as exc:
|
||||
print("%s zmq decode failed: %s" % (self.print_prefix, exc))
|
||||
return None
|
||||
request_id = str(request.get("request_id") or uuid.uuid4().hex)
|
||||
with self._identity_lock:
|
||||
self._pending_identities[request_id] = identity
|
||||
return request
|
||||
|
||||
def _deliver_request(self, request):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
self.deliver(request)
|
||||
except Exception as exc:
|
||||
print("%s zmq deliver failed: %s" % (self.print_prefix, exc))
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
||||
if elapsed_ms > 50.0:
|
||||
print("%s zmq slow handler method=%s %.0fms"
|
||||
% (self.print_prefix, request.get("method"), elapsed_ms))
|
||||
|
||||
def _drain_response_queue(self):
|
||||
while True:
|
||||
try:
|
||||
identity, payload = self._response_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
try:
|
||||
self._router.send_multipart([identity, payload])
|
||||
self._sent_response_count += 1
|
||||
if self._sent_response_count <= 5:
|
||||
print("%s zmq queued response sent" % self.print_prefix)
|
||||
except Exception as exc:
|
||||
print("%s zmq send failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
def send_response(self, request, response):
|
||||
if self._router is None:
|
||||
raise TransportError("zmq server socket is not bound")
|
||||
request_id = str(
|
||||
response.get("request_id") or request.get("request_id") or ""
|
||||
)
|
||||
with self._identity_lock:
|
||||
identity = self._pending_identities.pop(request_id, None)
|
||||
if identity is None:
|
||||
# No matching peer -- drop silently (client may have gone away).
|
||||
return
|
||||
payload = encode_rpc_request_payload(response).encode("utf-8")
|
||||
if self._router_thread is not None and threading.current_thread() is not self._router_thread:
|
||||
self._queued_response_count += 1
|
||||
if self._queued_response_count <= 5:
|
||||
print("%s zmq response queued for router thread" % self.print_prefix)
|
||||
self._response_queue.put((identity, payload))
|
||||
return
|
||||
try:
|
||||
self._router.send_multipart([identity, payload])
|
||||
except Exception as exc:
|
||||
print("%s zmq send failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
def drain_request_queue(self, max_items=20):
|
||||
"""Drain requests from the scheduled QMT thread when no receiver thread exists."""
|
||||
if self._router_thread is not None or self._router is None:
|
||||
return 0
|
||||
processed = 0
|
||||
for _index in range(max(int(max_items), 0)):
|
||||
request = self._receive_request(flags=self._zmq.NOBLOCK)
|
||||
if request is None:
|
||||
break
|
||||
self._deliver_request(request)
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
# -- client side ------------------------------------------------------
|
||||
def _resolve_connect_address(self):
|
||||
"""Resolve the address to connect to. No redis discovery -- use explicit
|
||||
connect_address, else derive from account_id."""
|
||||
if self.connect_address:
|
||||
return self.connect_address
|
||||
return _default_zmq_address(self.account_id)
|
||||
|
||||
def _ensure_dealer(self):
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
if self._dealer is None:
|
||||
address = self._resolve_connect_address()
|
||||
sock = ctx.socket(zmq.DEALER)
|
||||
# Unique identity so ROUTER can route replies back to us.
|
||||
sock.setsockopt(zmq.IDENTITY, uuid.uuid4().hex.encode("utf-8")[:16])
|
||||
sock.setsockopt(zmq.LINGER, self.client_linger_ms)
|
||||
sock.connect(address)
|
||||
self._dealer = sock
|
||||
self.connect_address = address
|
||||
return self._dealer
|
||||
|
||||
def send_request(self, request, timeout_seconds, **_kwargs):
|
||||
zmq = self._zmq or self._ensure_zmq()[0]
|
||||
with self._client_lock:
|
||||
dealer = self._ensure_dealer()
|
||||
request = dict(request)
|
||||
request.setdefault("request_id", uuid.uuid4().hex)
|
||||
request_id = request["request_id"]
|
||||
payload = encode_rpc_request_payload(request)
|
||||
try:
|
||||
dealer.send(payload.encode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise TransportError("zmq send failed: %s" % exc)
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
poller = self._zmq.Poller()
|
||||
poller.register(dealer, self._zmq.POLLIN)
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
events = dict(poller.poll(timeout=int(remaining * 1000)))
|
||||
if dealer in events:
|
||||
frames = dealer.recv_multipart()
|
||||
raw = frames[-1]
|
||||
response = _loads(raw)
|
||||
if response.get("request_id") == request_id:
|
||||
return response
|
||||
raise TransportTimeout("zmq rpc timeout: %s" % request.get("method"))
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
def stop(self):
|
||||
super(ZmqTransport, self).stop()
|
||||
# Clear _running so the router loop exits; the loop closes its own
|
||||
# socket (closing cross-thread trips a Windows signaler abort).
|
||||
thread = self._router_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(2.0)
|
||||
if thread is None and self._router is not None:
|
||||
try:
|
||||
self._router.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
self._router = None
|
||||
self._router_thread = None
|
||||
with self._client_lock:
|
||||
if self._dealer is not None:
|
||||
try:
|
||||
self._dealer.close(linger=self.client_linger_ms)
|
||||
except Exception:
|
||||
pass
|
||||
self._dealer = None
|
||||
# Do NOT terminate the shared context -- other sockets/users may rely on it.
|
||||
@@ -0,0 +1,409 @@
|
||||
# 大 QMT Redis Queue RPC 说明
|
||||
|
||||
更新时间:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
在大 QMT 策略进程内启动一个 Redis RPC 服务,用来远程调用少量白名单方法。实盘默认使用 Redis list queue + QMT `run_time("adjust", ...)` 调度 drain;请求 payload 会做安全编码,避免大 QMT 内置 Redis 客户端读取包含股票代码的 JSON 时触发 `Sensitive Data Detected`。
|
||||
|
||||
- `ping`
|
||||
- `get_ticks`
|
||||
- `get_instrument`
|
||||
- `get_market_data` / `get_market_data_ex` / `get_local_data`
|
||||
- `get_stock_list_in_sector` / `get_sector_list` / `get_sector_info`
|
||||
- `get_divid_factors` / `download_history_data` / `download_history_data2`
|
||||
- `get_trading_dates` / `get_holidays` / `download_holiday_data`
|
||||
- `get_ipo_info` / `get_etf_info` / `get_option_list`
|
||||
- `get_financial_data` / `download_financial_data`
|
||||
- `call_formula` / `subscribe_formula` / `unsubscribe_formula` / `get_formula_result` / `gen_factor_index`
|
||||
- `get_positions`
|
||||
- `get_asset`
|
||||
- `query_orders`
|
||||
- `query_trades`
|
||||
- `sync_positions`
|
||||
|
||||
下单类方法 `submit_order`、`cancel_order` 默认关闭,只有显式配置 `rpc_allow_order_methods=True` 后才会开放。
|
||||
|
||||
## MiniQMT 兼容方法名
|
||||
|
||||
RPC 服务端会把以下 MiniQMT 常用方法名映射到大 QMT 适配器:
|
||||
|
||||
| MiniQMT 方法名 | RPC 内部方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `query_stock_asset` | `get_asset` | 查询账户资产 |
|
||||
| `query_stock_positions` | `get_positions` | 查询全部持仓 |
|
||||
| `query_stock_position` | `query_stock_position` | 查询单只持仓,按 `stock_code` 过滤 |
|
||||
| `query_stock_orders` | `query_orders` | 查询委托;支持 `cancelable_only` 过滤 |
|
||||
| `query_stock_trades` | `query_trades` | 查询成交 |
|
||||
| `get_full_tick` | `get_ticks` | 默认直接 RPC 调用;可选开启 Redis 快照缓存降载 |
|
||||
| `get_instrument_detail` / `get_instrumentdetail` | `get_instrument` | 查询合约详情 |
|
||||
| `order_stock` / `order_stock_async` | `submit_order` | 买卖下单;默认关闭 |
|
||||
| `cancel_order_stock` / `cancel_order_stock_sysid` | `cancel_order` | 撤单;默认关闭 |
|
||||
|
||||
`order_stock` 参数兼容 `stock_code`、`order_type`、`order_volume`、`price_type`、`price`、`strategy_name`、`order_remark`。其中 `order_type=23/STOCK_BUY` 映射为买入,`order_type=24/STOCK_SELL` 映射为卖出。
|
||||
|
||||
`price_type` 会透传到大 QMT `passorder()`,常用值包括 `11/FIX_PRICE`、`5/LATEST_PRICE`、`44/MARKET_PEER_PRICE_FIRST`、`43/MARKET_SH_CONVERT_5_LIMIT`、`47/MARKET_SZ_CONVERT_5_CANCEL`。
|
||||
|
||||
`get_full_tick/get_ticks` 的 `codes` 参数支持两种写法:传合约代码如 `["600000.SH", "000001.SZ"]` 查询指定标的;传市场代码如 `["SH", "SZ"]` 查询全市场全推快照。
|
||||
|
||||
注意:兼容层的 `xtdata.get_full_tick(codes)` 默认走 Redis RPC 现调大 QMT。若需要降低全市场行情的大 payload 压力,可在客户端和 QMT 本地配置里显式打开 `full_tick_cache_enabled=True` / `BIGQMT_FULL_TICK_CACHE_CONFIG["enabled"]=True`,改为 Redis 需求驱动快照。
|
||||
|
||||
## 实现文件
|
||||
|
||||
- `src/bigqmt_signal_trader/redis_rpc.py`:RPC 协议、Redis queue 服务、外部客户端 helper。
|
||||
- `src/bigqmt_signal_trader/xtquant_compat.py`:MiniQMT 风格客户端兼容层。
|
||||
- `src/xtquant/`:可选的 `xtquant` import shim,用于最终替换老 import。
|
||||
- `src/bigqmt_signal_trader_strategy.py`:在 `init` 中启动 RPC;默认由 QMT `run_time("adjust", ...)` drain Redis queue,避免大 QMT 冻结自建后台线程。
|
||||
- `src/bigqmt_signal_trader_redis_rpc_runtime.py`:大 QMT 策略入口,默认不消费交易信号,只启用 RPC 和持仓同步。
|
||||
- `tests/bigqmt_signal_trader/test_redis_rpc.py`:RPC 单测。
|
||||
|
||||
## 运行方式
|
||||
|
||||
把源码同步到 QMT 的 `python` 目录:
|
||||
|
||||
```powershell
|
||||
$srcPkg = '<REPO_ROOT>\src\bigqmt_signal_trader'
|
||||
$dstPkg = '<QMT_PYTHON_DIR>\bigqmt_signal_trader'
|
||||
Get-ChildItem -LiteralPath $srcPkg -Force | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $dstPkg -Recurse -Force
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_strategy.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_strategy.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_redis_rpc_runtime.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_rpc_runtime.py' `
|
||||
-Force
|
||||
```
|
||||
|
||||
QMT 本地私有配置文件:
|
||||
|
||||
```python
|
||||
# <QMT_PYTHON_DIR>\bigqmt_signal_trader_local_config.py
|
||||
# coding: utf-8
|
||||
|
||||
BIGQMT_ACCOUNT_ID = "你的资金账号"
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "...",
|
||||
"rpc_allow_order_methods": False,
|
||||
"rpc_process_in_listener": True,
|
||||
"rpc_listener_methods": ("*",),
|
||||
"rpc_background_threads": False,
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "500nMilliSecond",
|
||||
"full_tick_cache_enabled": False,
|
||||
"full_tick_demand_ttl_seconds": 10,
|
||||
"full_tick_cache_ttl_seconds": 10,
|
||||
"full_tick_refresh_interval_seconds": 3,
|
||||
"full_tick_max_requests": 8,
|
||||
}
|
||||
```
|
||||
|
||||
这个文件含账号和 Redis 密码,只放 QMT 本地目录,不提交。
|
||||
|
||||
QMT 策略编辑器内容:
|
||||
|
||||
```python
|
||||
#coding:gbk
|
||||
import sys
|
||||
import os
|
||||
import importlib
|
||||
|
||||
_qmt_path = os.path.dirname(os.path.abspath(globals().get('__file__', '')))
|
||||
if not _qmt_path:
|
||||
_qmt_path = 'D:/YOUR_QMT_PYTHON_DIR'
|
||||
if _qmt_path not in sys.path:
|
||||
sys.path.insert(0, _qmt_path)
|
||||
|
||||
try:
|
||||
import bigqmt_signal_trader.redis_rpc as _redis_rpc
|
||||
_redis_rpc = importlib.reload(_redis_rpc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import bigqmt_signal_trader_strategy as _strategy
|
||||
try:
|
||||
_strategy.reset_app()
|
||||
except Exception:
|
||||
pass
|
||||
_strategy = importlib.reload(_strategy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import bigqmt_signal_trader_redis_rpc_runtime as _runtime
|
||||
_runtime = importlib.reload(_runtime)
|
||||
|
||||
try:
|
||||
from bigqmt_signal_trader_local_config import BIGQMT_REDIS_CONFIG
|
||||
_runtime.configure_runtime_redis(BIGQMT_REDIS_CONFIG)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from bigqmt_signal_trader_local_config import BIGQMT_ACCOUNT_ID
|
||||
_runtime.configure_runtime_account(BIGQMT_ACCOUNT_ID)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_runtime.bind_runtime_api(
|
||||
passorder_func=passorder,
|
||||
cancel_func=cancel,
|
||||
get_trade_detail_data_func=get_trade_detail_data,
|
||||
)
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
init = _runtime.init
|
||||
handlebar = _runtime.handlebar
|
||||
adjust = _runtime.adjust
|
||||
order_callback = _runtime.order_callback
|
||||
deal_callback = _runtime.deal_callback
|
||||
```
|
||||
|
||||
不要勾选“启动本地 python”。
|
||||
|
||||
## Redis 协议
|
||||
|
||||
### RPC 请求/响应
|
||||
|
||||
请求 channel:
|
||||
|
||||
```text
|
||||
bigqmt:rpc:req:{account_id}
|
||||
```
|
||||
|
||||
请求 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "req-001",
|
||||
"account_id": "YOUR_ACCOUNT_ID",
|
||||
"method": "get_positions",
|
||||
"params": {},
|
||||
"reply_channel": "bigqmt:rpc:resp:YOUR_ACCOUNT_ID:req-001",
|
||||
"reply_key": "bigqmt:rpc:resp:YOUR_ACCOUNT_ID:req-001",
|
||||
"ttl_seconds": 60
|
||||
}
|
||||
```
|
||||
|
||||
响应会同时写入:
|
||||
|
||||
```text
|
||||
bigqmt:rpc:resp:{account_id}:{request_id}
|
||||
```
|
||||
|
||||
并 publish 到同名 channel。
|
||||
|
||||
响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "req-001",
|
||||
"account_id": "YOUR_ACCOUNT_ID",
|
||||
"method": "get_positions",
|
||||
"ok": true,
|
||||
"data": {},
|
||||
"error": "",
|
||||
"handled_at": "2026-07-01 10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 可选:get_full_tick 需求驱动缓存
|
||||
|
||||
默认情况下,`xtdata.get_full_tick(codes)` 直接走 RPC。只有显式打开 `full_tick_cache_enabled=True` / `BIGQMT_FULL_TICK_CACHE_CONFIG["enabled"]=True` 时,客户端才会写入需求:
|
||||
|
||||
```text
|
||||
bigqmt:full_tick:demand:{account_id}
|
||||
```
|
||||
|
||||
其中 hash field 是规范化代码集合的 request id,value 包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "...",
|
||||
"codes": ["SH", "SZ"],
|
||||
"requested_at_ts": 1780000000.0,
|
||||
"expires_at_ts": 1780000010.0,
|
||||
"cache_ttl_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
大 QMT 每轮刷新后写入快照:
|
||||
|
||||
```text
|
||||
bigqmt:full_tick:cache:{account_id}:{request_id}
|
||||
```
|
||||
|
||||
快照 Redis key 的 TTL 默认是 10 秒;客户端还会校验 `updated_at_ts`,超过 `cache_ttl_seconds` 的快照不会返回。第一次调用如果还没有快照,客户端默认最多等待 `3.5s` 等下一轮大 QMT 刷新;**个股列表**仍然没有新快照时回退一次 live RPC(`get_full_tick`)以避免冷启动硬停;**市场代码**(`SH/SZ/BJ/HK`)则抛出超时、不回退 live 拉全市场。
|
||||
|
||||
### 异步下载任务(download jobs)
|
||||
|
||||
`download_history_data` / `download_history_data2` 是耗时的长调用:如果走同步 RPC,服务端会在**策略线程**上一直下载,冻结整个 RPC pump(且客户端 6s 就超时崩)。因此这两个方法改为**异步分块任务**:客户端把任务写入 Redis 队列立即返回,大 QMT 的策略线程每个 tick 只下载 `download_job_chunk_size` 只(受 `download_job_max_wall_seconds` 墙钟预算约束),永不长时间阻塞。
|
||||
|
||||
Redis 布局(按账户):
|
||||
|
||||
```text
|
||||
bigqmt:download:queue:{account_id} # 待处理 job_id 列表(RPUSH/LPOP)
|
||||
bigqmt:download:job:{account_id}:{job_id} # job JSON(含 state/done/total/error 进度)
|
||||
bigqmt:download:current:{account_id} # 当前正在处理的 job_id(串行,一次一个)
|
||||
```
|
||||
|
||||
job 状态:`pending → running → done | failed`。历史 K 线下载到**大 QMT 机器**的本地库;客户端随后用 `get_local_data` / `get_market_data` 快读取回。
|
||||
|
||||
客户端用法:
|
||||
|
||||
```python
|
||||
# 非阻塞:提交后轮询
|
||||
job = xtdata.submit_download_history_data2(["600000.SH", "000001.SZ"], "1d")
|
||||
status = xtdata.get_download_status(job["job_id"]) # {state, done, total, error}
|
||||
status = xtdata.wait_download(job["job_id"]) # 阻塞轮询到 done/failed(仅客户端阻塞)
|
||||
|
||||
# 兼容:download_history_data2(...) 仍可直接调用 = 提交 + 等待(默认最多 1800s);
|
||||
# 超时会抛 TimeoutError(任务在服务端继续跑,可继续轮询)。大批量建议用 submit + 轮询。
|
||||
```
|
||||
|
||||
服务端开关:`download_jobs_enabled`、`download_job_chunk_size`(默认 10,每 tick 最小下载块)、`download_job_max_wall_seconds`(默认 0.5s,每 tick 墙钟预算)、`download_job_ttl_seconds`(默认 3600)。
|
||||
|
||||
### 实时成交/委托回调推送(exec events)
|
||||
|
||||
大 QMT 的 `order_callback(ContextInfo, orderInfo)` / `deal_callback(ContextInfo, dealInfo)` 在策略进程内触发。服务端把 QMT 对象的 ThinkTrader `m_*` 字段规范化后 publish 到 Redis,客户端后台线程订阅并回调 —— 无需轮询即可**实时**拿到成交/委托。
|
||||
|
||||
Redis 频道(同名 stream,xadd + publish,供短时回放):
|
||||
|
||||
```text
|
||||
bigqmt:order_events:{account_id}
|
||||
bigqmt:trade_events:{account_id}
|
||||
```
|
||||
|
||||
成交事件字段(由 `deal_callback` 的 `m_*` 映射):`stock_code`(`m_strInstrumentID`)、`trade_id`(`m_strTradeID`)、`order_sys_id`(`m_strOrderSysID`)、`volume`(`m_nVolume`)、`price`(`m_dPrice`)、`amount`(`m_dTradeAmount`)、`commission`(`m_dComssion`)、`direction`(`m_nDirection`) 及 `action`(尽力映射 BUY/SELL)、`traded_at`(`m_strTradeTime`)。委托事件类似(`m_nOrderStatus`→`status`、`m_nVolumeTotal`→`order_volume`、`m_nVolumeTraded`→`traded_volume`、`m_dLimitPrice`→`price`)。
|
||||
|
||||
客户端用法(MiniQMT 风格,回调实时触发):
|
||||
|
||||
```python
|
||||
class MyCallback(XtQuantTraderCallback):
|
||||
def on_stock_trade(self, trade): # 成交实时回调
|
||||
print(trade.stock_code, trade.trade_id, trade.traded_volume, trade.traded_price)
|
||||
def on_stock_order(self, order): # 委托状态实时回调
|
||||
print(order.stock_code, order.order_status, order.traded_volume)
|
||||
|
||||
xt_trader.register_callback(MyCallback())
|
||||
xt_trader.start() # 启动后台监听线程(订阅上面两个频道)
|
||||
xt_trader.subscribe(acc) # 账号确定后会自动重订阅到该账号频道
|
||||
```
|
||||
|
||||
服务端开关:`exec_events_enabled`(默认 True)。`action` 由 `m_nDirection` 尽力映射(48/23→BUY,49/24→SELL),未知时为空但 `direction` 原值始终保留。
|
||||
|
||||
## 外部调用示例
|
||||
|
||||
```python
|
||||
import sys
|
||||
import redis
|
||||
|
||||
sys.path.insert(0, r"<REPO_ROOT>\src")
|
||||
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
|
||||
r = redis.Redis(
|
||||
host="YOUR_REDIS_HOST",
|
||||
port=6379,
|
||||
db=5,
|
||||
username="",
|
||||
password="...",
|
||||
)
|
||||
|
||||
response = call_redis_rpc(
|
||||
r,
|
||||
account_id="YOUR_ACCOUNT_ID",
|
||||
method="get_positions",
|
||||
params={},
|
||||
timeout_seconds=3,
|
||||
)
|
||||
|
||||
print(response)
|
||||
```
|
||||
|
||||
## 延迟模式
|
||||
|
||||
### 两档处理模型(重要)
|
||||
|
||||
同一进程只有一个 GIL,方法按处理线程分两档:
|
||||
|
||||
- **inline 档(后台接收线程直接处理)**:`ping`、行情类(`get_full_tick`/`get_market_data_ex`/
|
||||
`get_instrument_detail`)、`query_stock_asset`。中位数**亚毫秒**,但会撞上大 QMT 终端占 GIL
|
||||
的尾延迟(见下)。
|
||||
- **deferred 档(推迟到主策略线程,经 adjust drain)**:所有走 `get_trade_detail_data` 的**交易
|
||||
查询**——持仓/委托/成交、信用/账户明细,以及下单/撤单。**原因**:`get_trade_detail_data` 在后台
|
||||
线程上返回空(账户实有持仓也查出 0),必须在 QMT 主线程上下文里跑。这些方法登记在
|
||||
`LISTENER_DEFERRED_METHODS`,由 `run_time("adjust", interval)` 每拍 `drain_pending()` 在主
|
||||
线程执行。(`get_asset` 例外,走另一个 QMT 调用,后台线程即可,保持 inline 低延迟。)
|
||||
|
||||
> `adjust` 不是 QMT 内置回调。QMT 只自动调 `init`/`handlebar`;`handlebar` 里 `return
|
||||
> adjust(...)`,加上我们 `run_time("adjust", interval)` 注册的定时器,构成 RPC 队列的 drain 节奏。
|
||||
|
||||
### 尾延迟 = 大 QMT 终端占 GIL(不是本代码)
|
||||
|
||||
`gil_probe` 探针显示进程周期性被卡 ~490ms,但 `adjust_phase` 每段都 <50ms —— 即**尾延迟来自
|
||||
QMT 终端自身的 C++ 主循环占着 GIL**,`setswitchinterval`/精简 adjust 都 preempt 不了。唯一根治
|
||||
是把 serving 挪出该进程(sidecar 独立 GIL,见 `shm_transport.py` 预留)。
|
||||
|
||||
### schedule_adjust_interval 调这个数压尾延迟
|
||||
|
||||
`run_time` 间隔 = 后台线程拿到主线程 GIL 窗口的节奏源;间隔越小,inline 尾越低:
|
||||
|
||||
| interval | adjust 频率 | inline 尾(p90/max) | CPU | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `500nMilliSecond` | ~2.4/s | ~490 / 510ms | 极低 | 默认省电 |
|
||||
| `200nMilliSecond` | 折中 | ~200ms 量级 | 中 | **推荐平衡点** |
|
||||
| `100nMilliSecond` | ~2150/s(QMT 当"尽快跑"热循环) | ~92 / 108ms | 烧≈1 核 | 尾最低但费 CPU |
|
||||
|
||||
**deferred 交易查询恒定 ~1s**(实测 p50 1012~1013ms,与 interval 无关)—— 瓶颈是
|
||||
`get_trade_detail_data` 自身的柜台查询开销,调 interval 无效。要低延迟拿持仓,走**客户端 redis
|
||||
缓存**(position_sync 已在写)而非每次实时查。
|
||||
|
||||
### zmq 真机实测(同机 localhost)
|
||||
|
||||
- inline:`ping` p50 **0.4-0.5ms**;`get_market_data_ex` 因 handler 较重几乎必吃满一个尾窗口
|
||||
(500ms 档 p50≈495ms,100ms 档 p50≈96ms)。
|
||||
- deferred:持仓/委托/成交 p50 **~1s**。
|
||||
- 下单/撤单已在**实盘**验证:`order_stock` 挂单(status=50 已报)→ `query_stock_orders` 拿到
|
||||
sysid → `cancel_order_stock` 成功、无残留。
|
||||
|
||||
### 传输与后台线程
|
||||
|
||||
- **redis**(默认,跨机):`rpc_process_in_listener=True`。
|
||||
- **zmq**(同机低延迟):只加 `transport="zmq"` 一行;非 redis 传输 `_build_rpc_service` 会自动开
|
||||
`background_threads`,端口按账号派生 `tcp://127.0.0.1:1556x`。
|
||||
- 内置 Redis 客户端读取含股票代码的原始 JSON 会触发 `Sensitive Data Detected`;客户端 helper 默认
|
||||
对请求做安全编码。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 默认生产模式不在自建线程里调用 QMT API;QMT API 调用在 `adjust/handlebar` 中处理。
|
||||
- 默认只读,远程下单关闭。
|
||||
- 账号不匹配会拒绝请求。
|
||||
- 响应写 Redis key 并设置 TTL,方便调用端超时后排查。
|
||||
|
||||
## 本地测试
|
||||
|
||||
```powershell
|
||||
cd <REPO_ROOT>
|
||||
python -B -m unittest discover -s tests\bigqmt_signal_trader
|
||||
```
|
||||
|
||||
当前结果:
|
||||
|
||||
```text
|
||||
Ran 68 tests
|
||||
OK
|
||||
```
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# 大 QMT 信号下单包运行手册
|
||||
|
||||
更新时间:2026-07-01
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
这个包已经具备大 QMT 运行入口和 QMT 适配层:
|
||||
|
||||
- `bigqmt_signal_trader_strategy.py`:大 QMT 策略入口,响应 `init`、`handlebar/adjust`、委托回调、成交回调。
|
||||
- `BigQmtMarketDataProvider`:封装 `ContextInfo.get_full_tick()` 和 `ContextInfo.get_instrumentdetail()`。
|
||||
- `BigQmtPositionProvider`:封装 `get_trade_detail_data(account, 'STOCK', 'POSITION')`。
|
||||
- `BigQmtOrderGateway`:按 `qmt_jq_trade` 的参数形状调用 `passorder()`。
|
||||
- 默认模式仍是 `dryrun`,不会真实发委托。
|
||||
|
||||
截至 2026-07-01 凌晨,已完成:
|
||||
|
||||
- 本地单元测试:`32 tests OK`。
|
||||
- QMT `python` 目录导入测试通过。
|
||||
- 模拟 `init/adjust/sync_positions` 回调通过。
|
||||
- 模拟 `mode="bigqmt"` + fake `passorder` 参数测试通过。
|
||||
- Redis Stream 信号源、Redis 状态写回、Redis 持仓同步已实现。
|
||||
- 本机 Redis `127.0.0.1:6379 db=5` dry-run 集成测试通过。
|
||||
|
||||
还没有完成:
|
||||
|
||||
- 没有在 QMT 页面里通过“模型交易”做真实实盘委托验证。
|
||||
- 没有把 miniQMT 的真实买卖指令切成只写 Redis 信号。
|
||||
- 没有在真实账号上启用大 QMT `passorder` 实盘执行。
|
||||
|
||||
所以今天开盘可以先验证“大 QMT 是否能持续加载和触发回调”,以及 Redis dry-run 链路是否能消费测试信号并写回状态。如果要真的由大 QMT 替代 miniQMT 下单,必须先完成 miniQMT 只写信号、单账户灰度和实盘风控确认。
|
||||
|
||||
## 1.1 当前 QMT 页面检查结果
|
||||
|
||||
2026-07-01 08:00 左右检查大 QMT“模型交易”页面:
|
||||
|
||||
- 页面里当前运行/展示的策略名称是“网格策略”。
|
||||
- 没有看到 `bigqmt_signal_trader` 或 `bigqmt_signal_trader_redis_dryrun`。
|
||||
- “策略日志”页没有 `[bigqmt_signal_trader] init ok` 或 `[bigqmt_signal_trader] adjust ok`。
|
||||
|
||||
结论:当前这个文件还没有真正挂到大 QMT 模型交易里运行。
|
||||
|
||||
## 2. 官方文档里的关键点
|
||||
|
||||
### 2.1 编辑器运行不等于真实交易
|
||||
|
||||
大 QMT 编辑器里的“运行/回测/模型运行”主要用于公式、模型、信号验证。要真正把委托发送到交易柜台,需要进入“模型交易”页面,把策略加入模型交易实例并绑定资金账号。
|
||||
|
||||
结论:
|
||||
|
||||
- 编辑器“运行”:适合检查 `init/handlebar` 是否报错,不用于确认真实下单。
|
||||
- 模型交易“模拟信号”:适合开盘先观察回调、信号、价格、状态,不真实下单。
|
||||
- 模型交易“实盘交易”:只有确认信号源、幂等、风控都 OK 后才能打开。
|
||||
|
||||
### 2.2 不要勾选“启动本地 python”
|
||||
|
||||
官方文档说明,“启动本地 python”是把脚本作为独立 Python 进程运行。这个模式不会按大 QMT 回调机制触发 `init(ContextInfo)`、`handlebar(ContextInfo)`。
|
||||
|
||||
本包是回调式策略入口,必须让 QMT 自己调用:
|
||||
|
||||
- 不勾选:`init`、`handlebar`、`order_callback`、`deal_callback` 正常触发。
|
||||
- 勾选:脚本只会像普通 Python 文件一样执行 import,通常会马上结束,不会进入交易回调。
|
||||
|
||||
### 2.3 必须跳过历史 bar
|
||||
|
||||
QMT 加载策略时可能先跑历史 K 线,再进入最后一根实时 bar。入口已经加了保护:
|
||||
|
||||
```python
|
||||
if hasattr(ContextInfo, "is_last_bar") and not ContextInfo.is_last_bar():
|
||||
return None
|
||||
```
|
||||
|
||||
这可以避免未来接入真实信号源后,在历史回放阶段误消费当前待处理信号。
|
||||
|
||||
## 3. 文件部署
|
||||
|
||||
大 QMT 运行目录:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>
|
||||
```
|
||||
|
||||
源代码目录:
|
||||
|
||||
```text
|
||||
<REPO_ROOT>\src
|
||||
```
|
||||
|
||||
部署命令:
|
||||
|
||||
```powershell
|
||||
Copy-Item -Path '<REPO_ROOT>\src\bigqmt_signal_trader\*' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader' `
|
||||
-Recurse -Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_strategy.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_strategy.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_dryrun.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_dryrun.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_redis_dryrun.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_dryrun.py' `
|
||||
-Force
|
||||
```
|
||||
|
||||
清理缓存:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem -LiteralPath '<QMT_PYTHON_DIR>\bigqmt_signal_trader' `
|
||||
-Recurse -Filter '__pycache__' -Directory -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force
|
||||
|
||||
Get-ChildItem -LiteralPath '<QMT_PYTHON_DIR>' `
|
||||
-Filter '__pycache__' -Directory -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force
|
||||
```
|
||||
|
||||
## 4. QMT 编辑器加载测试
|
||||
|
||||
目的:只验证 QMT 能加载入口、能触发 `init/adjust`,不会真实下单。
|
||||
|
||||
### 4.1 策略文件内容
|
||||
|
||||
在 QMT 策略编辑器中新建一个策略,例如 `大QMT信号下单_dryrun`,内容使用下面这段。脚本必须保持 ASCII,避免 QMT 编辑器编码问题。
|
||||
|
||||
```python
|
||||
#coding:gbk
|
||||
from bigqmt_signal_trader_strategy import (
|
||||
adjust,
|
||||
configure,
|
||||
deal_callback,
|
||||
handlebar,
|
||||
init,
|
||||
order_callback,
|
||||
set_account_id,
|
||||
sync_positions,
|
||||
)
|
||||
|
||||
try:
|
||||
ACCOUNT_ID = account
|
||||
except NameError:
|
||||
ACCOUNT_ID = ""
|
||||
|
||||
if ACCOUNT_ID:
|
||||
set_account_id(ACCOUNT_ID)
|
||||
|
||||
configure(mode="dryrun", account_id=ACCOUNT_ID or "dryrun")
|
||||
```
|
||||
|
||||
### 4.2 QMT 页面设置
|
||||
|
||||
在策略编辑器右侧/基本信息里:
|
||||
|
||||
- 运行周期:建议先选 `1分钟` 或 `3分钟`。
|
||||
- 标的:建议先用流动性稳定的指数或股票,例如 `000300.SH`。
|
||||
- 启动本地 python:不要勾选。
|
||||
- 自动交易/实盘交易:不要在编辑器测试阶段打开。
|
||||
|
||||
点击顺序:
|
||||
|
||||
1. 保存。
|
||||
2. 编译。
|
||||
3. 运行。
|
||||
|
||||
期望输出:
|
||||
|
||||
```text
|
||||
[bigqmt_signal_trader] init ok
|
||||
[bigqmt_signal_trader] adjust ok
|
||||
```
|
||||
|
||||
如果只看到“开始运行/结束运行”,但没有 `init ok/adjust ok`:
|
||||
|
||||
- 检查是否勾选了“启动本地 python”。
|
||||
- 检查是否真的导入了 `bigqmt_signal_trader_strategy.py`。
|
||||
- 检查 QMT 输出窗或 `XtClient_Formula_YYYYMMDD.log` 是否有 traceback。
|
||||
|
||||
## 4.3 Redis dry-run 入口
|
||||
|
||||
已经新增安全观察入口:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_dryrun.py
|
||||
```
|
||||
|
||||
默认配置:
|
||||
|
||||
```text
|
||||
ACCOUNT_ID = bigqmt_probe
|
||||
Redis = 127.0.0.1:6379 db=5
|
||||
Stream = bigqmt:signals:bigqmt_probe
|
||||
Status = bigqmt:signal_status:bigqmt_probe:{signal_id}
|
||||
Position = bigqmt:positions:bigqmt_probe
|
||||
OrderGateway = DryRunOrderGateway
|
||||
```
|
||||
|
||||
如果 QMT 的 `python` 目录存在本地私有配置文件,则 Redis 连接会被覆盖:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>\bigqmt_signal_trader_local_config.py
|
||||
```
|
||||
|
||||
格式:
|
||||
|
||||
```python
|
||||
# coding: utf-8
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "...",
|
||||
}
|
||||
```
|
||||
|
||||
这个文件含 Redis 密码,只放 QMT 本地目录,不提交到源码仓库,也不要贴进文档。
|
||||
|
||||
这个入口只用于开盘观察 Redis 链路,不会真实下单,也不会消费真实账号流。不要把 `ACCOUNT_ID` 改成真实资金账号,除非你明确知道 dry-run 会 ack 掉该账号的 Redis 信号。
|
||||
|
||||
写入一条测试信号:
|
||||
|
||||
```powershell
|
||||
cd <REPO_ROOT>
|
||||
python -B -c "import sys,datetime,json,redis; sys.path.insert(0,'src'); from bigqmt_signal_trader.adapters.signal_redis import push_trade_signal; r=redis.Redis(host='127.0.0.1',port=6379,db=5); push_trade_signal(r, {'signal_id':'probe-001','account_id':'bigqmt_probe','action':'BUY','stock_code':'600000.SH','amount':100,'price_type':'FIX_PRICE','price':10.0,'created_at':'2026-07-01 09:31:00','expire_at':'2026-07-01 23:59:00','schema_version':1})"
|
||||
```
|
||||
|
||||
运行后检查状态:
|
||||
|
||||
```powershell
|
||||
python -B -c "import redis; r=redis.Redis(host='127.0.0.1',port=6379,db=5,decode_responses=True); print(r.hgetall('bigqmt:signal_status:bigqmt_probe:probe-001'))"
|
||||
```
|
||||
|
||||
期望状态里出现:
|
||||
|
||||
```text
|
||||
status = DRY_RUN
|
||||
user_order_id = dryrun:bq:...
|
||||
```
|
||||
|
||||
## 5. 模型交易页面运行
|
||||
|
||||
目的:开盘后观察策略在真实行情驱动下是否持续触发,而不是只在编辑器里跑一次。
|
||||
|
||||
### 5.1 第一步只跑模拟信号
|
||||
|
||||
进入 QMT 的“模型交易”页面:
|
||||
|
||||
1. 新建模型交易实例。
|
||||
2. 选择上面的策略文件。
|
||||
3. 绑定资金账号。
|
||||
4. 标的使用 `000300.SH` 或其他稳定标的。
|
||||
5. 周期先用 `1分钟`。
|
||||
6. 运行方式先选择“模拟信号”或等价的非实盘模式。
|
||||
7. 确认“启动本地 python”没有勾选。
|
||||
8. 启动模型交易。
|
||||
|
||||
开盘后观察:
|
||||
|
||||
- 输出窗是否出现 `[bigqmt_signal_trader] init ok`。
|
||||
- 第一根实时 bar 后是否出现 `[bigqmt_signal_trader] adjust ok`。
|
||||
- 日志里是否没有 `Traceback`、`ModuleNotFoundError`、`run script failed`。
|
||||
- 委托页面不应该出现真实委托,因为当前是 dry-run 且空信号源。
|
||||
|
||||
如果要验证 Redis dry-run 链路,则选择 `bigqmt_signal_trader_redis_dryrun.py`,并向 `bigqmt:signals:bigqmt_probe` 写入测试信号。它只会写 Redis 状态,不会真实委托。
|
||||
|
||||
### 5.2 真实大 QMT adapter 连通模式
|
||||
|
||||
如果只想确认大 QMT adapter 可以装配,但仍然没有真实信号源,可以把策略最后一行改成:
|
||||
|
||||
```python
|
||||
configure(mode="bigqmt", account_id=ACCOUNT_ID or "dryrun")
|
||||
```
|
||||
|
||||
注意:当前没有配置真实 `SignalSource`,所以即使是 `mode="bigqmt"`,也不会产生订单。它只会装配行情、持仓、委托 adapter,用于确认 QMT 环境里这些函数可用。
|
||||
|
||||
### 5.3 真正实盘委托前置条件
|
||||
|
||||
只有满足下面全部条件,才能考虑切到实盘交易:
|
||||
|
||||
- 已在 Redis db5 上验证 Redis Stream 信号源、`StateStore.claim()`、状态回写都正常。
|
||||
- 已用模拟信号验证 `passorder` 参数、持仓查询、撤单查询全部正常。
|
||||
- 已确认历史 bar 不会触发下单。
|
||||
- 已确认 miniQMT 不再对同一账户重复真实下单,避免双系统抢单。
|
||||
- 已确认 dry-run 没有 ack 掉真实账号待实盘处理的信号。
|
||||
|
||||
未满足这些条件时,不要切到“实盘交易”。
|
||||
|
||||
## 6. 今日开盘观察清单
|
||||
|
||||
日期:2026-07-01
|
||||
|
||||
### 9:10 前
|
||||
|
||||
- 确认 QMT 已登录。
|
||||
- 确认文件已部署到 `<QMT_PYTHON_DIR>`。
|
||||
- 在策略编辑器里编译成功。
|
||||
- 确认“启动本地 python”未勾选。
|
||||
- 在模型交易页面用“模拟信号”启动 `bigqmt_signal_trader_dryrun.py` 或 `bigqmt_signal_trader_redis_dryrun.py`。
|
||||
|
||||
### 9:30 到 9:35
|
||||
|
||||
- 看输出窗是否出现 `init ok` 和 `adjust ok`。
|
||||
- 看 `XtClient_Formula_20260701.log` 是否有 traceback。
|
||||
- 看策略是否持续运行,没有自动结束。
|
||||
- 看委托页面确认没有真实委托。
|
||||
- 如果跑 Redis dry-run,向 `bigqmt:signals:bigqmt_probe` 写一条测试信号,确认状态 key 变成 `DRY_RUN`。
|
||||
|
||||
### 9:35 后
|
||||
|
||||
如果模拟信号稳定:
|
||||
|
||||
- 可以把标的周期从 `1分钟` 调整到实际希望的触发周期。
|
||||
- 继续保持 dry-run 观察一段时间。
|
||||
- 不要直接改成实盘,除非真实信号源和状态存储已经接好。
|
||||
|
||||
## 7. 日志排查
|
||||
|
||||
QMT 公式日志:
|
||||
|
||||
```text
|
||||
<QMT_USERDATA_LOG_DIR>\XtClient_Formula_YYYYMMDD.log
|
||||
```
|
||||
|
||||
重点搜索:
|
||||
|
||||
```text
|
||||
bigqmt_signal_trader
|
||||
Traceback
|
||||
ModuleNotFoundError
|
||||
SyntaxError
|
||||
run script failed
|
||||
passorder
|
||||
```
|
||||
|
||||
常见问题:
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| 只开始运行/结束运行,没有回调日志 | 勾选了启动本地 python,或没有进入模型交易回调模式 | 取消勾选,使用模型交易运行 |
|
||||
| `No module named dataclasses` | QMT 内置 Python 版本低,不能依赖 dataclasses | 当前代码已移除 dataclasses,重新部署并清 `__pycache__` |
|
||||
| `__file__ is not defined` | QMT 编辑器脚本没有 `__file__` | 策略入口不要依赖 `__file__` |
|
||||
| 编码错误 | 编辑器保存编码和 `coding` 声明不一致 | 入口脚本用 `#coding:gbk`,内容保持 ASCII |
|
||||
| 启动后处理很多历史 bar | 未过滤历史 K 线 | 当前 `adjust` 已用 `is_last_bar()` 保护 |
|
||||
|
||||
## 8. 当前不能误解的点
|
||||
|
||||
- 当前包不是 `qmt_jq_trade` 的目标持仓同步脚本。
|
||||
- 当前包的设计是“外部系统产出逐笔交易信号,大 QMT 只执行”。
|
||||
- Redis db5 链路已经具备,但当前安全入口默认使用 `bigqmt_probe` 测试账号流。
|
||||
- 编辑器里点“运行”不是实盘验证。
|
||||
- 真正实盘必须走模型交易页面,并且要显式接入信号源、幂等状态和账户切换流程。
|
||||
|
||||
## 9. 官方文档参考
|
||||
|
||||
- ThinkTrader 大 QMT 接口文档:`https://dict.thinktrader.net/innerApi/interface_operation.html`
|
||||
- 大 QMT Python API 文档:`https://qmt.ptradeapi.com/QMT_Python_API_Doc.html`
|
||||
- 本项目参考脚本:`<REPO_ROOT>\src\api\qmt_jq_trade`
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Big QMT 执行回调重复触发修复记录
|
||||
|
||||
日期:2026-08-12
|
||||
|
||||
## 现象
|
||||
|
||||
实盘下单后,客户端收到的委托回报和成交回报各触发两次,例如:
|
||||
|
||||
```text
|
||||
委托回报: 159518 50 635042239
|
||||
委托回报: 159518 50 635042239
|
||||
成交回报: 159518 635042239 100 1.204
|
||||
成交回报: 159518 635042239 100 1.204
|
||||
```
|
||||
|
||||
同一笔委托的同一状态、同一笔成交被重复推送到客户端 callback。
|
||||
|
||||
## 原因
|
||||
|
||||
服务端策略脚本同时暴露了两套执行回调入口:
|
||||
|
||||
- `on_order` / `on_trade`
|
||||
- `order_callback` / `deal_callback`
|
||||
|
||||
其中 `order_callback()` 内部又调用 `on_order()`,`deal_callback()` 内部又调用 `on_trade()`。
|
||||
|
||||
如果 Big QMT 运行时同时识别并触发这两套入口,同一个原始委托/成交事件会进入服务端两次。每次都会执行:
|
||||
|
||||
1. 归一化 QMT 回调对象;
|
||||
2. 发布 Redis 执行事件;
|
||||
3. 转发给本地 app runner。
|
||||
|
||||
客户端订阅执行事件频道后,就会看到同一条委托回报/成交回报各触发两次。
|
||||
|
||||
## 修复方案
|
||||
|
||||
只保留 Big QMT 标准回调入口:
|
||||
|
||||
- `order_callback(ContextInfo, orderInfo)`
|
||||
- `deal_callback(ContextInfo, dealInfo)`
|
||||
|
||||
删除服务端策略入口中的别名回调:
|
||||
|
||||
- `on_order`
|
||||
- `on_trade`
|
||||
|
||||
`order_callback` 和 `deal_callback` 现在直接完成原来别名函数里的工作:
|
||||
|
||||
- `_publish_exec_event("order", orderInfo)`
|
||||
- `_publish_exec_event("trade", dealInfo)`
|
||||
- `forward_order_event(...)`
|
||||
- `forward_trade_event(...)`
|
||||
|
||||
这样 Big QMT 运行时只会看到一套执行回调入口,不需要依赖客户端或服务端去重。
|
||||
|
||||
## 修改范围
|
||||
|
||||
- `src/bigqmt_signal_trader_strategy.py`
|
||||
- 删除 `on_order` / `on_trade`
|
||||
- `order_callback` / `deal_callback` 直接发布和转发事件
|
||||
|
||||
- `src/bigqmt_signal_trader_redis_rpc_runtime.py`
|
||||
- 不再导入或导出 `on_order` / `on_trade`
|
||||
|
||||
- `src/bigqmt_signal_trader_dryrun.py`
|
||||
- 不再导入 `on_order` / `on_trade`
|
||||
|
||||
- `src/bigqmt_signal_trader_redis_dryrun.py`
|
||||
- 不再导入 `on_order` / `on_trade`
|
||||
|
||||
- `tests/bigqmt_signal_trader/test_runner.py`
|
||||
- 删除别名回调测试
|
||||
- 新增断言:策略模块不暴露 `on_order` / `on_trade`,只暴露 `order_callback` / `deal_callback`
|
||||
|
||||
- `src/bigqmt_signal_trader/README.md`
|
||||
- 更新策略入口说明
|
||||
|
||||
- `docs/BIG_QMT_SIGNAL_TRADER_RUNBOOK.md`
|
||||
- 更新 QMT 策略导入示例
|
||||
|
||||
## 验证
|
||||
|
||||
相关测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests\bigqmt_signal_trader\test_runner.py tests\bigqmt_signal_trader\test_exec_events.py -q
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
38 passed
|
||||
```
|
||||
|
||||
全量测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest -q
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
275 passed, 4 skipped
|
||||
```
|
||||
|
||||
## 注意
|
||||
|
||||
这次修复只处理“同一执行事件回调两次”的问题。
|
||||
|
||||
`order_stock()` 同步返回 `-1`,但随后又收到真实委托/成交回报,是另一类问题:同步下单路径没有及时拿到 `order_sys_id`,而异步执行事件稍后能拿到真实系统委托号。该问题不在本次修复范围内。
|
||||
@@ -0,0 +1,168 @@
|
||||
# FormulaServer 直连快速路径(58600)
|
||||
|
||||
## 这是什么
|
||||
|
||||
大 QMT 的 `58600` 端口是 **FormulaServer** —— QMT 内置的 C++ 行情/参考数据服务。端口取自
|
||||
QMT 安装目录的 `config/formulaserver/formulaserver.ini`:
|
||||
|
||||
```ini
|
||||
[server_formula]
|
||||
address = 0.0.0.0:58600
|
||||
```
|
||||
|
||||
QMT 自带 Python 里就有它的官方客户端:`bin.x64/Lib/site-packages/qmt_api`。协议是
|
||||
**BSON over TCP**,帧格式在 `qmt_api/net/RPCBase.py`:
|
||||
|
||||
```
|
||||
| packLen(uint32 BE) | seq(uint32 BE) | cmd(uint16 BE) | tag(uint16 BE) | BSON body |
|
||||
```
|
||||
|
||||
`cmd = 3`(`NET_CMD_RPC`),body 是 `{"func": <名字>, "params": {...}}`,
|
||||
响应 `{"status": 0, "params": {...}}`,`status != 0` 时 `params` 里带 `ErrorID`/`ErrorMsg`。
|
||||
`tag & 7` 标记 zlib 压缩,`tag >> 8` 的低 4 位是 seq 的高位。
|
||||
|
||||
## 为什么值得接
|
||||
|
||||
原来所有只读请求都要绕一整圈:
|
||||
|
||||
```
|
||||
客户端 → redis/zmq → QMT python 策略线程 → ContextInfo → 原路返回
|
||||
```
|
||||
|
||||
这不只是慢,还要和策略自己抢 QMT 主线程的 GIL —— zmq 传输约 30% 的请求会撞上 ~500ms 的
|
||||
调度尖峰,就是这么来的。
|
||||
|
||||
直连 FormulaServer 完全绕开策略进程:
|
||||
|
||||
| 路径 | p50 |
|
||||
|------|-----|
|
||||
| redis RPC | ~13ms |
|
||||
| zmq RPC | ~0.7ms(30% 撞 500ms GIL 尖峰)|
|
||||
| **FormulaServer 直连** | **0.07ms**,无 GIL 竞争 |
|
||||
|
||||
穿过完整客户端栈(`BigQmtRpcClient.call`)实测 **0.145ms/次**,比 redis 快约 90 倍。
|
||||
|
||||
## 能力边界
|
||||
|
||||
**FormulaServer 只有行情/参考数据。** 实测所有账户/交易类方法一律返回
|
||||
`ErrorID 200005 未找到该服务`:
|
||||
|
||||
```
|
||||
getAsset / getPositions / getAccountDetail / passorder -> 200005
|
||||
getFullTick / getQuote -> 200005
|
||||
```
|
||||
|
||||
所以它是**只读快速路径,不是 RPC 桥的替代品**。交易、账户查询、持仓、委托、成交、
|
||||
五档盘口全部仍然走 RPC。
|
||||
|
||||
### 已接入的方法(10 个)
|
||||
|
||||
| 我们的方法 | FormulaServer func |
|
||||
|---|---|
|
||||
| `get_instrument` / `get_instrument_detail` / `get_instrumentdetail` | `getInstrumentDetail` |
|
||||
| `get_last_volume` | `getLastVolume` |
|
||||
| `get_total_share` | `getTotalShare` |
|
||||
| `get_contract_multiplier` | `getContractMultiplier` |
|
||||
| `get_main_contract` | `getMainContract` |
|
||||
| `get_weight_in_index` | `getWeightInIndex` |
|
||||
| `get_stock_list_in_sector` | `getStockListInSector` |
|
||||
| `get_market_data_ex` | `getMarketData` |
|
||||
|
||||
### 刻意不接的方法,以及原因
|
||||
|
||||
宁可慢,不能悄悄给错数据。以下几项参数语义与我们的调用方不一致:
|
||||
|
||||
- **`get_trading_dates`** —— FormulaServer 要的是**股票代码**。实测:
|
||||
```
|
||||
{'stockCode': 'SH', ...} -> {'result': []} # 静默空
|
||||
{'stockCode': '000001.SZ', ...} -> ['20260630', '20260701', ...]
|
||||
```
|
||||
而 `market_bigqmt.get_trading_dates(market, ...)` 的调用方传的是市场代码。传错了不报错、
|
||||
只给空列表,交易日历错了后果太重。
|
||||
|
||||
- **`get_divid_factors`** —— 我们是 `(stock_code, start_time, end_time)` 区间,
|
||||
FormulaServer 是 `(stockCode, date)` 单日。
|
||||
|
||||
- **`get_risk_free_rate`** —— 我们传 `index=-1`,FormulaServer 要 `timetag`。语义不同。
|
||||
|
||||
- **复权 K 线** —— 实测 `dividendType` 传 `none` 和 `front` 返回**完全相同**的价格,
|
||||
说明复权没有生效。因此只有 `dividend_type="none"`(或空)才走直连,其他复权类型直接
|
||||
判为 unroutable 回退 RPC。否则策略要前复权、拿到的却是不复权价格,且毫无提示。
|
||||
|
||||
### 字段名坑
|
||||
|
||||
FormulaServer 的 `getInstrumentDetail` 返回 **`FloatVolumn` / `TotalVolumn`**(官方拼写错误),
|
||||
而原生 xtdata SDK 用的是 `FloatVolume` / `TotalVolume`。下游代码按 SDK 拼写读,直接透传会
|
||||
静默读到 `None`。所以 `_instrument_result` 做了别名归一化,两种拼写都保留。
|
||||
|
||||
### 尚未验证
|
||||
|
||||
`qmt_api/api.py` 的 `getMarketData` 支持 `fields=['quoter']`,注释说会返回
|
||||
`askPrice/askVol/bidPrice/bidVol`(level1 五档 / level2 十档)。**如果这在盘中可用,
|
||||
`get_full_tick` 也能走直连** —— 这是热路径,收益很大。
|
||||
|
||||
但收盘时段实测返回空,无法确认。需要**盘中**再测一次:
|
||||
|
||||
```python
|
||||
c.request('getMarketData', {'fields': ['quoter'], 'stockCodes': ['000001.SZ'],
|
||||
'startTime': '', 'endTime': '', 'period': 'tick',
|
||||
'dividendType': 'none', 'count': -1})
|
||||
```
|
||||
|
||||
在确认之前不要接 —— 没验证就上映射,正是订单方向判定踩过的坑。
|
||||
|
||||
## 失败行为
|
||||
|
||||
**任何失败都自动回退 RPC**,所以连不上 58600 的客户端行为与改动前完全一致:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|---|---|
|
||||
| 方法不在映射表 | `supports()` 返回 False,直接走 RPC |
|
||||
| 参数 translate 不了 | `Unroutable`,走 RPC,**不**触发熔断(这是单次调用的问题) |
|
||||
| 服务连不上 / IO 失败 | `Unroutable` + 熔断 `failure_cooldown_seconds`(默认 30s),期间全部走 RPC |
|
||||
| 服务端回 `200005` | 该方法永久标记 unimplemented,只停这一个方法,不影响其他 |
|
||||
| socket 断了(QMT 重启) | 自动重连重试一次 |
|
||||
|
||||
## 依赖
|
||||
|
||||
**不需要装任何东西。** BSON 编解码内置了无依赖实现;如果环境里有 pymongo 的 `bson`
|
||||
或 QMT 的 `xtquant.xtbson`,会优先用(更快、更久经考验)。两条路径的输出实测逐字节一致,
|
||||
测试里有对拍用例。
|
||||
|
||||
## 配置
|
||||
|
||||
客户端侧,默认开启,通常不用写:
|
||||
|
||||
```python
|
||||
BIGQMT_FORMULA_SERVER_CONFIG = {
|
||||
"enabled": True, # 或环境变量 BIGQMT_FORMULA_ENABLED=0 关闭
|
||||
# "host": "127.0.0.1", # 绑的是 0.0.0.0,跨机可达(需放行防火墙)
|
||||
# "port": 58600, # 不写则从 qmt_root 的 ini 读,再退回 58600
|
||||
# "qmt_root": r"D:\国金证券QMT交易端",
|
||||
# "timeout_seconds": 3.0,
|
||||
# "methods": ["get_instrument"], # 只路由白名单
|
||||
# "failure_cooldown_seconds": 30.0,
|
||||
}
|
||||
```
|
||||
|
||||
也可以写在 `BIGQMT_REDIS_CONFIG["formula_server"]` 里,后者优先级更高。
|
||||
|
||||
## 排查
|
||||
|
||||
```python
|
||||
client._formula_router().stats()
|
||||
# {'enabled': True, 'hits': 202, 'misses': 0, 'available': True,
|
||||
# 'unimplemented': [], 'methods': [...]}
|
||||
```
|
||||
|
||||
启动时会打一行:
|
||||
|
||||
```
|
||||
[bigqmt_formula] active at 127.0.0.1:58600 (10 methods routed direct)
|
||||
```
|
||||
|
||||
熔断时:
|
||||
|
||||
```
|
||||
[bigqmt_formula] unavailable, falling back to RPC for 30s: connect 127.0.0.1:58600 failed: ...
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Listolany
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
# MiniQMT → 普通QMT内置python 策略转换 Skill
|
||||
|
||||
把基于 **miniQMT / xtquant**(`XtQuantTrader` / `xtdata`)的 Python 量化策略,转换为**大QMT内置Python**(`passorder` / `ContextInfo` 体系)可实盘运行的策略。
|
||||
|
||||
> 本 Skill 配合 Cursor AI Agent 使用,覆盖从可行性评估到上线部署的完整流程,含自动化工具脚本和真实转换对照示例。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
SKILL.md 主工作流(7步转换流程)
|
||||
faq.md 买方共性疑虑 FAQ(外部数据/运行频率/同步下单/回测等8问)
|
||||
api_mapping.md 全量 API / 字段 / 枚举映射表
|
||||
constraints.md 限制清单、不可转场景判定与替代方案(含实测验证记录)
|
||||
examples.md 真实策略(700行 demo)转换前后对照
|
||||
scripts/
|
||||
analyze_strategy.py 第1步:静态分析,输出可行性报告
|
||||
check_converted.py 第5步:转换后合规校验(py3.6/GBK/框架结构)
|
||||
to_gbk.py 最终交付:UTF-8 → GBK 安全转存
|
||||
templates/
|
||||
template_timer.py 定时器型骨架(apscheduler / while+sleep 策略首选)
|
||||
template_bar.py 行情驱动型骨架(K线/订阅回调策略)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# 1. 分析原策略(得到可行性报告 + 推荐模板)
|
||||
python scripts/analyze_strategy.py 你的策略.py
|
||||
|
||||
# 2. 以推荐模板为骨架手动填充转换后代码
|
||||
|
||||
# 3. 校验转换结果(必须全部 PASS)
|
||||
python scripts/check_converted.py 转换后策略.py
|
||||
|
||||
# 4. GBK 落盘(大QMT内置端要求)
|
||||
python scripts/to_gbk.py 转换后策略.py 输出_gbk.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 智能体工具ClaudeCode/Cursor/OpenClaw/Hermes/Workbuddy 中使用
|
||||
|
||||
在对话里 `@MiniQMT2bigQMT_Skill`(将本目录放入 `.cursor/skills/` 或个人 skill 目录),Agent 会自动按 7 步流程完成转换并输出报告。
|
||||
|
||||
---
|
||||
|
||||
## 适用范围
|
||||
|
||||
| 可转换 | 处理方式 |
|
||||
|---|---|
|
||||
| apscheduler / while+sleep 定时调度 | → `C.run_time` 定时器 |
|
||||
| `order_stock_async` 异步下单 | → `passorder`(11参) + userOrderId 状态机 |
|
||||
| `query_stock_*` 查询接口 | → `get_trade_detail_data` + `m_` 前缀字段 |
|
||||
| `xtdata.*` 行情接口 | → `C.get_full_tick` / `C.get_market_data_ex` 等 |
|
||||
| 委托回调类 `on_stock_order` 等 | → 模块级 `order_callback` / `deal_callback` 等 |
|
||||
|
||||
| 不可直接转(给替代方案) | 建议 |
|
||||
|---|---|
|
||||
| 多账户/跨券商统一调度 | 文件桥方案(见 constraints.md D节) |
|
||||
| 重型 ML 依赖 / py3.6 装不了的库 | 模型外置,内置端只读信号执行 |
|
||||
| 7x24 守护 / 盘后批处理 | 保留外部计划任务喂文件给内置策略 |
|
||||
|
||||
---
|
||||
|
||||
## 常见疑虑速答(详见 faq.md)
|
||||
|
||||
- **外部数据还能取吗?** 能。内置端是完整 py3.6 非沙箱:本地文件通道(推荐)/ 直接网络请求(低频+超时)/ QMT 自身数据接口(比 mini 的 xtdata 更全)三选一。
|
||||
- **是不是最短一分钟跑一次?** 不是。`run_time` 支持毫秒级间隔且与 K 线周期无关,1 秒循环已实盘长期验证;`handlebar` 本身逐 tick 触发。
|
||||
- **同步下单没了怎么办?** 用 userOrderId 状态机等价改写(模板内置),实测下单后约 1 秒可查回委托号。
|
||||
- **能回测吗?** K线型策略可直接回测(mini 反而没有回测框架);定时器型策略需把信号逻辑双入口挂载(回测挂 handlebar,详见 faq.md Q4)。
|
||||
|
||||
---
|
||||
|
||||
## 关键实测结论(已用券商模拟环境双端交叉验证)
|
||||
|
||||
- xtconstant 常量与内置枚举数值一致(15项全部核实)
|
||||
- `xc.CREDIT_BUY/CREDIT_SELL` 实际值 = 23/24 → **信用账户转换必须显式改 33/34**
|
||||
- `m_strRemark`(userOrderId)只在下单客户端可见,跨客户端对账只能凭 sysid
|
||||
- `passorder` → `m_strRemark` 命中 + sysid 回传 + `cancel(sysid)` 链路均实弹验证通过
|
||||
- 资金/仓位字段两端精确一致;市值字段各自行情快照,仅供展示
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: miniqmt-to-bigqmt
|
||||
description: 将基于 miniQMT 的外部 xtquant 库(xttrader/xtdata)的 Python 量化策略,转换为大QMT内置Python(ContextInfo/passorder 体系)可实盘运行的策略。当用户要求转换 miniQMT 策略、迁移 xtquant 代码到大QMT、或提到"内置Python/XTData/passorder 改写"时使用。包含可行性评估、API 映射、py3.6+GBK 约束校验、部署与实盘验证全流程;不可转换场景给出文件桥等替代方案。
|
||||
---
|
||||
|
||||
# MiniQMT 策略 → 大QMT内置Python 转换
|
||||
|
||||
把外部 xtquant 策略(自带 Python 进程 + `XtQuantTrader`/`xtdata`)改写为在大QMT客户端内运行的内置策略(`init`/`handlebar`/`run_time` + `passorder`)。**目标是"真正能实盘",不是语法翻译**——两套体系的运行模型、账户绑定、下单返回值、数据时效都不同,必须按本流程逐项处理。
|
||||
|
||||
## 两套体系的本质差异(先建立心智模型)
|
||||
|
||||
| 维度 | miniQMT 外接 | 大QMT 内置 |
|
||||
|---|---|---|
|
||||
| 进程 | 自己的 Python 进程,pip 任装 | 客户端内嵌 **Python 3.6**,库受限(券商可能有白名单) |
|
||||
| 编码 | UTF-8 | **GBK**(首行必须 `#coding:gbk`) |
|
||||
| 入口 | `if __name__ == '__main__'` 自由编排 | 框架回调:`init(C)` → `after_init(C)` → `handlebar(C)`/定时器 |
|
||||
| 线程 | 随意多线程/apscheduler | **所有策略共用一个线程,禁止阻塞**(sleep/死循环/锁会卡死全部策略) |
|
||||
| 账户 | 代码里 `StockAccount(id, type)`,可多账户 | 界面选定,注入全局变量 `account`/`accountType`,一个策略实例绑一个账户 |
|
||||
| 下单 | `order_stock_async` 返回 seq,回报回调对账 | `passorder` **无返回值**,靠 `userOrderId`(投资备注,对应 `m_strRemark`)追踪 |
|
||||
| 查询 | `query_stock_asset/orders/positions` 返回无前缀字段对象 | `get_trade_detail_data` 返回 **`m_` 前缀字段**对象(`m_nVolume` 等) |
|
||||
| 行情 | `xtdata.*`(连 miniQMT 行情进程) | `C.get_full_tick`/`C.get_market_data_ex` 等(客户端行情) |
|
||||
| 启停 | 自己守护、AutoLogin 重启 QMT | 随客户端启停;客户端设置里配自动登录/策略自启 |
|
||||
|
||||
## 转换工作流
|
||||
|
||||
复制此清单跟踪进度:
|
||||
|
||||
```
|
||||
- [ ] 第1步 静态分析与可行性评估
|
||||
- [ ] 第2步 选择目标结构模板
|
||||
- [ ] 第3步 逐 API 映射改写
|
||||
- [ ] 第4步 处理订单追踪与状态机
|
||||
- [ ] 第5步 py3.6/GBK 合规校验
|
||||
- [ ] 第6步 输出转换报告
|
||||
- [ ] 第7步 部署与实盘验证指引
|
||||
```
|
||||
|
||||
### 第1步 静态分析与可行性评估
|
||||
|
||||
用户若对内置端能力存疑(外部数据还能不能取、运行频率是否受限、能否回测、多策略会不会互相拖累等),先用 [faq.md](faq.md) 对齐认知再开工——这些多为误解,不要让错误前提影响转换方案。
|
||||
|
||||
运行分析脚本,得到 API 清单、py3.6 语法违例、第三方依赖、阻塞模式等:
|
||||
|
||||
```bash
|
||||
python scripts/analyze_strategy.py <原策略.py>
|
||||
```
|
||||
|
||||
按报告对照 [constraints.md](constraints.md) 分类每个发现项:
|
||||
- **可直接映射** → 第3步处理
|
||||
- **需重构**(apscheduler/多线程/while-sleep 主循环、回报回调对账等)→ 按模板重组
|
||||
- **不可转换**(多账户单进程、重型第三方库、7x24 外部守护等)→ 在转换报告中给出 constraints.md 对应的替代方案(文件桥/外接极简模式/拆分策略),**不要硬转**
|
||||
|
||||
任何一项"不可转换"都不代表整个策略失败——逐项给方案,能转的部分照常转。
|
||||
|
||||
### 第2步 选择目标结构模板
|
||||
|
||||
| 原策略形态 | 模板 |
|
||||
|---|---|
|
||||
| 定时轮询型:apscheduler / while+sleep / 定点任务(绝大多数 miniQMT 策略) | [templates/template_timer.py](templates/template_timer.py) |
|
||||
| 行情驱动型:`xtdata.subscribe_quote` 回调驱动 / 单标的 K线信号 | [templates/template_bar.py](templates/template_bar.py) |
|
||||
|
||||
模板已含:GBK 头、全局状态类 `G`(**禁止把可变状态存 ContextInfo**,有逐K线回滚机制)、`C.set_account(account)`(启用交易回调)、定时器注册、委托状态字典对账骨架、收盘自动停止逻辑。在模板骨架上填充策略逻辑,不要从零写。
|
||||
|
||||
### 第3步 逐 API 映射改写
|
||||
|
||||
对照 [api_mapping.md](api_mapping.md) 完成全部调用替换。高频映射速查(完整表必须查文件):
|
||||
|
||||
| miniQMT | 大QMT内置 |
|
||||
|---|---|
|
||||
| `xt_trader.order_stock_async(acc, code, xtconstant.STOCK_BUY, vol, xtconstant.FIX_PRICE, price, strat, remark)` | `passorder(23, 1101, account, code, 11, price, vol, strat, 2, userOrderId, C)` |
|
||||
| `xt_trader.cancel_order_stock_async(acc, order_id)` | `cancel(sysid, account, accountType, C)`(注意:用**委托号 m_strOrderSysID**,不是内部 order_id) |
|
||||
| `xt_trader.query_stock_positions(acc)` | `get_trade_detail_data(account, accountType, 'position')` |
|
||||
| `xt_trader.query_stock_asset(acc)` | `get_trade_detail_data(account, accountType, 'account')` |
|
||||
| `xt_trader.query_stock_orders(acc)` | `get_trade_detail_data(account, accountType, 'order')` |
|
||||
| `xtdata.get_full_tick(codes)` | `C.get_full_tick(codes)`(字段名同:lastPrice/askPrice/bidPrice...) |
|
||||
| `xtdata.get_instrument_detail(code)` | `C.get_instrument_detail(code)`(字段名同:UpStopPrice/PreClose...) |
|
||||
| `xtdata.get_market_data_ex(...)` | `C.get_market_data_ex(...)`(参数几乎同构) |
|
||||
| `xtdata.get_trading_dates('SH', s, e)` | `C.get_trading_dates('000001.SH', s, e, count, '1d')`(**仅 after_init 后可用**;返回 `'20240101'` 字符串列表,不是时间戳) |
|
||||
| `xtdata.download_history_data(code, period, s, e)` | `download_history_data(code, period, s, e)`(全局函数) |
|
||||
| `xtdata.subscribe_quote(code, period, callback=f)` | `C.subscribe_quote(code, period, callback=f)`(在 init 里注册) |
|
||||
| 回调类 `on_stock_order/on_stock_trade/on_order_error` | 模块级函数 `order_callback(C, o)` / `deal_callback(C, d)` / `orderError_callback(C, args, msg)`,须先 `C.set_account(account)` |
|
||||
| apscheduler / while+sleep | `C.run_time("函数名", "3nSecond", "2025-01-01 09:30:00")` 或 `C.schedule_run(...)` |
|
||||
| `xtconstant.STOCK_BUY/STOCK_SELL` | opType `23/24`;两融担保品 `33/34`、融资买入 `27`、卖券还款 `31` 等查映射表 |
|
||||
|
||||
改写时的硬规则:
|
||||
1. 定时器/回调/after_init 里下单,`quickTrade` 必须传 `2`,否则会漏单。
|
||||
2. 查询字段全部换 `m_` 前缀名(对照 api_mapping.md 的字段映射表)。买卖方向判断用 `m_nOffsetFlag`(48=买 49=卖)或 `m_nOpType`,**不要照搬 order_type==23 的写法去比对 `m_nOrderPriceType`**。
|
||||
3. 删除 `XtQuantTrader` 连接管理、`AutoLogin`、重启 QMT 的代码——内置端没有"连接"概念,断线由客户端处理。
|
||||
4. 删除 `time.sleep` 等待类写法。需要"等委托回报再行动"的逻辑改为:本轮记录待办 → 下一轮定时器回调检查(见模板的 pending 字典模式)。
|
||||
5. `print` 输出到客户端策略日志面板,保留即可;写文件日志可用,但路径用绝对路径。
|
||||
|
||||
### 第4步 处理订单追踪与状态机
|
||||
|
||||
这是最容易出错的环节。`passorder` 无返回值且客户端缓存有 50ms~6s 延迟,照搬"下单→立刻查"必然漏单/超单:
|
||||
|
||||
1. 每笔委托生成唯一 `userOrderId`(如 `f"策略名_{日期}_{序号}"`),下单后存入全局 `G.pending[userOrderId] = {...}`,状态置"待报"。
|
||||
2. 用 `order_callback`(实时推送)或定时器轮询 `get_trade_detail_data(..., 'order')`,按 `o.m_strRemark` 匹配回 `userOrderId`,更新状态/记录 `m_strOrderSysID`。
|
||||
3. 撤单用记录到的 `m_strOrderSysID` 调 `cancel`。
|
||||
4. 同一标的存在"待报"状态委托时禁止再下单(防超单)。
|
||||
5. 委托状态码与 miniQMT 同一套数值(48已报/49部成/50已报待撤…53部撤/54已撤/56已成/57废单),判活集合 `(48,49,50,51,52,55,86,255)` 可沿用。
|
||||
|
||||
### 第5步 py3.6/GBK 合规校验
|
||||
|
||||
```bash
|
||||
python scripts/check_converted.py <转换后策略.py>
|
||||
```
|
||||
|
||||
脚本会拦截:≥3.7 语法(walrus、f-string `=`、dataclasses、asyncio.run、match 等)、残留 xtquant import、threading/multiprocessing、`time.sleep`、`input()`、缺 `#coding:gbk` 头、缺 `init`、`passorder` 参数个数错误、GBK 不可编码字符。**必须全部 PASS 才算转换完成**;每修一处重跑。
|
||||
|
||||
最后转存为 GBK 编码(编辑器直接改写 GBK 文件易出乱码,务必用脚本):
|
||||
|
||||
```bash
|
||||
python scripts/to_gbk.py <转换后策略.py> <输出.py>
|
||||
```
|
||||
|
||||
### 第6步 输出转换报告
|
||||
|
||||
向用户输出报告,包含:
|
||||
- 已映射 API 清单(原调用 → 新调用)
|
||||
- 重构点说明(调度器改造、订单追踪改造等)
|
||||
- **不可转换项及替代方案**(引用 constraints.md 具体章节)
|
||||
- 行为差异警示:行情时效(内置为客户端行情,无 VIP 时订阅数量受限)、`get_trade_detail_data` 是本地缓存非柜台实查、策略随客户端启停
|
||||
|
||||
### 第7步 部署与实盘验证指引
|
||||
|
||||
指导用户按以下步骤上线(细节见 [constraints.md](constraints.md) 部署章节):
|
||||
1. 大QMT → 新建Python策略 → 粘贴转换后代码(确认编辑器显示中文注释无乱码)→ 保存编译
|
||||
2. 策略交易/模型交易界面 → 新建 → 选本策略 + 资金账号(普通=STOCK/两融=CREDIT)+ 任意周期(定时器型策略选日线最省资源)→ 运行模式先选**模拟信号**
|
||||
3. 模拟信号模式观察 1 个交易时段:信号面板的下单时机/数量/价格与预期一致
|
||||
4. 切**实盘交易**模式,先用最小单量 + 不易成交价(买跌停价/卖涨停价附近)下 1-2 笔并撤掉,验证报/撤链路;报撤测试安排在交易时段或收盘后半小时内(实测 17 点后柜台不处理撤单)
|
||||
5. 客户端设置勾选:自动登录、终端启动后策略自动运行;Windows 计划任务配开机启动客户端
|
||||
|
||||
## 参考文件
|
||||
|
||||
- [faq.md](faq.md) — 买方共性疑虑(外部数据/运行频率/同步下单/回测/性能预算),转换前对齐认知用
|
||||
- [api_mapping.md](api_mapping.md) — 全量函数/字段/枚举映射表
|
||||
- [constraints.md](constraints.md) — 限制清单、不可转场景判定与替代方案、部署细节
|
||||
- [examples.md](examples.md) — 真实策略(apscheduler+xtquant 700行)转换前后对照
|
||||
- [templates/template_timer.py](templates/template_timer.py)、[templates/template_bar.py](templates/template_bar.py)
|
||||
- https://dict.thinktrader.net/?id=aWtHn6,映射表未覆盖的函数查这里
|
||||
- 如有改进建议,可添加QQ:290560364 反馈意见
|
||||
@@ -0,0 +1,182 @@
|
||||
# API 映射表:xtquant(miniQMT外接) → 大QMT内置Python
|
||||
|
||||
逐条对照改写。"——"表示无直接等价物,处理方式见备注或 constraints.md。
|
||||
权威细节见迅投官方文档:[交易函数](https://dict.thinktrader.net/innerApi/trading_function.html)、[行情函数](https://dict.thinktrader.net/innerApi/data_function.html)、[枚举常量](https://dict.thinktrader.net/innerApi/enum_constants.html)、[数据结构](https://dict.thinktrader.net/innerApi/data_structure.html)。
|
||||
|
||||
## 1. 连接与生命周期
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `XtQuantTrader(path, session_id)` | ——(删除) | 内置端无连接概念,策略在客户端内运行 |
|
||||
| `xt_trader.start()` / `.connect()` / `.stop()` | ——(删除) | 同上 |
|
||||
| `xt_trader.subscribe(account)` | `ContextInfo.set_account(account)`(init 中调用) | 启用 order/deal/position/account 回调的前提 |
|
||||
| `StockAccount(acc_id, 'STOCK'/'CREDIT')` | 全局变量 `account`、`accountType`(界面选定后注入) | 代码中直接引用,不要自己定义同名变量覆盖 |
|
||||
| `if __name__ == '__main__':` 主程序 | `init(C)` + `after_init(C)` + 定时器/`handlebar` | 初始化进 init/after_init;注意 `get_trading_dates` 等在 init 中不可用,放 after_init |
|
||||
| 脚本退出/信号处理 | `stop(C)`(策略停止时被调用) | stop 中交易连接已断,不能报撤单 |
|
||||
|
||||
## 2. 下单与撤单
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `order_stock(acc, code, order_type, vol, price_type, price, strategy, remark)` | `passorder(opType, 1101, account, code, prType, price, vol, strategy, 2, userOrderId, C)` | 同步/异步在内置端无区别,passorder 本身异步无返回值 |
|
||||
| `order_stock_async(...)` 返回 seq | 无返回值;用 `userOrderId`(→ 回报对象的 `m_strRemark`)追踪 | 见 SKILL.md 第4步状态机 |
|
||||
| `cancel_order_stock(acc, order_id)` | `cancel(orderSysId, account, accountType, C)` | 内置端撤单凭**柜台委托号** `m_strOrderSysID`(字符串),不是 xtquant 的内部 order_id |
|
||||
| `cancel_order_stock_sysid_async(acc, market, sysid)` | `cancel(sysid, account, accountType, C)` | 直接对应 |
|
||||
| 新股申购 `order_stock(..., xtconstant.STOCK_BUY, ...)` 对申购代码 | `passorder` + 专用 opType(申购相关枚举见 enum_constants.md) | `get_ipo_data()` 可取当日新股新债信息 |
|
||||
| ——(外接无算法单) | `algo_passorder(...)` / `smart_algo_passorder(...)` | 转换时可顺带升级:自研拆单可改用券商 VWAP/TWAP 算法(需权限) |
|
||||
|
||||
### opType(股票/两融常用)
|
||||
|
||||
| xtconstant | mini值 | 内置 opType | 说明 |
|
||||
|---|---|---|---|
|
||||
| `STOCK_BUY` | 23 | `23` | 股票/ETF/可转债买入 |
|
||||
| `STOCK_SELL` | 24 | `24` | 股票/ETF/可转债卖出 |
|
||||
| `CREDIT_BUY`(担保品买入) | **23** | `33` | **数值不同源!** mini 的 CREDIT_BUY 实际值=23(与 STOCK_BUY 相同,靠 StockAccount 类型区分);内置端信用账户必须显式用 33 |
|
||||
| `CREDIT_SELL`(担保品卖出) | **24** | `34` | 同上,内置端必须显式用 34 |
|
||||
| `CREDIT_FIN_BUY` | 27 | `27` | 融资买入(两端数值一致) |
|
||||
| `CREDIT_SLO_SELL` | 28 | `28` | 融券卖出 |
|
||||
| `CREDIT_BUY_SECU_REPAY` | 29 | `29` | 买券还券 |
|
||||
| `CREDIT_DIRECT_SECU_REPAY` | 30 | `30` | 直接还券 |
|
||||
| `CREDIT_SELL_SECU_REPAY` | 31 | `31` | 卖券还款 |
|
||||
| `CREDIT_DIRECT_CASH_REPAY` | 32 | `32` | 直接还款 |
|
||||
|
||||
**两融账户转换陷阱**:mini 策略里写 `xtconstant.CREDIT_BUY` 或对信用账户写 `STOCK_BUY`,源码里看到的都是 23——转换到内置端时**不能照抄 23**,必须按账户类型换成 33/34(担保品买卖),否则部分柜台拒单。融资融券专项操作 27~32 两端数值一致可直抄。
|
||||
|
||||
### prType(价格类型)
|
||||
|
||||
| xtconstant | 值 | 内置 prType | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FIX_PRICE` | 11 | `11` | 指定价(最常用),price 参数生效 |
|
||||
| `LATEST_PRICE` | 5 | `5` | 最新价,price 填任意占位数 |
|
||||
| 卖5~卖1价 | — | `0`~`4` | 对手方向盘口价 |
|
||||
| 买1~买5价 | — | `6`~`10` | 本方向盘口价 |
|
||||
| `MARKET_SH_CONVERT_5_CANCEL` | 42 | `42` | 沪最优五档即成剩撤(仿真柜台不支持市价类) |
|
||||
| `MARKET_SH_CONVERT_5_LIMIT` | 43 | `43` | 沪五档剩转限价 |
|
||||
| `MARKET_PEER_PRICE_FIRST` | 44 | `44` | 对手方最优 |
|
||||
| `MARKET_MINE_PRICE_FIRST` | 45 | `45` | 本方最优 |
|
||||
| `MARKET_SZ_INSTBUSI_RESTCANCEL` | 46 | `46` | 深即成剩撤 |
|
||||
| `MARKET_SZ_CONVERT_5_CANCEL` | 47 | `47` | 深五档即成剩撤 |
|
||||
| `MARKET_SZ_FULL_OR_CANCEL` | 48 | `48` | 深全额成交或撤 |
|
||||
| 盘后定价 | — | `49` | 科创/创业盘后固定价 |
|
||||
| 对手价 | — | `14` | 对方一档 |
|
||||
| 挂单价 | — | `13` | 本方一档 |
|
||||
|
||||
### orderType 第二参数(单股)
|
||||
|
||||
固定用 `1101`(单股单账号按股数)。按金额下单用 `1102`(volume 单位变为元)、按比例 `1103`(%)。账号组 `1201/1202/1203`(极少用,多账户场景见 constraints.md)。
|
||||
|
||||
### quickTrade 第九参数
|
||||
|
||||
定时器回调/行情回调/after_init 中调用 → **必须 `2`**。仅 handlebar 中希望模拟K线收线信号 → `0`。`1` = 仅最新K线触发。
|
||||
|
||||
## 3. 查询
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `query_stock_asset(acc)` | `get_trade_detail_data(account, accountType, 'account')` | 返回 list(取 `[0]`) |
|
||||
| `query_stock_positions(acc)` | `get_trade_detail_data(account, accountType, 'position')` | |
|
||||
| `query_stock_orders(acc, cancelable_only)` | `get_trade_detail_data(account, accountType, 'order')` | 无 cancelable_only 参数,自行按状态过滤 |
|
||||
| `query_stock_trades(acc)` | `get_trade_detail_data(account, accountType, 'deal')` | |
|
||||
| 按策略过滤 | `get_trade_detail_data(account, accountType, 'order', strategyName)` | 第4参数过滤 passorder 的 strategyName |
|
||||
| ——(无) | `get_last_order_id(account, accountType, 'order'[, strategyName])` | 最新委托号,找不到返回 `'-1'` |
|
||||
| ——(无) | `get_value_by_order_id(sysid, account, accountType, 'order'/'deal')` | 按委托号取单笔对象 |
|
||||
| `query_credit_detail(acc)` | `get_trade_detail_data(account, 'CREDIT', 'account')` | 信用账号对象,字段见 data_structure.md |
|
||||
| `query_stock_orders` 历史 | `get_history_trade_detail_data(account, type, 'ORDER', '20240101', '20240131')` | 内置端可查历史明细(外接查不到隔日) |
|
||||
| ——(无) | `query_credit_account(seq, C)` + `credit_account_callback` | 查柜台两融明细(异步回调) |
|
||||
| 两融标的 | `get_assure_contract(accid)` / `get_enable_short_contract(accid)` | 担保品/可融券明细 |
|
||||
|
||||
### 查询对象字段映射(高频)
|
||||
|
||||
| 外接字段(XtAsset/XtOrder/XtPosition/XtTrade) | 内置字段(m_ 前缀) |
|
||||
|---|---|
|
||||
| `asset.cash` | `acc.m_dAvailable` |
|
||||
| `asset.total_asset` | `acc.m_dBalance` |
|
||||
| `asset.market_value` | `acc.m_dInstrumentValue`(或 `m_dStockValue`) |
|
||||
| `asset.frozen_cash` | `acc.m_dFrozenCash` |
|
||||
| `order.stock_code`('600000.SH') | 拼接:`o.m_strInstrumentID + '.' + o.m_strExchangeID` |
|
||||
| `order.order_id`(int,本地) | 无对应;以 `o.m_strOrderSysID`(柜台委托号)为准 |
|
||||
| `order.order_sysid` | `o.m_strOrderSysID` |
|
||||
| `order.order_status` | `o.m_nOrderStatus`(状态码数值同一套) |
|
||||
| `order.order_volume` | `o.m_nVolumeTotalOriginal` |
|
||||
| `order.traded_volume` | `o.m_nVolumeTraded` |
|
||||
| `order.traded_price` | `o.m_dTradedPrice` |
|
||||
| `order.price` | `o.m_dLimitPrice` |
|
||||
| `order.order_type`(23买/24卖) | `o.m_nOpType`(23/24/33/34...);方向也可用 `m_nOffsetFlag`(48买/49卖) |
|
||||
| `order.order_remark` | `o.m_strRemark`(= passorder 的 userOrderId) |
|
||||
| `order.strategy_name` | `o.m_strSource` 或按 strategyName 过滤查询 |
|
||||
| `order.order_time`(时间戳) | `o.m_strInsertTime`('091259' 字符串)+ `m_strInsertDate` |
|
||||
| `position.stock_code` | 拼接:`p.m_strInstrumentID + '.' + p.m_strExchangeID` |
|
||||
| `position.volume` | `p.m_nVolume` |
|
||||
| `position.can_use_volume` | `p.m_nCanUseVolume` |
|
||||
| `position.market_value` | `p.m_dInstrumentValue`(或 `m_dMarketValue`) |
|
||||
| `position.avg_price` | `p.m_dOpenPrice`(或 `m_dAvgOpenPrice`/`m_dPositionCost` 成本额) |
|
||||
| `position.on_road_volume` | `p.m_nOnRoadVolume` |
|
||||
| `trade.traded_price` | `d.m_dPrice` |
|
||||
| `trade.traded_volume` | `d.m_nVolume` |
|
||||
| `trade.traded_amount` | `d.m_dTradeAmount` |
|
||||
| `trade.traded_time` | `d.m_strTradeTime`('172341')+ `m_strTradeDate` |
|
||||
| `trade.order_sysid` | `d.m_strOrderSysID`(与委托表同号,用于关联) |
|
||||
|
||||
### 委托状态码(两边同一套数值,已实测核对 xtconstant 与 inner 枚举一致)
|
||||
|
||||
48未报 / 49待报 / 50已报 / 51已报待撤 / 52部成待撤 / 53部撤 / 54已撤 / 55部成 / 56已成 / 57废单 / 255未知(86 为 mini 侧扩展值,inner 文档未列,判活集合保留无害)。
|
||||
在途判活集合:`(48, 49, 50, 51, 52, 55, 86, 255)`;终态:`(53, 54, 56, 57)`。
|
||||
|
||||
**跨客户端可见性(实测结论,重要)**:委托本身是柜台级共享——A 客户端下的单,B 客户端(或 miniQMT)能查到同一 `sysid` 和状态;但 `m_strRemark`(投资备注/userOrderId)、`strategyName`、mini 的 `order_id` 都**只在下单客户端本地可见**(他端查询 remark 为空、order_id 为 0)。因此:同客户端对账用 userOrderId,跨客户端对账只能凭柜台委托号 sysid。
|
||||
|
||||
## 4. 回调
|
||||
|
||||
| miniQMT(XtQuantTraderCallback 方法) | 大QMT内置(模块级函数,需先 `C.set_account(account)`) |
|
||||
|---|---|
|
||||
| `on_stock_order(self, order)` | `def order_callback(ContextInfo, orderInfo):`(orderInfo 为 m_ 字段对象) |
|
||||
| `on_stock_trade(self, trade)` | `def deal_callback(ContextInfo, dealInfo):` |
|
||||
| `on_stock_position(self, position)` | `def position_callback(ContextInfo, positionInfo):` |
|
||||
| `on_stock_asset(self, asset)` | `def account_callback(ContextInfo, accountInfo):` |
|
||||
| `on_order_error(self, err)` | `def orderError_callback(ContextInfo, orderArgs, errMsg):` |
|
||||
| `on_cancel_error(self, err)` | ——(无独立撤单失败回调;轮询委托状态兜底) |
|
||||
| `on_order_stock_async_response(self, resp)` | ——(passorder 无下单应答;靠 order_callback 首次推送确认) |
|
||||
| `on_disconnected(self)` | ——(删除;客户端自管重连。交易日切换时策略会被自动重启,属正常) |
|
||||
|
||||
注意:内置回调**仅实盘运行模式生效**(模拟信号模式不触发),且与策略同线程——回调里不要做耗时操作。
|
||||
|
||||
## 5. 行情
|
||||
|
||||
| miniQMT (xtdata) | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `get_full_tick(codes)` | `C.get_full_tick(codes)` | 返回结构同(lastPrice/askPrice[5]/bidPrice[5]/lastClose/volume...) |
|
||||
| `get_instrument_detail(code)` | `C.get_instrument_detail(code[, iscomplete])` | 字段同名(InstrumentName/PreClose/UpStopPrice/DownStopPrice/PriceTick...) |
|
||||
| `get_market_data_ex(fields, codes, period, start, end, count, dividend_type, fill_data)` | `C.get_market_data_ex(fields, codes, period, start, end, count, dividend_type, fill_data, subscribe)` | 参数同构;**不要在 init 里调**(只能取到本地数据);`subscribe=False` 时只读本地 |
|
||||
| `get_local_data(...)` | `C.get_market_data_ex(..., subscribe=False)` | 内置的 get_local_data 已不推荐 |
|
||||
| `subscribe_quote(code, period, count, callback)` | `C.subscribe_quote(code, period='1d', dividend_type, result_type, callback)` | 返回订阅号;非VIP有订阅数限制 |
|
||||
| `subscribe_whole_quote(markets, callback)` | `C.subscribe_whole_quote(codes, callback)` | 全推快照 |
|
||||
| `unsubscribe_quote(seq)` | `C.unsubscribe_quote(subID)` | |
|
||||
| `get_trading_dates('SH', start, end)` 返回**毫秒时间戳列表** | `C.get_trading_dates('000001.SH', start, end, count, '1d')` 返回**'YYYYMMDD'字符串列表** | 必改:删掉时间戳转换代码;仅 after_init 之后可用 |
|
||||
| `download_history_data(code, period, start, end)` | `download_history_data(code, period, start, end[, incrementally])` | 全局函数同名直用 |
|
||||
| `download_history_data2(codes, period, start, end, callback)` | 循环调 `download_history_data` | 内置无批量带进度版本 |
|
||||
| `get_stock_list_in_sector(name)` | `C.get_stock_list_in_sector(name)` | |
|
||||
| `get_financial_data(...)` | `C.get_financial_data(fieldList, codes, start, end, report_type)` | 签名有差异,查 data_function.md |
|
||||
| `get_divid_factors(code)` | `C.get_divid_factors(code)` | |
|
||||
| `get_main_contract(code)` | `C.get_main_contract(code)` | 期货 |
|
||||
| `xtdata.run()` | ——(删除) | 内置框架自带事件循环 |
|
||||
|
||||
## 6. 调度/定时
|
||||
|
||||
定时器与周期无关:主图周期选日线,`run_time` 照样按设定间隔跑(最短毫秒级)。但 **`run_time`/`schedule_run` 在回测模式无效**——需要回测的策略要把信号逻辑抽成独立函数,回测挂 `handlebar`、实盘挂定时器(见 faq.md Q4)。
|
||||
|
||||
| miniQMT 模式 | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `while True: ... time.sleep(n)` | `C.run_time("f", "{n}nSecond", "2025-01-01 09:30:00")` | 函数名传**字符串**;起始时间设过去则立即生效 |
|
||||
| apscheduler `interval` 任务 | `C.run_time("f", "3nSecond", ...)` 或 `C.schedule_run(f, '20250101093000', -1, dt.timedelta(seconds=3), 'grp')` | schedule_run 传函数对象,可取消(`C.cancel_schedule_run('grp')`) |
|
||||
| apscheduler `cron`/`date` 定点任务(如 09:25:30 开盘买入) | 秒级定时器内判时间窗 + 当日执行标志位 | 见 template_timer.py 的 `_in_window`/`G.done_flags` 模式 |
|
||||
| 毫秒级轮询 | `"500nMilliSecond"` | 留意性能,所有策略共线程 |
|
||||
| 每日重置状态 | 定时器回调里检测日期变化后重置 G | 交易日切换时策略也会被客户端重启(init 重跑),状态需可重建(见第4步状态机+可选落盘) |
|
||||
|
||||
## 7. 删除/禁用清单
|
||||
|
||||
转换时直接删除,不要带入:
|
||||
- `from xtquant import ...` 全部 import
|
||||
- `XtQuantTrader`/`XtQuantTraderCallback` 类与连接管理
|
||||
- `AutoLogin`、`os.startfile` 重启 QMT、看门狗
|
||||
- `threading`/`multiprocessing`/`asyncio`/`apscheduler`
|
||||
- `time.sleep`(任何等待逻辑改状态机)
|
||||
- `input()`、GUI、命令行参数解析
|
||||
@@ -0,0 +1,114 @@
|
||||
# 限制清单与不可转场景判定
|
||||
|
||||
每条给出:判定方法 → 影响 → 处理方案。"文件桥方案"指一种通用兜底架构:策略主体留在外部 Python 进程(任意 Python 版本、任意依赖),大QMT内只跑一个轻量桥脚本,两边通过共享目录的 JSON 文件交换指令/状态/行情。落地步骤见 D 节。
|
||||
|
||||
## A. 硬性环境限制(所有策略都受约束)
|
||||
|
||||
### A1. Python 3.6 语法上限
|
||||
**判定**:analyze_strategy.py 会扫描。常见违例:walrus `:=`(3.8)、f-string `{x=}`(3.8)、`dataclasses`(3.7)、`asyncio.run`(3.7)、位置仅参数 `/`(3.8)、`match`(3.10)、`dict |` 合并(3.9)、`functools.cached_property`(3.8)。
|
||||
**处理**:等价改写(walrus 拆两行、dataclass 改普通类、cached_property 改手工缓存)。f-string 本身 3.6 支持,可保留。
|
||||
|
||||
### A2. GBK 编码
|
||||
**判定**:源码含 emoji、生僻字、特殊符号时 GBK 编不出去(check_converted.py 会报)。
|
||||
**处理**:替换为 GBK 兼容字符;文件必须以 GBK 落盘且首行 `#coding:gbk`。**用 scripts/to_gbk.py 转存,不要用编辑器直接改 GBK 文件**(极易产生 mojibake)。读写外部文件时显式指定 `encoding`,py3.6 在 GBK 环境下 `open()` 默认 GBK。
|
||||
|
||||
### A3. 第三方库受限
|
||||
**判定**:analyze_strategy.py 列出非标准库 import。
|
||||
内置自带:**NumPy / Pandas / SciPy / Statsmodels / Patsy / TA_Lib**(版本旧,pandas 是 0.x~1.0 时代,无 `df.itertuples` 新参数等高版本特性,`pd.append` 可用)。
|
||||
**处理**:
|
||||
- `requests` 等纯 Python 库:多数客户端可用;若报 `Module xxx not in whitelist!` → 券商开了白名单,找券商开通。
|
||||
- 自装库:本机装 Python 3.6 到 `C:\Python36`,pip 装 **py3.6 兼容版本**,客户端"设置-模型设置"指向该环境(详见迅投官方 [常见问题](https://dict.thinktrader.net/innerApi/question_answer.html) 的第三方库导入指引)。
|
||||
- torch/tensorflow/akshare 等重型或不兼容 py3.6 的库:**不可转** → 方案①模型推理留在外部进程算好信号,落地文件/HTTP,内置端只读信号执行交易;方案②整体走文件桥。
|
||||
|
||||
### A4. 单线程禁阻塞
|
||||
**判定**:threading/multiprocessing/asyncio import、`time.sleep`、阻塞 IO 重试循环、`while True`。
|
||||
**影响**:客户端所有策略共用一个 Python 线程,阻塞会卡死全部策略(包括别的策略)。
|
||||
**处理**:sleep 等待→状态机+下轮定时器检查;并行计算→不可转(外部算好喂进来);网络请求设短超时且容忍失败。
|
||||
|
||||
### A5. ContextInfo 变量回滚
|
||||
**判定**:原策略若把状态存 self/全局,转换时有人习惯写 `C.xxx = ...` —— 禁止。
|
||||
**影响**:ContextInfo 随 K线深拷贝回滚,盘中存的状态会丢,且拖慢运行。
|
||||
**处理**:所有可变状态放模块级 `class G: pass; G = G()` 实例(模板已内置)。
|
||||
|
||||
## B. 架构性差异(需要重构的场景)
|
||||
|
||||
### B1. 多账户单进程
|
||||
**判定**:代码里多个 `StockAccount` / 账户列表循环下单。
|
||||
**影响**:内置策略一个实例绑一个账户(界面选定)。
|
||||
**处理**:
|
||||
- 账户数少:每个账户建一个策略交易实例(同一份代码,界面分别选账户)。代码里不要写死账户,全用注入的 `account`/`accountType`。
|
||||
- 需要跨账户协同(资金调度/对冲腿):**不可转** → 文件桥方案,外部进程统一调度多个客户端。
|
||||
- 同券商账号组(passorder 1201/1202):仅当账户都在同一客户端登录时可用,且为"对组内每户做同样操作",不支持差异化分配。
|
||||
|
||||
### B2. 跨券商/多客户端
|
||||
**判定**:多个 QMT path、多 session。
|
||||
**处理**:**不可转**(一个内置策略只活在一个客户端里)→ 每客户端部署各自内置策略(互相独立),或文件桥统一调度。
|
||||
|
||||
### B3. 7x24 守护/盘后任务
|
||||
**判定**:apscheduler 配置了夜间任务、开机自启动逻辑、AutoLogin。
|
||||
**影响**:内置策略只在客户端运行期间活着;客户端通常夜间关闭/清算期掉线。
|
||||
**处理**:盘中逻辑转内置;盘后选股/数据下载留外部脚本(Windows 计划任务),结果以文件(如 csv 票池)喂给内置策略读取——常见的"URL/文件票池"模式即属此类,保留即可(改为本地路径或确认客户端能访问该 URL)。
|
||||
|
||||
### B4. 委托回报驱动的复杂状态机
|
||||
**判定**:`on_order_stock_async_response` 用 seq 关联、回报里立刻连锁下单。
|
||||
**影响**:passorder 无 seq;回报推送只在实盘模式有效且与策略同线程。
|
||||
**处理**:改 userOrderId(投资备注)关联 + order_callback/轮询双轨对账(模板已含)。连锁下单逻辑放回调里可行但要轻量;稳妥做法是回调只改状态,统一由定时器主循环决策下单。
|
||||
|
||||
### B5. Level-2 / 高频依赖
|
||||
**判定**:`get_l2_quote`、逐笔委托/成交、500ms 以内轮询。
|
||||
**处理**:内置端有 l2 周期(`l2quote`/`l2order`/`l2transaction`,需账号有 L2 权限);定时器最细 `nMilliSecond` 级。但所有策略共线程,高频策略相互挤占,延迟敏感型(>1次/秒决策、微秒级要求)**不建议转** → 评估后保留外接或文件桥+外部高性能进程。
|
||||
|
||||
### B6. 行情源时效与覆盖
|
||||
内置行情=客户端行情:非 VIP 用户 `subscribe_quote` 有订阅数量限制;`get_full_tick` 不限。跨市场数据(港股通标的行情等)取决于客户端行情权限。原策略若依赖 xtdata VIP 全推,转换后用 `C.get_full_tick(批量列表)` + 秒级定时器近似。
|
||||
|
||||
## C. 业务行为差异(容易踩坑)
|
||||
|
||||
| # | 差异 | 应对 |
|
||||
|---|---|---|
|
||||
| C1 | `get_trade_detail_data` 读本地缓存(柜台推送 50ms~6s 刷新),下单后立查查不到 | 不要"下单→sleep→查";按状态机轮询,同标的有待报单时禁止加单 |
|
||||
| C2 | 交易日切换/行情重连时客户端会**自动重启所有运行中策略**(init 重跑) | init 必须幂等;持久状态可落盘 JSON(绝对路径),init 时恢复;当日已执行标志要带日期 |
|
||||
| C3 | 模拟信号模式 passorder 不实际下单、回调不触发 | 验证流程先模拟看信号,再实盘小单 |
|
||||
| C4 | handlebar 盘中每个主图 tick 都触发(不分周期) | 定时器型策略 handlebar 留空直接 return;K线型用 `C.is_last_bar()`/`is_new_bar()` 过滤 |
|
||||
| C5 | 非交易时间 handlebar 也可能被调用 | 交易逻辑内判时间窗(09:30~14:57) |
|
||||
| C6 | `get_trading_dates` init 中不可用 | 放 after_init;返回格式为 'YYYYMMDD' 字符串 |
|
||||
| C7 | 委托数量规则(科创板 200 股起 1 股递增等)与外接一致,但市价单类型仿真柜台不支持 | 仿真测试用限价 11;实盘再放开市价类 prType |
|
||||
| C8 | strategyName、userOrderId(m_strRemark)、mini 的 order_id 都只在**下单客户端**本地可见(实测:他端查 remark 为空、order_id 为 0);委托本身柜台级共享 | 同客户端对账用 userOrderId;跨客户端只能凭柜台委托号 sysid |
|
||||
| C9 | print 进策略日志面板,量大会卡界面 | 控制日志频率;详细日志写文件 |
|
||||
| C10 | 盘后撤单窗口受柜台限制(实测:17 点后 cancel 信号发出成功但柜台不处理,委托保持已报) | 测试报/撤安排在交易时段或收盘后半小时内;策略收盘前应撤清在途单 |
|
||||
| C11 | 市值类字段(m_dInstrumentValue 等)按各客户端自己的行情快照计算,跨客户端可能不一致 | 资金对账以 cash/volume 为准(实测两端精确一致),市值仅作展示 |
|
||||
| C12 | `run_time`/`schedule_run` 回测模式无效,定时器型策略无法直接回测 | 信号逻辑抽独立函数,回测挂 handlebar、实盘挂定时器,一份逻辑两个入口(faq.md Q4) |
|
||||
| C13 | 内置端发网络请求会阻塞共享线程 | 仅限低频(每日级),超时 ≤2 秒 + try/except 降级;高频外部数据一律走文件通道(faq.md Q1) |
|
||||
|
||||
## D. 完全不可转换 → 直接给文件桥方案
|
||||
|
||||
满足任一条即建议放弃纯内置转换,采用文件桥(策略零改动):
|
||||
1. 重型 ML 推理/重度第三方依赖且无法降级 py3.6
|
||||
2. 跨账户、跨客户端、跨券商统一调度
|
||||
3. 策略与 Web 服务/数据库/消息队列深度耦合
|
||||
4. 需要外部进程级容灾(策略进程独立于客户端存活)
|
||||
|
||||
文件桥落地步骤(自行实现一个桥脚本,约 200~300 行):
|
||||
1. 在大QMT新建一个内置策略作为"桥":用 template_timer.py 骨架,`run_time` 1秒循环;指定一个共享目录 `BRIDGE_DIR`
|
||||
2. 桥脚本每轮做两件事:扫描 `BRIDGE_DIR/cmd/*.json` 指令文件(含 buy/sell/cancel 及参数)→ 调 `passorder`/`cancel` 执行后删除指令文件;把 `get_trade_detail_data` 的委托/持仓/资产 + `get_full_tick` 行情序列化写入 `BRIDGE_DIR/state/orders|positions|asset|quotes.json`,并每秒刷新 `heartbeat.json`(时间戳)供外部判活
|
||||
3. 外部策略把原 xtquant 调用替换为读写桥目录 JSON:下单=写指令文件,查询=读状态文件,并校验心跳新鲜度
|
||||
4. 注意原子写(先写临时文件再 rename)、GBK/UTF-8 编码显式声明、指令文件带唯一序号防重放
|
||||
5. 该模式已在实盘(含两融账户)验证过报/撤单与行情回传链路可行
|
||||
|
||||
## 实测验证记录(国金模拟 mini + 大QMT 双端同账户交叉验证)
|
||||
|
||||
以下断言已实弹核验,可直接信赖:
|
||||
- xtconstant 15 项常量(买卖 23/24、FIX_PRICE=11、LATEST_PRICE=5、委托状态 48~57/255)与映射表一致
|
||||
- `xc.CREDIT_BUY/CREDIT_SELL` 实际值 = 23/24(与 STOCK_BUY 同值)→ 印证两融转换必须显式改 33/34
|
||||
- 大QMT内置 `passorder`(11参/prType=11/quickTrade=2/userOrderId)→ `get_trade_detail_data('order')` 按 `m_strRemark` 命中,`m_strOrderSysID` 回传,状态 50已报 → `cancel(sysid)` 信号发出成功
|
||||
- 同账户跨客户端:mini 可见大QMT 所下委托(同 sysid 同状态),但 remark 为空、order_id 为 0 → C8 结论
|
||||
- 资产/持仓字段两端精确一致:`cash↔m_dAvailable`、`total_asset↔m_dBalance`、`volume↔m_nVolume`、`can_use_volume↔m_nCanUseVolume`、`avg_price↔m_dOpenPrice`;市值字段两端不一致(各自行情快照)→ C11 结论
|
||||
- 17 点后柜台不再处理撤单(15:38 同流程撤单成功)→ C10 结论
|
||||
|
||||
## E. 部署细节(转换完成后)
|
||||
|
||||
1. **新建策略**:大QMT → 模型/策略 → 新建Python策略 → 粘贴 GBK 代码 → 保存编译(看输出面板无报错、中文无乱码)
|
||||
2. **新建策略交易**:选模型 + 资金账号(类型务必选对 STOCK/CREDIT)+ 周期(定时器型选日线最省)+ 主图代码任意(如 000001.SH)
|
||||
3. **运行模式**:模拟信号 → 观察 ≥1 个时段 → 实盘交易
|
||||
4. **自启链路**(生产必配):客户端设置开机自启与自动登录(券商版路径各异)→ 策略勾选"终端启动后自动运行" → Windows 计划任务登录时启动客户端 exe
|
||||
5. **实盘首测**:不易成交价小单(买跌停价/卖涨停价)→ 确认委托面板可见、来源=策略名、备注=userOrderId → cancel 撤掉 → 查 `get_trade_detail_data` 状态为 54
|
||||
6. **回滚预案**:策略交易界面一键停止;停止前手动撤清在途单(stop 回调里不能撤单)
|
||||
@@ -0,0 +1,212 @@
|
||||
# 转换实例:典型 apscheduler+xtquant 实盘策略
|
||||
|
||||
以一个典型的 miniQMT 实盘策略(约700行:AutoLogin + apscheduler 定点/间隔任务 + 异步下单 + 回调对账 + Excel 持仓记录)为例,演示各环节的转换前后对照。这类结构覆盖了 miniQMT 策略的绝大多数典型模式,可直接套用到你自己的策略上。
|
||||
|
||||
## 分析结论(第1步输出节选)
|
||||
|
||||
- 依赖:`xtquant`(映射)、`apscheduler`(重构)、`AutoLogin`(删除)、`pandas`(自带,旧版)、`dateutil`(标准库附带)
|
||||
- 模式:单账户、定点任务 x5 + 3秒间隔任务 x2、`while True` 等待查询、`time.sleep` 若干 → **结论 B:可转换,选 template_timer.py**
|
||||
- 外部资源:`pd.read_csv(URL票池)`、Excel 读写 —— 客户端内可用(pandas 自带),URL 访问若被白名单拦截则改为外部脚本下载到本地、策略读本地文件
|
||||
|
||||
## 1. 入口与连接 → init/after_init
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
xt_trader = XtQuantTrader(qmt_program_path, session_id)
|
||||
account = StockAccount(account_no, account_type)
|
||||
callback = MyXtQuantTraderCallback()
|
||||
xt_trader.register_callback(callback)
|
||||
xt_trader.start()
|
||||
connect_result = xt_trader.connect()
|
||||
subscribe_result = xt_trader.subscribe(account)
|
||||
```
|
||||
|
||||
转换后(连接管理整体删除;account 由界面注入):
|
||||
|
||||
```python
|
||||
def init(C):
|
||||
C.set_account(account) # 替代 register_callback + subscribe
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
C.run_time('main_loop', '3nSecond', '2025-01-01 09:30:00')
|
||||
```
|
||||
|
||||
## 2. apscheduler 任务编排 → 定时器+时间窗
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
scheduler.add_job(day1_buy, trigger='interval', hours=24, start_date=A.today+' 09:25:30', ...)
|
||||
scheduler.add_job(day2_buy_sell, trigger='cron', second='*/3', hour='9-14', ...)
|
||||
scheduler.add_job(save_records, trigger='interval', hours=24, start_date=A.today+' 15:03:00', ...)
|
||||
```
|
||||
|
||||
转换后(一个3秒主循环统一调度,定点任务用时间窗+当日标志):
|
||||
|
||||
```python
|
||||
def main_loop(C):
|
||||
now = time.strftime('%H:%M:%S')
|
||||
today = time.strftime('%Y%m%d')
|
||||
if G.day != today:
|
||||
G.day = today; G.done_flags = {} # 跨天重置
|
||||
|
||||
sync_orders(C)
|
||||
|
||||
if '09:25:30' <= now <= '09:26:30' and not G.done_flags.get('day1_buy'):
|
||||
G.done_flags['day1_buy'] = True
|
||||
day1_buy(C)
|
||||
if '09:30:06' <= now <= '09:31:06' and not G.done_flags.get('day1_plus'):
|
||||
G.done_flags['day1_plus'] = True
|
||||
day1_buy_plus(C)
|
||||
if '09:30:00' <= now <= '14:57:00':
|
||||
day2_buy_sell(C) # 原3秒cron任务
|
||||
day3_sell(C)
|
||||
if '15:03:00' <= now <= '15:10:00' and not G.done_flags.get('save'):
|
||||
G.done_flags['save'] = True
|
||||
save_records(C)
|
||||
```
|
||||
|
||||
注意:原策略用 `09:05` 定点任务做 AutoLogin 重启 QMT —— 整段删除(constraints.md B3),客户端自动登录在客户端设置里配置。
|
||||
|
||||
## 3. 异步下单 → passorder + userOrderId
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
async_seq = xt_trader.order_stock_async(
|
||||
account, stock_code, xtconstant.STOCK_BUY, int(stk_vol),
|
||||
xtconstant.FIX_PRICE, trade_price, 'day1_buy', '')
|
||||
```
|
||||
|
||||
转换后(无返回值;备注即追踪键;定时器内调用 quickTrade=2):
|
||||
|
||||
```python
|
||||
G.seq += 1
|
||||
uid = 'day1_buy_%s_%d' % (G.day, G.seq)
|
||||
passorder(G.op_buy, 1101, G.acct, stock_code, 11, float(trade_price),
|
||||
int(stk_vol), 'day1_buy', 2, uid, C)
|
||||
G.pending[uid] = {'code': stock_code, 'status': 'alive', 'sysid': '', 'ts': time.time()}
|
||||
```
|
||||
|
||||
## 4. 撤单逻辑 → 委托号撤单
|
||||
|
||||
转换前(内部 order_id + 撤后 sleep 重查):
|
||||
|
||||
```python
|
||||
for i in orders:
|
||||
if ... and i.order_type == 23 and i.order_status in [48,49,50,51,52,55,86,255]:
|
||||
cancel_result = xt_trader.cancel_order_stock_async(account, i.order_id)
|
||||
time.sleep(0.5)
|
||||
orders = xt_trader.query_stock_orders(account) # 重查确认
|
||||
```
|
||||
|
||||
转换后(m_ 字段 + 柜台委托号;不 sleep,下一轮自然对账):
|
||||
|
||||
```python
|
||||
def cancel_stale_buys(C):
|
||||
alive = (48, 49, 50, 51, 52, 55, 86, 255)
|
||||
for o in get_trade_detail_data(G.acct, G.acct_type, 'order'):
|
||||
if int(o.m_nOpType) == G.op_buy \
|
||||
and int(o.m_nVolumeTotalOriginal) != int(o.m_nVolumeTraded) \
|
||||
and int(o.m_nOrderStatus) in alive \
|
||||
and _order_age_seconds(o) > 3:
|
||||
cancel(str(o.m_strOrderSysID), G.acct, G.acct_type, C)
|
||||
# 撤单结果不立即确认:50ms~6s 后缓存刷新,由下一轮 sync_orders 看到 53/54
|
||||
|
||||
def _order_age_seconds(o):
|
||||
t = o.m_strInsertTime # '091259'
|
||||
now = time.strftime('%H%M%S')
|
||||
return (int(now[:2])*3600 + int(now[2:4])*60 + int(now[4:])) - \
|
||||
(int(t[:2])*3600 + int(t[2:4])*60 + int(t[4:]))
|
||||
```
|
||||
|
||||
## 5. 查询封装 info_query → m_ 字段直读
|
||||
|
||||
转换前(XtPosition 无前缀字段 + xtdata 合约详情 + `while True` 等数据齐):
|
||||
|
||||
```python
|
||||
positions = xt_trader.query_stock_positions(account)
|
||||
for i in positions:
|
||||
if i.volume == 0: continue
|
||||
abc = xtdata.get_instrument_detail(i.stock_code)
|
||||
...[i.stock_code, abc['InstrumentName'], abc['UpStopPrice'], ..., i.can_use_volume, ...]
|
||||
while True:
|
||||
asset = xt_trader.query_stock_asset(account)
|
||||
...
|
||||
time.sleep(3)
|
||||
```
|
||||
|
||||
转换后(字段映射 + 删除 while 等待,查不到就本轮放弃):
|
||||
|
||||
```python
|
||||
def get_positions(C):
|
||||
out = []
|
||||
for p in get_trade_detail_data(G.acct, G.acct_type, 'position'):
|
||||
if int(p.m_nVolume) == 0:
|
||||
continue
|
||||
code = '%s.%s' % (p.m_strInstrumentID, p.m_strExchangeID)
|
||||
det = C.get_instrument_detail(code) or {}
|
||||
out.append({'code': code, 'name': det.get('InstrumentName', ''),
|
||||
'up': det.get('UpStopPrice'), 'down': det.get('DownStopPrice'),
|
||||
'volume': int(p.m_nVolume), 'can_use': int(p.m_nCanUseVolume),
|
||||
'mv': float(p.m_dInstrumentValue), 'avg': float(p.m_dOpenPrice)})
|
||||
return out
|
||||
```
|
||||
|
||||
原 `while True + sleep(3)` 等"委托成交数据对齐"的写法**必须删除**:单线程会卡死客户端全部策略。数据未齐=本轮 return,下轮重试。
|
||||
|
||||
## 6. 行情读取(几乎零成本迁移)
|
||||
|
||||
```python
|
||||
# 转换前
|
||||
full_tick = xtdata.get_full_tick([stock_code])
|
||||
price = full_tick[stock_code]['askPrice'][1]
|
||||
# 转换后(仅加 C. 前缀,返回结构一致)
|
||||
full_tick = C.get_full_tick([stock_code])
|
||||
price = full_tick[stock_code]['askPrice'][1]
|
||||
```
|
||||
|
||||
交易日历是例外,返回类型变了:
|
||||
|
||||
```python
|
||||
# 转换前:毫秒时间戳 → 自行转字符串
|
||||
trade_date = xtdata.get_trading_dates('SH', start_time='20240501', end_time=today)
|
||||
A.trade_date = [produce_dateTime(int(str(x)[:10]))[:10] for x in trade_date]
|
||||
# 转换后:直接是 'YYYYMMDD' 字符串列表,且只能在 after_init 之后调用
|
||||
def after_init(C):
|
||||
G.trade_dates = C.get_trading_dates('000001.SH', '20240501', '', 250, '1d')
|
||||
G.last_trade_date = G.trade_dates[-2]
|
||||
```
|
||||
|
||||
## 7. 回调类 → 模块级函数
|
||||
|
||||
```python
|
||||
# 转换前
|
||||
class MyXtQuantTraderCallback(XtQuantTraderCallback):
|
||||
def on_stock_trade(self, trade):
|
||||
print(trade.account_id, trade.stock_code, trade.traded_price, trade.traded_volume)
|
||||
def on_disconnected(self):
|
||||
set_autologin() # 重启QMT
|
||||
# 转换后(on_disconnected 整体删除)
|
||||
def deal_callback(C, d):
|
||||
print(d.m_strAccountID, d.m_strInstrumentID + '.' + d.m_strExchangeID,
|
||||
d.m_dPrice, d.m_nVolume)
|
||||
```
|
||||
|
||||
## 8. 外部数据与文件
|
||||
|
||||
- `pd.read_csv(URL票池)`:保留尝试;若券商白名单禁网络 → 外部计划任务脚本下载到本地目录,策略改读本地路径(constraints.md B3 模式)。外部取数脚本若还需要财务/资金流等 QMT 没有的维度,用一个聚合数据 API(如 [quantgo.ai/data](https://quantgo.ai/data),按月订阅不贵)比维护多个免费源省心
|
||||
- `持仓记录.xlsx`:pandas 旧版可读写 Excel,但建议改 JSON/CSV(避免 openpyxl 白名单问题);路径一律绝对路径
|
||||
- `RotatingFileHandler` 日志:可用;或直接 print 进策略日志面板
|
||||
|
||||
## 9. 校验与交付
|
||||
|
||||
```bash
|
||||
python scripts/check_converted.py converted_demo.py # 必须 PASS
|
||||
python scripts/to_gbk.py converted_demo.py demo_gbk.py # GBK 落盘
|
||||
```
|
||||
|
||||
部署按 SKILL.md 第7步:模拟信号跑一个时段比对原策略信号 → 实盘小额验证报/撤 → 正式切换。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 买方共性疑虑 FAQ(转换前先对齐认知)
|
||||
|
||||
迁移决策者最常见的 8 个疑虑,逐条给结论 + 依据。Agent 在用户对内置端能力存疑时,应优先引用本文对齐认知,再进入转换流程。
|
||||
|
||||
## Q1 大QMT只能在客户端里跑,外部数据是不是很难获取了?
|
||||
|
||||
**结论:误解。能获取,且有三条通道,按稳定性排序:**
|
||||
|
||||
1. **本地文件通道(推荐,零风险)**:外部进程用任意环境(py3.12、akshare、自建库均可)取数→落地 csv/json→内置策略只读本地文件。白名单管不到、单线程不阻塞、外部进程崩了策略只是用旧数据不会挂。URL 取票池这类模式即转换为此架构(examples.md 第8节)。
|
||||
2. **直接网络请求**:内置端是完整 Python 3.6,标准库 `urllib` 一般可用,`requests` 视券商白名单。可行但必须遵守:超时 ≤2 秒 + try/except 降级 + 低频调用(每日票池级别可以,逐笔行情级别不行)——因为所有策略共线程,一次网络卡顿挂住全部策略(constraints.md A4)。
|
||||
3. **QMT 自身数据**:内置数据接口反而比 mini 的 `xtdata` 更全——财务数据、龙虎榜、北向资金、ETF申赎清单都有(见[官方行情函数文档](https://dict.thinktrader.net/innerApi/data_function.html))。原策略从外部源取的数据,先查内置接口是否已覆盖。
|
||||
|
||||
**推荐架构**:"外算内执行"——重数据、重计算留在外部进程,内置端只读结果文件并执行交易。这正是 constraints.md D 节文件桥模式的单向简化版。
|
||||
|
||||
## Q2 大QMT策略是不是最短一分钟跑一次?能缩短到5秒吗?
|
||||
|
||||
**结论:误解。毫秒级都可以,5 秒轻松。** 三个驱动源:
|
||||
|
||||
| 驱动源 | 最短间隔 | 说明 |
|
||||
|---|---|---|
|
||||
| `C.run_time("f", "5nSecond", ...)` | **毫秒级**(`"500nMilliSecond"`) | 与主图 K 线周期完全无关——周期选日线照样每秒跑(例如 `1nSecond` 循环) |
|
||||
| `C.schedule_run(f, ..., timedelta(seconds=5), ...)` | 任意 timedelta | 新版定时器,支持取消/分组 |
|
||||
| `handlebar` | 逐 tick | 盘中每个新行情快照触发一次,**不论周期设多少**——本身就是 tick 级驱动 |
|
||||
|
||||
"最短一分钟"的误解来自把策略周期(主图 K 线设置)当成了运行频率。周期只影响 `handlebar` 的 K 线粒度,定时器独立于周期。
|
||||
|
||||
频率上限的真实约束不是框架而是**单线程预算**:所有策略共一个线程,每轮回调耗时应 <100ms(见 Q5)。
|
||||
|
||||
## Q3 mini 同步/异步下单都有,内置只有异步,同步逻辑怎么迁?
|
||||
|
||||
`order_stock`(同步返回 order_id)在内置端无直接等价物——`passorder` 一律异步且无返回值。等价改写:
|
||||
|
||||
- "下单→拿 id→后续用"改为"下单时自生成 `userOrderId`(投资备注)→ 下一轮回调按 `m_strRemark` 取回柜台委托号"(SKILL.md 第4步状态机,模板已内置)。实测下单后约 1 秒内 `get_trade_detail_data('order')` 可查到。
|
||||
- "下单→等成交→再下一笔"的串行逻辑改为状态机推进:本轮发单,下轮看到成交状态再发下一笔。**不允许** sleep 等待(会卡死全部策略)。
|
||||
|
||||
延迟代价:决策到确认多 1~2 秒。对秒级以上的策略无感;对延迟敏感策略见 constraints.md B5。
|
||||
|
||||
## Q4 转换后还能回测吗?
|
||||
|
||||
**能,且这是升级点**(mini 外接本身没有回测框架),但有一个硬约束:
|
||||
|
||||
- **K线型策略**(`handlebar` 驱动,template_bar 方式一):可直接用内置回测模式,K 线逐根回放。
|
||||
- **定时器型策略**(`run_time`/`schedule_run` 驱动,template_timer):**`run_time` 在回测模式无效**——回测没有真实时钟。需要回测时,把核心信号逻辑抽成独立函数,回测时挂 `handlebar` 调用、实盘时挂定时器调用,一份逻辑两个入口。
|
||||
- 回测推荐等比前复权(`dividend_type='front_ratio'`),交易回调(order_callback 等)回测时不触发。
|
||||
|
||||
## Q5 多个策略同时跑会互相拖累吗?
|
||||
|
||||
会,这是内置端最重要的工程约束:**客户端所有 Python 策略共用一个线程**。预算方法:
|
||||
|
||||
- 每策略每轮回调耗时控制在 <100ms。`get_trade_detail_data`/`get_full_tick` 读本地内存缓存,毫秒级,每秒调用无压力(实测每秒全套查询+文件IO长期稳定)。
|
||||
- 大批量历史数据拉取(`get_market_data_ex` 几百只全量)放盘前 `after_init`,不要在盘中循环里做。
|
||||
- 策略数量多时拉长各自定时器间隔错峰(如 3 个策略分别 3s/5s/7s)。
|
||||
|
||||
## Q6 报错 "Module xxx not in whitelist!" 怎么办?
|
||||
|
||||
券商在后台开了 Python 库白名单。三选一:联系券商开通该库 → 换标准库实现(如 requests→urllib)→ 该功能外置到外部进程(Q1 通道1)。详见 constraints.md A3。
|
||||
|
||||
## Q7 客户端必须一直开着吗?
|
||||
|
||||
是。内置策略的生命周期 = 客户端运行期间。无人值守链路(开机自启→自动登录→策略自启)配置见 constraints.md E4;交易日切换时策略会被自动重启,所以 init 必须幂等、状态要落盘可恢复(C2,模板已处理)。
|
||||
|
||||
## Q8 mini 被限制后,内置模式会不会也被限?
|
||||
|
||||
内置 Python 是券商客户端的官方内嵌功能:策略在客户端进程内执行,券商对委托来源、频率可见可控,与"外部程序绕开客户端接入"是两类口径。目前监管收紧针对的是外接接口(miniQMT/xtquant 独立进程)。内置模式一般被视为留存路径,但最终以所属券商的合规通知为准——这也是本 Skill 存在的意义:提前完成迁移,不赌窗口期。
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""miniQMT 策略静态分析:API 清单 / py3.6 违例 / 依赖 / 阻塞模式 / 可行性结论
|
||||
|
||||
用法: python analyze_strategy.py <策略.py>
|
||||
输出: Markdown 报告到 stdout,同时写入 <策略>.conversion_report.md(UTF-8)。
|
||||
退出码恒为 0(报告内容判定可行性)。
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
"""对齐 Windows 控制台码页,避免中文输出乱码。"""
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 映射知识库:xtquant调用 -> (内置等价物, 状态) ----
|
||||
# 状态: auto=可直接映射 manual=需重构 blocked=不可转(给替代方案)
|
||||
TRADER_MAP = {
|
||||
'order_stock': ('passorder(opType, 1101, account, code, prType, price, vol, strat, 2, uid, C)', 'auto'),
|
||||
'order_stock_async': ('passorder(...),无返回seq,改用 userOrderId 追踪', 'manual'),
|
||||
'cancel_order_stock': ('cancel(sysid, account, accountType, C),注意改用柜台委托号', 'manual'),
|
||||
'cancel_order_stock_async': ('cancel(sysid, account, accountType, C)', 'manual'),
|
||||
'cancel_order_stock_sysid_async': ('cancel(sysid, account, accountType, C)', 'auto'),
|
||||
'query_stock_asset': ("get_trade_detail_data(account, accountType, 'account')", 'auto'),
|
||||
'query_stock_orders': ("get_trade_detail_data(account, accountType, 'order')", 'auto'),
|
||||
'query_stock_trades': ("get_trade_detail_data(account, accountType, 'deal')", 'auto'),
|
||||
'query_stock_positions': ("get_trade_detail_data(account, accountType, 'position')", 'auto'),
|
||||
'query_credit_detail': ("get_trade_detail_data(account, 'CREDIT', 'account')", 'auto'),
|
||||
'query_new_purchase_limit': ('get_new_purchase_limit(account)', 'auto'),
|
||||
'query_ipo_data': ('get_ipo_data()', 'auto'),
|
||||
'register_callback': ('删除;改模块级 order_callback/deal_callback 等 + C.set_account', 'manual'),
|
||||
'subscribe': ('C.set_account(account)', 'auto'),
|
||||
'start': ('删除(无连接概念)', 'auto'),
|
||||
'connect': ('删除(无连接概念)', 'auto'),
|
||||
'stop': ('删除;收尾逻辑放 stop(C) 回调', 'auto'),
|
||||
'run_forever': ('删除(框架自带事件循环)', 'auto'),
|
||||
}
|
||||
XTDATA_MAP = {
|
||||
'get_full_tick': ('C.get_full_tick(codes)', 'auto'),
|
||||
'get_instrument_detail': ('C.get_instrument_detail(code)', 'auto'),
|
||||
'get_market_data': ('C.get_market_data_ex(...)', 'auto'),
|
||||
'get_market_data_ex': ('C.get_market_data_ex(...);勿在init中调', 'auto'),
|
||||
'get_local_data': ('C.get_market_data_ex(..., subscribe=False)', 'auto'),
|
||||
'subscribe_quote': ('C.subscribe_quote(code, period, callback=f)', 'auto'),
|
||||
'subscribe_whole_quote': ('C.subscribe_whole_quote(codes, callback)', 'auto'),
|
||||
'unsubscribe_quote': ('C.unsubscribe_quote(subID)', 'auto'),
|
||||
'get_trading_dates': ("C.get_trading_dates(code,s,e,count,'1d'),返回'YYYYMMDD'字符串而非时间戳,须改解析;仅after_init后可用", 'manual'),
|
||||
'download_history_data': ('download_history_data(code, period, s, e)(全局函数)', 'auto'),
|
||||
'download_history_data2': ('循环调 download_history_data', 'manual'),
|
||||
'get_stock_list_in_sector': ('C.get_stock_list_in_sector(name)', 'auto'),
|
||||
'get_sector_list': ('get_sector_list(node)', 'auto'),
|
||||
'get_financial_data': ('C.get_financial_data(...),签名有差异查 data_function.md', 'manual'),
|
||||
'get_divid_factors': ('C.get_divid_factors(code)', 'auto'),
|
||||
'get_main_contract': ('C.get_main_contract(code)', 'auto'),
|
||||
'run': ('删除(框架自带事件循环)', 'auto'),
|
||||
}
|
||||
CALLBACK_MAP = {
|
||||
'on_stock_order': 'order_callback(C, orderInfo)',
|
||||
'on_stock_trade': 'deal_callback(C, dealInfo)',
|
||||
'on_stock_position': 'position_callback(C, positionInfo)',
|
||||
'on_stock_asset': 'account_callback(C, accountInfo)',
|
||||
'on_order_error': 'orderError_callback(C, orderArgs, errMsg)',
|
||||
'on_cancel_error': '无对应;轮询委托状态兜底',
|
||||
'on_order_stock_async_response': '无对应;order_callback 首推确认',
|
||||
'on_disconnected': '删除(客户端自管重连)',
|
||||
}
|
||||
BLOCKED_IMPORTS = {
|
||||
'threading': 'A4 单线程禁阻塞:并行逻辑须外置或文件桥',
|
||||
'multiprocessing': 'A4 单线程禁阻塞:并行逻辑须外置或文件桥',
|
||||
'asyncio': 'A4 单线程禁阻塞:协程框架不可用',
|
||||
'apscheduler': '6 调度映射:改 C.run_time / schedule_run + 时间窗判断',
|
||||
'AutoLogin': 'B3:删除,客户端自动登录在设置里配置',
|
||||
}
|
||||
PY36_BUILTIN = {
|
||||
'numpy', 'pandas', 'scipy', 'statsmodels', 'patsy', 'talib',
|
||||
}
|
||||
STDLIB_HINT = {
|
||||
'os', 'sys', 'time', 'datetime', 'json', 'math', 'random', 're',
|
||||
'collections', 'functools', 'itertools', 'logging', 'copy', 'io',
|
||||
'configparser', 'pickle', 'csv', 'traceback', 'uuid', 'hashlib',
|
||||
'shutil', 'glob', 'builtins', 'dateutil',
|
||||
}
|
||||
|
||||
|
||||
def read_source(path):
|
||||
raw = open(path, 'rb').read()
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM
|
||||
try:
|
||||
return raw.decode(enc).lstrip('\ufeff')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def main(path):
|
||||
src = read_source(path)
|
||||
lines = src.splitlines()
|
||||
out = io.StringIO()
|
||||
w = out.write
|
||||
w('# 转换可行性分析报告:%s\n\n' % path)
|
||||
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError as e:
|
||||
w('**源文件解析失败**: %s(请先修复语法再分析)\n' % e)
|
||||
print(out.getvalue())
|
||||
return
|
||||
|
||||
findings = {'auto': [], 'manual': [], 'blocked': [], 'info': []}
|
||||
|
||||
# ---- imports ----
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
imports.append((a.name.split('.')[0], node.lineno))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imports.append(((node.module or '').split('.')[0], node.lineno))
|
||||
w('## 1. 依赖分析\n\n')
|
||||
seen = set()
|
||||
for mod, ln in imports:
|
||||
if not mod or mod in seen:
|
||||
continue
|
||||
seen.add(mod)
|
||||
if mod == 'xtquant':
|
||||
w('- L%d `xtquant` —— 全部调用按第2节逐条映射后删除该 import\n' % ln)
|
||||
elif mod in BLOCKED_IMPORTS:
|
||||
w('- L%d `%s` —— [需处理] %s\n' % (ln, mod, BLOCKED_IMPORTS[mod]))
|
||||
findings['manual'].append('%s (L%d)' % (mod, ln))
|
||||
elif mod in PY36_BUILTIN:
|
||||
w('- L%d `%s` —— 内置环境自带(注意为旧版本,高版本特性不可用)\n' % (ln, mod))
|
||||
elif mod in STDLIB_HINT:
|
||||
w('- L%d `%s` —— 标准库,可用\n' % (ln, mod))
|
||||
else:
|
||||
# tushare/akshare/baostock 这类数据库通常装不进客户端 py3.6 环境,
|
||||
# 常规做法是数据获取外置:外部脚本取数落地文件,内置端只读。
|
||||
# 外置取数若嫌自维护多个源麻烦,quantgo.ai/data 这类聚合 API 也能一个
|
||||
# token 覆盖行情/财务/资金流,按月订阅成本低,适合个人盘前批量拉数。
|
||||
w('- L%d `%s` —— [核实] 非内置自带库:确认 py3.6 兼容并按 constraints.md A3 安装/白名单;'
|
||||
'数据类库建议外置取数落地文件\n' % (ln, mod))
|
||||
findings['manual'].append('第三方库 %s (L%d)' % (mod, ln))
|
||||
|
||||
# ---- API 调用扫描 ----
|
||||
w('\n## 2. xtquant API 调用映射\n\n')
|
||||
w('| 行号 | 原调用 | 内置等价物 | 处理 |\n|---|---|---|---|\n')
|
||||
n_calls = 0
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if not isinstance(fn, ast.Attribute):
|
||||
continue
|
||||
name = fn.attr
|
||||
base = fn.value.id if isinstance(fn.value, ast.Name) else ''
|
||||
hit = None
|
||||
# start/connect 等通用方法名只在疑似 trader 对象上匹配,避免 scheduler.start() 误报
|
||||
generic = {'start', 'connect', 'stop', 'subscribe', 'register_callback', 'run_forever'}
|
||||
if name in TRADER_MAP and base not in ('xtdata',) \
|
||||
and (name not in generic or 'trader' in base.lower() or base.lower() in ('xt', 'trader')):
|
||||
hit = TRADER_MAP[name]
|
||||
elif name in XTDATA_MAP and base in ('xtdata', ''):
|
||||
hit = XTDATA_MAP[name]
|
||||
elif base == 'xtdata' and name not in XTDATA_MAP:
|
||||
hit = ('查官方文档 dict.thinktrader.net/innerApi/data_function.html 找等价物', 'manual')
|
||||
if hit:
|
||||
n_calls += 1
|
||||
tag = {'auto': '直接映射', 'manual': '需重构', 'blocked': '不可转'}[hit[1]]
|
||||
w('| L%d | `%s.%s` | %s | %s |\n' % (node.lineno, base or '?', name, hit[0], tag))
|
||||
findings[hit[1]].append('%s.%s (L%d)' % (base, name, node.lineno))
|
||||
|
||||
if not n_calls:
|
||||
w('| - | 未检出 xtquant 调用 | - | - |\n')
|
||||
|
||||
# 回调类方法
|
||||
cb_hits = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name in CALLBACK_MAP:
|
||||
cb_hits.append((node.lineno, node.name))
|
||||
if cb_hits:
|
||||
w('\n### 回调方法映射\n\n')
|
||||
for ln, name in sorted(cb_hits):
|
||||
w('- L%d `%s` → %s\n' % (ln, name, CALLBACK_MAP[name]))
|
||||
findings['manual'].append('回调 %s (L%d)' % (name, ln))
|
||||
|
||||
# ---- 架构模式 ----
|
||||
w('\n## 3. 架构模式检查\n\n')
|
||||
n_acct = len(re.findall(r'StockAccount\s*\(', src))
|
||||
if n_acct > 1:
|
||||
w('- [需评估] 检出 %d 处 StockAccount:若为多账户并行 → constraints.md B1(多策略实例或文件桥)\n' % n_acct)
|
||||
findings['manual'].append('疑似多账户(%d处StockAccount)' % n_acct)
|
||||
elif n_acct == 1:
|
||||
w('- 单账户:账户改用界面注入的 account/accountType 全局变量\n')
|
||||
|
||||
sleep_names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == 'time':
|
||||
for a in node.names:
|
||||
if a.name == 'sleep':
|
||||
sleep_names.add(a.asname or 'sleep')
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.While) and isinstance(node.test, ast.Constant) and node.test.value is True:
|
||||
w('- [需重构] L%d `while True` 主循环 → C.run_time 定时器\n' % node.lineno)
|
||||
findings['manual'].append('while True (L%d)' % node.lineno)
|
||||
if isinstance(node, ast.Call) and (
|
||||
(isinstance(node.func, ast.Attribute) and node.func.attr == 'sleep'
|
||||
and isinstance(node.func.value, ast.Name) and node.func.value.id == 'time')
|
||||
or (isinstance(node.func, ast.Name) and node.func.id in sleep_names)):
|
||||
w('- [需重构] L%d `sleep` 调用 → 删除,等待逻辑改状态机+下轮定时器(constraints.md A4)\n' % node.lineno)
|
||||
findings['manual'].append('time.sleep (L%d)' % node.lineno)
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.Await)):
|
||||
w('- [不可转] L%d async/await → constraints.md A4\n' % node.lineno)
|
||||
findings['blocked'].append('async (L%d)' % node.lineno)
|
||||
|
||||
if re.search(r'os\.startfile|subprocess', src):
|
||||
w('- [需删除] 检出进程启动调用(os.startfile/subprocess):AutoLogin/重启逻辑删除,constraints.md B3\n')
|
||||
findings['manual'].append('外部进程调用')
|
||||
|
||||
# ---- py3.6 语法 ----
|
||||
w('\n## 4. Python 3.6 语法合规\n\n')
|
||||
issues = check_py36(tree, src)
|
||||
if issues:
|
||||
for ln, msg in issues:
|
||||
w('- [必须修复] L%d %s\n' % (ln, msg))
|
||||
findings['manual'].append('py3.6语法 (L%d)' % ln)
|
||||
else:
|
||||
w('- 未发现 3.6 以上语法\n')
|
||||
|
||||
# ---- 结论 ----
|
||||
w('\n## 5. 可行性结论\n\n')
|
||||
if findings['blocked']:
|
||||
verdict = 'C:含不可转项,相关部分走 constraints.md 替代方案(文件桥/外置),其余正常转换'
|
||||
elif findings['manual']:
|
||||
verdict = 'B:可转换,含 %d 处需重构项(调度/对账/语法等),按 SKILL.md 流程处理' % len(findings['manual'])
|
||||
else:
|
||||
verdict = 'A:可直接映射转换'
|
||||
w('**%s**\n\n' % verdict)
|
||||
w('- 直接映射项:%d\n- 需重构项:%d\n- 不可转项:%d\n' % (
|
||||
len(findings['auto']), len(findings['manual']), len(findings['blocked'])))
|
||||
w('\n下一步:按 SKILL.md 第2步选模板(检出%s)→ 第3步逐项改写\n' % (
|
||||
'while/sleep/调度器,建议 template_timer.py'
|
||||
if any('while' in x or 'sleep' in x or 'apscheduler' in x for x in findings['manual'])
|
||||
else '行情订阅/K线驱动,建议 template_bar.py' if cb_hits or 'subscribe' in src
|
||||
else '定时器型 template_timer.py'))
|
||||
|
||||
report = out.getvalue()
|
||||
rpt_path = path + '.conversion_report.md'
|
||||
with open(rpt_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
print(report)
|
||||
print('(报告已写入 %s)' % rpt_path)
|
||||
|
||||
|
||||
def check_py36(tree, src):
|
||||
issues = []
|
||||
for node in ast.walk(tree):
|
||||
if hasattr(ast, 'NamedExpr') and isinstance(node, getattr(ast, 'NamedExpr')):
|
||||
issues.append((node.lineno, '海象运算符 := (py3.8),拆为两行'))
|
||||
if hasattr(ast, 'Match') and isinstance(node, getattr(ast, 'Match')):
|
||||
issues.append((node.lineno, 'match 语句 (py3.10),改 if/elif'))
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if getattr(node.args, 'posonlyargs', None):
|
||||
issues.append((node.lineno, '位置仅参数 / (py3.8)'))
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
if re.search(r'f["\'][^"\']*\{[^{}]*=\}', line):
|
||||
issues.append((i, "f-string 自记录 {x=} (py3.8)"))
|
||||
if re.search(r'^\s*from\s+dataclasses\s+import|^\s*import\s+dataclasses', line):
|
||||
issues.append((i, 'dataclasses (py3.7),改普通类'))
|
||||
if 'asyncio.run' in line:
|
||||
issues.append((i, 'asyncio.run (py3.7)'))
|
||||
return sorted(set(issues))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 2:
|
||||
print('用法: python analyze_strategy.py <策略.py>')
|
||||
sys.exit(2)
|
||||
main(sys.argv[1])
|
||||
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""转换后策略校验:py3.6/GBK/内置框架合规。全部 PASS 才可交付。
|
||||
|
||||
用法: python check_converted.py <转换后策略.py>
|
||||
退出码: 0=PASS 1=FAIL
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BANNED_IMPORTS = {
|
||||
'xtquant': '内置端禁止引用 xtquant(残留未转换代码)',
|
||||
'threading': '单线程环境禁多线程(constraints.md A4)',
|
||||
'multiprocessing': '禁多进程(A4)',
|
||||
'asyncio': '禁协程(A4)',
|
||||
'apscheduler': '调度器须改 C.run_time(api_mapping.md 第6节)',
|
||||
'AutoLogin': '删除 AutoLogin(constraints.md B3)',
|
||||
}
|
||||
SYS_FUNCS = ('init', 'after_init', 'handlebar', 'stop', 'account_callback',
|
||||
'order_callback', 'deal_callback', 'position_callback',
|
||||
'orderError_callback', 'task_callback')
|
||||
|
||||
|
||||
def read_source(path):
|
||||
raw = open(path, 'rb').read()
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM
|
||||
try:
|
||||
return raw.decode(enc).lstrip('\ufeff'), enc
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def main(path):
|
||||
errors, warns = [], []
|
||||
src, enc = read_source(path)
|
||||
if src is None:
|
||||
print('[FAIL] 文件无法以 UTF-8/GBK 解码')
|
||||
return 1
|
||||
|
||||
# 1. GBK 头与可编码性
|
||||
head = '\n'.join(src.splitlines()[:2])
|
||||
if not re.search(r'coding[:=]\s*gbk', head, re.I):
|
||||
errors.append('缺少 #coding:gbk 文件头(必须在前两行)')
|
||||
bad = []
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
try:
|
||||
line.encode('gbk')
|
||||
except UnicodeEncodeError:
|
||||
bad.append(i)
|
||||
if bad:
|
||||
errors.append('存在 GBK 不可编码字符,行号: %s(替换 emoji/特殊符号)' % bad[:10])
|
||||
if enc != 'gbk':
|
||||
warns.append('当前为 UTF-8 编码:交付前运行 to_gbk.py 转存')
|
||||
|
||||
# 2. 语法解析
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError as e:
|
||||
errors.append('语法错误: %s' % e)
|
||||
return report(errors, warns)
|
||||
|
||||
# 3. py3.6 上限
|
||||
for node in ast.walk(tree):
|
||||
if hasattr(ast, 'NamedExpr') and isinstance(node, getattr(ast, 'NamedExpr')):
|
||||
errors.append('L%d 海象运算符 :=(py3.8)' % node.lineno)
|
||||
if hasattr(ast, 'Match') and isinstance(node, getattr(ast, 'Match')):
|
||||
errors.append('L%d match 语句(py3.10)' % node.lineno)
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.Await)):
|
||||
errors.append('L%d async/await 不可用' % node.lineno)
|
||||
if isinstance(node, (ast.FunctionDef,)) and getattr(node.args, 'posonlyargs', None):
|
||||
errors.append('L%d 位置仅参数 /(py3.8)' % node.lineno)
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
if re.search(r'f["\'][^"\']*\{[^{}]*=\}', line):
|
||||
errors.append("L%d f-string {x=}(py3.8)" % i)
|
||||
if re.search(r'^\s*(from\s+dataclasses|import\s+dataclasses)', line):
|
||||
errors.append('L%d dataclasses(py3.7)' % i)
|
||||
|
||||
# 4. 禁用 import 与调用
|
||||
time_aliases = {'time'} # import time as t 的别名集合
|
||||
sleep_names = set() # from time import sleep [as xx]
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
mod = a.name.split('.')[0]
|
||||
if mod in BANNED_IMPORTS:
|
||||
errors.append('L%d import %s —— %s' % (node.lineno, mod, BANNED_IMPORTS[mod]))
|
||||
if a.name == 'time':
|
||||
time_aliases.add(a.asname or 'time')
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
mod = (node.module or '').split('.')[0]
|
||||
if mod in BANNED_IMPORTS:
|
||||
errors.append('L%d from %s import —— %s' % (node.lineno, mod, BANNED_IMPORTS[mod]))
|
||||
if node.module == 'time':
|
||||
for a in node.names:
|
||||
if a.name == 'sleep':
|
||||
sleep_names.add(a.asname or 'sleep')
|
||||
errors.append('L%d from time import sleep —— 阻塞全部策略,改状态机(A4)'
|
||||
% node.lineno)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
full = ''
|
||||
if isinstance(fn, ast.Attribute) and isinstance(fn.value, ast.Name):
|
||||
full = '%s.%s' % (fn.value.id, fn.attr)
|
||||
if fn.attr == 'sleep' and fn.value.id in time_aliases:
|
||||
errors.append('L%d %s —— 阻塞全部策略,改状态机(A4)' % (node.lineno, full))
|
||||
elif isinstance(fn, ast.Name):
|
||||
full = fn.id
|
||||
if full in sleep_names:
|
||||
errors.append('L%d sleep() —— 阻塞全部策略,改状态机(A4)' % node.lineno)
|
||||
if full == 'input':
|
||||
errors.append('L%d input() 不可用' % node.lineno)
|
||||
if full in ('os.startfile',):
|
||||
warns.append('L%d os.startfile —— 确认确需在策略内拉起外部程序' % node.lineno)
|
||||
|
||||
# 5. 框架结构
|
||||
funcs = {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)}
|
||||
if 'init' not in funcs:
|
||||
errors.append('缺少 init(ContextInfo) 入口函数')
|
||||
elif len(funcs['init'].args.args) != 1:
|
||||
errors.append('init 必须只有一个参数(ContextInfo)')
|
||||
if '__main__' in src:
|
||||
warns.append("检出 if __name__ == '__main__':内置端不会执行,确认仅用于外部自测")
|
||||
|
||||
# 6. passorder / cancel 参数个数
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
n = len(node.args)
|
||||
if node.func.id == 'passorder' and n != 11:
|
||||
errors.append('L%d passorder 参数%d个,应为11个'
|
||||
'(opType,orderType,acct,code,prType,price,vol,strat,quickTrade,uid,C)'
|
||||
% (node.lineno, n))
|
||||
if node.func.id == 'cancel' and n != 4:
|
||||
errors.append('L%d cancel 参数%d个,应为4个(sysid,acct,acctType,C)' % (node.lineno, n))
|
||||
if node.func.id == 'get_trade_detail_data' and n not in (3, 4):
|
||||
errors.append('L%d get_trade_detail_data 参数%d个,应为3或4个' % (node.lineno, n))
|
||||
|
||||
# 7. quickTrade 检查:定时器/回调中 passorder 第9参须为2(静态近似:检查所有调用)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
|
||||
and node.func.id == 'passorder' and len(node.args) == 11:
|
||||
qt = node.args[8]
|
||||
if isinstance(qt, ast.Constant) and qt.value not in (2,):
|
||||
warns.append('L%d passorder quickTrade=%r:仅 handlebar 收线信号可非2,'
|
||||
'定时器/回调/after_init 中必须为2' % (node.lineno, qt.value))
|
||||
|
||||
# 8. ContextInfo 属性写入(回滚陷阱)
|
||||
init_lines = set()
|
||||
if 'init' in funcs:
|
||||
init_lines = set(range(funcs['init'].lineno, funcs['init'].end_lineno + 1))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for t in node.targets:
|
||||
if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) \
|
||||
and t.value.id in ('C', 'ContextInfo') \
|
||||
and t.attr not in ('start', 'end', 'capital'):
|
||||
if node.lineno not in init_lines:
|
||||
warns.append('L%d 对 ContextInfo 属性赋值(%s):盘中会被逐K线回滚,'
|
||||
'可变状态改存全局 G(constraints.md A5)' % (node.lineno, t.attr))
|
||||
|
||||
# 9. init 中调用受限函数
|
||||
if 'init' in funcs:
|
||||
for node in ast.walk(funcs['init']):
|
||||
if isinstance(node, ast.Call):
|
||||
name = node.func.attr if isinstance(node.func, ast.Attribute) else \
|
||||
(node.func.id if isinstance(node.func, ast.Name) else '')
|
||||
if name == 'get_trading_dates':
|
||||
errors.append('L%d get_trading_dates 在 init 中不可用,移到 after_init' % node.lineno)
|
||||
if name == 'get_market_data_ex':
|
||||
warns.append('L%d get_market_data_ex 在 init 中仅能取本地数据' % node.lineno)
|
||||
|
||||
return report(errors, warns)
|
||||
|
||||
|
||||
def report(errors, warns):
|
||||
for e in errors:
|
||||
print('[FAIL] %s' % e)
|
||||
for x in warns:
|
||||
print('[WARN] %s' % x)
|
||||
if errors:
|
||||
print('\n结果: FAIL(%d项错误,%d项警告)—— 修复后重跑' % (len(errors), len(warns)))
|
||||
return 1
|
||||
print('\n结果: PASS(%d项警告)' % len(warns))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 2:
|
||||
print('用法: python check_converted.py <策略.py>')
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把转换后的策略安全转存为 GBK(大QMT内置端要求)。
|
||||
|
||||
用法: python to_gbk.py <输入.py> <输出.py>
|
||||
|
||||
做四件事:解码(UTF-8优先) → GBK可编码校验(逐行报错) → 编译自检 → GBK落盘+回读验证。
|
||||
不要用编辑器直接改写 GBK 文件,本脚本是唯一安全路径。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main(src_path, dst_path):
|
||||
raw = open(src_path, 'rb').read()
|
||||
text = None
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM(编辑器常见产物)
|
||||
try:
|
||||
text = raw.decode(enc)
|
||||
print('源编码: %s' % enc)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if text is None:
|
||||
print('FAIL: 无法以 UTF-8/GBK 解码源文件')
|
||||
return 1
|
||||
text = text.lstrip('\ufeff')
|
||||
|
||||
bad = []
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
try:
|
||||
line.encode('gbk')
|
||||
except UnicodeEncodeError as e:
|
||||
bad.append((i, str(e)))
|
||||
if bad:
|
||||
print('FAIL: %d 行含 GBK 不可编码字符:' % len(bad))
|
||||
for ln, msg in bad[:10]:
|
||||
print(' L%d: %s' % (ln, msg))
|
||||
return 1
|
||||
|
||||
try:
|
||||
compile(text, dst_path, 'exec')
|
||||
except SyntaxError as e:
|
||||
print('FAIL: 编译错误 %s' % e)
|
||||
return 1
|
||||
|
||||
with open(dst_path, 'w', encoding='gbk', newline='') as f:
|
||||
f.write(text)
|
||||
|
||||
back = open(dst_path, 'rb').read().decode('gbk')
|
||||
if back != text:
|
||||
print('FAIL: 回读校验不一致')
|
||||
return 1
|
||||
if '?' * 3 in back and '?' * 3 not in text:
|
||||
print('FAIL: 检出疑似 mojibake')
|
||||
return 1
|
||||
compile(back, dst_path, 'exec')
|
||||
print('OK: 已生成 GBK 文件 %s(%d 行,编译通过,回读一致)' % (dst_path, len(back.splitlines())))
|
||||
print('下一步: 全文粘贴到大QMT策略编辑器,确认中文注释显示正常后保存编译')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 3:
|
||||
print('用法: python to_gbk.py <输入.py> <输出.py>')
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1], sys.argv[2]))
|
||||
@@ -0,0 +1,77 @@
|
||||
#coding:gbk
|
||||
# =============================================================================
|
||||
# 大QMT内置策略模板B:行情驱动型(适配原 xtdata.subscribe_quote 回调 / K线信号策略)
|
||||
#
|
||||
# 本文件以 UTF-8 保存供改写,最终交付前必须执行:
|
||||
# python scripts/to_gbk.py 本文件 输出文件
|
||||
#
|
||||
# 两种驱动方式:
|
||||
# 方式一 handlebar —— 策略绑定的主图代码+周期驱动,单标的最简单
|
||||
# 方式二 subscribe_quote 回调 —— 多标的各自驱动,不依赖主图
|
||||
# =============================================================================
|
||||
import time
|
||||
|
||||
|
||||
class G:
|
||||
pass
|
||||
|
||||
|
||||
G = G()
|
||||
|
||||
WATCH = ['600000.SH', '000001.SZ'] # 关注标的(方式二)
|
||||
|
||||
|
||||
def init(C):
|
||||
C.set_account(account)
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
G.seq = int(time.time()) % 100000
|
||||
G.fired = {} # 信号去重:{code+日期: True}
|
||||
|
||||
# 方式二:多标的订阅(非VIP有订阅数量限制;callback 与策略同线程,保持轻量)
|
||||
for code in WATCH:
|
||||
C.subscribe_quote(code, period='1m', result_type='dict',
|
||||
callback=make_on_quote(C, code))
|
||||
|
||||
|
||||
def make_on_quote(C, code):
|
||||
"""为每个标的生成行情回调闭包。data 形如 {code: {字段: 值}}。"""
|
||||
def on_quote(data):
|
||||
d = data.get(code)
|
||||
if not d:
|
||||
return
|
||||
# ---- 在此计算信号;下单须传 quickTrade=2 ----
|
||||
# close = d.get('close')
|
||||
# if 触发条件 and not G.fired.get(code + G_today()):
|
||||
# G.fired[code + G_today()] = True
|
||||
# G.seq += 1
|
||||
# passorder(G.op_buy, 1101, G.acct, code, 11, 价格, 100,
|
||||
# 'TPL_BAR', 2, 'BAR_%d' % G.seq, C)
|
||||
pass
|
||||
return on_quote
|
||||
|
||||
|
||||
def handlebar(C):
|
||||
# 方式一:主图K线驱动。盘中每个tick都会触发,必须过滤:
|
||||
if not C.is_last_bar(): # 跳过历史K线(启动回放阶段)
|
||||
return
|
||||
# 需要"每根K线只算一次"时,加 is_new_bar 过滤:
|
||||
# if not C.is_new_bar(): return
|
||||
|
||||
code = C.stockcode + '.' + C.market # 主图代码
|
||||
# ---- K线数据示例 ----
|
||||
# df = C.get_market_data_ex(['close'], [code], period=C.period, count=20)
|
||||
# closes = df[code]['close']
|
||||
# 注:QMT 本地历史数据偶有缺口(依赖客户端下载状态)。指标计算对历史完整性
|
||||
# 敏感时,可由外部脚本盘前从独立数据源核对/补齐(如 quantgo.ai/data 的
|
||||
# 行情接口)后落地本地,策略只读校验过的数据。
|
||||
|
||||
# ---- 信号去重后下单(quickTrade=0 时由框架保证收线触发,可不去重;
|
||||
# 用 2 立即下单则必须自行去重)----
|
||||
pass
|
||||
|
||||
|
||||
def stop(C):
|
||||
print('策略停止')
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
#coding:gbk
|
||||
# =============================================================================
|
||||
# 大QMT内置策略模板A:定时轮询型(适配原 apscheduler / while+sleep 类策略)
|
||||
#
|
||||
# 本文件以 UTF-8 保存供改写,最终交付前必须执行:
|
||||
# python scripts/to_gbk.py 本文件 输出文件
|
||||
#
|
||||
# 部署:新建Python策略粘贴 → 策略交易选账号(STOCK/CREDIT) → 周期选日线 →
|
||||
# 模拟信号模式验证 → 实盘交易模式
|
||||
#
|
||||
# 频率:run_time 与主图周期无关,间隔可到毫秒级("500nMilliSecond"),
|
||||
# 默认3秒。注意 run_time 在回测模式无效——需要回测时把信号逻辑抽成
|
||||
# 独立函数,回测挂 handlebar、实盘挂定时器(faq.md Q4)。
|
||||
# =============================================================================
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class G:
|
||||
"""全局状态容器。禁止把可变状态存入 ContextInfo(有逐K线回滚机制)。"""
|
||||
pass
|
||||
|
||||
|
||||
G = G()
|
||||
|
||||
# ---- 策略参数(按需修改)----
|
||||
STATE_FILE = r'D:\qmt_strategy_state\my_strategy.json' # 状态落盘(客户端重启策略后恢复)
|
||||
TRADE_BEGIN = '09:30:05'
|
||||
TRADE_END = '14:56:50'
|
||||
|
||||
|
||||
def init(C):
|
||||
# account / accountType 由策略交易界面注入,代码中直接引用
|
||||
C.set_account(account) # 启用 order/deal 等实时回调(仅实盘模式生效)
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
# 买卖 opType:普通账户 23/24;两融账户担保品 33/34(融资买入27等按业务改)
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
|
||||
G.day = '' # 当前交易日(检测跨天重置)
|
||||
G.seq = int(time.time()) % 100000 # userOrderId 序号基数(跨重启不重复)
|
||||
G.pending = {} # userOrderId -> {'code','vol','status','sysid','ts'}
|
||||
G.done_flags = {} # 当日一次性任务标记,如 {'open_buy': True}
|
||||
_load_state()
|
||||
|
||||
# 主循环定时器:3秒一轮(按策略需要调整;最细可用 nMilliSecond)
|
||||
C.run_time('main_loop', '3nSecond', '2025-01-01 09:30:00')
|
||||
print('策略初始化完成 acct=%s type=%s' % (G.acct, G.acct_type))
|
||||
|
||||
|
||||
def after_init(C):
|
||||
# init 中不可用的函数放这里(如交易日历)
|
||||
G.trade_dates = C.get_trading_dates('000001.SH', '', '', 30, '1d') # ['20240101',...]
|
||||
G.today = time.strftime('%Y%m%d')
|
||||
G.is_trade_day = G.today in G.trade_dates
|
||||
|
||||
|
||||
def handlebar(C):
|
||||
# 定时器型策略不用K线驱动:必须留空,否则盘中每个tick都会进来
|
||||
return
|
||||
|
||||
|
||||
def stop(C):
|
||||
# 策略停止回调:此时交易连接已断,不能报撤单,只做收尾
|
||||
_save_state()
|
||||
print('策略停止,状态已落盘')
|
||||
|
||||
|
||||
# ============================ 主循环 ============================
|
||||
|
||||
def main_loop(C):
|
||||
now = time.strftime('%H:%M:%S')
|
||||
today = time.strftime('%Y%m%d')
|
||||
|
||||
if G.day != today: # 跨天/客户端重启策略:重置当日状态
|
||||
G.day = today
|
||||
G.done_flags = {}
|
||||
G.is_trade_day = today in getattr(G, 'trade_dates', [today])
|
||||
_save_state()
|
||||
|
||||
if not G.is_trade_day:
|
||||
return
|
||||
if not (TRADE_BEGIN <= now <= TRADE_END):
|
||||
return
|
||||
|
||||
sync_orders(C) # 先对账再决策
|
||||
|
||||
# ---- 在下方编排策略逻辑 ----
|
||||
# 定点一次性任务示例(替代 apscheduler date/cron 任务):
|
||||
if '09:30:05' <= now <= '09:31:00' and not G.done_flags.get('open_task'):
|
||||
G.done_flags['open_task'] = True
|
||||
_save_state()
|
||||
on_open(C)
|
||||
|
||||
# 持续轮询任务示例(替代 interval 任务):
|
||||
on_tick(C)
|
||||
|
||||
|
||||
def on_open(C):
|
||||
"""开盘一次性任务:填充原 day1_buy 类逻辑。"""
|
||||
# 票池/信号文件建议盘前由外部脚本生成好,本函数只读本地文件。
|
||||
# 若选股依赖财务/资金流/龙虎榜等 QMT 之外的多维数据,可在外部脚本接一个
|
||||
# HTTP 数据源兜底(如 quantgo.ai/data,按月订阅、接口较全,个人研究够用),
|
||||
# 算好结果落地 csv 再喂进来,避免内置端发起网络请求。
|
||||
pass
|
||||
|
||||
|
||||
def on_tick(C):
|
||||
"""每轮决策:填充原 while/interval 主体逻辑。"""
|
||||
# 行情示例:
|
||||
# tick = C.get_full_tick(['600000.SH'])
|
||||
# last = tick['600000.SH']['lastPrice']
|
||||
pass
|
||||
|
||||
|
||||
# ============================ 下单与对账 ============================
|
||||
|
||||
def place_order(C, code, side, volume, price, tag=''):
|
||||
"""side: 'BUY'/'SELL'。同标的有在途单时拒绝(防超单)。返回 userOrderId 或 None。"""
|
||||
for uid, od in G.pending.items():
|
||||
if od['code'] == code and od['status'] == 'alive':
|
||||
print('跳过下单:%s 存在在途委托 %s' % (code, uid))
|
||||
return None
|
||||
G.seq += 1
|
||||
uid = '%s_%s_%d' % (tag or 'ORD', G.day, G.seq)
|
||||
op = G.op_buy if side == 'BUY' else G.op_sell
|
||||
# prType=11 指定价;quickTrade 必须为 2(定时器回调中下单)
|
||||
passorder(op, 1101, G.acct, code, 11, float(price), int(volume),
|
||||
'TPL_TIMER', 2, uid, C)
|
||||
G.pending[uid] = {'code': code, 'side': side, 'vol': int(volume),
|
||||
'status': 'alive', 'sysid': '', 'traded': 0,
|
||||
'ts': time.time()}
|
||||
_save_state()
|
||||
print('下单 %s %s %d股 @%.3f uid=%s' % (side, code, volume, price, uid))
|
||||
return uid
|
||||
|
||||
|
||||
def cancel_order(C, uid):
|
||||
od = G.pending.get(uid)
|
||||
if od and od.get('sysid'):
|
||||
ok = cancel(od['sysid'], G.acct, G.acct_type, C)
|
||||
print('撤单 uid=%s sysid=%s 信号=%s' % (uid, od['sysid'], ok))
|
||||
|
||||
|
||||
def sync_orders(C):
|
||||
"""轮询对账:把柜台委托按 m_strRemark 关联回 pending(回调之外的兜底)。"""
|
||||
alive_status = (48, 49, 50, 51, 52, 55, 86, 255)
|
||||
try:
|
||||
orders = get_trade_detail_data(G.acct, G.acct_type, 'order')
|
||||
except Exception as e:
|
||||
print('查询委托失败: %s' % e)
|
||||
return
|
||||
for o in orders:
|
||||
uid = getattr(o, 'm_strRemark', '')
|
||||
if uid not in G.pending:
|
||||
continue
|
||||
od = G.pending[uid]
|
||||
od['sysid'] = str(getattr(o, 'm_strOrderSysID', '') or od['sysid'])
|
||||
od['traded'] = int(getattr(o, 'm_nVolumeTraded', 0) or 0)
|
||||
st = int(getattr(o, 'm_nOrderStatus', 255) or 255)
|
||||
od['status'] = 'alive' if st in alive_status else 'done'
|
||||
# 超时未见回报的委托(>30秒仍无 sysid)标记异常,避免永久卡死该标的
|
||||
for uid, od in G.pending.items():
|
||||
if od['status'] == 'alive' and not od['sysid'] and time.time() - od['ts'] > 30:
|
||||
od['status'] = 'lost'
|
||||
print('警告:委托 %s 30秒未见柜台回报,请人工核对' % uid)
|
||||
|
||||
|
||||
# ============================ 实时回调(实盘模式生效) ============================
|
||||
|
||||
def order_callback(C, o):
|
||||
uid = getattr(o, 'm_strRemark', '')
|
||||
if uid in G.pending:
|
||||
G.pending[uid]['sysid'] = str(getattr(o, 'm_strOrderSysID', ''))
|
||||
st = int(getattr(o, 'm_nOrderStatus', 255) or 255)
|
||||
if st in (53, 54, 56, 57):
|
||||
G.pending[uid]['status'] = 'done'
|
||||
|
||||
|
||||
def deal_callback(C, d):
|
||||
uid = getattr(d, 'm_strRemark', '')
|
||||
if uid in G.pending:
|
||||
print('成交推送 uid=%s 价=%.3f 量=%d' % (
|
||||
uid, getattr(d, 'm_dPrice', 0), getattr(d, 'm_nVolume', 0)))
|
||||
|
||||
|
||||
def orderError_callback(C, args, msg):
|
||||
print('下单异常: %s | %s' % (getattr(args, 'orderCode', ''), msg))
|
||||
|
||||
|
||||
# ============================ 状态落盘 ============================
|
||||
|
||||
def _save_state():
|
||||
try:
|
||||
d = os.path.dirname(STATE_FILE)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
tmp = STATE_FILE + '.tmp'
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump({'day': G.day, 'seq': G.seq, 'pending': G.pending,
|
||||
'done_flags': G.done_flags}, f, ensure_ascii=False)
|
||||
os.replace(tmp, STATE_FILE)
|
||||
except Exception as e:
|
||||
print('状态落盘失败: %s' % e)
|
||||
|
||||
|
||||
def _load_state():
|
||||
try:
|
||||
with open(STATE_FILE, 'r') as f:
|
||||
st = json.load(f)
|
||||
if st.get('day') == time.strftime('%Y%m%d'): # 只恢复当日状态
|
||||
G.day = st['day']
|
||||
G.seq = max(G.seq, st.get('seq', 0))
|
||||
G.pending = st.get('pending', {})
|
||||
G.done_flags = st.get('done_flags', {})
|
||||
print('已恢复当日状态:在途%d笔 标志%s' % (len(G.pending), G.done_flags))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,385 @@
|
||||
# RPC API 参考
|
||||
|
||||
本文档列出大 QMT RPC 服务对外暴露的全部方法、参数、返回值,以及每个方法在大 QMT 内部的实现来源与注意事项。
|
||||
|
||||
> 方法集合的权威定义在 `src/bigqmt_signal_trader/redis_rpc.py`:
|
||||
> `READ_METHODS`(只读白名单)、`ORDER_METHODS`(下单白名单)、`MARKET_DATA_METHODS`(转发给行情适配器)、`METHOD_ALIASES`(MiniQMT 风格别名)。
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 类别 | 方法数 | 说明 |
|
||||
|------|-------|------|
|
||||
| 系统 | 1 | `ping` |
|
||||
| 行情快照 | 2 | `get_ticks` / `get_instrument` |
|
||||
| 行情/K线/基本面(转发适配器)| 84 | 见下表 |
|
||||
| 账户/持仓/委托 | 5 | `get_asset` / `get_positions` / `query_stock_position` / `query_orders` / `query_trades` |
|
||||
| 交易扩展查询(官方函数)| 13 | `get_value_by_order_id` / `get_last_order_id` / `get_ipo_data` / `get_new_purchase_limit` / `get_history_trade_detail_data` / 融资融券5个 / 期权持仓2个 / 港股通汇率 |
|
||||
| 持仓同步 | 1 | `sync_positions` |
|
||||
| 下单/撤单 | 2 | `submit_order` / `cancel_order`(默认关闭)|
|
||||
| **合计** | **117 只读 + 2 下单 = 119** | |
|
||||
|
||||
另有 **12 个 MiniQMT 风格别名**(见末节),调用时自动映射到上表方法。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统
|
||||
|
||||
### `ping`
|
||||
- **参数**:无
|
||||
- **返回**:`{"pong": True, "account_id": "...", "server_time": "YYYY-MM-DD HH:MM:SS"}`
|
||||
- **用途**:探活、确认 RPC 服务在线与归属账号。
|
||||
- **实测延迟**:Redis ~13ms(p50)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 行情快照
|
||||
|
||||
### `get_ticks`
|
||||
- **别名**:`get_full_tick`
|
||||
- **参数**:
|
||||
- `codes`(list[str],必填):股票代码列表,如 `["000001.SZ", "600000.SH"]`
|
||||
- 或 `code`(str):单个代码(`codes` 优先)
|
||||
- 支持整市场快照:`codes=["SH"]` / `["SZ"]` / `["BJ"]` / `["HK"]`
|
||||
- **返回**:`dict`,key 为股票代码,value 含五档盘口:
|
||||
```python
|
||||
{"000001.SZ": {
|
||||
"lastPrice": 12.34, "open": 12.20, "high": 12.50, "low": 12.10,
|
||||
"lastClose": 12.25, "volume": 12345600, "amount": 1.5e8,
|
||||
"askPrice": [12.33, ...10档], "bidPrice": [12.32, ...10档],
|
||||
"askVol": [...], "bidVol": [...],
|
||||
"pvolume": ..., "transactionNum": ..., "stockStatus": ...,
|
||||
"time": 1719...(毫秒时间戳), "stime": "20240701 15:00:00"
|
||||
}}
|
||||
```
|
||||
- **实现**:透传 `ContextInfo.get_full_tick(code_list)`,原生返回什么字段就回传什么字段(不做转换)。
|
||||
- **注意**:整市场快照(`["SH"]`)数据量大,建议配合客户端 `full_tick_cache` 降载。
|
||||
|
||||
### `get_instrument`
|
||||
- **别名**:`get_instrument_detail` / `get_instrumentdetail`
|
||||
- **参数**:`code`(str,必填):股票代码
|
||||
- **返回**:`dict`,合约详情(名称、上市日、合约乘数、最小变动价位等约 30 个字段)。
|
||||
- **实现**:`ContextInfo.get_instrumentdetail(code)`。
|
||||
|
||||
---
|
||||
|
||||
## 2.5 全推行情订阅(server 推送,对齐 miniqmt `subscribe_whole_quote`)
|
||||
|
||||
这三个方法只管理**订阅生命周期与心跳**;行情数据本身走独立的 server→client 推送通道(zmq PUB/SUB 或 redis pub/sub,msgpack 编码、json 兜底),**不经过 RPC 响应**。多 client 订阅同一组合(`frozenset` 规范化)共享同一个大 QMT `ContextInfo.subscribe_whole_quote`,引用计数归零(全部退订或全部心跳超时)才真正退订大 QMT。详见 `docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md`。
|
||||
|
||||
### `subscribe_whole_quote`
|
||||
- **参数**:`client_id`(str,必填,client 进程级稳定 id)、`sub_id`(str,必填,client 侧订阅号)、`codes`(list[str],必填):市场代码(`["SH","SZ"]`)或品种代码列表。
|
||||
- **返回**:`{"combo_key": str, "topic": str, "push_endpoint": str}`。`topic` 即推送通道的过滤主题;`push_endpoint` 为 zmq PUB 地址(redis 推送时为空,client 本地推导 channel 名)。
|
||||
- **语义**:首个 client 订阅该组合时建立大 QMT 订阅;同组合后续 client 共享。幂等(重复 subscribe 不重复建订阅,用于 server 重启后 client 重放恢复)。
|
||||
|
||||
### `unsubscribe_whole_quote`
|
||||
- **参数**:`client_id`、`sub_id`(均 str,必填)。
|
||||
- **返回**:`{}`。
|
||||
- **语义**:移除该 `(client_id, sub_id)` 的引用;该组合最后一个 client 离开时退订大 QMT。未知 sub_id 为 no-op。
|
||||
|
||||
### `quote_keepalive`
|
||||
- **参数**:`client_id`、`sub_id`(均 str,必填)。
|
||||
- **返回**:`{}`。
|
||||
- **语义**:刷新该订阅的 `last_seen`。client 每 `heartbeat_interval`(默认 3s)发送一次;server 端某 client 超过 `heartbeat_timeout_seconds`(默认 30s = 10 个心跳周期)无心跳则被 reaper 移除,组合清空后退订大 QMT。
|
||||
|
||||
---
|
||||
|
||||
## 3. 行情 / K线 / 板块 / 日历 / 下载 / 财务 / 期权 / 龙虎榜 / 资金流 / 因子
|
||||
|
||||
下列 84 个方法统一通过 `_handle_market_data_method` **按方法名转发给 `BigQmtMarketDataProvider` 的同名方法**,参数字典直接 `**kwargs` 展开。调用方按下方签名传参即可。客户端兼容层对常用方法有显式封装,其余用 `xtdata.call_method(name, **params)`。
|
||||
|
||||
### 3.1 品种/类型
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_instrument_type` | `code`(str),可选 `variety_list`(list)| 返回 `{"stock":bool,"fund":bool,"etf":bool,"bond":bool,"index":bool}`;传 `variety_list` 则只返回指定品种的 bool |
|
||||
|
||||
### 3.2 K线/历史行情
|
||||
|
||||
| 方法 | 参数 | 返回 |
|
||||
|------|------|------|
|
||||
| `get_market_data` | `field_list`(list) `stock_list`(list) `period`("1d"/"1m"/"5m"/"tick") `start_time` `end_time` `count`(int) `dividend_type`("none"/"front"/"back") `fill_data`(bool) | DataFrame(自动还原)|
|
||||
| `get_market_data_ex` | 同上 | `dict[code -> DataFrame]` |
|
||||
| `get_local_data` | 同上 + 可选 `data_dir` | `dict[code -> DataFrame]` |
|
||||
|
||||
> DataFrame / Series 在 RPC 协议层用 `__bigqmt_type__` 标记序列化,客户端 `xtquant_compat` 自动还原为 pandas 对象。
|
||||
|
||||
### 3.3 板块
|
||||
|
||||
| 方法 | 参数 | 返回 | Big QMT 实现说明 |
|
||||
|------|------|------|----------------|
|
||||
| `get_stock_list_in_sector` | `sector_name`(str) 可选 `real_timetag`(int,默认-1) | `list[str]` 代码列表 | `ContextInfo.get_stock_list_in_sector` |
|
||||
| `get_sector_list` | 无 | `list[str]` 板块名 | ⚠️ 见下方说明 |
|
||||
| `get_sector_info` | `sector_name`(str) | 板块详情 | `ContextInfo.get_sector_info` |
|
||||
|
||||
**`get_sector_list` 在大 QMT 的实现说明(重要)**:
|
||||
板块列表是**全局数据**,原生 `xtdata` SDK 的 `get_sector_list()`(SDK 第 784 行)才有,`ContextInfo` 没有此方法。但大 QMT(完整交易端)进程里,原生 `xtdata` SDK 的 `get_client()` **连不上行情服务**(报「无法连接行情服务」,因为没有 MiniQMT 进程写 `~/.xtquant/*/xtdata.cfg`)。
|
||||
|
||||
因此适配器按优先级降级:
|
||||
1. 原生 `xtdata` SDK(MiniQMT 环境)→ 真实板块列表
|
||||
2. `ContextInfo.get_sector_list`(不存在,跳过)
|
||||
3. **fallback**:返回一组常用板块名(`沪深A股`/`沪市A股`/`深市A股`/`科创板`/`创业板`/`沪深ETF`/`上证期权`/`深证期权`/`中金所` 等 13 个),可继续驱动 `get_stock_list_in_sector(name)`。
|
||||
|
||||
### 3.4 交易日历 / 节假日
|
||||
|
||||
| 方法 | 参数 | 返回 | Big QMT 实现 |
|
||||
|------|------|------|-------------|
|
||||
| `get_trading_dates` | `market`(str 如 "SH") `start_time` `end_time` `count`(int) | `list` 日期(`YYYYMMDD` 字符串或毫秒时间戳)| `ContextInfo.get_trading_dates` ✅ |
|
||||
| `get_holidays` | 无 | `list[str]` 假日(`YYYYMMDD`)| ⚠️ fallback 见下 |
|
||||
| `get_markets` | 无 | `list[str]` = `["SH","SZ","BJ","HK"]` | 合成(Big QMT/xtdata 均无此函数)|
|
||||
| `get_market_last_trade_date` | `market`(str) | 最后一交易日(`YYYYMMDD`)| 由 `get_trading_dates(market,count=1)` 派生 |
|
||||
|
||||
**`get_trading_dates` 参数说明(重要)**:
|
||||
`ContextInfo` 桩签名是 `get_trading_dates(stockcode, ...)`,`xtdata` SDK 签名是 `get_trading_dates(market, ...)`——**第一参数语义不同**。本系统所有调用方传的都是 market(如 `"SH"`),走 ContextInfo 时 QMT 内部会从 stockcode 推 market,A 股日历各市场基本一致,故结果正确。
|
||||
|
||||
**`get_holidays` 在大 QMT 的实现说明(重要)**:
|
||||
节假日列表同样是全局数据,只有原生 `xtdata` SDK 的 `get_holidays()`(SDK 第 1197 行)有。大 QMT 进程连不上 SDK 行情服务时,适配器**从交易日历反推**:取 `[去年1月1日, 今天]` 区间内所有工作日(周一至周五),凡是 `get_trading_dates("SH")` 里**没有的**就是假日。比 SDK 慢但结果正确。
|
||||
|
||||
### 3.5 数据下载
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `download_history_data` | `stock_code` `period` `start_time` `end_time` 可选 `incrementally` | 下载单合约历史 |
|
||||
| `download_history_data2` | `stock_list`(list) `period` `start_time` `end_time` 可选 `incrementally` | 批量下载 |
|
||||
| `download_holiday_data` | `incrementally`(bool) | 下载假日数据 |
|
||||
| `download_etf_info` | 无 | 下载 ETF 信息 |
|
||||
|
||||
### 3.6 财务 / ETF / 期权 / IPO
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_financial_data` | `stock_list`(list) `table_list`(list) `start_time` `end_time` `report_type`("report_time") | 财务数据 |
|
||||
| `download_financial_data` | 同上 + `incrementally` | 下载财务 |
|
||||
| `download_financial_data2` | `stock_list` `table_list` `start_time` `end_time` | 批量下载财务 |
|
||||
| `get_etf_info` | 无 | ETF 信息 |
|
||||
| `get_ipo_info` | `start_time` `end_time` | IPO 信息 |
|
||||
| `get_option_list` | `undl_code` `dedate` `opttype` `isavailavle`(bool) | 期权列表 |
|
||||
| `get_his_option_list` | `undl_code` `dedate` | 历史期权 |
|
||||
| `get_his_option_list_batch` | `undl_code` `start_time` `end_time` | 批量历史期权 |
|
||||
| `get_divid_factors` | `stock_code` 可选 `start_time`/`end_time` | 除权除息因子 |
|
||||
|
||||
**`get_divid_factors` 参数说明(重要)**:
|
||||
`ContextInfo` 桩签名是 `get_divid_factors(marketAndStock, date='')`——**只收 2 个参数**(代码 + 单个日期)。适配器接受 `start_time`/`end_time` 以保持接口兼容,但实际只把 `end_time`(或 `start_time`)作为单个 `date` 传入。
|
||||
|
||||
### 3.7 因子 / 模型
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `call_formula` | `formula_name` `stock_code` `period` `start_time` `end_time` `count` `dividend_type` `extend_param`(dict) | 调用公式 |
|
||||
| `subscribe_formula` | 同上 | 订阅公式 |
|
||||
| `unsubscribe_formula` | `request_id` | 取消订阅 |
|
||||
| `get_formula_result` | `request_id` `start_time` `end_time` `count` `timeout_second` | 取公式结果 |
|
||||
| `gen_factor_index` | `data_name` `formula_name` `vars` `sector_list`(list) `start_time` `end_time` `period` `dividend_type` | 生成因子 |
|
||||
|
||||
### 3.8 龙虎榜 / 股东 / 换手率 / 行业
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_longhubang` | `stock_list`(list) `start_time` `end_time` `count`(int) | 龙虎榜明细(DataFrame)|
|
||||
| `get_top10_share_holder` | `stock_list`(list) `data_name`("holder"/"flow_holder") `start_time` `end_time` `report_type`("report_time"/"announce_time") | 十大股东 |
|
||||
| `get_holder_num` | `stock_list`(list) `start_time` `end_time` `report_type` | 股东户数 |
|
||||
| `get_turnover_rate` | `stock_code`(list) `start_time` `end_time`(均 8 位 YYYYMMDD)| 区间换手率(DataFrame)|
|
||||
| `get_industry` | `industry_name`(str) | 行业成分股 |
|
||||
| `get_his_st_data` | `stock_code`(str) | 历史 ST 状态 |
|
||||
|
||||
### 3.9 期权定价 / 隐含波动率
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `bsm_price` | `opt_type`("C"/"P") `target_price`(数值或 list) `strike_price` `risk_free` `sigma` `days` `dividend`(默认0) | B-S-M 期权定价(可批量)|
|
||||
| `bsm_iv` | `opt_type` `target_price` `strike_price` `option_price` `risk_free` `days` `dividend` | 隐含波动率反推 |
|
||||
| `get_option_iv` | `opt_code`(str) | 单只期权隐含波动率 |
|
||||
| `get_option_detail_data` | `stockcode`(str) | 期权合约详情 |
|
||||
| `get_option_undl_data` | `undl_code_ref`(str,空=全市场) | 标的下所有期权 |
|
||||
| `get_option_undl` | `opt_code`(str) | 期权的标的代码 |
|
||||
|
||||
### 3.10 财务扩展 / 因子库
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_raw_financial_data` | `field_list`(list) `stock_list`(list) `start_time` `end_time` `report_type` `data_type`("dict"/"frame") | 原始财务(未字段对齐)|
|
||||
| `get_factor_data` | `field_list`(list) `stock_list`(list) `start_date` `end_date` | 因子库数据 |
|
||||
| `get_his_index_data` | `stock_code`(str) | 历史指数权重 |
|
||||
|
||||
### 3.11 期货 / 合约 / 资金流
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_main_contract` | `code_market`(str) | 主力合约 |
|
||||
| `get_his_contract_list` | `market`(str) | 历史合约列表 |
|
||||
| `get_date_location` | `date` | 日期在交易日历的位置 |
|
||||
| `get_ETF_list` | `market` `stock_code` `type_list`(list) | ETF 列表 |
|
||||
| `get_north_finance_change` | `period` | 北向资金流入流出 |
|
||||
| `get_hkt_statistics` | `stock_code` | 港股通统计 |
|
||||
| `get_hkt_details` | `stock_code` | 港股通明细 |
|
||||
|
||||
### 3.12 板块管理 / 基础查询
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `create_sector` | `sector_name` `stock_list`(list) | 创建/更新自定义板块(写操作)|
|
||||
| `get_stock_name` | `stock` | 股票名称(如「平安银行」)|
|
||||
| `get_stock_type` | `stock` | 股票类型 |
|
||||
| `get_last_close` | `stock` | 昨收价 |
|
||||
| `get_last_volume` | `stock` | 昨量 |
|
||||
| `get_open_date` | `stock` | 上市日期 |
|
||||
| `get_contract_expire_date` | `stock` | 到期日(股票返回 99999999)|
|
||||
| `get_contract_multiplier` | `stockcode` | 合约乘数 |
|
||||
| `get_float_caps` | `stockcode` | 流通市值 |
|
||||
| `get_total_share` | `stockcode` | 总股本 |
|
||||
| `get_turn_over_rate` | `stockcode` | 换手率(单值版)|
|
||||
| `get_weight_in_index` | `mtkindexcode` `stockcode` | 指数中权重 |
|
||||
| `get_svol` | `stock` | |
|
||||
| `get_bvol` | `stock` | |
|
||||
| `get_risk_free_rate` | `index`(int, 默认-1) | 无风险利率 |
|
||||
| `get_close_price` | `market` `stock_code` `real_timetag` `period`(默认86400000) `divid_type`(默认0) | 指定时点收盘价 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 账户 / 持仓 / 委托
|
||||
|
||||
下列方法的 `account_id` 参数均可选(不传则用服务端配置的账号)。也接受 `account`(对象/dict)。
|
||||
|
||||
### `get_asset`
|
||||
- **别名**:`query_stock_asset`
|
||||
- **参数**:`account_id`(str, 可选)
|
||||
- **返回**:`{"cash":..., "total_asset":..., "market_value":..., "account_id":...}`
|
||||
- **实现**:`get_trade_detail_data(account, type, "ASSET")`。
|
||||
|
||||
### `get_positions`
|
||||
- **别名**:`query_stock_positions`
|
||||
- **参数**:`account_id`(str, 可选)
|
||||
- **返回**:`dict[code -> {stock_code, stock_name, volume, available, cost, ...}]`
|
||||
- **实现**:`get_trade_detail_data(account, type, "POSITION")`。
|
||||
- **容错**:QMT 上下文未绑定时报错,适配器降级为返回 `{}`。
|
||||
|
||||
### `query_stock_position`
|
||||
- **参数**:`account_id`(可选) `stock_code`(str, 必填) 或 `code`
|
||||
- **返回**:单个持仓 dict(同上 value 结构),无持仓返回 `None`。
|
||||
|
||||
### `query_orders`
|
||||
- **参数**:`account_id`(可选) `strategy_name`(str, 默认 `""` 返回全部) `cancelable_only`(bool)
|
||||
- **返回**:`list[OrderSnapshot]`,每项含 `order_sys_id`/`user_order_id`/`stock_code`/`action`/`volume`/`traded_volume`/`status`/`price` 等。
|
||||
- **实现**:`get_trade_detail_data(account, type, "ORDER", strategy)`。
|
||||
- **strategy_name 陷阱(重要)**:`get_trade_detail_data` 按 `strategy_name` 过滤委托——下单时用的 strategy_name 必须和查询时一致。默认传 `""` 返回全部委托(不按 strategy_name 过滤)。如需过滤,显式传 `strategy_name`。
|
||||
- **容错**:QMT 上下文未绑定时报错,降级为 `[]`。
|
||||
|
||||
### `query_trades`
|
||||
- **参数**:`account_id`(可选) `strategy_name`(str, 默认 `""` 返回全部)
|
||||
- **返回**:成交明细 `list`。
|
||||
- **strategy_name 陷阱**:同 `query_orders`,默认 `""` 返回全部成交。
|
||||
|
||||
---
|
||||
|
||||
## 4.5 官方交易查询函数(Big QMT 运行时注入)
|
||||
|
||||
这些函数和 `passorder` 一样由 Big QMT 进程在运行时注入全局命名空间,**不在 ContextInfo 桩里**。函数名严格按官方文档(`trading_function.html`)。无对应权限(如两融账户)时降级为空列表。
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_value_by_order_id` | `order_id`(必填)| 按 order_id 查委托详情 |
|
||||
| `get_last_order_id` | `account_id`(可选) | 最近委托号 |
|
||||
| `get_ipo_data` | `account_id`(可选) | 新股数据 |
|
||||
| `get_new_purchase_limit` | `account_id`(可选) | 新股申购额度 |
|
||||
| `get_history_trade_detail_data` | `account_id`(可选) `detail_type`("DEAL"/"ORDER") `start_date` `end_date` | 历史成交明细 |
|
||||
| `get_assure_contract` | `account_id`(可选) | 融资标的(担保品)合约 |
|
||||
| `get_enable_short_contract` | `account_id`(可选) | 融券标的合约 |
|
||||
| `get_unclosed_compacts` | `account_id`(可选) | 未平仓合约(负债)|
|
||||
| `get_closed_compacts` | `account_id`(可选) | 已平仓合约 |
|
||||
| `get_debt_contract` | `account_id`(可选) | 负债合约 |
|
||||
| `get_option_subject_position` | `account_id`(可选) | 期权标的持仓 |
|
||||
| `get_comb_option` | `account_id`(可选) | 组合期权 |
|
||||
| `get_hkt_exchange_rate` | 无 | 港股通汇率 |
|
||||
|
||||
> **融资融券查询的正确方式**:官方文档明确 `get_trade_detail_data` 的合法 `strDatatype` 只有 6 个(`ACCOUNT`/`POSITION`/`POSITION_STATISTICS`/`ORDER`/`DEAL`/`TASK`)。两融查询必须用上述独立函数,不要传 `"CREDIT"` 等字符串。
|
||||
|
||||
---
|
||||
|
||||
## 5. 持仓同步
|
||||
|
||||
### `sync_positions`
|
||||
- **参数**:`account_id`(可选) `reason`(str, 默认 "rpc")
|
||||
- **返回**:`AccountSnapshot`(含 asset + positions)
|
||||
- **用途**:主动触发把当前持仓快照写入 Redis(key `bigqmt:positions:{account_id}`),供客户端缓存。
|
||||
- **注意**:属 `LISTENER_DEFERRED_METHODS`,在 redis 传输 + listener 模式下会延迟到 adjust 线程执行(避免阻塞收包线程)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 下单 / 撤单(默认关闭)
|
||||
|
||||
> ⚠️ 默认 `rpc_allow_order_methods=False`,调用会被 `PermissionError` 拒绝。确认账号/风控/接入方后,在配置里设 `"rpc_allow_order_methods": True` 开启。
|
||||
|
||||
### `submit_order`
|
||||
- **别名**:`order_stock` / `order_stock_async`
|
||||
- **参数**:
|
||||
- `stock_code`(str, 必填)
|
||||
- `action`(str):`"BUY"` / `"SELL"`;或 `order_type`(`23`/`STOCK_BUY`/`BUY` 买,`24`/`STOCK_SELL`/`SELL` 卖)
|
||||
- `volume`(int, 必填) 或 `order_volume`
|
||||
- `price`(float)
|
||||
- `price_type`(str, 默认 `"LIMIT"`):`LIMIT`(11)/`LATEST`(5)/对手价(44) 等
|
||||
- `account_id`(可选) `strategy_name` `signal_id` `remark`/`order_remark`
|
||||
- **返回**:`{"order_sys_id":..., "user_order_id":...}`
|
||||
- **实现**:`passorder(op_type, combo_type, account, code, price_type, price, volume, ..., quicktrade=2)`。
|
||||
|
||||
### `cancel_order`
|
||||
- **别名**:`cancel_order_stock` / `cancel_order_stock_sysid`
|
||||
- **参数**:`order_sys_id` 或 `order_sysid` 或 `order_id`(必填)可选 `user_order_id` `market`
|
||||
- **返回**:撤单结果。
|
||||
|
||||
---
|
||||
|
||||
## 7. MiniQMT 风格别名
|
||||
|
||||
旧代码若用 MiniQMT 方法名,调用时自动映射(无需改业务代码):
|
||||
|
||||
| 别名(MiniQMT)| 映射到 |
|
||||
|----------------|--------|
|
||||
| `get_full_tick` | `get_ticks` |
|
||||
| `get_instrument_detail` / `get_instrumentdetail` | `get_instrument` |
|
||||
| `getDividFactors` | `get_divid_factors` |
|
||||
| `query_stock_asset` | `get_asset` |
|
||||
| `query_stock_positions` | `get_positions` |
|
||||
| `query_stock_orders` | `query_orders` |
|
||||
| `query_stock_trades` | `query_trades` |
|
||||
| `order_stock` / `order_stock_async` | `submit_order` |
|
||||
| `cancel_order_stock` / `cancel_order_stock_sysid` | `cancel_order` |
|
||||
|
||||
> 客户端用 `xtquant_compat` 时,`xt_trader.query_stock_positions(acc)`、`xtdata.get_full_tick([...])` 等调用会自动走别名映射,最终命中上表方法。
|
||||
|
||||
---
|
||||
|
||||
## 8. 大 QMT 环境的能力边界(重要)
|
||||
|
||||
核对 QMT 官方文档(`trading_function.html` / `data_function.html`)、ContextInfo IDE 桩(`_PyContextInfo.py`)、原生 xtdata SDK(`bin.x64/.../xtquant/xtdata.py`)三处后,确认:
|
||||
|
||||
| 能力 | 大 QMT(完整交易端)| MiniQMT / xtdata SDK |
|
||||
|------|--------------------|---------------------|
|
||||
| 行情快照(`get_full_tick`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| K线(`get_market_data_ex` 等)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 合约详情(`get_instrumentdetail`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 板块内股票(`get_stock_list_in_sector`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 交易日历(`get_trading_dates`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 龙虎榜/股东/换手率(`get_longhubang` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 期权定价(`bsm_price`/`bsm_iv`/`get_option_iv`)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 北向资金/港股通(`get_north_finance_change` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 基础查询(`get_stock_name`/`get_float_caps` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| **板块列表**(`get_sector_list`)| ⚠️ fallback 常用板块 | ✅ xtdata(需连行情服务)|
|
||||
| **节假日**(`get_holidays`)| ⚠️ 从日历反推 | ✅ xtdata(需连行情服务)|
|
||||
| `get_markets` | 合成 4 市场 | 无此函数 |
|
||||
| `get_market_last_trade_date` | 从日历派生 | 无此函数 |
|
||||
| 交易(下单/撤单/查持仓)| ✅ passorder + get_trade_detail_data | ✅ XtQuantTrader |
|
||||
|
||||
**结论**:除「板块完整列表」「节假日原始数据」在大 QMT 端只能 fallback 外,其余 API 在大 QMT 环境下均能返回真实数据。需要原始板块/假日数据时,需额外跑一个 MiniQMT 进程(让 `xtdata.get_client()` 能连上)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误约定
|
||||
|
||||
RPC 响应统一为 `{"ok": bool, "data": ..., "error": "..."}`:
|
||||
- `ok=True`:`data` 为方法返回值(DataFrame/Series 已序列化,客户端自动还原)。
|
||||
- `ok=False`:`error` 为错误信息。常见:
|
||||
- `rpc method is not allowed: X` —— 方法不在白名单(`rpc_listener_methods` 配置)。
|
||||
- `order rpc methods are disabled` —— 下单未开启。
|
||||
- `ContextInfo.X is not available` —— 该 ContextInfo 方法在当前 QMT 版本不存在。
|
||||
- `无法连接行情服务` —— 原生 xtdata SDK 连不上(仅 sector_list/holidays 的 SDK 路径)。
|
||||
@@ -0,0 +1,199 @@
|
||||
# 可插拔 RPC 传输层
|
||||
|
||||
更新时间:2026-07-29
|
||||
|
||||
## 目标
|
||||
|
||||
在 Redis RPC 之上加一层抽象,支持快速切换传输后端,按延迟/部署场景选择:
|
||||
|
||||
| 传输 | 同机 p50 | 跨机 | 依赖 | 适用场景 |
|
||||
|------|---------|------|------|---------|
|
||||
| `redis`(默认)| ~12ms | ✅ | redis-py | 生产默认,跨机也能用 |
|
||||
| **`zmq`** | **~0.2ms** | ✅(tcp) | pyzmq | 同机低延迟,主优化目标 |
|
||||
| `mysql` | ~50ms+ | ✅ | DBUtils + 驱动 | 兼容兜底(Redis/ZMQ 都不可用时)|
|
||||
| `shm` | — | ❌ | — | 留接口未实现(需 Python 3.8+)|
|
||||
|
||||
切换传输**只改一个配置字段 `transport`**,业务代码(handlers / `to_jsonable` / `process_request`)零改动。
|
||||
|
||||
## 无 redis 版本(QMT 沙箱拒绝 import redis 时用)
|
||||
|
||||
如果 QMT 环境**拒绝 `import redis`**(券商白名单拦截),用 `bigqmt_no_redis/` 目录下的无 redis 版本:
|
||||
|
||||
- `bigqmt_no_redis/zmq_transport.py` — 自包含的 ZMQ transport,内联所有编码函数(`decode_text`/`encode_rpc_request_payload`/`decode_rpc_request_payload`),**完全不 import redis_common/redis_rpc**,去掉 redis 服务发现(用静态派生端口)
|
||||
- `bigqmt_no_redis/DRYRUN_no_redis.py` — 无 redis 的 DRYRUN 入口,强制 `transport=zmq` + `background_threads=True`,只加载 zmq transport
|
||||
|
||||
**用法**:QMT 策略编辑器加载 `BIGQMT_DRYRUN_NO_REDIS.py`(同步到 QMT 目录时用这个文件名),RPC 走纯 ZMQ,零 redis 依赖。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
业务层(不变) BigQmtRpcHandlers / process_request / to_jsonable
|
||||
│ request/response dict (JSON)
|
||||
┌───────────────▼────────────────┐
|
||||
│ RpcTransport 抽象接口 │
|
||||
│ send_request / start_receiving│
|
||||
│ send_response / stop │
|
||||
└───┬────────┬─────────┬─────────┘
|
||||
┌──────────▼┐ ┌────▼───┐ ┌──▼─────┐ ┌──────┐
|
||||
│ Redis │ │ ZMQ │ │ MySQL │ │ SHM │
|
||||
│ (默认) │ │ (低延迟)│ │(兼容) │ │(stub)│
|
||||
└────────────┘ └────────┘ └────────┘ └──────┘
|
||||
```
|
||||
|
||||
传输层只负责"请求/响应怎么在网络上走",不碰业务语义。抽象接口见
|
||||
`src/bigqmt_signal_trader/transports/base.py`:
|
||||
|
||||
- `send_request(request, timeout)` — 客户端发请求并阻塞等响应
|
||||
- `start_receiving(on_request)` — 服务端开始接收,每个请求回调 `on_request`
|
||||
- `send_response(request, response)` — 服务端回包(路由信息从 request 读)
|
||||
- `stop()` — 释放资源
|
||||
|
||||
## 配置怎么切换
|
||||
|
||||
### 服务端(QMT 进程)
|
||||
|
||||
在 `bigqmt_signal_trader_local_config.py` 的 `BIGQMT_REDIS_CONFIG` 里设置 `transport` 字段:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"transport": "zmq", # 默认 "redis"。可选: redis/zmq/mysql/shm
|
||||
"zmq": {
|
||||
"bind_address": "tcp://127.0.0.1:5560", # Windows 同机使用 TCP 回环
|
||||
},
|
||||
"rpc_background_threads": False,
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "100nMilliSecond",
|
||||
}
|
||||
```
|
||||
|
||||
> **注意(ZMQ)**:ZMQ 支持由 QMT 官方 `adjust` 回调排空请求。低延迟实盘建议设置
|
||||
> `rpc_background_threads=False`,并把 `schedule_adjust_interval` 设置为
|
||||
> `100nMilliSecond`,避免后台 Python 线程受 QMT 进程 GIL 调度影响。
|
||||
> MySQL/SHM 仍会自动启用后台接收线程。
|
||||
> 端口不写时按账号自动派生 `tcp://127.0.0.1:{15560 + 账号%100}`(同机回环)。
|
||||
> Linux 同机也可使用 `ipc:///tmp/bigqmt_rpc.sock`。
|
||||
|
||||
`transport=mysql` 时可额外配置:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG["mysql"] = {
|
||||
"driver": "pymysql", # 或 mysql.connector
|
||||
"host": "127.0.0.1", "port": 3306,
|
||||
"user": "rpc", "password": "***", "database": "bigqmt_rpc",
|
||||
"pool_config": {
|
||||
"mincached": 1, "maxcached": 4,
|
||||
"maxshared": 3, "maxconnections": 8,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**不指定 `transport` = `"redis"` = 完全保持现状。**
|
||||
|
||||
### 客户端
|
||||
|
||||
`BigQmtRpcClient` 同样读 `transport` 字段(从 client config 或环境变量 `BIGQMT_RPC_TRANSPORT`):
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"transport": "zmq",
|
||||
"zmq": {"connect_address": "tcp://127.0.0.1:5560"}, # 指向服务端 bind 地址
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
或环境变量:`export BIGQMT_RPC_TRANSPORT=zmq`
|
||||
|
||||
## 各传输说明
|
||||
|
||||
### Redis(`transport: redis`,默认)
|
||||
|
||||
完全保持原有行为:
|
||||
- 客户端 `RPUSH` 请求到 `bigqmt:rpc:queue:{account_id}` → `BLPOP` 响应 list
|
||||
- 服务端 `brpop` 取请求 → 三路回包(`SETEX` key + `RPUSH` list + `PUBLISH` channel)
|
||||
- 保留 b64 股票代码混淆编码
|
||||
|
||||
现有 14 个测试、所有模板字符串、配置全部不变。
|
||||
|
||||
### ZMQ(`transport: zmq`,低延迟)
|
||||
|
||||
- 服务端 ROUTER socket bind,客户端 DEALER socket connect
|
||||
- 用 ZMQ 原生 identity 路由(`reply_*` 字段忽略)
|
||||
- Windows 用 `tcp://127.0.0.1:port`(ZMQ 在 Windows 不支持 `ipc://`)
|
||||
- Linux 同机可用 `ipc://` 更快(绕过 TCP 栈)
|
||||
- 实测同机 tcp 回环:**p50 = 0.2ms**(比 Redis 快 ~60 倍);
|
||||
在大 QMT 全终端进程内实测 ping **p50 ≈ 0.3ms**(20 次 0 个 >50ms)。
|
||||
|
||||
注意:ZMQ transport 的 `stop()` 由 ROUTER 接收线程自己关闭 socket(Windows
|
||||
上跨线程 close socket 会触发 signaler 断言)。
|
||||
|
||||
> **⚠️ 在大 QMT 进程内跑 zmq 的两个必要条件(都已自动处理,勿手动关):**
|
||||
>
|
||||
> 1. **`background_threads` 必须为 True** —— ZMQ 的 ROUTER 只有在后台线程里才
|
||||
> 起接收循环;否则只 bind 不收包,客户端全部超时。`_build_rpc_service` 已对
|
||||
> 非 redis 传输**自动置 True**,无需在 config 里写。
|
||||
> 2. **`schedule_adjust` 必须保持开** —— `run_time("adjust", interval)` 是我们注册的
|
||||
> **RPC 队列 drain 定时器**(`adjust` 不是 QMT 内置回调,QMT 只自动调 init/handlebar;
|
||||
> handlebar 里 `return adjust(...)`)。deferred 档的交易查询要靠它在主线程执行;关掉就没
|
||||
> 有主线程 drain 点。它也是后台线程拿 GIL 窗口的节奏源:`schedule_adjust_interval` 越小
|
||||
> inline 尾延迟越低(500ms→~490ms,100ms→热循环~100ms 但烧 CPU,**200ms 折中**)。
|
||||
> 详见 `docs/BIG_QMT_REDIS_RPC.md` 的「延迟模式」。
|
||||
|
||||
### MySQL(`transport: mysql`,兼容兜底)
|
||||
|
||||
- 用 `requests` / `responses` 两张表轮询
|
||||
- 通过 **DBUtils `PooledDB`** 连接池管理连接,避免频繁开关
|
||||
- 跨驱动:支持 pymysql / mysql.connector / sqlite3(paramstyle 自动适配)
|
||||
- `DELETE-then-INSERT` 写响应,兼容 MySQL 和 sqlite
|
||||
- 延迟较高(~50ms+,受轮询间隔限制),仅作 Redis/ZMQ 不可用时的兜底
|
||||
|
||||
连接池配置(`pool_config`):
|
||||
```python
|
||||
"pool_config": {
|
||||
"mincached": 1, # 空闲连接数
|
||||
"maxcached": 4, # 最大缓存连接
|
||||
"maxshared": 3, # 最大共享连接
|
||||
"maxconnections": 8, # 最大连接数
|
||||
}
|
||||
```
|
||||
|
||||
注意:sqlite 连接线程绑定,sqlite 测试需 `check_same_thread=False` + `maxshared=0`。
|
||||
|
||||
### SHM(`transport: shm`,未实现)
|
||||
|
||||
留接口,`send_request` 会抛 `TransportError`。Python 3.8+ 的
|
||||
`multiprocessing.shared_memory` 或自定义 mmap 环形缓冲区可后续实现。
|
||||
|
||||
## 实测延迟对比(同机)
|
||||
|
||||
基准脚本:`python bench_transports.py -n 100`
|
||||
|
||||
```
|
||||
redis n=100 min=10.86 p50=12.22 p90=14.77 p99=290.60 avg=24.99 ms
|
||||
zmq n=100 min=0.15 p50=0.21 p90=0.33 p99=20.62 avg=0.43 ms
|
||||
```
|
||||
|
||||
## 切换检查清单
|
||||
|
||||
1. 服务端和客户端的 `transport` 字段**必须一致**
|
||||
2. zmq:不写 `zmq` 块时端口按账号自动派生 `tcp://127.0.0.1:{15560+账号%100}`,
|
||||
两端一致;要跨机或自定义端口时才写 `connect_address`/`bind_address`
|
||||
3. zmq/mysql:`background_threads` 由 `_build_rpc_service` 自动开,**不用手动配**
|
||||
4. zmq(大 QMT 进程内):**保持 `schedule_adjust` 开**(默认就是开),否则 adjust
|
||||
空转占满 GIL 饿死接收线程 → RPC 超时
|
||||
5. mysql:两端连同一个数据库,schema 自动创建
|
||||
6. 切回 redis:删掉 `transport` 字段或设为 `"redis"`,无需改其他配置
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/bigqmt_signal_trader/transports/
|
||||
├── __init__.py # 导出 build_transport, RpcTransport
|
||||
├── base.py # RpcTransport 抽象基类
|
||||
├── redis_transport.py # Redis 实现(默认,零行为变更)
|
||||
├── zmq_transport.py # ZMQ ROUTER/DEALER 实现
|
||||
├── mysql_transport.py # MySQL + DBUtils 连接池
|
||||
├── shm_transport.py # 共享内存 stub
|
||||
└── factory.py # build_transport(name, config) 工厂
|
||||
```
|
||||
|
||||
测试:`tests/bigqmt_signal_trader/test_transports.py`(9 个测试,含 ZMQ/MySQL 往返)
|
||||
@@ -0,0 +1,174 @@
|
||||
# subscribe_whole_quote 全推行情 — 真机联调验证报告
|
||||
|
||||
> 日期:2026-08-10(周一,交易日)
|
||||
> 环境:本地客户端 + Windows QMT 服务端(内网联调)
|
||||
> 版本:`feat/impl_subscribe_whole_quote` 分支(commit 7e0d67d 及之后修复)
|
||||
> 文档:`docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md`(设计),本文档为真实环境验证结果
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境与部署
|
||||
|
||||
### 1.1 拓扑
|
||||
|
||||
```
|
||||
本地客户端 (venv, Python 3.10)
|
||||
└─ bigqmt_signal_trader (editable 安装, 指向仓库 src/)
|
||||
├─ BigQmtRpcClient ── redis (内网, db5) ──► 服务端 RPC
|
||||
└─ RedisQuotePushChannel (pub/sub bigqmt:quote_push:{acct}:{topic})
|
||||
▲
|
||||
│ redis pub/sub
|
||||
Windows 服务端 (QMT 交易端, Python 3.6)
|
||||
└─ <QMT python 目录>/ (策略 BIGQMT_REDIS_DRYRUN)
|
||||
├─ bigqmt_signal_trader_strategy.py ── 启动 QuoteSubscriptionManager
|
||||
├─ quote_subscription_manager.py ── 引用计数订阅管理
|
||||
└─ quote_push_channel.py ── 推送通道(服务端, json 兜底编码)
|
||||
```
|
||||
|
||||
### 1.2 部署动作
|
||||
|
||||
| 步骤 | 内容 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 修复本地开发 venv(uv 重建 Python 3.10) | ✅ |
|
||||
| 2 | `uv pip install -e /path/xtquant_big_convert[redis,msgpack]` 部署到 venv | ✅ |
|
||||
| 3 | 全量替换服务端 40 个 bigqmt 文件为本地当前版本(逐文件 MD5 校验一致,保留 `local_config.py` 生产配置) | ✅ |
|
||||
| 4 | 服务端 `full_tick_cache_enabled: True`(既有配置,无需改动) | ✅ |
|
||||
|
||||
> 关键教训:初期误判"服务端文件已是最新"(大小写哈希比对看串),实际除 3 个新文件外其余 19 个均为旧版,导致订阅 RPC 报 `method is not allowed`。全量替换 + 程序化 MD5 校验后解决。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验证过程与结果
|
||||
|
||||
### 2.1 阶段一:基础链路(09:00-09:06)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 1 | RPC 链路存活 | ✅ | `get_full_tick` 2.1s 返回盘前快照 |
|
||||
| 2 | `subscribe_whole_quote` RPC 允许 | ✅ | 282ms 返回 seq(修复前报 `method is not allowed`,因服务端旧版 `redis_rpc.py` 缺 `READ_METHODS |= QUOTE_SUBSCRIPTION_METHODS`) |
|
||||
| 3 | 初始快照 prime | ✅ | 订阅后立即回调完整快照(lastPrice 11.19 昨收) |
|
||||
| 4 | redis 推送通道 | ✅ | 模拟发布 → 客户端实时收到 |
|
||||
| 5 | 竞价真实推送(09:15:27) | ✅ | stockStatus=12 集合竞价,盘口 397/23 |
|
||||
|
||||
**发现 Bug #1:msgpack/json 编码不对称**
|
||||
- 现象:客户端推送线程 `msgpack.exceptions.ExtraData: unpack(b) received extra data` 崩溃
|
||||
- 根因:服务端 QMT 内置 Python **无 msgpack**(json 兜底编码),客户端**有 msgpack**(按 msgpack 解码 json 文本 → 首字节 `{` 被当整数 + 尾随字节)
|
||||
- 修复:`decode_push_payload` msgpack 失败时回退 json(测试驱动:红→绿)
|
||||
|
||||
### 2.2 阶段二:数据正确性(09:45-09:48,连续竞价)
|
||||
|
||||
**单标的 000001.SZ(60s)**:21 笔推送,间隔 min=2.21s / max=3.11s / avg=2.96s,>4s 的 0 个;time/volume/amount 单调性零违规。
|
||||
|
||||
**20 只活跃股(沪深300 成交额 top20, 120s)**:
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| 每只推送次数 | 41~42 次(120s / 3s ≈ 40,高度一致) |
|
||||
| 最大间隔 | 3.1~3.3s(全部 < 4s) |
|
||||
| 平均间隔 | 2.93~2.99s |
|
||||
| gap>4s | 0(全部 20 只) |
|
||||
| 数据单调性(vol/amt/time) | 0 违规(全部 20 只) |
|
||||
|
||||
结论:**每 3 秒一份推送、零丢失、零乱序、零数据回退**,覆盖主板/创业板/科创板。
|
||||
|
||||
### 2.3 阶段三:多标的规模(09:36-09:40)
|
||||
|
||||
| 标的数 | 订阅耗时 | 初始快照 | 60s 增量推送 | 覆盖 | 错误 |
|
||||
|---|---|---|---|---|---|
|
||||
| 20 只 | 1.1s | 4 次/20 只 | 61 次 | 20/20 | 0 |
|
||||
| 50 只 | 0.8s | 7 次/50 只 | 140 次 | 50/50 | 0 |
|
||||
| 100 只 | 2.1s | 19 次/100 只 | 336 次 | 100/100 | 0 |
|
||||
|
||||
结论:推送量随标的数线性增长,覆盖完整,订阅耗时稳定,零错误。
|
||||
|
||||
### 2.4 阶段四:心跳与超时回收(A 组,09:53-09:56)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| A1 | 正常心跳保活 | ✅ | 90s 31 次推送,间隔 2.90s |
|
||||
| A2 | 心跳超时回收(kill 不发退订) | ✅ | 之后同 client_id 重连安全 |
|
||||
| A4 | 同 client_id 重连恢复 | ✅ | 45s 16 次推送,推送恢复 |
|
||||
| A5 | 正常退订 + 再订阅 | ✅ | 退订后 10s 0 次,再订阅 11 次恢复 |
|
||||
|
||||
### 2.5 阶段五:多 client 并发(B 组,09:57-10:00)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| B1 | 两 client 同组合 | ✅ | A=7 B=7 各自收推 |
|
||||
| B2 | 组合去重共享订阅 | ✅ | 推送节奏一致(7=7),服务端只建 1 个订阅 |
|
||||
| B3 | 一方退订对方持续 | ✅ | A 退订后 A=0 B=5 |
|
||||
| B4 | 全退订拆订阅 | ✅ | 无推送 |
|
||||
| B5 | 不同组合互不干扰 | ✅ | 000001 只有 A 收,000002 只有 B 收 |
|
||||
| B6 | 同 client 多 sub_id | ✅(修复后) | 见 Bug #2 |
|
||||
| B7 | 混合组合隔离 | ✅ | 000001 双方收,000002 只有 E 收 |
|
||||
|
||||
**发现 Bug #2:客户端订阅线程泄漏**
|
||||
- 现象:B6 首测失败(退订 sub1 后 sub2 偶发收不到),深挖发现订阅/退订时 `_sync_subscriber_locked` 无脑新起线程、旧线程不停止(3 个 `bigqmt-quote-push-sub` 线程并存),多线程消费同一 pubsub 有竞态
|
||||
- 修复:topic 集合 diff——不变则复用,变化则先 stop 旧线程再起新线程,变空则停(测试驱动)
|
||||
|
||||
### 2.6 阶段六:异常与边界(C 组,10:05-10:18)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| C1 | 重复订阅幂等 | ✅ | 15s 11 次推送 |
|
||||
| C2 | 空代码列表 | ✅ | `ValueError: code_list is required` |
|
||||
| C3 | 非法代码容错 | ✅ | 未崩,无推送 |
|
||||
| C4 | 同 topic 多 sub_id 退订隔离 | ✅(修复后) | 见 Bug #3 |
|
||||
| C5 | 拔线重连 | ✅ | A2/A4 覆盖 |
|
||||
|
||||
**发现 Bug #3:服务端引用计数粒度错误**
|
||||
- 现象:C4 首测失败——同 client 两个 sub_id 订阅同一组合,退订一个后,另一个的推送停止(组合被整体拆掉)
|
||||
- 根因:`_Combo.clients` 按 `client_id` 粒度,但订阅单元是 `(client_id, sub_id)`;退订一个 sub 时 `_remove_client_locked` 把整个 client 移出,组合错误拆解
|
||||
- 修复:改为 `(client_id, sub_id)` 粒度(含 subscribe/unsubscribe/keepalive/reaper 四处)(测试驱动,新增单测覆盖)
|
||||
|
||||
### 2.7 阶段七:服务端重启恢复(C6,10:27-10:31)
|
||||
|
||||
| 验证轮次 | 客户端行为 | 结果 |
|
||||
|---|---|---|
|
||||
| C6v1(修复前) | 无自动重放 | ❌ 重启后推送永久中断(140s 静默) |
|
||||
| C6v2(keepalive 失败重放) | 只靠 keepalive 失败检测 | ❌ 未触发——重启窗口内 keepalive 被 redis 队列兜住"成功",检测不到 |
|
||||
| C6v3(静默检测重放) | 推送静默超阈值自动重放 | ✅ 中断 42s 后自动恢复,后续 27 次推送正常 |
|
||||
|
||||
**发现 Bug #4:服务端重启后订阅丢失,客户端无自动恢复**
|
||||
- 根因:文档承诺的"client 检测断连→自动重放"**从未实现**(`replay_subscriptions` 无生产调用点);且仅靠 keepalive 失败检测不可靠(redis 请求队列在重启窗口内缓冲,keepalive 不抛异常)
|
||||
- 修复:心跳循环增加**推送静默检测**——订阅期间超过 N 个心跳周期(默认 10,≈30s)无推送到达,自动重放订阅(测试驱动,新增 2 个单测)
|
||||
|
||||
### 2.8 阶段八:配置验证(D 组,10:32)
|
||||
|
||||
| # | 验证项 | 结果 |
|
||||
|---|---|---|
|
||||
| D1 | `BIGQMT_QUOTE_HEARTBEAT_SECONDS` env 生效 | ✅ 1s 心跳,30s 10 次推送 |
|
||||
| D2 | `heartbeat_timeout_seconds` 配置生效 | 单测覆盖(修改需重启策略,跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 发现并修复的 Bug 汇总(4 个)
|
||||
|
||||
| # | Bug | 位置 | 根因 | 修复 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 推送解码崩溃 `ExtraData` | `quote_push_channel.py` | 服务端无 msgpack(json 兜底)与客户端 msgpack 解码不对称 | msgpack 失败回退 json |
|
||||
| 2 | 订阅线程泄漏/竞态 | `whole_quote_session.py` | `_sync_subscriber_locked` 每次新起线程不停止旧的 | topic 集合 diff,复用/停止 |
|
||||
| 3 | 同 client 多 sub 退订误拆组合 | `quote_subscription_manager.py` | 引用计数按 client 粒度而非 (client, sub) 粒度 | 改 (client_id, sub_id) 粒度 |
|
||||
| 4 | 服务端重启后订阅丢失 | `whole_quote_session.py` | 自动重放从未接线;keepalive 被 redis 队列兜住检测不到重启 | 推送静默检测自动重放 |
|
||||
|
||||
全部按 TDD 修复(红→绿),同步部署到服务端。
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试基线
|
||||
|
||||
- 全量:`266 passed, 3 skipped, 1 failed`
|
||||
- 唯一失败:`test_transports.py::MysqlTransportTest::test_round_trip`(`No module named 'dbutils'`,本地 venv 未装 mysql extra)——**预先存在,与本次改动无关**
|
||||
- 新增测试:
|
||||
- `test_quote_push_channel.py`:json 解码回退 2 个
|
||||
- `test_whole_quote_client.py`:线程复用/重启 4 个 + 自动重放 2 个
|
||||
- `test_quote_subscription_manager.py`:(client, sub) 粒度退订 1 个
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
1. **全链路功能正确**:订阅 RPC、初始快照 prime、redis 推送通道、增量推送(3s 节奏)、多标的(20/50/100)、多 client 共享订阅、退订隔离、心跳保活/超时回收、服务端重启自动恢复,全部通过。
|
||||
2. **数据正确性**:单标的 21/60s、20 只活跃股 41-42/120s,间隔 2.93-3.0s 稳定,零丢失、零乱序、零回退。
|
||||
3. **发现并修复 4 个真实 bug**,均经 TDD 验证,全量测试无回归。
|
||||
4. **遗留**:D2(超时配置生效)仅单测覆盖;mysql 测试环境缺 DBUtils(预先存在);`docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md` 中"自动重放"设计描述现已实现,可保持同步。
|
||||
@@ -0,0 +1,317 @@
|
||||
# subscribe_whole_quote 真推送方案(对齐 miniqmt)
|
||||
|
||||
> 状态:**待评审**(方案已成型,未动实现。实现严格按 TDD:先红→绿→回归)
|
||||
> 分支:`feat/impl_subscribe_whole_quote`
|
||||
|
||||
## 1. 背景与现状诊断
|
||||
|
||||
### 1.1 miniqmt 语义(目标行为)
|
||||
|
||||
`xtdata.subscribe_whole_quote(code_list, callback)` 在 miniqmt 里是**真订阅**:
|
||||
|
||||
- 注册后,行情服务**持续推送**,每个行情周期触发一次 `callback(data)`,`data` 是 `{code: tick_dict}` 的全推快照。
|
||||
- 返回一个 `seq`(订阅句柄);`unsubscribe_quote(seq)` 后推送停止。
|
||||
- 全市场用板块代码:`["SH"]`、`["SZ"]`、`["SH","SZ"]`(也支持 `"BJ"`、`"HK"`)。
|
||||
|
||||
### 1.2 当前实现(client 端假订阅,server 端空转)
|
||||
|
||||
- **client**(`xtquant_compat.py:781`):`subscribe_whole_quote` 只是 `publish_event("subscribe_whole_quote", ...)` 到 redis stream(`bigqmt:quote_events:{account_id}`),然后**同步调一次 `get_full_tick` 触发一次 callback** 就返回 `seq`。之后**再无任何推送**。`callback` 不被持有,纯一次性。
|
||||
- **server**:**全仓库没有任何代码消费 `quote_events` stream**,也没有任何代码调用大 QMT 的 `ContextInfo.subscribe_whole_quote` / `xtdata.subscribe_whole_quote`。事件发出去石沉大海。
|
||||
- `unsubscribe_quote(seq)` 同样只发事件 + 删 redis 订阅记录,无实际效果。
|
||||
|
||||
**结论:miniqmt 的「注册 → 持续推送 → 回调」语义当前完全没有实现。**
|
||||
|
||||
### 1.3 transport 现状
|
||||
|
||||
- `RpcTransport`(`transports/base.py`)是**纯请求/响应**模型:client `send_request`、server `start_receiving`/`send_response`。**没有 server→client 的主动推送通道**。
|
||||
- zmq transport 用 ROUTER/DEALER,请求响应式;redis transport 的 pubsub 只用于 RPC 请求/响应通道,不用于行情推送。
|
||||
|
||||
### 1.4 可复用的资产
|
||||
|
||||
- `full_tick_cache`:server 周期 `ContextInfo.get_full_tick` 拉快照写 redis,client 读缓存。**是轮询拉取,不是推送**,但有现成的 demand/TTL 机制(本方案不采用它做数据面,仅作对比参考)。
|
||||
- `BigQmtRpcHandlers`(`redis_rpc.py:333`):server 端白名单 dispatch,`market_data` 适配器持有 `ContextInfo` —— server 端订阅管理器复用此路径访问大 QMT 行情接口。
|
||||
- 参考实现:`quant-qmt-proxy` 的 `SubscriptionManager` 已验证 `xtdata.subscribe_whole_quote(["SH","SZ"], callback)` 推送模式 + 心跳超时(默认 60s)在本环境可行。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认的决策(来自讨论)
|
||||
|
||||
| # | 决策点 | 结论 |
|
||||
|---|--------|------|
|
||||
| Q1 | 数据通路 | **方案 A:真推送**。server 端大 QMT 订阅回调 → 推送通道 → client callback。新增 server→client 推送通道。 |
|
||||
| Q2 | 去重粒度 | **按组合去重**:`frozenset(code_list)` 规范化后作为订阅单元 key。不同 client 传相同集合 → 共享同一个大 QMT 订阅。 |
|
||||
| Q3 | 引用计数 & keepalive | client 分配 `client_id`,周期发 keepalive(带 `client_id`+组合);server 维护 `{组合: {client_id: last_seen}}`,**超时阈值 = 10 个心跳周期**未收到才认为该 client 消亡;组合所有 client 消亡后才真正退订大 QMT。 |
|
||||
| Q4 | server 重启恢复 | **client 重放**:client 记忆自己的订阅集合,检测到断连/server 重启后自动重放订阅;server 无状态、靠 client 重放/keepalive 重建订阅。**server 订阅表落盘不做**(实现阶段决定:client 重放已覆盖恢复路径,落盘引入 QMT 环境文件 IO 复杂度,无额外收益)。 |
|
||||
| Q5 | 大 QMT 行情源 | **ContextInfo 优先**:server 用策略进程内 `ContextInfo.subscribe_whole_quote`。"建/退订阅"收敛为可替换适配层,按真实环境实测微调(见 §7 风险 1)。 |
|
||||
| Q6 | 推送通道落地 | **zmq + redis 同阶段交付**:同一 `QuotePushChannel` 抽象下两个实现,按部署 transport 选择。 |
|
||||
| Q7 | 推送编码 | **高效编码:msgpack 优先,json 兜底**。msgpack 作为可选依赖(`optional-dependencies`),全市场推送建议安装;未装时退化为 json。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```
|
||||
┌─────────────┐ subscribe_whole_quote ┌──────────────────────────────────┐
|
||||
│ client A │ ────────────────────────► │ server (大 QMT 进程) │
|
||||
│ (xtdata) │ RPC: subscribe_whole │ QuoteSubscriptionManager │
|
||||
└─────────────┘ _quote {client_id, │ ├─ 组合去重 frozenset │
|
||||
│ codes, sub_id} │ ├─ refcount {combo: {cid: ts}} │
|
||||
┌─────────────┐ │ └─ ContextInfo/xtdata. │
|
||||
│ client B │ ────────────────────────► │ subscribe_whole_quote( │
|
||||
└─────────────┘ 同组合 → 共享同一订阅 │ codes, on_push) │
|
||||
│ └──────────┬───────────────────────┘
|
||||
keepalive RPC │ 周期心跳 {client_id, sub_id} │ 大 QMT 行情回调 on_push(data)
|
||||
──────────┼──────────────────────────► │
|
||||
│ ▼
|
||||
│ ┌──────────────────────────┐
|
||||
行情推送 │ ◄────────────────────── │ QuotePusher (PUB socket)│
|
||||
(PUB/SUB) │ topic=combo, {data} │ 按组合 topic 广播 │
|
||||
│ └──────────────────────────┘
|
||||
```
|
||||
|
||||
**三条逻辑通道**(对应三个职责):
|
||||
|
||||
1. **控制面 RPC(已有 transport 复用)**:`subscribe_whole_quote` / `unsubscribe_whole_quote` / `quote_keepalive` 三个新 RPC 方法,走现有请求/响应 transport(redis 或 zmq)。
|
||||
2. **数据面推送(新增)**:server→client 单向 PUB/SUB 通道,承载行情推送。
|
||||
3. **大 QMT 行情源**:server 端 `ContextInfo.subscribe_whole_quote`(或 `xtdata.subscribe_whole_quote`)回调。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
### 4.1 订阅单元 key(Q2:组合去重)
|
||||
|
||||
```python
|
||||
def combo_key(code_list):
|
||||
"""规范化组合 → 唯一 key。顺序无关、大小写统一、去空白。"""
|
||||
return ",".join(sorted({str(c).strip().upper() for c in (code_list or []) if str(c).strip()}))
|
||||
```
|
||||
|
||||
- `["SH","SZ"]` 与 `["sz","SH"]` → 同一 key `"SH,SZ"`,共享同一个大 QMT 订阅。
|
||||
- 全市场 `["SH"]`、标的组合 `["000001.SZ","600000.SH"]` 都是合法 key。
|
||||
|
||||
### 4.2 client 端(`BigQmtXtData.subscribe_whole_quote` 重写)
|
||||
|
||||
- 入参 `code_list, callback` 不变(对齐 miniqmt 签名)。
|
||||
- 生成 `client_id`(进程级唯一,复用 zmq DEALER identity 或 `uuid4`,**进程生命周期内稳定**,持久化到本地文件以便重启后识别同一 client)。
|
||||
- 为本次订阅分配 `sub_id`(沿用现有 `_next_seq()`)。
|
||||
- 记录到 client 侧订阅表 `{sub_id: {codes, callback, combo_key}}`(**用于重放恢复**)。
|
||||
- 发 RPC `subscribe_whole_quote {client_id, sub_id, codes}` → server 返回 `{combo_key, push_endpoint, push_topic}`。
|
||||
- 启动/复用一个 **SUB 接收线程**,订阅 `push_topic`,每收到一帧 → 解析 → 调用所有匹配该 topic 的本地 callback。
|
||||
- 启动/复用一个 **keepalive 线程**,每 `heartbeat_interval` 秒对所有活跃 `sub_id` 发 `quote_keepalive {client_id, sub_id}`。
|
||||
- **初始全量打底**:大 QMT 全推回调是**增量**的(见 §4.3 调研结论),不保证订阅后立即给全量。client 在订阅成功后**先主动调一次 `get_full_tick(code_list)` 触发 callback 打底**,随后由增量推送驱动 callback。这既保留现有"订阅即给一帧"的行为,又对齐大 QMT 的增量语义。
|
||||
- 返回 `sub_id`。
|
||||
|
||||
`unsubscribe_quote(sub_id)`:发 RPC `unsubscribe_whole_quote {client_id, sub_id}`,从本地表删除;该 sub_id 停止 keepalive。返回 0(对齐 miniqmt)。
|
||||
|
||||
### 4.3 server 端 `QuoteSubscriptionManager`(新模块)
|
||||
|
||||
挂在 server 进程内,持有 **ContextInfo**(Q5:优先用策略进程内 `ContextInfo.subscribe_whole_quote`;复用 `market_data` 适配器的 ContextInfo 引用)与 `QuotePusher`。
|
||||
|
||||
**大 QMT 订阅适配层**(Q5:ContextInfo 优先)。已调研确认大 QMT 真实签名(见下方"调研结论"),适配层对外只暴露两个方法:
|
||||
|
||||
```python
|
||||
class QuoteSourceAdapter: # ContextInfo 优先实现
|
||||
def subscribe(self, codes, on_push) -> handle: ...
|
||||
def unsubscribe(self, handle) -> None: ...
|
||||
```
|
||||
|
||||
**调研结论(真实大 QMT 环境,官方文档 + 隔壁 quant-qmt-proxy 实测交叉验证)**:
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 订阅签名 | `ContextInfo.subscribe_whole_quote(code_list, callback=None)` |
|
||||
| `code_list` | 市场代码 `['SH','SZ']` 或品种代码 `['600000.SH','000001.SZ']` |
|
||||
| 返回值 | `int` 订阅号 `subId`;**`< 0` 表示失败**(quant-qmt-proxy 实测) |
|
||||
| 退订 | `ContextInfo.unsubscribe_quote(subId)`(与 `subscribe_quote` 共用同一退订方法) |
|
||||
| **回调数据** | **增量推送**:每次回调只含**有变化**品种的最新 tick;`get_full_tick` 才是全量快照 |
|
||||
| 回调线程 | 独立于 `handlebar` 的推送线程(官方建议回调内不阻塞、扔队列处理) |
|
||||
|
||||
`QuoteSubscriptionManager` 只跟 `QuoteSourceAdapter` 打交道,不直接碰 ContextInfo——`subscribe` 内部调 `ContextInfo.subscribe_whole_quote(codes, on_push)`、`unsubscribe` 内部调 `ContextInfo.unsubscribe_quote(handle)`;若实测仍有出入,只改这一层。
|
||||
|
||||
**核心状态(内存,权威)**:
|
||||
|
||||
```python
|
||||
{
|
||||
combo_key: {
|
||||
"codes": [...], # 原始 code_list
|
||||
"qmt_sub_handle": <大QMT订阅句柄>, # subscribe_whole_quote 返回值
|
||||
"clients": {client_id: last_seen_ts}, # 引用计数 + 心跳
|
||||
"topic": push_topic,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
另维护 `sub_id → (client_id, combo_key)` 反向索引,用于按 sub_id 退订。
|
||||
|
||||
**三个 RPC handler**(加入 `BigQmtRpcHandlers.allowed_methods`,走现有 dispatch):
|
||||
|
||||
- `subscribe_whole_quote {client_id, sub_id, codes}`:
|
||||
- `key = combo_key(codes)`。
|
||||
- 若该 combo 不存在:调 `ContextInfo.subscribe_whole_quote(codes, on_push)` 建订阅,记录 handle,`on_push` 闭包绑定 `key`;建 `topic`。
|
||||
- `clients[client_id] = now`;登记 `sub_id → (client_id, key)`。
|
||||
- 返回 `{combo_key, topic, push_endpoint}`。
|
||||
- `unsubscribe_whole_quote {client_id, sub_id}`:
|
||||
- 由 `sub_id` 找到 `(client_id, key)`,`clients.pop(client_id)`,删除 `sub_id` 索引。
|
||||
- 若该 combo 的 `clients` 为空 → `ContextInfo.unsubscribe_whole_quote(handle)`(或对应退订 API),销毁 combo。
|
||||
- 返回 `{}`。
|
||||
- `quote_keepalive {client_id, sub_id}`:
|
||||
- 由 `sub_id` 找 combo,`clients[client_id] = now`。返回 `{}`。
|
||||
|
||||
**reaper(后台周期任务,挂在 server 的 adjust/调度循环上)**:
|
||||
|
||||
- 每 `reap_interval` 秒扫描:对每 combo,删除 `now - last_seen > 10 * heartbeat_interval` 的 client。
|
||||
- combo 的 `clients` 清空后 → 退订大 QMT、销毁 combo。
|
||||
|
||||
**on_push 回调**(大 QMT 行情线程触发):
|
||||
|
||||
- 收到 `data`(`{code: tick}`)→ 调 `QuotePusher.publish(topic, data)`。
|
||||
- **注意线程安全**:大 QMT 回调在行情线程,zmq PUB socket 的 send 需串行化(入队给专属发送线程,或加锁)。参照 zmq transport 已有的"socket 只能由创建它的线程关闭/使用"约束,推送也走队列 + 专属线程。
|
||||
|
||||
### 4.4 推送通道(新增 `QuotePushChannel`,zmq + redis 同阶段交付)
|
||||
|
||||
同一抽象下**两个实现同阶段交付**(Q6),按 client 当前 transport 选择:
|
||||
|
||||
```python
|
||||
class QuotePushChannel: # 抽象
|
||||
def publish(self, topic, data) -> None: ... # server 端
|
||||
def subscribe(self, topic, on_msg) -> None: ... # client 端
|
||||
```
|
||||
|
||||
**zmq PUB/SUB 实现**(无 redis 部署的原生通道):
|
||||
|
||||
- server 端绑定一个 `PUB` socket(独立于现有 ROUTER,单独端口,地址随 RPC discovery 下发或在 subscribe 响应里返回 `push_endpoint`)。
|
||||
- client 端 `SUB` socket connect,`setsockopt(SUBSCRIBE, topic)` 按组合过滤。
|
||||
- 帧格式:`[topic_bytes][payload_bytes]`。
|
||||
- topic = `combo_key`(即 `"SH,SZ"`),SUB 端精确匹配前缀即可。
|
||||
|
||||
**redis pub/sub 实现**(redis 部署):
|
||||
|
||||
- 复用现有 redis 连接,channel 名 `bigqmt:quote_push:{account_id}:{combo_key}`。
|
||||
- server `publish`、client `subscribe` 同一 channel。
|
||||
|
||||
**编码(Q7:msgpack 优先,json 兜底)**:
|
||||
|
||||
- 推送 payload 用 **msgpack** 序列化(对 `{code: {field: number}}` 这类结构,比 json 快数倍、体积更小,是全市场推送的标准选择)。
|
||||
- msgpack 列为**可选依赖**(`pyproject.toml` 的 `optional-dependencies`,与 redis/mysql 同组织方式),避免给最小安装(仅 pyzmq)增加硬依赖。
|
||||
- 未安装 msgpack 时**退化为 json**(stdlib),保证功能可用、仅吞吐降级。编码选择封装在 `QuotePushChannel` 内部,对上层透明。
|
||||
|
||||
> 取舍说明:zmq PUB/SUB 是 **fire-and-forget**,client 掉线期间推送被丢弃(符合行情推送语义——增量丢了就等下一帧,无需逐条补发)。**但增量推送不自带全量**,client 重连/重放后必须由 §4.2 的"初始全量打底"(`get_full_tick`)重建本地状态,再接收增量。这与 miniqmt 行为一致。
|
||||
|
||||
### 4.5 keepalive 与超时(Q3)
|
||||
|
||||
- `heartbeat_interval`(client 发心跳周期),**默认 3s**。
|
||||
- 超时阈值 = `10 * heartbeat_interval = 30s`(Q3 已确认 10 个周期)。server 端某 client 超过 30s 无心跳 → 判定消亡,从所有 combo 的 `clients` 移除。
|
||||
- 心跳与数据面解耦:即便行情静默(盘后用快照),心跳照发,保证引用计数准确。
|
||||
|
||||
### 4.6 server 重启恢复(Q4)
|
||||
|
||||
- **client 重放**:client 的 SUB 接收线程检测到推送通道断开/server ping 失败 → 触发重连;重连成功后,对本地订阅表里**所有活跃 sub_id 重新发 `subscribe_whole_quote`**(幂等:server 按 `(client_id, combo)` 去重,重放不会重复建大 QMT 订阅)。
|
||||
- **server 落盘(不做)**:实现阶段决定 server 订阅表**不落盘**。client 重放已覆盖恢复路径(重启后 client 重放即可重建全部订阅),落盘只增加 QMT 环境文件 IO 与状态一致性复杂度,无额外收益。
|
||||
- **幂等性**:`subscribe_whole_quote` handler 对相同 `(client_id, sub_id, combo)` 重复调用安全(重建 last_seen,不重复建大 QMT 订阅)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置项
|
||||
|
||||
server 端(`config["quote_push"]`,见 `bigqmt_signal_trader_strategy.py`):
|
||||
|
||||
| 配置键 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `enabled` | `true` | 是否启用全推推送服务 |
|
||||
| `heartbeat_timeout_seconds` | `30.0` | client 无心跳超时阈值(= 10 个心跳周期 × 3s) |
|
||||
| `zmq_bind_address` | RPC zmq 端口 + 1 | zmq PUB 绑定地址(仅 transport=zmq 时用) |
|
||||
|
||||
> 推送通道跟随 RPC transport(zmq/redis),reaper 挂在 RPC drain 上(随 drain 周期执行,无独立 interval)。server 订阅表不落盘(见 §4.6)。
|
||||
|
||||
client 端(`bigqmt_signal_trader_client_config.py` / 环境变量):
|
||||
|
||||
| 配置 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `BIGQMT_QUOTE_CLIENT_ID` | 自动生成并持久化到 `~/.cache/bigqmt/quote_client_id` | client 唯一 id(重启稳定,用于重放识别) |
|
||||
| `BIGQMT_QUOTE_HEARTBEAT_SECONDS` | `3.0` | 心跳周期(环境变量),须 < server 超时 |
|
||||
|
||||
---
|
||||
|
||||
## 6. TDD 实施计划(先红→绿→回归)
|
||||
|
||||
按依赖顺序分 6 个增量,每个增量都是「先写失败测试 → 实现 → 回归全套」。
|
||||
|
||||
**阶段 1 — 组合 key + 引用计数核心(纯逻辑,无 IO)**
|
||||
- 红:`tests/bigqmt_signal_trader/test_quote_subscription_manager.py`
|
||||
- `combo_key` 顺序无关/大小写统一。
|
||||
- 两个 client 订阅同组合 → 只建一次大 QMT 订阅(mock ContextInfo),refcount=2。
|
||||
- 一个 client 退 → 不退大 QMT;全部退 → 退一次大 QMT。
|
||||
- 心跳超时:构造 `last_seen` 过期 → reaper 移除 client;combo 空 → 退订。
|
||||
- 绿:`src/bigqmt_signal_trader/quote_subscription_manager.py`(`combo_key` + `QuoteSubscriptionManager`,ContextInfo 用注入的 mock)。
|
||||
- 回归:全套测试。
|
||||
|
||||
**阶段 2 — server RPC handler 接线**
|
||||
- 红:`subscribe_whole_quote` / `unsubscribe_whole_quote` / `quote_keepalive` 三个方法经 `BigQmtRpcHandlers.handle` 可达、白名单放行、参数校验、幂等重放。
|
||||
- 绿:在 `redis_rpc.py` 加 handler 方法 + `allowed_methods`,注入 `QuoteSubscriptionManager`。
|
||||
- 回归。
|
||||
|
||||
**阶段 3 — 推送通道抽象 + zmq/redis 双实现 + 编码**
|
||||
- 红:`tests/bigqmt_signal_trader/test_quote_push_channel.py`
|
||||
- `QuotePushChannel` 接口(`publish(topic, data)` / `subscribe(topic, on_msg)`)。
|
||||
- zmq PUB/SUB 回环:bind PUB → SUB connect → publish → SUB 收到且 topic 过滤正确。
|
||||
- redis pub/sub 回环:fake redis 下 publish → subscribe 收到。
|
||||
- 编码:msgpack 可用时 payload 用 msgpack(解出结构与原始一致),未装时退化 json。
|
||||
- 绿:`src/bigqmt_signal_trader/quote_push_channel.py`(抽象 + zmq 实现 + redis 实现 + msgpack/json 编码选择)。
|
||||
- 回归。
|
||||
|
||||
**阶段 4 — server on_push → 推送通道接线**
|
||||
- 红:mock ContextInfo 触发 `on_push(data)` → `QuotePushChannel.publish` 被以正确 topic+data 调用;线程安全(并发回调不竞态)。
|
||||
- 绿:`QuoteSubscriptionManager` 接 `QuotePushChannel`。
|
||||
- 回归。
|
||||
|
||||
**阶段 5 — client 端重写(订阅 + SUB 接收 + keepalive 线程)**
|
||||
- 红:`tests/bigqmt_signal_trader/test_whole_quote_client.py`
|
||||
- `subscribe_whole_quote` 发正确 RPC、注册本地 callback、启动 keepalive。
|
||||
- 收到推送帧 → 触发对应 callback。
|
||||
- `unsubscribe_quote` 发 RPC、停心跳。
|
||||
- 重放:模拟断连后 → 对所有活跃 sub_id 重发 subscribe。
|
||||
- 绿:重写 `BigQmtXtData.subscribe_whole_quote` / `unsubscribe_quote`,新增 client 侧 SUB 接收与 keepalive 线程、`client_id` 管理。
|
||||
- 回归。
|
||||
|
||||
**阶段 6 — 端到端 + 多 client 共享 + server 重启恢复**
|
||||
- 红:端到端测试(in-proc fake server + 两 client)
|
||||
- 两 client 订同组合 → 各自 callback 都收到推送;server 只对大 QMT 建一次订阅。
|
||||
- 一 client 退 → 另一个仍收;全退 → server 退订大 QMT。
|
||||
- server "重启"(重建 manager + 推送通道)→ client 重放 → 恢复推送。
|
||||
- client 静默超 30s → server 清引用 → 退订。
|
||||
- 绿:补齐集成胶水(server 启动时装配 manager+push channel,client 重连逻辑)。
|
||||
- 回归:全套 + 现有 `test_all_apis.py` 端到端不破坏。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与开放问题
|
||||
|
||||
1. **大 QMT 订阅/退订 API(已调研确认,风险解除)**:签名、返回值(`int` 订阅号,`<0` 失败)、退订方法 `unsubscribe_quote(subId)` 均已确认(见 §4.3 调研结论)。`QuoteSourceAdapter` 仍保留,作为唯一接触 ContextInfo 的层,便于真实环境联调时微调。
|
||||
2. **行情回调线程模型**:大 QMT `on_push` 在独立的推送线程触发(非 `handlebar` 线程),zmq send 必须跨线程安全(队列 + 专属发送线程);官方亦建议回调内不阻塞、扔队列。阶段 4 专门覆盖。
|
||||
3. **增量推送语义**:大 QMT 全推回调是**增量**(只推变化品种),不是全量快照。client 端必须用 `get_full_tick` 打底 + 增量更新(§4.2),不能假设订阅后即得全量。这点与早期假设不同,已在 §4.2/§4.4 修正。
|
||||
4. **全市场推送量级**:`["SH","SZ"]` 全推增量仍可能每帧数千条。已按 Q7 采用 **msgpack** 编码(可选依赖,未装退化 json)压低开销;PUB 广播吞吐仍需在真实环境实测,若仍不足再评估压缩/分片(不过早优化)。
|
||||
5. **msgpack 依赖**:新增可选依赖 `msgpack`(`optional-dependencies`,对齐 redis/mysql 的组织方式),最小安装(仅 pyzmq)不受影响;未装时推送通道退化 json 编码。
|
||||
6. **与 full_tick_cache 关系**:二者独立。full_tick_cache 服务 `get_full_tick` 按需拉取;本方案服务 `subscribe_whole_quote` 推送。不冲突,不合并。
|
||||
|
||||
---
|
||||
|
||||
## 8. 交付物清单(已全部交付)
|
||||
|
||||
- 新增:`src/bigqmt_signal_trader/quote_subscription_manager.py`(`combo_key`、`QuoteSubscriptionManager`、`QuoteSourceAdapter`/`ContextInfoQuoteSource`、`build_quote_subscription_service`)
|
||||
- 新增:`src/bigqmt_signal_trader/quote_push_channel.py`(抽象 + zmq/redis 实现 + msgpack/json 编码)
|
||||
- 新增:`src/bigqmt_signal_trader/whole_quote_session.py`(client 端订阅会话:订阅表 + 推送路由 + 心跳线程 + 重放)
|
||||
- 修改:`src/bigqmt_signal_trader/redis_rpc.py`(`QUOTE_SUBSCRIPTION_METHODS` + 3 个 handler + 白名单 + `quote_subscription_manager` 注入)
|
||||
- 修改:`src/bigqmt_signal_trader/xtquant_compat.py`(`subscribe_whole_quote`/`unsubscribe_quote` 重写 + session 懒建 + client_id 持久化 + push channel 选择 + `get_full_tick` 打底)
|
||||
- 修改:`src/bigqmt_signal_trader_strategy.py`(server 启动装配 `_build_quote_subscription_service` + publisher 启动 + reaper 挂 RPC drain)
|
||||
- 修改:`pyproject.toml`(`optional-dependencies` 增加 `msgpack`)
|
||||
- 新增测试:`test_quote_subscription_manager.py` / `test_quote_push_channel.py` / `test_quote_on_push_wiring.py` / `test_whole_quote_client.py` / `test_xtdata_whole_quote.py` / `test_quote_subscription_service.py` / `test_whole_quote_e2e.py`
|
||||
- 文档:本文档 + `RPC_API_REFERENCE.md` §2.5 增补 3 个新方法 + `bigqmt_signal_trader_client_config.example.py` 增补 client 配置
|
||||
|
||||
**实现期间 TDD 抓到的两个真实 bug**:
|
||||
1. **`_sub_index` 键冲突**:两 client 各自 `sub_id` 从 1 开始,server 以单 `sub_id` 为键互相覆盖 → 引用计数错乱、"全退才退订"失效。e2e 测试暴露后改为 `(client_id, sub_id)` 复合键。这是多 client 场景的核心正确性问题,单 client 测试无法覆盖。
|
||||
2. **`load_client_config` 漏 `quote_client_id` 键**:导致 client_id 配置读不到、静默退回持久化文件路径。
|
||||
|
||||
**未实现**:server 订阅表落盘兜底(§4.6,经决策不做,靠 client 重放恢复)。
|
||||
|
||||
**待真实环境联调**(不阻塞交付,均已在 §7 标注):`ContextInfo.subscribe_whole_quote` 实际句柄/退订微调、`["SH","SZ"]` 全推吞吐实测、zmq/redis 推送通道在真实部署的连通性。
|
||||
@@ -0,0 +1,222 @@
|
||||
# MiniQMT 无损替换兼容层
|
||||
|
||||
更新时间:2026-07-01
|
||||
|
||||
## 目标
|
||||
|
||||
把原来依赖 MiniQMT 的调用:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
```
|
||||
|
||||
替换为“大 QMT 策略进程 + Redis RPC”的远程调用,同时尽量保持业务代码继续使用:
|
||||
|
||||
```python
|
||||
xt_trader.query_stock_positions(acc)
|
||||
xt_trader.query_stock_asset(acc)
|
||||
xt_trader.query_stock_orders(acc)
|
||||
xt_trader.query_stock_trades(acc)
|
||||
xt_trader.order_stock(...)
|
||||
xt_trader.order_stock_async(...)
|
||||
xt_trader.cancel_order_stock_sysid(...)
|
||||
xtdata.get_full_tick(...)
|
||||
```
|
||||
|
||||
## 接入方式一:显式导入新包
|
||||
|
||||
适合先灰度,不影响机器上的真实 `xtquant` 包。
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import (
|
||||
StockAccount,
|
||||
configure,
|
||||
xt_trader,
|
||||
xtdata,
|
||||
)
|
||||
from bigqmt_signal_trader import xtquant_compat as xtconstant
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
ticks = xtdata.get_full_tick(["600000.SH"])
|
||||
```
|
||||
|
||||
这类写法的优点是替换范围小,适合先在 `core/trader.py` 或独立测试脚本里验证查询链路。`configure()` 会原地更新已导入的 `xt_trader` / `xtdata` 对象,所以可以先 `from ... import xt_trader`,再调用 `configure()`。
|
||||
|
||||
## 接入方式二:用 `xtquant` shim 替换老 import
|
||||
|
||||
适合最终切换。把本仓库的 `src` 放到 `PYTHONPATH` 最前面后,老代码里的:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
```
|
||||
|
||||
会命中本仓库提供的 `src/xtquant/` shim。这样主业务代码基本不用改,只需要在本地私有配置文件里设置 Redis 和账号:
|
||||
|
||||
```python
|
||||
# D:\gjzqqmt\xtquant_big_convert\src\bigqmt_signal_trader_client_config.py
|
||||
BIGQMT_ACCOUNT_ID = "YOUR_ACCOUNT_ID"
|
||||
BIGQMT_RPC_TIMEOUT_SECONDS = 6.0
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "******",
|
||||
}
|
||||
|
||||
BIGQMT_FULL_TICK_CACHE_CONFIG = {
|
||||
"enabled": False,
|
||||
"demand_ttl_seconds": 10,
|
||||
"cache_ttl_seconds": 10,
|
||||
"wait_seconds": 3.5,
|
||||
}
|
||||
```
|
||||
|
||||
然后启动前只需要确认本仓库的 `src` 在 `PYTHONPATH` 最前面:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "D:\gjzqqmt\xtquant_big_convert\src;$env:PYTHONPATH"
|
||||
```
|
||||
|
||||
如果同一台机器仍然安装了真实 MiniQMT 的 `xtquant` 包,要确认 `D:\gjzqqmt\xtquant_big_convert\src` 位于 `PYTHONPATH` 最前面,否则 Python 会先加载真实 `xtquant`。
|
||||
|
||||
## 推荐落地步骤
|
||||
|
||||
1. 大 QMT 侧先运行 `BIGQMT_REDIS_DRYRUN` / `bigqmt_signal_trader_redis_rpc_runtime.py`,保持 `rpc_allow_order_methods=False`。
|
||||
2. 原策略侧用显式导入方式跑查询自检:资产、持仓、单票五档行情、`["SH","SZ"]` 全市场行情。
|
||||
3. 查询链路稳定后,把原项目中 `core/trader.py` 的初始化切到兼容层,但仍保持远程下单关闭。
|
||||
4. 对比 MiniQMT 与大 QMT 返回的资产、持仓、委托、成交字段,确认业务字段都能读到。
|
||||
5. 只在确认风控、账号、委托价型都正确后,在大 QMT 私有配置里打开 `rpc_allow_order_methods=True`。
|
||||
6. 最终切换时再使用 `xtquant` shim,让旧 import 保持不变。
|
||||
|
||||
## 当前已兼容的方法
|
||||
|
||||
| MiniQMT 调用 | 兼容状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `XtQuantTrader(path, session_id)` | 已兼容 | 构造本地 RPC 客户端,不连接 MiniQMT |
|
||||
| `register_callback()` | 已兼容 | 保存 callback;RPC 暂不推送回调 |
|
||||
| `start()` / `connect()` / `subscribe()` | 已兼容 | 返回 `0`,`subscribe()` 会补账号 |
|
||||
| `query_stock_asset(acc)` | 已兼容 | 返回对象含 `cash`、`available_cash`、`total_asset`、`market_value` |
|
||||
| `query_stock_positions(acc)` | 已兼容 | 返回对象列表,含 `stock_code`、`volume`、`can_use_volume`、`avg_price`、`price` |
|
||||
| `query_stock_position(acc, code)` | 已兼容 | 返回单只持仓对象或 `None` |
|
||||
| `query_stock_orders(acc, cancelable_only=False)` | 已兼容 | 返回对象列表,含 `order_type`、`order_status`、`order_volume`、`traded_volume`、`order_sysid` |
|
||||
| `query_stock_trades(acc)` | 已兼容 | 返回对象列表,含 `order_type`、`traded_volume`、`traded_price` |
|
||||
| `order_stock()` / `order_stock_async()` | 已兼容 | 需要大 QMT 本地配置打开 `rpc_allow_order_methods=True` |
|
||||
| `cancel_order_stock_sysid()` | 已兼容 | 需要大 QMT 本地配置打开 `rpc_allow_order_methods=True` |
|
||||
| `xtdata.get_full_tick(codes)` | 已兼容 | 默认直接 RPC 调用;支持单票、ETF、`["SH", "SZ"]` 全市场;可选打开 Redis 快照缓存 |
|
||||
| `xtdata.get_instrument_detail(code)` | 已兼容 | 映射到大 QMT `get_instrumentdetail()` |
|
||||
| `xtdata.get_instrument_type(code)` | 已接入 | 优先调大 QMT;不支持时按代码前缀做基础判断 |
|
||||
| `xtdata.subscribe_quote(...)` / `subscribe_whole_quote(...)` | Redis 订阅兼容 | 写入 `bigqmt:quote_subscriptions:{account_id}`,并向 `bigqmt:quote_events:{account_id}` 发事件;callback 会收到一次当前快照/历史数据 |
|
||||
| `xtdata.unsubscribe_quote(seq)` | Redis 事件兼容 | 不强依赖大 QMT 反订阅 API,直接删除 Redis 订阅表并推送 `unsubscribe_quote` 事件 |
|
||||
| `xtdata.get_market_data(...)` | 已接入 RPC | 透传到大 QMT `ContextInfo.get_market_data`,返回 DataFrame/字典结构会自动 JSON 化再还原 |
|
||||
| `xtdata.get_market_data_ex(...)` | 已接入 RPC | 大 QMT 不支持 `get_market_data_ex` 时回退到 `get_market_data` |
|
||||
| `xtdata.get_local_data(...)` | 已接入 RPC | 大 QMT 不支持 `get_local_data` 时回退到 `get_market_data` |
|
||||
| `xtdata.get_stock_list_in_sector(...)` | 已接入 RPC | 优先调大 QMT;失败时对 `"沪深A股"` 用 `get_full_tick(["SH","SZ"])` 过滤 |
|
||||
| `xtdata.get_sector_list()` / `get_sector_info()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_divid_factors(...)` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.download_history_data(...)` / `download_history_data2(...)` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_trading_dates(...)` / `get_holidays()` / `download_holiday_data()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_ipo_info(...)` | 已接入 RPC | 行情侧新股资料;交易侧 `query_ipo_data()` 仍是占位 |
|
||||
| `xtdata.get_etf_info()` / `download_etf_info()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_option_list(...)` / 历史期权列表 | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_financial_data(...)` / `download_financial_data(...)` | 已接入 RPC | 支持 DataFrame 返回值序列化 |
|
||||
| `xtdata.call_formula(...)` / `subscribe_formula(...)` / `unsubscribe_formula(...)` / `get_formula_result(...)` | 已接入 RPC | 对应截图里的模型调用/订阅能力,依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.gen_factor_index(...)` | 已接入 RPC | 对应生成因子数据,依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `query_ipo_data()` / `query_new_purchase_limit()` | 占位兼容 | 当前返回空结果,打新需要后续补大 QMT 等价能力 |
|
||||
|
||||
## 下单开关
|
||||
|
||||
大 QMT 本地配置默认关闭远程下单。要真正替换 MiniQMT 下单,需要在 QMT 本地私有配置中显式开启:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "******",
|
||||
"rpc_allow_order_methods": True,
|
||||
}
|
||||
```
|
||||
|
||||
开启后,`price_type` 会从客户端透传到大 QMT `passorder()`,不会再固定成默认限价。
|
||||
|
||||
## 最小自检脚本
|
||||
|
||||
这个脚本只读,不会下单:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
|
||||
asset = xt_trader.query_stock_asset(acc)
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
tick = xtdata.get_full_tick(["600000.SH"])
|
||||
all_a = xtdata.get_stock_list_in_sector("沪深A股")
|
||||
|
||||
print("cash:", asset.cash)
|
||||
print("total_asset:", asset.total_asset)
|
||||
print("positions:", len(positions), positions[:3])
|
||||
print("bid5:", tick["600000.SH"]["bidPrice"])
|
||||
print("ask5:", tick["600000.SH"]["askPrice"])
|
||||
print("hs_a_count:", len(all_a))
|
||||
```
|
||||
|
||||
如果想验证最终 shim 方式:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
|
||||
trader = XtQuantTrader("", 12345)
|
||||
acc = StockAccount(trader.client.account_id, "STOCK")
|
||||
|
||||
assert trader.connect() == 0
|
||||
assert trader.subscribe(acc) == 0
|
||||
|
||||
print(xtconstant.STOCK_BUY)
|
||||
print(trader.query_stock_asset(acc))
|
||||
print(xtdata.get_full_tick(["600000.SH"]))
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
```powershell
|
||||
cd D:\gjzqqmt\xtquant_big_convert
|
||||
python -B -m unittest discover -s tests\bigqmt_signal_trader
|
||||
```
|
||||
|
||||
实盘前建议先只跑查询链路:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id)
|
||||
print(xt_trader.query_stock_asset(acc))
|
||||
print(xt_trader.query_stock_positions(acc)[:3])
|
||||
print(xtdata.get_full_tick(["600000.SH"]))
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `subscribe_quote()` / `subscribe_whole_quote()` 当前通过 Redis 记录订阅意图,并给 callback 推一次当前数据;持续行情推送需要独立的 Redis 行情生产者消费 `bigqmt:quote_subscriptions:{account_id}`。
|
||||
- `get_full_tick()` 默认直接 RPC 现拉;如果全市场 payload 过大,再在客户端和 QMT 本地配置里打开 Redis 快照缓存。
|
||||
- `unsubscribe_quote(seq)` 当前按你的要求直接写 Redis:删除订阅表并推送 `unsubscribe_quote` 事件,不等待大 QMT 确认。
|
||||
- `get_stock_list_in_sector("沪深A股")` 的本地兜底会通过 `get_full_tick(["SH", "SZ"])` 过滤 A 股,速度取决于大 QMT 全市场快照返回耗时。
|
||||
- 历史行情、财务、ETF、期权、模型/因子等接口已经接到 RPC,但实际是否可用取决于大 QMT 策略环境里的 `ContextInfo` 是否暴露同名方法。
|
||||
- `query_ipo_data()` / `query_new_purchase_limit()` 当前返回空结果,打新逻辑不能直接视为无损替换。
|
||||
- RPC 下单默认关闭;打开前必须确认大 QMT 页面正在运行正确账号的 RPC 策略。
|
||||
@@ -0,0 +1,142 @@
|
||||
# QMT 原生 ZMQ 回测桥接
|
||||
|
||||
## 1. 目标和边界
|
||||
|
||||
正式模式是在 **QMT 回测进程内部**运行一个 ZMQ 服务,把 QMT 当前回测 Bar、
|
||||
账户、持仓、委托和成交桥接给外部策略。QMT 是唯一的行情推进器、回测引擎、
|
||||
账户系统和撮合器。
|
||||
|
||||
`BIGQMT_ZMQ_BACKTEST.py` 与现有实盘 RPC 入口完全分离:
|
||||
|
||||
- 不导入或修改 `bigqmt_signal_trader`;
|
||||
- 使用独立端口 `tcp://127.0.0.1:16662`;
|
||||
- 只允许 `ContextInfo.do_back_test=true` 的 QMT 回测上下文;
|
||||
- 协议固定返回 `live_ready=false`;
|
||||
- ZMQ 后台线程只接收请求和排队,不直接调用 QMT API;
|
||||
- `passorder`、撤单和账户查询只在 QMT `handlebar` 回调线程执行。
|
||||
|
||||
项目仍保留端口 `16661` 的 CSV 独立回测工具,用于脱离 QMT 的协议测试。该工具
|
||||
使用本地 `BacktestEngine/SimulatedBroker`,不是 QMT 原生回测服务,二者不能混用。
|
||||
|
||||
## 2. 运行时序
|
||||
|
||||
1. QMT 加载 `BIGQMT_ZMQ_BACKTEST.py` 并调用 `init(ContextInfo)`。
|
||||
2. 入口确认当前是 QMT 回测模式,绑定 QMT 注入的 `passorder`、`cancel` 和
|
||||
`get_trade_detail_data`,然后启动 ZMQ 服务。
|
||||
3. QMT 调用 `handlebar` 时,服务发布当前 Bar,并等待外部策略完成这一 Bar 的决策。
|
||||
4. 外部策略调用 `submit_order` 或 `cancel_order`;ZMQ 线程只把命令放入当前 Bar 队列。
|
||||
5. 外部策略调用 `next_bar` 后,QMT 回调线程排空命令并调用 QMT API,然后把控制权
|
||||
交还 QMT。QMT 自己撮合并推进下一根 Bar。
|
||||
6. QMT 的 `order_callback`、`deal_callback` 以及账户查询结果会进入 ZMQ 状态;
|
||||
QMT 调用 `stop/after_backtest` 后,下一次 `next_bar` 返回 `done=true`。
|
||||
|
||||
外部策略超时不释放当前 Bar 时,桥接会抛出超时错误并停止继续下单,避免 QMT
|
||||
静默跑完整段历史而外部策略没有参与。
|
||||
|
||||
## 3. QMT 端安装与配置
|
||||
|
||||
同步以下内容到正在运行的 QMT `python` 目录:
|
||||
|
||||
```text
|
||||
src/bigqmt_backtest/
|
||||
src/BIGQMT_ZMQ_BACKTEST.py
|
||||
```
|
||||
|
||||
编辑 `BIGQMT_ZMQ_BACKTEST.py` 顶部配置:
|
||||
|
||||
```python
|
||||
BACKTEST_ZMQ_CONFIG = {
|
||||
"bind_endpoint": "tcp://127.0.0.1:16662",
|
||||
"run_id": "", # 空值会按启动时间生成
|
||||
"account_id": "你的QMT回测账号",
|
||||
"account_type": "STOCK",
|
||||
"strategy_name": "ZMQ_BACKTEST",
|
||||
"combo_type": 1101,
|
||||
"quick_trade": 2,
|
||||
"market_price_type": 5,
|
||||
"limit_price_type": 11,
|
||||
"bar_wait_timeout_seconds": 60,
|
||||
"require_qmt_backtest": True,
|
||||
}
|
||||
```
|
||||
|
||||
入口是 GBK/ASCII;`bigqmt_backtest` 包使用 UTF-8。在 QMT 中创建回测任务并且只加载
|
||||
`BIGQMT_ZMQ_BACKTEST.py`,不要使用“启动本地 Python”。启动日志会打印实际
|
||||
`run_id`、端口和账号。
|
||||
|
||||
`account_id` 必须填写,服务会调用 `ContextInfo.set_account(account_id)`,外部订单最终
|
||||
通过以下 QMT 原生接口提交:
|
||||
|
||||
```text
|
||||
passorder(23/24, 1101, account_id, symbol, price_type, price, quantity,
|
||||
strategy_name, quick_trade, client_order_id, ContextInfo)
|
||||
```
|
||||
|
||||
撮合价格、成交时间、手续费、资金和持仓均以 QMT 回测结果为准,桥接不再计算第二套
|
||||
结果。
|
||||
|
||||
## 4. 外部策略启动
|
||||
|
||||
安装客户端包:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
启动 QMT 回测后,在外部 Python 运行:
|
||||
|
||||
```powershell
|
||||
python examples/zmq_backtest_strategy.py `
|
||||
--endpoint tcp://127.0.0.1:16662 `
|
||||
--symbol 600000.SH `
|
||||
--fast 5 `
|
||||
--slow 20
|
||||
```
|
||||
|
||||
没有传 `--run-id` 时,客户端先调用 `describe` 发现 QMT 本次运行的 `run_id`。同一运行
|
||||
只允许首个调用 `start` 的 `client_id` 控制。
|
||||
|
||||
## 5. 协议
|
||||
|
||||
ZMQ 使用 `REQ/REP`,请求包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "唯一幂等键",
|
||||
"run_id": "qmt-native-20260719-120000",
|
||||
"client_id": "strategy-a",
|
||||
"method": "start",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | QMT 原生模式含义 |
|
||||
|---|---|
|
||||
| `ping` / `describe` | 探活、发现 `run_id` 和确认 `engine_owner=QMT` |
|
||||
| `start` | 外部策略挂接并等待 QMT 第一根 Bar;不启动第二个引擎 |
|
||||
| `submit_order` | 把订单意图排入当前 Bar,返回 `QUEUED` |
|
||||
| `cancel_order` | 把撤单意图排入当前 Bar |
|
||||
| `next_bar` | 释放当前 Bar;QMT 线程执行命令并等待 QMT 下一根 Bar |
|
||||
| `state` | 返回 QMT 缓存的资金、持仓和当前 Bar |
|
||||
| `history` | 返回 QMT 已经发布给外部策略的历史 Bar,不泄露未来数据 |
|
||||
| `orders` / `fills` | 返回 QMT 委托/成交查询及回调归一化结果 |
|
||||
| `finish` | 外部策略解除挂接;QMT 回测报告仍由 QMT 生成 |
|
||||
|
||||
响应包含 `execution_backend=QMT_NATIVE`、`execution_mode=QMT_BACKTEST` 和
|
||||
`live_ready=false`。相同 `request_id` 的重试返回缓存响应。
|
||||
|
||||
## 6. CSV 独立模式
|
||||
|
||||
仅当不启动 QMT、需要验证协议或外部策略逻辑时使用:
|
||||
|
||||
```powershell
|
||||
python -m bigqmt_backtest.server `
|
||||
--data examples/backtest_bars.example.csv `
|
||||
--config examples/backtest_config.example.json `
|
||||
--run-id demo-001 `
|
||||
--bind tcp://127.0.0.1:16661
|
||||
```
|
||||
|
||||
该模式响应 `execution_backend=LOCAL_SIM`,本地产出 `result.json`、委托、成交、资金
|
||||
曲线等证据。它不会调用 QMT,也不能代表 QMT 原生撮合结果。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
datetime,symbol,open,high,low,close,volume,prev_close
|
||||
2026-01-05 09:30:00,600000.SH,10.00,10.05,9.98,10.02,100000,9.95
|
||||
2026-01-05 09:31:00,600000.SH,10.02,10.08,10.01,10.07,120000,9.95
|
||||
2026-01-05 09:32:00,600000.SH,10.07,10.12,10.06,10.11,110000,9.95
|
||||
2026-01-05 09:33:00,600000.SH,10.11,10.13,10.05,10.06,130000,9.95
|
||||
2026-01-06 09:30:00,600000.SH,10.08,10.10,10.00,10.02,150000,10.06
|
||||
2026-01-06 09:31:00,600000.SH,10.02,10.04,9.96,9.98,140000,10.06
|
||||
|
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"initial_cash": 1000000,
|
||||
"initial_positions": {},
|
||||
"buy_commission_rate": 0.0003,
|
||||
"sell_commission_rate": 0.0003,
|
||||
"min_commission": 5,
|
||||
"stamp_tax_rate": 0.0005,
|
||||
"transfer_fee_rate": 0.00001,
|
||||
"slippage_bps": 0,
|
||||
"max_volume_participation": 0.1,
|
||||
"price_limit_rate": 0.1,
|
||||
"lot_size": 100,
|
||||
"time_in_force": "NEXT_BAR",
|
||||
"seed": 0,
|
||||
"fee_schedule": "a_share_2023_08_28",
|
||||
"market_rules_version": "a_share_v1",
|
||||
"strategy_name": "ma_example",
|
||||
"parameters": {
|
||||
"fast": 2,
|
||||
"slow": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Example external moving-average strategy for the ZMQ backtest bridge."""
|
||||
|
||||
import argparse
|
||||
|
||||
from bigqmt_backtest.client import BacktestZmqClient
|
||||
from bigqmt_backtest.strategy import ExternalStrategyRunner
|
||||
|
||||
|
||||
class MovingAverageStrategy(object):
|
||||
def __init__(self, symbol, fast=5, slow=20):
|
||||
self.symbol = symbol
|
||||
self.fast = int(fast)
|
||||
self.slow = int(slow)
|
||||
self.sequence = 0
|
||||
|
||||
def on_bar(self, context, bars):
|
||||
if self.symbol not in bars:
|
||||
return []
|
||||
rows = context.history(self.symbol, count=self.slow, fields=["close"])
|
||||
if len(rows) < self.slow:
|
||||
return []
|
||||
closes = [float(row["close"]) for row in rows]
|
||||
fast_value = sum(closes[-self.fast :]) / self.fast
|
||||
slow_value = sum(closes) / self.slow
|
||||
position = context.positions.get(self.symbol, {})
|
||||
quantity = int(position.get("quantity") or 0)
|
||||
available = int(position.get("available") or 0)
|
||||
self.sequence += 1
|
||||
if fast_value > slow_value and quantity == 0:
|
||||
return [
|
||||
{
|
||||
"client_order_id": "ma-buy-%d" % self.sequence,
|
||||
"symbol": self.symbol,
|
||||
"side": "BUY",
|
||||
"quantity": 100,
|
||||
"order_type": "MARKET",
|
||||
}
|
||||
]
|
||||
if fast_value < slow_value and available > 0:
|
||||
return [
|
||||
{
|
||||
"client_order_id": "ma-sell-%d" % self.sequence,
|
||||
"symbol": self.symbol,
|
||||
"side": "SELL",
|
||||
"quantity": available,
|
||||
"order_type": "MARKET",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--endpoint", default="tcp://127.0.0.1:16661")
|
||||
parser.add_argument("--run-id", default="", help="Optional; discovered from QMT when omitted")
|
||||
parser.add_argument("--symbol", required=True)
|
||||
parser.add_argument("--fast", type=int, default=5)
|
||||
parser.add_argument("--slow", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
with BacktestZmqClient(args.endpoint, args.run_id, client_id="ma-example") as client:
|
||||
result = ExternalStrategyRunner(
|
||||
client,
|
||||
MovingAverageStrategy(args.symbol, fast=args.fast, slow=args.slow),
|
||||
).run()
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,160 @@
|
||||
# coding: utf-8
|
||||
"""Live API smoke test + latency bench (read-only, safe for live account).
|
||||
|
||||
Covers every read method grouped by category. Reports per-call status and
|
||||
latency, plus a category summary. Does NOT call any order/cancel method.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, r"D:\gjzqqmt\xtquant_big_convert\src")
|
||||
sys.path.insert(0, r"D:\国金证券QMT交易端_lemo\python")
|
||||
|
||||
import bigqmt_signal_trader.xtquant_compat as compat
|
||||
|
||||
compat.configure()
|
||||
client = compat.get_default_client()
|
||||
ACCOUNT = client.account_id
|
||||
print("account:", ACCOUNT, "| transport:", client.transport_name)
|
||||
print("=" * 78)
|
||||
|
||||
# (category, method, params)
|
||||
GROUPS = [
|
||||
("系统", [
|
||||
("ping", {}),
|
||||
]),
|
||||
("行情快照", [
|
||||
("get_full_tick", {"codes": ["000001.SZ"]}),
|
||||
("get_ticks", {"codes": ["000001.SZ", "600000.SH"]}),
|
||||
]),
|
||||
("合约/品种", [
|
||||
("get_instrument", {"code": "000001.SZ"}),
|
||||
("get_instrument_type", {"code": "000001.SZ"}),
|
||||
("get_stock_name", {"stock": "000001.SZ"}),
|
||||
("get_last_close", {"stock": "000001.SZ"}),
|
||||
("get_float_caps", {"stockcode": "000001.SZ"}),
|
||||
("get_total_share", {"stockcode": "000001.SZ"}),
|
||||
("get_contract_multiplier", {"stockcode": "000001.SZ"}),
|
||||
]),
|
||||
("K线/历史", [
|
||||
("get_market_data_ex", {"field_list": ["close"], "stock_list": ["000001.SZ"], "period": "1d", "count": 5}),
|
||||
("get_market_data", {"field_list": ["close"], "stock_list": ["000001.SZ"], "period": "1d", "count": 5}),
|
||||
("get_local_data", {"field_list": ["close"], "stock_list": ["000001.SZ"], "period": "1d", "count": 5}),
|
||||
("get_divid_factors", {"stock_code": "000001.SZ", "end_time": "20250101"}),
|
||||
]),
|
||||
("板块", [
|
||||
("get_sector_list", {}),
|
||||
("get_stock_list_in_sector", {"sector_name": "沪深A股"}),
|
||||
("get_sector_info", {"sector_name": "沪深A股"}),
|
||||
]),
|
||||
("交易日历/时间", [
|
||||
("get_trading_dates", {"market": "SH", "count": 5}),
|
||||
("get_holidays", {}),
|
||||
("get_markets", {}),
|
||||
("get_market_last_trade_date", {"market": "SH"}),
|
||||
("get_trading_calendar", {"market": "SH", "start_time": "20250601", "end_time": "20250615"}),
|
||||
("get_date_location", {"date": "20250701"}),
|
||||
("datetime_to_timetag", {"datetime_str": "20250701150000", "format": "%Y%m%d%H%M%S"}),
|
||||
("timetag_to_datetime", {"timetag": 1751353200000, "format": "%Y%m%d %H:%M:%S"}),
|
||||
]),
|
||||
("财务/因子", [
|
||||
("get_financial_data", {"stock_list": ["000001.SZ"], "table_list": ["CAPITAL"], "start_time": "20240101", "end_time": "20241231"}),
|
||||
]),
|
||||
("ETF/期权/期货", [
|
||||
("get_etf_info", {}),
|
||||
("get_main_contract", {"code_market": "IF"}),
|
||||
("get_his_contract_list", {"market": "IF"}),
|
||||
]),
|
||||
("期权定价", [
|
||||
("bsm_price", {"opt_type": "C", "target_price": 3.0, "strike_price": 2.8, "risk_free": 0.03, "sigma": 0.3, "days": 30}),
|
||||
("bsm_iv", {"opt_type": "C", "target_price": 3.0, "strike_price": 2.8, "option_price": 0.25, "risk_free": 0.03, "days": 30}),
|
||||
]),
|
||||
("龙虎榜/资金流", [
|
||||
("get_longhubang", {"stock_list": ["000001.SZ"], "start_time": "20250101", "end_time": "20250630"}),
|
||||
("get_turnover_rate", {"stock_code": ["000001.SZ"], "start_time": "20250601", "end_time": "20250630"}),
|
||||
("get_industry", {"industry_name": "银行"}),
|
||||
("get_north_finance_change", {"period": "1d"}),
|
||||
]),
|
||||
("账户查询", [
|
||||
("get_asset", {}),
|
||||
("get_positions", {}),
|
||||
("query_stock_position", {"stock_code": "000001.SZ"}),
|
||||
("query_orders", {}),
|
||||
("query_trades", {}),
|
||||
]),
|
||||
("官方交易函数", [
|
||||
("get_ipo_data", {}),
|
||||
("get_new_purchase_limit", {}),
|
||||
("get_hkt_exchange_rate", {}),
|
||||
("get_value_by_order_id", {"order_id": "1"}),
|
||||
("get_last_order_id", {}),
|
||||
]),
|
||||
("融资融券(普通账户应空)", [
|
||||
("get_assure_contract", {}),
|
||||
("get_unclosed_compacts", {}),
|
||||
("get_debt_contract", {}),
|
||||
("get_enable_short_contract", {}),
|
||||
]),
|
||||
]
|
||||
|
||||
results = [] # (category, method, status, ms, summary)
|
||||
|
||||
|
||||
def summarize(d):
|
||||
if d is None:
|
||||
return "None"
|
||||
if isinstance(d, dict):
|
||||
if not d:
|
||||
return "{}"
|
||||
if "__bigqmt_type__" in d:
|
||||
return "[%s cols=%d rec=%d]" % (d.get("__bigqmt_type__"), len(d.get("columns") or []), len(d.get("records") or []))
|
||||
k = list(d.keys())[:2]
|
||||
return "{%s...}(%d)" % (k, len(d))
|
||||
if isinstance(d, list):
|
||||
return "[len=%d]" % len(d)
|
||||
return repr(d)[:40]
|
||||
|
||||
|
||||
for category, methods in GROUPS:
|
||||
print("\n--- %s ---" % category)
|
||||
for method, params in methods:
|
||||
t0 = time.time()
|
||||
try:
|
||||
data = client.call(method, params)
|
||||
ms = (time.time() - t0) * 1000
|
||||
status = "OK"
|
||||
results.append((category, method, status, ms, summarize(data)))
|
||||
except Exception as e:
|
||||
ms = (time.time() - t0) * 1000
|
||||
status = "FAIL"
|
||||
results.append((category, method, status, ms, str(e)[:40]))
|
||||
r = results[-1]
|
||||
print(" [%-4s %6.1fms] %-28s %s" % (r[2], r[3], r[1], r[4]))
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 78)
|
||||
print("=== 汇总 ===")
|
||||
ok = [r for r in results if r[2] == "OK"]
|
||||
fail = [r for r in results if r[2] == "FAIL"]
|
||||
print("通过 %d / 失败 %d / 总计 %d" % (len(ok), len(fail), len(results)))
|
||||
|
||||
print("\n=== 按类别 ===")
|
||||
cats = {}
|
||||
for r in results:
|
||||
cats.setdefault(r[0], []).append(r)
|
||||
for cat, items in cats.items():
|
||||
o = sum(1 for i in items if i[2] == "OK")
|
||||
avg = sum(i[3] for i in items) / len(items)
|
||||
print(" %-22s %d/%d avg=%.1fms" % (cat, o, len(items), avg))
|
||||
|
||||
print("\n=== 延迟分布 (OK) ===")
|
||||
lat = sorted(i[3] for i in ok)
|
||||
if lat:
|
||||
p50 = lat[len(lat) // 2]
|
||||
p90 = lat[int(len(lat) * 0.9)]
|
||||
print(" n=%d min=%.1fms p50=%.1fms p90=%.1fms max=%.1fms" % (len(lat), lat[0], p50, p90, lat[-1]))
|
||||
|
||||
if fail:
|
||||
print("\n=== 失败明细 ===")
|
||||
for r in fail:
|
||||
print(" %-28s %s" % (r[1], r[4]))
|
||||
@@ -0,0 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "xtquant-big-convert"
|
||||
version = "0.2.2"
|
||||
description = "Big QMT RPC bridge and MiniQMT-compatible adapter layer (redis/zmq/mysql transports)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "litaolemo"},
|
||||
]
|
||||
keywords = ["qmt", "quant", "trading", "rpc", "redis", "zmq", "bigqmt", "miniqmt"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
]
|
||||
dependencies = [
|
||||
"pyzmq>=25.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
redis = ["redis>=5.0.0"]
|
||||
mysql = ["pymysql>=1.0.0", "DBUtils>=3.0.0"]
|
||||
# Faster/smaller wire encoding for whole-quote push (falls back to json if absent).
|
||||
msgpack = ["msgpack>=1.0.0"]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/litaolemo/xtquant_big_convert"
|
||||
Repository = "https://github.com/litaolemo/xtquant_big_convert.git"
|
||||
Issues = "https://github.com/litaolemo/xtquant_big_convert/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
py-modules = [
|
||||
"BIGQMT_REDIS_DRYRUN",
|
||||
"BIGQMT_ZMQ_BACKTEST",
|
||||
"bigqmt_signal_trader_strategy",
|
||||
"bigqmt_signal_trader_redis_rpc_runtime",
|
||||
"bigqmt_signal_trader_redis_dryrun",
|
||||
"bigqmt_signal_trader_dryrun",
|
||||
"bigqmt_signal_trader_diagnostic",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["bigqmt_signal_trader*", "bigqmt_backtest*", "xtquant*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
bigqmt_signal_trader = ["*.md"]
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
name: qmt-trader
|
||||
description: "通过统一 CLI 脚本驱动大 QMT 迅投量化交易端的全部能力,含实时行情查询、K线历史数据、账户资产与持仓查询、委托与成交查询、买入卖出下单、撤单、板块龙虎榜北向资金财务数据等,并内置 xtquant_big_convert 桥接服务的安装部署引导(装包/同步 QMT 端文件/配置/启动验证/排错)。适用于大模型辅助量化交易分析、行情研判、持仓监控、半自动下单等场景。当用户需要查看股票行情、分析K线、查询持仓资产、查看今日委托成交、下单买卖、撤单、查询北向资金龙虎榜财务数据,或需要安装部署 QMT RPC 桥接服务时触发此 skill。"
|
||||
---
|
||||
|
||||
# QMT Trader — 大模型驱动的 QMT 交易/行情工具
|
||||
|
||||
## 概述
|
||||
|
||||
本 skill 提供一个确定性 CLI 脚本 `scripts/qmt.py`,让大模型通过命令行调用大 QMT 的全部
|
||||
交易与行情能力,避免每次现场写 Python 代码。所有命令默认输出 JSON(便于解析),加 `--table`
|
||||
切换人类可读表格。
|
||||
|
||||
**前置条件**:本 skill 依赖 xtquant_big_convert 桥接服务已部署运行。若 `ping` 失败或用户尚未部署,
|
||||
先按下文「首次部署」引导完成:装包 → 同步 QMT 端文件 → 写配置 → QMT 里运行入口 → 验证。
|
||||
|
||||
## 首次部署(只需一次,AI 逐步引导用户完成)
|
||||
|
||||
部署分两端:**客户端**(跑本 skill/策略的开发机)和**服务端**(大 QMT 客户端内置 Python)。
|
||||
|
||||
### 第 1 步:客户端安装包
|
||||
|
||||
```bash
|
||||
pip install "xtquant-big-convert[redis]" # redis 传输(默认,推荐)
|
||||
# 或 zmq 同机低延迟:pip install xtquant-big-convert(基础版已含 pyzmq)
|
||||
```
|
||||
|
||||
> 没发布到 PyPI 的私有 fork 用源码安装:`git clone <repo> && cd xtquant_big_convert && pip install -e .[redis]`
|
||||
|
||||
### 第 2 步:把服务端文件同步到 QMT 的 python 目录
|
||||
|
||||
需要拷 4 项到大 QMT 的 `python` 目录(如 `D:\国金证券QMT交易端\python\`):
|
||||
|
||||
```
|
||||
bigqmt_signal_trader/ (整个包,pip 装的在 site-packages 里)
|
||||
bigqmt_signal_trader_strategy.py
|
||||
bigqmt_signal_trader_redis_rpc_runtime.py
|
||||
BIGQMT_REDIS_DRYRUN.py (★ QMT 编辑器入口,GBK 编码)
|
||||
```
|
||||
|
||||
pip 安装后的文件位置可以用这条命令定位(输出目录里就有全部 4 项):
|
||||
|
||||
```bash
|
||||
python -c "import bigqmt_signal_trader_strategy as m, os; print(os.path.dirname(m.__file__))"
|
||||
```
|
||||
|
||||
> QMT 沙箱若拒绝 `import redis`(部分券商白名单拦截),改用仓库里的 `bigqmt_no_redis/` 无 redis 版本(自包含 ZMQ 传输)。
|
||||
|
||||
### 第 3 步:创建 QMT 端私有配置
|
||||
|
||||
在 QMT 的 `python` 目录创建 `bigqmt_signal_trader_local_config.py`(含账号密码,**不要提交 git**):
|
||||
|
||||
```python
|
||||
# coding: utf-8
|
||||
BIGQMT_ACCOUNT_ID = "资金账号"
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "Redis地址", "port": 6379, "db": 5, "password": "Redis密码",
|
||||
"rpc_allow_order_methods": False, # 下单开关,默认关闭;确认风控后改 True
|
||||
"rpc_process_in_listener": True,
|
||||
"rpc_listener_methods": ("*",),
|
||||
"rpc_background_threads": False, # 若切 zmq/mysql 传输必须改 True
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "500nMilliSecond",
|
||||
}
|
||||
```
|
||||
|
||||
> 切 zmq:配置里加 `"transport": "zmq"` 并把 `rpc_background_threads` 改 `True`(QMT 端需装 pyzmq 19.0.2,Python 3.6 最后支持的版本)。
|
||||
|
||||
### 第 4 步:在 QMT 策略编辑器运行入口
|
||||
|
||||
QMT 策略编辑器里**只加载运行 `BIGQMT_REDIS_DRYRUN.py` 一个文件**(它自动 import 其余模块)。
|
||||
若 QMT 装在非默认路径且用 exec 方式加载,需改文件里 `_known_qmt_python_dir()` 的 fallback 路径。
|
||||
|
||||
启动成功标志(QMT 输出面板):
|
||||
|
||||
```
|
||||
[bigqmt_shell] local redis config loaded keys=[...]
|
||||
[bigqmt_shell] local account config loaded=True
|
||||
[bigqmt_rpc] started channel=bigqmt:rpc:req:你的账号
|
||||
[bigqmt_signal_trader] init ok
|
||||
```
|
||||
|
||||
### 第 5 步:客户端配置 + 验证
|
||||
|
||||
客户端用环境变量(或 `bigqmt_signal_trader_client_config.py`)指向同一套 Redis/账号:
|
||||
|
||||
```powershell
|
||||
$env:BIGQMT_ACCOUNT_ID="资金账号"
|
||||
$env:BIGQMT_REDIS_HOST="Redis地址"; $env:BIGQMT_REDIS_PORT="6379"
|
||||
$env:BIGQMT_REDIS_DB="5"; $env:BIGQMT_REDIS_PASSWORD="Redis密码"
|
||||
```
|
||||
|
||||
然后验证(redis ~13ms / zmq ~0.7ms 为正常):
|
||||
|
||||
```bash
|
||||
python scripts/qmt.py ping
|
||||
```
|
||||
|
||||
### 部署排错速查
|
||||
|
||||
| 现象 | 排查 |
|
||||
|------|------|
|
||||
| `ping` 超时 | 客户端/服务端 transport 不一致(一边 redis 一边 zmq);QMT 端服务没启动;Redis 地址/密码/db 不一致 |
|
||||
| QMT 面板报 `import redis` 被拒 | 换 `bigqmt_no_redis/` 无 redis 版本 |
|
||||
| 启动了但查询全空 | 账号没对上:服务端 `BIGQMT_ACCOUNT_ID` vs 客户端 `BIGQMT_ACCOUNT_ID`;QMT 需在实盘模式 |
|
||||
| 下单报 `ORDER_DISABLED` | 正常保护,服务端配置 `rpc_allow_order_methods` 改 `True` 才放行 |
|
||||
| 详细错误日志 | QMT python 目录下 `logs/bigqmt_*.log`(保留 7 天),排错首选 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 第 0 步:确认连通性
|
||||
|
||||
```bash
|
||||
python scripts/qmt.py ping
|
||||
```
|
||||
|
||||
返回 `ok: true` 且 `latency_ms` 合理(redis ~13ms / zmq ~0.7ms)即表示服务端就绪。
|
||||
|
||||
### 第 1 步:一键快照(资产+持仓+委托+成交)
|
||||
|
||||
```bash
|
||||
python scripts/qmt.py snapshot
|
||||
```
|
||||
|
||||
一次 RPC 往返返回账户全景,适合快速了解当前状态。
|
||||
|
||||
## 命令速查
|
||||
|
||||
### 行情分析
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `tick <codes...>` | 实时五档盘口 | `tick 600000.SH 000001.SZ` |
|
||||
| `kline <code>` | K线/历史行情 | `kline 600000.SH --period 1d --count 60 --dividend front` |
|
||||
| `instrument <code>` | 合约详情 | `instrument 600000.SH` |
|
||||
| `sector [name]` | 板块成分股/板块列表 | `sector "沪深A股"` |
|
||||
| `trading-dates` | 交易日历 | `trading-dates --count 10` |
|
||||
| `north` | 北向资金 | `north --period 1d` |
|
||||
| `longhubang <code>` | 龙虎榜 | `longhubang 600000.SH --count 5` |
|
||||
| `financial <codes...>` | 财务数据 | `financial 000001.SZ --tables Capital.CAPITAL` |
|
||||
| `download <codes...>` | 下载历史数据 | `download 600654.SH --period 1d --dividend front` |
|
||||
| `quote-subscribe <codes...>` | 实时全推订阅 | `quote-subscribe SH SZ --max 10` |
|
||||
|
||||
### 账户/持仓/委托
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `account` | 账户资产 | `account` |
|
||||
| `positions [code]` | 持仓列表 | `positions` / `positions 600000.SH` |
|
||||
| `orders` | 今日委托 | `orders --cancelable` |
|
||||
| `trades` | 今日成交 | `trades` |
|
||||
| `snapshot` | 一键全景 | `snapshot` |
|
||||
|
||||
### 下单/撤单
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `buy <code> <volume>` | 买入 | `buy 600000.SH 100 --price 7.50` |
|
||||
| `sell <code> <volume>` | 卖出 | `sell 600000.SH 100 --price 7.50` |
|
||||
| `cancel <order_id>` | 撤单 | `cancel 12345 --market SH` |
|
||||
|
||||
> 下单命令支持 `--dry-run`(只打印不下单)、`--latest`(最新价)、`--strategy`、`--remark`。
|
||||
|
||||
### 扩展查询(高频)
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `holiday` | 节假日列表 | `holiday` |
|
||||
| `stock-name <code>` | 股票名称 | `stock-name 600000.SH` |
|
||||
| `instrument-type <code>` | 品种类型 | `instrument-type 600000.SH` |
|
||||
| `divid-factors <code>` | 除权除息因子 | `divid-factors 600000.SH` |
|
||||
| `market-times [market]` | 日内交易时段 | `market-times SH` |
|
||||
| `trading-calendar [market]` | 交易日历(含时段) | `trading-calendar SH` |
|
||||
| `option-list <code>` | 期权列表 | `option-list 510050.SH` |
|
||||
| `bsm-price ...` | BSM 期权定价 | `bsm-price C 3.0 2.8 0.03 0.3 30` |
|
||||
| `bsm-iv ...` | BSM 隐含波动率 | `bsm-iv C 3.0 2.8 0.25 0.03 30` |
|
||||
| `hkt-stats <code>` | 港股通统计 | `hkt-stats 600000.SH` |
|
||||
| `hkt-details <code>` | 港股通明细 | `hkt-details 600000.SH` |
|
||||
| `hkt-rate` | 港股通汇率 | `hkt-rate` |
|
||||
| `top10-holder <code>` | 十大股东 | `top10-holder 600000.SH` |
|
||||
| `holder-num <code>` | 股东户数 | `holder-num 600000.SH` |
|
||||
| `ipo` / `ipo-limit` | 新股数据/申购额度 | `ipo` |
|
||||
| `credit-assure` | 融资担保品合约 | `credit-assure` |
|
||||
| `credit-short` | 融券标的合约 | `credit-short` |
|
||||
| `credit-debt` | 负债合约 | `credit-debt` |
|
||||
| `his-st <code>` | 历史 ST 数据 | `his-st 600000.SH` |
|
||||
| `index-weight <index>` | 指数权重 | `index-weight 000300.SH` |
|
||||
| `industry <name>` | 行业成分 | `industry 银行` |
|
||||
| `sector-info [name]` | 板块详情 | `sector-info 沪深A股` |
|
||||
| `local-data <code>` | 本地缓存数据 | `local-data 600000.SH` |
|
||||
| `timetag2dt <ms>` | 毫秒时间戳转日期 | `timetag2dt 1751353200000` |
|
||||
| `dt2timetag <dt>` | 日期转毫秒时间戳 | `dt2timetag 20250701150000` |
|
||||
|
||||
### 通用 RPC(兜底所有方法)
|
||||
|
||||
`rpc <method> [json_params]` 可调用**任意白名单方法**(含未列出的,如 `get_l2_quote` / `call_formula` / `get_raw_financial_data` 等):
|
||||
|
||||
```bash
|
||||
python scripts/qmt.py rpc get_holidays
|
||||
python scripts/qmt.py rpc get_stock_name '{"stock":"600000.SH"}'
|
||||
python scripts/qmt.py rpc get_l2_quote '{"stock_code":"600000.SH","count":5}'
|
||||
python scripts/qmt.py rpc call_formula '{"formula_name":"MA","stock_code":"600000.SH","period":"1d"}'
|
||||
```
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:行情分析
|
||||
|
||||
分析某只股票的技术面:
|
||||
|
||||
```bash
|
||||
# 1. 看实时盘口
|
||||
python scripts/qmt.py tick 600000.SH
|
||||
|
||||
# 2. 拉最近 60 根日 K(前复权),输出含 MA5/MA20/MA60 统计
|
||||
python scripts/qmt.py kline 600000.SH --period 1d --count 60 --dividend front
|
||||
|
||||
# 3. 看合约详情(名称、上市日、最小变动价位等)
|
||||
python scripts/qmt.py instrument 600000.SH
|
||||
|
||||
# 4. 看近期龙虎榜
|
||||
python scripts/qmt.py longhubang 600000.SH --count 5
|
||||
```
|
||||
|
||||
### 场景二:持仓监控
|
||||
|
||||
```bash
|
||||
# 一键看全景
|
||||
python scripts/qmt.py snapshot
|
||||
|
||||
# 只看持仓(含浮动盈亏)
|
||||
python scripts/qmt.py positions
|
||||
|
||||
# 看可撤委托
|
||||
python scripts/qmt.py orders --cancelable
|
||||
```
|
||||
|
||||
### 场景三:下单交易
|
||||
|
||||
```bash
|
||||
# 0. 先看当前价
|
||||
python scripts/qmt.py tick 600000.SH
|
||||
|
||||
# 1. 干跑确认参数
|
||||
python scripts/qmt.py buy 600000.SH 100 --price 7.50 --dry-run
|
||||
|
||||
# 2. 真实下单(限价 7.50 买 100 股)
|
||||
python scripts/qmt.py buy 600000.SH 100 --price 7.50 --strategy my_strat
|
||||
|
||||
# 3. 确认委托进了系统
|
||||
python scripts/qmt.py orders
|
||||
|
||||
# 4. 需要时撤单
|
||||
python scripts/qmt.py cancel <order_sysid> --market SH
|
||||
```
|
||||
|
||||
### 场景四:批量行情分析
|
||||
|
||||
```bash
|
||||
# 同时看多只股票的盘口
|
||||
python scripts/qmt.py tick 600000.SH 000001.SZ 600519.SH
|
||||
|
||||
# 看板块成分股
|
||||
python scripts/qmt.py sector "沪深A股"
|
||||
|
||||
# 看北向资金流向
|
||||
python scripts/qmt.py north
|
||||
```
|
||||
|
||||
## 安全须知
|
||||
|
||||
1. **下单默认关闭**:服务端 `rpc_allow_order_methods` 默认 `False`。必须由人工在服务端配置中
|
||||
显式开启后才能下单,否则 `buy`/`sell`/`cancel` 会报 `ORDER_DISABLED` 错误。
|
||||
|
||||
2. **下单前先看价**:始终先用 `tick` 确认当前价格,避免下出明显不合理的委托。
|
||||
|
||||
3. **超时防重复**:如果 `buy`/`sell` 报 `ORDER_TIMEOUT`,委托可能已提交。**先用 `orders` 查询确认**,
|
||||
不要直接重试,避免重复下单。
|
||||
|
||||
4. **strategy_name 一致性**:下单时的 `--strategy` 和查询时的 `--strategy` 必须一致。
|
||||
查全部委托用 `orders --strategy ""`(空字符串=不过滤)。
|
||||
|
||||
5. **实盘模式**:QMT 必须运行在实盘模式(非模拟/模型交易)才能收到完整回报。
|
||||
|
||||
## 脚本说明
|
||||
|
||||
### scripts/qmt.py
|
||||
|
||||
统一 CLI 入口,包含以下子命令:
|
||||
|
||||
**基础查询**:
|
||||
- `ping` — 连通性检测(含延迟测量)
|
||||
- `account` — 查询账户资产(现金/冻结/总资产/市值)
|
||||
- `positions [code]` — 查询持仓(含浮动盈亏计算)
|
||||
- `orders [--cancelable] [--strategy ""]` — 查询今日委托(含语义化状态名)
|
||||
- `trades [--strategy ""]` — 查询今日成交
|
||||
- `snapshot` — 一键全景(资产+持仓+委托+成交)
|
||||
|
||||
**行情**:
|
||||
- `tick <codes...>` — 实时五档盘口(含涨跌幅计算)
|
||||
- `kline <code> [--period 1d] [--count N] [--dividend front]` — K线(含 MA5/20/60 统计)
|
||||
- `instrument <code>` — 合约详情
|
||||
- `sector [name]` — 板块成分股/板块列表
|
||||
- `trading-dates [--count N]` — 交易日历
|
||||
- `north [--period 1d]` — 北向资金
|
||||
- `longhubang <code> [--count N]` — 龙虎榜
|
||||
- `financial <codes...> [--tables T1,T2]` — 财务数据
|
||||
- `download <codes...>` — 下载历史数据到服务端
|
||||
- `quote-subscribe <codes...> [--max N] [--timeout S]` — 实时全推行情订阅
|
||||
|
||||
**扩展查询**:
|
||||
- `holiday` — 节假日列表
|
||||
- `stock-name <code>` — 股票名称
|
||||
- `instrument-type <code>` — 品种类型
|
||||
- `divid-factors <code>` — 除权除息因子
|
||||
- `market-times [market]` — 日内交易时段
|
||||
- `trading-calendar [market]` — 交易日历(含时段)
|
||||
- `option-list <code>` — 期权列表
|
||||
- `bsm-price` / `bsm-iv` — BSM 期权定价/隐含波动率
|
||||
- `hkt-stats` / `hkt-details` / `hkt-rate` — 港股通统计/明细/汇率
|
||||
- `top10-holder <code>` / `holder-num <code>` — 十大股东/股东户数
|
||||
- `ipo` / `ipo-limit` — 新股数据/申购额度
|
||||
- `credit-assure` / `credit-short` / `credit-debt` — 融资融券查询
|
||||
- `his-st <code>` — 历史 ST 数据
|
||||
- `index-weight <index>` — 指数权重
|
||||
- `industry <name>` — 行业成分
|
||||
- `sector-info [name]` — 板块详情
|
||||
- `local-data <code>` — 本地缓存数据
|
||||
- `timetag2dt` / `dt2timetag` — 时间戳转换
|
||||
|
||||
**交易**:
|
||||
- `buy <code> <volume> [--price P] [--latest]` — 买入下单
|
||||
- `sell <code> <volume> [--price P] [--latest]` — 卖出下单
|
||||
- `cancel <order_id> [--market SH]` — 撤单
|
||||
|
||||
**通用兜底**:
|
||||
- `rpc <method> [json_params]` — 调用任意白名单方法(未列出的方法都能这样调)
|
||||
|
||||
**配置自动发现**:脚本会自动把仓库 `src/` 加入 `sys.path`(开发模式直接运行,无需 pip install),并自动发现 QMT 的 python 目录(读 `local_config.py` 里的 transport 配置)。配置从环境变量(`BIGQMT_ACCOUNT_ID`/`BIGQMT_REDIS_HOST` 等)或配置文件读取。
|
||||
|
||||
**输出格式**:默认 JSON(`ok`/`data`/`ts` 三字段),加 `--table` 切换表格输出。错误返回 `ok: false` + `error`/`detail`/`code`,退出码 1。
|
||||
|
||||
## 参考
|
||||
|
||||
详细的 API 参数、返回值结构、常量定义和已知陷阱见 `references/api_reference.md`。
|
||||
当命令速查不够用时(如需要直接 RPC 调用、查看信用交易类型、了解回调系统等),查阅该文件。
|
||||
@@ -0,0 +1,523 @@
|
||||
# QMT API 参考手册
|
||||
|
||||
本文档是 `qmt-trader` skill 的完整 API 参考。当 SKILL.md 的速查不够用时,查阅本文件获取
|
||||
参数细节、返回值结构和已知陷阱。
|
||||
|
||||
---
|
||||
|
||||
## 1. 初始化与配置
|
||||
|
||||
### 配置来源(优先级从高到低)
|
||||
|
||||
1. **环境变量**
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `BIGQMT_ACCOUNT_ID` | — | 资金账号 |
|
||||
| `BIGQMT_REDIS_HOST` | `127.0.0.1` | Redis 地址 |
|
||||
| `BIGQMT_REDIS_PORT` | `6379` | Redis 端口 |
|
||||
| `BIGQMT_REDIS_DB` | `5` | Redis DB |
|
||||
| `BIGQMT_REDIS_PASSWORD` | — | Redis 密码 |
|
||||
| `BIGQMT_RPC_TRANSPORT` | `redis` | 传输方式 redis/zmq |
|
||||
| `BIGQMT_RPC_TIMEOUT_SECONDS` | `6.0` | RPC 超时 |
|
||||
|
||||
2. **配置文件** `bigqmt_signal_trader_client_config.py`(在 PYTHONPATH 中,gitignored)
|
||||
|
||||
3. **备选配置文件** `bigqmt_signal_trader_local_config.py`
|
||||
|
||||
### Python 初始化
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure() # 从配置/环境变量初始化
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 行情数据 API
|
||||
|
||||
### 2.1 get_full_tick — 实时五档盘口
|
||||
|
||||
```python
|
||||
xtdata.get_full_tick(code_list)
|
||||
```
|
||||
|
||||
- **参数**: `code_list: list[str]`,如 `["000001.SZ", "600000.SH"]`;也支持整市场 `["SH"]`, `["SZ"]`
|
||||
- **返回**: `dict[code -> dict]`,每只含 `lastPrice`/`open`/`high`/`low`/`lastClose`/`volume`/`amount`/
|
||||
`bidPrice`(10档)/`askPrice`(10档)/`bidVol`/`askVol`/`time`/`stime`
|
||||
- **CLI**: `python qmt.py tick 600000.SH 000001.SZ`
|
||||
- **注意**: 整市场快照数据量大(5000+ 股),超时自动设 30 秒
|
||||
|
||||
### 2.2 get_market_data_ex — K线/历史行情
|
||||
|
||||
```python
|
||||
xtdata.get_market_data_ex(
|
||||
field_list=None, # ["close","open","high","low","volume","amount"] 或 None=全部
|
||||
stock_list=None, # ["000001.SZ"]
|
||||
period="1d", # "1d"/"1m"/"5m"/"15m"/"30m"/"60m"/"tick"
|
||||
start_time="", # "YYYYMMDD" 或 "YYYYMMDDHHMMSS"
|
||||
end_time="",
|
||||
count=-1, # -1=不限
|
||||
dividend_type="none", # "none"/"front"(前复权)/"back"(后复权)
|
||||
fill_data=True, # 是否填充缺失
|
||||
)
|
||||
```
|
||||
|
||||
- **返回**: `dict[code -> pandas.DataFrame]`,index 是时间戳字符串,列含 `time`(epoch ms)/`open`/`high`/`low`/`close`/`volume`/`amount`
|
||||
- **CLI**: `python qmt.py kline 600000.SH --period 1d --count 60 --dividend front`
|
||||
- **自愈**: 请求复权但服务端缺原始数据时(返回全 0),自动触发下载+重试
|
||||
- **陷阱**: 前/后复权必须先在服务端下载原始数据,否则返回全 0(已自愈但仍可能首次慢)
|
||||
|
||||
### 2.3 get_instrument_detail — 合约详情
|
||||
|
||||
```python
|
||||
xtdata.get_instrument_detail(stock_code) # 别名 get_instrumentdetail
|
||||
```
|
||||
|
||||
- **返回**: `dict`,含名称/上市日/合约乘数/最小变动价位等约 30 字段
|
||||
- **CLI**: `python qmt.py instrument 600000.SH`
|
||||
|
||||
### 2.4 get_stock_list_in_sector — 板块成分股
|
||||
|
||||
```python
|
||||
xtdata.get_stock_list_in_sector(sector_name) # 如 "沪深A股", "科创板", "创业板"
|
||||
```
|
||||
|
||||
- **返回**: `list[str]` 代码列表
|
||||
- **CLI**: `python qmt.py sector "沪深A股"`
|
||||
|
||||
### 2.5 get_sector_list — 板块列表
|
||||
|
||||
```python
|
||||
xtdata.get_sector_list()
|
||||
```
|
||||
|
||||
- **返回**: `list[str]`
|
||||
- **CLI**: `python qmt.py sector`
|
||||
- **注意**: 大 QMT 环境 fallback 返回 13 个常用板块名(非完整列表)
|
||||
|
||||
### 2.6 get_trading_dates — 交易日历
|
||||
|
||||
```python
|
||||
xtdata.get_trading_dates(market="SH", start_time="", end_time="", count=-1)
|
||||
```
|
||||
|
||||
- **CLI**: `python qmt.py trading-dates --count 10`
|
||||
|
||||
### 2.7 get_north_finance_change — 北向资金
|
||||
|
||||
```python
|
||||
xtdata.get_north_finance_change(period="1d")
|
||||
```
|
||||
|
||||
- **CLI**: `python qmt.py north`
|
||||
|
||||
### 2.8 get_longhubang — 龙虎榜
|
||||
|
||||
```python
|
||||
xtdata.get_longhubang(stock_list=["600000.SH"], start_time="", end_time="", count=5)
|
||||
```
|
||||
|
||||
- **返回**: `pandas.DataFrame`
|
||||
- **CLI**: `python qmt.py longhubang 600000.SH --count 5`
|
||||
|
||||
### 2.9 get_financial_data — 财务数据
|
||||
|
||||
```python
|
||||
xtdata.get_financial_data(
|
||||
stock_list=["000001.SZ"],
|
||||
table_list=["Capital.CAPITAL"], # 表名
|
||||
start_time="", end_time="",
|
||||
)
|
||||
```
|
||||
|
||||
- **CLI**: `python qmt.py financial 000001.SZ --tables Capital.CAPITAL`
|
||||
|
||||
### 2.10 download_history_data2 — 下载历史数据
|
||||
|
||||
```python
|
||||
xtdata.download_history_data2(
|
||||
stock_list=["600654.SH"], period="1d",
|
||||
start_time="20240101", dividend_type="front",
|
||||
)
|
||||
```
|
||||
|
||||
- **返回**: `{"finished": N, "total": M}`
|
||||
- **CLI**: `python qmt.py download 600654.SH --period 1d --start 20240101 --dividend front`
|
||||
|
||||
### 2.11 subscribe_whole_quote — 全推行情订阅
|
||||
|
||||
```python
|
||||
sub_id = xtdata.subscribe_whole_quote(["SH","SZ"], callback=on_quote)
|
||||
# ... 运行策略 ...
|
||||
xtdata.unsubscribe_quote(sub_id)
|
||||
```
|
||||
|
||||
- **机制**: 服务端真推送(非轮询),增量推送有变化的品种
|
||||
- **CLI**: `python qmt.py quote-subscribe SH SZ --max 10 --timeout 30`
|
||||
- **心跳**: 客户端 3 秒一次 keepalive,服务端重启后自动恢复
|
||||
|
||||
---
|
||||
|
||||
## 3. 账户/持仓/委托查询 API
|
||||
|
||||
### 3.1 query_stock_asset — 查询资产
|
||||
|
||||
```python
|
||||
asset = xt_trader.query_stock_asset(acc)
|
||||
```
|
||||
|
||||
- **返回属性**: `account_id` / `cash`(可用现金) / `frozen_cash` / `total_asset` / `market_value`
|
||||
- **CLI**: `python qmt.py account`
|
||||
- **容错**: RPC 失败时从 Redis 缓存 `bigqmt:positions:{account_id}` 读取
|
||||
|
||||
### 3.2 query_stock_positions — 查询全部持仓
|
||||
|
||||
```python
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
```
|
||||
|
||||
- **返回属性**: `stock_code` / `stock_name` / `volume`(总持仓) / `can_use_volume`(可用) /
|
||||
`avg_price`(成本) / `price`(最新价) / `market_value` / `frozen_volume` / `yesterday_volume`
|
||||
- **CLI**: `python qmt.py positions [code]`
|
||||
|
||||
### 3.3 query_stock_position — 查询单只持仓
|
||||
|
||||
```python
|
||||
pos = xt_trader.query_stock_position(acc, "600000.SH")
|
||||
```
|
||||
|
||||
- **返回**: 单个对象或 `None`
|
||||
|
||||
### 3.4 query_stock_orders — 查询委托
|
||||
|
||||
```python
|
||||
orders = xt_trader.query_stock_orders(acc, cancelable_only=False, strategy_name="")
|
||||
```
|
||||
|
||||
- **返回属性**: `stock_code` / `order_type`(23=BUY,24=SELL) / `order_status` /
|
||||
`order_volume` / `traded_volume` / `price` / `order_sysid` / `order_remark`
|
||||
- **CLI**: `python qmt.py orders [--cancelable] [--strategy ""]`
|
||||
- **⚠️ strategy_name 陷阱**: 下单时的 strategy_name 必须和查询时一致。服务端默认 `""` 返回全部;
|
||||
客户端 `BigQmtXtTrader` 默认 `"bigqmt_signal_trader"`。用 `""` 查全部最安全。
|
||||
|
||||
### 3.5 query_stock_trades — 查询成交
|
||||
|
||||
```python
|
||||
trades = xt_trader.query_stock_trades(acc, strategy_name="")
|
||||
```
|
||||
|
||||
- **返回属性**: `stock_code` / `order_type` / `traded_volume` / `traded_price` /
|
||||
`traded_at` / `order_sysid` / `trade_id`
|
||||
- **CLI**: `python qmt.py trades`
|
||||
|
||||
### 3.6 委托状态码
|
||||
|
||||
| 值 | 常量 | 含义 |
|
||||
|----|------|------|
|
||||
| 48 | ORDER_UNREPORTED | 未申报 |
|
||||
| 49 | ORDER_WAIT_REPORTING | 等待申报 |
|
||||
| 50 | ORDER_REPORTED | 已申报 |
|
||||
| 51 | ORDER_REPORTED_CANCEL | 已申报撤单 |
|
||||
| 52 | ORDER_PARTSUCC_CANCEL | 部成撤单 |
|
||||
| 53 | ORDER_PART_CANCEL | 部撤 |
|
||||
| 54 | ORDER_CANCELED | 已撤 |
|
||||
| 55 | ORDER_PART_SUCC | 部分成交 |
|
||||
| 56 | ORDER_SUCCEEDED | 全部成交 |
|
||||
| 57 | ORDER_JUNK | 废单 |
|
||||
| 255 | ORDER_UNKNOWN | 未知 |
|
||||
|
||||
可撤状态: 49, 50, 55
|
||||
|
||||
---
|
||||
|
||||
## 4. 下单 API
|
||||
|
||||
### 4.1 order_stock — 同步下单
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import STOCK_BUY, STOCK_SELL, FIX_PRICE, LATEST_PRICE
|
||||
|
||||
order_id = xt_trader.order_stock(
|
||||
acc, # StockAccount
|
||||
stock_code, # "600000.SH"
|
||||
order_type, # STOCK_BUY(23) / STOCK_SELL(24)
|
||||
order_volume, # int,委托数量
|
||||
price_type, # FIX_PRICE(11) / LATEST_PRICE(5)
|
||||
price, # float,限价单价格(最新价时传 0)
|
||||
strategy_name, # str
|
||||
order_remark, # str,user_order_id
|
||||
)
|
||||
```
|
||||
|
||||
- **返回**: `order_sys_id`(字符串) 或 `-1`(失败)
|
||||
- **CLI**: `python qmt.py buy 600000.SH 100 --price 7.50 [--strategy s] [--remark r]`
|
||||
- **CLI**: `python qmt.py sell 600000.SH 100 --price 7.50`
|
||||
- **⚠️ 权限**: 服务端默认 `rpc_allow_order_methods=False`,必须显式开启才能下单
|
||||
- **⚠️ 超时**: 超时后委托可能已提交,先查 `query_orders` 确认,避免重复下单
|
||||
|
||||
### 4.2 order_stock_async — 异步下单
|
||||
|
||||
```python
|
||||
seq = xt_trader.order_stock_async(acc, code, order_type, vol, price_type, price, strategy, remark)
|
||||
```
|
||||
|
||||
- **返回**: seq(结果通过 callback 回调)
|
||||
|
||||
### 4.3 order_stock_batch — 批量下单
|
||||
|
||||
```python
|
||||
results = xt_trader.order_stock_batch(acc, orders, batch_id="")
|
||||
# orders: list[dict],每项含 stock_code/action/volume/price/price_type/strategy_name
|
||||
```
|
||||
|
||||
- **上限**: 500 条/批
|
||||
|
||||
### 4.4 信用交易委托类型
|
||||
|
||||
| 常量 | 值 | 用途 |
|
||||
|------|-----|------|
|
||||
| CREDIT_BUY | 23 | 担保品买入 |
|
||||
| CREDIT_SELL | 24 | 担保品卖出 |
|
||||
| CREDIT_FIN_BUY | 27 | 融资买入 |
|
||||
| CREDIT_SLO_SELL | 28 | 融券卖出 |
|
||||
| CREDIT_BUY_SECU_REPAY | 29 | 买券还券 |
|
||||
| CREDIT_DIRECT_SECU_REPAY | 30 | 直接还券 |
|
||||
| CREDIT_SELL_SECU_REPAY | 31 | 卖券还款 |
|
||||
| CREDIT_DIRECT_CASH_REPAY | 32 | 直接还款 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 撤单 API
|
||||
|
||||
### 5.1 cancel_order_stock_sysid
|
||||
|
||||
```python
|
||||
success = xt_trader.cancel_order_stock_sysid(acc, market, order_sysid)
|
||||
# market: "SH" / "SZ" / ""
|
||||
```
|
||||
|
||||
- **CLI**: `python qmt.py cancel <order_sysid> --market SH`
|
||||
|
||||
### 5.2 cancel_order_stock
|
||||
|
||||
```python
|
||||
success = xt_trader.cancel_order_stock(acc, order_id)
|
||||
# 等价于 cancel_order_stock_sysid(acc, "", order_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 回调系统
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import XtQuantTraderCallback
|
||||
|
||||
class MyCallback(XtQuantTraderCallback):
|
||||
def on_stock_order(self, order): ... # 委托变更
|
||||
def on_stock_trade(self, trade): ... # 成交推送
|
||||
def on_order_error(self, error): ... # 委托错误
|
||||
def on_cancel_error(self, error): ... # 撤单错误
|
||||
def on_order_stock_async_response(self, resp): ...
|
||||
def on_account_status(self, status): ...
|
||||
|
||||
xt_trader.register_callback(MyCallback())
|
||||
xt_trader.start()
|
||||
xt_trader.connect()
|
||||
xt_trader.subscribe(acc)
|
||||
```
|
||||
|
||||
事件推送通过 Redis pubsub 频道:
|
||||
- `bigqmt:exec:order:{account_id}`
|
||||
- `bigqmt:exec:trade:{account_id}`
|
||||
- `bigqmt:exec:order_error:{account_id}`
|
||||
- `bigqmt:exec:cancel_error:{account_id}`
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键陷阱速查
|
||||
|
||||
### 7.1 strategy_name 不匹配
|
||||
- 下单用 `strategy_name="rpc_test"` → 查询用 `strategy_name="bigqmt_signal_trader"` → 返回空
|
||||
- **解决**: 查询时传 `strategy_name=""` 返回全部,或保持一致
|
||||
|
||||
### 7.2 下单静默失败
|
||||
- `passorder` 调用成功但委托没进系统(QMT 风控拒绝但没报错)
|
||||
- **解决**: 服务端下单后等 0.5 秒查 `query_orders` 确认;检查返回的 `server_error` 字段
|
||||
|
||||
### 7.3 复权 K 线返回全 0
|
||||
- 服务端缺原始数据时,前/后复权返回的 close 全是 0.0
|
||||
- **解决**: 先 `download_history_data2` 下载原始数据(客户端有自愈机制)
|
||||
|
||||
### 7.4 Transport 不匹配
|
||||
- 客户端 redis / 服务端 zmq → ping 超时
|
||||
- **解决**: 两端 `transport` 字段保持一致
|
||||
|
||||
### 7.5 QMT 必须运行在实盘模式
|
||||
- 模拟模式下委托进 QMT 界面但不在真实委托队列,`query_orders` 查不到
|
||||
- `order_stock` 返回 -1,触发 `on_order_error`
|
||||
|
||||
### 7.6 整市场快照数据量大
|
||||
- `get_full_tick(["SH"])` 返回 5000+ 股完整盘口
|
||||
- **解决**: 启用 `full_tick_cache` 或增大超时(已自动设 30 秒)
|
||||
|
||||
### 7.7 全推行情是增量的
|
||||
- `subscribe_whole_quote` 的大 QMT 回调只推有变化的品种
|
||||
- **解决**: 订阅成功后客户端自动调一次 `get_full_tick` 打底
|
||||
|
||||
### 7.8 下单超时与重复下单
|
||||
- `order_stock` 超时 → 委托可能已提交但没收到响应
|
||||
- **解决**: 超时后先查 `query_orders`/`query_trades` 确认状态,再决定是否重试
|
||||
|
||||
---
|
||||
|
||||
## 8. 常量速查
|
||||
|
||||
### 交易常量
|
||||
|
||||
| 常量 | 值 | 用途 |
|
||||
|------|-----|------|
|
||||
| STOCK_BUY | 23 | 股票买入 |
|
||||
| STOCK_SELL | 24 | 股票卖出 |
|
||||
| FIX_PRICE | 11 | 限价/指定价 |
|
||||
| LATEST_PRICE | 5 | 最新价 |
|
||||
| MARKET_PEER_PRICE_FIRST | 44 | 对手方最优价 |
|
||||
|
||||
### 账号类型
|
||||
|
||||
| 常量 | 值 |
|
||||
|------|-----|
|
||||
| FUTURE_ACCOUNT | 1 |
|
||||
| SECURITY_ACCOUNT | 2 |
|
||||
| CREDIT_ACCOUNT | 3 |
|
||||
| FUTURE_OPTION_ACCOUNT | 5 |
|
||||
| STOCK_OPTION_ACCOUNT | 6 |
|
||||
|
||||
### 期货委托类型(部分)
|
||||
|
||||
| 常量 | 值 | 用途 |
|
||||
|------|-----|------|
|
||||
| FUTURE_OPEN_LONG | 0 | 开多 |
|
||||
| FUTURE_CLOSE_LONG_TODAY | 2 | 平今多 |
|
||||
| FUTURE_OPEN_SHORT | 3 | 开空 |
|
||||
| FUTURE_CLOSE_SHORT_TODAY | 4 | 平今空 |
|
||||
| FUTURE_CLOSE_LONG_HISTORY | 6 | 平昨多 |
|
||||
| FUTURE_CLOSE_SHORT_HISTORY | 7 | 平昨空 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 直接 RPC 调用(绕过兼容层)
|
||||
|
||||
当兼容层方法不够用时,可直接调 RPC:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
import redis
|
||||
|
||||
r = redis.Redis(host="...", port=6379, db=5, password="...")
|
||||
resp = call_redis_rpc(r, "ACCOUNT_ID", "get_full_tick", {"codes": ["000001.SZ"]})
|
||||
print(resp["data"]["000001.SZ"]["lastPrice"])
|
||||
```
|
||||
|
||||
- **万能入口**: `xtdata.call_method("get_float_caps", stockcode="000001.SZ")`
|
||||
- **方法别名映射**:
|
||||
- `get_full_tick` → `get_ticks`
|
||||
- `get_instrument_detail` → `get_instrument`
|
||||
- `query_stock_asset` → `get_asset`
|
||||
- `query_stock_positions` → `get_positions`
|
||||
- `query_stock_orders` → `query_orders`
|
||||
- `query_stock_trades` → `query_trades`
|
||||
- `order_stock` → `submit_order`
|
||||
- `cancel_order_stock` → `cancel_order`
|
||||
|
||||
### RPC 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {...},
|
||||
"error": "",
|
||||
"server_error": "",
|
||||
"handled_at": "2024-07-01 15:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
- `ok=true`: `data` 为方法返回值(DataFrame 已序列化,客户端自动还原 pandas 对象)
|
||||
- `ok=false`: `error` 为错误信息
|
||||
- `server_error`: 额外诊断(如 passorder 提交但委托未进系统)
|
||||
|
||||
---
|
||||
|
||||
## 10. 可用 RPC 方法白名单(117 个只读 + 3 个下单/撤单)
|
||||
|
||||
### 行情快照
|
||||
`get_ticks`/`get_full_tick`, `get_instrument`/`get_instrument_detail`, `get_instrument_type`,
|
||||
`get_stock_name`, `get_stock_type`, `get_last_close`, `get_last_volume`, `get_float_caps`,
|
||||
`get_total_share`, `get_turn_over_rate`, `get_weight_in_index`, `get_contract_multiplier`,
|
||||
`get_contract_expire_date`, `get_open_date`, `get_svol`, `get_bvol`, `get_risk_free_rate`,
|
||||
`is_stock_type`, `get_cb_info`
|
||||
|
||||
### K线/历史
|
||||
`get_market_data`, `get_market_data_ex`, `get_local_data`, `get_close_price`, `get_index_weight`
|
||||
|
||||
### L2 行情(需 L2 权限)
|
||||
`get_l2_quote`, `get_l2_order`, `get_l2_transaction`, `subscribe_l2thousand`
|
||||
|
||||
### 板块
|
||||
`get_stock_list_in_sector`, `get_sector_list`, `get_sector_info`, `create_sector`, `add_sector`, `remove_sector`
|
||||
|
||||
### 交易日历/时段
|
||||
`get_trading_dates`, `get_holidays`, `get_markets`, `get_market_last_trade_date`,
|
||||
`get_date_location`, `get_trading_calendar`, `get_trade_times`
|
||||
|
||||
### 数据下载
|
||||
`download_history_data`, `download_history_data2`, `download_holiday_data`,
|
||||
`download_etf_info`, `download_cb_data`, `download_history_contracts`,
|
||||
`download_index_weight`, `download_sector_data`
|
||||
|
||||
### 财务/因子
|
||||
`get_financial_data`, `download_financial_data`, `download_financial_data2`,
|
||||
`get_raw_financial_data`, `get_factor_data`
|
||||
|
||||
### ETF/期权/期货
|
||||
`get_etf_info`, `get_ipo_info`, `get_option_list`, `get_his_option_list`,
|
||||
`get_his_option_list_batch`, `get_option_detail_data`, `get_option_undl_data`,
|
||||
`get_option_undl`, `get_ETF_list`, `get_main_contract`, `get_his_contract_list`
|
||||
|
||||
### 期权定价
|
||||
`bsm_price`, `bsm_iv`, `get_option_iv`
|
||||
|
||||
### 龙虎榜/股东
|
||||
`get_longhubang`, `get_top10_share_holder`, `get_holder_num`, `get_turnover_rate`,
|
||||
`get_industry`, `get_his_st_data`, `get_his_index_data`
|
||||
|
||||
### 资金流
|
||||
`get_north_finance_change`, `get_hkt_statistics`, `get_hkt_details`, `get_hkt_exchange_rate`
|
||||
|
||||
### 因子/模型
|
||||
`call_formula`, `subscribe_formula`, `unsubscribe_formula`, `get_formula_result`, `gen_factor_index`
|
||||
|
||||
### 时间转换(纯本地)
|
||||
`datetime_to_timetag`, `timetag_to_datetime`
|
||||
|
||||
### 账户查询
|
||||
`get_asset`, `get_positions`, `query_stock_position`, `query_orders`, `query_trades`,
|
||||
`get_history_trade_detail_data`, `get_value_by_order_id`, `get_last_order_id`
|
||||
|
||||
### 融资融券(需两融权限)
|
||||
`get_assure_contract`, `get_enable_short_contract`, `get_unclosed_compacts`,
|
||||
`get_closed_compacts`, `get_debt_contract`
|
||||
|
||||
### 期权持仓
|
||||
`get_option_subject_position`, `get_comb_option`
|
||||
|
||||
### 持仓同步
|
||||
`sync_positions`
|
||||
|
||||
### 下单/撤单(需开启 rpc_allow_order_methods)
|
||||
`submit_order`/`order_stock`, `submit_orders_batch`/`order_stock_batch`,
|
||||
`cancel_order`/`cancel_order_stock`/`cancel_order_stock_sysid`
|
||||
|
||||
### 全推行情
|
||||
`subscribe_whole_quote`, `unsubscribe_whole_quote`, `quote_keepalive`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
# coding: utf-8
|
||||
"""Run the full test suite from a single entry point.
|
||||
|
||||
Groups tests by area and prints a clear per-group + total report. Optional
|
||||
live/API tests (need a running QMT + redis) are skipped by default.
|
||||
|
||||
Usage:
|
||||
python run_all_tests.py # all offline tests (default)
|
||||
python run_all_tests.py -v # verbose
|
||||
python run_all_tests.py --live # also run live RPC tests (needs QMT running)
|
||||
python run_all_tests.py --group signal_trader # only one group
|
||||
python run_all_tests.py --group backtest
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(ROOT, "src")
|
||||
|
||||
# Test groups: (name, paths, requires_live)
|
||||
GROUPS = [
|
||||
("signal_trader", [os.path.join("tests", "bigqmt_signal_trader")], False),
|
||||
("backtest", [os.path.join("tests", "bigqmt_backtest")], False),
|
||||
]
|
||||
LIVE_GROUP = ("live_api", ["test_all_apis.py"], True)
|
||||
|
||||
|
||||
def run_group(name, paths, verbose):
|
||||
"""Run a pytest group, return (passed, failed, skipped, seconds)."""
|
||||
cmd = [sys.executable, "-m", "pytest"] + paths + ["-q" if not verbose else "-v"]
|
||||
t0 = time.time()
|
||||
proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
|
||||
elapsed = time.time() - t0
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
# Parse pytest summary like "290 passed in 8.5s" / "1 failed, 289 passed, 3 skipped"
|
||||
passed = failed = skipped = 0
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not any(tok in line for tok in ("passed", "failed", "skipped", "error")):
|
||||
continue
|
||||
for part in line.split(","):
|
||||
words = part.strip().split()
|
||||
if len(words) >= 2 and words[0].isdigit():
|
||||
num = int(words[0])
|
||||
if words[1].startswith("passed"):
|
||||
passed += num
|
||||
elif words[1].startswith("failed") or words[1].startswith("error"):
|
||||
failed += num
|
||||
elif words[1].startswith("skipped"):
|
||||
skipped += num
|
||||
return passed, failed, skipped, elapsed, out, proc.returncode
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run all bigqmt tests")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
parser.add_argument("--live", action="store_true", help="also run live RPC tests (needs QMT)")
|
||||
parser.add_argument("--group", help="run only this group (signal_trader/backtest/live_api)")
|
||||
args = parser.parse_args()
|
||||
|
||||
groups = list(GROUPS)
|
||||
if args.live:
|
||||
groups.append(LIVE_GROUP)
|
||||
if args.group:
|
||||
groups = [g for g in groups if g[0] == args.group]
|
||||
if not groups:
|
||||
print("Unknown group: %s (available: %s)" % (args.group, ", ".join(g[0] for g in GROUPS + [LIVE_GROUP])))
|
||||
return 1
|
||||
|
||||
print("=" * 70)
|
||||
print("Big QMT Bridge - 全量测试")
|
||||
print("=" * 70)
|
||||
|
||||
total_passed = total_failed = total_skipped = 0
|
||||
total_time = 0.0
|
||||
failed_groups = []
|
||||
for name, paths, needs_live in groups:
|
||||
print("\n--- %s ---" % name)
|
||||
passed, failed, skipped, elapsed, out, rc = run_group(name, paths, args.verbose)
|
||||
total_passed += passed
|
||||
total_failed += failed
|
||||
total_skipped += skipped
|
||||
total_time += elapsed
|
||||
status = "PASS" if failed == 0 and rc == 0 else "FAIL"
|
||||
print(" %s: %d passed, %d failed, %d skipped (%.1fs)" % (status, passed, failed, skipped, elapsed))
|
||||
if failed or rc != 0:
|
||||
failed_groups.append(name)
|
||||
if not args.verbose:
|
||||
# print the failing part of the output for visibility
|
||||
tail = "\n".join(out.splitlines()[-20:])
|
||||
print(tail)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("=== 汇总 ===")
|
||||
print("通过 %d / 失败 %d / 跳过 %d / 总计 %d" % (total_passed, total_failed, total_skipped, total_passed + total_failed + total_skipped))
|
||||
print("总耗时 %.1fs" % total_time)
|
||||
if failed_groups:
|
||||
print("失败分组: %s" % ", ".join(failed_groups))
|
||||
return 1
|
||||
print("全部通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,254 @@
|
||||
#coding:gbk
|
||||
"""QMT bridge entry using the same file-loader pattern as qmt_realtime strategies.
|
||||
|
||||
Broker QMT strategy sandboxes may reject local package names through their
|
||||
normal ``import`` allowlist. The realtime QMT strategies in gupiao_ztfx load
|
||||
their colocated helpers through ``importlib.util.spec_from_file_location``.
|
||||
This entry applies path-based loading to the bridge package, including its
|
||||
internal relative imports, while leaving all standard-library and QMT imports
|
||||
untouched. This terminal's spec loader ignores custom builtins for nested
|
||||
package imports, so local bridge files are compiled explicitly after resolving
|
||||
their path.
|
||||
"""
|
||||
import builtins as _builtins
|
||||
import importlib as _importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
_LOCAL_ROOTS = (
|
||||
"bigqmt_signal_trader",
|
||||
"bigqmt_signal_trader_strategy",
|
||||
"bigqmt_signal_trader_redis_rpc_runtime",
|
||||
"bigqmt_signal_trader_local_config",
|
||||
)
|
||||
_ORIGINAL_IMPORT = _builtins.__import__
|
||||
_ORIGINAL_IMPORT_MODULE = _importlib.import_module
|
||||
_ORIGINAL_RELOAD = _importlib.reload
|
||||
|
||||
|
||||
def _known_qmt_python_dir():
|
||||
# Find the QMT python dir from sys.path instead of a hardcoded path, so
|
||||
# the bridge loads regardless of broker install location or launch mode
|
||||
# (editor / paste-run / exec). Falls back to empty when not found.
|
||||
for p in sys.path:
|
||||
if p and r"\python" in p and os.path.isdir(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
_SOURCE_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
except Exception:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
if not _SOURCE_ROOT:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
|
||||
|
||||
def _is_local_module(name):
|
||||
return any(name == root or name.startswith(root + ".") for root in _LOCAL_ROOTS)
|
||||
|
||||
|
||||
def _resolve_name(name, module_globals, level):
|
||||
if not level:
|
||||
return name
|
||||
package = (module_globals or {}).get("__package__") or (module_globals or {}).get("__name__", "")
|
||||
if not package:
|
||||
raise ImportError("relative import without package")
|
||||
for unused in range(level - 1):
|
||||
if "." not in package:
|
||||
raise ImportError("relative import beyond top-level package")
|
||||
package = package.rsplit(".", 1)[0]
|
||||
return package + ("." + name if name else "")
|
||||
|
||||
|
||||
def _find_local_source(name):
|
||||
relative = name.replace(".", os.sep)
|
||||
dirs = []
|
||||
if _SOURCE_ROOT:
|
||||
dirs.append(_SOURCE_ROOT)
|
||||
for p in sys.path:
|
||||
if p and os.path.isdir(p) and p not in dirs:
|
||||
dirs.append(p)
|
||||
for d in dirs:
|
||||
package_init = os.path.join(d, relative, "__init__.py")
|
||||
if os.path.isfile(package_init):
|
||||
return package_init, True
|
||||
module_file = os.path.join(d, relative + ".py")
|
||||
if os.path.isfile(module_file):
|
||||
return module_file, False
|
||||
raise ModuleNotFoundError("local source not found: %s" % name, name=name)
|
||||
|
||||
|
||||
def _set_parent_attribute(name, module):
|
||||
if "." not in name:
|
||||
return
|
||||
parent_name, child_name = name.rsplit(".", 1)
|
||||
parent = _load_local_module(parent_name)
|
||||
setattr(parent, child_name, module)
|
||||
|
||||
|
||||
def _load_local_module(name):
|
||||
existing = sys.modules.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
source_path, is_package = _find_local_source(name)
|
||||
if "." in name:
|
||||
_load_local_module(name.rsplit(".", 1)[0])
|
||||
module = types.ModuleType(name)
|
||||
module.__file__ = source_path
|
||||
module.__package__ = name if is_package else name.rpartition(".")[0]
|
||||
if is_package:
|
||||
module.__path__ = [os.path.dirname(source_path)]
|
||||
module_builtins = dict(_builtins.__dict__)
|
||||
module_builtins["__import__"] = _local_import
|
||||
module.__dict__["__builtins__"] = module_builtins
|
||||
module.__dict__["__bigqmt_load_local_module"] = _load_local_module
|
||||
sys.modules[name] = module
|
||||
# QMT native allowlist rejects the root package eager exports.
|
||||
if name == "bigqmt_signal_trader":
|
||||
return module
|
||||
try:
|
||||
with open(source_path, "rb") as source_file:
|
||||
source = source_file.read()
|
||||
exec(compile(source, source_path, "exec"), module.__dict__)
|
||||
except Exception:
|
||||
sys.modules.pop(name, None)
|
||||
raise
|
||||
_set_parent_attribute(name, module)
|
||||
return module
|
||||
|
||||
|
||||
def _local_import(name, module_globals=None, module_locals=None, fromlist=(), level=0):
|
||||
absolute_name = _resolve_name(name, module_globals, level)
|
||||
if not _is_local_module(absolute_name):
|
||||
return _ORIGINAL_IMPORT(name, module_globals, module_locals, fromlist, level)
|
||||
module = _load_local_module(absolute_name)
|
||||
for child in fromlist or ():
|
||||
if child != "*":
|
||||
try:
|
||||
_load_local_module(absolute_name + "." + child)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
if fromlist:
|
||||
return module
|
||||
return _load_local_module(absolute_name.split(".", 1)[0])
|
||||
|
||||
|
||||
def _local_import_module(name, package=None):
|
||||
if _is_local_module(name):
|
||||
return _load_local_module(name)
|
||||
return _ORIGINAL_IMPORT_MODULE(name, package)
|
||||
|
||||
|
||||
def _local_reload(module):
|
||||
if _is_local_module(getattr(module, "__name__", "")):
|
||||
return _load_local_module(module.__name__)
|
||||
return _ORIGINAL_RELOAD(module)
|
||||
|
||||
|
||||
def _clear_local_modules():
|
||||
for name in list(sys.modules):
|
||||
if _is_local_module(name):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def _stop_previous_rpc_service():
|
||||
"""Release the previous QMT strategy's socket before clearing its module.
|
||||
|
||||
QMT can re-execute this entry in the same Python process. The old strategy
|
||||
module owns the RPC service and its ZMQ ROUTER socket, so dropping that
|
||||
module from ``sys.modules`` first would make the service unreachable and
|
||||
leave its port bound for the next strategy start.
|
||||
"""
|
||||
previous = sys.modules.get("bigqmt_signal_trader_strategy")
|
||||
reset = getattr(previous, "reset_app", None)
|
||||
if not callable(reset):
|
||||
return
|
||||
try:
|
||||
reset()
|
||||
print("[bigqmt_shell] previous rpc service stopped")
|
||||
except Exception as exc:
|
||||
# Continue the reload so a broken old instance does not prevent QMT
|
||||
# from reporting its normal startup error.
|
||||
print("[bigqmt_shell] previous rpc service stop failed: %s" % exc)
|
||||
|
||||
|
||||
_stop_previous_rpc_service()
|
||||
_clear_local_modules()
|
||||
_importlib.import_module = _local_import_module
|
||||
_importlib.reload = _local_reload
|
||||
print("[bigqmt_shell] importlib entry source_root=%s" % _SOURCE_ROOT)
|
||||
|
||||
|
||||
def _fallback_account_id():
|
||||
for name in ("BIGQMT_ACCOUNT_ID", "account", "account_id", "accountID"):
|
||||
value = globals().get(name)
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
_local_import("bigqmt_signal_trader.adapters.redis_common", globals(), fromlist=("*",))
|
||||
_local_import("bigqmt_signal_trader.redis_rpc", globals(), fromlist=("*",))
|
||||
_strategy = _local_import("bigqmt_signal_trader_strategy", globals(), fromlist=("*",))
|
||||
_strategy.reset_app()
|
||||
except Exception as bridge_preload_error:
|
||||
print("[bigqmt_shell] bridge preload failed: %s" % bridge_preload_error)
|
||||
|
||||
_runtime = _local_import("bigqmt_signal_trader_redis_rpc_runtime", globals(), fromlist=("*",))
|
||||
|
||||
|
||||
def _load_local_config():
|
||||
return _local_import("bigqmt_signal_trader_local_config", globals(), fromlist=("*",))
|
||||
|
||||
|
||||
try:
|
||||
_config = _load_local_config()
|
||||
BIGQMT_REDIS_CONFIG = getattr(_config, "BIGQMT_REDIS_CONFIG", {})
|
||||
print("[bigqmt_shell] local redis config loaded keys=%s" % sorted((BIGQMT_REDIS_CONFIG or {}).keys()))
|
||||
_runtime.configure_runtime_redis(BIGQMT_REDIS_CONFIG)
|
||||
except Exception as redis_config_error:
|
||||
print("[bigqmt_shell] local redis config load failed: %s" % redis_config_error)
|
||||
|
||||
try:
|
||||
_config = _load_local_config()
|
||||
BIGQMT_ACCOUNT_ID = getattr(_config, "BIGQMT_ACCOUNT_ID", "")
|
||||
print("[bigqmt_shell] local account config loaded=%s" % bool(BIGQMT_ACCOUNT_ID))
|
||||
_runtime.configure_runtime_account(BIGQMT_ACCOUNT_ID)
|
||||
except Exception as account_config_error:
|
||||
print("[bigqmt_shell] local account config load failed: %s" % account_config_error)
|
||||
account_id = _fallback_account_id()
|
||||
if account_id:
|
||||
_runtime.configure_runtime_account(account_id)
|
||||
|
||||
try:
|
||||
qmt_extra = {}
|
||||
for function_name in (
|
||||
"get_history_trade_detail_data", "get_value_by_order_id", "get_last_order_id",
|
||||
"get_ipo_data", "get_new_purchase_limit", "get_assure_contract",
|
||||
"get_enable_short_contract", "get_unclosed_compacts", "get_closed_compacts",
|
||||
"get_debt_contract", "get_option_subject_position", "get_comb_option",
|
||||
"get_hkt_exchange_rate",
|
||||
"download_history_data", "download_history_data2",
|
||||
):
|
||||
if function_name in globals():
|
||||
qmt_extra[function_name] = globals()[function_name]
|
||||
print("[bigqmt_shell] down_history_data bound=%s" % ("down_history_data" in qmt_extra))
|
||||
_runtime.bind_runtime_api(
|
||||
passorder_func=globals().get("passorder"),
|
||||
cancel_func=globals().get("cancel"),
|
||||
get_trade_detail_data_func=globals().get("get_trade_detail_data"),
|
||||
extra_funcs=qmt_extra or None,
|
||||
)
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
|
||||
init = _runtime.init
|
||||
handlebar = _runtime.handlebar
|
||||
adjust = _runtime.adjust
|
||||
order_callback = _runtime.order_callback
|
||||
deal_callback = _runtime.deal_callback
|
||||
@@ -0,0 +1,152 @@
|
||||
#coding:gbk
|
||||
"""Isolated QMT backtest entry for external ZMQ strategies.
|
||||
|
||||
This file is ASCII-only. It loads only the bigqmt_backtest package and never
|
||||
loads or mutates the live bridge package.
|
||||
"""
|
||||
|
||||
import builtins as _builtins
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
BACKTEST_ZMQ_CONFIG = {
|
||||
"bind_endpoint": "tcp://127.0.0.1:16662",
|
||||
"run_id": "",
|
||||
"account_id": "",
|
||||
"account_type": "STOCK",
|
||||
"strategy_name": "ZMQ_BACKTEST",
|
||||
"combo_type": 1101,
|
||||
"quick_trade": 2,
|
||||
"market_price_type": 5,
|
||||
"limit_price_type": 11,
|
||||
"bar_wait_timeout_seconds": 60,
|
||||
"require_qmt_backtest": True,
|
||||
}
|
||||
|
||||
|
||||
_LOCAL_ROOT = "bigqmt_backtest"
|
||||
_ORIGINAL_IMPORT = _builtins.__import__
|
||||
|
||||
|
||||
def _known_qmt_python_dir():
|
||||
for p in sys.path:
|
||||
if p and r"\python" in p and os.path.isdir(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
|
||||
try:
|
||||
_SOURCE_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
except Exception:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
if not _SOURCE_ROOT:
|
||||
_SOURCE_ROOT = _known_qmt_python_dir()
|
||||
|
||||
|
||||
def _is_local(name):
|
||||
return name == _LOCAL_ROOT or name.startswith(_LOCAL_ROOT + ".")
|
||||
|
||||
|
||||
def _resolve_name(name, module_globals, level):
|
||||
if not level:
|
||||
return name
|
||||
package = (module_globals or {}).get("__package__") or ""
|
||||
if not package:
|
||||
raise ImportError("relative import without package")
|
||||
for unused in range(level - 1):
|
||||
package = package.rsplit(".", 1)[0]
|
||||
return package + (("." + name) if name else "")
|
||||
|
||||
|
||||
def _find_source(name):
|
||||
relative = name.replace(".", os.sep)
|
||||
dirs = []
|
||||
if _SOURCE_ROOT:
|
||||
dirs.append(_SOURCE_ROOT)
|
||||
for p in sys.path:
|
||||
if p and os.path.isdir(p) and p not in dirs:
|
||||
dirs.append(p)
|
||||
for d in dirs:
|
||||
package_init = os.path.join(d, relative, "__init__.py")
|
||||
if os.path.isfile(package_init):
|
||||
return package_init, True
|
||||
module_file = os.path.join(d, relative + ".py")
|
||||
if os.path.isfile(module_file):
|
||||
return module_file, False
|
||||
raise ModuleNotFoundError("local source not found: %s" % name, name=name)
|
||||
|
||||
|
||||
def _load_local_module(name):
|
||||
existing = sys.modules.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
source_path, is_package = _find_source(name)
|
||||
if "." in name:
|
||||
_load_local_module(name.rsplit(".", 1)[0])
|
||||
module = types.ModuleType(name)
|
||||
module.__file__ = source_path
|
||||
module.__package__ = name if is_package else name.rpartition(".")[0]
|
||||
if is_package:
|
||||
module.__path__ = [os.path.dirname(source_path)]
|
||||
module_builtins = dict(_builtins.__dict__)
|
||||
module_builtins["__import__"] = _local_import
|
||||
module.__dict__["__builtins__"] = module_builtins
|
||||
sys.modules[name] = module
|
||||
if name == _LOCAL_ROOT:
|
||||
return module
|
||||
try:
|
||||
with open(source_path, "rb") as source_file:
|
||||
source = source_file.read()
|
||||
exec(compile(source, source_path, "exec"), module.__dict__)
|
||||
except Exception:
|
||||
sys.modules.pop(name, None)
|
||||
raise
|
||||
if "." in name:
|
||||
parent_name, child_name = name.rsplit(".", 1)
|
||||
setattr(_load_local_module(parent_name), child_name, module)
|
||||
return module
|
||||
|
||||
|
||||
def _local_import(name, module_globals=None, module_locals=None, fromlist=(), level=0):
|
||||
absolute_name = _resolve_name(name, module_globals, level)
|
||||
if not _is_local(absolute_name):
|
||||
return _ORIGINAL_IMPORT(name, module_globals, module_locals, fromlist, level)
|
||||
module = _load_local_module(absolute_name)
|
||||
for child in fromlist or ():
|
||||
if child != "*":
|
||||
try:
|
||||
_load_local_module(absolute_name + "." + child)
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
if fromlist:
|
||||
return module
|
||||
return _load_local_module(absolute_name.split(".", 1)[0])
|
||||
|
||||
|
||||
for _name in sorted(
|
||||
[name for name in list(sys.modules) if _is_local(name)],
|
||||
key=lambda item: item.count("."),
|
||||
reverse=True,
|
||||
):
|
||||
sys.modules.pop(_name, None)
|
||||
|
||||
|
||||
_runtime = _load_local_module("bigqmt_backtest.qmt_runtime")
|
||||
_runtime.configure(**BACKTEST_ZMQ_CONFIG)
|
||||
_runtime.bind_qmt_api(
|
||||
passorder_func=globals().get("passorder") or getattr(_builtins, "passorder", None),
|
||||
cancel_func=globals().get("cancel") or getattr(_builtins, "cancel", None),
|
||||
get_trade_detail_data_func=(
|
||||
globals().get("get_trade_detail_data")
|
||||
or getattr(_builtins, "get_trade_detail_data", None)
|
||||
),
|
||||
)
|
||||
|
||||
init = _runtime.init
|
||||
handlebar = _runtime.handlebar
|
||||
order_callback = _runtime.order_callback
|
||||
deal_callback = _runtime.deal_callback
|
||||
stop = _runtime.stop
|
||||
after_backtest = _runtime.after_backtest
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Isolated ZMQ bridge for QMT-native and standalone backtests.
|
||||
|
||||
This package deliberately does not import ``bigqmt_signal_trader``. The live
|
||||
bridge and both backtest backends therefore have separate module state,
|
||||
identities, and order gateways. QMT-native mode never uses the local broker.
|
||||
"""
|
||||
|
||||
from .client import BacktestZmqClient
|
||||
from .data_feed import CsvBarFeed, InMemoryBarFeed
|
||||
from .engine import BacktestConfig, BacktestEngine
|
||||
from .protocol import BacktestBridgeProtocol
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BacktestBridgeProtocol",
|
||||
"BacktestConfig",
|
||||
"BacktestEngine",
|
||||
"BacktestZmqClient",
|
||||
"CsvBarFeed",
|
||||
"InMemoryBarFeed",
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .server import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,358 @@
|
||||
"""A-share simulated broker used only by the standalone backtest runtime."""
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import (
|
||||
BacktestFill,
|
||||
BacktestOrder,
|
||||
Position,
|
||||
ZERO,
|
||||
decimal_value,
|
||||
json_number,
|
||||
money,
|
||||
normalize_symbol,
|
||||
round_price,
|
||||
)
|
||||
|
||||
|
||||
ACTIVE_ORDER_STATUSES = ("PENDING", "PARTIALLY_FILLED")
|
||||
|
||||
|
||||
class SimulatedBroker(object):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.cash = money(config.initial_cash)
|
||||
self.positions = {}
|
||||
for symbol, payload in dict(config.initial_positions or {}).items():
|
||||
data = dict(payload or {})
|
||||
position = Position(
|
||||
symbol,
|
||||
quantity=data.get("quantity", data.get("volume", 0)),
|
||||
available=data.get("available"),
|
||||
today_buy=data.get("today_buy", 0),
|
||||
avg_cost=data.get("avg_cost", data.get("cost", 0)),
|
||||
)
|
||||
if position.quantity > 0:
|
||||
self.positions[position.symbol] = position
|
||||
self.orders_list = []
|
||||
self.fills_list = []
|
||||
self._client_order_ids = {}
|
||||
self._client_order_fingerprints = {}
|
||||
self._order_sequence = 0
|
||||
self._fill_sequence = 0
|
||||
self._trading_date = None
|
||||
self.total_fees = ZERO
|
||||
self.turnover = ZERO
|
||||
|
||||
def _new_order(self, payload, frame_index, submitted_at):
|
||||
self._order_sequence += 1
|
||||
return BacktestOrder(
|
||||
order_id="bt-order-%06d" % self._order_sequence,
|
||||
client_order_id=payload.get("client_order_id"),
|
||||
symbol=payload.get("symbol"),
|
||||
side=payload.get("side"),
|
||||
quantity=payload.get("quantity"),
|
||||
order_type=payload.get("order_type", "MARKET"),
|
||||
limit_price=payload.get("limit_price", payload.get("price")),
|
||||
submitted_index=frame_index,
|
||||
submitted_at=submitted_at,
|
||||
time_in_force=payload.get("time_in_force", self.config.time_in_force),
|
||||
)
|
||||
|
||||
def _reject(self, order, reason):
|
||||
order.status = "REJECTED"
|
||||
order.reject_reason = str(reason)
|
||||
return order
|
||||
|
||||
def _reserved_sell(self, symbol):
|
||||
return sum(
|
||||
order.remaining
|
||||
for order in self.orders_list
|
||||
if order.symbol == symbol and order.side == "SELL" and order.status in ACTIVE_ORDER_STATUSES
|
||||
)
|
||||
|
||||
def submit(self, payload, frame_index, submitted_at):
|
||||
payload = dict(payload or {})
|
||||
client_order_id = str(payload.get("client_order_id") or "")
|
||||
if client_order_id and client_order_id in self._client_order_ids:
|
||||
limit_value = payload.get("limit_price", payload.get("price"))
|
||||
fingerprint_payload = {
|
||||
"symbol": normalize_symbol(payload.get("symbol")),
|
||||
"side": str(payload.get("side") or "").upper(),
|
||||
"quantity": int(payload.get("quantity") or 0),
|
||||
"order_type": str(payload.get("order_type") or "MARKET").upper(),
|
||||
"limit_price": None if limit_value in (None, "") else float(decimal_value(limit_value)),
|
||||
"time_in_force": str(payload.get("time_in_force", self.config.time_in_force)).upper(),
|
||||
}
|
||||
fingerprint = json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"))
|
||||
if self._client_order_fingerprints.get(client_order_id) != fingerprint:
|
||||
raise ValueError("client_order_id reused with different order payload")
|
||||
return self._client_order_ids[client_order_id]
|
||||
try:
|
||||
symbol = normalize_symbol(payload.get("symbol"))
|
||||
side = str(payload.get("side") or "").upper()
|
||||
quantity = int(payload.get("quantity") or 0)
|
||||
order_type = str(payload.get("order_type") or "MARKET").upper()
|
||||
if side not in ("BUY", "SELL"):
|
||||
raise ValueError("side must be BUY or SELL")
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
if order_type not in ("MARKET", "LIMIT"):
|
||||
raise ValueError("order_type must be MARKET or LIMIT")
|
||||
if order_type == "LIMIT" and decimal_value(payload.get("limit_price", payload.get("price"))) <= 0:
|
||||
raise ValueError("positive limit_price is required for LIMIT order")
|
||||
payload.update({"symbol": symbol, "side": side, "quantity": quantity, "order_type": order_type})
|
||||
order = self._new_order(payload, frame_index, submitted_at)
|
||||
except Exception as exc:
|
||||
self._order_sequence += 1
|
||||
order = BacktestOrder(
|
||||
"bt-order-%06d" % self._order_sequence,
|
||||
client_order_id,
|
||||
payload.get("symbol") or "UNKNOWN",
|
||||
payload.get("side") or "UNKNOWN",
|
||||
int(payload.get("quantity") or 0),
|
||||
payload.get("order_type") or "MARKET",
|
||||
payload.get("limit_price", payload.get("price")),
|
||||
frame_index,
|
||||
submitted_at,
|
||||
payload.get("time_in_force", self.config.time_in_force),
|
||||
)
|
||||
self._reject(order, "invalid_order:%s" % exc)
|
||||
self.orders_list.append(order)
|
||||
return order
|
||||
|
||||
self.orders_list.append(order)
|
||||
if client_order_id:
|
||||
self._client_order_ids[client_order_id] = order
|
||||
fingerprint_payload = {
|
||||
"symbol": order.symbol,
|
||||
"side": order.side,
|
||||
"quantity": order.quantity,
|
||||
"order_type": order.order_type,
|
||||
"limit_price": None if order.limit_price is None else float(order.limit_price),
|
||||
"time_in_force": order.time_in_force,
|
||||
}
|
||||
self._client_order_fingerprints[client_order_id] = json.dumps(
|
||||
fingerprint_payload, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
lot_size = self.config.lot_size
|
||||
if order.side == "BUY" and order.quantity % lot_size:
|
||||
return self._reject(order, "buy_quantity_not_round_lot")
|
||||
if order.side == "SELL":
|
||||
position = self.positions.get(order.symbol)
|
||||
available = 0 if position is None else max(position.available - self._reserved_sell(order.symbol) + order.quantity, 0)
|
||||
if available <= 0:
|
||||
return self._reject(order, "t_plus_one_unavailable")
|
||||
if order.quantity > available:
|
||||
return self._reject(order, "insufficient_sellable")
|
||||
if order.quantity % lot_size and order.quantity != available:
|
||||
return self._reject(order, "sell_quantity_not_round_lot")
|
||||
return order
|
||||
|
||||
def cancel(self, order_id):
|
||||
for order in self.orders_list:
|
||||
if order.order_id == str(order_id) or order.client_order_id == str(order_id):
|
||||
if order.status not in ACTIVE_ORDER_STATUSES:
|
||||
return order
|
||||
order.status = "CANCELLED"
|
||||
order.reject_reason = "cancelled_by_strategy"
|
||||
return order
|
||||
raise KeyError("order not found: %s" % order_id)
|
||||
|
||||
def _settle_trading_day(self, trading_date):
|
||||
if self._trading_date == trading_date:
|
||||
return
|
||||
if self._trading_date is not None:
|
||||
for position in self.positions.values():
|
||||
position.available = position.quantity
|
||||
position.today_buy = 0
|
||||
self._trading_date = trading_date
|
||||
|
||||
def _limits(self, order, bar):
|
||||
prev_close = decimal_value(bar.get("prev_close") or bar.get("close"))
|
||||
if bar.get("price_limit_rate") not in (None, ""):
|
||||
rate = decimal_value(bar.get("price_limit_rate"))
|
||||
else:
|
||||
pure = order.symbol.split(".", 1)[0]
|
||||
if order.symbol.endswith(".BJ"):
|
||||
rate = Decimal("0.30")
|
||||
elif pure.startswith(("300", "301", "688", "689")):
|
||||
rate = Decimal("0.20")
|
||||
else:
|
||||
rate = decimal_value(self.config.price_limit_rate)
|
||||
up_limit = bar.get("up_limit")
|
||||
down_limit = bar.get("down_limit")
|
||||
up_limit = round_price(order.symbol, up_limit if up_limit not in (None, "") else prev_close * (Decimal("1") + rate))
|
||||
down_limit = round_price(order.symbol, down_limit if down_limit not in (None, "") else prev_close * (Decimal("1") - rate))
|
||||
return up_limit, down_limit
|
||||
|
||||
def _match_price(self, order, bar):
|
||||
if bool(bar.get("suspended")) or float(bar.get("volume") or 0) <= 0:
|
||||
return None, "suspended_or_no_volume"
|
||||
open_price = round_price(order.symbol, bar["open"])
|
||||
high = round_price(order.symbol, bar["high"])
|
||||
low = round_price(order.symbol, bar["low"])
|
||||
up_limit, down_limit = self._limits(order, bar)
|
||||
if order.side == "BUY" and open_price == high == low == up_limit:
|
||||
return None, "limit_up_locked"
|
||||
if order.side == "SELL" and open_price == high == low == down_limit:
|
||||
return None, "limit_down_locked"
|
||||
if order.order_type == "LIMIT" and not down_limit <= order.limit_price <= up_limit:
|
||||
return None, "limit_price_outside_daily_range"
|
||||
if order.order_type == "MARKET":
|
||||
price = open_price
|
||||
elif order.side == "BUY":
|
||||
if low > order.limit_price:
|
||||
return None, "limit_not_crossed"
|
||||
price = min(open_price, order.limit_price)
|
||||
else:
|
||||
if high < order.limit_price:
|
||||
return None, "limit_not_crossed"
|
||||
price = max(open_price, order.limit_price)
|
||||
slip = decimal_value(self.config.slippage_bps) / Decimal("10000")
|
||||
if order.side == "BUY":
|
||||
price = min(round_price(order.symbol, price * (Decimal("1") + slip)), up_limit)
|
||||
else:
|
||||
price = max(round_price(order.symbol, price * (Decimal("1") - slip)), down_limit)
|
||||
return price, ""
|
||||
|
||||
def _fees(self, side, amount):
|
||||
rate = self.config.buy_commission_rate if side == "BUY" else self.config.sell_commission_rate
|
||||
commission = max(amount * decimal_value(rate), decimal_value(self.config.min_commission)) if rate else ZERO
|
||||
stamp = amount * decimal_value(self.config.stamp_tax_rate) if side == "SELL" else ZERO
|
||||
transfer = amount * decimal_value(self.config.transfer_fee_rate)
|
||||
return money(commission), money(stamp), money(transfer)
|
||||
|
||||
def _volume_cap(self, order, bar, used_volume=0):
|
||||
raw = int(float(bar.get("volume") or 0) * float(self.config.max_volume_participation))
|
||||
cap = max((raw // self.config.lot_size) * self.config.lot_size - int(used_volume), 0)
|
||||
return min(order.remaining, cap)
|
||||
|
||||
def _affordable_buy_quantity(self, quantity, price):
|
||||
quantity = (int(quantity) // self.config.lot_size) * self.config.lot_size
|
||||
while quantity > 0:
|
||||
amount = money(price * quantity)
|
||||
fees = sum(self._fees("BUY", amount), ZERO)
|
||||
if self.cash >= amount + fees:
|
||||
return quantity
|
||||
quantity -= self.config.lot_size
|
||||
return 0
|
||||
|
||||
def _apply_fill(self, order, quantity, price, frame_index, filled_at):
|
||||
amount = money(price * quantity)
|
||||
commission, stamp, transfer = self._fees(order.side, amount)
|
||||
self._fill_sequence += 1
|
||||
fill = BacktestFill(
|
||||
"bt-fill-%06d" % self._fill_sequence,
|
||||
order,
|
||||
quantity,
|
||||
price,
|
||||
commission,
|
||||
stamp,
|
||||
transfer,
|
||||
frame_index,
|
||||
filled_at,
|
||||
)
|
||||
fees = fill.total_fee
|
||||
position = self.positions.get(order.symbol)
|
||||
if order.side == "BUY":
|
||||
if position is None:
|
||||
position = Position(order.symbol)
|
||||
self.positions[order.symbol] = position
|
||||
old_cost = position.avg_cost * position.quantity
|
||||
self.cash = money(self.cash - amount - fees)
|
||||
position.quantity += quantity
|
||||
position.today_buy += quantity
|
||||
position.avg_cost = (old_cost + amount + fees) / position.quantity
|
||||
else:
|
||||
if position is None or position.available < quantity:
|
||||
raise RuntimeError("sellable quantity changed before fill")
|
||||
self.cash = money(self.cash + amount - fees)
|
||||
position.quantity -= quantity
|
||||
position.available -= quantity
|
||||
position.realized_pnl += amount - fees - position.avg_cost * quantity
|
||||
if position.quantity <= 0:
|
||||
self.positions.pop(order.symbol, None)
|
||||
order.filled_quantity += quantity
|
||||
order.status = "FILLED" if order.remaining == 0 else "PARTIALLY_FILLED"
|
||||
self.total_fees += fees
|
||||
self.turnover += amount
|
||||
self.fills_list.append(fill)
|
||||
return fill
|
||||
|
||||
def advance(self, frame_index, frame):
|
||||
trading_date = str(frame["datetime"])[:10]
|
||||
self._settle_trading_day(trading_date)
|
||||
fills = []
|
||||
used_volume = {}
|
||||
for order in self.orders_list:
|
||||
if order.status not in ACTIVE_ORDER_STATUSES or frame_index <= order.submitted_index:
|
||||
continue
|
||||
bar = frame["bars"].get(order.symbol)
|
||||
if bar is None:
|
||||
continue
|
||||
if order.time_in_force == "DAY" and str(order.submitted_at)[:10] != trading_date:
|
||||
order.status = "EXPIRED"
|
||||
order.reject_reason = "day_order_expired"
|
||||
continue
|
||||
order.last_attempt_index = frame_index
|
||||
price, reason = self._match_price(order, bar)
|
||||
if price is None:
|
||||
if order.time_in_force == "NEXT_BAR":
|
||||
order.status = "EXPIRED"
|
||||
order.reject_reason = reason
|
||||
continue
|
||||
quantity = self._volume_cap(order, bar, used_volume.get(order.symbol, 0))
|
||||
if quantity <= 0:
|
||||
reason = "volume_participation_exhausted"
|
||||
elif order.side == "BUY":
|
||||
quantity = self._affordable_buy_quantity(quantity, price)
|
||||
if quantity <= 0:
|
||||
reason = "insufficient_cash"
|
||||
else:
|
||||
position = self.positions.get(order.symbol)
|
||||
quantity = min(quantity, 0 if position is None else position.available)
|
||||
if quantity <= 0:
|
||||
reason = "t_plus_one_unavailable"
|
||||
if quantity > 0:
|
||||
fills.append(self._apply_fill(order, quantity, price, frame_index, frame["datetime"]))
|
||||
used_volume[order.symbol] = used_volume.get(order.symbol, 0) + quantity
|
||||
if order.time_in_force == "NEXT_BAR" and order.remaining > 0:
|
||||
if order.filled_quantity == 0:
|
||||
order.status = "EXPIRED"
|
||||
else:
|
||||
order.status = "PARTIALLY_FILLED_EXPIRED"
|
||||
order.reject_reason = reason or "next_bar_remaining_expired"
|
||||
return fills
|
||||
|
||||
def expire_open_orders(self, reason="backtest_finished"):
|
||||
for order in self.orders_list:
|
||||
if order.status in ACTIVE_ORDER_STATUSES:
|
||||
order.status = "EXPIRED"
|
||||
order.reject_reason = reason
|
||||
|
||||
def snapshot(self, bars):
|
||||
positions = {}
|
||||
market_value = ZERO
|
||||
for symbol in sorted(self.positions):
|
||||
position = self.positions[symbol]
|
||||
bar = bars.get(symbol) or {}
|
||||
mark = decimal_value(bar.get("close"), position.avg_cost)
|
||||
market_value += mark * position.quantity
|
||||
positions[symbol] = position.to_dict(mark)
|
||||
total_asset = money(self.cash + market_value)
|
||||
return {
|
||||
"cash": json_number(self.cash, 2),
|
||||
"market_value": json_number(money(market_value), 2),
|
||||
"total_asset": json_number(total_asset, 2),
|
||||
"positions": positions,
|
||||
"total_fees": json_number(money(self.total_fees), 2),
|
||||
"turnover": json_number(money(self.turnover), 2),
|
||||
}
|
||||
|
||||
def orders(self):
|
||||
return [order.to_dict() for order in self.orders_list]
|
||||
|
||||
def fills(self):
|
||||
return [fill.to_dict() for fill in self.fills_list]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""External-strategy client SDK for the ZMQ backtest bridge."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
class BacktestRemoteError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BacktestZmqClient(object):
|
||||
def __init__(
|
||||
self,
|
||||
endpoint,
|
||||
run_id,
|
||||
client_id="external-strategy",
|
||||
timeout_seconds=10.0,
|
||||
):
|
||||
self.endpoint = str(endpoint)
|
||||
self.run_id = str(run_id)
|
||||
self.client_id = str(client_id)
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self._context = None
|
||||
self._socket = None
|
||||
|
||||
def _connect(self):
|
||||
if self._socket is not None:
|
||||
return self._socket
|
||||
import zmq
|
||||
|
||||
self._context = zmq.Context.instance()
|
||||
self._socket = self._context.socket(zmq.REQ)
|
||||
self._socket.setsockopt(zmq.LINGER, 0)
|
||||
self._socket.connect(self.endpoint)
|
||||
return self._socket
|
||||
|
||||
def _reset_socket(self):
|
||||
if self._socket is not None:
|
||||
self._socket.close(linger=0)
|
||||
self._socket = None
|
||||
|
||||
def request(self, method, params=None, request_id=None):
|
||||
import zmq
|
||||
|
||||
request_id = str(request_id or uuid.uuid4().hex)
|
||||
envelope = {
|
||||
"schema_version": 1,
|
||||
"request_id": request_id,
|
||||
"run_id": self.run_id,
|
||||
"client_id": self.client_id,
|
||||
"method": str(method),
|
||||
"params": dict(params or {}),
|
||||
}
|
||||
socket = self._connect()
|
||||
socket.send(json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
poller = zmq.Poller()
|
||||
poller.register(socket, zmq.POLLIN)
|
||||
events = dict(poller.poll(int(self.timeout_seconds * 1000)))
|
||||
if socket not in events:
|
||||
self._reset_socket()
|
||||
raise TimeoutError("backtest ZMQ request timed out: %s" % method)
|
||||
response = json.loads(socket.recv().decode("utf-8"))
|
||||
if str(response.get("request_id") or "") != request_id:
|
||||
raise BacktestRemoteError("response request_id mismatch")
|
||||
if not response.get("ok"):
|
||||
raise BacktestRemoteError(str(response.get("error") or "remote request failed"))
|
||||
return response.get("data")
|
||||
|
||||
def ping(self):
|
||||
return self.request("ping")
|
||||
|
||||
def describe(self):
|
||||
data = self.request("describe")
|
||||
if not self.run_id and data.get("run_id"):
|
||||
self.run_id = str(data["run_id"])
|
||||
return data
|
||||
|
||||
def start(self):
|
||||
return self.request("start")
|
||||
|
||||
def next_bar(self):
|
||||
return self.request("next_bar")
|
||||
|
||||
def state(self):
|
||||
return self.request("state")
|
||||
|
||||
def submit_order(
|
||||
self,
|
||||
symbol,
|
||||
side,
|
||||
quantity,
|
||||
order_type="MARKET",
|
||||
limit_price=None,
|
||||
client_order_id="",
|
||||
time_in_force="NEXT_BAR",
|
||||
):
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"quantity": int(quantity),
|
||||
"order_type": order_type,
|
||||
"client_order_id": client_order_id,
|
||||
"time_in_force": time_in_force,
|
||||
}
|
||||
if limit_price is not None:
|
||||
params["limit_price"] = limit_price
|
||||
return self.request("submit_order", params)
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
return self.request("cancel_order", {"order_id": order_id})
|
||||
|
||||
def history(self, symbol, count=100, fields=None):
|
||||
params = {"symbol": symbol, "count": int(count)}
|
||||
if fields is not None:
|
||||
params["fields"] = list(fields)
|
||||
return self.request("history", params)
|
||||
|
||||
def orders(self):
|
||||
return self.request("orders")
|
||||
|
||||
def fills(self):
|
||||
return self.request("fills")
|
||||
|
||||
def finish(self):
|
||||
return self.request("finish")
|
||||
|
||||
def close(self):
|
||||
self._reset_socket()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Deterministic historical bar feeds for the backtest bridge."""
|
||||
|
||||
import csv
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from .models import normalize_symbol
|
||||
|
||||
|
||||
DATETIME_FIELDS = ("datetime", "timestamp", "time", "date", "stime")
|
||||
SYMBOL_FIELDS = ("symbol", "stock_code", "code", "stock")
|
||||
REQUIRED_PRICE_FIELDS = ("open", "high", "low", "close")
|
||||
|
||||
|
||||
def _first(row, names, default=None):
|
||||
for name in names:
|
||||
value = row.get(name)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
if isinstance(value, dt.datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
if isinstance(value, dt.date):
|
||||
return dt.datetime.combine(value, dt.time())
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError("bar datetime is required")
|
||||
if text.isdigit():
|
||||
if len(text) == 8:
|
||||
return dt.datetime.strptime(text, "%Y%m%d")
|
||||
if len(text) == 14:
|
||||
return dt.datetime.strptime(text, "%Y%m%d%H%M%S")
|
||||
numeric = int(text)
|
||||
if numeric > 10 ** 12:
|
||||
numeric = numeric / 1000.0
|
||||
return dt.datetime.fromtimestamp(numeric)
|
||||
normalized = text.replace("T", " ").replace("Z", "").strip()
|
||||
from_isoformat = getattr(dt.datetime, "fromisoformat", None)
|
||||
if from_isoformat is not None:
|
||||
try:
|
||||
return from_isoformat(normalized).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
return dt.datetime.strptime(normalized, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("unsupported bar datetime: %s" % text)
|
||||
|
||||
|
||||
def _bool_value(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value or "").strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
def _optional_float(value):
|
||||
return None if value in (None, "") else float(value)
|
||||
|
||||
|
||||
def normalize_bar(row, default_symbol=""):
|
||||
timestamp = parse_datetime(_first(row, DATETIME_FIELDS))
|
||||
symbol = normalize_symbol(_first(row, SYMBOL_FIELDS, default_symbol))
|
||||
bar = {
|
||||
"datetime": timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"symbol": symbol,
|
||||
}
|
||||
for field in REQUIRED_PRICE_FIELDS:
|
||||
value = row.get(field)
|
||||
if value in (None, ""):
|
||||
raise ValueError("%s is required for %s at %s" % (field, symbol, bar["datetime"]))
|
||||
bar[field] = float(value)
|
||||
if bar[field] <= 0:
|
||||
raise ValueError("%s must be positive for %s at %s" % (field, symbol, bar["datetime"]))
|
||||
if bar["high"] < max(bar["open"], bar["close"], bar["low"]):
|
||||
raise ValueError("bar high is inconsistent for %s at %s" % (symbol, bar["datetime"]))
|
||||
if bar["low"] > min(bar["open"], bar["close"], bar["high"]):
|
||||
raise ValueError("bar low is inconsistent for %s at %s" % (symbol, bar["datetime"]))
|
||||
bar["volume"] = float(row.get("volume") or 0)
|
||||
bar["amount"] = float(row.get("amount") or 0)
|
||||
bar["prev_close"] = _optional_float(row.get("prev_close"))
|
||||
bar["up_limit"] = _optional_float(row.get("up_limit"))
|
||||
bar["down_limit"] = _optional_float(row.get("down_limit"))
|
||||
bar["suspended"] = _bool_value(row.get("suspended"))
|
||||
if row.get("price_limit_rate") not in (None, ""):
|
||||
bar["price_limit_rate"] = float(row["price_limit_rate"])
|
||||
return timestamp, bar
|
||||
|
||||
|
||||
class InMemoryBarFeed(object):
|
||||
def __init__(self, rows, source="memory", data_hash=None, default_symbol=""):
|
||||
normalized = []
|
||||
for row in rows:
|
||||
timestamp, bar = normalize_bar(dict(row), default_symbol=default_symbol)
|
||||
normalized.append((timestamp, bar))
|
||||
normalized.sort(key=lambda item: (item[0], item[1]["symbol"]))
|
||||
seen = set()
|
||||
frames = []
|
||||
current_timestamp = None
|
||||
current_bars = None
|
||||
previous_close = {}
|
||||
for timestamp, bar in normalized:
|
||||
identity = (timestamp, bar["symbol"])
|
||||
if identity in seen:
|
||||
raise ValueError("duplicate bar for %s at %s" % (bar["symbol"], bar["datetime"]))
|
||||
seen.add(identity)
|
||||
if bar["prev_close"] is None:
|
||||
bar["prev_close"] = previous_close.get(bar["symbol"])
|
||||
previous_close[bar["symbol"]] = bar["close"]
|
||||
if current_timestamp != timestamp:
|
||||
current_timestamp = timestamp
|
||||
current_bars = {}
|
||||
frames.append(
|
||||
{
|
||||
"datetime": timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"bars": current_bars,
|
||||
}
|
||||
)
|
||||
current_bars[bar["symbol"]] = bar
|
||||
if not frames:
|
||||
raise ValueError("historical data is empty")
|
||||
self._frames = frames
|
||||
self.source = str(source)
|
||||
if data_hash:
|
||||
self.data_hash = str(data_hash)
|
||||
else:
|
||||
payload = json.dumps(frames, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
self.data_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
def __len__(self):
|
||||
return len(self._frames)
|
||||
|
||||
def frame(self, index):
|
||||
frame = self._frames[int(index)]
|
||||
return {"datetime": frame["datetime"], "bars": {key: dict(value) for key, value in frame["bars"].items()}}
|
||||
|
||||
def history(self, symbol, end_index, count=100, fields=None):
|
||||
symbol = normalize_symbol(symbol)
|
||||
end_index = min(int(end_index), len(self._frames) - 1)
|
||||
count = max(int(count or 0), 0)
|
||||
result = []
|
||||
for index in range(0, end_index + 1):
|
||||
bar = self._frames[index]["bars"].get(symbol)
|
||||
if bar is None:
|
||||
continue
|
||||
if fields:
|
||||
item = {"datetime": bar["datetime"], "symbol": symbol}
|
||||
for field in fields:
|
||||
if field in bar:
|
||||
item[str(field)] = bar[field]
|
||||
else:
|
||||
item = dict(bar)
|
||||
result.append(item)
|
||||
return result[-count:] if count else []
|
||||
|
||||
|
||||
class CsvBarFeed(InMemoryBarFeed):
|
||||
def __init__(self, path, default_symbol="", encoding="utf-8-sig"):
|
||||
absolute = os.path.abspath(path)
|
||||
with open(absolute, "rb") as handle:
|
||||
raw = handle.read()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
text = raw.decode(encoding)
|
||||
reader = csv.DictReader(io.StringIO(text, newline=""))
|
||||
rows = list(reader)
|
||||
super(CsvBarFeed, self).__init__(
|
||||
rows,
|
||||
source=absolute,
|
||||
data_hash=digest,
|
||||
default_symbol=default_symbol,
|
||||
)
|
||||
|
||||
|
||||
class StreamingBarFeed(object):
|
||||
"""Thread-safe feed populated by QMT ``handlebar`` callbacks.
|
||||
|
||||
The external strategy can only read through ``frame``/``history`` with an
|
||||
engine-controlled end index, so bars already captured from QMT but not yet
|
||||
advanced to remain inaccessible.
|
||||
"""
|
||||
|
||||
def __init__(self, source="qmt_native_backtest"):
|
||||
self.source = str(source)
|
||||
self._frames = []
|
||||
self._seen = set()
|
||||
self._previous_close = {}
|
||||
self._condition = threading.Condition()
|
||||
self.closed = False
|
||||
|
||||
@property
|
||||
def data_hash(self):
|
||||
with self._condition:
|
||||
payload = json.dumps(
|
||||
self._frames,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
def __len__(self):
|
||||
with self._condition:
|
||||
return len(self._frames)
|
||||
|
||||
def append(self, row, default_symbol=""):
|
||||
timestamp, bar = normalize_bar(dict(row), default_symbol=default_symbol)
|
||||
identity = (timestamp, bar["symbol"])
|
||||
with self._condition:
|
||||
if identity in self._seen:
|
||||
return False
|
||||
if self.closed:
|
||||
raise RuntimeError("streaming feed is closed")
|
||||
self._seen.add(identity)
|
||||
if bar["prev_close"] is None:
|
||||
bar["prev_close"] = self._previous_close.get(bar["symbol"])
|
||||
self._previous_close[bar["symbol"]] = bar["close"]
|
||||
timestamp_text = timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self._frames and self._frames[-1]["datetime"] == timestamp_text:
|
||||
self._frames[-1]["bars"][bar["symbol"]] = bar
|
||||
elif self._frames and self._frames[-1]["datetime"] > timestamp_text:
|
||||
raise ValueError("streaming bars must be appended chronologically")
|
||||
else:
|
||||
self._frames.append({"datetime": timestamp_text, "bars": {bar["symbol"]: bar}})
|
||||
self._condition.notify_all()
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
with self._condition:
|
||||
self.closed = True
|
||||
self._condition.notify_all()
|
||||
|
||||
def wait_for_index(self, index, timeout_seconds=None):
|
||||
index = int(index)
|
||||
with self._condition:
|
||||
if len(self._frames) > index:
|
||||
return True
|
||||
self._condition.wait_for(
|
||||
lambda: len(self._frames) > index or self.closed,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
return len(self._frames) > index
|
||||
|
||||
def frame(self, index):
|
||||
with self._condition:
|
||||
frame = self._frames[int(index)]
|
||||
return {
|
||||
"datetime": frame["datetime"],
|
||||
"bars": {key: dict(value) for key, value in frame["bars"].items()},
|
||||
}
|
||||
|
||||
def history(self, symbol, end_index, count=100, fields=None):
|
||||
symbol = normalize_symbol(symbol)
|
||||
count = max(int(count or 0), 0)
|
||||
with self._condition:
|
||||
end_index = min(int(end_index), len(self._frames) - 1)
|
||||
frames = self._frames[: end_index + 1]
|
||||
result = []
|
||||
for frame in frames:
|
||||
bar = frame["bars"].get(symbol)
|
||||
if bar is None:
|
||||
continue
|
||||
if fields:
|
||||
item = {"datetime": bar["datetime"], "symbol": symbol}
|
||||
for field in fields:
|
||||
if field in bar:
|
||||
item[str(field)] = bar[field]
|
||||
else:
|
||||
item = dict(bar)
|
||||
result.append(item)
|
||||
return result[-count:] if count else []
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Deterministic bar-by-bar backtest engine."""
|
||||
|
||||
import csv
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
from .broker import ACTIVE_ORDER_STATUSES, SimulatedBroker
|
||||
from .models import decimal_value, normalize_symbol
|
||||
|
||||
|
||||
ENGINE_VERSION = "1.0.0"
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
class BacktestConfig(object):
|
||||
def __init__(
|
||||
self,
|
||||
run_id,
|
||||
output_dir,
|
||||
initial_cash=1000000,
|
||||
initial_positions=None,
|
||||
buy_commission_rate=0.0003,
|
||||
sell_commission_rate=0.0003,
|
||||
min_commission=5,
|
||||
stamp_tax_rate=0.0005,
|
||||
transfer_fee_rate=0.00001,
|
||||
slippage_bps=0,
|
||||
max_volume_participation=0.1,
|
||||
price_limit_rate=0.10,
|
||||
lot_size=100,
|
||||
time_in_force="NEXT_BAR",
|
||||
seed=0,
|
||||
strategy_name="external_zmq_strategy",
|
||||
parameters=None,
|
||||
fee_schedule="a_share_2023_08_28",
|
||||
market_rules_version="a_share_v1",
|
||||
):
|
||||
self.run_id = str(run_id or "").strip()
|
||||
if not self.run_id:
|
||||
raise ValueError("run_id is required")
|
||||
self.output_dir = os.path.abspath(output_dir)
|
||||
self.initial_cash = decimal_value(initial_cash)
|
||||
if self.initial_cash < 0:
|
||||
raise ValueError("initial_cash cannot be negative")
|
||||
self.initial_positions = dict(initial_positions or {})
|
||||
self.buy_commission_rate = decimal_value(buy_commission_rate)
|
||||
self.sell_commission_rate = decimal_value(sell_commission_rate)
|
||||
self.min_commission = decimal_value(min_commission)
|
||||
self.stamp_tax_rate = decimal_value(stamp_tax_rate)
|
||||
self.transfer_fee_rate = decimal_value(transfer_fee_rate)
|
||||
self.slippage_bps = decimal_value(slippage_bps)
|
||||
self.max_volume_participation = float(max_volume_participation)
|
||||
if not 0 < self.max_volume_participation <= 1:
|
||||
raise ValueError("max_volume_participation must be in (0, 1]")
|
||||
self.price_limit_rate = decimal_value(price_limit_rate)
|
||||
self.lot_size = int(lot_size)
|
||||
if self.lot_size <= 0:
|
||||
raise ValueError("lot_size must be positive")
|
||||
self.time_in_force = str(time_in_force or "NEXT_BAR").upper()
|
||||
if self.time_in_force not in ("NEXT_BAR", "DAY"):
|
||||
raise ValueError("time_in_force must be NEXT_BAR or DAY")
|
||||
self.seed = int(seed)
|
||||
self.strategy_name = str(strategy_name or "external_zmq_strategy")
|
||||
self.parameters = dict(parameters or {})
|
||||
self.fee_schedule = str(fee_schedule or "custom")
|
||||
self.market_rules_version = str(market_rules_version or "custom")
|
||||
|
||||
def to_dict(self, include_paths=True, include_identity=True):
|
||||
payload = {
|
||||
"initial_cash": float(self.initial_cash),
|
||||
"initial_positions": self.initial_positions,
|
||||
"buy_commission_rate": float(self.buy_commission_rate),
|
||||
"sell_commission_rate": float(self.sell_commission_rate),
|
||||
"min_commission": float(self.min_commission),
|
||||
"stamp_tax_rate": float(self.stamp_tax_rate),
|
||||
"transfer_fee_rate": float(self.transfer_fee_rate),
|
||||
"slippage_bps": float(self.slippage_bps),
|
||||
"max_volume_participation": self.max_volume_participation,
|
||||
"price_limit_rate": float(self.price_limit_rate),
|
||||
"lot_size": self.lot_size,
|
||||
"time_in_force": self.time_in_force,
|
||||
"seed": self.seed,
|
||||
"strategy_name": self.strategy_name,
|
||||
"parameters": self.parameters,
|
||||
"fee_schedule": self.fee_schedule,
|
||||
"market_rules_version": self.market_rules_version,
|
||||
}
|
||||
if include_identity:
|
||||
payload["run_id"] = self.run_id
|
||||
if include_paths:
|
||||
payload["output_dir"] = self.output_dir
|
||||
return payload
|
||||
|
||||
|
||||
class BacktestEngine(object):
|
||||
def __init__(self, feed, config):
|
||||
self.feed = feed
|
||||
self.config = config
|
||||
self.created_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.started = False
|
||||
self.finished = False
|
||||
self.current_index = -1
|
||||
self.current_frame = None
|
||||
self.last_fills = []
|
||||
self.equity_curve = []
|
||||
self.position_rows = []
|
||||
self._result = None
|
||||
self.broker = SimulatedBroker(config)
|
||||
|
||||
def _record_state(self):
|
||||
snapshot = self.broker.snapshot(self.current_frame["bars"])
|
||||
equity_row = {
|
||||
"frame_index": self.current_index,
|
||||
"datetime": self.current_frame["datetime"],
|
||||
"cash": snapshot["cash"],
|
||||
"market_value": snapshot["market_value"],
|
||||
"total_asset": snapshot["total_asset"],
|
||||
}
|
||||
self.equity_curve.append(equity_row)
|
||||
for symbol, position in snapshot["positions"].items():
|
||||
row = {"frame_index": self.current_index, "datetime": self.current_frame["datetime"]}
|
||||
row.update(position)
|
||||
self.position_rows.append(row)
|
||||
|
||||
def start(self):
|
||||
if self.started:
|
||||
return self.state()
|
||||
self.started = True
|
||||
self.current_index = 0
|
||||
self.current_frame = self.feed.frame(0)
|
||||
self.broker._settle_trading_day(self.current_frame["datetime"][:10])
|
||||
self._record_state()
|
||||
return self.state()
|
||||
|
||||
def _require_started(self):
|
||||
if not self.started:
|
||||
raise RuntimeError("backtest has not started")
|
||||
if self.finished:
|
||||
raise RuntimeError("backtest is already finished")
|
||||
|
||||
def submit_order(self, payload):
|
||||
self._require_started()
|
||||
return self.broker.submit(
|
||||
payload,
|
||||
frame_index=self.current_index,
|
||||
submitted_at=self.current_frame["datetime"],
|
||||
).to_dict()
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
self._require_started()
|
||||
return self.broker.cancel(order_id).to_dict()
|
||||
|
||||
def next_bar(self):
|
||||
self._require_started()
|
||||
if self.current_index >= len(self.feed) - 1:
|
||||
return self.state()
|
||||
self.current_index += 1
|
||||
self.current_frame = self.feed.frame(self.current_index)
|
||||
self.last_fills = self.broker.advance(self.current_index, self.current_frame)
|
||||
self._record_state()
|
||||
return self.state()
|
||||
|
||||
def history(self, symbol, count=100, fields=None):
|
||||
self._require_started()
|
||||
return self.feed.history(normalize_symbol(symbol), self.current_index, count=count, fields=fields)
|
||||
|
||||
def orders(self):
|
||||
return self.broker.orders()
|
||||
|
||||
def fills(self):
|
||||
return self.broker.fills()
|
||||
|
||||
def state(self):
|
||||
if not self.started:
|
||||
return {
|
||||
"run_id": self.config.run_id,
|
||||
"started": False,
|
||||
"finished": self.finished,
|
||||
"done": False,
|
||||
"frame_index": -1,
|
||||
"frame_count": len(self.feed),
|
||||
}
|
||||
portfolio = self.broker.snapshot(self.current_frame["bars"])
|
||||
return {
|
||||
"run_id": self.config.run_id,
|
||||
"started": True,
|
||||
"finished": self.finished,
|
||||
"done": self.current_index >= len(self.feed) - 1,
|
||||
"frame_index": self.current_index,
|
||||
"frame_count": len(self.feed),
|
||||
"datetime": self.current_frame["datetime"],
|
||||
"bars": {key: dict(value) for key, value in self.current_frame["bars"].items()},
|
||||
"fills": [fill.to_dict() for fill in self.last_fills],
|
||||
"cash": portfolio["cash"],
|
||||
"market_value": portfolio["market_value"],
|
||||
"total_asset": portfolio["total_asset"],
|
||||
"positions": portfolio["positions"],
|
||||
"total_fees": portfolio["total_fees"],
|
||||
"turnover": portfolio["turnover"],
|
||||
}
|
||||
|
||||
def _metrics(self):
|
||||
assets = [float(row["total_asset"]) for row in self.equity_curve]
|
||||
initial = assets[0] if assets else float(self.config.initial_cash)
|
||||
final = assets[-1] if assets else initial
|
||||
peak = None
|
||||
max_drawdown = 0.0
|
||||
for value in assets:
|
||||
peak = value if peak is None else max(peak, value)
|
||||
if peak > 0:
|
||||
max_drawdown = min(max_drawdown, value / peak - 1.0)
|
||||
dates = sorted(set(row["datetime"][:10] for row in self.equity_curve))
|
||||
total_return = 0.0 if initial == 0 else final / initial - 1.0
|
||||
annualized = None
|
||||
if len(dates) > 1 and initial > 0 and final > 0:
|
||||
annualized = math.pow(final / initial, 252.0 / len(dates)) - 1.0
|
||||
filled_orders = len([order for order in self.broker.orders_list if order.filled_quantity > 0])
|
||||
rejected_orders = len([order for order in self.broker.orders_list if order.status == "REJECTED"])
|
||||
return {
|
||||
"initial_total_asset": round(initial, 2),
|
||||
"final_total_asset": round(final, 2),
|
||||
"total_return": round(total_return, 10),
|
||||
"annualized_return": None if annualized is None else round(annualized, 10),
|
||||
"max_drawdown": round(-max_drawdown, 10),
|
||||
"trading_days": len(dates),
|
||||
"bar_count": len(self.equity_curve),
|
||||
"order_count": len(self.broker.orders_list),
|
||||
"filled_order_count": filled_orders,
|
||||
"rejected_order_count": rejected_orders,
|
||||
"fill_count": len(self.broker.fills_list),
|
||||
"total_fees": round(float(self.broker.total_fees), 2),
|
||||
"turnover": round(float(self.broker.turnover), 2),
|
||||
}
|
||||
|
||||
def _signature_payload(self, metrics):
|
||||
orders = []
|
||||
for item in self.orders():
|
||||
clean = dict(item)
|
||||
clean.pop("client_order_id", None)
|
||||
orders.append(clean)
|
||||
return {
|
||||
"engine_version": ENGINE_VERSION,
|
||||
"data_hash": self.feed.data_hash,
|
||||
"config": self.config.to_dict(include_paths=False, include_identity=False),
|
||||
"orders": orders,
|
||||
"fills": self.fills(),
|
||||
"equity": self.equity_curve,
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
def finish(self):
|
||||
if self._result is not None:
|
||||
return dict(self._result)
|
||||
if not self.started:
|
||||
self.start()
|
||||
self.broker.expire_open_orders()
|
||||
metrics = self._metrics()
|
||||
signature_json = json.dumps(
|
||||
self._signature_payload(metrics), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
signature = hashlib.sha256(signature_json.encode("utf-8")).hexdigest()
|
||||
self.finished = True
|
||||
final_state = self.state()
|
||||
self._result = {
|
||||
"schema_version": 1,
|
||||
"engine_version": ENGINE_VERSION,
|
||||
"run_id": self.config.run_id,
|
||||
"strategy_name": self.config.strategy_name,
|
||||
"data_hash": self.feed.data_hash,
|
||||
"deterministic_signature": signature,
|
||||
"metrics": metrics,
|
||||
"final_state": final_state,
|
||||
}
|
||||
self._write_artifacts()
|
||||
return dict(self._result)
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path, payload):
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
@staticmethod
|
||||
def _write_csv(path, rows, fieldnames):
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
def _write_artifacts(self):
|
||||
output_dir = self.config.output_dir
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
meta = {
|
||||
"schema_version": 1,
|
||||
"engine_version": ENGINE_VERSION,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"run_id": self.config.run_id,
|
||||
"created_at": self.created_at,
|
||||
"data_source": self.feed.source,
|
||||
"data_hash": self.feed.data_hash,
|
||||
"frame_count": len(self.feed),
|
||||
"config": self.config.to_dict(),
|
||||
"live_ready": False,
|
||||
"execution_channel": "backtest_zmq_only",
|
||||
}
|
||||
self._write_json(os.path.join(output_dir, "meta.json"), meta)
|
||||
self._write_json(os.path.join(output_dir, "result.json"), self._result)
|
||||
self._write_csv(
|
||||
os.path.join(output_dir, "orders.csv"),
|
||||
self.orders(),
|
||||
(
|
||||
"order_id", "client_order_id", "symbol", "side", "quantity", "filled_quantity",
|
||||
"remaining_quantity", "order_type", "limit_price", "submitted_index", "submitted_at",
|
||||
"time_in_force", "status", "reject_reason",
|
||||
),
|
||||
)
|
||||
self._write_csv(
|
||||
os.path.join(output_dir, "fills.csv"),
|
||||
self.fills(),
|
||||
(
|
||||
"fill_id", "order_id", "client_order_id", "symbol", "side", "quantity", "price",
|
||||
"amount", "commission", "stamp_tax", "transfer_fee", "total_fee", "filled_index", "filled_at",
|
||||
),
|
||||
)
|
||||
self._write_csv(
|
||||
os.path.join(output_dir, "equity.csv"),
|
||||
self.equity_curve,
|
||||
("frame_index", "datetime", "cash", "market_value", "total_asset"),
|
||||
)
|
||||
self._write_csv(
|
||||
os.path.join(output_dir, "positions.csv"),
|
||||
self.position_rows,
|
||||
(
|
||||
"frame_index", "datetime", "symbol", "quantity", "available", "today_buy", "avg_cost",
|
||||
"realized_pnl", "mark_price", "market_value",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StreamingBacktestEngine(BacktestEngine):
|
||||
"""Backtest engine whose bars arrive from a QMT backtest callback thread."""
|
||||
|
||||
def __init__(self, feed, config, bar_wait_timeout_seconds=60.0):
|
||||
super(StreamingBacktestEngine, self).__init__(feed, config)
|
||||
self.bar_wait_timeout_seconds = float(bar_wait_timeout_seconds)
|
||||
|
||||
def start(self):
|
||||
if not self.started and not self.feed.wait_for_index(0, self.bar_wait_timeout_seconds):
|
||||
raise TimeoutError("timed out waiting for the first QMT backtest bar")
|
||||
return super(StreamingBacktestEngine, self).start()
|
||||
|
||||
def next_bar(self):
|
||||
self._require_started()
|
||||
target = self.current_index + 1
|
||||
if not self.feed.wait_for_index(target, self.bar_wait_timeout_seconds):
|
||||
if self.feed.closed:
|
||||
return self.state()
|
||||
raise TimeoutError("timed out waiting for QMT backtest bar index %d" % target)
|
||||
self.current_index = target
|
||||
self.current_frame = self.feed.frame(target)
|
||||
self.last_fills = self.broker.advance(self.current_index, self.current_frame)
|
||||
self._record_state()
|
||||
return self.state()
|
||||
|
||||
def state(self):
|
||||
state = super(StreamingBacktestEngine, self).state()
|
||||
if state.get("started"):
|
||||
state["done"] = bool(self.feed.closed and self.current_index >= len(self.feed) - 1)
|
||||
return state
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Backtest-only domain models with JSON-safe serialization."""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
|
||||
ZERO = Decimal("0")
|
||||
MONEY_QUANT = Decimal("0.01")
|
||||
|
||||
|
||||
def decimal_value(value, default="0"):
|
||||
if value in (None, ""):
|
||||
value = default
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def money(value):
|
||||
return decimal_value(value).quantize(MONEY_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def json_number(value, digits=None):
|
||||
if value is None:
|
||||
return None
|
||||
number = float(value)
|
||||
return round(number, digits) if digits is not None else number
|
||||
|
||||
|
||||
def normalize_symbol(value):
|
||||
text = str(value or "").strip().upper()
|
||||
if not text:
|
||||
raise ValueError("symbol is required")
|
||||
if "." in text:
|
||||
pure, market = text.rsplit(".", 1)
|
||||
if pure and market in ("SH", "SZ", "BJ"):
|
||||
return "%s.%s" % (pure, market)
|
||||
return text
|
||||
if text.isdigit() and len(text) == 6:
|
||||
if text.startswith(("4", "8")):
|
||||
return text + ".BJ"
|
||||
if text.startswith(("5", "6", "9")):
|
||||
return text + ".SH"
|
||||
return text + ".SZ"
|
||||
return text
|
||||
|
||||
|
||||
def price_precision(symbol):
|
||||
pure = normalize_symbol(symbol).split(".", 1)[0]
|
||||
return 3 if pure.startswith(("15", "16", "50", "51", "52", "56", "58")) else 2
|
||||
|
||||
|
||||
def price_quant(symbol):
|
||||
return Decimal("0.001") if price_precision(symbol) == 3 else Decimal("0.01")
|
||||
|
||||
|
||||
def round_price(symbol, value):
|
||||
return decimal_value(value).quantize(price_quant(symbol), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
class Position(object):
|
||||
def __init__(self, symbol, quantity=0, available=None, today_buy=0, avg_cost=0, realized_pnl=0):
|
||||
self.symbol = normalize_symbol(symbol)
|
||||
self.quantity = int(quantity or 0)
|
||||
self.available = self.quantity if available is None else int(available or 0)
|
||||
self.today_buy = int(today_buy or 0)
|
||||
self.avg_cost = decimal_value(avg_cost)
|
||||
self.realized_pnl = decimal_value(realized_pnl)
|
||||
|
||||
def to_dict(self, mark_price=None):
|
||||
market_value = None if mark_price is None else money(decimal_value(mark_price) * self.quantity)
|
||||
return {
|
||||
"symbol": self.symbol,
|
||||
"quantity": self.quantity,
|
||||
"available": self.available,
|
||||
"today_buy": self.today_buy,
|
||||
"avg_cost": json_number(self.avg_cost, 6),
|
||||
"realized_pnl": json_number(self.realized_pnl, 2),
|
||||
"mark_price": json_number(mark_price, 6),
|
||||
"market_value": json_number(market_value, 2),
|
||||
}
|
||||
|
||||
|
||||
class BacktestOrder(object):
|
||||
def __init__(
|
||||
self,
|
||||
order_id,
|
||||
client_order_id,
|
||||
symbol,
|
||||
side,
|
||||
quantity,
|
||||
order_type,
|
||||
limit_price,
|
||||
submitted_index,
|
||||
submitted_at,
|
||||
time_in_force="NEXT_BAR",
|
||||
):
|
||||
self.order_id = str(order_id)
|
||||
self.client_order_id = str(client_order_id or "")
|
||||
self.symbol = normalize_symbol(symbol)
|
||||
self.side = str(side).upper()
|
||||
self.quantity = int(quantity)
|
||||
self.filled_quantity = 0
|
||||
self.order_type = str(order_type).upper()
|
||||
self.limit_price = decimal_value(limit_price) if limit_price not in (None, "") else None
|
||||
self.submitted_index = int(submitted_index)
|
||||
self.submitted_at = str(submitted_at)
|
||||
self.time_in_force = str(time_in_force or "NEXT_BAR").upper()
|
||||
self.status = "PENDING"
|
||||
self.reject_reason = ""
|
||||
self.last_attempt_index = None
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
return max(self.quantity - self.filled_quantity, 0)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"order_id": self.order_id,
|
||||
"client_order_id": self.client_order_id,
|
||||
"symbol": self.symbol,
|
||||
"side": self.side,
|
||||
"quantity": self.quantity,
|
||||
"filled_quantity": self.filled_quantity,
|
||||
"remaining_quantity": self.remaining,
|
||||
"order_type": self.order_type,
|
||||
"limit_price": json_number(self.limit_price, 6),
|
||||
"submitted_index": self.submitted_index,
|
||||
"submitted_at": self.submitted_at,
|
||||
"time_in_force": self.time_in_force,
|
||||
"status": self.status,
|
||||
"reject_reason": self.reject_reason,
|
||||
}
|
||||
|
||||
|
||||
class BacktestFill(object):
|
||||
def __init__(
|
||||
self,
|
||||
fill_id,
|
||||
order,
|
||||
quantity,
|
||||
price,
|
||||
commission,
|
||||
stamp_tax,
|
||||
transfer_fee,
|
||||
filled_index,
|
||||
filled_at,
|
||||
):
|
||||
self.fill_id = str(fill_id)
|
||||
self.order_id = order.order_id
|
||||
self.client_order_id = order.client_order_id
|
||||
self.symbol = order.symbol
|
||||
self.side = order.side
|
||||
self.quantity = int(quantity)
|
||||
self.price = decimal_value(price)
|
||||
self.amount = money(self.price * self.quantity)
|
||||
self.commission = money(commission)
|
||||
self.stamp_tax = money(stamp_tax)
|
||||
self.transfer_fee = money(transfer_fee)
|
||||
self.total_fee = money(self.commission + self.stamp_tax + self.transfer_fee)
|
||||
self.filled_index = int(filled_index)
|
||||
self.filled_at = str(filled_at)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"fill_id": self.fill_id,
|
||||
"order_id": self.order_id,
|
||||
"client_order_id": self.client_order_id,
|
||||
"symbol": self.symbol,
|
||||
"side": self.side,
|
||||
"quantity": self.quantity,
|
||||
"price": json_number(self.price, 6),
|
||||
"amount": json_number(self.amount, 2),
|
||||
"commission": json_number(self.commission, 2),
|
||||
"stamp_tax": json_number(self.stamp_tax, 2),
|
||||
"transfer_fee": json_number(self.transfer_fee, 2),
|
||||
"total_fee": json_number(self.total_fee, 2),
|
||||
"filled_index": self.filled_index,
|
||||
"filled_at": self.filled_at,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Versioned request/response protocol for the backtest-only ZMQ bridge."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class BacktestBridgeProtocol(object):
|
||||
def __init__(self, engine, request_cache_size=10000):
|
||||
self.engine = engine
|
||||
self.request_cache_size = int(request_cache_size)
|
||||
self.client_id = None
|
||||
self._responses = {}
|
||||
self._request_fingerprints = {}
|
||||
self._response_order = []
|
||||
|
||||
def _response(self, request, ok, data=None, error=""):
|
||||
execution_backend = str(getattr(self.engine, "execution_backend", "LOCAL_SIM"))
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"request_id": str(request.get("request_id") or ""),
|
||||
"run_id": self.engine.config.run_id,
|
||||
"client_id": str(request.get("client_id") or ""),
|
||||
"method": str(request.get("method") or ""),
|
||||
"ok": bool(ok),
|
||||
"data": data,
|
||||
"error": str(error or ""),
|
||||
"execution_mode": "QMT_BACKTEST" if execution_backend == "QMT_NATIVE" else "BACKTEST",
|
||||
"execution_backend": execution_backend,
|
||||
"live_ready": False,
|
||||
"handled_at": dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(request):
|
||||
return json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
def _remember(self, request_id, request, response):
|
||||
self._responses[request_id] = response
|
||||
self._request_fingerprints[request_id] = self._fingerprint(request)
|
||||
self._response_order.append(request_id)
|
||||
while len(self._response_order) > self.request_cache_size:
|
||||
oldest = self._response_order.pop(0)
|
||||
self._responses.pop(oldest, None)
|
||||
self._request_fingerprints.pop(oldest, None)
|
||||
|
||||
def _validate(self, request):
|
||||
if not isinstance(request, dict):
|
||||
raise ValueError("request must be a JSON object")
|
||||
if int(request.get("schema_version") or 0) != SCHEMA_VERSION:
|
||||
raise ValueError("unsupported schema_version")
|
||||
if not str(request.get("request_id") or ""):
|
||||
raise ValueError("request_id is required")
|
||||
method = str(request.get("method") or "").lower()
|
||||
requested_run_id = str(request.get("run_id") or "")
|
||||
discovery = method in ("ping", "describe") and not requested_run_id
|
||||
if not discovery and requested_run_id != self.engine.config.run_id:
|
||||
raise ValueError("run_id mismatch")
|
||||
if not str(request.get("client_id") or ""):
|
||||
raise ValueError("client_id is required")
|
||||
if not str(request.get("method") or ""):
|
||||
raise ValueError("method is required")
|
||||
|
||||
def _claim_or_check_client(self, request):
|
||||
client_id = str(request["client_id"])
|
||||
method = str(request["method"]).lower()
|
||||
if self.client_id is None and method == "start":
|
||||
self.client_id = client_id
|
||||
if method not in ("ping", "describe") and self.client_id != client_id:
|
||||
raise PermissionError("run is owned by another client_id")
|
||||
|
||||
def _dispatch(self, request):
|
||||
method = str(request["method"]).lower()
|
||||
params = dict(request.get("params") or {})
|
||||
if method == "ping":
|
||||
return {
|
||||
"status": "ok",
|
||||
"started": self.engine.started,
|
||||
"finished": self.engine.finished,
|
||||
}
|
||||
if method == "describe":
|
||||
execution_backend = str(getattr(self.engine, "execution_backend", "LOCAL_SIM"))
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": self.engine.config.run_id,
|
||||
"engine_version": str(getattr(self.engine, "engine_version", "1.0.0")),
|
||||
"execution_backend": execution_backend,
|
||||
"engine_owner": "QMT" if execution_backend == "QMT_NATIVE" else "LOCAL",
|
||||
"matching_owner": "QMT" if execution_backend == "QMT_NATIVE" else "LOCAL",
|
||||
"methods": [
|
||||
"ping", "describe", "start", "next_bar", "submit_order", "cancel_order",
|
||||
"state", "history", "orders", "fills", "finish",
|
||||
],
|
||||
"fill_timing": str(getattr(self.engine, "fill_timing", "next_symbol_bar")),
|
||||
"live_ready": False,
|
||||
}
|
||||
if method == "start":
|
||||
return self.engine.start()
|
||||
if method == "next_bar":
|
||||
return self.engine.next_bar()
|
||||
if method == "submit_order":
|
||||
return self.engine.submit_order(params)
|
||||
if method == "cancel_order":
|
||||
return self.engine.cancel_order(params.get("order_id"))
|
||||
if method == "state":
|
||||
return self.engine.state()
|
||||
if method == "history":
|
||||
return self.engine.history(
|
||||
params.get("symbol"),
|
||||
count=params.get("count", 100),
|
||||
fields=params.get("fields"),
|
||||
)
|
||||
if method == "orders":
|
||||
return self.engine.orders()
|
||||
if method == "fills":
|
||||
return self.engine.fills()
|
||||
if method == "finish":
|
||||
return self.engine.finish()
|
||||
raise ValueError("unsupported method: %s" % method)
|
||||
|
||||
def handle(self, request):
|
||||
request_id = str((request or {}).get("request_id") or "")
|
||||
if request_id and request_id in self._responses:
|
||||
if self._request_fingerprints.get(request_id) != self._fingerprint(request):
|
||||
return self._response(request, False, None, "request_id reused with different payload")
|
||||
return self._responses[request_id]
|
||||
try:
|
||||
self._validate(request)
|
||||
self._claim_or_check_client(request)
|
||||
response = self._response(request, True, self._dispatch(request))
|
||||
except Exception as exc:
|
||||
response = self._response(request or {}, False, None, "%s: %s" % (exc.__class__.__name__, exc))
|
||||
if request_id:
|
||||
self._remember(request_id, request, response)
|
||||
return response
|
||||
@@ -0,0 +1,718 @@
|
||||
"""QMT-native backtest service exposed to external strategies over ZMQ.
|
||||
|
||||
QMT remains the only backtest engine and matching system. The ZMQ listener
|
||||
thread only queues commands; every QMT API call is executed by ``handlebar`` on
|
||||
QMT's callback thread.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from .data_feed import StreamingBarFeed, parse_datetime
|
||||
from .models import normalize_symbol
|
||||
from .protocol import BacktestBridgeProtocol
|
||||
from .zmq_server import ZmqBacktestServer
|
||||
|
||||
|
||||
_CONFIG = {}
|
||||
_QMT_API = {}
|
||||
_RUNTIME = None
|
||||
|
||||
|
||||
def configure(**kwargs):
|
||||
_CONFIG.update(kwargs)
|
||||
|
||||
|
||||
def bind_qmt_api(passorder_func=None, cancel_func=None, get_trade_detail_data_func=None):
|
||||
if passorder_func is not None:
|
||||
_QMT_API["passorder"] = passorder_func
|
||||
if cancel_func is not None:
|
||||
_QMT_API["cancel"] = cancel_func
|
||||
if get_trade_detail_data_func is not None:
|
||||
_QMT_API["get_trade_detail_data"] = get_trade_detail_data_func
|
||||
|
||||
|
||||
def _sequence(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
for item in value.values():
|
||||
result = _sequence(item)
|
||||
if result:
|
||||
return result
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
if hasattr(value, "tolist"):
|
||||
try:
|
||||
result = value.tolist()
|
||||
return result if isinstance(result, list) else [result]
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "values"):
|
||||
try:
|
||||
return list(value.values)
|
||||
except Exception:
|
||||
pass
|
||||
return [value]
|
||||
|
||||
|
||||
def _last_value(value):
|
||||
values = _sequence(value)
|
||||
return values[-1] if values else None
|
||||
|
||||
|
||||
def _attr(value, names, default=None):
|
||||
for name in names:
|
||||
if isinstance(value, dict) and name in value:
|
||||
result = value.get(name)
|
||||
else:
|
||||
result = getattr(value, name, None)
|
||||
if result is not None:
|
||||
return result
|
||||
return default
|
||||
|
||||
|
||||
def _json_number(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _full_symbol(row):
|
||||
code = str(_attr(row, ("m_strInstrumentID", "instrument_id", "stock_code", "symbol"), "") or "")
|
||||
market = str(_attr(row, ("m_strExchangeID", "exchange_id", "market"), "") or "").upper()
|
||||
if "." not in code and market in ("SH", "SZ", "BJ"):
|
||||
code = code + "." + market
|
||||
return normalize_symbol(code) if code else ""
|
||||
|
||||
|
||||
def _side_from_offset(value):
|
||||
try:
|
||||
return "BUY" if int(value or 0) == 48 else "SELL"
|
||||
except (TypeError, ValueError):
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def _is_qmt_backtest(context):
|
||||
value = getattr(context, "do_back_test", None)
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except Exception:
|
||||
value = None
|
||||
if bool(value):
|
||||
return True
|
||||
for name in ("is_backtest", "is_back_test", "backtest"):
|
||||
value = getattr(context, name, None)
|
||||
if callable(value):
|
||||
try:
|
||||
value = value()
|
||||
except Exception:
|
||||
value = None
|
||||
if bool(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class QmtBarExtractor(object):
|
||||
def __init__(self):
|
||||
self.previous_close = {}
|
||||
|
||||
@staticmethod
|
||||
def _symbol(context):
|
||||
raw = ""
|
||||
for name in ("stock", "symbol", "stockcode"):
|
||||
value = getattr(context, name, None)
|
||||
if value:
|
||||
raw = str(value)
|
||||
break
|
||||
if not raw:
|
||||
raise ValueError("QMT ContextInfo has no stock symbol")
|
||||
if "." not in raw:
|
||||
market = str(getattr(context, "market", "") or "").upper()
|
||||
if market in ("SH", "SZ", "BJ"):
|
||||
raw = raw + "." + market
|
||||
return normalize_symbol(raw)
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(context):
|
||||
barpos = getattr(context, "barpos", getattr(context, "bar_index", None))
|
||||
getter = getattr(context, "get_bar_timetag", None)
|
||||
if callable(getter) and barpos is not None:
|
||||
return parse_datetime(getter(barpos)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
for name in ("bar_time", "datetime", "timestamp"):
|
||||
value = getattr(context, name, None)
|
||||
if value not in (None, ""):
|
||||
return parse_datetime(value).strftime("%Y-%m-%d %H:%M:%S")
|
||||
raise ValueError("QMT ContextInfo has no deterministic bar timestamp")
|
||||
|
||||
@staticmethod
|
||||
def _periods(context):
|
||||
result = []
|
||||
for value in (getattr(context, "period", None), "1m", "1d"):
|
||||
text = str(value or "").strip()
|
||||
if text and text not in result:
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
def _history_value(self, context, field):
|
||||
getter = getattr(context, "get_history_data", None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
for period in self._periods(context):
|
||||
for call in (
|
||||
lambda period=period: getter(1, period, field),
|
||||
lambda period=period: getter(field, 1, period),
|
||||
lambda: getter(field, 1),
|
||||
):
|
||||
try:
|
||||
value = _last_value(call())
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _field(self, context, field, aliases=()):
|
||||
for name in (field,) + tuple(aliases):
|
||||
value = _last_value(getattr(context, name, None))
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
for name in (field,) + tuple(aliases):
|
||||
value = self._history_value(context, name)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def extract(self, context):
|
||||
symbol = self._symbol(context)
|
||||
close = self._field(context, "close")
|
||||
row = {
|
||||
"datetime": self._timestamp(context),
|
||||
"symbol": symbol,
|
||||
"open": self._field(context, "open"),
|
||||
"high": self._field(context, "high"),
|
||||
"low": self._field(context, "low"),
|
||||
"close": close,
|
||||
"volume": self._field(context, "volume", ("vol",)) or 0,
|
||||
"amount": self._field(context, "amount") or 0,
|
||||
"prev_close": self._field(context, "prev_close", ("preClose", "lastClose")),
|
||||
}
|
||||
if row["prev_close"] in (None, ""):
|
||||
row["prev_close"] = self.previous_close.get(symbol)
|
||||
self.previous_close[symbol] = close
|
||||
return row
|
||||
|
||||
|
||||
class NativeSessionConfig(object):
|
||||
def __init__(self, run_id, strategy_name, account_id):
|
||||
self.run_id = str(run_id)
|
||||
self.strategy_name = str(strategy_name)
|
||||
self.account_id = str(account_id)
|
||||
|
||||
|
||||
class QmtNativeBacktestSession(object):
|
||||
"""Engine-shaped adapter whose actual engine and broker are both QMT."""
|
||||
|
||||
engine_version = "qmt-native-1.0.0"
|
||||
execution_backend = "QMT_NATIVE"
|
||||
fill_timing = "qmt_native_matching"
|
||||
|
||||
def __init__(self, config=None, qmt_api=None):
|
||||
options = dict(config or {})
|
||||
run_id = str(options.get("run_id") or ("qmt-native-" + dt.datetime.now().strftime("%Y%m%d-%H%M%S")))
|
||||
self.config = NativeSessionConfig(
|
||||
run_id=run_id,
|
||||
strategy_name=options.get("strategy_name") or "ZMQ_BACKTEST",
|
||||
account_id=options.get("account_id") or "",
|
||||
)
|
||||
self.account_type = str(options.get("account_type") or "STOCK")
|
||||
self.combo_type = int(options.get("combo_type") or 1101)
|
||||
self.quick_trade = int(options.get("quick_trade") if options.get("quick_trade") is not None else 2)
|
||||
self.market_price_type = int(options.get("market_price_type") or 5)
|
||||
self.limit_price_type = int(options.get("limit_price_type") or 11)
|
||||
self.bar_wait_timeout = float(options.get("bar_wait_timeout_seconds") or 60.0)
|
||||
self.require_backtest = bool(options.get("require_qmt_backtest", True))
|
||||
self.qmt_api = dict(qmt_api or {})
|
||||
self.feed = StreamingBarFeed(source="qmt_native_backtest")
|
||||
self.extractor = QmtBarExtractor()
|
||||
self.created_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.started = False
|
||||
self.finished = False
|
||||
self.qmt_completed = False
|
||||
self.current_index = -1
|
||||
self.current_frame = None
|
||||
self._released_index = -1
|
||||
self._condition = threading.Condition()
|
||||
self._pending_commands = []
|
||||
self._orders = {}
|
||||
self._fills = {}
|
||||
self._current_frame_fills = []
|
||||
self._published_fill_keys = set()
|
||||
self._positions = {}
|
||||
self._asset = {"cash": None, "total_asset": None}
|
||||
self._context = None
|
||||
self._failure = ""
|
||||
|
||||
def bind_context(self, context):
|
||||
if self.require_backtest and not _is_qmt_backtest(context):
|
||||
raise RuntimeError("QMT native bridge refused to run outside QMT backtest mode")
|
||||
if not self.config.account_id:
|
||||
raise RuntimeError("account_id is required for the QMT native backtest bridge")
|
||||
self._context = context
|
||||
if self.config.account_id and hasattr(context, "set_account"):
|
||||
context.set_account(self.config.account_id)
|
||||
|
||||
def _require_api(self, name):
|
||||
func = self.qmt_api.get(name)
|
||||
if func is None:
|
||||
raise RuntimeError("QMT runtime API is unavailable: %s" % name)
|
||||
return func
|
||||
|
||||
def _require_started(self):
|
||||
if not self.started:
|
||||
raise RuntimeError("external strategy has not attached")
|
||||
if self.finished:
|
||||
raise RuntimeError("external strategy session is already finished")
|
||||
|
||||
def start(self):
|
||||
with self._condition:
|
||||
if not self.started:
|
||||
self.started = True
|
||||
self._condition.notify_all()
|
||||
if self.current_index < 0 and not self.qmt_completed:
|
||||
ready = self._condition.wait_for(
|
||||
lambda: self.current_index >= 0 or self.qmt_completed or bool(self._failure),
|
||||
timeout=self.bar_wait_timeout,
|
||||
)
|
||||
if not ready:
|
||||
raise TimeoutError("timed out waiting for QMT's first backtest bar")
|
||||
if self._failure:
|
||||
raise RuntimeError(self._failure)
|
||||
return self._state_unlocked()
|
||||
|
||||
def _queue_command(self, command):
|
||||
with self._condition:
|
||||
self._require_started()
|
||||
if self.qmt_completed or self.current_index < 0:
|
||||
raise RuntimeError("QMT backtest has no active bar")
|
||||
if self._released_index >= self.current_index:
|
||||
raise RuntimeError("current QMT bar has already been released")
|
||||
command = dict(command)
|
||||
command["frame_index"] = self.current_index
|
||||
self._pending_commands.append(command)
|
||||
return command
|
||||
|
||||
def submit_order(self, payload):
|
||||
payload = dict(payload or {})
|
||||
side = str(payload.get("side") or "").upper()
|
||||
if side not in ("BUY", "SELL"):
|
||||
raise ValueError("side must be BUY or SELL")
|
||||
quantity = int(payload.get("quantity") or 0)
|
||||
if quantity <= 0:
|
||||
raise ValueError("quantity must be positive")
|
||||
symbol = normalize_symbol(payload.get("symbol"))
|
||||
order_type = str(payload.get("order_type") or "MARKET").upper()
|
||||
if order_type not in ("MARKET", "LIMIT"):
|
||||
raise ValueError("order_type must be MARKET or LIMIT")
|
||||
limit_price = payload.get("limit_price")
|
||||
if order_type == "LIMIT" and limit_price in (None, ""):
|
||||
raise ValueError("limit_price is required for LIMIT order")
|
||||
client_order_id = str(payload.get("client_order_id") or ("zmq:" + uuid.uuid4().hex[:20]))
|
||||
record = {
|
||||
"order_id": client_order_id,
|
||||
"client_order_id": client_order_id,
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"filled_quantity": 0,
|
||||
"order_type": order_type,
|
||||
"limit_price": None if limit_price in (None, "") else float(limit_price),
|
||||
"status": "QUEUED",
|
||||
"reject_reason": "",
|
||||
"submitted_index": self.current_index,
|
||||
"submitted_at": (self.current_frame or {}).get("datetime", ""),
|
||||
"execution_backend": self.execution_backend,
|
||||
}
|
||||
with self._condition:
|
||||
self._orders[client_order_id] = record
|
||||
self._queue_command({"kind": "submit", "client_order_id": client_order_id})
|
||||
return dict(record)
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
order_id = str(order_id or "").strip()
|
||||
if not order_id:
|
||||
raise ValueError("order_id is required")
|
||||
command = self._queue_command({"kind": "cancel", "order_id": order_id})
|
||||
return {"order_id": order_id, "status": "CANCEL_QUEUED", "frame_index": command["frame_index"]}
|
||||
|
||||
def _execute_submit(self, command, context):
|
||||
client_order_id = command["client_order_id"]
|
||||
with self._condition:
|
||||
record = dict(self._orders[client_order_id])
|
||||
if not self.config.account_id:
|
||||
raise RuntimeError("account_id is required for QMT native passorder")
|
||||
passorder = self._require_api("passorder")
|
||||
side = record["side"]
|
||||
order_type = record["order_type"]
|
||||
price_type = self.limit_price_type if order_type == "LIMIT" else self.market_price_type
|
||||
price = float(record["limit_price"] or 0)
|
||||
result = passorder(
|
||||
23 if side == "BUY" else 24,
|
||||
self.combo_type,
|
||||
self.config.account_id,
|
||||
record["symbol"],
|
||||
price_type,
|
||||
price,
|
||||
int(record["quantity"]),
|
||||
self.config.strategy_name,
|
||||
self.quick_trade,
|
||||
client_order_id,
|
||||
context,
|
||||
)
|
||||
with self._condition:
|
||||
target = self._orders[client_order_id]
|
||||
target["status"] = "SUBMITTED"
|
||||
if result not in (None, ""):
|
||||
target["qmt_order_id"] = str(result)
|
||||
|
||||
def _execute_cancel(self, command, context):
|
||||
cancel = self._require_api("cancel")
|
||||
order_id = command["order_id"]
|
||||
with self._condition:
|
||||
record = self._orders.get(order_id)
|
||||
qmt_order_id = (
|
||||
(record or {}).get("qmt_order_id")
|
||||
or (record or {}).get("order_id")
|
||||
or order_id
|
||||
)
|
||||
result = cancel(qmt_order_id, self.config.account_id, self.account_type, context)
|
||||
with self._condition:
|
||||
record = self._orders.get(order_id)
|
||||
if record is not None:
|
||||
record["status"] = "CANCEL_SUBMITTED" if result is not False else "CANCEL_REJECTED"
|
||||
|
||||
def _execute_commands(self, commands, context):
|
||||
for command in commands:
|
||||
try:
|
||||
if command["kind"] == "submit":
|
||||
self._execute_submit(command, context)
|
||||
elif command["kind"] == "cancel":
|
||||
self._execute_cancel(command, context)
|
||||
except Exception as exc:
|
||||
key = command.get("client_order_id") or command.get("order_id")
|
||||
with self._condition:
|
||||
record = self._orders.get(key)
|
||||
if record is not None:
|
||||
record["status"] = "REJECTED"
|
||||
record["reject_reason"] = "%s: %s" % (exc.__class__.__name__, exc)
|
||||
print("[bigqmt_backtest] QMT command failed kind=%s error=%s" % (command.get("kind"), exc))
|
||||
|
||||
def on_bar(self, context):
|
||||
self.bind_context(context)
|
||||
self._refresh_qmt_state()
|
||||
row = self.extractor.extract(context)
|
||||
appended = self.feed.append(row)
|
||||
if not appended:
|
||||
return False
|
||||
with self._condition:
|
||||
self.current_index = len(self.feed) - 1
|
||||
self.current_frame = self.feed.frame(self.current_index)
|
||||
new_fill_keys = [key for key in self._fills if key not in self._published_fill_keys]
|
||||
self._current_frame_fills = [dict(self._fills[key]) for key in new_fill_keys]
|
||||
self._published_fill_keys.update(new_fill_keys)
|
||||
index = self.current_index
|
||||
self._condition.notify_all()
|
||||
released = self._condition.wait_for(
|
||||
lambda: self._released_index >= index or self.finished or bool(self._failure),
|
||||
timeout=self.bar_wait_timeout,
|
||||
)
|
||||
if not released:
|
||||
self._failure = "external strategy timed out on QMT bar index %d" % index
|
||||
self._condition.notify_all()
|
||||
raise TimeoutError(self._failure)
|
||||
commands = [item for item in self._pending_commands if item.get("frame_index") == index]
|
||||
self._pending_commands = [item for item in self._pending_commands if item.get("frame_index") != index]
|
||||
self._execute_commands(commands, context)
|
||||
self._refresh_qmt_state()
|
||||
print(
|
||||
"[bigqmt_backtest] QMT native bar released index=%d datetime=%s symbol=%s commands=%d"
|
||||
% (index, row["datetime"], row["symbol"], len(commands))
|
||||
)
|
||||
return True
|
||||
|
||||
def next_bar(self):
|
||||
with self._condition:
|
||||
self._require_started()
|
||||
previous = self.current_index
|
||||
if self.qmt_completed:
|
||||
return self._state_unlocked()
|
||||
self._released_index = max(self._released_index, previous)
|
||||
self._condition.notify_all()
|
||||
ready = self._condition.wait_for(
|
||||
lambda: self.current_index > previous or self.qmt_completed or bool(self._failure),
|
||||
timeout=self.bar_wait_timeout,
|
||||
)
|
||||
if not ready:
|
||||
raise TimeoutError("timed out waiting for QMT backtest bar after index %d" % previous)
|
||||
if self._failure:
|
||||
raise RuntimeError(self._failure)
|
||||
return self._state_unlocked()
|
||||
|
||||
def history(self, symbol, count=100, fields=None):
|
||||
with self._condition:
|
||||
self._require_started()
|
||||
end_index = self.current_index
|
||||
return self.feed.history(symbol, end_index, count=count, fields=fields)
|
||||
|
||||
def orders(self):
|
||||
with self._condition:
|
||||
return [dict(value) for value in self._orders.values()]
|
||||
|
||||
def fills(self):
|
||||
with self._condition:
|
||||
return [dict(value) for value in self._fills.values()]
|
||||
|
||||
def _state_unlocked(self):
|
||||
if self.current_frame is None:
|
||||
return {
|
||||
"run_id": self.config.run_id,
|
||||
"started": self.started,
|
||||
"finished": self.finished,
|
||||
"done": self.qmt_completed,
|
||||
"frame_index": -1,
|
||||
"frame_count": len(self.feed),
|
||||
"execution_backend": self.execution_backend,
|
||||
}
|
||||
return {
|
||||
"run_id": self.config.run_id,
|
||||
"started": self.started,
|
||||
"finished": self.finished,
|
||||
"done": self.qmt_completed,
|
||||
"frame_index": self.current_index,
|
||||
"frame_count": len(self.feed),
|
||||
"datetime": self.current_frame["datetime"],
|
||||
"bars": {key: dict(value) for key, value in self.current_frame["bars"].items()},
|
||||
"fills": [dict(value) for value in self._current_frame_fills],
|
||||
"cash": self._asset.get("cash"),
|
||||
"total_asset": self._asset.get("total_asset"),
|
||||
"positions": {key: dict(value) for key, value in self._positions.items()},
|
||||
"execution_backend": self.execution_backend,
|
||||
"qmt_completed": self.qmt_completed,
|
||||
"failure": self._failure,
|
||||
}
|
||||
|
||||
def state(self):
|
||||
with self._condition:
|
||||
return self._state_unlocked()
|
||||
|
||||
def finish(self):
|
||||
with self._condition:
|
||||
if self.finished:
|
||||
return self._result_unlocked()
|
||||
self._released_index = max(self._released_index, self.current_index)
|
||||
self.finished = True
|
||||
self._condition.notify_all()
|
||||
return self._result_unlocked()
|
||||
|
||||
def _result_unlocked(self):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"engine_version": self.engine_version,
|
||||
"run_id": self.config.run_id,
|
||||
"strategy_name": self.config.strategy_name,
|
||||
"execution_backend": self.execution_backend,
|
||||
"qmt_completed": self.qmt_completed,
|
||||
"order_count": len(self._orders),
|
||||
"fill_count": len(self._fills),
|
||||
"final_state": self._state_unlocked(),
|
||||
"result_owner": "QMT",
|
||||
}
|
||||
|
||||
def on_qmt_stop(self):
|
||||
self.feed.close()
|
||||
with self._condition:
|
||||
new_fill_keys = [key for key in self._fills if key not in self._published_fill_keys]
|
||||
self._current_frame_fills = [dict(self._fills[key]) for key in new_fill_keys]
|
||||
self._published_fill_keys.update(new_fill_keys)
|
||||
self.qmt_completed = True
|
||||
self._condition.notify_all()
|
||||
print("[bigqmt_backtest] QMT native backtest completed bars=%d" % len(self.feed))
|
||||
|
||||
def on_order(self, order):
|
||||
item = {
|
||||
"order_id": str(_attr(order, ("m_strOrderSysID", "order_sys_id", "order_id"), "") or ""),
|
||||
"client_order_id": str(_attr(order, ("m_strRemark", "remark", "user_order_id"), "") or ""),
|
||||
"symbol": _full_symbol(order),
|
||||
"side": _side_from_offset(_attr(order, ("m_nOffsetFlag", "offset_flag"), 0)),
|
||||
"quantity": int(_attr(order, ("m_nVolumeTotalOriginal", "volume", "quantity"), 0) or 0),
|
||||
"filled_quantity": int(_attr(order, ("m_nVolumeTraded", "traded_volume", "filled_quantity"), 0) or 0),
|
||||
"price": _json_number(_attr(order, ("m_dLimitPrice", "m_dPrice", "price"))),
|
||||
"status": str(_attr(order, ("m_nOrderStatus", "status"), "") or ""),
|
||||
}
|
||||
key = item["client_order_id"] or item["order_id"] or ("order:" + uuid.uuid4().hex)
|
||||
with self._condition:
|
||||
existing = self._orders.get(key, {})
|
||||
existing.update(item)
|
||||
self._orders[key] = existing
|
||||
self._condition.notify_all()
|
||||
return dict(existing)
|
||||
|
||||
def on_trade(self, trade):
|
||||
item = {
|
||||
"fill_id": str(_attr(trade, ("m_strTradeID", "trade_id", "fill_id"), "") or ""),
|
||||
"order_id": str(_attr(trade, ("m_strOrderSysID", "order_sys_id", "order_id"), "") or ""),
|
||||
"client_order_id": str(_attr(trade, ("m_strRemark", "remark", "user_order_id"), "") or ""),
|
||||
"symbol": _full_symbol(trade),
|
||||
"side": _side_from_offset(_attr(trade, ("m_nOffsetFlag", "offset_flag"), 0)),
|
||||
"quantity": int(_attr(trade, ("m_nVolume", "volume", "quantity"), 0) or 0),
|
||||
"price": _json_number(_attr(trade, ("m_dPrice", "m_dTradePrice", "price"))),
|
||||
"filled_at": str(_attr(trade, ("m_strTradeTime", "trade_time", "filled_at"), "") or ""),
|
||||
}
|
||||
key = item["fill_id"] or "%s:%s:%s" % (item["order_id"], item["quantity"], item["price"])
|
||||
with self._condition:
|
||||
self._fills[key] = item
|
||||
self._condition.notify_all()
|
||||
return dict(item)
|
||||
|
||||
def _query(self, detail_type):
|
||||
query = self.qmt_api.get("get_trade_detail_data")
|
||||
if query is None or not self.config.account_id:
|
||||
return []
|
||||
calls = []
|
||||
if detail_type in ("ORDER", "DEAL", "TRADE"):
|
||||
calls.append(lambda: query(
|
||||
self.config.account_id, self.account_type, detail_type, self.config.strategy_name
|
||||
))
|
||||
calls.append(lambda: query(self.config.account_id, self.account_type, detail_type))
|
||||
last_error = None
|
||||
for call in calls:
|
||||
try:
|
||||
return list(call() or [])
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
print("[bigqmt_backtest] QMT query failed type=%s error=%s" % (detail_type, last_error))
|
||||
return []
|
||||
|
||||
def _refresh_qmt_state(self):
|
||||
positions = {}
|
||||
for row in self._query("POSITION"):
|
||||
symbol = _full_symbol(row)
|
||||
if not symbol:
|
||||
continue
|
||||
positions[symbol] = {
|
||||
"symbol": symbol,
|
||||
"quantity": int(_attr(row, ("m_nVolume", "volume", "quantity"), 0) or 0),
|
||||
"available": int(_attr(row, ("m_nCanUseVolume", "available", "can_use_volume"), 0) or 0),
|
||||
"avg_cost": _json_number(_attr(row, ("m_dOpenPrice", "m_dCostPrice", "cost", "avg_cost"))),
|
||||
}
|
||||
asset_rows = self._query("ACCOUNT") or self._query("ASSET")
|
||||
asset = {"cash": None, "total_asset": None}
|
||||
if asset_rows:
|
||||
row = asset_rows[0]
|
||||
asset = {
|
||||
"cash": _json_number(_attr(row, ("m_dAvailable", "m_dAvailableCash", "available_cash", "cash"))),
|
||||
"total_asset": _json_number(_attr(row, ("m_dBalance", "m_dAsset", "total_asset", "asset"))),
|
||||
}
|
||||
order_rows = self._query("ORDER")
|
||||
trade_rows = self._query("DEAL") or self._query("TRADE")
|
||||
with self._condition:
|
||||
self._positions = positions
|
||||
self._asset = asset
|
||||
for row in order_rows:
|
||||
self.on_order(row)
|
||||
for row in trade_rows:
|
||||
self.on_trade(row)
|
||||
|
||||
|
||||
class QmtBacktestBridgeRuntime(object):
|
||||
def __init__(self, config=None, qmt_api=None):
|
||||
config = dict(config or {})
|
||||
bind_endpoint = str(config.pop("bind_endpoint", "tcp://127.0.0.1:16662"))
|
||||
self.engine = QmtNativeBacktestSession(config=config, qmt_api=qmt_api)
|
||||
self.protocol = BacktestBridgeProtocol(self.engine)
|
||||
self.server = ZmqBacktestServer(self.protocol, endpoint=bind_endpoint, exit_on_finish=True)
|
||||
self.server_thread = None
|
||||
|
||||
def start(self, context):
|
||||
if self.server_thread is not None:
|
||||
return
|
||||
self.engine.bind_context(context)
|
||||
self.server_thread = threading.Thread(
|
||||
target=self.server.serve_forever,
|
||||
name="bigqmt-native-backtest-zmq",
|
||||
daemon=True,
|
||||
)
|
||||
self.server_thread.start()
|
||||
if not self.server.wait_until_ready(5.0) or not self.server.actual_endpoint:
|
||||
raise RuntimeError("QMT native backtest ZMQ service failed to bind")
|
||||
print(
|
||||
"[bigqmt_backtest] QMT native service started run_id=%s endpoint=%s account=%s live_ready=False"
|
||||
% (self.engine.config.run_id, self.server.actual_endpoint, self.engine.config.account_id)
|
||||
)
|
||||
|
||||
def on_bar(self, context):
|
||||
return self.engine.on_bar(context)
|
||||
|
||||
def on_order(self, order):
|
||||
return self.engine.on_order(order)
|
||||
|
||||
def on_trade(self, trade):
|
||||
return self.engine.on_trade(trade)
|
||||
|
||||
def on_qmt_stop(self):
|
||||
self.engine.on_qmt_stop()
|
||||
|
||||
def stop_server(self):
|
||||
self.server.stop()
|
||||
|
||||
|
||||
def reset_runtime():
|
||||
global _RUNTIME
|
||||
if _RUNTIME is not None:
|
||||
_RUNTIME.stop_server()
|
||||
_RUNTIME = None
|
||||
|
||||
|
||||
def get_runtime():
|
||||
return _RUNTIME
|
||||
|
||||
|
||||
def init(ContextInfo):
|
||||
global _RUNTIME
|
||||
reset_runtime()
|
||||
_RUNTIME = QmtBacktestBridgeRuntime(_CONFIG, _QMT_API)
|
||||
_RUNTIME.start(ContextInfo)
|
||||
return _RUNTIME
|
||||
|
||||
|
||||
def handlebar(ContextInfo):
|
||||
if _RUNTIME is None:
|
||||
init(ContextInfo)
|
||||
return _RUNTIME.on_bar(ContextInfo)
|
||||
|
||||
|
||||
def order_callback(ContextInfo, orderInfo):
|
||||
if _RUNTIME is not None:
|
||||
return _RUNTIME.on_order(orderInfo)
|
||||
return None
|
||||
|
||||
|
||||
def deal_callback(ContextInfo, dealInfo):
|
||||
if _RUNTIME is not None:
|
||||
return _RUNTIME.on_trade(dealInfo)
|
||||
return None
|
||||
|
||||
|
||||
def stop(ContextInfo=None):
|
||||
if _RUNTIME is not None:
|
||||
_RUNTIME.on_qmt_stop()
|
||||
|
||||
|
||||
def after_backtest(ContextInfo=None):
|
||||
return stop(ContextInfo)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Command-line entry for the standalone ZMQ backtest bridge."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .data_feed import CsvBarFeed
|
||||
from .engine import BacktestConfig, BacktestEngine
|
||||
from .protocol import BacktestBridgeProtocol
|
||||
from .zmq_server import ZmqBacktestServer
|
||||
|
||||
|
||||
def _load_config(path):
|
||||
if not path:
|
||||
return {}
|
||||
with open(os.path.abspath(path), encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("config JSON must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _parser():
|
||||
parser = argparse.ArgumentParser(description="Standalone ZMQ backtest bridge")
|
||||
parser.add_argument("--data", required=True, help="UTF-8 CSV historical bar file")
|
||||
parser.add_argument("--config", default="", help="Optional UTF-8 JSON backtest config")
|
||||
parser.add_argument("--run-id", default="", help="Unique run identity")
|
||||
parser.add_argument("--output-dir", default="", help="Artifact directory")
|
||||
parser.add_argument("--bind", default="tcp://127.0.0.1:16661", help="ZMQ REP bind endpoint")
|
||||
parser.add_argument("--default-symbol", default="", help="Used when CSV has no symbol column")
|
||||
parser.add_argument("--initial-cash", type=float, default=None)
|
||||
parser.add_argument("--slippage-bps", type=float, default=None)
|
||||
parser.add_argument("--max-volume-participation", type=float, default=None)
|
||||
parser.add_argument("--keep-running", action="store_true", help="Do not stop server after finish")
|
||||
return parser
|
||||
|
||||
|
||||
def build_engine(args):
|
||||
payload = _load_config(args.config)
|
||||
run_id = str(args.run_id or payload.pop("run_id", "") or ("bt-" + uuid.uuid4().hex[:12]))
|
||||
output_dir = args.output_dir or payload.pop("output_dir", "") or os.path.join("backtest_runs", run_id)
|
||||
if args.initial_cash is not None:
|
||||
payload["initial_cash"] = args.initial_cash
|
||||
if args.slippage_bps is not None:
|
||||
payload["slippage_bps"] = args.slippage_bps
|
||||
if args.max_volume_participation is not None:
|
||||
payload["max_volume_participation"] = args.max_volume_participation
|
||||
config = BacktestConfig(run_id=run_id, output_dir=output_dir, **payload)
|
||||
feed = CsvBarFeed(args.data, default_symbol=args.default_symbol)
|
||||
return BacktestEngine(feed, config)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = _parser().parse_args(argv)
|
||||
engine = build_engine(args)
|
||||
server = ZmqBacktestServer(
|
||||
BacktestBridgeProtocol(engine),
|
||||
endpoint=args.bind,
|
||||
exit_on_finish=not args.keep_running,
|
||||
)
|
||||
startup = {
|
||||
"event": "backtest_bridge_starting",
|
||||
"run_id": engine.config.run_id,
|
||||
"bind": args.bind,
|
||||
"data_hash": engine.feed.data_hash,
|
||||
"output_dir": engine.config.output_dir,
|
||||
"live_ready": False,
|
||||
}
|
||||
print(json.dumps(startup, ensure_ascii=False, sort_keys=True), flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Small external-strategy contract and a synchronous ZMQ runner."""
|
||||
|
||||
|
||||
class StrategyContext(object):
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.state = None
|
||||
|
||||
@property
|
||||
def now(self):
|
||||
return None if self.state is None else self.state.get("datetime")
|
||||
|
||||
@property
|
||||
def cash(self):
|
||||
return 0 if self.state is None else self.state.get("cash", 0)
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
return {} if self.state is None else self.state.get("positions", {})
|
||||
|
||||
def history(self, symbol, count=100, fields=None):
|
||||
return self.client.history(symbol, count=count, fields=fields)
|
||||
|
||||
|
||||
class ExternalStrategyRunner(object):
|
||||
"""Drive a user strategy without exposing future bars.
|
||||
|
||||
Strategy methods are optional:
|
||||
|
||||
* ``on_start(context)``
|
||||
* ``on_bar(context, bars) -> iterable[order dict]``
|
||||
* ``on_fill(context, fill)``
|
||||
* ``on_finish(context, result)``
|
||||
"""
|
||||
|
||||
def __init__(self, client, strategy):
|
||||
self.client = client
|
||||
self.strategy = strategy
|
||||
self.context = StrategyContext(client)
|
||||
|
||||
def _call(self, name, *args):
|
||||
callback = getattr(self.strategy, name, None)
|
||||
return callback(*args) if callback is not None else None
|
||||
|
||||
def _apply_orders(self, orders):
|
||||
for order in list(orders or []):
|
||||
payload = dict(order)
|
||||
self.client.submit_order(
|
||||
symbol=payload["symbol"],
|
||||
side=payload["side"],
|
||||
quantity=payload["quantity"],
|
||||
order_type=payload.get("order_type", "MARKET"),
|
||||
limit_price=payload.get("limit_price"),
|
||||
client_order_id=payload.get("client_order_id", ""),
|
||||
time_in_force=payload.get("time_in_force", "NEXT_BAR"),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
if not self.client.run_id:
|
||||
self.client.describe()
|
||||
state = self.client.start()
|
||||
self.context.state = state
|
||||
self._call("on_start", self.context)
|
||||
while True:
|
||||
for fill in state.get("fills", []):
|
||||
self._call("on_fill", self.context, fill)
|
||||
orders = self._call("on_bar", self.context, state.get("bars", {}))
|
||||
self._apply_orders(orders)
|
||||
if state.get("done"):
|
||||
break
|
||||
state = self.client.next_bar()
|
||||
self.context.state = state
|
||||
result = self.client.finish()
|
||||
self._call("on_finish", self.context, result)
|
||||
return result
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Minimal REQ/REP ZMQ server for one isolated backtest run."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
|
||||
class ZmqBacktestServer(object):
|
||||
def __init__(self, protocol, endpoint="tcp://127.0.0.1:16661", exit_on_finish=False, poll_ms=100):
|
||||
self.protocol = protocol
|
||||
self.endpoint = str(endpoint)
|
||||
self.exit_on_finish = bool(exit_on_finish)
|
||||
self.poll_ms = int(poll_ms)
|
||||
self._stop_event = threading.Event()
|
||||
self._ready_event = threading.Event()
|
||||
self.actual_endpoint = None
|
||||
|
||||
def wait_until_ready(self, timeout_seconds=None):
|
||||
return self._ready_event.wait(timeout_seconds)
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def serve_forever(self):
|
||||
import zmq
|
||||
|
||||
context = zmq.Context.instance()
|
||||
socket = context.socket(zmq.REP)
|
||||
socket.setsockopt(zmq.LINGER, 0)
|
||||
socket.setsockopt(zmq.RCVHWM, 1000)
|
||||
socket.setsockopt(zmq.SNDHWM, 1000)
|
||||
try:
|
||||
if self.endpoint.endswith(":0"):
|
||||
base = self.endpoint.rsplit(":", 1)[0]
|
||||
port = socket.bind_to_random_port(base)
|
||||
self.actual_endpoint = "%s:%d" % (base, port)
|
||||
else:
|
||||
socket.bind(self.endpoint)
|
||||
self.actual_endpoint = self.endpoint
|
||||
self._ready_event.set()
|
||||
poller = zmq.Poller()
|
||||
poller.register(socket, zmq.POLLIN)
|
||||
while not self._stop_event.is_set():
|
||||
events = dict(poller.poll(self.poll_ms))
|
||||
if socket not in events:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(socket.recv().decode("utf-8"))
|
||||
response = self.protocol.handle(request)
|
||||
except Exception as exc:
|
||||
response = {
|
||||
"schema_version": 1,
|
||||
"request_id": "",
|
||||
"run_id": self.protocol.engine.config.run_id,
|
||||
"client_id": "",
|
||||
"method": "",
|
||||
"ok": False,
|
||||
"data": None,
|
||||
"error": "%s: %s" % (exc.__class__.__name__, exc),
|
||||
}
|
||||
socket.send(json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
if self.exit_on_finish and self.protocol.engine.finished:
|
||||
break
|
||||
finally:
|
||||
self._ready_event.set()
|
||||
socket.close(linger=0)
|
||||
@@ -0,0 +1,50 @@
|
||||
# bigqmt_signal_trader
|
||||
|
||||
大 QMT 信号交易包的核心骨架。当前版本只完成可替换包边界和 dry-run 运行入口,不会发送真实委托。
|
||||
|
||||
## 已完成
|
||||
|
||||
- `TradeSignal`、`OrderRequest`、`PositionSnapshot`、`AccountSnapshot` 等核心数据模型。
|
||||
- `SignalSource`、`MarketDataProvider`、`PositionProvider`、`OrderGateway`、`PositionSyncSink`、`StateStore` 等替换接口。
|
||||
- `SignalTradingApp.tick()` 编排流程:
|
||||
1. 读取信号。
|
||||
2. 原子 claim。
|
||||
3. 读取持仓。
|
||||
4. 计算买卖数量。
|
||||
5. 生成价格。
|
||||
6. 调用可替换 `OrderGateway`。
|
||||
7. 写回状态。
|
||||
8. 同步持仓快照。
|
||||
- `DryRunOrderGateway`:记录委托请求,不调用真实 `passorder`。
|
||||
- `bigqmt_signal_trader_strategy.py`:大 QMT 运行文件骨架,响应 `init`、`adjust`、`order_callback`、`deal_callback`、`sync_positions`。
|
||||
|
||||
## 当前安全状态
|
||||
|
||||
默认 `adapter_factory.build_app()` 使用:
|
||||
|
||||
- 空信号源。
|
||||
- 空行情源。
|
||||
- 空持仓源。
|
||||
- dry-run 下单 gateway。
|
||||
- no-op 状态存储。
|
||||
- 内存持仓同步 sink。
|
||||
|
||||
因此即使大 QMT 加载该运行文件,也不会真实下单。
|
||||
|
||||
## 后续接入顺序
|
||||
|
||||
1. 实现 `BigQmtMarketDataProvider` 和 `BigQmtPositionProvider`。
|
||||
2. 实现 `BigQmtOrderGateway(passorder/cancel/get_trade_detail_data)`。
|
||||
3. 实现 Redis Stream / MySQL outbox 信号源。
|
||||
4. 实现 Redis / MySQL 状态写回。
|
||||
5. 实现 Redis / MySQL 持仓同步 sink。
|
||||
6. dry-run 跑通后,再按账户灰度切换真实下单。
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
cd <REPO_ROOT>
|
||||
python -m unittest discover -s tests\bigqmt_signal_trader
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""可替换的大 QMT 信号下单包核心模块。"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
from .app import SignalTradingApp
|
||||
from .models import (
|
||||
AccountSnapshot,
|
||||
AssetSnapshot,
|
||||
OrderRequest,
|
||||
OrderSubmitResult,
|
||||
PositionSnapshot,
|
||||
SignalAction,
|
||||
SignalStatus,
|
||||
TradeSignal,
|
||||
)
|
||||
from .xtquant_compat import BigQmtRpcClient, BigQmtXtData, BigQmtXtTrader
|
||||
|
||||
__all__ = [
|
||||
"AccountSnapshot",
|
||||
"AssetSnapshot",
|
||||
"BigQmtRpcClient",
|
||||
"BigQmtXtData",
|
||||
"BigQmtXtTrader",
|
||||
"OrderRequest",
|
||||
"OrderSubmitResult",
|
||||
"PositionSnapshot",
|
||||
"SignalAction",
|
||||
"SignalStatus",
|
||||
"SignalTradingApp",
|
||||
"TradeSignal",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""根据配置装配 SignalTradingApp。
|
||||
|
||||
当前第一版只提供安全的空信号 + dry-run 默认实现,后续再接 Redis/MySQL/大 QMT
|
||||
真实 adapter。这样大 QMT 运行文件可以先加载和响应调度,不会误发真实委托。
|
||||
"""
|
||||
|
||||
from .adapters.order_dryrun import DryRunOrderGateway
|
||||
from .app import SignalTradingApp
|
||||
from .models import AssetSnapshot
|
||||
|
||||
|
||||
class EmptySignalSource:
|
||||
def fetch(self, account_id, limit):
|
||||
return []
|
||||
|
||||
def ack(self, signal):
|
||||
return None
|
||||
|
||||
|
||||
class EmptyMarketDataProvider:
|
||||
def get_ticks(self, codes):
|
||||
return {}
|
||||
|
||||
def get_instrument(self, code):
|
||||
return {}
|
||||
|
||||
|
||||
class EmptyPositionProvider:
|
||||
def get_positions(self, account_id):
|
||||
return {}
|
||||
|
||||
def get_asset(self, account_id):
|
||||
return AssetSnapshot(account_id=account_id, cash=None, total_asset=None)
|
||||
|
||||
|
||||
class NoopPositionSyncSink:
|
||||
def __init__(self):
|
||||
self.snapshots = []
|
||||
|
||||
def publish(self, snapshot):
|
||||
self.snapshots.append(snapshot)
|
||||
|
||||
|
||||
class NoopStateStore:
|
||||
def claim(self, signal, consumer_id):
|
||||
return False
|
||||
|
||||
def mark_submitted(self, signal_id, result):
|
||||
return None
|
||||
|
||||
def mark_finished(self, signal_id, status, message=""):
|
||||
return None
|
||||
|
||||
|
||||
def _config_bool(value, default=False):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
def build_app(context_info=None, config=None):
|
||||
config = config or {}
|
||||
mode = str(config.get("mode") or "dryrun").lower()
|
||||
account_id = config.get("account_id", "default")
|
||||
source_type = str(config.get("signal_source_type") or config.get("source_type") or "").lower()
|
||||
state_type = str(config.get("state_store_type") or "").lower()
|
||||
position_sync_type = str(config.get("position_sync_type") or "").lower()
|
||||
|
||||
signal_source = config.get("signal_source")
|
||||
market_data = config.get("market_data")
|
||||
position_provider = config.get("position_provider")
|
||||
order_gateway = config.get("order_gateway")
|
||||
position_sync_sink = config.get("position_sync_sink")
|
||||
state_store = config.get("state_store")
|
||||
redis_client = config.get("redis_client")
|
||||
|
||||
if source_type == "redis" or state_type == "redis" or position_sync_type == "redis":
|
||||
from .adapters.redis_common import build_redis_client
|
||||
|
||||
redis_client = redis_client or build_redis_client(config.get("redis") or {})
|
||||
|
||||
if source_type == "redis":
|
||||
from .adapters.signal_redis import RedisStreamSignalSource
|
||||
|
||||
redis_cfg = config.get("redis") or {}
|
||||
signal_source = signal_source or RedisStreamSignalSource(
|
||||
redis_client=redis_client,
|
||||
stream_key_template=redis_cfg.get("stream_key_template", "bigqmt:signals:{account_id}"),
|
||||
group_name=redis_cfg.get("group_name", "bigqmt-signal-trader"),
|
||||
consumer_name=redis_cfg.get("consumer_name", "bigqmt-consumer"),
|
||||
block_ms=int(redis_cfg.get("block_ms", 0)),
|
||||
)
|
||||
|
||||
if state_type == "redis" or (source_type == "redis" and state_store is None):
|
||||
from .adapters.state_redis import RedisStateStore
|
||||
|
||||
redis_cfg = config.get("redis") or {}
|
||||
state_store = state_store or RedisStateStore(
|
||||
redis_client=redis_client,
|
||||
account_id=account_id,
|
||||
claim_key_template=redis_cfg.get("claim_key_template", "bigqmt:signal_claim:{account_id}:{signal_id}"),
|
||||
status_key_template=redis_cfg.get("status_key_template", "bigqmt:signal_status:{account_id}:{signal_id}"),
|
||||
claim_ttl_seconds=int(redis_cfg.get("claim_ttl_seconds", 3600)),
|
||||
status_ttl_seconds=int(redis_cfg.get("status_ttl_seconds", 86400)),
|
||||
)
|
||||
|
||||
if position_sync_type == "redis":
|
||||
from .adapters.position_sync_redis import RedisPositionSyncSink
|
||||
|
||||
redis_cfg = config.get("redis") or {}
|
||||
position_sync_sink = position_sync_sink or RedisPositionSyncSink(
|
||||
redis_client=redis_client,
|
||||
key_template=redis_cfg.get("position_key_template", "bigqmt:positions:{account_id}"),
|
||||
event_stream_template=redis_cfg.get("position_event_stream_template", "bigqmt:position_events:{account_id}"),
|
||||
ttl_seconds=int(redis_cfg.get("position_ttl_seconds", 120)),
|
||||
publish_events=_config_bool(redis_cfg.get("position_publish_events"), True),
|
||||
)
|
||||
|
||||
if mode == "bigqmt":
|
||||
from .adapters.market_bigqmt import BigQmtMarketDataProvider
|
||||
from .adapters.order_bigqmt import BigQmtOrderGateway
|
||||
from .adapters.position_bigqmt import BigQmtPositionProvider
|
||||
|
||||
qmt_api = config.get("qmt_api") or {}
|
||||
get_trade_detail_data_func = qmt_api.get("get_trade_detail_data")
|
||||
market_data = market_data or BigQmtMarketDataProvider(context_info, qmt_api=qmt_api)
|
||||
position_provider = position_provider or BigQmtPositionProvider(
|
||||
get_trade_detail_data_func=get_trade_detail_data_func,
|
||||
account_type=config.get("account_type", "STOCK"),
|
||||
)
|
||||
# passorder / cancel need the RAW QMT ContextInfo as their last arg -- QMT's
|
||||
# injected passorder reads internals off it (e.g. .request_id). Our runtime
|
||||
# wrapper (BigQmtRuntimeAdapter) doesn't have those, so unwrap it here.
|
||||
raw_context_info = getattr(context_info, "context_info", context_info)
|
||||
order_gateway = order_gateway or BigQmtOrderGateway(
|
||||
context_info=raw_context_info,
|
||||
account_id=account_id,
|
||||
passorder_func=qmt_api.get("passorder"),
|
||||
cancel_func=qmt_api.get("cancel"),
|
||||
get_trade_detail_data_func=get_trade_detail_data_func,
|
||||
account_type=config.get("account_type", "STOCK"),
|
||||
combo_type=int(config.get("combo_type", 1101)),
|
||||
price_type=int(config.get("order_price_type", 11)),
|
||||
quick_trade=int(config.get("quick_trade", 2)),
|
||||
)
|
||||
|
||||
return SignalTradingApp(
|
||||
account_id=account_id,
|
||||
signal_source=signal_source or EmptySignalSource(),
|
||||
market_data=market_data or EmptyMarketDataProvider(),
|
||||
position_provider=position_provider or EmptyPositionProvider(),
|
||||
order_gateway=order_gateway or DryRunOrderGateway(),
|
||||
position_sync_sink=position_sync_sink or NoopPositionSyncSink(),
|
||||
state_store=state_store or NoopStateStore(),
|
||||
consumer_id=config.get("consumer_id", "bigqmt-signal-trader"),
|
||||
fetch_limit=config.get("fetch_limit", 20),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""具体外部系统 adapter。"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
"""Big QMT order gateway.
|
||||
|
||||
The passorder signature follows src/api/qmt_jq_trade.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from ..code_utils import normalize_stock_code
|
||||
from ..models import CancelResult, OrderSnapshot, OrderSubmitResult, SignalAction, TradeSnapshot
|
||||
from .position_bigqmt import _attr, _full_code
|
||||
|
||||
|
||||
PRICE_TYPE_ALIASES = {
|
||||
"LIMIT": 11,
|
||||
"FIX_PRICE": 11,
|
||||
"LATEST_PRICE": 5,
|
||||
"MARKET_PEER_PRICE_FIRST": 44,
|
||||
"MARKET_SH_CONVERT_5_LIMIT": 43,
|
||||
"MARKET_SZ_CONVERT_5_CANCEL": 47,
|
||||
}
|
||||
|
||||
|
||||
def _action_from_offset_flag(offset_flag):
|
||||
return SignalAction.BUY.value if int(offset_flag or 0) == 48 else SignalAction.SELL.value
|
||||
|
||||
|
||||
# 报单时间。大 QMT 的 ORDER 行把日期和时间分成两个字段, MiniQMT 的
|
||||
# XtOrder.order_time 是 Unix 秒, 所以要拼接后转换。成交那条路径早就读了
|
||||
# m_strTradeTime, 委托这边一直漏掉 (issue #48)。
|
||||
_ORDER_DATE_FIELDS = ("m_strInsertDate", "m_strOrderDate", "insert_date", "order_date")
|
||||
_ORDER_TIME_FIELDS = ("m_strInsertTime", "m_strOrderTime", "insert_time", "order_time")
|
||||
|
||||
# 取不到时打印该行实际有哪些 m_*, 每进程一次。字段名无法离线核实,
|
||||
# 猜一个然后静默返回 0 正是订单方向那个 bug 的成因。
|
||||
_missing_order_time_reported = []
|
||||
|
||||
|
||||
def _report_missing_order_time(row):
|
||||
if _missing_order_time_reported:
|
||||
return
|
||||
_missing_order_time_reported.append(True)
|
||||
try:
|
||||
available = sorted(n for n in dir(row) if n.startswith("m_"))
|
||||
except Exception:
|
||||
available = []
|
||||
print(
|
||||
"[bigqmt_order] order_time not found (tried %s / %s); ORDER row exposes: %s"
|
||||
% (", ".join(_ORDER_DATE_FIELDS), ", ".join(_ORDER_TIME_FIELDS),
|
||||
", ".join(available) or "<none>")
|
||||
)
|
||||
|
||||
|
||||
def _order_time_seconds(row):
|
||||
"""把 ORDER 行的报单日期+时间转成 Unix 秒, 拿不到返回 0。
|
||||
|
||||
容忍几种实际会遇到的写法: 日期 '20260819' 或 '2026-08-19',
|
||||
时间 '093015'、'09:30:15' 或 '09:30:15.123'。已经是数字时间戳的直接用
|
||||
(毫秒会被归一到秒)。
|
||||
"""
|
||||
raw_time = _attr(row, _ORDER_TIME_FIELDS)
|
||||
raw_date = _attr(row, _ORDER_DATE_FIELDS)
|
||||
if raw_time is None and raw_date is None:
|
||||
_report_missing_order_time(row)
|
||||
return 0
|
||||
|
||||
# 已是数字: 当成时间戳 (>1e11 视为毫秒)。
|
||||
if isinstance(raw_time, (int, float)) and not isinstance(raw_time, bool):
|
||||
value = float(raw_time)
|
||||
if value > 1e11:
|
||||
value /= 1000.0
|
||||
if value > 1e8: # 像时间戳而不是 093015 这种时分秒
|
||||
return int(value)
|
||||
|
||||
date_text = "".join(ch for ch in str(raw_date or "") if ch.isdigit())
|
||||
time_text = "".join(ch for ch in str(raw_time or "") if ch.isdigit())
|
||||
if not date_text or len(date_text) < 8:
|
||||
return 0
|
||||
time_text = (time_text + "000000")[:6] # 补齐到 HHMMSS, 丢掉毫秒
|
||||
try:
|
||||
import time as _time
|
||||
|
||||
parsed = _time.strptime(date_text[:8] + time_text, "%Y%m%d%H%M%S")
|
||||
return int(_time.mktime(parsed))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _price_type_value(value, default):
|
||||
if value is None or value == "":
|
||||
return int(default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
text = str(value).strip().upper()
|
||||
return int(PRICE_TYPE_ALIASES.get(text, default))
|
||||
|
||||
|
||||
class BigQmtOrderGateway:
|
||||
def __init__(
|
||||
self,
|
||||
context_info,
|
||||
account_id="",
|
||||
passorder_func=None,
|
||||
cancel_func=None,
|
||||
get_trade_detail_data_func=None,
|
||||
account_type="STOCK",
|
||||
combo_type=1101,
|
||||
price_type=11,
|
||||
quick_trade=2,
|
||||
):
|
||||
self.context_info = context_info
|
||||
self.account_id = account_id
|
||||
self.passorder = passorder_func
|
||||
self.cancel_func = cancel_func
|
||||
self.get_trade_detail_data = get_trade_detail_data_func
|
||||
self.account_type = account_type
|
||||
self.combo_type = combo_type
|
||||
self.price_type = price_type
|
||||
self.quick_trade = quick_trade
|
||||
|
||||
def _require_passorder(self):
|
||||
if self.passorder is None:
|
||||
raise RuntimeError("passorder is not available in Big QMT runtime")
|
||||
return self.passorder
|
||||
|
||||
def _require_cancel(self):
|
||||
if self.cancel_func is None:
|
||||
raise RuntimeError("cancel is not available in Big QMT runtime")
|
||||
return self.cancel_func
|
||||
|
||||
def _require_query_func(self):
|
||||
if self.get_trade_detail_data is None:
|
||||
raise RuntimeError("get_trade_detail_data is not available in Big QMT runtime")
|
||||
return self.get_trade_detail_data
|
||||
|
||||
@staticmethod
|
||||
def build_user_order_id(signal_id):
|
||||
text = str(signal_id or "")
|
||||
digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:10]
|
||||
return "bq:%s:%s" % (digest, text[:30])
|
||||
|
||||
def submit(self, request):
|
||||
passorder = self._require_passorder()
|
||||
action = str(request.action).upper()
|
||||
if action == SignalAction.BUY.value:
|
||||
op_type = 23
|
||||
elif action == SignalAction.SELL.value:
|
||||
op_type = 24
|
||||
else:
|
||||
raise ValueError("unsupported order action: %s" % request.action)
|
||||
|
||||
user_order_id = str(request.remark or "").strip() or self.build_user_order_id(request.signal_id)
|
||||
account_id = request.account_id or self.account_id
|
||||
passorder(
|
||||
op_type,
|
||||
self.combo_type,
|
||||
account_id,
|
||||
normalize_stock_code(request.stock_code),
|
||||
_price_type_value(request.price_type, self.price_type),
|
||||
float(request.price),
|
||||
int(request.volume),
|
||||
request.strategy_name,
|
||||
self.quick_trade,
|
||||
user_order_id,
|
||||
self.context_info,
|
||||
)
|
||||
return OrderSubmitResult(
|
||||
status="SUBMITTED",
|
||||
user_order_id=user_order_id,
|
||||
order_sys_id=None,
|
||||
message="passorder submitted",
|
||||
)
|
||||
|
||||
def cancel(self, order_ref):
|
||||
cancel_func = self._require_cancel()
|
||||
ok = cancel_func(order_ref.order_sys_id, self.account_id, self.account_type, self.context_info)
|
||||
return CancelResult(success=bool(ok), message="" if ok else "cancel returned false")
|
||||
|
||||
def query_orders(self, account_id, strategy_name):
|
||||
try:
|
||||
return self.query_orders_strict(account_id, strategy_name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def query_orders_strict(self, account_id, strategy_name):
|
||||
query = self._require_query_func()
|
||||
rows = query(account_id, self.account_type, "ORDER", strategy_name) or []
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append(
|
||||
OrderSnapshot(
|
||||
order_sys_id=str(_attr(row, ("m_strOrderSysID", "order_sys_id"), "") or ""),
|
||||
user_order_id=str(_attr(row, ("m_strRemark", "user_order_id", "remark"), "") or ""),
|
||||
stock_code=_full_code(
|
||||
_attr(row, ("m_strInstrumentID", "instrument_id", "stock_code")),
|
||||
_attr(row, ("m_strExchangeID", "exchange_id", "market")),
|
||||
),
|
||||
action=_action_from_offset_flag(_attr(row, ("m_nOffsetFlag", "offset_flag"), 0)),
|
||||
volume=int(_attr(row, ("m_nVolumeTotalOriginal", "volume"), 0) or 0),
|
||||
traded_volume=int(_attr(row, ("m_nVolumeTraded", "traded_volume"), 0) or 0),
|
||||
status=str(_attr(row, ("m_nOrderStatus", "status"), "") or ""),
|
||||
price=float(_attr(row, ("m_dLimitPrice", "m_dPrice", "price"), 0.0) or 0.0),
|
||||
strategy_name=str(_attr(row, ("m_strStrategyName", "strategy_name"), "") or ""),
|
||||
remark=str(_attr(row, ("m_strRemark", "remark"), "") or ""),
|
||||
order_time=_order_time_seconds(row),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def query_trades(self, account_id, strategy_name):
|
||||
try:
|
||||
return self.query_trades_strict(account_id, strategy_name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def query_trades_strict(self, account_id, strategy_name):
|
||||
query = self._require_query_func()
|
||||
rows = []
|
||||
last_error = None
|
||||
for detail_type in ("DEAL", "TRADE"):
|
||||
try:
|
||||
if str(strategy_name or "").strip():
|
||||
rows = query(account_id, self.account_type, detail_type, strategy_name) or []
|
||||
else:
|
||||
rows = query(account_id, self.account_type, detail_type) or []
|
||||
if rows:
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if not rows and last_error is not None:
|
||||
raise last_error
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append(
|
||||
TradeSnapshot(
|
||||
trade_id=str(_attr(row, ("m_strTradeID", "trade_id"), "") or ""),
|
||||
order_sys_id=str(_attr(row, ("m_strOrderSysID", "order_sys_id"), "") or ""),
|
||||
stock_code=_full_code(
|
||||
_attr(row, ("m_strInstrumentID", "instrument_id", "stock_code")),
|
||||
_attr(row, ("m_strExchangeID", "exchange_id", "market")),
|
||||
),
|
||||
action=_action_from_offset_flag(_attr(row, ("m_nOffsetFlag", "offset_flag"), 0)),
|
||||
volume=int(_attr(row, ("m_nVolume", "volume"), 0) or 0),
|
||||
price=float(_attr(row, ("m_dPrice", "m_dTradePrice", "price"), 0.0) or 0.0),
|
||||
traded_at=str(_attr(row, ("m_strTradeTime", "trade_time", "traded_at"), "") or ""),
|
||||
user_order_id=str(_attr(row, ("m_strRemark", "user_order_id", "remark"), "") or ""),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def query_submission_identities_strict(self, account_id, strategy_name):
|
||||
orders = self.query_orders_strict(account_id, strategy_name)
|
||||
trades = self.query_trades_strict(account_id, strategy_name)
|
||||
return orders, trades
|
||||
@@ -0,0 +1,30 @@
|
||||
"""不发真实委托的下单 gateway,用于联调和回放。"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from ..models import OrderSubmitResult
|
||||
|
||||
|
||||
class DryRunOrderGateway:
|
||||
def __init__(self):
|
||||
self.submitted = []
|
||||
self.cancelled = []
|
||||
|
||||
def submit(self, request):
|
||||
self.submitted.append(request)
|
||||
digest = hashlib.sha1(request.signal_id.encode("utf-8")).hexdigest()[:10]
|
||||
return OrderSubmitResult(
|
||||
status="DRY_RUN",
|
||||
user_order_id=f"dryrun:bq:{digest}:{request.signal_id}",
|
||||
order_sys_id=None,
|
||||
)
|
||||
|
||||
def cancel(self, order_ref):
|
||||
self.cancelled.append(order_ref)
|
||||
return None
|
||||
|
||||
def query_orders(self, account_id, strategy_name):
|
||||
return []
|
||||
|
||||
def query_trades(self, account_id, strategy_name):
|
||||
return []
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Big QMT position and asset adapters."""
|
||||
|
||||
from ..code_utils import normalize_stock_code
|
||||
from ..models import AssetSnapshot, PositionSnapshot
|
||||
|
||||
|
||||
def _attr(obj, names, default=None):
|
||||
for name in names:
|
||||
if hasattr(obj, name):
|
||||
value = getattr(obj, name)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _float_or_none(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# Candidate ThinkTrader field names on the ACCOUNT row of get_trade_detail_data.
|
||||
# The MiniQMT SDK only documents the normalized name (XtAsset.frozen_cash); the
|
||||
# big QMT ACCOUNT struct is a different surface and brokers vary, so probe the
|
||||
# plausible spellings the way cash/total_asset already do.
|
||||
_FROZEN_CASH_FIELDS = (
|
||||
"m_dFrozenCash",
|
||||
"m_dFrozen",
|
||||
"m_dFrozenBalance",
|
||||
"m_dFrozenMargin",
|
||||
"frozen_cash",
|
||||
"frozen",
|
||||
)
|
||||
_MARKET_VALUE_FIELDS = (
|
||||
"m_dInstrumentValue",
|
||||
"m_dStockValue",
|
||||
"m_dMarketValue",
|
||||
"market_value",
|
||||
)
|
||||
|
||||
# Printed once per process when the frozen field is not found, listing what the
|
||||
# row actually carries. Guessing a field name and shipping it unverified is how
|
||||
# the order-direction bug happened; this makes the real name self-reporting.
|
||||
_missing_field_reported = set()
|
||||
|
||||
|
||||
def _report_missing_field(label, row, candidates):
|
||||
if label in _missing_field_reported:
|
||||
return
|
||||
_missing_field_reported.add(label)
|
||||
try:
|
||||
available = sorted(name for name in dir(row) if name.startswith("m_"))
|
||||
except Exception:
|
||||
available = []
|
||||
print(
|
||||
"[bigqmt_asset] %s not found (tried %s); ACCOUNT row exposes: %s"
|
||||
% (label, ", ".join(candidates), ", ".join(available) or "<none>")
|
||||
)
|
||||
|
||||
|
||||
def _full_code(instrument_id, exchange_id):
|
||||
code = str(instrument_id or "").strip().upper()
|
||||
market = str(exchange_id or "").strip().upper()
|
||||
if "." in code:
|
||||
return normalize_stock_code(code)
|
||||
if market in ("SH", "SZ"):
|
||||
return normalize_stock_code("%s.%s" % (code, market))
|
||||
return normalize_stock_code(code)
|
||||
|
||||
|
||||
class BigQmtPositionProvider:
|
||||
def __init__(self, get_trade_detail_data_func, account_type="STOCK"):
|
||||
self.get_trade_detail_data = get_trade_detail_data_func
|
||||
self.account_type = account_type
|
||||
|
||||
def _require_query_func(self):
|
||||
if self.get_trade_detail_data is None:
|
||||
raise RuntimeError("get_trade_detail_data is not available in Big QMT runtime")
|
||||
return self.get_trade_detail_data
|
||||
|
||||
def get_positions(self, account_id):
|
||||
query = self._require_query_func()
|
||||
# QMT's get_trade_detail_data can raise on POSITION queries in some
|
||||
# states (e.g. context not bound). Degrade to empty like get_asset does.
|
||||
try:
|
||||
rows = query(account_id, self.account_type, "POSITION") or []
|
||||
except Exception:
|
||||
return {}
|
||||
positions = {}
|
||||
for row in rows:
|
||||
code = _full_code(
|
||||
_attr(row, ("m_strInstrumentID", "instrument_id", "stock_code")),
|
||||
_attr(row, ("m_strExchangeID", "exchange_id", "market")),
|
||||
)
|
||||
positions[code] = PositionSnapshot(
|
||||
stock_code=code,
|
||||
volume=int(_attr(row, ("m_nVolume", "volume"), 0) or 0),
|
||||
available=int(_attr(row, ("m_nCanUseVolume", "available", "can_use_volume"), 0) or 0),
|
||||
cost=float(_attr(row, ("m_dOpenPrice", "m_dCostPrice", "cost"), 0.0) or 0.0),
|
||||
stock_name=str(_attr(row, ("m_strInstrumentName", "stock_name"), "") or ""),
|
||||
market_value=_float_or_none(_attr(row, ("m_dMarketValue", "m_dInstrumentValue", "market_value"))),
|
||||
price=_float_or_none(_attr(row, ("m_dLastPrice", "m_dSettlementPrice", "price", "last_price"))),
|
||||
open_price=_float_or_none(_attr(row, ("m_dOpenPrice", "m_dCostPrice", "open_price", "cost"))),
|
||||
frozen_volume=int(_attr(row, ("m_nFrozenVolume", "frozen_volume"), 0) or 0),
|
||||
on_road_volume=int(_attr(row, ("m_nOnRoadVolume", "on_road_volume"), 0) or 0),
|
||||
yesterday_volume=int(_attr(row, ("m_nYesterdayVolume", "yesterday_volume"), 0) or 0),
|
||||
direction=int(_attr(row, ("m_nDirection", "direction"), 48) or 48),
|
||||
)
|
||||
return positions
|
||||
|
||||
def get_asset(self, account_id):
|
||||
query = self._require_query_func()
|
||||
rows = []
|
||||
for detail_type in ("ACCOUNT", "ASSET"):
|
||||
try:
|
||||
rows = query(account_id, self.account_type, detail_type) or []
|
||||
if rows:
|
||||
break
|
||||
except Exception:
|
||||
rows = []
|
||||
if not rows:
|
||||
return AssetSnapshot(account_id=account_id, cash=None, total_asset=None)
|
||||
|
||||
row = rows[0]
|
||||
cash = _attr(row, ("m_dAvailable", "m_dAvailableCash", "available_cash", "cash"))
|
||||
total_asset = _attr(row, ("m_dBalance", "m_dAsset", "total_asset", "asset"))
|
||||
frozen_cash = _attr(row, _FROZEN_CASH_FIELDS)
|
||||
market_value = _attr(row, _MARKET_VALUE_FIELDS)
|
||||
if frozen_cash is None:
|
||||
_report_missing_field("frozen_cash", row, _FROZEN_CASH_FIELDS)
|
||||
if market_value is None and cash is not None and total_asset is not None:
|
||||
# Derive only as a last resort. Without frozen_cash this overstates
|
||||
# market value by the frozen amount, so subtract it when known.
|
||||
market_value = float(total_asset) - float(cash)
|
||||
if frozen_cash is not None:
|
||||
market_value -= float(frozen_cash)
|
||||
return AssetSnapshot(
|
||||
account_id=account_id,
|
||||
cash=float(cash) if cash is not None else None,
|
||||
total_asset=float(total_asset) if total_asset is not None else None,
|
||||
frozen_cash=float(frozen_cash) if frozen_cash is not None else None,
|
||||
market_value=float(market_value) if market_value is not None else None,
|
||||
)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""Publish Big QMT position snapshots to Redis."""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
|
||||
|
||||
class RedisPositionSyncSink:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client,
|
||||
key_template="bigqmt:positions:{account_id}",
|
||||
event_stream_template="bigqmt:position_events:{account_id}",
|
||||
ttl_seconds=120,
|
||||
publish_events=True,
|
||||
):
|
||||
self.redis = redis_client
|
||||
self.key_template = key_template
|
||||
self.event_stream_template = event_stream_template
|
||||
self.ttl_seconds = int(ttl_seconds)
|
||||
self.publish_events = bool(publish_events)
|
||||
|
||||
@staticmethod
|
||||
def _time_text(value):
|
||||
if isinstance(value, _dt.datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value)
|
||||
|
||||
def _snapshot_to_dict(self, snapshot):
|
||||
return {
|
||||
"account_id": snapshot.account_id,
|
||||
"reason": snapshot.reason,
|
||||
"updated_at": self._time_text(snapshot.updated_at),
|
||||
"asset": {
|
||||
"cash": snapshot.asset.cash,
|
||||
"total_asset": snapshot.asset.total_asset,
|
||||
# Carried so the client's cached-asset fallback exposes the same
|
||||
# fields as a live query_stock_asset.
|
||||
"frozen_cash": getattr(snapshot.asset, "frozen_cash", None),
|
||||
"market_value": getattr(snapshot.asset, "market_value", None),
|
||||
},
|
||||
"positions": {
|
||||
code: {
|
||||
"stock_code": position.stock_code,
|
||||
"volume": position.volume,
|
||||
"available": position.available,
|
||||
"cost": position.cost,
|
||||
"stock_name": position.stock_name,
|
||||
}
|
||||
for code, position in snapshot.positions.items()
|
||||
},
|
||||
}
|
||||
|
||||
def publish(self, snapshot):
|
||||
payload = json.dumps(self._snapshot_to_dict(snapshot), ensure_ascii=False)
|
||||
key = self.key_template.format(account_id=snapshot.account_id)
|
||||
if self.ttl_seconds > 0:
|
||||
self.redis.setex(key, self.ttl_seconds, payload)
|
||||
else:
|
||||
self.redis.set(key, payload)
|
||||
if self.publish_events:
|
||||
stream_key = self.event_stream_template.format(account_id=snapshot.account_id)
|
||||
# Cap the stream to prevent unbounded memory growth. Order/trade
|
||||
# events already use maxlen=2000; position events were missing it,
|
||||
# causing 4.2GB+ streams in production (issue #21).
|
||||
self.redis.xadd(stream_key, {"payload": payload}, maxlen=2000, approximate=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Redis client helpers for Big QMT signal trader."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _float_or_none(value, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
if value == "":
|
||||
return default
|
||||
text = str(value).strip()
|
||||
if text.lower() in ("none", "null"):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def build_redis_client(config=None):
|
||||
config = config or {}
|
||||
try:
|
||||
import redis
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError("redis package is required when Redis adapters are enabled") from exc
|
||||
|
||||
url = config.get("url") or os.environ.get("BIGQMT_REDIS_URL")
|
||||
if url:
|
||||
return redis.Redis.from_url(
|
||||
url,
|
||||
socket_connect_timeout=_float_or_none(config.get("socket_connect_timeout", 1.5), 1.5),
|
||||
socket_timeout=_float_or_none(config.get("socket_timeout", 1.5), 1.5),
|
||||
)
|
||||
|
||||
host = config.get("host") or os.environ.get("BIGQMT_REDIS_HOST") or "127.0.0.1"
|
||||
port = int(config.get("port") or os.environ.get("BIGQMT_REDIS_PORT") or 6379)
|
||||
db = int(config.get("db") or os.environ.get("BIGQMT_REDIS_DB") or 5)
|
||||
username = config.get("username") or os.environ.get("BIGQMT_REDIS_USERNAME") or None
|
||||
password = config.get("password") or os.environ.get("BIGQMT_REDIS_PASSWORD") or None
|
||||
return redis.Redis(
|
||||
host=host,
|
||||
port=port,
|
||||
db=db,
|
||||
username=username,
|
||||
password=password,
|
||||
socket_connect_timeout=_float_or_none(config.get("socket_connect_timeout", 1.5), 1.5),
|
||||
socket_timeout=_float_or_none(config.get("socket_timeout", 1.5), 1.5),
|
||||
health_check_interval=int(config.get("health_check_interval", 30)),
|
||||
)
|
||||
|
||||
|
||||
def decode_text(value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
def redis_mapping_to_text(mapping):
|
||||
return {decode_text(key): decode_text(value) for key, value in (mapping or {}).items()}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Redis Stream signal source.
|
||||
|
||||
Redis is only a transport here. It must never call passorder or inspect QMT.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
|
||||
from ..models import TradeSignal
|
||||
from .redis_common import decode_text, redis_mapping_to_text
|
||||
|
||||
|
||||
DEFAULT_STREAM_KEY_TEMPLATE = "bigqmt:signals:{account_id}"
|
||||
DEFAULT_GROUP = "bigqmt-signal-trader"
|
||||
|
||||
|
||||
def _json_default(value):
|
||||
if isinstance(value, (_dt.datetime, _dt.date)):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _coerce_scalar(value):
|
||||
text = decode_text(value).strip()
|
||||
lowered = text.lower()
|
||||
if lowered == "true":
|
||||
return True
|
||||
if lowered == "false":
|
||||
return False
|
||||
if lowered in ("none", "null"):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def parse_stream_payload(fields):
|
||||
text_fields = redis_mapping_to_text(fields)
|
||||
payload_text = text_fields.get("payload") or text_fields.get("data")
|
||||
if payload_text:
|
||||
payload = json.loads(payload_text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Redis stream payload must be a JSON object")
|
||||
return payload
|
||||
return {decode_text(key): _coerce_scalar(value) for key, value in fields.items()}
|
||||
|
||||
|
||||
class RedisStreamSignalSource:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client,
|
||||
stream_key_template=DEFAULT_STREAM_KEY_TEMPLATE,
|
||||
group_name=DEFAULT_GROUP,
|
||||
consumer_name="bigqmt-consumer",
|
||||
block_ms=0,
|
||||
):
|
||||
self.redis = redis_client
|
||||
self.stream_key_template = stream_key_template
|
||||
self.group_name = group_name
|
||||
self.consumer_name = consumer_name
|
||||
self.block_ms = int(block_ms or 0)
|
||||
self._stream_ids_by_signal_id = {}
|
||||
self._created_groups = set()
|
||||
|
||||
def _stream_key(self, account_id):
|
||||
return self.stream_key_template.format(account_id=account_id)
|
||||
|
||||
def _ensure_group(self, stream_key):
|
||||
if stream_key in self._created_groups:
|
||||
return
|
||||
try:
|
||||
self.redis.xgroup_create(stream_key, self.group_name, id="0-0", mkstream=True)
|
||||
except Exception as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
self._created_groups.add(stream_key)
|
||||
|
||||
def fetch(self, account_id, limit):
|
||||
stream_key = self._stream_key(account_id)
|
||||
self._ensure_group(stream_key)
|
||||
kwargs = {
|
||||
"groupname": self.group_name,
|
||||
"consumername": self.consumer_name,
|
||||
"streams": {stream_key: ">"},
|
||||
"count": int(limit),
|
||||
}
|
||||
if self.block_ms > 0:
|
||||
kwargs["block"] = self.block_ms
|
||||
rows = self.redis.xreadgroup(**kwargs) or []
|
||||
signals = []
|
||||
for _, entries in rows:
|
||||
for stream_id, fields in entries:
|
||||
payload = parse_stream_payload(fields)
|
||||
signal = TradeSignal.from_dict(payload)
|
||||
self._stream_ids_by_signal_id[signal.signal_id] = (stream_key, stream_id)
|
||||
signals.append(signal)
|
||||
return signals
|
||||
|
||||
def ack(self, signal):
|
||||
ref = self._stream_ids_by_signal_id.pop(signal.signal_id, None)
|
||||
if not ref:
|
||||
return None
|
||||
stream_key, stream_id = ref
|
||||
return self.redis.xack(stream_key, self.group_name, stream_id)
|
||||
|
||||
|
||||
def push_trade_signal(redis_client, payload, account_id=None, stream_key_template=DEFAULT_STREAM_KEY_TEMPLATE):
|
||||
if isinstance(payload, TradeSignal):
|
||||
account_id = account_id or payload.account_id
|
||||
raw_payload = dict(payload.raw_payload)
|
||||
else:
|
||||
raw_payload = dict(payload)
|
||||
account_id = account_id or raw_payload.get("account_id")
|
||||
if not account_id:
|
||||
raise ValueError("account_id is required")
|
||||
stream_key = stream_key_template.format(account_id=account_id)
|
||||
return redis_client.xadd(stream_key, {"payload": json.dumps(raw_payload, ensure_ascii=False, default=_json_default)})
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Redis signal state store."""
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
class RedisStateStore:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client,
|
||||
account_id="default",
|
||||
claim_key_template="bigqmt:signal_claim:{account_id}:{signal_id}",
|
||||
status_key_template="bigqmt:signal_status:{account_id}:{signal_id}",
|
||||
claim_ttl_seconds=3600,
|
||||
status_ttl_seconds=86400,
|
||||
):
|
||||
self.redis = redis_client
|
||||
self.account_id = account_id
|
||||
self.claim_key_template = claim_key_template
|
||||
self.status_key_template = status_key_template
|
||||
self.claim_ttl_seconds = int(claim_ttl_seconds)
|
||||
self.status_ttl_seconds = int(status_ttl_seconds)
|
||||
self._accounts_by_signal_id = {}
|
||||
|
||||
def _account_for(self, signal_id):
|
||||
return self._accounts_by_signal_id.get(signal_id) or self.account_id
|
||||
|
||||
def _claim_key(self, account_id, signal_id):
|
||||
return self.claim_key_template.format(account_id=account_id, signal_id=signal_id)
|
||||
|
||||
def _status_key(self, account_id, signal_id):
|
||||
return self.status_key_template.format(account_id=account_id, signal_id=signal_id)
|
||||
|
||||
@staticmethod
|
||||
def _now_text():
|
||||
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _write_status(self, account_id, signal_id, mapping):
|
||||
key = self._status_key(account_id, signal_id)
|
||||
fields = {
|
||||
"signal_id": signal_id,
|
||||
"account_id": account_id,
|
||||
"updated_at": self._now_text(),
|
||||
}
|
||||
fields.update({k: "" if v is None else str(v) for k, v in mapping.items()})
|
||||
self.redis.hset(key, mapping=fields)
|
||||
if self.status_ttl_seconds > 0:
|
||||
self.redis.expire(key, self.status_ttl_seconds)
|
||||
|
||||
def claim(self, signal, consumer_id):
|
||||
account_id = signal.account_id or self.account_id
|
||||
self._accounts_by_signal_id[signal.signal_id] = account_id
|
||||
key = self._claim_key(account_id, signal.signal_id)
|
||||
ok = self.redis.set(key, consumer_id, nx=True, ex=self.claim_ttl_seconds)
|
||||
if ok:
|
||||
self._write_status(
|
||||
account_id,
|
||||
signal.signal_id,
|
||||
{
|
||||
"status": "CLAIMED",
|
||||
"consumer_id": consumer_id,
|
||||
"stock_code": signal.stock_code,
|
||||
"action": signal.action.value,
|
||||
"message": "",
|
||||
},
|
||||
)
|
||||
return bool(ok)
|
||||
|
||||
def mark_submitted(self, signal_id, result):
|
||||
account_id = self._account_for(signal_id)
|
||||
self._write_status(
|
||||
account_id,
|
||||
signal_id,
|
||||
{
|
||||
"status": result.status,
|
||||
"user_order_id": result.user_order_id,
|
||||
"order_sys_id": result.order_sys_id,
|
||||
"message": result.message,
|
||||
},
|
||||
)
|
||||
|
||||
def mark_finished(self, signal_id, status, message=""):
|
||||
account_id = self._account_for(signal_id)
|
||||
self._write_status(
|
||||
account_id,
|
||||
signal_id,
|
||||
{
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""信号交易应用编排层。"""
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
from .models import AccountSnapshot, OrderRequest, SignalAction
|
||||
from .price_engine import build_order_price
|
||||
from .risk_guard import validate_signal
|
||||
|
||||
|
||||
class SignalTradingApp:
|
||||
def __init__(
|
||||
self,
|
||||
account_id,
|
||||
signal_source,
|
||||
market_data,
|
||||
position_provider,
|
||||
order_gateway,
|
||||
position_sync_sink,
|
||||
state_store,
|
||||
consumer_id="bigqmt-signal-trader",
|
||||
fetch_limit=20,
|
||||
):
|
||||
self.account_id = account_id
|
||||
self.signal_source = signal_source
|
||||
self.market_data = market_data
|
||||
self.position_provider = position_provider
|
||||
self.order_gateway = order_gateway
|
||||
self.position_sync_sink = position_sync_sink
|
||||
self.state_store = state_store
|
||||
self.consumer_id = consumer_id
|
||||
self.fetch_limit = int(fetch_limit)
|
||||
|
||||
def tick(self, now=None):
|
||||
now = now or _dt.datetime.now()
|
||||
signals = self.signal_source.fetch(self.account_id, self.fetch_limit)
|
||||
positions = self.position_provider.get_positions(self.account_id)
|
||||
|
||||
for signal in signals:
|
||||
if not self.state_store.claim(signal, self.consumer_id):
|
||||
continue
|
||||
try:
|
||||
self._handle_signal(signal, now, positions)
|
||||
except Exception as exc:
|
||||
self.state_store.mark_finished(signal.signal_id, "FAILED", str(exc))
|
||||
self.signal_source.ack(signal)
|
||||
|
||||
self.sync_positions("tick", now=now)
|
||||
|
||||
def _handle_signal(self, signal, now, positions):
|
||||
decision = validate_signal(signal, now, positions)
|
||||
if not decision.allowed:
|
||||
self.state_store.mark_finished(signal.signal_id, "SKIPPED", decision.reason)
|
||||
self.signal_source.ack(signal)
|
||||
return
|
||||
|
||||
price = build_order_price(
|
||||
self.market_data,
|
||||
decision.stock_code,
|
||||
signal.action.value,
|
||||
price_type=signal.price_type,
|
||||
fixed_price=signal.price,
|
||||
)
|
||||
request = OrderRequest(
|
||||
signal_id=signal.signal_id,
|
||||
account_id=signal.account_id,
|
||||
action=signal.action.value,
|
||||
stock_code=decision.stock_code,
|
||||
volume=decision.volume,
|
||||
price=price,
|
||||
price_type="LIMIT",
|
||||
strategy_name=signal.strategy_name,
|
||||
remark=signal.remark,
|
||||
)
|
||||
result = self.order_gateway.submit(request)
|
||||
self.state_store.mark_submitted(signal.signal_id, result)
|
||||
self.signal_source.ack(signal)
|
||||
|
||||
def on_init(self, runtime):
|
||||
return None
|
||||
|
||||
def on_order_event(self, event):
|
||||
return None
|
||||
|
||||
def on_trade_event(self, event):
|
||||
self.sync_positions("trade_event")
|
||||
|
||||
def sync_positions(self, reason, now=None):
|
||||
now = now or _dt.datetime.now()
|
||||
asset = self.position_provider.get_asset(self.account_id)
|
||||
positions = self.position_provider.get_positions(self.account_id)
|
||||
snapshot = AccountSnapshot(
|
||||
account_id=self.account_id,
|
||||
asset=asset,
|
||||
positions=positions,
|
||||
reason=reason,
|
||||
updated_at=now,
|
||||
)
|
||||
self.position_sync_sink.publish(snapshot)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""证券代码标准化和委托数量处理。"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_DIGIT_CODE_RE = re.compile(r"^\d{6}$")
|
||||
|
||||
|
||||
def normalize_stock_code(code):
|
||||
text = str(code or "").strip().upper()
|
||||
if not text:
|
||||
return ""
|
||||
if text.startswith("SH") and _DIGIT_CODE_RE.match(text[2:]):
|
||||
return f"{text[2:]}.SH"
|
||||
if text.startswith("SZ") and _DIGIT_CODE_RE.match(text[2:]):
|
||||
return f"{text[2:]}.SZ"
|
||||
if text.endswith(".SH") or text.endswith(".SZ"):
|
||||
prefix = text[:6]
|
||||
if _DIGIT_CODE_RE.match(prefix):
|
||||
return text
|
||||
if _DIGIT_CODE_RE.match(text):
|
||||
market = "SH" if text.startswith(("5", "6")) else "SZ"
|
||||
return f"{text}.{market}"
|
||||
raise ValueError(f"invalid stock code: {code}")
|
||||
|
||||
|
||||
def min_lot(stock_code):
|
||||
normalized = normalize_stock_code(stock_code)
|
||||
pure = normalized.split(".")[0]
|
||||
return 200 if pure.startswith("688") else 100
|
||||
|
||||
|
||||
def round_buy_volume(stock_code, amount):
|
||||
lot = min_lot(stock_code)
|
||||
value = int(amount or 0)
|
||||
if value <= 0:
|
||||
return 0
|
||||
return (value // lot) * lot
|
||||
|
||||
|
||||
def round_sell_volume(stock_code, amount, sell_all=False):
|
||||
value = int(amount or 0)
|
||||
if value <= 0:
|
||||
return 0
|
||||
if sell_all:
|
||||
return value
|
||||
lot = min_lot(stock_code)
|
||||
return (value // lot) * lot
|
||||
@@ -0,0 +1,81 @@
|
||||
"""可替换 adapter 的接口定义。"""
|
||||
|
||||
import datetime as _dt
|
||||
from typing import Dict, List
|
||||
|
||||
try:
|
||||
from typing import Protocol
|
||||
except ImportError: # pragma: no cover
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from .models import (
|
||||
AccountSnapshot,
|
||||
AssetSnapshot,
|
||||
CancelResult,
|
||||
OrderRef,
|
||||
OrderRequest,
|
||||
OrderSnapshot,
|
||||
OrderSubmitResult,
|
||||
PositionSnapshot,
|
||||
TradeSignal,
|
||||
TradeSnapshot,
|
||||
)
|
||||
|
||||
|
||||
class SignalSource(Protocol):
|
||||
def fetch(self, account_id: str, limit: int) -> List[TradeSignal]:
|
||||
...
|
||||
|
||||
def ack(self, signal: TradeSignal) -> None:
|
||||
...
|
||||
|
||||
|
||||
class MarketDataProvider(Protocol):
|
||||
def get_ticks(self, codes: List[str]) -> Dict[str, dict]:
|
||||
...
|
||||
|
||||
def get_instrument(self, code: str) -> dict:
|
||||
...
|
||||
|
||||
|
||||
class PositionProvider(Protocol):
|
||||
def get_positions(self, account_id: str) -> Dict[str, PositionSnapshot]:
|
||||
...
|
||||
|
||||
def get_asset(self, account_id: str) -> AssetSnapshot:
|
||||
...
|
||||
|
||||
|
||||
class OrderGateway(Protocol):
|
||||
def submit(self, request: OrderRequest) -> OrderSubmitResult:
|
||||
...
|
||||
|
||||
def cancel(self, order_ref: OrderRef) -> CancelResult:
|
||||
...
|
||||
|
||||
def query_orders(self, account_id: str, strategy_name: str) -> List[OrderSnapshot]:
|
||||
...
|
||||
|
||||
def query_trades(self, account_id: str, strategy_name: str) -> List[TradeSnapshot]:
|
||||
...
|
||||
|
||||
|
||||
class PositionSyncSink(Protocol):
|
||||
def publish(self, snapshot: AccountSnapshot) -> None:
|
||||
...
|
||||
|
||||
|
||||
class StateStore(Protocol):
|
||||
def claim(self, signal: TradeSignal, consumer_id: str) -> bool:
|
||||
...
|
||||
|
||||
def mark_submitted(self, signal_id: str, result: OrderSubmitResult) -> None:
|
||||
...
|
||||
|
||||
def mark_finished(self, signal_id: str, status: str, message: str = "") -> None:
|
||||
...
|
||||
|
||||
|
||||
class RuntimeAdapter(Protocol):
|
||||
def now(self) -> _dt.datetime:
|
||||
...
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Async, chunked download jobs for Big QMT.
|
||||
|
||||
A client submits a download job (fire-and-forget) into a Redis queue and polls
|
||||
its status. The Big QMT strategy thread drains one job at a time and downloads a
|
||||
bounded slice of symbols per tick (``chunk_size`` symbols, capped by a wall-clock
|
||||
budget), so a long ``download_history_data2`` never blocks the strategy thread /
|
||||
RPC pump. Historical bars land in the Big QMT machine's local store; clients then
|
||||
read them back with fast ``get_local_data`` / ``get_market_data`` calls.
|
||||
|
||||
Redis layout (per account). All stored VALUES are digit-free encoded (see _enc)
|
||||
so the QMT terminal's redis compliance filter never trips on stock codes in the
|
||||
job data the pump reads back:
|
||||
- ``bigqmt:dljob:pending:{account_id}`` list of pending job ids (RPUSH/LPOP)
|
||||
- ``bigqmt:dljob:item:{account_id}:{job_id}`` encoded job blob incl. progress
|
||||
- ``bigqmt:dljob:active:{account_id}`` id of the job being processed now
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
# Dedicated "bigqmt:dljob:*" namespace for the client<->pump protocol.
|
||||
QUEUE_KEY_TEMPLATE = "bigqmt:dljob:pending:{account_id}"
|
||||
JOB_KEY_TEMPLATE = "bigqmt:dljob:item:{account_id}:{job_id}"
|
||||
CURRENT_KEY_TEMPLATE = "bigqmt:dljob:active:{account_id}"
|
||||
|
||||
DEFAULT_JOB_TTL_SECONDS = 3600
|
||||
DEFAULT_CHUNK_SIZE = 10
|
||||
DEFAULT_MAX_WALL_SECONDS = 0.5
|
||||
|
||||
# Terminal + in-flight states.
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
_ACTIVE_STATES = (PENDING, RUNNING)
|
||||
|
||||
|
||||
def queue_key(account_id):
|
||||
return QUEUE_KEY_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def job_key(account_id, job_id):
|
||||
return JOB_KEY_TEMPLATE.format(account_id=str(account_id or ""), job_id=str(job_id or ""))
|
||||
|
||||
|
||||
def current_key(account_id):
|
||||
return CURRENT_KEY_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def _text(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
# The 国金证券 QMT terminal ships a redis client whose check_response() raises
|
||||
# "Sensitive Data Detected, Forbidden!" whenever a Redis *response* contains a
|
||||
# stock-code + operation-code DIGIT pattern (a brokerage control against trading
|
||||
# signals flowing through Redis). The pump runs inside that terminal and must read
|
||||
# job data (which contains stock codes) back from Redis. So every value the pump
|
||||
# reads is stored as a DIGIT-FREE token: hex-encode, then shift digits 0-9 -> the
|
||||
# letters g-p, making the stored value all letters (a-p). The stock-code regex
|
||||
# requires digits, so it can never match. Reversible; writes are never filtered
|
||||
# (only responses are), so only read-back values need this.
|
||||
_DIGIT_TO_ALPHA = str.maketrans("0123456789", "ghijklmnop")
|
||||
_ALPHA_TO_DIGIT = str.maketrans("ghijklmnop", "0123456789")
|
||||
|
||||
|
||||
def _enc(text_value):
|
||||
return _text(text_value).encode("utf-8").hex().translate(_DIGIT_TO_ALPHA)
|
||||
|
||||
|
||||
def _dec(token):
|
||||
text = _text(token)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return bytes.fromhex(text.translate(_ALPHA_TO_DIGIT)).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def submit_download_job(
|
||||
redis_client,
|
||||
account_id,
|
||||
stock_list,
|
||||
period,
|
||||
method="download_history_data2",
|
||||
start_time="",
|
||||
end_time="",
|
||||
incrementally=None,
|
||||
chunk_size=DEFAULT_CHUNK_SIZE,
|
||||
job_ttl_seconds=DEFAULT_JOB_TTL_SECONDS,
|
||||
):
|
||||
"""Queue a download job and return its initial status dict (non-blocking)."""
|
||||
codes = [str(code) for code in (stock_list or []) if str(code or "").strip()]
|
||||
if not codes:
|
||||
raise ValueError("stock_list is required for a download job")
|
||||
job_id = uuid.uuid4().hex[:16]
|
||||
now = time.time()
|
||||
job = {
|
||||
"job_id": job_id,
|
||||
"method": str(method or "download_history_data2"),
|
||||
"stock_list": codes,
|
||||
"period": period,
|
||||
"start_time": start_time or "",
|
||||
"end_time": end_time or "",
|
||||
"incrementally": incrementally,
|
||||
"chunk_size": int(chunk_size or DEFAULT_CHUNK_SIZE),
|
||||
"total": len(codes),
|
||||
"done": 0,
|
||||
"state": PENDING,
|
||||
"error": "",
|
||||
"created_at_ts": now,
|
||||
"updated_at_ts": now,
|
||||
}
|
||||
ttl = int(max(1, job_ttl_seconds))
|
||||
redis_client.setex(job_key(account_id, job_id), ttl, _enc(json.dumps(job, ensure_ascii=False)))
|
||||
redis_client.rpush(queue_key(account_id), _enc(job_id))
|
||||
try:
|
||||
redis_client.expire(queue_key(account_id), ttl)
|
||||
except Exception:
|
||||
pass
|
||||
return job
|
||||
|
||||
|
||||
def read_download_status(redis_client, account_id, job_id):
|
||||
"""Return the current job status dict, or None if unknown/expired."""
|
||||
decoded = _dec(redis_client.get(job_key(account_id, job_id)))
|
||||
if not decoded:
|
||||
return None
|
||||
try:
|
||||
job = json.loads(decoded)
|
||||
except Exception:
|
||||
return None
|
||||
return job if isinstance(job, dict) else None
|
||||
|
||||
|
||||
def wait_download_job(
|
||||
redis_client,
|
||||
account_id,
|
||||
job_id,
|
||||
wait_seconds=600.0,
|
||||
poll_interval_seconds=0.5,
|
||||
):
|
||||
"""Block (client-side only) until the job reaches a terminal state or timeout."""
|
||||
deadline = time.time() + max(0.0, float(wait_seconds))
|
||||
while True:
|
||||
status = read_download_status(redis_client, account_id, job_id)
|
||||
if status and status.get("state") in (DONE, FAILED):
|
||||
return status
|
||||
if time.time() >= deadline:
|
||||
return status
|
||||
time.sleep(max(0.05, float(poll_interval_seconds)))
|
||||
|
||||
|
||||
def _write_job(redis_client, account_id, job, job_ttl_seconds):
|
||||
job["updated_at_ts"] = time.time()
|
||||
ttl = int(max(1, job_ttl_seconds))
|
||||
redis_client.setex(job_key(account_id, job["job_id"]), ttl, _enc(json.dumps(job, ensure_ascii=False)))
|
||||
|
||||
|
||||
def _acquire_current_job(redis_client, account_id):
|
||||
ckey = current_key(account_id)
|
||||
current_id = _dec(redis_client.get(ckey))
|
||||
if current_id:
|
||||
job = read_download_status(redis_client, account_id, current_id)
|
||||
if job and job.get("state") in _ACTIVE_STATES:
|
||||
return job
|
||||
# Stale pointer (job done/failed/expired): drop it and pick the next one.
|
||||
redis_client.delete(ckey)
|
||||
while True:
|
||||
job_id = _dec(redis_client.lpop(queue_key(account_id)))
|
||||
if not job_id:
|
||||
return None
|
||||
job = read_download_status(redis_client, account_id, job_id)
|
||||
if job and job.get("state") in _ACTIVE_STATES:
|
||||
redis_client.set(ckey, _enc(job_id))
|
||||
return job
|
||||
# Skip unknown/expired/finished ids left in the queue.
|
||||
|
||||
|
||||
def _download_chunk(market_data, method, chunk, period, start_time, end_time, incrementally):
|
||||
if method == "download_history_data":
|
||||
for code in chunk:
|
||||
market_data.download_history_data(code, period, start_time, end_time, incrementally)
|
||||
else:
|
||||
market_data.download_history_data2(chunk, period, start_time, end_time, incrementally)
|
||||
|
||||
|
||||
def pump_download_jobs(
|
||||
redis_client,
|
||||
market_data,
|
||||
account_id,
|
||||
chunk_size=DEFAULT_CHUNK_SIZE,
|
||||
max_wall_seconds=DEFAULT_MAX_WALL_SECONDS,
|
||||
job_ttl_seconds=DEFAULT_JOB_TTL_SECONDS,
|
||||
):
|
||||
"""Advance the active download job by a bounded slice. Call once per tick.
|
||||
|
||||
Downloads at least one chunk (so progress is always made) and keeps going
|
||||
until the wall-clock budget is spent. Returns a small status summary, or None
|
||||
when there is no active job. Runs on the caller (strategy) thread.
|
||||
"""
|
||||
job = _acquire_current_job(redis_client, account_id)
|
||||
if job is None:
|
||||
return None
|
||||
stock_list = job.get("stock_list") or []
|
||||
total = int(job.get("total") or len(stock_list))
|
||||
done = int(job.get("done") or 0)
|
||||
step = int(job.get("chunk_size") or chunk_size or DEFAULT_CHUNK_SIZE)
|
||||
if step <= 0:
|
||||
step = DEFAULT_CHUNK_SIZE
|
||||
method = str(job.get("method") or "download_history_data2")
|
||||
period = job.get("period")
|
||||
start_time = job.get("start_time") or ""
|
||||
end_time = job.get("end_time") or ""
|
||||
incrementally = job.get("incrementally")
|
||||
|
||||
started_at = time.time()
|
||||
processed_this_tick = 0
|
||||
try:
|
||||
while done < total:
|
||||
# Always run one chunk; only the budget check (after the first) can
|
||||
# stop the tick, so a single heavy chunk is the smallest block unit.
|
||||
if max_wall_seconds and processed_this_tick and (time.time() - started_at) > float(max_wall_seconds):
|
||||
break
|
||||
chunk = stock_list[done:done + step]
|
||||
_download_chunk(market_data, method, chunk, period, start_time, end_time, incrementally)
|
||||
done += len(chunk)
|
||||
processed_this_tick += len(chunk)
|
||||
except Exception as exc:
|
||||
job["state"] = FAILED
|
||||
job["error"] = "%s: %s" % (exc.__class__.__name__, exc)
|
||||
job["done"] = done
|
||||
_write_job(redis_client, account_id, job, job_ttl_seconds)
|
||||
redis_client.delete(current_key(account_id))
|
||||
return {"job_id": job["job_id"], "state": FAILED, "done": done, "total": total, "error": job["error"]}
|
||||
|
||||
job["done"] = done
|
||||
if done >= total:
|
||||
job["state"] = DONE
|
||||
_write_job(redis_client, account_id, job, job_ttl_seconds)
|
||||
redis_client.delete(current_key(account_id))
|
||||
return {"job_id": job["job_id"], "state": DONE, "done": done, "total": total}
|
||||
job["state"] = RUNNING
|
||||
_write_job(redis_client, account_id, job, job_ttl_seconds)
|
||||
return {"job_id": job["job_id"], "state": RUNNING, "done": done, "total": total}
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Real-time order/trade (execution) event push over Redis.
|
||||
|
||||
Big QMT fires ``order_callback(ContextInfo, orderInfo)`` and
|
||||
``deal_callback(ContextInfo, dealInfo)`` inside the strategy process. We normalize
|
||||
the QMT order/deal object (ThinkTrader ``m_*`` fields) into a plain dict and
|
||||
publish it to a Redis channel, so clients receive ``on_stock_order`` /
|
||||
``on_stock_trade`` callbacks in real time (MiniQMT style) instead of polling.
|
||||
|
||||
Channels (also used as capped streams for short replay, xadd + publish):
|
||||
- ``bigqmt:order_events:{account_id}``
|
||||
- ``bigqmt:trade_events:{account_id}``
|
||||
|
||||
The normalized field names match ``BigQmtXtTrader._order_from_dict`` /
|
||||
``_trade_from_dict`` so the client can shape them straight into MiniQMT objects.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
ORDER_CHANNEL_TEMPLATE = "bigqmt:order_events:{account_id}"
|
||||
TRADE_CHANNEL_TEMPLATE = "bigqmt:trade_events:{account_id}"
|
||||
ORDER_ERROR_CHANNEL_TEMPLATE = "bigqmt:order_error_events:{account_id}"
|
||||
CANCEL_ERROR_CHANNEL_TEMPLATE = "bigqmt:cancel_error_events:{account_id}"
|
||||
ORDER_IDENTITY_KEY_TEMPLATE = "bigqmt:order_identity:{account_id}:{user_order_id}"
|
||||
|
||||
EVENT_ORDER = "order"
|
||||
EVENT_TRADE = "trade"
|
||||
EVENT_ORDER_ERROR = "order_error"
|
||||
EVENT_CANCEL_ERROR = "cancel_error"
|
||||
|
||||
# ThinkTrader enum_EEntrustBS (买卖方向, the m_nDirection field), universal across
|
||||
# 股票/期货/期权. Ref: https://dict.thinktrader.net/innerApi/enum_constants.html
|
||||
ENTRUST_BUY = 48 # 买入 / 多
|
||||
ENTRUST_SELL = 49 # 卖出 / 空
|
||||
ENTRUST_PLEDGE_IN = 81 # 质押入库
|
||||
ENTRUST_PLEDGE_OUT = 66 # 质押出库
|
||||
|
||||
# enum_EEntrustBS (买卖方向, the m_nDirection field), per QMT enum docs.
|
||||
# 48=买, 49=卖. Universal across 股票/期货/期权.
|
||||
#
|
||||
# Real-world findings from live COrderDetail/CDealDetail callbacks
|
||||
# (diagnosed via exec_events_debug_raw_fields=True, 2026-07-29):
|
||||
# QMT returns m_nDirection=48 **unconditionally** — even for sell orders.
|
||||
# m_nOffsetFlag correctly reflects direction (48=买, 49=卖 for stocks).
|
||||
# m_nOpType correctly reflects direction (23=买, 24=卖) on orders.
|
||||
# query_orders uses m_nOffsetFlag and works correctly in production.
|
||||
#
|
||||
# Therefore _extract_direction uses an arbitration chain:
|
||||
# Preferred: m_nOffsetFlag (most reliable in live callbacks, matches query_orders)
|
||||
# Fallback: m_nDirection (traditional EEntrustBS; can be stuck at 48 in calls)
|
||||
# Arbiter: when direction≠offset (futures: sell+open=49+48),
|
||||
# consult m_nOpType (23/24) to resolve the conflict; for trades
|
||||
# (no m_nOpType) trust m_nOffsetFlag (QMT docs confirm stock
|
||||
# direction=offset).
|
||||
# Last: order_type (MiniQMT STOCK_BUY=23 / STOCK_SELL=24) and plain text
|
||||
# Unknown -> "" (the raw value is always preserved so callers can refine).
|
||||
OFFSET_OPEN = 48
|
||||
OFFSET_CLOSE = 49
|
||||
OFFSET_CLOSE_TODAY = 51
|
||||
OFFSET_CLOSE_YESTERDAY = 52
|
||||
|
||||
_BUY_DIRECTIONS = {ENTRUST_BUY, str(ENTRUST_BUY), OFFSET_OPEN, str(OFFSET_OPEN), 23, "23", "BUY", "buy", "B"}
|
||||
_SELL_DIRECTIONS = {ENTRUST_SELL, str(ENTRUST_SELL), OFFSET_CLOSE, str(OFFSET_CLOSE), OFFSET_CLOSE_TODAY, str(OFFSET_CLOSE_TODAY), OFFSET_CLOSE_YESTERDAY, str(OFFSET_CLOSE_YESTERDAY), 24, "24", "SELL", "sell", "S"}
|
||||
|
||||
|
||||
def order_channel(account_id):
|
||||
return ORDER_CHANNEL_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def trade_channel(account_id):
|
||||
return TRADE_CHANNEL_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def order_error_channel(account_id):
|
||||
return ORDER_ERROR_CHANNEL_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def cancel_error_channel(account_id):
|
||||
return CANCEL_ERROR_CHANNEL_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def order_identity_key(account_id, user_order_id):
|
||||
return ORDER_IDENTITY_KEY_TEMPLATE.format(
|
||||
account_id=str(account_id or ""),
|
||||
user_order_id=str(user_order_id or ""),
|
||||
)
|
||||
|
||||
|
||||
def _attr(obj, names, default=None):
|
||||
for name in names:
|
||||
if isinstance(obj, dict):
|
||||
if name in obj and obj[name] is not None:
|
||||
return obj[name]
|
||||
else:
|
||||
value = getattr(obj, name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _action_from_direction(direction):
|
||||
if direction in _BUY_DIRECTIONS:
|
||||
return "BUY"
|
||||
if direction in _SELL_DIRECTIONS:
|
||||
return "SELL"
|
||||
return ""
|
||||
|
||||
|
||||
def _is_buy(val):
|
||||
v = int(val)
|
||||
return v in _BUY_DIRECTIONS
|
||||
|
||||
|
||||
def _is_sell(val):
|
||||
v = int(val)
|
||||
return v in _SELL_DIRECTIONS
|
||||
|
||||
|
||||
def _conflict_resolve(d_val, o_val, obj):
|
||||
"""When m_nDirection and m_nOffsetFlag disagree, arbitrate via m_nOpType.
|
||||
|
||||
Live diagnosis confirms:
|
||||
- Stock sell: direction=48(buy), offset=49(sell), op_type=24(sell) → sell
|
||||
- Futures sell+open: direction=49(sell), offset=48(open), op_type=24(sell) → sell
|
||||
- Futures buy+close: direction=48(buy), offset=49(close), op_type=23(buy) → buy
|
||||
|
||||
Returns a resolved value, or None if no arbiter can decide.
|
||||
"""
|
||||
op = _attr(obj, ["m_nOpType", "op_type", "order_type"])
|
||||
if op is not None:
|
||||
try:
|
||||
op_int = int(op)
|
||||
if op_int in _BUY_DIRECTIONS:
|
||||
return d_val if _is_buy(d_val) else o_val if _is_buy(o_val) else op
|
||||
if op_int in _SELL_DIRECTIONS:
|
||||
return d_val if _is_sell(d_val) else o_val if _is_sell(o_val) else op
|
||||
except (TypeError, ValueError):
|
||||
if op in _BUY_DIRECTIONS:
|
||||
return d_val if _is_buy(d_val) else o_val if _is_buy(o_val) else op
|
||||
if op in _SELL_DIRECTIONS:
|
||||
return d_val if _is_sell(d_val) else o_val if _is_sell(o_val) else op
|
||||
# no arbiter — trust offset (QMT docs confirm stock direction=offset)
|
||||
return o_val
|
||||
|
||||
|
||||
def _extract_direction(obj):
|
||||
"""Extract buy/sell direction, matching query_orders' reliable logic.
|
||||
|
||||
Priority chain (documented with live-diagnosis justification):
|
||||
1. m_nOffsetFlag — most reliable in live callbacks (matches query_orders)
|
||||
2. m_nDirection — traditional EEntrustBS (can be stuck at 48)
|
||||
3. Arbitration: when direction≠offset, consult m_nOpType (orders: 23/24)
|
||||
to resolve correctly for both stocks AND futures.
|
||||
4. m_nOpType / order_type — last resort fallback.
|
||||
|
||||
The raw value is always returned (even pledge=81) so callers can inspect it;
|
||||
_action_from_direction maps only known buy/sell values, leaving others "".
|
||||
|
||||
References
|
||||
----------
|
||||
- Live diagnosis 2026-07-29 (COrderDetail/CDealDetail):
|
||||
m_nDirection=48 unconditionally, m_nOffsetFlag=48(buy)/49(sell) correct,
|
||||
m_nOpType=23(buy)/24(sell) correct (orders only).
|
||||
- QMT enum docs: enum_EEntrustBS (48=买,49=卖), enum_EOffset_Flag_Type
|
||||
(48=开仓,49=平仓). For stocks direction=offset; for futures they differ.
|
||||
- query_orders uses m_nOffsetFlag and works correctly in production.
|
||||
"""
|
||||
offset = _attr(obj, ["m_nOffsetFlag", "offset_flag"])
|
||||
direction = _attr(obj, ["m_nDirection", "direction"])
|
||||
|
||||
# 1. offset alone — use it directly (matches query_orders)
|
||||
if offset is not None and direction is None:
|
||||
try:
|
||||
o = int(offset)
|
||||
if o in _BUY_DIRECTIONS or o in _SELL_DIRECTIONS:
|
||||
return offset
|
||||
except (TypeError, ValueError):
|
||||
if offset in _BUY_DIRECTIONS or offset in _SELL_DIRECTIONS:
|
||||
return offset
|
||||
|
||||
# 2. direction alone — use it
|
||||
if direction is not None and offset is None:
|
||||
try:
|
||||
d = int(direction)
|
||||
if d in _BUY_DIRECTIONS or d in _SELL_DIRECTIONS:
|
||||
return direction
|
||||
if d != 0:
|
||||
return direction
|
||||
except (TypeError, ValueError):
|
||||
if direction in _BUY_DIRECTIONS or direction in _SELL_DIRECTIONS:
|
||||
return direction
|
||||
return direction
|
||||
|
||||
# 3. both present
|
||||
if direction is not None and offset is not None:
|
||||
try:
|
||||
d = int(direction)
|
||||
o = int(offset)
|
||||
d_valid = (d in _BUY_DIRECTIONS or d in _SELL_DIRECTIONS)
|
||||
o_valid = (o in _BUY_DIRECTIONS or o in _SELL_DIRECTIONS)
|
||||
|
||||
if d_valid and o_valid:
|
||||
if d == o:
|
||||
return direction # agree → use either
|
||||
# disagree → arbitrate via m_nOpType
|
||||
return _conflict_resolve(d, o, obj)
|
||||
|
||||
if d_valid and not o_valid:
|
||||
return direction
|
||||
if o_valid and not d_valid:
|
||||
return offset
|
||||
# neither valid — fall through
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 4. last resort: m_nOpType / order_type
|
||||
return _attr(obj, ["m_nOpType", "op_type", "order_type"])
|
||||
|
||||
|
||||
# Fields we care about when diagnosing a direction misread. Anything starting
|
||||
# with "m_" is captured automatically; these are the MiniQMT-style names that
|
||||
# do not match that prefix.
|
||||
_RAW_SNAPSHOT_EXTRA_FIELDS = (
|
||||
"stock_code",
|
||||
"order_type",
|
||||
"op_type",
|
||||
"direction",
|
||||
"offset_flag",
|
||||
"order_status",
|
||||
"order_volume",
|
||||
"traded_volume",
|
||||
"price",
|
||||
"order_id",
|
||||
"order_sysid",
|
||||
"order_sys_id",
|
||||
"trade_id",
|
||||
"traded_id",
|
||||
"strategy_name",
|
||||
"strategyName",
|
||||
"user_order_id",
|
||||
"order_remark",
|
||||
"remark",
|
||||
)
|
||||
|
||||
|
||||
def raw_field_snapshot(obj, max_repr=120):
|
||||
"""Capture every readable field of a live QMT callback object.
|
||||
|
||||
Direction extraction relies on understanding what ``m_nDirection``,
|
||||
``m_nOffsetFlag`` and ``m_nOpType`` carry in live callbacks. This dumps
|
||||
every readable field so one live order settles the question.
|
||||
|
||||
Returns ``{name: "<type> <value>"}``. Never raises: a callback that dies
|
||||
while being diagnosed would be worse than no diagnosis.
|
||||
"""
|
||||
snapshot = {}
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
names = list(obj.keys())
|
||||
else:
|
||||
names = [name for name in dir(obj) if name.startswith("m_")]
|
||||
names.extend(_RAW_SNAPSHOT_EXTRA_FIELDS)
|
||||
except Exception:
|
||||
return {"__error__": "dir() failed"}
|
||||
seen = set()
|
||||
for name in names:
|
||||
key = str(name)
|
||||
if key in seen or key.startswith("__"):
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
if key not in obj:
|
||||
continue
|
||||
value = obj[key]
|
||||
else:
|
||||
if not hasattr(obj, key):
|
||||
continue
|
||||
value = getattr(obj, key)
|
||||
if callable(value):
|
||||
continue
|
||||
text = repr(value)
|
||||
if len(text) > max_repr:
|
||||
text = text[:max_repr] + "..."
|
||||
snapshot[key] = "%s %s" % (type(value).__name__, text)
|
||||
except Exception as exc: # noqa: BLE001 - diagnostics must not break callbacks
|
||||
snapshot[key] = "<unreadable: %s>" % exc.__class__.__name__
|
||||
return snapshot
|
||||
|
||||
|
||||
def format_raw_snapshot(kind, obj):
|
||||
"""One-line, GBK-safe rendering of :func:`raw_field_snapshot` for the QMT panel."""
|
||||
snapshot = raw_field_snapshot(obj)
|
||||
parts = ["%s=%s" % (name, snapshot[name]) for name in sorted(snapshot)]
|
||||
return "[bigqmt_exec_raw] %s type=%s %s" % (
|
||||
kind,
|
||||
type(obj).__name__,
|
||||
" | ".join(parts) or "<no fields>",
|
||||
)
|
||||
|
||||
|
||||
def normalize_order_event(order, account_id=""):
|
||||
"""Build a JSON-able order event dict from a Big QMT orderInfo object."""
|
||||
direction = _extract_direction(order)
|
||||
return {
|
||||
"event_type": EVENT_ORDER,
|
||||
"account_id": str(_attr(order, ["m_strAccountID", "account_id"], account_id) or account_id or ""),
|
||||
"stock_code": str(_attr(order, ["m_strInstrumentID", "stock_code", "m_strInstrument"], "") or ""),
|
||||
"order_sys_id": str(_attr(order, ["m_strOrderSysID", "order_sys_id", "order_sysid", "order_id"], "") or ""),
|
||||
"order_volume": _attr(order, ["m_nVolumeTotal", "order_volume", "volume"]),
|
||||
"traded_volume": _attr(order, ["m_nVolumeTraded", "traded_volume"]),
|
||||
"price": _attr(order, ["m_dLimitPrice", "price", "limit_price"]),
|
||||
"status": _attr(order, ["m_nOrderStatus", "order_status", "status"]),
|
||||
"direction": direction,
|
||||
"action": _action_from_direction(direction),
|
||||
"offset_flag": _attr(order, ["m_nOffsetFlag", "offset_flag"]),
|
||||
"strategy_name": str(_attr(order, ["strategyName", "m_strStrategyName", "strategy_name"], "") or ""),
|
||||
"remark": str(_attr(order, ["m_strRemark", "order_remark", "remark", "user_order_id"], "") or ""),
|
||||
"user_order_id": str(_attr(order, ["m_strRemark", "user_order_id", "order_remark", "remark"], "") or ""),
|
||||
"opt_name": str(_attr(order, ["m_strOptName", "opt_name"], "") or ""),
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"created_at_ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def remember_order_identity(redis_client, account_id, user_order_id, strategy_name="", stock_code="", ttl_seconds=86400):
|
||||
user_order_id = str(user_order_id or "").strip()
|
||||
if not user_order_id or redis_client is None:
|
||||
return None
|
||||
payload = {
|
||||
"account_id": str(account_id or ""),
|
||||
"user_order_id": user_order_id,
|
||||
"strategy_name": str(strategy_name or ""),
|
||||
"stock_code": str(stock_code or ""),
|
||||
"created_at_ts": time.time(),
|
||||
}
|
||||
try:
|
||||
redis_client.setex(
|
||||
order_identity_key(account_id, user_order_id),
|
||||
int(ttl_seconds or 86400),
|
||||
json.dumps(payload, ensure_ascii=False, default=str),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
|
||||
|
||||
def enrich_order_identity(redis_client, account_id, event):
|
||||
if redis_client is None or not isinstance(event, dict):
|
||||
return event
|
||||
user_order_id = str(event.get("user_order_id") or event.get("remark") or "").strip()
|
||||
if not user_order_id:
|
||||
return event
|
||||
try:
|
||||
raw = redis_client.get(order_identity_key(account_id, user_order_id))
|
||||
except Exception:
|
||||
raw = None
|
||||
if not raw:
|
||||
return event
|
||||
try:
|
||||
identity = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw))
|
||||
except Exception:
|
||||
return event
|
||||
if not event.get("strategy_name") and identity.get("strategy_name"):
|
||||
event["strategy_name"] = str(identity.get("strategy_name") or "")
|
||||
if not event.get("stock_code") and identity.get("stock_code"):
|
||||
event["stock_code"] = str(identity.get("stock_code") or "")
|
||||
return event
|
||||
|
||||
|
||||
def normalize_trade_event(trade, account_id=""):
|
||||
"""Build a JSON-able trade (成交) event dict from a Big QMT dealInfo object."""
|
||||
direction = _extract_direction(trade)
|
||||
return {
|
||||
"event_type": EVENT_TRADE,
|
||||
"account_id": str(_attr(trade, ["m_strAccountID", "account_id"], account_id) or account_id or ""),
|
||||
"stock_code": str(_attr(trade, ["m_strInstrumentID", "stock_code"], "") or ""),
|
||||
"order_sys_id": str(_attr(trade, ["m_strOrderSysID", "order_sys_id", "order_sysid", "order_id"], "") or ""),
|
||||
"trade_id": str(_attr(trade, ["m_strTradeID", "trade_id"], "") or ""),
|
||||
"volume": _attr(trade, ["m_nVolume", "volume", "traded_volume"]),
|
||||
"price": _attr(trade, ["m_dPrice", "price", "traded_price"]),
|
||||
"amount": _attr(trade, ["m_dTradeAmount", "amount"]),
|
||||
"commission": _attr(trade, ["m_dComssion", "m_dCommission", "commission"]),
|
||||
"direction": direction,
|
||||
"action": _action_from_direction(direction),
|
||||
"offset_flag": _attr(trade, ["m_nOffsetFlag", "offset_flag"]),
|
||||
"traded_at": str(_attr(trade, ["m_strTradeTime", "traded_at", "trade_time"], "") or ""),
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"created_at_ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def _publish(redis_client, channel, event, maxlen=2000):
|
||||
raw = json.dumps(event, ensure_ascii=False, default=str)
|
||||
try:
|
||||
redis_client.xadd(channel, {"payload": raw}, maxlen=maxlen, approximate=True)
|
||||
except Exception:
|
||||
pass
|
||||
redis_client.publish(channel, raw)
|
||||
return event
|
||||
|
||||
|
||||
def publish_order_event(redis_client, account_id, event):
|
||||
return _publish(redis_client, order_channel(account_id), event)
|
||||
|
||||
|
||||
def publish_trade_event(redis_client, account_id, event):
|
||||
return _publish(redis_client, trade_channel(account_id), event)
|
||||
|
||||
|
||||
def publish_order_error_event(redis_client, account_id, event):
|
||||
return _publish(redis_client, order_error_channel(account_id), event)
|
||||
|
||||
|
||||
def publish_cancel_error_event(redis_client, account_id, event):
|
||||
return _publish(redis_client, cancel_error_channel(account_id), event)
|
||||
|
||||
|
||||
def normalize_order_error_event(order_error, account_id=""):
|
||||
"""Build a JSON-able order-error event dict (废单/拒单).
|
||||
|
||||
QMT order callbacks carry the failed order via m_strOrderSysID / error info.
|
||||
MiniQMT's on_order_error receives an XtOrderError with error_id/error_msg.
|
||||
"""
|
||||
return {
|
||||
"event_type": EVENT_ORDER_ERROR,
|
||||
"account_id": str(_attr(order_error, ["m_strAccountID", "account_id"], account_id) or account_id or ""),
|
||||
"stock_code": str(_attr(order_error, ["m_strInstrumentID", "stock_code"], "") or ""),
|
||||
"order_sys_id": str(_attr(order_error, ["m_strOrderSysID", "order_sys_id", "order_sysid", "order_id"], "") or ""),
|
||||
"error_id": _attr(order_error, ["m_nErrorID", "error_id", "m_nOrderStatus"]),
|
||||
"error_msg": str(_attr(order_error, ["m_strErrorMsg", "error_msg", "m_strMsg"], "") or ""),
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"created_at_ts": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def normalize_cancel_error_event(cancel_error, account_id=""):
|
||||
"""Build a JSON-able cancel-error event dict (撤单失败)."""
|
||||
return {
|
||||
"event_type": EVENT_CANCEL_ERROR,
|
||||
"account_id": str(_attr(cancel_error, ["m_strAccountID", "account_id"], account_id) or account_id or ""),
|
||||
"stock_code": str(_attr(cancel_error, ["m_strInstrumentID", "stock_code"], "") or ""),
|
||||
"order_sys_id": str(_attr(cancel_error, ["m_strOrderSysID", "order_sys_id", "order_sysid", "order_id"], "") or ""),
|
||||
"error_id": _attr(cancel_error, ["m_nErrorID", "error_id"]),
|
||||
"error_msg": str(_attr(cancel_error, ["m_strErrorMsg", "error_msg", "m_strMsg"], "") or ""),
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"created_at_ts": time.time(),
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Direct client for the Big QMT FormulaServer RPC (default port 58600).
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The RPC bridge in :mod:`redis_rpc` routes every read through the QMT *strategy*
|
||||
process: client -> redis/zmq -> QMT python thread -> ContextInfo -> back. That
|
||||
costs ~13ms (redis) or ~0.7ms-with-500ms-GIL-spikes (zmq), and every read
|
||||
competes for the QMT main-thread GIL against the strategy itself.
|
||||
|
||||
FormulaServer is the C++ quote/reference-data service inside the same QMT
|
||||
terminal, listening on the port named in ``config/formulaserver/formulaserver.ini``
|
||||
(``[server_formula] address``, default 58600). QMT ships its own client for it at
|
||||
``bin.x64/Lib/site-packages/qmt_api``. Talking to it directly bypasses the
|
||||
strategy process entirely: measured p50 **0.07ms**, and zero GIL contention.
|
||||
|
||||
What it can and cannot do
|
||||
-------------------------
|
||||
FormulaServer serves market/reference data ONLY. Every account, position, order
|
||||
and trade method answers ``ErrorID 200005 未找到该服务``, as do ``getFullTick``
|
||||
and ``getQuote``. So this is a read fast-path, never a replacement for the RPC
|
||||
bridge — trading, account queries and 五档 snapshots stay on it.
|
||||
|
||||
Deliberately NOT routed here, despite FormulaServer exposing something similar:
|
||||
|
||||
* ``get_trading_dates`` — FormulaServer wants a *stock code* (``000001.SZ``);
|
||||
passing a market (``SH``) silently returns ``[]``. Our callers pass markets.
|
||||
* ``get_divid_factors`` / ``get_risk_free_rate`` — parameter semantics differ
|
||||
(range vs single date, index vs timetag). A wrong calendar or dividend factor
|
||||
is worse than a slow one.
|
||||
* Adjusted bars — see :func:`_market_data_params`; ``dividendType`` appears to
|
||||
be ignored by the server, so only unadjusted requests are routed.
|
||||
|
||||
Every failure here is non-fatal: :class:`FormulaServerRouter` reports the method
|
||||
as unroutable and the caller falls back to the normal RPC path.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 58600
|
||||
DEFAULT_TIMEOUT_SECONDS = 3.0
|
||||
# After a transport failure, stop trying for this long so a dead/absent
|
||||
# FormulaServer costs one timeout rather than one per call.
|
||||
DEFAULT_FAILURE_COOLDOWN_SECONDS = 30.0
|
||||
|
||||
NET_CMD_RPC = 3
|
||||
COMPRESS_ZLIB = 1
|
||||
COMPRESS_DOUBLE_ZLIB = 2
|
||||
|
||||
# FormulaServer's "method not found" code. Distinct from a transport failure:
|
||||
# it means the server is healthy and simply does not implement the call.
|
||||
ERROR_METHOD_NOT_FOUND = 200005
|
||||
|
||||
|
||||
class FormulaServerError(RuntimeError):
|
||||
"""FormulaServer answered with a non-zero status (bad params, no such method)."""
|
||||
|
||||
def __init__(self, message, error_id=None):
|
||||
RuntimeError.__init__(self, message)
|
||||
self.error_id = error_id
|
||||
|
||||
|
||||
class FormulaServerUnavailable(RuntimeError):
|
||||
"""The FormulaServer could not be reached (connect/IO/protocol failure)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BSON codec
|
||||
# ---------------------------------------------------------------------------
|
||||
# FormulaServer frames BSON documents. pymongo's ``bson`` is used when present
|
||||
# (faster, battle-tested); otherwise the minimal codec below covers the types
|
||||
# this wire actually carries. Keeping a built-in path means an external client
|
||||
# needs no pymongo just to read market data.
|
||||
|
||||
def _load_bson():
|
||||
for module_name in ("bson", "xtquant.xtbson.bson36"):
|
||||
try:
|
||||
module = __import__(module_name, fromlist=["BSON"])
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(module, "BSON"):
|
||||
return module
|
||||
return None
|
||||
|
||||
|
||||
_BSON = _load_bson()
|
||||
|
||||
|
||||
def _encode_document(pairs):
|
||||
body = b"".join(_encode_element(str(key), value) for key, value in pairs)
|
||||
return struct.pack("<i", len(body) + 5) + body + b"\x00"
|
||||
|
||||
|
||||
def _encode_element(name, value):
|
||||
key = name.encode("utf-8") + b"\x00"
|
||||
if value is None:
|
||||
return b"\x0a" + key
|
||||
# bool before int: bool is an int subclass.
|
||||
if isinstance(value, bool):
|
||||
return b"\x08" + key + (b"\x01" if value else b"\x00")
|
||||
if isinstance(value, int):
|
||||
if -2147483648 <= value <= 2147483647:
|
||||
return b"\x10" + key + struct.pack("<i", value)
|
||||
return b"\x12" + key + struct.pack("<q", value)
|
||||
if isinstance(value, float):
|
||||
return b"\x01" + key + struct.pack("<d", value)
|
||||
if isinstance(value, bytes):
|
||||
return b"\x05" + key + struct.pack("<i", len(value)) + b"\x00" + value
|
||||
if isinstance(value, str):
|
||||
raw = value.encode("utf-8") + b"\x00"
|
||||
return b"\x02" + key + struct.pack("<i", len(raw)) + raw
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b"\x04" + key + _encode_document(
|
||||
(str(index), item) for index, item in enumerate(value)
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return b"\x03" + key + _encode_document(value.items())
|
||||
raise TypeError("cannot BSON-encode %s" % type(value).__name__)
|
||||
|
||||
|
||||
def _decode_document(data, pos):
|
||||
size = struct.unpack_from("<i", data, pos)[0]
|
||||
end = pos + size
|
||||
pos += 4
|
||||
out = {}
|
||||
while pos < end - 1:
|
||||
type_byte = data[pos] if isinstance(data[pos], int) else ord(data[pos])
|
||||
pos += 1
|
||||
terminator = data.index(b"\x00", pos)
|
||||
name = data[pos:terminator].decode("utf-8", "replace")
|
||||
pos = terminator + 1
|
||||
out[name], pos = _decode_element(type_byte, data, pos)
|
||||
return out, end
|
||||
|
||||
|
||||
def _decode_element(type_byte, data, pos):
|
||||
if type_byte == 0x01:
|
||||
return struct.unpack_from("<d", data, pos)[0], pos + 8
|
||||
if type_byte == 0x02:
|
||||
length = struct.unpack_from("<i", data, pos)[0]
|
||||
pos += 4
|
||||
return data[pos:pos + length - 1].decode("utf-8", "replace"), pos + length
|
||||
if type_byte == 0x03:
|
||||
return _decode_document(data, pos)
|
||||
if type_byte == 0x04:
|
||||
doc, end = _decode_document(data, pos)
|
||||
try:
|
||||
ordered = sorted(doc, key=lambda key: int(key))
|
||||
except (TypeError, ValueError):
|
||||
ordered = sorted(doc)
|
||||
return [doc[key] for key in ordered], end
|
||||
if type_byte == 0x05:
|
||||
length = struct.unpack_from("<i", data, pos)[0]
|
||||
pos += 5 # int32 length + 1 subtype byte
|
||||
return data[pos:pos + length], pos + length
|
||||
if type_byte == 0x08:
|
||||
flag = data[pos] if isinstance(data[pos], int) else ord(data[pos])
|
||||
return bool(flag), pos + 1
|
||||
if type_byte in (0x09, 0x12):
|
||||
return struct.unpack_from("<q", data, pos)[0], pos + 8
|
||||
if type_byte == 0x0A:
|
||||
return None, pos
|
||||
if type_byte == 0x10:
|
||||
return struct.unpack_from("<i", data, pos)[0], pos + 4
|
||||
if type_byte == 0x11:
|
||||
return struct.unpack_from("<Q", data, pos)[0], pos + 8
|
||||
raise ValueError("unsupported BSON type byte 0x%02x" % type_byte)
|
||||
|
||||
|
||||
def bson_encode(document):
|
||||
if _BSON is not None:
|
||||
return _BSON.BSON.encode(document)
|
||||
return _encode_document(document.items())
|
||||
|
||||
|
||||
def bson_decode(payload):
|
||||
if _BSON is not None:
|
||||
return _BSON.BSON(payload).decode()
|
||||
return _decode_document(payload, 0)[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Address discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_formulaserver_port(qmt_root):
|
||||
"""Read ``[server_formula] address`` from a QMT install's formulaserver.ini.
|
||||
|
||||
``qmt_root`` is the terminal directory (the one holding ``bin.x64`` and
|
||||
``config``). Returns the port int, or None when the file is absent or
|
||||
unparsable — callers then fall back to :data:`DEFAULT_PORT`.
|
||||
"""
|
||||
if not qmt_root:
|
||||
return None
|
||||
path = os.path.join(str(qmt_root), "config", "formulaserver", "formulaserver.ini")
|
||||
try:
|
||||
try:
|
||||
import configparser
|
||||
except ImportError: # pragma: no cover - py2 safety net
|
||||
import ConfigParser as configparser
|
||||
parser = configparser.ConfigParser()
|
||||
if not parser.read(path):
|
||||
return None
|
||||
address = parser.get("server_formula", "address")
|
||||
except Exception:
|
||||
return None
|
||||
if ":" not in str(address):
|
||||
return None
|
||||
try:
|
||||
return int(str(address).rsplit(":", 1)[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_address(config=None):
|
||||
"""Resolve (host, port) for the FormulaServer.
|
||||
|
||||
Priority: explicit ``host``/``port`` > ``formulaserver.ini`` under
|
||||
``qmt_root`` > ``BIGQMT_FORMULA_HOST``/``BIGQMT_FORMULA_PORT`` > defaults.
|
||||
The address binds ``0.0.0.0`` in QMT's shipped config, so a remote client
|
||||
can reach it too when the firewall allows.
|
||||
"""
|
||||
config = dict(config or {})
|
||||
host = str(config.get("host") or os.environ.get("BIGQMT_FORMULA_HOST") or DEFAULT_HOST)
|
||||
port = config.get("port")
|
||||
if not port:
|
||||
port = read_formulaserver_port(config.get("qmt_root"))
|
||||
if not port:
|
||||
port = os.environ.get("BIGQMT_FORMULA_PORT")
|
||||
try:
|
||||
port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
port = DEFAULT_PORT
|
||||
return host, port
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FormulaServerClient(object):
|
||||
"""Thread-safe BSON-over-TCP client for FormulaServer.
|
||||
|
||||
One socket is shared under a lock. FormulaServer matches responses by
|
||||
sequence number, so concurrent use of a single socket would require
|
||||
demultiplexing; serializing is simpler and, at 0.07ms per call, ample.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host=DEFAULT_HOST,
|
||||
port=DEFAULT_PORT,
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
print_prefix="[bigqmt_formula]",
|
||||
):
|
||||
self.host = str(host or DEFAULT_HOST)
|
||||
self.port = int(port or DEFAULT_PORT)
|
||||
self.timeout_seconds = float(timeout_seconds or DEFAULT_TIMEOUT_SECONDS)
|
||||
self.print_prefix = print_prefix
|
||||
self._lock = threading.Lock()
|
||||
self._socket = None
|
||||
self._seq = 0
|
||||
|
||||
# -- wire ------------------------------------------------------------
|
||||
def _connect_locked(self):
|
||||
if self._socket is not None:
|
||||
return self._socket
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), self.timeout_seconds)
|
||||
sock.settimeout(self.timeout_seconds)
|
||||
except Exception as exc:
|
||||
raise FormulaServerUnavailable(
|
||||
"connect %s:%s failed: %s" % (self.host, self.port, exc)
|
||||
)
|
||||
self._socket = sock
|
||||
return sock
|
||||
|
||||
def _close_locked(self):
|
||||
if self._socket is not None:
|
||||
try:
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
self._close_locked()
|
||||
|
||||
def _recv_exactly(self, sock, length):
|
||||
chunks = []
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
more = sock.recv(remaining)
|
||||
if not more:
|
||||
raise FormulaServerUnavailable("socket closed mid-message")
|
||||
chunks.append(more)
|
||||
remaining -= len(more)
|
||||
return b"".join(chunks)
|
||||
|
||||
def _request_locked(self, func, params):
|
||||
sock = self._connect_locked()
|
||||
self._seq += 1
|
||||
seq = self._seq
|
||||
body = bson_encode({"func": str(func), "params": dict(params or {})})
|
||||
tag = ((seq >> 32) & 0x0F) << 8
|
||||
packet = struct.pack(
|
||||
"!IIHH%ds" % len(body),
|
||||
len(body) + 12,
|
||||
seq & 0xFFFFFFFF,
|
||||
NET_CMD_RPC,
|
||||
tag,
|
||||
body,
|
||||
)
|
||||
try:
|
||||
sock.sendall(packet)
|
||||
except Exception as exc:
|
||||
raise FormulaServerUnavailable("send failed: %s" % exc)
|
||||
# Subscription pushes share the socket; skip anything that is not our seq.
|
||||
while True:
|
||||
try:
|
||||
header = self._recv_exactly(sock, 4)
|
||||
pack_len = struct.unpack_from("!I", header, 0)[0]
|
||||
rest = self._recv_exactly(sock, pack_len - 4)
|
||||
except FormulaServerUnavailable:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise FormulaServerUnavailable("recv failed: %s" % exc)
|
||||
raw = header + rest
|
||||
try:
|
||||
got_seq, _cmd, got_tag, payload_bytes = struct.unpack_from(
|
||||
"!IHH%ds" % (pack_len - 12), raw, 4
|
||||
)
|
||||
if (got_tag & 7) in (COMPRESS_ZLIB, COMPRESS_DOUBLE_ZLIB):
|
||||
payload_bytes = zlib.decompress(payload_bytes)
|
||||
got_seq = ((got_tag >> 8) & 0x0F) << 32 | got_seq
|
||||
payload = bson_decode(payload_bytes)
|
||||
except Exception as exc:
|
||||
raise FormulaServerUnavailable("decode failed: %s" % exc)
|
||||
if got_seq != seq:
|
||||
continue
|
||||
if payload.get("status") == 0:
|
||||
return payload.get("params")
|
||||
detail = payload.get("params")
|
||||
error_id = None
|
||||
if isinstance(detail, dict):
|
||||
error_id = detail.get("ErrorID")
|
||||
raise FormulaServerError(
|
||||
"%s failed: %r" % (func, detail), error_id=error_id
|
||||
)
|
||||
|
||||
def request(self, func, params=None):
|
||||
"""Call ``func`` and return its ``params`` payload.
|
||||
|
||||
Retries once on a transport failure, since QMT restarts (or an idle
|
||||
socket reaped by the server) show up as a dead socket on first use.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
return self._request_locked(func, params)
|
||||
except FormulaServerError:
|
||||
raise
|
||||
except FormulaServerUnavailable:
|
||||
self._close_locked()
|
||||
return self._request_locked(func, params)
|
||||
|
||||
def ping(self):
|
||||
"""Cheap liveness probe. True when FormulaServer answers at all.
|
||||
|
||||
A ``FormulaServerError`` still counts as alive — the server replied.
|
||||
"""
|
||||
try:
|
||||
self.request("getLastVolume", {"stockCode": "000001.SZ"})
|
||||
return True
|
||||
except FormulaServerError:
|
||||
return True
|
||||
except FormulaServerUnavailable:
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return "<FormulaServerClient %s:%s>" % (self.host, self.port)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Method mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
# FormulaServer misspells two instrument fields relative to the xtdata SDK
|
||||
# (``FloatVolume``/``TotalVolume``). Downstream code reads the SDK spelling, so
|
||||
# alias them rather than let the lookup silently miss.
|
||||
_INSTRUMENT_ALIASES = (
|
||||
("FloatVolumn", "FloatVolume"),
|
||||
("TotalVolumn", "TotalVolume"),
|
||||
)
|
||||
|
||||
|
||||
def _first(params, names, default=None):
|
||||
for name in names:
|
||||
if name in params and params[name] is not None:
|
||||
return params[name]
|
||||
return default
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return list(value)
|
||||
|
||||
|
||||
def _require_code(params, names):
|
||||
code = _first(params, names)
|
||||
text = str(code or "").strip()
|
||||
if not text:
|
||||
raise ValueError("a stock code is required (one of %s)" % ", ".join(names))
|
||||
return text
|
||||
|
||||
|
||||
def _instrument_params(params):
|
||||
return {"strOptionCode": _require_code(params, ("code", "stock_code", "stockcode"))}
|
||||
|
||||
|
||||
def _instrument_result(raw, params):
|
||||
detail = (raw or {}).get("result")
|
||||
if not isinstance(detail, dict):
|
||||
return detail or {}
|
||||
out = dict(detail)
|
||||
for wire_name, sdk_name in _INSTRUMENT_ALIASES:
|
||||
if wire_name in out and sdk_name not in out:
|
||||
out[sdk_name] = out[wire_name]
|
||||
return out
|
||||
|
||||
|
||||
def _scalar_result(raw, params):
|
||||
return (raw or {}).get("result")
|
||||
|
||||
|
||||
def _list_result(raw, params):
|
||||
return (raw or {}).get("result") or []
|
||||
|
||||
|
||||
def _last_volume_params(params):
|
||||
return {"stockCode": _require_code(params, ("stock", "code", "stock_code", "stockcode"))}
|
||||
|
||||
|
||||
def _total_share_params(params):
|
||||
return {"stockCode": _require_code(params, ("stockcode", "code", "stock_code", "stock"))}
|
||||
|
||||
|
||||
def _contract_multiplier_params(params):
|
||||
return {"contractCode": _require_code(params, ("stockcode", "code", "stock_code", "contract_code"))}
|
||||
|
||||
|
||||
def _main_contract_params(params):
|
||||
return {"codeMarket": _require_code(params, ("code_market", "codeMarket", "code"))}
|
||||
|
||||
|
||||
def _sector_params(params):
|
||||
name = str(_first(params, ("sector_name", "sectorName", "sector"), "") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("sector_name is required")
|
||||
# ContextInfo's real_timetag defaults to -1; FormulaServer's realtime
|
||||
# defaults to 0. Both return identical constituents (verified), so normalize
|
||||
# the sentinel rather than forward a value the server never documents.
|
||||
realtime = _first(params, ("real_timetag", "realtime"), 0)
|
||||
try:
|
||||
realtime = int(realtime)
|
||||
except (TypeError, ValueError):
|
||||
realtime = 0
|
||||
if realtime < 0:
|
||||
realtime = 0
|
||||
return {"sectorName": name, "realtime": realtime}
|
||||
|
||||
|
||||
def _weight_in_index_params(params):
|
||||
index_code = _first(params, ("mtkindexcode", "index_code", "indexCode"))
|
||||
stock_code = _first(params, ("stockcode", "stock_code", "code"))
|
||||
if not index_code or not stock_code:
|
||||
raise ValueError("mtkindexcode and stockcode are required")
|
||||
return {"indexCode": str(index_code), "stockCode": str(stock_code)}
|
||||
|
||||
|
||||
def _market_data_params(params):
|
||||
fields = _as_list(_first(params, ("field_list", "fields"), None))
|
||||
codes = _as_list(_first(params, ("stock_list", "stock_code", "stockCodes"), None))
|
||||
if not fields or not codes:
|
||||
raise ValueError("field_list and stock_list are required")
|
||||
dividend_type = str(params.get("dividend_type") or "none").lower()
|
||||
# FormulaServer returns byte-identical bars for dividendType none/front, so
|
||||
# adjustment is not applied here. Serving an adjusted request from this path
|
||||
# would silently hand back unadjusted prices — refuse and let RPC answer.
|
||||
if dividend_type not in ("", "none"):
|
||||
raise ValueError("adjusted bars (dividend_type=%s) are not served here" % dividend_type)
|
||||
period = str(params.get("period") or "1d")
|
||||
count = params.get("count", -1)
|
||||
try:
|
||||
count = int(count)
|
||||
except (TypeError, ValueError):
|
||||
count = -1
|
||||
return {
|
||||
"fields": [str(field) for field in fields],
|
||||
"stockCodes": [str(code) for code in codes],
|
||||
"startTime": str(params.get("start_time") or ""),
|
||||
"endTime": str(params.get("end_time") or ""),
|
||||
"period": period,
|
||||
"dividendType": "none",
|
||||
"count": count,
|
||||
}
|
||||
|
||||
|
||||
def _market_data_result(raw, params):
|
||||
"""Translate FormulaServer's flat bar list into the RPC path's payload.
|
||||
|
||||
Wire shape is ``[code, [time, [field, value, ...], time, [...]], code, ...]``.
|
||||
We emit the same ``__bigqmt_type__: DataFrame`` envelope the QMT-side adapter
|
||||
builds, so the client's ``_restore_jsonable`` rebuilds identical DataFrames
|
||||
whichever path answered.
|
||||
"""
|
||||
flat = (raw or {}).get("result") or []
|
||||
fields = [str(field) for field in (_first(params, ("field_list", "fields"), None) or [])]
|
||||
columns = list(fields)
|
||||
if columns and "stime" not in columns:
|
||||
columns.insert(0, "stime")
|
||||
|
||||
parsed = {}
|
||||
for index in range(0, len(flat) - 1, 2):
|
||||
code = str(flat[index])
|
||||
timeline = flat[index + 1] or []
|
||||
records = []
|
||||
for offset in range(0, len(timeline) - 1, 2):
|
||||
stamp = timeline[offset]
|
||||
pairs = timeline[offset + 1] or []
|
||||
record = {"stime": stamp}
|
||||
for cursor in range(0, len(pairs) - 1, 2):
|
||||
record[str(pairs[cursor])] = pairs[cursor + 1]
|
||||
records.append(record)
|
||||
parsed[code] = records
|
||||
|
||||
requested = [str(code) for code in _as_list(_first(params, ("stock_list", "stock_code"), None))]
|
||||
for code in parsed:
|
||||
if code not in requested:
|
||||
requested.append(code)
|
||||
return {
|
||||
code: {
|
||||
"__bigqmt_type__": "DataFrame",
|
||||
"columns": columns,
|
||||
"records": parsed.get(code) or [],
|
||||
}
|
||||
for code in requested
|
||||
}
|
||||
|
||||
|
||||
# our RPC method -> (FormulaServer func, param builder, result adapter)
|
||||
METHOD_MAP = {
|
||||
"get_instrument": ("getInstrumentDetail", _instrument_params, _instrument_result),
|
||||
"get_instrumentdetail": ("getInstrumentDetail", _instrument_params, _instrument_result),
|
||||
"get_instrument_detail": ("getInstrumentDetail", _instrument_params, _instrument_result),
|
||||
"get_last_volume": ("getLastVolume", _last_volume_params, _scalar_result),
|
||||
"get_total_share": ("getTotalShare", _total_share_params, _scalar_result),
|
||||
"get_contract_multiplier": ("getContractMultiplier", _contract_multiplier_params, _scalar_result),
|
||||
"get_main_contract": ("getMainContract", _main_contract_params, _scalar_result),
|
||||
"get_weight_in_index": ("getWeightInIndex", _weight_in_index_params, _scalar_result),
|
||||
"get_stock_list_in_sector": ("getStockListInSector", _sector_params, _list_result),
|
||||
"get_market_data_ex": ("getMarketData", _market_data_params, _market_data_result),
|
||||
}
|
||||
|
||||
SUPPORTED_METHODS = tuple(sorted(METHOD_MAP))
|
||||
|
||||
|
||||
class Unroutable(Exception):
|
||||
"""This call cannot be served by FormulaServer — use the RPC bridge."""
|
||||
|
||||
|
||||
class FormulaServerRouter(object):
|
||||
"""Routes supported read methods to FormulaServer, or declines.
|
||||
|
||||
:meth:`call` raises :class:`Unroutable` for anything it cannot serve —
|
||||
method not mapped, params that do not translate, server down, feature
|
||||
disabled. Callers treat that as "fall back to RPC".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client=None,
|
||||
enabled=True,
|
||||
methods=None,
|
||||
failure_cooldown_seconds=DEFAULT_FAILURE_COOLDOWN_SECONDS,
|
||||
print_prefix="[bigqmt_formula]",
|
||||
config=None,
|
||||
):
|
||||
self.enabled = bool(enabled)
|
||||
self.print_prefix = print_prefix
|
||||
self.failure_cooldown_seconds = float(
|
||||
failure_cooldown_seconds or DEFAULT_FAILURE_COOLDOWN_SECONDS
|
||||
)
|
||||
if client is None and self.enabled:
|
||||
host, port = resolve_address(config)
|
||||
timeout = float((config or {}).get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
client = FormulaServerClient(
|
||||
host=host, port=port, timeout_seconds=timeout, print_prefix=print_prefix
|
||||
)
|
||||
self.client = client
|
||||
if methods:
|
||||
self.methods = set(str(name) for name in methods) & set(METHOD_MAP)
|
||||
else:
|
||||
self.methods = set(METHOD_MAP)
|
||||
self._unavailable_until = 0.0
|
||||
self._announced = False
|
||||
# Methods the server itself rejected as unimplemented — never retried.
|
||||
self._unimplemented = set()
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
|
||||
def _available(self):
|
||||
if not self.enabled or self.client is None:
|
||||
return False
|
||||
return time.time() >= self._unavailable_until
|
||||
|
||||
def _mark_unavailable(self, reason):
|
||||
self._unavailable_until = time.time() + self.failure_cooldown_seconds
|
||||
print(
|
||||
"%s unavailable, falling back to RPC for %.0fs: %s"
|
||||
% (self.print_prefix, self.failure_cooldown_seconds, reason)
|
||||
)
|
||||
|
||||
def supports(self, method):
|
||||
return (
|
||||
str(method) in self.methods
|
||||
and str(method) not in self._unimplemented
|
||||
and self._available()
|
||||
)
|
||||
|
||||
def call(self, method, params=None):
|
||||
"""Serve ``method`` from FormulaServer, or raise :class:`Unroutable`."""
|
||||
method = str(method)
|
||||
if not self.supports(method):
|
||||
raise Unroutable(method)
|
||||
func, build_params, adapt_result = METHOD_MAP[method]
|
||||
try:
|
||||
wire_params = build_params(dict(params or {}))
|
||||
except Exception as exc:
|
||||
# Params that do not translate are a per-call condition, not a
|
||||
# server fault — do not trip the breaker.
|
||||
self.misses += 1
|
||||
raise Unroutable("%s: %s" % (method, exc))
|
||||
try:
|
||||
raw = self.client.request(func, wire_params)
|
||||
except FormulaServerError as exc:
|
||||
self.misses += 1
|
||||
if exc.error_id == ERROR_METHOD_NOT_FOUND:
|
||||
self._unimplemented.add(method)
|
||||
print(
|
||||
"%s %s not implemented by this terminal, using RPC from now on"
|
||||
% (self.print_prefix, method)
|
||||
)
|
||||
raise Unroutable("%s: %s" % (method, exc))
|
||||
except FormulaServerUnavailable as exc:
|
||||
self.misses += 1
|
||||
self._mark_unavailable(str(exc))
|
||||
raise Unroutable("%s: %s" % (method, exc))
|
||||
except Exception as exc:
|
||||
self.misses += 1
|
||||
self._mark_unavailable("%s: %s" % (exc.__class__.__name__, exc))
|
||||
raise Unroutable("%s: %s" % (method, exc))
|
||||
try:
|
||||
result = adapt_result(raw, dict(params or {}))
|
||||
except Exception as exc:
|
||||
self.misses += 1
|
||||
raise Unroutable("%s: result adaptation failed: %s" % (method, exc))
|
||||
self.hits += 1
|
||||
if not self._announced:
|
||||
self._announced = True
|
||||
print(
|
||||
"%s active at %s:%s (%d methods routed direct)"
|
||||
% (self.print_prefix, self.client.host, self.client.port, len(self.methods))
|
||||
)
|
||||
return result
|
||||
|
||||
def stats(self):
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"hits": self.hits,
|
||||
"misses": self.misses,
|
||||
"available": self._available(),
|
||||
"unimplemented": sorted(self._unimplemented),
|
||||
"methods": sorted(self.methods),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
if self.client is not None:
|
||||
self.client.close()
|
||||
|
||||
|
||||
def build_router(config=None, print_prefix="[bigqmt_formula]"):
|
||||
"""Build a router from a ``formula_server`` config dict.
|
||||
|
||||
Recognised keys: ``enabled`` (default True), ``host``, ``port``,
|
||||
``qmt_root``, ``timeout_seconds``, ``methods``, ``failure_cooldown_seconds``.
|
||||
``enabled=False`` yields a router that declines everything, so callers need
|
||||
no None checks.
|
||||
"""
|
||||
config = dict(config or {})
|
||||
enabled = config.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
enabled = enabled.strip().lower() not in ("0", "false", "no", "off")
|
||||
return FormulaServerRouter(
|
||||
enabled=bool(enabled),
|
||||
methods=config.get("methods"),
|
||||
failure_cooldown_seconds=config.get("failure_cooldown_seconds"),
|
||||
print_prefix=print_prefix,
|
||||
config=config,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Demand-driven Redis cache for Big QMT full tick snapshots."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import pickle
|
||||
import time
|
||||
|
||||
from .code_utils import normalize_stock_code
|
||||
|
||||
|
||||
MARKET_CODES = {"SH", "SZ", "BJ", "HK"}
|
||||
DEMAND_KEY_TEMPLATE = "bigqmt:full_tick:demand:{account_id}"
|
||||
CACHE_KEY_TEMPLATE = "bigqmt:full_tick:cache:{account_id}:{request_id}"
|
||||
|
||||
|
||||
def _decode_text(value):
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _loads_json(value):
|
||||
return json.loads(_decode_text(value))
|
||||
|
||||
|
||||
def normalize_full_tick_codes(codes):
|
||||
normalized = []
|
||||
seen = set()
|
||||
for code in codes or []:
|
||||
text = str(code or "").strip().upper()
|
||||
if not text:
|
||||
continue
|
||||
if text in MARKET_CODES:
|
||||
item = text
|
||||
else:
|
||||
item = normalize_stock_code(text)
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
return sorted(normalized)
|
||||
|
||||
|
||||
def full_tick_request_id(codes):
|
||||
normalized = normalize_full_tick_codes(codes)
|
||||
digest = hashlib.sha1("|".join(normalized).encode("utf-8")).hexdigest()
|
||||
return digest[:20]
|
||||
|
||||
|
||||
def full_tick_demand_key(account_id):
|
||||
return DEMAND_KEY_TEMPLATE.format(account_id=str(account_id or ""))
|
||||
|
||||
|
||||
def full_tick_cache_key(account_id, codes=None, request_id=None):
|
||||
rid = str(request_id or full_tick_request_id(codes or []))
|
||||
return CACHE_KEY_TEMPLATE.format(account_id=str(account_id or ""), request_id=rid)
|
||||
|
||||
|
||||
def _dump_snapshot(payload):
|
||||
return pickle.dumps(payload, protocol=4)
|
||||
|
||||
|
||||
def _load_snapshot(raw):
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return pickle.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
return _loads_json(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def request_full_tick_cache(redis_client, account_id, codes, demand_ttl_seconds=10, cache_ttl_seconds=10):
|
||||
normalized = normalize_full_tick_codes(codes)
|
||||
if not normalized:
|
||||
raise ValueError("full tick codes are required")
|
||||
now = time.time()
|
||||
request_id = full_tick_request_id(normalized)
|
||||
payload = {
|
||||
"request_id": request_id,
|
||||
"codes": normalized,
|
||||
"requested_at_ts": now,
|
||||
"expires_at_ts": now + float(demand_ttl_seconds),
|
||||
"cache_ttl_seconds": float(cache_ttl_seconds),
|
||||
}
|
||||
key = full_tick_demand_key(account_id)
|
||||
redis_client.hset(key, request_id, json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
try:
|
||||
redis_client.expire(key, max(30, int(float(demand_ttl_seconds) * 3)))
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
|
||||
|
||||
def write_full_tick_cache(redis_client, account_id, codes, data, cache_ttl_seconds=10):
|
||||
normalized = normalize_full_tick_codes(codes)
|
||||
request_id = full_tick_request_id(normalized)
|
||||
now = time.time()
|
||||
payload = {
|
||||
"request_id": request_id,
|
||||
"codes": normalized,
|
||||
"updated_at_ts": now,
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)),
|
||||
"data": data or {},
|
||||
}
|
||||
key = full_tick_cache_key(account_id, request_id=request_id)
|
||||
ttl = int(max(1, float(cache_ttl_seconds)))
|
||||
redis_client.setex(key, ttl, _dump_snapshot(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def read_full_tick_cache(redis_client, account_id, codes, max_age_seconds=10):
|
||||
normalized = normalize_full_tick_codes(codes)
|
||||
key = full_tick_cache_key(account_id, codes=normalized)
|
||||
snapshot = _load_snapshot(redis_client.get(key))
|
||||
if not isinstance(snapshot, dict):
|
||||
return None
|
||||
if normalize_full_tick_codes(snapshot.get("codes") or []) != normalized:
|
||||
return None
|
||||
updated_at = float(snapshot.get("updated_at_ts") or 0)
|
||||
if updated_at <= 0:
|
||||
return None
|
||||
if time.time() - updated_at > float(max_age_seconds):
|
||||
return None
|
||||
data = snapshot.get("data")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def wait_full_tick_cache(redis_client, account_id, codes, max_age_seconds=10, wait_seconds=3.5, poll_interval_seconds=0.2):
|
||||
deadline = time.time() + max(0.0, float(wait_seconds))
|
||||
while True:
|
||||
data = read_full_tick_cache(redis_client, account_id, codes, max_age_seconds=max_age_seconds)
|
||||
if data is not None:
|
||||
return data
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
time.sleep(max(0.05, float(poll_interval_seconds)))
|
||||
|
||||
|
||||
def iter_active_full_tick_demands(redis_client, account_id, demand_ttl_seconds=10, max_requests=8):
|
||||
key = full_tick_demand_key(account_id)
|
||||
raw_mapping = redis_client.hgetall(key) or {}
|
||||
now = time.time()
|
||||
active = []
|
||||
for field, raw_payload in list(raw_mapping.items()):
|
||||
field_text = _decode_text(field)
|
||||
try:
|
||||
payload = _loads_json(raw_payload)
|
||||
except Exception:
|
||||
try:
|
||||
redis_client.hdel(key, field_text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
expires_at = float(payload.get("expires_at_ts") or 0)
|
||||
if expires_at <= now:
|
||||
try:
|
||||
redis_client.hdel(key, field_text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
codes = normalize_full_tick_codes(payload.get("codes") or [])
|
||||
if not codes:
|
||||
try:
|
||||
redis_client.hdel(key, field_text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
payload["codes"] = codes
|
||||
payload["cache_ttl_seconds"] = float(payload.get("cache_ttl_seconds") or demand_ttl_seconds)
|
||||
active.append(payload)
|
||||
active.sort(key=lambda item: float(item.get("requested_at_ts") or 0), reverse=True)
|
||||
return active[: int(max_requests)]
|
||||
|
||||
|
||||
def _demand_is_market(codes):
|
||||
return any(str(code).strip().upper() in MARKET_CODES for code in codes or [])
|
||||
|
||||
|
||||
def refresh_full_tick_cache(
|
||||
redis_client,
|
||||
context_info,
|
||||
account_id,
|
||||
demand_ttl_seconds=10,
|
||||
cache_ttl_seconds=10,
|
||||
max_requests=8,
|
||||
kind=None,
|
||||
max_wall_seconds=None,
|
||||
):
|
||||
"""Refresh cached snapshots for active demands.
|
||||
|
||||
``kind`` selects which demands to refresh: ``None`` (all), ``"symbol"``
|
||||
(only symbol-list demands), or ``"market"`` (only whole-market demands such
|
||||
as SH/SZ/BJ/HK). ``max_wall_seconds`` caps how long one refresh round may run
|
||||
on the caller (strategy) thread; the in-flight demand always completes and at
|
||||
least one demand is always refreshed before the budget can cut the round.
|
||||
"""
|
||||
started_at = time.time()
|
||||
refreshed = 0
|
||||
for demand in iter_active_full_tick_demands(
|
||||
redis_client,
|
||||
account_id,
|
||||
demand_ttl_seconds=demand_ttl_seconds,
|
||||
max_requests=max_requests,
|
||||
):
|
||||
codes = demand.get("codes") or []
|
||||
is_market = _demand_is_market(codes)
|
||||
if kind == "symbol" and is_market:
|
||||
continue
|
||||
if kind == "market" and not is_market:
|
||||
continue
|
||||
if max_wall_seconds and refreshed and (time.time() - started_at) > float(max_wall_seconds):
|
||||
break
|
||||
tick_data = context_info.get_full_tick(codes) or {}
|
||||
ttl = demand.get("cache_ttl_seconds") or cache_ttl_seconds
|
||||
write_full_tick_cache(redis_client, account_id, codes, tick_data, cache_ttl_seconds=ttl)
|
||||
refreshed += 1
|
||||
return refreshed
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Client-side local cache for Big QMT market data.
|
||||
|
||||
Pull bars from Big QMT once over RPC, persist them on the client, then read them
|
||||
back with ``get_local_data`` without touching Big QMT again — for offline / local
|
||||
analysis. One file per (period, dividend_type, code); incremental merge + dedupe
|
||||
by time. Default storage is Parquet (columnar, compressed, cross-language); falls
|
||||
back to pickle when pyarrow is unavailable. A cache written in one format is read
|
||||
+ migrated transparently if the configured format changes.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# Candidate time-column names produced by the RPC market-data path.
|
||||
_TIME_COLS = ("stime", "time", "index", "date", "datetime", "timetag")
|
||||
|
||||
|
||||
def _time_col(df):
|
||||
cols = list(getattr(df, "columns", []))
|
||||
for name in _TIME_COLS:
|
||||
if name in cols:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _pad_end(value):
|
||||
text = str(value)
|
||||
return text + "9" * (14 - len(text)) if 0 < len(text) < 14 else text
|
||||
|
||||
|
||||
def _drop_placeholder_rows(df):
|
||||
"""Big QMT fills dates it has no local data for with all-zero rows. A real bar
|
||||
never has close/open == 0, so drop those placeholders — the cache should hold
|
||||
only real bars, not 0-fill padding."""
|
||||
for col in ("close", "open", "price", "lastPrice"):
|
||||
if col in getattr(df, "columns", []):
|
||||
try:
|
||||
return df[df[col] != 0].reset_index(drop=True)
|
||||
except Exception:
|
||||
return df
|
||||
return df
|
||||
|
||||
|
||||
def _pyarrow_available():
|
||||
try:
|
||||
import pyarrow # noqa: F401
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_format(fmt):
|
||||
fmt = str(fmt or "auto").lower()
|
||||
if fmt in ("parquet", "pq"):
|
||||
return "parquet"
|
||||
if fmt in ("pkl", "pickle"):
|
||||
return "pkl"
|
||||
# auto / unknown
|
||||
return "parquet" if _pyarrow_available() else "pkl"
|
||||
|
||||
|
||||
class LocalMarketCache:
|
||||
def __init__(self, cache_dir=None, fmt="auto"):
|
||||
self.cache_dir = str(cache_dir or os.path.join(os.path.expanduser("~"), ".bigqmt_cache"))
|
||||
self.fmt = _resolve_format(fmt)
|
||||
|
||||
def _ext(self):
|
||||
return ".parquet" if self.fmt == "parquet" else ".pkl"
|
||||
|
||||
def path(self, code, period, dividend_type="none"):
|
||||
safe_code = str(code or "").replace("/", "_").replace("\\", "_")
|
||||
div = str(dividend_type or "none")
|
||||
return os.path.join(self.cache_dir, str(period or "1d"), div, safe_code + self._ext())
|
||||
|
||||
def _existing_path(self, code, period, dividend_type):
|
||||
"""Return the on-disk file for this key in the configured format, else the
|
||||
other format (so switching format still finds + migrates the old cache)."""
|
||||
primary = self.path(code, period, dividend_type)
|
||||
if os.path.isfile(primary):
|
||||
return primary
|
||||
base = primary[: -len(self._ext())]
|
||||
for ext in (".parquet", ".pkl"):
|
||||
alt = base + ext
|
||||
if os.path.isfile(alt):
|
||||
return alt
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _read_file(path):
|
||||
import pandas as pd
|
||||
|
||||
# Read by actual file extension (an existing cache may be either format).
|
||||
if path.endswith(".pkl"):
|
||||
return pd.read_pickle(path)
|
||||
return pd.read_parquet(path)
|
||||
|
||||
def _write_file(self, df, path):
|
||||
# Write in the configured format regardless of the path (the temp file ends
|
||||
# with ".tmp", not the format extension).
|
||||
if self.fmt == "parquet":
|
||||
df.to_parquet(path, index=False)
|
||||
else:
|
||||
df.to_pickle(path)
|
||||
|
||||
def write(self, code, period, df, dividend_type="none"):
|
||||
"""Merge ``df`` into the cache for (code, period, dividend_type).
|
||||
|
||||
Dedupe is by time keeping the LAST write, so re-pulling a range overwrites
|
||||
stale values — which is exactly what front-adjusted (前复权) data needs after
|
||||
a new dividend re-scales history. Returns total rows stored.
|
||||
"""
|
||||
if df is None or not hasattr(df, "shape") or df.shape[0] == 0:
|
||||
return 0
|
||||
import pandas as pd
|
||||
|
||||
incoming = _drop_placeholder_rows(df.copy())
|
||||
primary = self.path(code, period, dividend_type)
|
||||
existing = self._existing_path(code, period, dividend_type)
|
||||
if incoming.shape[0] == 0:
|
||||
# Nothing real to add (all 0-fill placeholders); keep existing cache.
|
||||
if existing:
|
||||
try:
|
||||
return self._read_file(existing).shape[0]
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
directory = os.path.dirname(primary)
|
||||
if directory and not os.path.isdir(directory):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
merged = incoming
|
||||
tcol = _time_col(merged)
|
||||
if existing:
|
||||
try:
|
||||
old = self._read_file(existing)
|
||||
merged = pd.concat([old, merged], ignore_index=True)
|
||||
except Exception:
|
||||
pass
|
||||
if tcol and tcol in merged.columns:
|
||||
merged = merged.drop_duplicates(subset=[tcol], keep="last").sort_values(tcol).reset_index(drop=True)
|
||||
else:
|
||||
merged = merged.drop_duplicates().reset_index(drop=True)
|
||||
# Atomic-ish write (temp + replace) so a crash mid-write can't corrupt the file.
|
||||
tmp = primary + ".tmp"
|
||||
self._write_file(merged, tmp)
|
||||
os.replace(tmp, primary)
|
||||
# Migrated from the other format? drop the stale file.
|
||||
if existing and existing != primary:
|
||||
try:
|
||||
os.remove(existing)
|
||||
except Exception:
|
||||
pass
|
||||
return merged.shape[0]
|
||||
|
||||
def read(self, code, period, start_time="", end_time="", count=-1, dividend_type="none"):
|
||||
"""Return the cached DataFrame for (code, period, dividend_type), filtered."""
|
||||
existing = self._existing_path(code, period, dividend_type)
|
||||
if not existing:
|
||||
return None
|
||||
try:
|
||||
df = self._read_file(existing)
|
||||
except Exception:
|
||||
return None
|
||||
tcol = _time_col(df)
|
||||
if tcol and tcol in df.columns:
|
||||
series = df[tcol].astype(str)
|
||||
if start_time:
|
||||
df = df[series >= str(start_time)]
|
||||
if end_time:
|
||||
df = df[series <= _pad_end(end_time)]
|
||||
df = df.sort_values(tcol).reset_index(drop=True)
|
||||
try:
|
||||
n = int(count)
|
||||
except (TypeError, ValueError):
|
||||
n = -1
|
||||
if n > 0 and df.shape[0] > n:
|
||||
df = df.tail(n).reset_index(drop=True)
|
||||
return df
|
||||
|
||||
def covered(self, code, period, dividend_type="none"):
|
||||
"""Return (first_time, last_time, rows) for the cache, or None if empty."""
|
||||
df = self.read(code, period, dividend_type=dividend_type)
|
||||
if df is None or df.shape[0] == 0:
|
||||
return None
|
||||
tcol = _time_col(df)
|
||||
if not tcol:
|
||||
return (None, None, df.shape[0])
|
||||
series = df[tcol].astype(str)
|
||||
return (series.iloc[0], series.iloc[-1], df.shape[0])
|
||||
|
||||
def stats(self):
|
||||
"""Return (files, periods) currently cached across all dividend types."""
|
||||
files = 0
|
||||
periods = set()
|
||||
if os.path.isdir(self.cache_dir):
|
||||
for root, _dirs, fnames in os.walk(self.cache_dir):
|
||||
cached = [f for f in fnames if f.endswith(".parquet") or f.endswith(".pkl")]
|
||||
if cached:
|
||||
files += len(cached)
|
||||
rel = os.path.relpath(root, self.cache_dir)
|
||||
periods.add(rel.split(os.sep)[0] if rel != "." else rel)
|
||||
return files, sorted(periods)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""File-based logging for the Big QMT bridge.
|
||||
|
||||
Diagnostics used to be print()-only, which is lost when the QMT output panel
|
||||
scrolls or the terminal restarts. This module wires Python's stdlib ``logging``
|
||||
to a rotating file so errors survive restarts and can be reviewed after a
|
||||
crash.
|
||||
|
||||
Usage (any module, both server and client):
|
||||
|
||||
from bigqmt_signal_trader.logging_setup import get_logger
|
||||
log = get_logger("rpc")
|
||||
log.info("started")
|
||||
log.error("download failed: %s", exc)
|
||||
|
||||
Behavior:
|
||||
- Log directory resolves to ``<qmt_python_dir>/logs`` when running inside QMT
|
||||
(found via a sys.path entry ending in ``\\python``), else ``~/.cache/bigqmt/logs``.
|
||||
- Rotates at midnight into ``bigqmt.log.YYYY-MM-DD`` backups, keeping the last
|
||||
7 days by default (override with env BIGQMT_LOG_RETENTION_DAYS).
|
||||
- Each record is also printed to stdout so the QMT output panel still shows it.
|
||||
- Thread-safe (logging is; the print side is best-effort wrapped).
|
||||
- Never raises: a logging failure must not bring down the strategy.
|
||||
- Opt out via env BIGQMT_LOG_ENABLED=0 / BIGQMT_LOG_TO_STDOUT=0.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
_LOGGER_NAME = "bigqmt"
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _env_bool(name, default=True):
|
||||
value = os.environ.get(name)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
def _resolve_log_dir():
|
||||
"""Pick a writable log dir. Prefers the QMT python dir (the sys.path entry
|
||||
ending in ``\\python``) so logs sit beside the deployed strategy; falls back
|
||||
to a user cache dir otherwise. Deliberately does NOT use this package's own
|
||||
src/ directory (a repo checkout is not a writable runtime location)."""
|
||||
candidates = []
|
||||
for entry in sys.path:
|
||||
try:
|
||||
if entry and entry.endswith(r"\python") and os.path.isdir(entry):
|
||||
candidates.append(entry)
|
||||
except Exception:
|
||||
continue
|
||||
candidates.append(os.path.join(os.path.expanduser("~"), ".cache", "bigqmt"))
|
||||
for base in candidates:
|
||||
try:
|
||||
path = os.path.join(base, "logs")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
# probe writability
|
||||
probe = os.path.join(path, ".write_test")
|
||||
with open(probe, "w"):
|
||||
pass
|
||||
try:
|
||||
os.remove(probe)
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class _SafeStreamHandler(logging.Handler):
|
||||
"""print() the record so the QMT output panel shows it; never raises."""
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
print(self.format(record))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_old_logs(log_dir, retention_days):
|
||||
"""Delete rotated log files older than retention_days.
|
||||
|
||||
TimedRotatingFileHandler only prunes backups at rotation time; this sweeps
|
||||
stale files on startup too (e.g. after a weekend gap or a config change).
|
||||
"""
|
||||
try:
|
||||
cutoff = time.time() - retention_days * 86400
|
||||
for name in os.listdir(log_dir):
|
||||
if not (name.startswith("bigqmt") and name.endswith(".log") or ".log." in name):
|
||||
continue
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
if os.path.getmtime(path) < cutoff:
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _setup():
|
||||
global _initialized
|
||||
if _initialized:
|
||||
return
|
||||
_initialized = True
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
if not _env_bool("BIGQMT_LOG_ENABLED", True):
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return
|
||||
|
||||
fmt = logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# File handler: rotate at midnight, keep the last 7 days only.
|
||||
log_dir = _resolve_log_dir()
|
||||
if log_dir is not None:
|
||||
try:
|
||||
fname = os.path.join(log_dir, "bigqmt.log")
|
||||
file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
fname,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=int(os.environ.get("BIGQMT_LOG_RETENTION_DAYS", 7)),
|
||||
encoding="utf-8",
|
||||
utc=False,
|
||||
)
|
||||
file_handler.setFormatter(fmt)
|
||||
logger.addHandler(file_handler)
|
||||
_cleanup_old_logs(log_dir, int(os.environ.get("BIGQMT_LOG_RETENTION_DAYS", 7)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stdout handler so the QMT panel still shows logs.
|
||||
if _env_bool("BIGQMT_LOG_TO_STDOUT", True):
|
||||
stream = _SafeStreamHandler()
|
||||
stream.setLevel(logging.INFO)
|
||||
stream.setFormatter(fmt)
|
||||
logger.addHandler(stream)
|
||||
|
||||
if not logger.handlers:
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def get_logger(name=""):
|
||||
"""Return a module logger under the shared ``bigqmt`` root.
|
||||
|
||||
``get_logger("rpc")`` -> logger named ``bigqmt.rpc``; the tag is shown in
|
||||
each log line so the old ``[bigqmt_rpc]`` prefixes remain visible.
|
||||
"""
|
||||
_setup()
|
||||
suffix = str(name or "").strip(".")
|
||||
full = _LOGGER_NAME if not suffix else "%s.%s" % (_LOGGER_NAME, suffix)
|
||||
return logging.getLogger(full)
|
||||
|
||||
|
||||
def log_file_path():
|
||||
"""Return the current log file path (or None if file logging is off)."""
|
||||
_setup()
|
||||
log_dir = _resolve_log_dir()
|
||||
if log_dir is None:
|
||||
return None
|
||||
return os.path.join(log_dir, "bigqmt.log")
|
||||
@@ -0,0 +1,308 @@
|
||||
"""交易信号、委托请求和账户快照的数据模型。"""
|
||||
|
||||
import datetime as _dt
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class SignalAction(str, Enum):
|
||||
BUY = "BUY"
|
||||
SELL = "SELL"
|
||||
CLEAR = "CLEAR"
|
||||
CANCEL = "CANCEL"
|
||||
|
||||
|
||||
class SignalStatus(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
CLAIMED = "CLAIMED"
|
||||
SUBMITTED = "SUBMITTED"
|
||||
SKIPPED = "SKIPPED"
|
||||
FAILED = "FAILED"
|
||||
FILLED = "FILLED"
|
||||
|
||||
|
||||
def parse_datetime(value: Any, field_name: str) -> _dt.datetime:
|
||||
if isinstance(value, _dt.datetime):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
return _dt.datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{field_name} must use format YYYY-MM-DD HH:MM:SS") from exc
|
||||
raise ValueError(f"{field_name} is required")
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> Optional[int]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _bool_value(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None or value == "":
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
text = str(value).strip().lower()
|
||||
return text in ("1", "true", "yes", "y", "on")
|
||||
|
||||
|
||||
class TradeSignal:
|
||||
def __init__(
|
||||
self,
|
||||
signal_id,
|
||||
account_id,
|
||||
action,
|
||||
created_at,
|
||||
expire_at,
|
||||
schema_version,
|
||||
stock_code="",
|
||||
stock_name="",
|
||||
amount=None,
|
||||
percentage=None,
|
||||
price_type="AUTO_LIMIT",
|
||||
price=None,
|
||||
strategy_name="bigqmt_signal_trader",
|
||||
remark="",
|
||||
source="",
|
||||
source_type="auto",
|
||||
force=False,
|
||||
bypass_stop_buy=False,
|
||||
bypass_stop_sell=False,
|
||||
bypass_daily_limit=False,
|
||||
status=SignalStatus.PENDING,
|
||||
raw_payload=None,
|
||||
):
|
||||
self.signal_id = signal_id
|
||||
self.account_id = account_id
|
||||
self.action = action
|
||||
self.created_at = created_at
|
||||
self.expire_at = expire_at
|
||||
self.schema_version = schema_version
|
||||
self.stock_code = stock_code
|
||||
self.stock_name = stock_name
|
||||
self.amount = amount
|
||||
self.percentage = percentage
|
||||
self.price_type = price_type
|
||||
self.price = price
|
||||
self.strategy_name = strategy_name
|
||||
self.remark = remark
|
||||
self.source = source
|
||||
self.source_type = source_type
|
||||
self.force = force
|
||||
self.bypass_stop_buy = bypass_stop_buy
|
||||
self.bypass_stop_sell = bypass_stop_sell
|
||||
self.bypass_daily_limit = bypass_daily_limit
|
||||
self.status = status
|
||||
self.raw_payload = dict(raw_payload or {})
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "TradeSignal":
|
||||
required = ("signal_id", "account_id", "action", "created_at", "expire_at", "schema_version")
|
||||
for field_name in required:
|
||||
if payload.get(field_name) in (None, ""):
|
||||
raise ValueError(f"{field_name} is required")
|
||||
|
||||
try:
|
||||
action = SignalAction(str(payload["action"]).upper())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unsupported action: {payload.get('action')}") from exc
|
||||
|
||||
amount = _optional_int(payload.get("amount"))
|
||||
percentage = _optional_float(payload.get("percentage"))
|
||||
stock_code = str(payload.get("stock_code") or "").strip().upper()
|
||||
|
||||
if action == SignalAction.BUY:
|
||||
if not stock_code:
|
||||
raise ValueError("stock_code is required for BUY")
|
||||
if amount is None or amount <= 0:
|
||||
raise ValueError("amount must be positive for BUY")
|
||||
elif action == SignalAction.SELL:
|
||||
if not stock_code:
|
||||
raise ValueError("stock_code is required for SELL")
|
||||
if amount is None and percentage is None:
|
||||
raise ValueError("amount or percentage is required for SELL")
|
||||
elif action == SignalAction.CLEAR and percentage is None:
|
||||
percentage = 100.0
|
||||
|
||||
return cls(
|
||||
signal_id=str(payload["signal_id"]),
|
||||
account_id=str(payload["account_id"]),
|
||||
action=action,
|
||||
stock_code=stock_code,
|
||||
stock_name=str(payload.get("stock_name") or ""),
|
||||
amount=amount,
|
||||
percentage=percentage,
|
||||
price_type=str(payload.get("price_type") or "AUTO_LIMIT").upper(),
|
||||
price=_optional_float(payload.get("price")),
|
||||
strategy_name=str(payload.get("strategy_name") or "bigqmt_signal_trader"),
|
||||
remark=str(payload.get("remark") or ""),
|
||||
source=str(payload.get("source") or ""),
|
||||
source_type=str(payload.get("source_type") or "auto"),
|
||||
force=_bool_value(payload.get("force", False)),
|
||||
bypass_stop_buy=_bool_value(payload.get("bypass_stop_buy", False)),
|
||||
bypass_stop_sell=_bool_value(payload.get("bypass_stop_sell", False)),
|
||||
bypass_daily_limit=_bool_value(payload.get("bypass_daily_limit", False)),
|
||||
created_at=parse_datetime(payload.get("created_at"), "created_at"),
|
||||
expire_at=parse_datetime(payload.get("expire_at"), "expire_at"),
|
||||
schema_version=int(payload["schema_version"]),
|
||||
raw_payload=dict(payload),
|
||||
)
|
||||
|
||||
def is_expired(self, now: _dt.datetime) -> bool:
|
||||
return now > self.expire_at
|
||||
|
||||
|
||||
class PositionSnapshot:
|
||||
def __init__(
|
||||
self,
|
||||
stock_code,
|
||||
volume,
|
||||
available,
|
||||
cost=0.0,
|
||||
stock_name="",
|
||||
market_value=None,
|
||||
price=None,
|
||||
open_price=None,
|
||||
frozen_volume=0,
|
||||
on_road_volume=0,
|
||||
yesterday_volume=None,
|
||||
direction=48,
|
||||
):
|
||||
self.stock_code = stock_code
|
||||
self.volume = volume
|
||||
self.available = available
|
||||
self.cost = cost
|
||||
self.stock_name = stock_name
|
||||
self.market_value = market_value
|
||||
self.price = price
|
||||
self.open_price = open_price
|
||||
self.frozen_volume = frozen_volume
|
||||
self.on_road_volume = on_road_volume
|
||||
self.yesterday_volume = yesterday_volume
|
||||
self.direction = direction
|
||||
|
||||
|
||||
class AssetSnapshot:
|
||||
"""Account funds, mirroring MiniQMT's ``XtAsset``.
|
||||
|
||||
Field names follow ``xtquant.xttype.XtAsset(account_id, cash, frozen_cash,
|
||||
market_value, total_asset)`` so ``query_stock_asset`` can hand callers the
|
||||
same attributes they get from MiniQMT.
|
||||
|
||||
``cash`` is 可用 (available), NOT the full 资金余额:
|
||||
``total_asset == cash + frozen_cash + market_value``. New fields are
|
||||
appended with None defaults so existing positional callers keep working,
|
||||
and None means "the terminal did not report it" — distinct from 0.0.
|
||||
"""
|
||||
|
||||
def __init__(self, account_id, cash=None, total_asset=None, frozen_cash=None, market_value=None):
|
||||
self.account_id = account_id
|
||||
self.cash = cash
|
||||
self.total_asset = total_asset
|
||||
self.frozen_cash = frozen_cash
|
||||
self.market_value = market_value
|
||||
|
||||
|
||||
class AccountSnapshot:
|
||||
def __init__(self, account_id, asset, positions, reason, updated_at):
|
||||
self.account_id = account_id
|
||||
self.asset = asset
|
||||
self.positions = positions
|
||||
self.reason = reason
|
||||
self.updated_at = updated_at
|
||||
|
||||
|
||||
class OrderRequest:
|
||||
def __init__(
|
||||
self,
|
||||
signal_id,
|
||||
account_id,
|
||||
action,
|
||||
stock_code,
|
||||
volume,
|
||||
price,
|
||||
price_type,
|
||||
strategy_name,
|
||||
remark="",
|
||||
):
|
||||
self.signal_id = signal_id
|
||||
self.account_id = account_id
|
||||
self.action = action
|
||||
self.stock_code = stock_code
|
||||
self.volume = volume
|
||||
self.price = price
|
||||
self.price_type = price_type
|
||||
self.strategy_name = strategy_name
|
||||
self.remark = remark
|
||||
|
||||
|
||||
class OrderSubmitResult:
|
||||
def __init__(self, status, user_order_id, order_sys_id=None, message=""):
|
||||
self.status = status
|
||||
self.user_order_id = user_order_id
|
||||
self.order_sys_id = order_sys_id
|
||||
self.message = message
|
||||
|
||||
|
||||
class OrderSnapshot:
|
||||
def __init__(
|
||||
self,
|
||||
order_sys_id,
|
||||
user_order_id,
|
||||
stock_code,
|
||||
action,
|
||||
volume,
|
||||
traded_volume,
|
||||
status,
|
||||
price=0.0,
|
||||
strategy_name="",
|
||||
remark="",
|
||||
order_time=0,
|
||||
):
|
||||
self.order_sys_id = order_sys_id
|
||||
self.user_order_id = user_order_id
|
||||
self.stock_code = stock_code
|
||||
self.action = action
|
||||
self.volume = volume
|
||||
self.traded_volume = traded_volume
|
||||
self.status = status
|
||||
self.price = price
|
||||
self.strategy_name = strategy_name
|
||||
self.remark = remark
|
||||
# 报单时间, Unix 秒 -- MiniQMT XtOrder.order_time 的语义。0 = 未上报。
|
||||
# 追加在末尾并给默认值, 保持既有位置参数调用不受影响。
|
||||
self.order_time = order_time
|
||||
|
||||
|
||||
class TradeSnapshot:
|
||||
def __init__(self, trade_id, order_sys_id, stock_code, action, volume, price,
|
||||
traded_at="", user_order_id=""):
|
||||
self.trade_id = trade_id
|
||||
self.order_sys_id = order_sys_id
|
||||
self.stock_code = stock_code
|
||||
self.action = action
|
||||
self.volume = volume
|
||||
self.price = price
|
||||
self.traded_at = traded_at
|
||||
self.user_order_id = user_order_id
|
||||
|
||||
|
||||
class OrderRef:
|
||||
def __init__(self, order_sys_id, user_order_id=""):
|
||||
self.order_sys_id = order_sys_id
|
||||
self.user_order_id = user_order_id
|
||||
|
||||
|
||||
class CancelResult:
|
||||
def __init__(self, success, message=""):
|
||||
self.success = success
|
||||
self.message = message
|
||||
@@ -0,0 +1,53 @@
|
||||
"""订单价格生成逻辑。"""
|
||||
|
||||
from .code_utils import normalize_stock_code
|
||||
|
||||
|
||||
def _price_precision(stock_code):
|
||||
pure = normalize_stock_code(stock_code).split(".")[0]
|
||||
return 3 if pure.startswith(("15", "16", "51", "52")) else 2
|
||||
|
||||
|
||||
def _second_level(values):
|
||||
if isinstance(values, (list, tuple)) and len(values) > 1:
|
||||
try:
|
||||
value = float(values[1])
|
||||
return value if value > 0 else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_order_price(market_data, stock_code, action, price_type="AUTO_LIMIT", fixed_price=None):
|
||||
if str(price_type or "AUTO_LIMIT").upper() == "FIX_PRICE":
|
||||
if fixed_price is None:
|
||||
raise ValueError("fixed_price is required when price_type is FIX_PRICE")
|
||||
return float(fixed_price)
|
||||
|
||||
code = normalize_stock_code(stock_code)
|
||||
ticks = market_data.get_ticks([code])
|
||||
tick = ticks.get(code)
|
||||
if not tick:
|
||||
raise ValueError(f"missing tick data for {code}")
|
||||
|
||||
last_price = float(tick.get("lastPrice") or 0)
|
||||
if last_price <= 0:
|
||||
raise ValueError(f"invalid lastPrice for {code}")
|
||||
|
||||
instrument = market_data.get_instrument(code)
|
||||
if int(instrument.get("InstrumentStatus") or 0) > 0:
|
||||
raise ValueError(f"{code} is suspended")
|
||||
|
||||
precision = _price_precision(code)
|
||||
action_text = str(action).upper()
|
||||
if action_text == "BUY":
|
||||
up_stop = float(instrument.get("UpStopPrice") or last_price * 1.1)
|
||||
calculated = min(round(last_price * 1.002, precision), up_stop)
|
||||
ask2 = _second_level(tick.get("askPrice"))
|
||||
return round(ask2, precision) if ask2 and ask2 < calculated else calculated
|
||||
if action_text == "SELL":
|
||||
down_stop = float(instrument.get("DownStopPrice") or last_price * 0.9)
|
||||
calculated = max(round(last_price * 0.998, precision), down_stop)
|
||||
bid2 = _second_level(tick.get("bidPrice"))
|
||||
return round(bid2, precision) if bid2 and bid2 > calculated else calculated
|
||||
raise ValueError(f"unsupported action for price: {action}")
|
||||
@@ -0,0 +1,484 @@
|
||||
# coding: utf-8
|
||||
"""Start and stop a Big QMT terminal (issue #45).
|
||||
|
||||
Big QMT has to be restarted most mornings, and the login dialog is the reason
|
||||
it cannot simply be dropped into a scheduler. Two ways past it:
|
||||
|
||||
* **Passwordless (preferred)** -- ``XtMiniQmt.exe linkMini`` starts MiniQMT
|
||||
against an existing session with no dialog at all. This is what
|
||||
``免密登录qmt.bat`` does. No UI automation, so nothing here depends on a
|
||||
desktop being visible.
|
||||
* **Credential entry** -- for the full terminal (``XtItClient.exe``) the dialog
|
||||
is unavoidable. We drive it with ``win32api.SendMessage`` posted straight to
|
||||
the window handle, NOT with pyautogui/pywinauto. That distinction is the
|
||||
answer to the question in issue #45: pyautogui replays physical input at
|
||||
screen coordinates, so it needs the window focused and the desktop unlocked;
|
||||
SendMessage delivers to a handle and works on a background -- or locked --
|
||||
session, as long as the session still exists (an RDP disconnect is fine, a
|
||||
full logout is not).
|
||||
|
||||
Everything is scoped to one install directory. A machine here runs several QMT
|
||||
copies side by side, so an unscoped ``taskkill /im XtItClient.exe`` would take
|
||||
down someone else's trading session.
|
||||
|
||||
Waits are on observed readiness, never a fixed sleep: startup is complete when
|
||||
the FormulaServer port accepts a connection, which is also exactly what the
|
||||
rest of this package needs before it can do anything.
|
||||
|
||||
Windows only. ``psutil`` is used when importable, otherwise we shell out to
|
||||
``wmic``/``taskkill``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .logging_setup import get_logger
|
||||
|
||||
|
||||
log = get_logger("launcher")
|
||||
|
||||
# Processes a QMT install owns. miniquote/BrokerProxy/minibroker are children
|
||||
# that survive the main window and hold the ports we need to rebind.
|
||||
QMT_PROCESS_NAMES = (
|
||||
"XtItClient.exe",
|
||||
"XtMiniQmt.exe",
|
||||
"miniquote.exe",
|
||||
"BrokerProxy.exe",
|
||||
"minibroker.exe",
|
||||
)
|
||||
|
||||
# FormulaServer. Listening means the terminal is far enough up to answer.
|
||||
DEFAULT_READY_PORT = 58600
|
||||
|
||||
__all__ = [
|
||||
"QmtLauncherError",
|
||||
"close_qmt",
|
||||
"find_qmt_processes",
|
||||
"is_qmt_running",
|
||||
"open_qmt",
|
||||
"restart_qmt",
|
||||
"wait_until_ready",
|
||||
]
|
||||
|
||||
|
||||
class QmtLauncherError(RuntimeError):
|
||||
"""Launching or stopping a QMT terminal failed."""
|
||||
|
||||
|
||||
def _normalize_dir(path):
|
||||
if not path:
|
||||
return ""
|
||||
return os.path.normcase(os.path.normpath(os.path.abspath(str(path))))
|
||||
|
||||
|
||||
def resolve_install_dir(install_dir):
|
||||
"""Accept an install root, its bin.x64, or a path to an exe inside it.
|
||||
|
||||
Returns the normalized ``bin.x64`` directory, which is what process paths
|
||||
are compared against.
|
||||
"""
|
||||
path = str(install_dir or "").strip().strip('"').strip("'")
|
||||
if not path:
|
||||
raise QmtLauncherError("install_dir is required (QMT root, bin.x64, or an exe path)")
|
||||
if os.path.isfile(path) or path.lower().endswith(".exe"):
|
||||
path = os.path.dirname(path)
|
||||
normalized = os.path.normpath(os.path.abspath(path))
|
||||
if os.path.basename(normalized).lower() != "bin.x64":
|
||||
candidate = os.path.join(normalized, "bin.x64")
|
||||
if os.path.isdir(candidate):
|
||||
normalized = candidate
|
||||
return normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- discovery
|
||||
def _iter_processes_psutil():
|
||||
import psutil
|
||||
|
||||
for proc in psutil.process_iter(["pid", "name", "exe"]):
|
||||
try:
|
||||
info = proc.info
|
||||
yield int(info["pid"]), str(info.get("name") or ""), str(info.get("exe") or "")
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
|
||||
def _iter_processes_wmic():
|
||||
"""psutil-free fallback. wmic still ships on the Windows builds QMT runs on."""
|
||||
try:
|
||||
raw = subprocess.check_output(
|
||||
["wmic", "process", "get", "ProcessId,Name,ExecutablePath", "/format:csv"],
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise QmtLauncherError(
|
||||
"cannot enumerate processes: psutil is not installed and wmic failed (%s)" % exc
|
||||
)
|
||||
text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw)
|
||||
for row in text.splitlines():
|
||||
parts = [p.strip() for p in row.split(",")]
|
||||
# CSV columns: Node,ExecutablePath,Name,ProcessId
|
||||
if len(parts) < 4 or parts[3].lower() in ("processid", ""):
|
||||
continue
|
||||
try:
|
||||
pid = int(parts[3])
|
||||
except ValueError:
|
||||
continue
|
||||
yield pid, parts[2], parts[1]
|
||||
|
||||
|
||||
def _iter_processes():
|
||||
try:
|
||||
import psutil # noqa: F401
|
||||
except ImportError:
|
||||
return _iter_processes_wmic()
|
||||
return _iter_processes_psutil()
|
||||
|
||||
|
||||
def find_qmt_processes(install_dir, names=QMT_PROCESS_NAMES):
|
||||
"""Return ``[(pid, name, exe), ...]`` for QMT processes under ``install_dir``.
|
||||
|
||||
A process with no readable exe path is skipped rather than guessed at: on a
|
||||
machine running several QMT copies, killing by name alone is how you take
|
||||
down the wrong account.
|
||||
"""
|
||||
target = _normalize_dir(resolve_install_dir(install_dir))
|
||||
wanted = set(str(n).lower() for n in names)
|
||||
found = []
|
||||
for pid, name, exe in _iter_processes():
|
||||
if name.lower() not in wanted or not exe:
|
||||
continue
|
||||
if _normalize_dir(os.path.dirname(exe)) == target:
|
||||
found.append((pid, name, exe))
|
||||
return found
|
||||
|
||||
|
||||
def is_qmt_running(install_dir):
|
||||
return bool(find_qmt_processes(install_dir))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ readiness
|
||||
def port_is_listening(port=DEFAULT_READY_PORT, host="127.0.0.1", timeout=1.0):
|
||||
sock = socket.socket()
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def wait_until_ready(port=DEFAULT_READY_PORT, host="127.0.0.1", timeout_seconds=180.0,
|
||||
poll_interval=2.0):
|
||||
"""Block until ``port`` accepts a connection. Returns seconds waited.
|
||||
|
||||
Raises :class:`QmtLauncherError` on timeout rather than returning False, so
|
||||
a scheduled restart fails loudly instead of letting the next step run
|
||||
against a terminal that never came up.
|
||||
"""
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
started = time.time()
|
||||
while time.time() < deadline:
|
||||
if port_is_listening(port, host):
|
||||
waited = time.time() - started
|
||||
log.info("qmt ready after %.1fs (%s:%d listening)", waited, host, port)
|
||||
return waited
|
||||
time.sleep(poll_interval)
|
||||
raise QmtLauncherError(
|
||||
"QMT did not become ready within %.0fs (%s:%d never listened)"
|
||||
% (timeout_seconds, host, port)
|
||||
)
|
||||
|
||||
|
||||
def wait_until_stopped(install_dir, timeout_seconds=60.0, poll_interval=1.0):
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
while time.time() < deadline:
|
||||
if not find_qmt_processes(install_dir):
|
||||
return True
|
||||
time.sleep(poll_interval)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- close
|
||||
def _terminate(pid, force=False):
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
if force:
|
||||
proc.kill()
|
||||
else:
|
||||
proc.terminate()
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
cmd = ["taskkill", "/pid", str(pid)]
|
||||
if force:
|
||||
cmd.append("/f")
|
||||
try:
|
||||
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def close_qmt(install_dir, timeout_seconds=60.0, force_after_seconds=20.0):
|
||||
"""Stop every QMT process under ``install_dir``. Returns how many were stopped.
|
||||
|
||||
Asks politely first: the terminal flushes local data on a clean exit, and
|
||||
killing it outright is how the K-line store ends up truncated. Escalates to
|
||||
a hard kill only after ``force_after_seconds``.
|
||||
"""
|
||||
targets = find_qmt_processes(install_dir)
|
||||
if not targets:
|
||||
log.info("no QMT process under %s; nothing to close", install_dir)
|
||||
return 0
|
||||
|
||||
for pid, name, _exe in targets:
|
||||
log.info("closing %s (pid=%s)", name, pid)
|
||||
_terminate(pid, force=False)
|
||||
|
||||
if wait_until_stopped(install_dir, timeout_seconds=force_after_seconds):
|
||||
log.info("closed %d process(es) cleanly", len(targets))
|
||||
return len(targets)
|
||||
|
||||
remaining = find_qmt_processes(install_dir)
|
||||
log.warning("%d process(es) still alive after %.0fs; forcing",
|
||||
len(remaining), force_after_seconds)
|
||||
for pid, name, _exe in remaining:
|
||||
_terminate(pid, force=True)
|
||||
|
||||
grace = max(timeout_seconds - force_after_seconds, 5.0)
|
||||
if not wait_until_stopped(install_dir, timeout_seconds=grace):
|
||||
still = find_qmt_processes(install_dir)
|
||||
raise QmtLauncherError(
|
||||
"could not stop: %s" % ", ".join("%s(pid=%s)" % (n, p) for p, n, _ in still)
|
||||
)
|
||||
return len(targets)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- open
|
||||
def _spawn(command, cwd=None, shell=False):
|
||||
log.info("launching: %s", command if isinstance(command, str) else " ".join(command))
|
||||
kwargs = {"cwd": cwd, "shell": shell,
|
||||
"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
||||
if os.name == "nt":
|
||||
# Detach so the terminal outlives this process -- otherwise a scheduled
|
||||
# task exiting takes QMT with it.
|
||||
detached = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
new_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||
kwargs["creationflags"] = detached | new_group
|
||||
return subprocess.Popen(command, **kwargs)
|
||||
|
||||
|
||||
def open_qmt(install_dir, mode="auto", bat_path=None, exe_name=None,
|
||||
ready_port=DEFAULT_READY_PORT, ready_timeout_seconds=180.0,
|
||||
wait_ready=True, credentials=None, window_title_prefix=None):
|
||||
"""Start a QMT terminal under ``install_dir`` and wait until it answers.
|
||||
|
||||
``mode``:
|
||||
``"linkmini"`` -- ``XtMiniQmt.exe linkMini``, no login dialog.
|
||||
``"bat"`` -- run ``bat_path`` (e.g. 免密登录qmt.bat).
|
||||
``"exe"`` -- start ``exe_name`` (default XtItClient.exe) as-is; use
|
||||
when the terminal restores its own session.
|
||||
``"login"`` -- start the exe, then type credentials into the dialog.
|
||||
``"auto"`` -- bat if given, else linkmini if XtMiniQmt.exe exists,
|
||||
else exe.
|
||||
|
||||
``credentials`` (mode="login") is ``{"user": ..., "password": ...}``. Pass
|
||||
it from your local config or environment; never hardcode it, and note the
|
||||
values are typed into a window, so anything that can read that window can
|
||||
read them.
|
||||
"""
|
||||
bin_dir = resolve_install_dir(install_dir)
|
||||
if not os.path.isdir(bin_dir):
|
||||
raise QmtLauncherError("no such directory: %s" % bin_dir)
|
||||
|
||||
mode = str(mode or "auto").lower()
|
||||
if mode == "auto":
|
||||
if bat_path:
|
||||
mode = "bat"
|
||||
elif os.path.isfile(os.path.join(bin_dir, "XtMiniQmt.exe")):
|
||||
mode = "linkmini"
|
||||
else:
|
||||
mode = "exe"
|
||||
|
||||
if mode == "bat":
|
||||
if not bat_path or not os.path.isfile(bat_path):
|
||||
raise QmtLauncherError("bat_path is required for mode='bat': %r" % bat_path)
|
||||
_spawn([bat_path], cwd=os.path.dirname(bat_path), shell=True)
|
||||
elif mode == "linkmini":
|
||||
exe = os.path.join(bin_dir, "XtMiniQmt.exe")
|
||||
if not os.path.isfile(exe):
|
||||
raise QmtLauncherError("XtMiniQmt.exe not found in %s" % bin_dir)
|
||||
_spawn([exe, "linkMini"], cwd=bin_dir)
|
||||
elif mode in ("exe", "login"):
|
||||
exe = os.path.join(bin_dir, str(exe_name or "XtItClient.exe"))
|
||||
if not os.path.isfile(exe):
|
||||
raise QmtLauncherError("%s not found in %s" % (os.path.basename(exe), bin_dir))
|
||||
_spawn([exe], cwd=bin_dir)
|
||||
if mode == "login":
|
||||
_login_via_window(credentials or {}, window_title_prefix)
|
||||
else:
|
||||
raise QmtLauncherError("unknown mode %r (bat/linkmini/exe/login/auto)" % mode)
|
||||
|
||||
if not wait_ready:
|
||||
return 0.0
|
||||
return wait_until_ready(ready_port, timeout_seconds=ready_timeout_seconds)
|
||||
|
||||
|
||||
def _login_via_window(credentials, window_title_prefix=None, appear_timeout_seconds=90.0):
|
||||
"""Type credentials into the QMT login dialog via SendMessage.
|
||||
|
||||
Matches the window by title PREFIX. The reference implementation pinned the
|
||||
full title including a build number ("国金证券QMT交易端 1.0.0.29456"), which
|
||||
stops finding the window on the next terminal update.
|
||||
"""
|
||||
user = str(credentials.get("user") or credentials.get("account") or "")
|
||||
password = str(credentials.get("password") or "")
|
||||
if not user or not password:
|
||||
raise QmtLauncherError(
|
||||
"mode='login' needs credentials={'user':..., 'password':...}"
|
||||
)
|
||||
try:
|
||||
import win32api
|
||||
import win32con
|
||||
import win32gui
|
||||
except ImportError:
|
||||
raise QmtLauncherError(
|
||||
"mode='login' needs pywin32 (pip install pywin32); "
|
||||
"prefer mode='linkmini' or mode='bat', which need no UI automation"
|
||||
)
|
||||
|
||||
prefix = str(window_title_prefix or "QMT")
|
||||
|
||||
def _collect(hwnd, acc):
|
||||
if not win32gui.IsWindowVisible(hwnd):
|
||||
return
|
||||
title = win32gui.GetWindowText(hwnd) or ""
|
||||
if title.strip().startswith(prefix):
|
||||
acc.append(hwnd)
|
||||
|
||||
def _find():
|
||||
matches = []
|
||||
win32gui.EnumWindows(_collect, matches)
|
||||
return matches[0] if matches else None
|
||||
|
||||
deadline = time.time() + appear_timeout_seconds
|
||||
handle = None
|
||||
while time.time() < deadline:
|
||||
handle = _find()
|
||||
if handle:
|
||||
break
|
||||
time.sleep(2.0)
|
||||
if not handle:
|
||||
raise QmtLauncherError(
|
||||
"login window starting with %r did not appear within %.0fs"
|
||||
% (prefix, appear_timeout_seconds)
|
||||
)
|
||||
|
||||
def _send_text(text):
|
||||
for ch in str(text):
|
||||
win32api.SendMessage(handle, win32con.WM_KEYDOWN, ord(ch), 0)
|
||||
win32api.SendMessage(handle, win32con.WM_KEYUP, ord(ch), 0)
|
||||
time.sleep(0.2)
|
||||
|
||||
def _send_enter():
|
||||
win32api.SendMessage(handle, win32con.WM_KEYDOWN, win32con.VK_RETURN, 0)
|
||||
win32api.SendMessage(handle, win32con.WM_KEYUP, win32con.VK_RETURN, 0)
|
||||
time.sleep(1.0)
|
||||
|
||||
# Never log the values themselves.
|
||||
log.info("entering credentials into window %r", prefix)
|
||||
_send_text(user)
|
||||
_send_enter()
|
||||
_send_text(password)
|
||||
_send_enter()
|
||||
_send_enter()
|
||||
|
||||
|
||||
def restart_qmt(install_dir, settle_seconds=5.0, **open_kwargs):
|
||||
"""Close, wait for the ports to be released, then start again.
|
||||
|
||||
``settle_seconds`` matters: the FormulaServer and RPC sockets linger briefly
|
||||
after the process dies, and the ZMQ transport binds its configured port
|
||||
exactly (no scanning), so restarting too eagerly fails the rebind.
|
||||
"""
|
||||
closed = close_qmt(install_dir)
|
||||
if closed:
|
||||
time.sleep(settle_seconds)
|
||||
waited = open_qmt(install_dir, **open_kwargs)
|
||||
log.info("restart complete (closed=%d, ready in %.1fs)", closed, waited)
|
||||
return waited
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- CLI
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m bigqmt_signal_trader.qmt_launcher",
|
||||
description="Start/stop a Big QMT terminal, scoped to one install directory.",
|
||||
)
|
||||
parser.add_argument("action", choices=("open", "close", "restart", "status"))
|
||||
parser.add_argument("--dir", required=True,
|
||||
help="QMT root, its bin.x64, or a path to an exe inside it")
|
||||
parser.add_argument("--mode", default="auto",
|
||||
choices=("auto", "bat", "linkmini", "exe", "login"))
|
||||
parser.add_argument("--bat", default=None, help="batch file for --mode bat")
|
||||
parser.add_argument("--exe", default=None, help="exe name for --mode exe/login")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_READY_PORT)
|
||||
parser.add_argument("--timeout", type=float, default=180.0)
|
||||
parser.add_argument("--no-wait", action="store_true")
|
||||
parser.add_argument("--title-prefix", default=None,
|
||||
help="login window title prefix (--mode login)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.action == "status":
|
||||
procs = find_qmt_processes(args.dir)
|
||||
if not procs:
|
||||
print("not running (%s)" % resolve_install_dir(args.dir))
|
||||
return 1
|
||||
for pid, name, exe in procs:
|
||||
print("%-16s pid=%-8s %s" % (name, pid, exe))
|
||||
print("ready port %d: %s" % (
|
||||
args.port, "listening" if port_is_listening(args.port) else "not listening"))
|
||||
return 0
|
||||
|
||||
credentials = None
|
||||
if args.mode == "login":
|
||||
# Read from the environment so a password never reaches argv, where it
|
||||
# would be visible to any process listing.
|
||||
credentials = {"user": os.environ.get("BIGQMT_LOGIN_USER", ""),
|
||||
"password": os.environ.get("BIGQMT_LOGIN_PASSWORD", "")}
|
||||
|
||||
try:
|
||||
if args.action == "close":
|
||||
print("closed %d process(es)" % close_qmt(args.dir))
|
||||
else:
|
||||
kwargs = dict(mode=args.mode, bat_path=args.bat, exe_name=args.exe,
|
||||
ready_port=args.port, ready_timeout_seconds=args.timeout,
|
||||
wait_ready=not args.no_wait, credentials=credentials,
|
||||
window_title_prefix=args.title_prefix)
|
||||
if args.action == "restart":
|
||||
restart_qmt(args.dir, **kwargs)
|
||||
else:
|
||||
open_qmt(args.dir, **kwargs)
|
||||
print("ok")
|
||||
except QmtLauncherError as exc:
|
||||
print("error: %s" % exc, file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Server→client whole-quote push channel.
|
||||
|
||||
The RPC transport is request/response only; whole-quote data needs the opposite
|
||||
direction — the server pushes each incremental tick batch to every client
|
||||
subscribed to that combination. This module provides one abstract channel with
|
||||
two interchangeable implementations:
|
||||
|
||||
* :class:`ZmqQuotePushChannel` — a ``PUB`` socket on the server, a ``SUB`` socket
|
||||
per client. Native to no-redis deployments. Fire-and-forget: a client that is
|
||||
down simply misses frames (acceptable for incremental quote pushes).
|
||||
* :class:`RedisQuotePushChannel` — redis ``publish``/``subscribe`` on a
|
||||
per-account, per-combination channel, for redis deployments.
|
||||
|
||||
Wire encoding is msgpack when available (smaller + faster for the
|
||||
``{code: {field: number}}`` payload shape), falling back to stdlib json so the
|
||||
channel stays usable without the optional dependency.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
try:
|
||||
import msgpack
|
||||
|
||||
_HAS_MSGPACK = True
|
||||
except Exception: # pragma: no cover - depends on optional dependency
|
||||
msgpack = None
|
||||
_HAS_MSGPACK = False
|
||||
|
||||
|
||||
def encode_push_payload(payload):
|
||||
"""Encode a push payload dict to bytes (msgpack preferred, json fallback)."""
|
||||
if _HAS_MSGPACK:
|
||||
return msgpack.packb(payload, use_bin_type=True)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
|
||||
def decode_push_payload(blob):
|
||||
"""Inverse of :func:`encode_push_payload`. Accepts bytes or str.
|
||||
|
||||
Encoding is not symmetric across deployments: a server without msgpack
|
||||
falls back to json while a client with msgpack installed decodes with
|
||||
msgpack — ``msgpack.unpackb`` then raises ``ExtraData`` on the json text
|
||||
(its first byte ``{`` parses as an int, leaving trailing bytes). So try
|
||||
msgpack first, and fall back to json when the bytes are not a single
|
||||
valid msgpack object.
|
||||
"""
|
||||
if blob is None:
|
||||
return None
|
||||
if isinstance(blob, str):
|
||||
blob = blob.encode("utf-8")
|
||||
if _HAS_MSGPACK:
|
||||
try:
|
||||
return msgpack.unpackb(blob, raw=False)
|
||||
except Exception:
|
||||
pass
|
||||
return json.loads(blob.decode("utf-8"))
|
||||
|
||||
|
||||
class QuotePushChannel(object):
|
||||
"""Abstract push channel. Server side: ``start_publisher`` + ``publish``.
|
||||
Client side: ``start_subscriber(topics, on_msg)``. A single instance may act
|
||||
as publisher or subscriber depending on which start method is called."""
|
||||
|
||||
def start_publisher(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
raise NotImplementedError
|
||||
|
||||
def publish(self, topic, data):
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ZmqQuotePushChannel(QuotePushChannel):
|
||||
def __init__(self, bind_address=None, connect_address=None, context=None, print_prefix="[bigqmt_quote_push]"):
|
||||
self.bind_address = bind_address
|
||||
self.connect_address = connect_address
|
||||
self.print_prefix = print_prefix
|
||||
self._zmq = None
|
||||
self._context = context
|
||||
self._pub = None
|
||||
self._pub_lock = threading.Lock()
|
||||
self._sub = None
|
||||
self._sub_thread = None
|
||||
self._running = False
|
||||
|
||||
def _ensure_context(self):
|
||||
if self._zmq is None:
|
||||
import zmq
|
||||
|
||||
self._zmq = zmq
|
||||
if self._context is None:
|
||||
self._context = zmq.Context.instance()
|
||||
return self._zmq, self._context
|
||||
|
||||
# -- server side ---------------------------------------------------------
|
||||
def start_publisher(self):
|
||||
zmq, ctx = self._ensure_context()
|
||||
if not self.bind_address:
|
||||
raise ValueError("bind_address is required to start a publisher")
|
||||
self._pub = ctx.socket(zmq.PUB)
|
||||
self._pub.bind(self.bind_address)
|
||||
self._running = True
|
||||
|
||||
def publish(self, topic, data):
|
||||
payload = encode_push_payload({"combo_key": topic, "data": data})
|
||||
frame = [str(topic).encode("utf-8"), payload]
|
||||
# PUB socket is not thread-safe; serialize under the lock and read the
|
||||
# socket inside it so a concurrent stop() (which nulls _pub) can't hand
|
||||
# us a closed socket.
|
||||
with self._pub_lock:
|
||||
pub = self._pub
|
||||
if pub is None:
|
||||
return
|
||||
try:
|
||||
pub.send_multipart(frame)
|
||||
except Exception as exc:
|
||||
print("%s zmq publish failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
# -- client side ---------------------------------------------------------
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
zmq, ctx = self._ensure_context()
|
||||
if not self.connect_address:
|
||||
raise ValueError("connect_address is required to start a subscriber")
|
||||
sub = ctx.socket(zmq.SUB)
|
||||
sub.connect(self.connect_address)
|
||||
for topic in topics or []:
|
||||
sub.setsockopt(zmq.SUBSCRIBE, str(topic).encode("utf-8"))
|
||||
self._sub = sub
|
||||
self._running = True
|
||||
self._sub_thread = threading.Thread(
|
||||
target=self._sub_loop, args=(sub, on_msg), name="bigqmt-quote-push-sub", daemon=True
|
||||
)
|
||||
self._sub_thread.start()
|
||||
|
||||
def _sub_loop(self, sub, on_msg):
|
||||
# The SUB socket is owned by THIS thread; it must be closed HERE (in a
|
||||
# finally) and never from another thread. Closing a ZMQ socket cross-
|
||||
# thread trips a Windows signaler assertion and aborts the whole QMT
|
||||
# process (the "auto-exit" users hit).
|
||||
poller = self._zmq.Poller()
|
||||
poller.register(sub, self._zmq.POLLIN)
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
events = dict(poller.poll(200))
|
||||
except Exception:
|
||||
break
|
||||
if sub not in events:
|
||||
continue
|
||||
try:
|
||||
frames = sub.recv_multipart(self._zmq.NOBLOCK)
|
||||
except Exception:
|
||||
continue
|
||||
if len(frames) < 2:
|
||||
continue
|
||||
topic = frames[0].decode("utf-8", errors="ignore")
|
||||
data = decode_push_payload(frames[-1])
|
||||
payload_data = data.get("data") if isinstance(data, dict) else data
|
||||
try:
|
||||
on_msg(topic, payload_data)
|
||||
except Exception as exc:
|
||||
print("%s subscriber callback failed: %s" % (self.print_prefix, exc))
|
||||
finally:
|
||||
try:
|
||||
sub.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
# Signal the sub thread to exit and let IT close its own socket (see
|
||||
# _sub_loop). Closing the SUB socket from this (foreign) thread would
|
||||
# trip the Windows ZMQ signaler abort and crash QMT.
|
||||
self._running = False
|
||||
thread = self._sub_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(1.0)
|
||||
self._sub_thread = None
|
||||
self._sub = None
|
||||
# The PUB socket is only touched by publisher threads under _pub_lock;
|
||||
# null it first so a racing publish() sees None and bails, then close.
|
||||
with self._pub_lock:
|
||||
pub = self._pub
|
||||
self._pub = None
|
||||
if pub is not None:
|
||||
try:
|
||||
pub.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class RedisQuotePushChannel(QuotePushChannel):
|
||||
def __init__(self, redis_client, account_id="", channel_template="bigqmt:quote_push:{account_id}:{topic}", print_prefix="[bigqmt_quote_push]"):
|
||||
self.redis = redis_client
|
||||
self.account_id = str(account_id or "")
|
||||
self.channel_template = channel_template
|
||||
self.print_prefix = print_prefix
|
||||
self._running = False
|
||||
self._pubsub = None
|
||||
self._thread = None
|
||||
|
||||
def _channel(self, topic):
|
||||
return self.channel_template.format(account_id=self.account_id, topic=topic)
|
||||
|
||||
# -- server side ---------------------------------------------------------
|
||||
def start_publisher(self):
|
||||
# Redis publish needs no setup; present for interface symmetry.
|
||||
self._running = True
|
||||
|
||||
def publish(self, topic, data):
|
||||
payload = encode_push_payload({"combo_key": topic, "data": data})
|
||||
try:
|
||||
self.redis.publish(self._channel(topic), payload)
|
||||
except Exception as exc:
|
||||
print("%s redis publish failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
# -- client side ---------------------------------------------------------
|
||||
def start_subscriber(self, topics, on_msg):
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._sub_loop, args=(list(topics or []), on_msg), name="bigqmt-quote-push-sub", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _sub_loop(self, topics, on_msg):
|
||||
# The pubsub connection is owned by THIS thread and closed HERE so a
|
||||
# concurrent stop() can't close it out from under us.
|
||||
pubsub = self.redis.pubsub(ignore_subscribe_messages=True)
|
||||
self._pubsub = pubsub
|
||||
channels = [self._channel(topic) for topic in topics]
|
||||
try:
|
||||
pubsub.subscribe(*channels)
|
||||
except Exception as exc:
|
||||
print("%s redis subscribe failed: %s" % (self.print_prefix, exc))
|
||||
return
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
message = pubsub.get_message(timeout=0.2)
|
||||
except Exception:
|
||||
break
|
||||
if not message or message.get("type") != "message":
|
||||
continue
|
||||
channel = message.get("channel")
|
||||
if isinstance(channel, bytes):
|
||||
channel = channel.decode("utf-8", errors="ignore")
|
||||
topic = str(channel).rsplit(":", 1)[-1]
|
||||
data = decode_push_payload(message.get("data"))
|
||||
payload_data = data.get("data") if isinstance(data, dict) else data
|
||||
try:
|
||||
on_msg(topic, payload_data)
|
||||
except Exception as exc:
|
||||
print("%s subscriber callback failed: %s" % (self.print_prefix, exc))
|
||||
finally:
|
||||
try:
|
||||
pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(1.0)
|
||||
self._thread = None
|
||||
self._pubsub = None
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Reference-counted whole-quote subscription manager (server side).
|
||||
|
||||
One big-QMT ``ContextInfo.subscribe_whole_quote`` subscription is shared by every
|
||||
client that asked for the same (normalized) code combination. The big-QMT
|
||||
subscription is only created for the first client of a combination and only torn
|
||||
down after the last client either unsubscribes or goes silent (keepalive timeout).
|
||||
|
||||
The manager talks to big QMT exclusively through a :class:`QuoteSourceAdapter`;
|
||||
it never touches ``ContextInfo`` directly so the real-environment wiring (method
|
||||
names / handle shape) stays isolated to the adapter.
|
||||
|
||||
Threading: ``subscribe``/``unsubscribe``/``keepalive`` run on the RPC thread,
|
||||
``reap_expired`` on the scheduler thread and ``on_push`` on big QMT's quote
|
||||
thread. Shared state is guarded by one re-entrant lock; calls out to the quote
|
||||
source and to the push publisher happen OUTSIDE the lock so a slow/blocking
|
||||
publish never stalls quote-thread state, and no callback can deadlock.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
|
||||
def combo_key(code_list):
|
||||
"""Normalize a code list into an order-independent combination key.
|
||||
|
||||
Uppercases, strips whitespace, drops empties and duplicates, sorts. So
|
||||
``["SH","SZ"]``, ``["sz","sh"]`` and ``["SH","SH","SZ"]`` all map to
|
||||
``"SH,SZ"`` and share one big-QMT subscription.
|
||||
"""
|
||||
normalized = {str(code).strip().upper() for code in (code_list or []) if str(code or "").strip()}
|
||||
return ",".join(sorted(normalized))
|
||||
|
||||
|
||||
class QuoteSourceAdapter(object):
|
||||
"""Big-QMT whole-quote source. ContextInfo-backed implementation lives in the
|
||||
server runtime; tests substitute a fake. ``subscribe`` must return a handle
|
||||
usable by ``unsubscribe``."""
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
raise NotImplementedError
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ContextInfoQuoteSource(QuoteSourceAdapter):
|
||||
"""Real big-QMT source backed by the strategy's ``ContextInfo``.
|
||||
|
||||
Verified against the real environment: ``ContextInfo.subscribe_whole_quote(
|
||||
code_list, callback)`` returns an int subscription id (``< 0`` on failure) and
|
||||
pushes INCREMENTAL ``{code: tick}`` batches on a dedicated quote thread;
|
||||
``ContextInfo.unsubscribe_quote(sub_id)`` cancels it.
|
||||
"""
|
||||
|
||||
def __init__(self, context_info):
|
||||
self._context = context_info
|
||||
|
||||
def subscribe(self, codes, on_push):
|
||||
sub_id = self._context.subscribe_whole_quote(list(codes), callback=on_push)
|
||||
if sub_id is None or int(sub_id) < 0:
|
||||
raise RuntimeError("ContextInfo.subscribe_whole_quote failed for codes=%s" % (list(codes),))
|
||||
return int(sub_id)
|
||||
|
||||
def unsubscribe(self, handle):
|
||||
try:
|
||||
self._context.unsubscribe_quote(handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _Combo(object):
|
||||
__slots__ = ("key", "codes", "handle", "topic", "clients")
|
||||
|
||||
def __init__(self, key, codes, handle, topic):
|
||||
self.key = key
|
||||
self.codes = codes
|
||||
self.handle = handle
|
||||
self.topic = topic
|
||||
# (client_id, sub_id) -> last_seen. Sub-id granularity: one client may
|
||||
# hold several subscriptions to the same combination, and each one keeps
|
||||
# the shared big-QMT subscription alive independently.
|
||||
self.clients = {} # (client_id, sub_id) -> last_seen timestamp
|
||||
|
||||
|
||||
class QuoteSubscriptionManager(object):
|
||||
def __init__(self, source, heartbeat_timeout_seconds=30.0, time_func=None, on_push_publisher=None, push_endpoint=""):
|
||||
self._source = source
|
||||
self._heartbeat_timeout = float(heartbeat_timeout_seconds)
|
||||
self._now = time_func or _monotonic
|
||||
# Optional callable(topic, data) invoked when big QMT pushes a tick batch.
|
||||
# Wired to the QuotePushChannel in a later stage; None keeps dispatch inert.
|
||||
self._on_push_publisher = on_push_publisher
|
||||
# Advertised to clients in subscribe responses so a zmq subscriber knows
|
||||
# where to connect (redis subscribers derive the channel locally instead).
|
||||
self._push_endpoint = str(push_endpoint or "")
|
||||
self._lock = threading.RLock()
|
||||
self._combos = {} # combo_key -> _Combo
|
||||
self._sub_index = {} # (client_id, sub_id) -> combo_key
|
||||
|
||||
# -- subscription lifecycle ---------------------------------------------
|
||||
def subscribe(self, client_id, sub_id, code_list):
|
||||
"""Register (client_id, sub_id) against its combination; create the shared
|
||||
big-QMT subscription on first use. Idempotent for replayed subscribes."""
|
||||
client_id = str(client_id or "")
|
||||
sub_id = str(sub_id or "")
|
||||
key = combo_key(code_list)
|
||||
now = self._now()
|
||||
|
||||
with self._lock:
|
||||
combo = self._combos.get(key)
|
||||
if combo is None:
|
||||
codes = sorted({str(c).strip().upper() for c in (code_list or []) if str(c or "").strip()})
|
||||
# source.subscribe registers the on_push callback with big QMT; it
|
||||
# does not call back into the manager, so it is safe under the lock.
|
||||
handle = self._source.subscribe(codes, self._make_on_push(key))
|
||||
combo = _Combo(key, codes, handle, key)
|
||||
self._combos[key] = combo
|
||||
|
||||
combo.clients[(client_id, sub_id)] = now
|
||||
self._sub_index[(client_id, sub_id)] = key
|
||||
return {"combo_key": key, "topic": combo.topic, "push_endpoint": self._push_endpoint}
|
||||
|
||||
def unsubscribe(self, client_id, sub_id):
|
||||
"""Drop (client_id, sub_id); tear the big-QMT subscription down when the
|
||||
last subscription of the combination leaves. Unknown sub_ids are a no-op."""
|
||||
client_id = str(client_id or "")
|
||||
sub_id = str(sub_id or "")
|
||||
with self._lock:
|
||||
key = self._sub_index.pop((client_id, sub_id), None)
|
||||
if key is None:
|
||||
return
|
||||
handle_to_close = self._remove_subscription_locked(key, client_id, sub_id)
|
||||
self._close_source(handle_to_close)
|
||||
|
||||
def keepalive(self, client_id, sub_id):
|
||||
"""Refresh last_seen for (client_id, sub_id). Unknown sub_ids are a no-op."""
|
||||
client_id = str(client_id or "")
|
||||
key = self._sub_index.get((client_id, str(sub_id or "")))
|
||||
if key is None:
|
||||
return
|
||||
with self._lock:
|
||||
combo = self._combos.get(key)
|
||||
if combo is None:
|
||||
return
|
||||
combo.clients[(client_id, str(sub_id or ""))] = self._now()
|
||||
|
||||
# -- reaper ---------------------------------------------------------------
|
||||
def reap_expired(self, now=None):
|
||||
"""Remove subscriptions silent for longer than the keepalive timeout;
|
||||
tear down combos that end up empty. Returns the number reaped."""
|
||||
now = self._now() if now is None else now
|
||||
reaped = 0
|
||||
handles_to_close = []
|
||||
with self._lock:
|
||||
for key in list(self._combos.keys()):
|
||||
combo = self._combos.get(key)
|
||||
if combo is None:
|
||||
continue
|
||||
for (client_id, sub_id), last_seen in list(combo.clients.items()):
|
||||
if now - last_seen > self._heartbeat_timeout:
|
||||
self._sub_index.pop((client_id, sub_id), None)
|
||||
handle = self._remove_subscription_locked(key, client_id, sub_id)
|
||||
if handle is not None:
|
||||
handles_to_close.append(handle)
|
||||
reaped += 1
|
||||
for handle in handles_to_close:
|
||||
self._close_source(handle)
|
||||
return reaped
|
||||
|
||||
# -- internals -------------------------------------------------------------
|
||||
def _make_on_push(self, key):
|
||||
def on_push(data):
|
||||
publisher = self._on_push_publisher
|
||||
if publisher is None:
|
||||
return
|
||||
with self._lock:
|
||||
combo = self._combos.get(key)
|
||||
topic = combo.topic if combo is not None else None
|
||||
if topic is None:
|
||||
return
|
||||
# Publish outside the lock: it is network IO and must not stall the
|
||||
# quote thread or block reaper/RPC threads waiting on the lock.
|
||||
publisher(topic, data)
|
||||
|
||||
return on_push
|
||||
|
||||
def _remove_subscription_locked(self, key, client_id, sub_id):
|
||||
"""Remove one (client_id, sub_id) from a combo. If the combo has no
|
||||
subscriptions left, detach it and return its source handle for the
|
||||
caller to close OUTSIDE the lock; else return None. Caller must hold
|
||||
the lock."""
|
||||
combo = self._combos.get(key)
|
||||
if combo is None:
|
||||
return None
|
||||
combo.clients.pop((client_id, sub_id), None)
|
||||
if combo.clients:
|
||||
return None
|
||||
self._combos.pop(key, None)
|
||||
return combo.handle
|
||||
|
||||
def _close_source(self, handle):
|
||||
if handle is None:
|
||||
return
|
||||
try:
|
||||
self._source.unsubscribe(handle)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _monotonic():
|
||||
import time
|
||||
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def build_quote_subscription_service(
|
||||
context_info,
|
||||
transport_name="redis",
|
||||
account_id="",
|
||||
redis_client=None,
|
||||
zmq_bind_address=None,
|
||||
enabled=True,
|
||||
heartbeat_timeout_seconds=30.0,
|
||||
time_func=None,
|
||||
):
|
||||
"""Assemble the server-side whole-quote service: a ContextInfo-backed source,
|
||||
a push channel matching the RPC transport, and a QuoteSubscriptionManager
|
||||
wired so big-QMT pushes publish to the channel. Returns ``(manager, channel)``
|
||||
or ``None`` when disabled. The caller starts the channel publisher and feeds
|
||||
``manager.reap_expired`` from the scheduler loop."""
|
||||
if not enabled:
|
||||
return None
|
||||
from .quote_push_channel import RedisQuotePushChannel, ZmqQuotePushChannel
|
||||
|
||||
source = ContextInfoQuoteSource(context_info)
|
||||
transport_name = str(transport_name or "redis").lower()
|
||||
if transport_name == "zmq":
|
||||
bind_address = zmq_bind_address or _default_quote_push_zmq_bind(account_id)
|
||||
channel = ZmqQuotePushChannel(bind_address=bind_address)
|
||||
push_endpoint = bind_address
|
||||
else:
|
||||
channel = RedisQuotePushChannel(redis_client, account_id=account_id)
|
||||
push_endpoint = ""
|
||||
manager = QuoteSubscriptionManager(
|
||||
source,
|
||||
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
|
||||
time_func=time_func,
|
||||
on_push_publisher=channel.publish,
|
||||
push_endpoint=push_endpoint,
|
||||
)
|
||||
return manager, channel
|
||||
|
||||
|
||||
def _default_quote_push_zmq_bind(account_id):
|
||||
"""Default server PUB bind address: loopback, RPC zmq port + 1 (client side
|
||||
derives the same host/port + 1 to connect)."""
|
||||
from .transports.zmq_transport import _default_zmq_port
|
||||
|
||||
return "tcp://0.0.0.0:%d" % (_default_zmq_port(account_id) + 1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
"""信号执行前的轻量风控和数量计算。"""
|
||||
|
||||
from .code_utils import normalize_stock_code, round_buy_volume, round_sell_volume
|
||||
from .models import SignalAction, TradeSignal
|
||||
|
||||
|
||||
class RiskDecision:
|
||||
def __init__(self, allowed, reason="", volume=0, stock_code=""):
|
||||
self.allowed = allowed
|
||||
self.reason = reason
|
||||
self.volume = volume
|
||||
self.stock_code = stock_code
|
||||
|
||||
|
||||
def build_trade_volume(signal: TradeSignal, positions):
|
||||
code = normalize_stock_code(signal.stock_code)
|
||||
if signal.action == SignalAction.BUY:
|
||||
return RiskDecision(True, volume=round_buy_volume(code, signal.amount), stock_code=code)
|
||||
|
||||
if signal.action == SignalAction.SELL:
|
||||
position = positions.get(code)
|
||||
if not position or position.available <= 0:
|
||||
return RiskDecision(False, "no_available_position", stock_code=code)
|
||||
if signal.amount is not None:
|
||||
raw_volume = min(int(signal.amount), int(position.available))
|
||||
sell_all = raw_volume == int(position.available)
|
||||
else:
|
||||
pct = float(signal.percentage or 100)
|
||||
raw_volume = int(int(position.available) * pct / 100.0)
|
||||
sell_all = pct >= 100
|
||||
volume = round_sell_volume(code, raw_volume, sell_all=sell_all)
|
||||
if volume <= 0:
|
||||
return RiskDecision(False, "volume_below_min_lot", stock_code=code)
|
||||
return RiskDecision(True, volume=volume, stock_code=code)
|
||||
|
||||
return RiskDecision(False, f"unsupported_action:{signal.action}", stock_code=code)
|
||||
|
||||
|
||||
def validate_signal(signal, now, positions):
|
||||
if signal.is_expired(now):
|
||||
return RiskDecision(False, "expired", stock_code=signal.stock_code)
|
||||
decision = build_trade_volume(signal, positions)
|
||||
if decision.allowed and decision.volume <= 0:
|
||||
return RiskDecision(False, "invalid_volume", stock_code=decision.stock_code)
|
||||
return decision
|
||||
@@ -0,0 +1,71 @@
|
||||
"""大 QMT 运行文件可复用的转发入口。"""
|
||||
|
||||
import datetime as _dt
|
||||
import traceback
|
||||
|
||||
from .logging_setup import get_logger
|
||||
|
||||
_log = get_logger("runner")
|
||||
|
||||
_APP = None
|
||||
|
||||
|
||||
def reset_app():
|
||||
global _APP
|
||||
_APP = None
|
||||
|
||||
|
||||
def get_app():
|
||||
return _APP
|
||||
|
||||
|
||||
def init_app(context_info, app_factory):
|
||||
global _APP
|
||||
_APP = app_factory(context_info)
|
||||
if hasattr(_APP, "on_init"):
|
||||
_APP.on_init(context_info)
|
||||
return _APP
|
||||
|
||||
|
||||
def tick_app(context_info, now=None):
|
||||
if _APP is None:
|
||||
return None
|
||||
now = now or _dt.datetime.now()
|
||||
try:
|
||||
return _APP.tick(now)
|
||||
except Exception:
|
||||
_log.error("tick_app failed:\n%s", traceback.format_exc())
|
||||
return None
|
||||
|
||||
|
||||
def forward_order_event(event):
|
||||
# Unguarded events reach QMT's order_callback, which stops the strategy on
|
||||
# raise. Guard like tick_app so a bad event (e.g. redis outage during the
|
||||
# position-sync publish) never stops the strategy.
|
||||
if _APP is None:
|
||||
return None
|
||||
try:
|
||||
return _APP.on_order_event(event)
|
||||
except Exception:
|
||||
_log.error("forward_order_event failed:\n%s", traceback.format_exc())
|
||||
return None
|
||||
|
||||
|
||||
def forward_trade_event(event):
|
||||
if _APP is None:
|
||||
return None
|
||||
try:
|
||||
return _APP.on_trade_event(event)
|
||||
except Exception:
|
||||
_log.error("forward_trade_event failed:\n%s", traceback.format_exc())
|
||||
return None
|
||||
|
||||
|
||||
def sync_positions_app(reason="manual"):
|
||||
if _APP is None:
|
||||
return None
|
||||
try:
|
||||
return _APP.sync_positions(reason)
|
||||
except Exception:
|
||||
_log.error("sync_positions_app failed:\n%s", traceback.format_exc())
|
||||
return None
|
||||
@@ -0,0 +1,19 @@
|
||||
"""大 QMT 运行环境适配器骨架。"""
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
class BigQmtRuntimeAdapter:
|
||||
def __init__(self, context_info):
|
||||
self.context_info = context_info
|
||||
|
||||
def now(self):
|
||||
return _dt.datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def to_order_event(order):
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def to_trade_event(trade):
|
||||
return trade
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Pluggable transport layer for the BigQMT RPC bridge.
|
||||
|
||||
The :class:`~bigqmt_signal_trader.transports.base.RpcTransport` interface owns
|
||||
the wire: how a request dict travels from the client to the QMT server and how
|
||||
the response dict travels back. ``redis`` is the reference implementation; the
|
||||
same business layer (handlers / ``process_request`` / ``to_jsonable``) runs
|
||||
unchanged over any transport.
|
||||
|
||||
Select a transport with ``rpc.transport`` in the config (default ``"redis"``).
|
||||
See :mod:`~bigqmt_signal_trader.transports.factory`.
|
||||
"""
|
||||
|
||||
from .base import RpcTransport, TransportError, TransportTimeout
|
||||
from .factory import build_transport
|
||||
|
||||
__all__ = ["RpcTransport", "TransportError", "TransportTimeout", "build_transport"]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Abstract transport interface for the BigQMT RPC bridge.
|
||||
|
||||
A transport owns the request/response wire. The business layer (handlers,
|
||||
``process_request``, ``to_jsonable``, ``enqueue_payload``, ``drain_pending``)
|
||||
is transport-agnostic; it only deals with request/response dicts.
|
||||
|
||||
Two roles, one interface
|
||||
------------------------
|
||||
* **Client side** — :meth:`RpcTransport.send_request`: send a request dict and
|
||||
block for the matching response dict (matched by ``request_id``).
|
||||
* **Server side** — :meth:`RpcTransport.start_receiving` registers a callback
|
||||
``on_request(request_dict)`` invoked per inbound request; the callback returns
|
||||
the response dict. :meth:`RpcTransport.send_response` delivers a response
|
||||
back to the client that sent ``request_dict`` (reply routing info is read
|
||||
from the request).
|
||||
|
||||
The request dict always carries the existing envelope (``schema_version``,
|
||||
``request_id``, ``account_id``, ``method``, ``params``). It MAY carry reply
|
||||
routing hints (``reply_key``/``reply_channel``/``reply_list``/``ttl_seconds``);
|
||||
Redis uses them, other transports may ignore them and use native routing.
|
||||
"""
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""A transport failed (connection lost, encode error, etc.)."""
|
||||
|
||||
|
||||
class TransportTimeout(TimeoutError):
|
||||
"""A request did not complete within the timeout window."""
|
||||
|
||||
|
||||
class RpcTransport(object):
|
||||
"""Abstract request/response transport. Concrete implementations own the wire.
|
||||
|
||||
Subclasses MUST override :meth:`send_request`,
|
||||
:meth:`start_receiving`, :meth:`send_response`, and :meth:`stop`.
|
||||
"""
|
||||
|
||||
name = "abstract"
|
||||
|
||||
def __init__(self, account_id="", print_prefix="[bigqmt_rpc]"):
|
||||
self.account_id = str(account_id or "")
|
||||
self.print_prefix = print_prefix
|
||||
self._on_request = None
|
||||
self._running = False
|
||||
|
||||
# -- client side -------------------------------------------------------
|
||||
def send_request(self, request, timeout_seconds):
|
||||
"""Send a request dict and block for the response dict.
|
||||
|
||||
``request`` is the full request envelope. Returns the response dict
|
||||
(with ``request_id`` matching). Raises :class:`TransportTimeout` if no
|
||||
response arrives within ``timeout_seconds``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# -- server side -------------------------------------------------------
|
||||
def start_receiving(self, on_request):
|
||||
"""Begin accepting inbound requests on the server side.
|
||||
|
||||
``on_request(request_dict)`` is invoked per inbound request and MUST
|
||||
return the response dict. Implementations may spawn a background
|
||||
thread. Safe to call once per transport instance.
|
||||
"""
|
||||
self._on_request = on_request
|
||||
self._running = True
|
||||
|
||||
def send_response(self, request, response):
|
||||
"""Deliver ``response`` back to the client that sent ``request``.
|
||||
|
||||
Reply routing is read from ``request`` (e.g. ``reply_key`` /
|
||||
``reply_channel`` / ``reply_list`` for Redis, or a native peer handle
|
||||
for ZMQ). Must be safe to call from the request-handling callback.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self):
|
||||
"""Stop receiving and release any sockets/connections/threads."""
|
||||
self._running = False
|
||||
self._on_request = None
|
||||
|
||||
def deliver(self, request):
|
||||
"""Internal: invoke the registered ``on_request`` callback.
|
||||
|
||||
Concrete transports call this when an inbound request arrives. If the
|
||||
callback returns a non-None response dict, it is delivered back to the
|
||||
client via :meth:`send_response` automatically — so a callback only
|
||||
needs to ``return response``. Callbacks that send the response
|
||||
themselves (e.g. the Redis service path, which routes through
|
||||
``_publish_response``) should return ``None`` to suppress the auto-send.
|
||||
|
||||
Handler exceptions are turned into an ``ok=False`` response envelope so
|
||||
the receive loop keeps running.
|
||||
"""
|
||||
callback = self._on_request
|
||||
if callback is None:
|
||||
return None
|
||||
try:
|
||||
response = callback(request)
|
||||
except Exception as exc: # noqa: BLE001 - transport must survive
|
||||
import datetime as _dt
|
||||
|
||||
response = {
|
||||
"schema_version": 1,
|
||||
"request_id": str((request or {}).get("request_id") or ""),
|
||||
"account_id": str((request or {}).get("account_id") or self.account_id or ""),
|
||||
"method": str((request or {}).get("method") or ""),
|
||||
"ok": False,
|
||||
"data": None,
|
||||
"error": "%s: %s" % (exc.__class__.__name__, exc),
|
||||
"handled_at": _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
if response is not None:
|
||||
try:
|
||||
self.send_response(request, response)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s account_id=%r>" % (self.__class__.__name__, self.account_id)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Transport factory: pick a transport backend by name.
|
||||
|
||||
``build_transport(name, config, ...)`` returns a ready transport instance.
|
||||
``name`` is the ``rpc.transport`` config value (default ``"redis"``). Unknown
|
||||
names raise :class:`ValueError`. Optional dependencies (``zmq``, a mysql
|
||||
driver) are imported lazily; a missing dependency surfaces as a clear
|
||||
``ImportError`` only when that transport is actually selected.
|
||||
"""
|
||||
|
||||
from .base import RpcTransport
|
||||
from .redis_transport import RedisTransport
|
||||
|
||||
|
||||
KNOWN_TRANSPORTS = ("redis", "zmq", "mysql", "shm")
|
||||
|
||||
|
||||
def build_transport(
|
||||
name,
|
||||
config=None,
|
||||
account_id="",
|
||||
print_prefix="[bigqmt_rpc]",
|
||||
):
|
||||
"""Construct a transport by name.
|
||||
|
||||
``config`` is the ``rpc`` config dict. Each backend reads its own sub-keys
|
||||
(``config["zmq"]``, ``config["mysql"]``); Redis reads the legacy keys
|
||||
(``request_channel_template`` etc.) plus ``redis_client``/
|
||||
``response_redis_client`` that the caller may inject.
|
||||
"""
|
||||
config = dict(config or {})
|
||||
name = str(name or "redis").lower()
|
||||
|
||||
if name in ("redis", "", "default"):
|
||||
return _build_redis(config, account_id, print_prefix)
|
||||
if name == "zmq":
|
||||
return _build_zmq(config, account_id, print_prefix)
|
||||
if name == "mysql":
|
||||
return _build_mysql(config, account_id, print_prefix)
|
||||
if name == "shm":
|
||||
return _build_shm(config, account_id, print_prefix)
|
||||
raise ValueError(
|
||||
"unknown rpc transport %r (known: %s)" % (name, ", ".join(KNOWN_TRANSPORTS))
|
||||
)
|
||||
|
||||
|
||||
def _build_redis(config, account_id, print_prefix):
|
||||
redis_client = config.get("redis_client")
|
||||
if redis_client is None:
|
||||
from ..adapters.redis_common import build_redis_client
|
||||
|
||||
redis_config = dict(config.get("redis") or {})
|
||||
redis_client = build_redis_client(redis_config)
|
||||
response_redis_client = config.get("response_redis_client")
|
||||
if response_redis_client is None:
|
||||
response_redis_client = redis_client
|
||||
return RedisTransport(
|
||||
redis_client,
|
||||
account_id=account_id,
|
||||
response_redis_client=response_redis_client,
|
||||
request_channel_template=config.get(
|
||||
"request_channel_template", "bigqmt:rpc:req:{account_id}"
|
||||
),
|
||||
request_queue_template=config.get(
|
||||
"request_queue_template", "bigqmt:rpc:queue:{account_id}"
|
||||
),
|
||||
response_channel_template=config.get(
|
||||
"response_channel_template", "bigqmt:rpc:resp:{account_id}:{request_id}"
|
||||
),
|
||||
response_list_template=config.get(
|
||||
"response_list_template", "bigqmt:rpc:respq:{account_id}:{request_id}"
|
||||
),
|
||||
response_key_template=config.get(
|
||||
"response_key_template", "bigqmt:rpc:resp:{account_id}:{request_id}"
|
||||
),
|
||||
response_ttl_seconds=int(config.get("response_ttl_seconds", 60)),
|
||||
queue_poll_interval_seconds=float(config.get("queue_poll_interval_seconds", 0.02)),
|
||||
debug_log_limit=int(config.get("debug_log_limit", 0)),
|
||||
print_prefix=print_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _build_zmq(config, account_id, print_prefix):
|
||||
from .zmq_transport import ZmqTransport
|
||||
|
||||
zmq_config = dict(config.get("zmq") or {})
|
||||
# Wire up service discovery: if the caller injected a redis_client (server
|
||||
# side) or provided redis connection settings, the ZMQ transport can
|
||||
# publish/look up the actual bound port when the default port is taken.
|
||||
discovery_client = zmq_config.get("discovery_redis_client")
|
||||
if discovery_client is None and config.get("redis_client") is not None:
|
||||
discovery_client = config.get("redis_client")
|
||||
zmq_config["discovery_redis_client"] = discovery_client
|
||||
if discovery_client is None and config.get("redis"):
|
||||
# Build a small client just for discovery from the redis config block.
|
||||
try:
|
||||
from ..adapters.redis_common import build_redis_client
|
||||
|
||||
discovery_client = build_redis_client(dict(config.get("redis") or {}))
|
||||
zmq_config["discovery_redis_client"] = discovery_client
|
||||
except Exception:
|
||||
pass
|
||||
return ZmqTransport.from_config(
|
||||
zmq_config,
|
||||
account_id=account_id,
|
||||
print_prefix=print_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _build_mysql(config, account_id, print_prefix):
|
||||
from .mysql_transport import MysqlTransport
|
||||
|
||||
return MysqlTransport.from_config(
|
||||
config.get("mysql") or {},
|
||||
account_id=account_id,
|
||||
print_prefix=print_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _build_shm(config, account_id, print_prefix):
|
||||
from .shm_transport import SharedMemoryTransport
|
||||
|
||||
return SharedMemoryTransport(
|
||||
account_id=account_id,
|
||||
print_prefix=print_prefix,
|
||||
**dict(config.get("shm") or {})
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""MySQL transport for the BigQMT RPC bridge.
|
||||
|
||||
A compatibility-oriented backend for environments where Redis/ZMQ are
|
||||
unavailable but a relational DB is. Latency is dominated by polling cadence,
|
||||
so this is NOT a low-latency path (expect tens of ms); use it when the
|
||||
deployment constraints rule out the others.
|
||||
|
||||
Schema (auto-created on first connect)::
|
||||
|
||||
CREATE TABLE bigqmt_rpc_requests (
|
||||
request_id VARCHAR(64) PRIMARY KEY,
|
||||
account_id VARCHAR(64) NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at DOUBLE NOT NULL,
|
||||
claimed_at DOUBLE NULL,
|
||||
INDEX idx_account_created (account_id, created_at)
|
||||
);
|
||||
CREATE TABLE bigqmt_rpc_responses (
|
||||
request_id VARCHAR(64) PRIMARY KEY,
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at DOUBLE NOT NULL
|
||||
);
|
||||
|
||||
The client ``INSERT``s a request row and polls ``bigqmt_rpc_responses`` by
|
||||
``request_id``; the server ``SELECT ... FOR UPDATE SKIP LOCKED`` (or a
|
||||
``claimed_at`` flag on older engines) claims a request, invokes the handler,
|
||||
then ``INSERT``s the response row. Rows are cleaned up lazily by TTL.
|
||||
|
||||
Any DB-API 2.0 driver works (pymysql, mysql-connector, sqlite3 for tests).
|
||||
Pass ``driver="pymysql"`` / ``"sqlite3"`` etc. via config.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from ..adapters.redis_common import decode_text
|
||||
from ..redis_rpc import encode_rpc_request_payload, decode_rpc_request_payload
|
||||
from .base import RpcTransport, TransportError, TransportTimeout
|
||||
|
||||
|
||||
REQUESTS_TABLE = "bigqmt_rpc_requests"
|
||||
RESPONSES_TABLE = "bigqmt_rpc_responses"
|
||||
|
||||
|
||||
_SCHEMA = [
|
||||
"""CREATE TABLE IF NOT EXISTS {requests} (
|
||||
request_id VARCHAR(64) PRIMARY KEY,
|
||||
account_id VARCHAR(64) NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at DOUBLE NOT NULL,
|
||||
claimed_at DOUBLE NULL
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS {responses} (
|
||||
request_id VARCHAR(64) PRIMARY KEY,
|
||||
payload MEDIUMTEXT NOT NULL,
|
||||
created_at DOUBLE NOT NULL
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_{requests}_account_created ON {requests} (account_id, created_at)",
|
||||
]
|
||||
|
||||
|
||||
def _loads(raw):
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
text = decode_text(raw)
|
||||
text = decode_rpc_request_payload(text)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
class MysqlTransport(RpcTransport):
|
||||
"""Polling-based transport over a relational DB.
|
||||
|
||||
Both client and server open a short-lived connection per operation to keep
|
||||
the implementation driver-agnostic and avoid cross-thread cursor state.
|
||||
For high throughput a connection pool would help; this backend targets
|
||||
compatibility, not throughput.
|
||||
"""
|
||||
|
||||
name = "mysql"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver="pymysql",
|
||||
connect_kwargs=None,
|
||||
requests_table=REQUESTS_TABLE,
|
||||
responses_table=RESPONSES_TABLE,
|
||||
account_id="",
|
||||
print_prefix="[bigqmt_rpc]",
|
||||
poll_interval_seconds=0.02,
|
||||
row_ttl_seconds=120,
|
||||
background_threads=True,
|
||||
pool_config=None,
|
||||
use_pool=True,
|
||||
):
|
||||
super(MysqlTransport, self).__init__(account_id=account_id, print_prefix=print_prefix)
|
||||
self.driver = driver
|
||||
self.connect_kwargs = dict(connect_kwargs or {})
|
||||
self.requests_table = requests_table
|
||||
self.responses_table = responses_table
|
||||
self.poll_interval_seconds = max(0.001, float(poll_interval_seconds))
|
||||
self.row_ttl_seconds = int(row_ttl_seconds)
|
||||
self.background_threads = bool(background_threads)
|
||||
self._thread = None
|
||||
self._schema_ready = False
|
||||
self.use_pool = bool(use_pool)
|
||||
self.pool_config = dict(pool_config or {})
|
||||
self._pool = None
|
||||
# paramstyle: mysql drivers use "format" (%s), sqlite3 uses "qmark" (?).
|
||||
# Resolved lazily on first connect.
|
||||
self._placeholder = None
|
||||
|
||||
def _resolve_placeholder(self, mod):
|
||||
style = getattr(mod, "paramstyle", "format")
|
||||
if style == "qmark":
|
||||
return "?"
|
||||
return "%s" # format / pyformat / default
|
||||
|
||||
def _ph(self):
|
||||
# Return the placeholder char (resolving lazily).
|
||||
if self._placeholder is None:
|
||||
try:
|
||||
mod = __import__(self.driver)
|
||||
self._placeholder = self._resolve_placeholder(mod)
|
||||
except ImportError:
|
||||
self._placeholder = "%s"
|
||||
return self._placeholder
|
||||
|
||||
def _sql(self, template):
|
||||
"""Render a SQL template: fill {t}/{requests}/{responses} table names
|
||||
and swap the standard ``%s`` placeholder for the driver's paramstyle."""
|
||||
return template.format(
|
||||
t=None, # not used; callers format table names themselves
|
||||
requests=self.requests_table,
|
||||
responses=self.responses_table,
|
||||
).replace("__PH__", self._ph())
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config, account_id="", print_prefix="[bigqmt_rpc]"):
|
||||
config = dict(config or {})
|
||||
driver = config.get("driver", "pymysql")
|
||||
connect_kwargs = dict(config.get("connect_kwargs") or {})
|
||||
# Allow flat keys (host/port/user/...) as a convenience.
|
||||
for key in ("host", "port", "user", "password", "database", "charset"):
|
||||
if key in config and key not in connect_kwargs:
|
||||
connect_kwargs[key] = config[key]
|
||||
return cls(
|
||||
driver=driver,
|
||||
connect_kwargs=connect_kwargs,
|
||||
requests_table=config.get("requests_table", REQUESTS_TABLE),
|
||||
responses_table=config.get("responses_table", RESPONSES_TABLE),
|
||||
account_id=config.get("account_id", account_id),
|
||||
print_prefix=print_prefix,
|
||||
poll_interval_seconds=float(config.get("poll_interval_seconds", 0.02)),
|
||||
row_ttl_seconds=int(config.get("row_ttl_seconds", 120)),
|
||||
background_threads=bool(config.get("background_threads", True)),
|
||||
pool_config=config.get("pool_config"),
|
||||
use_pool=bool(config.get("use_pool", True)),
|
||||
)
|
||||
|
||||
# -- driver access / connection pool ----------------------------------
|
||||
def _import_driver(self):
|
||||
try:
|
||||
return __import__(self.driver)
|
||||
except ImportError as exc: # pragma: no cover - depends on env
|
||||
raise TransportError(
|
||||
"db driver %r is required for the mysql transport: %s"
|
||||
% (self.driver, exc)
|
||||
)
|
||||
|
||||
def _build_pool(self):
|
||||
"""Create a DBUtils PooledDB backed by the configured driver.
|
||||
|
||||
Works with any DB-API 2.0 driver (pymysql, mysql.connector, sqlite3,
|
||||
...). Pool sizing comes from ``pool_config``; connection kwargs are
|
||||
forwarded to the driver's ``connect()``.
|
||||
"""
|
||||
try:
|
||||
from dbutils.pooled_db import PooledDB
|
||||
except ImportError as exc:
|
||||
raise TransportError(
|
||||
"DBUtils is required for the mysql transport connection pool: %s" % exc
|
||||
)
|
||||
driver = self._import_driver()
|
||||
cfg = dict(self.pool_config)
|
||||
# Sensible defaults for an RPC workload: small idle pool, modest cap,
|
||||
# reuse connections across threads. Callers override via pool_config.
|
||||
mincached = cfg.pop("mincached", 1)
|
||||
maxcached = cfg.pop("maxcached", 4)
|
||||
maxshared = cfg.pop("maxshared", 3)
|
||||
maxconnections = cfg.pop("maxconnections", 8)
|
||||
blocking = cfg.pop("blocking", True)
|
||||
maxusage = cfg.pop("maxusage", 0)
|
||||
reset = cfg.pop("reset", True)
|
||||
# Whatever remains in cfg is treated as extra creator kwargs (e.g.
|
||||
# ping, setsession) and merged under the connect kwargs.
|
||||
extra = cfg
|
||||
connect_kwargs = self._pooled_connect_args()
|
||||
connect_kwargs.update(extra)
|
||||
return PooledDB(
|
||||
creator=driver,
|
||||
mincached=mincached,
|
||||
maxcached=maxcached,
|
||||
maxshared=maxshared,
|
||||
maxconnections=maxconnections,
|
||||
blocking=blocking,
|
||||
maxusage=maxusage,
|
||||
reset=reset,
|
||||
**connect_kwargs
|
||||
)
|
||||
|
||||
def _pooled_connect_args(self):
|
||||
"""Return the kwargs to forward to the driver's connect().
|
||||
|
||||
Stripped of empty credential fields so drivers that reject empty
|
||||
username/password (e.g. pymysql with auth plugin) don't choke.
|
||||
"""
|
||||
cfg = dict(self.connect_kwargs)
|
||||
if not cfg.get("user") and "user" in cfg:
|
||||
cfg.pop("user")
|
||||
if not cfg.get("password") and "password" in cfg:
|
||||
cfg.pop("password")
|
||||
return cfg
|
||||
|
||||
def _connect(self):
|
||||
if not self.use_pool:
|
||||
return self._import_driver().connect(**self.connect_kwargs)
|
||||
if self._pool is None:
|
||||
self._pool = self._build_pool()
|
||||
# PooledDB.connection() hands out a pooled connection; calling .close()
|
||||
# on it returns it to the pool rather than closing the underlying socket.
|
||||
return self._pool.connection()
|
||||
|
||||
def _ensure_schema(self):
|
||||
if self._schema_ready:
|
||||
return
|
||||
ctx = {"requests": self.requests_table, "responses": self.responses_table}
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for stmt in _SCHEMA:
|
||||
try:
|
||||
cur.execute(stmt.format(**ctx))
|
||||
except Exception:
|
||||
# "CREATE INDEX IF NOT EXISTS" is not supported on some
|
||||
# MySQL versions; the index is an optimization, ignore failure.
|
||||
pass
|
||||
conn.commit()
|
||||
self._schema_ready = True
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _now(self):
|
||||
return time.time()
|
||||
|
||||
# -- client side ------------------------------------------------------
|
||||
def send_request(self, request, timeout_seconds):
|
||||
self._ensure_schema()
|
||||
request = dict(request)
|
||||
request.setdefault("request_id", uuid.uuid4().hex)
|
||||
request_id = str(request["request_id"])
|
||||
request.setdefault("account_id", self.account_id)
|
||||
payload = encode_rpc_request_payload(request)
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
self._sql(
|
||||
"INSERT INTO {requests} (request_id, account_id, payload, created_at, claimed_at) "
|
||||
"VALUES (__PH__, __PH__, __PH__, __PH__, NULL)"
|
||||
),
|
||||
(request_id, str(request.get("account_id") or self.account_id), payload, self._now()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
while time.time() < deadline:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
self._sql("SELECT payload FROM {responses} WHERE request_id = __PH__"),
|
||||
(request_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
payload = row[0]
|
||||
try:
|
||||
cur.execute(
|
||||
self._sql("DELETE FROM {responses} WHERE request_id = __PH__"),
|
||||
(request_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return _loads(payload)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(self.poll_interval_seconds)
|
||||
raise TransportTimeout("mysql rpc timeout: %s" % request.get("method"))
|
||||
|
||||
# -- server side ------------------------------------------------------
|
||||
def start_receiving(self, on_request, background_threads=None):
|
||||
super(MysqlTransport, self).start_receiving(on_request)
|
||||
self._ensure_schema()
|
||||
if background_threads is None:
|
||||
background_threads = self.background_threads
|
||||
if not background_threads:
|
||||
print("%s mysql polling table=%s background_threads=False" % (
|
||||
self.print_prefix, self.requests_table))
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop, name="bigqmt-mysql-rpc", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
print("%s mysql started polling table=%s" % (
|
||||
self.print_prefix, self.requests_table))
|
||||
|
||||
def _poll_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
self._claim_and_handle_batch()
|
||||
except Exception as exc:
|
||||
if not self._running:
|
||||
break
|
||||
print("%s mysql poll failed: %s" % (self.print_prefix, exc))
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
time.sleep(self.poll_interval_seconds)
|
||||
|
||||
def _claim_and_handle_batch(self, max_items=20):
|
||||
conn = self._connect()
|
||||
claimed = []
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
# Claim rows: mark claimed_at so concurrent servers skip them.
|
||||
# Uses an atomic UPDATE ... WHERE claimed_at IS NULL with a cap.
|
||||
cur.execute(
|
||||
self._sql(
|
||||
"SELECT request_id, payload FROM {requests} WHERE account_id = __PH__ "
|
||||
"AND claimed_at IS NULL ORDER BY created_at ASC LIMIT __PH__"
|
||||
),
|
||||
(self.account_id, int(max_items)),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
now = self._now()
|
||||
for request_id, payload in rows:
|
||||
cur.execute(
|
||||
self._sql(
|
||||
"UPDATE {requests} SET claimed_at = __PH__ WHERE request_id = __PH__ "
|
||||
"AND claimed_at IS NULL"
|
||||
),
|
||||
(now, request_id),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
claimed.append((request_id, payload))
|
||||
conn.commit()
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
for request_id, payload in claimed:
|
||||
try:
|
||||
request = _loads(payload)
|
||||
request["request_id"] = request_id
|
||||
except Exception as exc:
|
||||
print("%s mysql decode failed: %s" % (self.print_prefix, exc))
|
||||
self._delete_request(request_id)
|
||||
continue
|
||||
try:
|
||||
self.deliver(request)
|
||||
except Exception as exc:
|
||||
print("%s mysql deliver failed: %s" % (self.print_prefix, exc))
|
||||
self._delete_request(request_id)
|
||||
|
||||
def _delete_request(self, request_id):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
self._sql("DELETE FROM {requests} WHERE request_id = __PH__"),
|
||||
(request_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def send_response(self, request, response):
|
||||
request_id = str(
|
||||
response.get("request_id") or request.get("request_id") or ""
|
||||
)
|
||||
payload = encode_rpc_request_payload(response)
|
||||
# DELETE-then-INSERT is portable across MySQL and sqlite (avoids the
|
||||
# MySQL-only ON DUPLICATE KEY / REPLACE syntax). One connection, one txn.
|
||||
conn = self._connect()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
self._sql("DELETE FROM {responses} WHERE request_id = __PH__"),
|
||||
(request_id,),
|
||||
)
|
||||
cur.execute(
|
||||
self._sql(
|
||||
"INSERT INTO {responses} (request_id, payload, created_at) "
|
||||
"VALUES (__PH__, __PH__, __PH__)"
|
||||
),
|
||||
(request_id, payload, self._now()),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
print("%s mysql response write failed: %s" % (self.print_prefix, exc))
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- non-background drain (strategy adjust thread) --------------------
|
||||
def drain_request_queue(self, max_items=20):
|
||||
if not self._running:
|
||||
return 0
|
||||
before = 0 # _claim_and_handle_batch handles its own count
|
||||
self._claim_and_handle_batch(max_items=max_items)
|
||||
return 0
|
||||
|
||||
def stop(self):
|
||||
super(MysqlTransport, self).stop()
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._thread.join(1.0)
|
||||
self._thread = None
|
||||
# Close the connection pool so background connections are released.
|
||||
if self._pool is not None:
|
||||
try:
|
||||
self._pool.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._pool = None
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Redis transport for the BigQMT RPC bridge.
|
||||
|
||||
This is the reference transport and the default. It preserves the exact wire
|
||||
behavior of the original ``RedisPubSubRpcService``:
|
||||
|
||||
* Client ``send_request``: ``RPUSH`` the (base64-obfuscated) request onto the
|
||||
per-account request queue, then ``BLPOP`` the per-request response list with
|
||||
a ``GET response_key`` fallback. A ``pubsub`` transport variant is kept for
|
||||
callers that pass ``transport="pubsub"`` to ``call_redis_rpc``.
|
||||
* Server receive: two background loops — a ``pubsub.subscribe`` loop and a
|
||||
``brpop`` queue loop. Either delivers inbound payloads to the registered
|
||||
``on_request`` callback.
|
||||
* Server ``send_response``: fan-out writes to ``reply_key`` (``SETEX``),
|
||||
``reply_list`` (``RPUSH`` + ``EXPIRE``) and ``reply_channel`` (``PUBLISH``).
|
||||
|
||||
The module-level :func:`call_redis_rpc` helper keeps its original signature and
|
||||
delegates here so existing callers and ``bench_latency.py`` are unchanged.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from ..adapters.redis_common import decode_text
|
||||
from ..redis_rpc import (
|
||||
decode_rpc_request_payload,
|
||||
encode_rpc_request_payload,
|
||||
)
|
||||
from .base import RpcTransport, TransportTimeout
|
||||
|
||||
import json # noqa: E402 (kept here so transport owns all wire encoding)
|
||||
|
||||
|
||||
REQUEST_CHANNEL_TEMPLATE = "bigqmt:rpc:req:{account_id}"
|
||||
REQUEST_QUEUE_TEMPLATE = "bigqmt:rpc:queue:{account_id}"
|
||||
RESPONSE_CHANNEL_TEMPLATE = "bigqmt:rpc:resp:{account_id}:{request_id}"
|
||||
RESPONSE_LIST_TEMPLATE = "bigqmt:rpc:respq:{account_id}:{request_id}"
|
||||
RESPONSE_KEY_TEMPLATE = "bigqmt:rpc:resp:{account_id}:{request_id}"
|
||||
|
||||
|
||||
def _format(template, account_id, request_id):
|
||||
if not template:
|
||||
return ""
|
||||
return template.format(account_id=account_id, request_id=request_id)
|
||||
|
||||
|
||||
def _loads(raw_payload):
|
||||
"""Decode a wire payload (bytes/str/dict) into a request dict."""
|
||||
if isinstance(raw_payload, dict):
|
||||
return dict(raw_payload)
|
||||
text = decode_text(raw_payload)
|
||||
text = decode_rpc_request_payload(text)
|
||||
payload = json.loads(text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("rpc payload must be a json object")
|
||||
return payload
|
||||
|
||||
|
||||
def _is_redis_timeout(exc):
|
||||
name = exc.__class__.__name__.lower()
|
||||
module = getattr(exc.__class__, "__module__", "")
|
||||
text = str(exc).lower()
|
||||
return ("redis" in module and "timeout" in name) or "timeout reading from socket" in text
|
||||
|
||||
|
||||
class RedisTransport(RpcTransport):
|
||||
"""Redis-backed transport. Owns rpush/blpop/brpop/publish/setex."""
|
||||
|
||||
name = "redis"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_client,
|
||||
account_id="",
|
||||
response_redis_client=None,
|
||||
request_channel_template=REQUEST_CHANNEL_TEMPLATE,
|
||||
request_queue_template=REQUEST_QUEUE_TEMPLATE,
|
||||
response_channel_template=RESPONSE_CHANNEL_TEMPLATE,
|
||||
response_list_template=RESPONSE_LIST_TEMPLATE,
|
||||
response_key_template=RESPONSE_KEY_TEMPLATE,
|
||||
response_ttl_seconds=60,
|
||||
queue_poll_interval_seconds=0.02,
|
||||
debug_log_limit=0,
|
||||
print_prefix="[bigqmt_rpc]",
|
||||
):
|
||||
super(RedisTransport, self).__init__(account_id=account_id, print_prefix=print_prefix)
|
||||
self.listen_redis = redis_client
|
||||
self.redis = response_redis_client or redis_client
|
||||
self.request_channel_template = request_channel_template
|
||||
self.request_queue_template = request_queue_template
|
||||
self.response_channel_template = response_channel_template
|
||||
self.response_list_template = response_list_template
|
||||
self.response_key_template = response_key_template
|
||||
self.response_ttl_seconds = int(response_ttl_seconds)
|
||||
self.queue_poll_interval_seconds = max(0.001, float(queue_poll_interval_seconds))
|
||||
self.debug_log_limit = int(debug_log_limit)
|
||||
self._received_count = 0
|
||||
self._published_count = 0
|
||||
self._pubsub = None
|
||||
self._thread = None
|
||||
self._queue_thread = None
|
||||
# Hooks so the service can observe/intercept received payloads (debug
|
||||
# logging, inline-vs-deferred dispatch). When None, the request is
|
||||
# delivered straight to the on_request callback.
|
||||
self.on_raw_payload = None
|
||||
|
||||
# -- properties mirroring the original service -------------------------
|
||||
@property
|
||||
def request_channel(self):
|
||||
return self.request_channel_template.format(account_id=self.account_id)
|
||||
|
||||
@property
|
||||
def request_queue(self):
|
||||
return self.request_queue_template.format(account_id=self.account_id)
|
||||
|
||||
def _response_clients(self):
|
||||
clients = [self.redis]
|
||||
if self.listen_redis is not self.redis:
|
||||
clients.append(self.listen_redis)
|
||||
return clients
|
||||
|
||||
# -- client side -------------------------------------------------------
|
||||
def send_request(self, request, timeout_seconds, transport="queue"):
|
||||
"""Send ``request`` and block for the response dict.
|
||||
|
||||
``transport`` selects the Redis sub-transport: ``"queue"`` (default,
|
||||
RPUSH+BLPOP) or ``"pubsub"`` (PUBLISH+subscribe). Kept for parity with
|
||||
the original ``call_redis_rpc`` signature.
|
||||
"""
|
||||
return _call_redis_rpc(
|
||||
self.listen_redis,
|
||||
self.account_id,
|
||||
request,
|
||||
timeout_seconds=float(timeout_seconds),
|
||||
transport=transport,
|
||||
request_channel_template=self.request_channel_template,
|
||||
request_queue_template=self.request_queue_template,
|
||||
response_channel_template=self.response_channel_template,
|
||||
response_list_template=self.response_list_template,
|
||||
response_key_template=self.response_key_template,
|
||||
)
|
||||
|
||||
# -- server side -------------------------------------------------------
|
||||
def start_receiving(self, on_request, background_threads=True):
|
||||
"""Spawn the pubsub + queue receive loops (unless ``background_threads``)."""
|
||||
super(RedisTransport, self).start_receiving(on_request)
|
||||
if not background_threads:
|
||||
print(
|
||||
"%s started queue=%s background_threads=False"
|
||||
% (self.print_prefix, self.request_queue)
|
||||
)
|
||||
return
|
||||
if (
|
||||
self._thread is not None
|
||||
and self._thread.is_alive()
|
||||
and self._queue_thread is not None
|
||||
and self._queue_thread.is_alive()
|
||||
):
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._listen_loop, name="bigqmt-redis-rpc", daemon=True
|
||||
)
|
||||
self._queue_thread = threading.Thread(
|
||||
target=self._queue_loop, name="bigqmt-redis-rpc-queue", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._queue_thread.start()
|
||||
print(
|
||||
"%s started channel=%s queue=%s"
|
||||
% (self.print_prefix, self.request_channel, self.request_queue)
|
||||
)
|
||||
|
||||
def _listen_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
pubsub = self.listen_redis.pubsub(ignore_subscribe_messages=True)
|
||||
self._pubsub = pubsub
|
||||
pubsub.subscribe(self.request_channel)
|
||||
if self.debug_log_limit > 0:
|
||||
print(
|
||||
"%s subscribed channel=%s" % (self.print_prefix, self.request_channel)
|
||||
)
|
||||
while self._running:
|
||||
message = pubsub.get_message(timeout=1.0)
|
||||
if not self._running:
|
||||
break
|
||||
if not message or message.get("type") != "message":
|
||||
continue
|
||||
self._handle_received_payload(message.get("data"), "pubsub")
|
||||
except Exception:
|
||||
print(
|
||||
"%s listener failed:\n%s" % (self.print_prefix, traceback.format_exc())
|
||||
)
|
||||
time.sleep(1.0)
|
||||
finally:
|
||||
try:
|
||||
if self._pubsub is not None:
|
||||
self._pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._pubsub = None
|
||||
|
||||
def _queue_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
if self.debug_log_limit > 0:
|
||||
print(
|
||||
"%s queue polling key=%s" % (self.print_prefix, self.request_queue)
|
||||
)
|
||||
while self._running:
|
||||
item = self.listen_redis.brpop(self.request_queue, timeout=1)
|
||||
if not self._running:
|
||||
break
|
||||
if not item:
|
||||
continue
|
||||
raw = (
|
||||
item[1]
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2
|
||||
else item
|
||||
)
|
||||
self._handle_received_payload(raw, "queue")
|
||||
except Exception:
|
||||
print(
|
||||
"%s queue listener failed:\n%s"
|
||||
% (self.print_prefix, traceback.format_exc())
|
||||
)
|
||||
time.sleep(1.0)
|
||||
|
||||
def _handle_received_payload(self, raw_payload, source):
|
||||
self._received_count += 1
|
||||
if self.on_raw_payload is not None:
|
||||
# Service wants to observe/intercept (e.g. debug log + dispatch fork).
|
||||
self.on_raw_payload(raw_payload, source)
|
||||
return
|
||||
# Default: decode and deliver straight to the registered callback.
|
||||
request = _loads(raw_payload)
|
||||
self.deliver(request)
|
||||
|
||||
def send_response(self, request, response):
|
||||
"""Fan out the response to reply_key/reply_list/reply_channel."""
|
||||
request_id = response.get("request_id") or request.get("request_id") or ""
|
||||
account_id = response.get("account_id") or request.get("account_id") or self.account_id
|
||||
payload = json.dumps(response, ensure_ascii=False)
|
||||
ttl_seconds = int(request.get("ttl_seconds") or self.response_ttl_seconds)
|
||||
response_key = request.get("reply_key") or _format(
|
||||
self.response_key_template, account_id, request_id
|
||||
)
|
||||
response_channel = request.get("reply_channel") or _format(
|
||||
self.response_channel_template, account_id, request_id
|
||||
)
|
||||
response_list = request.get("reply_list")
|
||||
if response_key:
|
||||
self._write_response_key(response_key, ttl_seconds, payload)
|
||||
if response_list:
|
||||
self._push_response_list(response_list, ttl_seconds, payload)
|
||||
if response_channel:
|
||||
self._publish_response_channel(response_channel, payload)
|
||||
|
||||
def _write_response_key(self, response_key, ttl_seconds, payload):
|
||||
first_error = None
|
||||
wrote = 0
|
||||
for client in self._response_clients():
|
||||
try:
|
||||
if ttl_seconds > 0:
|
||||
client.setex(response_key, ttl_seconds, payload)
|
||||
else:
|
||||
client.set(response_key, payload)
|
||||
wrote += 1
|
||||
except Exception as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
if wrote <= 0 and first_error is not None:
|
||||
raise first_error
|
||||
return wrote
|
||||
|
||||
def _push_response_list(self, response_list, ttl_seconds, payload):
|
||||
first_error = None
|
||||
pushed = 0
|
||||
for client in self._response_clients():
|
||||
try:
|
||||
client.rpush(response_list, payload)
|
||||
if ttl_seconds > 0:
|
||||
client.expire(response_list, ttl_seconds)
|
||||
pushed += 1
|
||||
except Exception as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
if pushed <= 0 and first_error is not None:
|
||||
raise first_error
|
||||
return pushed
|
||||
|
||||
def _publish_response_channel(self, response_channel, payload):
|
||||
first_error = None
|
||||
receivers = 0
|
||||
published = 0
|
||||
for client in self._response_clients():
|
||||
try:
|
||||
receivers += int(client.publish(response_channel, payload) or 0)
|
||||
published += 1
|
||||
except Exception as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
if published <= 0 and first_error is not None:
|
||||
raise first_error
|
||||
self._published_count += 1
|
||||
if self._published_count <= self.debug_log_limit:
|
||||
print("%s published response receivers=%s" % (self.print_prefix, receivers))
|
||||
return receivers
|
||||
|
||||
# -- non-background drain helpers (used by the strategy adjust thread) -
|
||||
def drain_request_queue(self, max_items=20):
|
||||
processed = 0
|
||||
for _ in range(int(max_items)):
|
||||
try:
|
||||
item = self.listen_redis.lpop(self.request_queue)
|
||||
except Exception as exc:
|
||||
if _is_redis_timeout(exc):
|
||||
print("%s ERROR drain timeout on LPOP queue=%s; skip this tick" % (self.print_prefix, self.request_queue))
|
||||
break
|
||||
raise
|
||||
if not item:
|
||||
break
|
||||
if self.on_raw_payload is not None:
|
||||
self.on_raw_payload(item, "queue-drain")
|
||||
else:
|
||||
self.deliver(_loads(item))
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
def stop(self):
|
||||
super(RedisTransport, self).stop()
|
||||
pubsub = self._pubsub
|
||||
if pubsub is not None:
|
||||
try:
|
||||
pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(1.0)
|
||||
queue_thread = self._queue_thread
|
||||
if queue_thread is not None and queue_thread.is_alive():
|
||||
queue_thread.join(1.0)
|
||||
self._thread = None
|
||||
self._queue_thread = None
|
||||
self._pubsub = None
|
||||
|
||||
|
||||
def _call_redis_rpc(
|
||||
redis_client,
|
||||
account_id,
|
||||
request,
|
||||
timeout_seconds=3.0,
|
||||
transport="queue",
|
||||
request_channel_template=REQUEST_CHANNEL_TEMPLATE,
|
||||
request_queue_template=REQUEST_QUEUE_TEMPLATE,
|
||||
response_channel_template=RESPONSE_CHANNEL_TEMPLATE,
|
||||
response_list_template=RESPONSE_LIST_TEMPLATE,
|
||||
response_key_template=RESPONSE_KEY_TEMPLATE,
|
||||
ttl_seconds=60,
|
||||
):
|
||||
"""Client-side round trip. Accepts a pre-built request envelope."""
|
||||
request_id = request.get("request_id") or uuid.uuid4().hex
|
||||
request_channel = request_channel_template.format(account_id=account_id)
|
||||
request_queue = request_queue_template.format(account_id=account_id)
|
||||
response_channel = response_channel_template.format(
|
||||
account_id=account_id, request_id=request_id
|
||||
)
|
||||
response_list = response_list_template.format(account_id=account_id, request_id=request_id)
|
||||
response_key = response_key_template.format(account_id=account_id, request_id=request_id)
|
||||
# Ensure reply routing is present (the original helper filled these in).
|
||||
request = dict(request)
|
||||
request.setdefault("request_id", request_id)
|
||||
request.setdefault("reply_channel", response_channel)
|
||||
request.setdefault("reply_list", response_list)
|
||||
request.setdefault("reply_key", response_key)
|
||||
request.setdefault("ttl_seconds", ttl_seconds)
|
||||
request["request_id"] = request_id
|
||||
payload = encode_rpc_request_payload(request)
|
||||
|
||||
if str(transport or "queue").lower() in ("queue", "list", "blpop"):
|
||||
redis_client.rpush(request_queue, payload)
|
||||
redis_client.expire(request_queue, max(60, int(ttl_seconds)))
|
||||
wait_timeout = max(1, int(float(timeout_seconds) + 0.999))
|
||||
item = redis_client.blpop(response_list, timeout=wait_timeout)
|
||||
if item:
|
||||
raw_response = (
|
||||
item[1] if isinstance(item, (list, tuple)) and len(item) >= 2 else item
|
||||
)
|
||||
try:
|
||||
redis_client.delete(response_list)
|
||||
except Exception:
|
||||
pass
|
||||
return json.loads(decode_text(raw_response))
|
||||
raw_response = redis_client.get(response_key)
|
||||
if raw_response:
|
||||
return json.loads(decode_text(raw_response))
|
||||
raise TransportTimeout("redis rpc timeout: %s" % request.get("method"))
|
||||
|
||||
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
|
||||
try:
|
||||
pubsub.subscribe(response_channel)
|
||||
redis_client.publish(request_channel, payload)
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
message = pubsub.get_message(timeout=remaining)
|
||||
if not message or message.get("type") != "message":
|
||||
continue
|
||||
response = json.loads(decode_text(message.get("data")))
|
||||
if response.get("request_id") == request_id:
|
||||
return response
|
||||
raw_response = redis_client.get(response_key)
|
||||
if raw_response:
|
||||
return json.loads(decode_text(raw_response))
|
||||
raise TransportTimeout("redis rpc timeout: %s" % request.get("method"))
|
||||
finally:
|
||||
try:
|
||||
pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared-memory transport stub.
|
||||
|
||||
Reserved for a future low-latency same-host backend. Not implemented because
|
||||
the QMT runtime ships Python 3.6, where ``multiprocessing.shared_memory`` is
|
||||
unavailable (added in 3.8). A ``mmap``-plus-named-mutex implementation is
|
||||
possible but non-trivial; until it lands, selecting this transport raises a
|
||||
clear error so misconfiguration fails fast.
|
||||
"""
|
||||
|
||||
from .base import RpcTransport, TransportError
|
||||
|
||||
|
||||
class SharedMemoryTransport(RpcTransport):
|
||||
name = "shm"
|
||||
|
||||
def __init__(self, account_id="", print_prefix="[bigqmt_rpc]", **kwargs):
|
||||
super(SharedMemoryTransport, self).__init__(
|
||||
account_id=account_id, print_prefix=print_prefix
|
||||
)
|
||||
|
||||
def _unsupported(self):
|
||||
raise TransportError(
|
||||
"shared-memory transport is not implemented yet "
|
||||
"(requires Python 3.8+ shared_memory or a custom mmap ring buffer)"
|
||||
)
|
||||
|
||||
def send_request(self, request, timeout_seconds):
|
||||
self._unsupported()
|
||||
|
||||
def send_response(self, request, response):
|
||||
self._unsupported()
|
||||
|
||||
def start_receiving(self, on_request, **kwargs):
|
||||
self._unsupported()
|
||||
@@ -0,0 +1,459 @@
|
||||
"""ZeroMQ transport for the BigQMT RPC bridge.
|
||||
|
||||
Designed for same-host low latency. Topology:
|
||||
|
||||
* **Server** binds a ``ROUTER`` socket. Each inbound message arrives as
|
||||
``[identity, payload]``; the server remembers ``identity`` keyed by
|
||||
``request_id`` and replies with ``[identity, payload]`` so ZMQ routes the
|
||||
response back to the originating client automatically.
|
||||
* **Client** connects a ``DEALER`` socket (with a unique random identity), sends
|
||||
``[payload]``, then ``poll``/``recv`` for the response. DEALER gives each
|
||||
client an asymmetric async path that pairs naturally with ROUTER.
|
||||
|
||||
Wire framing is a single JSON payload per message. The original b64 stock-code
|
||||
obfuscation (``encode_rpc_request_payload``) is applied too, so payloads stay
|
||||
opaque even though ZMQ does not need it — keeps the wire uniform with Redis.
|
||||
|
||||
Two threads on the server: the ROUTER recv loop, and a per-client is implicit
|
||||
(ZMQ handles multiplexing). One thread on the client for recv is avoided by
|
||||
using DEALER + ``poll`` (synchronous request/response fits the RPC model).
|
||||
"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from ..adapters.redis_common import decode_text
|
||||
from ..redis_rpc import (
|
||||
decode_rpc_request_payload,
|
||||
encode_rpc_request_payload,
|
||||
)
|
||||
from .base import RpcTransport, TransportError, TransportTimeout
|
||||
|
||||
|
||||
# ZMQ does not support ipc:// on Windows (it trips a signaler abort), so the
|
||||
# default endpoint is tcp loopback. The port is derived from the account_id so
|
||||
# distinct accounts don't collide on the same port; override via config when
|
||||
# needed. Base 15560 keeps it clear of common dev ports.
|
||||
DEFAULT_ZMQ_HOST = "127.0.0.1"
|
||||
DEFAULT_ZMQ_BASE_PORT = 15560
|
||||
DEFAULT_ZMQ_PORT_RANGE = 100 # derived port = base + (account_id_int mod range)
|
||||
|
||||
|
||||
def _default_zmq_port(account_id):
|
||||
"""Derive a stable port from account_id so each account gets its own socket."""
|
||||
text = str(account_id or "")
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
try:
|
||||
offset = int(digits) % DEFAULT_ZMQ_PORT_RANGE if digits else 0
|
||||
except ValueError:
|
||||
offset = 0
|
||||
return DEFAULT_ZMQ_BASE_PORT + offset
|
||||
|
||||
|
||||
def _default_zmq_address(account_id, host=None):
|
||||
host = host or DEFAULT_ZMQ_HOST
|
||||
return "tcp://%s:%d" % (host, _default_zmq_port(account_id))
|
||||
|
||||
|
||||
def _loads(raw):
|
||||
if isinstance(raw, dict):
|
||||
return dict(raw)
|
||||
text = decode_text(raw)
|
||||
text = decode_rpc_request_payload(text)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
class ZmqTransport(RpcTransport):
|
||||
"""ZMQ ROUTER/DEALER transport.
|
||||
|
||||
The same instance plays both roles depending on method called:
|
||||
``send_request`` acts as a client (DEALER connect), ``start_receiving`` +
|
||||
``send_response`` act as a server (ROUTER bind). A deployment normally uses
|
||||
one instance per role (the QMT process is the server; the external client
|
||||
is the client).
|
||||
"""
|
||||
|
||||
name = "zmq"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bind_address=None,
|
||||
connect_address=None,
|
||||
host=None,
|
||||
port=None,
|
||||
account_id="",
|
||||
print_prefix="[bigqmt_rpc]",
|
||||
io_threads=1,
|
||||
recv_timeout_seconds=1.0,
|
||||
server_hwm=10000,
|
||||
client_linger_ms=0,
|
||||
discovery_redis_client=None,
|
||||
discovery_key_template="bigqmt:zmq:addr:{account_id}",
|
||||
discovery_ttl_seconds=300,
|
||||
port_scan_range=50,
|
||||
):
|
||||
super(ZmqTransport, self).__init__(account_id=account_id, print_prefix=print_prefix)
|
||||
# Address resolution order: explicit bind_address/connect_address win;
|
||||
# otherwise build tcp://host:port from host/port (port defaults to a
|
||||
# value derived from account_id so distinct accounts don't collide).
|
||||
resolved_host = host or DEFAULT_ZMQ_HOST
|
||||
if port is not None:
|
||||
resolved_port = int(port)
|
||||
else:
|
||||
resolved_port = _default_zmq_port(account_id)
|
||||
default_addr = "tcp://%s:%d" % (resolved_host, resolved_port)
|
||||
self.bind_address = bind_address or default_addr
|
||||
self.connect_address = connect_address
|
||||
self.bind_host = resolved_host
|
||||
self.base_port = resolved_port
|
||||
self.io_threads = int(io_threads)
|
||||
self.recv_timeout_seconds = float(recv_timeout_seconds)
|
||||
self.server_hwm = int(server_hwm)
|
||||
self.client_linger_ms = int(client_linger_ms)
|
||||
# Discovery remains available for clients, but a server must bind the
|
||||
# configured address exactly. ``port_scan_range`` is retained only for
|
||||
# backward-compatible config loading and is intentionally not used.
|
||||
self.discovery_redis_client = discovery_redis_client
|
||||
self.discovery_key_template = discovery_key_template
|
||||
self.discovery_ttl_seconds = int(discovery_ttl_seconds)
|
||||
self.port_scan_range = int(port_scan_range)
|
||||
|
||||
self._zmq = None # imported lazily
|
||||
self._ctx = None
|
||||
# server state
|
||||
self._router = None
|
||||
self._router_thread = None
|
||||
self._actual_bind_address = None # set after start_receiving()
|
||||
self._pending_identities = {} # request_id -> client identity bytes
|
||||
self._identity_lock = threading.Lock()
|
||||
self._response_queue = queue.Queue()
|
||||
self._queued_response_count = 0
|
||||
self._sent_response_count = 0
|
||||
# client state
|
||||
self._dealer = None
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
# -- construction helper ----------------------------------------------
|
||||
@classmethod
|
||||
def from_config(cls, config, account_id="", print_prefix="[bigqmt_rpc]"):
|
||||
config = dict(config or {})
|
||||
return cls(
|
||||
bind_address=config.get("bind_address"),
|
||||
connect_address=config.get("connect_address"),
|
||||
host=config.get("host"),
|
||||
port=config.get("port"),
|
||||
account_id=config.get("account_id", account_id),
|
||||
print_prefix=print_prefix,
|
||||
io_threads=int(config.get("io_threads", 1)),
|
||||
recv_timeout_seconds=float(config.get("recv_timeout_seconds", 1.0)),
|
||||
server_hwm=int(config.get("server_hwm", 10000)),
|
||||
client_linger_ms=int(config.get("client_linger_ms", 0)),
|
||||
discovery_redis_client=config.get("discovery_redis_client"),
|
||||
discovery_key_template=config.get(
|
||||
"discovery_key_template", "bigqmt:zmq:addr:{account_id}"
|
||||
),
|
||||
discovery_ttl_seconds=int(config.get("discovery_ttl_seconds", 300)),
|
||||
port_scan_range=int(config.get("port_scan_range", 50)),
|
||||
)
|
||||
|
||||
# -- shared zmq context -----------------------------------------------
|
||||
def _ensure_zmq(self):
|
||||
if self._zmq is None:
|
||||
try:
|
||||
import zmq # noqa: F401
|
||||
except ImportError as exc: # pragma: no cover - depends on env
|
||||
raise TransportError(
|
||||
"pyzmq is required for the zmq transport: %s" % exc
|
||||
)
|
||||
self._zmq = zmq
|
||||
if self._ctx is None:
|
||||
self._ctx = self._zmq.Context.instance(self.io_threads)
|
||||
return self._zmq, self._ctx
|
||||
|
||||
# -- server side ------------------------------------------------------
|
||||
def _bind_configured_address(self):
|
||||
"""Bind exactly one configured address and reject duplicate servers."""
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
sock = ctx.socket(zmq.ROUTER)
|
||||
sock.setsockopt(zmq.RCVHWM, self.server_hwm)
|
||||
sock.setsockopt(zmq.SNDHWM, self.server_hwm)
|
||||
sock.setsockopt(zmq.RCVTIMEO, int(self.recv_timeout_seconds * 1000))
|
||||
try:
|
||||
sock.bind(self.bind_address)
|
||||
except self._zmq.ZMQError as exc:
|
||||
try:
|
||||
sock.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(exc, "errno", None) == zmq.EADDRINUSE:
|
||||
# 端口被占——通常是之前策略实例没正常停止。给出友好提示和解决步骤。
|
||||
print(
|
||||
"%s ZMQ_BIND_CONFLICT: 端口 %s 被占用!"
|
||||
% (self.print_prefix, self.bind_address)
|
||||
)
|
||||
print(
|
||||
"%s 原因:之前的 QMT 策略实例没正常停止,仍占着这个端口。"
|
||||
% self.print_prefix
|
||||
)
|
||||
print(
|
||||
"%s 解决:1) 在 QMT 里停止旧策略再运行;2) 或等 60s 让系统释放端口;"
|
||||
% self.print_prefix
|
||||
)
|
||||
print(
|
||||
"%s 3) 或改配置用别的端口(BIGQMT_REDIS_CONFIG.zmq.port)"
|
||||
% self.print_prefix
|
||||
)
|
||||
raise TransportError(
|
||||
"ZMQ_BIND_CONFLICT address=%s; another bridge instance "
|
||||
"already owns the configured endpoint" % self.bind_address
|
||||
)
|
||||
raise
|
||||
self._router = sock
|
||||
self._actual_bind_address = self.bind_address
|
||||
self._publish_discovery(self.bind_address)
|
||||
|
||||
def _publish_discovery(self, address):
|
||||
if self.discovery_redis_client is None:
|
||||
return
|
||||
key = self.discovery_key_template.format(account_id=self.account_id)
|
||||
try:
|
||||
self.discovery_redis_client.setex(
|
||||
key, self.discovery_ttl_seconds, address
|
||||
)
|
||||
except Exception as exc:
|
||||
print("%s zmq discovery publish failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
def _clear_discovery(self):
|
||||
if self.discovery_redis_client is None:
|
||||
return
|
||||
key = self.discovery_key_template.format(account_id=self.account_id)
|
||||
try:
|
||||
self.discovery_redis_client.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start_receiving(self, on_request, background_threads=True):
|
||||
super(ZmqTransport, self).start_receiving(on_request)
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
self._bind_configured_address()
|
||||
bound = self._actual_bind_address or self.bind_address
|
||||
if not background_threads:
|
||||
print(
|
||||
"%s zmq bound=%s background_threads=False"
|
||||
% (self.print_prefix, bound)
|
||||
)
|
||||
return
|
||||
self._router_thread = threading.Thread(
|
||||
target=self._router_loop, name="bigqmt-zmq-rpc", daemon=True
|
||||
)
|
||||
self._router_thread.start()
|
||||
print(
|
||||
"%s zmq started bound=%s" % (self.print_prefix, self.bind_address)
|
||||
)
|
||||
|
||||
def _router_loop(self):
|
||||
try:
|
||||
while self._running:
|
||||
self._drain_response_queue()
|
||||
request = self._receive_request()
|
||||
if request is not None:
|
||||
self._deliver_request(request)
|
||||
finally:
|
||||
# Close the ROUTER socket on the thread that owns it. On Windows,
|
||||
# closing a ZMQ socket from a different thread trips a signaler
|
||||
# assertion (abort); closing it here is safe because this thread
|
||||
# created and exclusively used it.
|
||||
try:
|
||||
self._router.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
self._router = None
|
||||
|
||||
def _receive_request(self, flags=0):
|
||||
try:
|
||||
frames = self._router.recv_multipart(flags=flags)
|
||||
except self._zmq.Again:
|
||||
return None
|
||||
except Exception as exc:
|
||||
if self._running:
|
||||
print("%s zmq recv failed: %s" % (self.print_prefix, exc))
|
||||
if not flags:
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
if len(frames) < 2:
|
||||
return None
|
||||
identity, payload = frames[0], frames[-1]
|
||||
try:
|
||||
request = _loads(payload)
|
||||
except Exception as exc:
|
||||
print("%s zmq decode failed: %s" % (self.print_prefix, exc))
|
||||
return None
|
||||
request_id = str(request.get("request_id") or uuid.uuid4().hex)
|
||||
with self._identity_lock:
|
||||
self._pending_identities[request_id] = identity
|
||||
return request
|
||||
|
||||
def _deliver_request(self, request):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
self.deliver(request)
|
||||
except Exception as exc:
|
||||
print("%s zmq deliver failed: %s" % (self.print_prefix, exc))
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
||||
if elapsed_ms > 50.0:
|
||||
print("%s zmq slow handler method=%s %.0fms"
|
||||
% (self.print_prefix, request.get("method"), elapsed_ms))
|
||||
|
||||
def _drain_response_queue(self):
|
||||
while True:
|
||||
try:
|
||||
identity, payload = self._response_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
try:
|
||||
self._router.send_multipart([identity, payload])
|
||||
self._sent_response_count += 1
|
||||
if self._sent_response_count <= 5:
|
||||
print("%s zmq queued response sent" % self.print_prefix)
|
||||
except Exception as exc:
|
||||
print("%s zmq send failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
def send_response(self, request, response):
|
||||
if self._router is None:
|
||||
raise TransportError("zmq server socket is not bound")
|
||||
request_id = str(
|
||||
response.get("request_id") or request.get("request_id") or ""
|
||||
)
|
||||
with self._identity_lock:
|
||||
identity = self._pending_identities.pop(request_id, None)
|
||||
if identity is None:
|
||||
# No matching peer — drop silently (client may have gone away).
|
||||
return
|
||||
payload = encode_rpc_request_payload(response).encode("utf-8")
|
||||
if self._router_thread is not None and threading.current_thread() is not self._router_thread:
|
||||
self._queued_response_count += 1
|
||||
if self._queued_response_count <= 5:
|
||||
print("%s zmq response queued for router thread" % self.print_prefix)
|
||||
self._response_queue.put((identity, payload))
|
||||
return
|
||||
try:
|
||||
self._router.send_multipart([identity, payload])
|
||||
except Exception as exc:
|
||||
print("%s zmq send failed: %s" % (self.print_prefix, exc))
|
||||
|
||||
def drain_request_queue(self, max_items=20):
|
||||
"""Drain requests from the scheduled QMT thread when no receiver thread exists."""
|
||||
if self._router_thread is not None or self._router is None:
|
||||
return 0
|
||||
processed = 0
|
||||
for _index in range(max(int(max_items), 0)):
|
||||
request = self._receive_request(flags=self._zmq.NOBLOCK)
|
||||
if request is None:
|
||||
break
|
||||
self._deliver_request(request)
|
||||
processed += 1
|
||||
return processed
|
||||
|
||||
# -- client side ------------------------------------------------------
|
||||
def _resolve_connect_address(self):
|
||||
"""Resolve the address to connect to.
|
||||
|
||||
Order: explicit connect_address > discovery lookup > default derived.
|
||||
Discovery lets the client find a server that had to move off the
|
||||
default port because of a collision.
|
||||
"""
|
||||
if self.connect_address:
|
||||
return self.connect_address
|
||||
discovered = self._lookup_discovery()
|
||||
if discovered:
|
||||
return discovered
|
||||
return _default_zmq_address(self.account_id)
|
||||
|
||||
def _lookup_discovery(self):
|
||||
if self.discovery_redis_client is None:
|
||||
return None
|
||||
key = self.discovery_key_template.format(account_id=self.account_id)
|
||||
try:
|
||||
raw = self.discovery_redis_client.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
|
||||
except Exception:
|
||||
return None
|
||||
return text or None
|
||||
|
||||
def _ensure_dealer(self):
|
||||
zmq, ctx = self._ensure_zmq()
|
||||
if self._dealer is None:
|
||||
address = self._resolve_connect_address()
|
||||
sock = ctx.socket(zmq.DEALER)
|
||||
# Unique identity so ROUTER can route replies back to us.
|
||||
sock.setsockopt(zmq.IDENTITY, uuid.uuid4().hex.encode("utf-8")[:16])
|
||||
sock.setsockopt(zmq.LINGER, self.client_linger_ms)
|
||||
sock.connect(address)
|
||||
self._dealer = sock
|
||||
self.connect_address = address
|
||||
return self._dealer
|
||||
|
||||
def send_request(self, request, timeout_seconds, **_kwargs):
|
||||
zmq = self._zmq or self._ensure_zmq()[0]
|
||||
with self._client_lock:
|
||||
dealer = self._ensure_dealer()
|
||||
request = dict(request)
|
||||
request.setdefault("request_id", uuid.uuid4().hex)
|
||||
request_id = request["request_id"]
|
||||
payload = encode_rpc_request_payload(request)
|
||||
try:
|
||||
dealer.send(payload.encode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise TransportError("zmq send failed: %s" % exc)
|
||||
deadline = time.time() + float(timeout_seconds)
|
||||
poller = self._zmq.Poller()
|
||||
poller.register(dealer, self._zmq.POLLIN)
|
||||
while True:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
events = dict(poller.poll(timeout=int(remaining * 1000)))
|
||||
if dealer in events:
|
||||
frames = dealer.recv_multipart()
|
||||
raw = frames[-1]
|
||||
response = _loads(raw)
|
||||
if response.get("request_id") == request_id:
|
||||
return response
|
||||
raise TransportTimeout("zmq rpc timeout: %s" % request.get("method"))
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
def stop(self):
|
||||
super(ZmqTransport, self).stop()
|
||||
# Clear _running so the router loop exits; the loop closes its own
|
||||
# socket (closing cross-thread trips a Windows signaler abort).
|
||||
thread = self._router_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(2.0)
|
||||
if thread is None and self._router is not None:
|
||||
try:
|
||||
self._router.close(linger=0)
|
||||
except Exception:
|
||||
pass
|
||||
self._router = None
|
||||
self._router_thread = None
|
||||
# If we were a server that published a discovery address, clear it so
|
||||
# clients don't keep hitting a dead endpoint.
|
||||
if self._actual_bind_address is not None:
|
||||
self._clear_discovery()
|
||||
self._actual_bind_address = None
|
||||
with self._client_lock:
|
||||
if self._dealer is not None:
|
||||
try:
|
||||
self._dealer.close(linger=self.client_linger_ms)
|
||||
except Exception:
|
||||
pass
|
||||
self._dealer = None
|
||||
# Do NOT terminate the shared context — other sockets/users may rely on it.
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Client-side whole-quote subscription session.
|
||||
|
||||
Owns the per-process state for ``subscribe_whole_quote``: the local
|
||||
subscription table, the shared push-channel subscriber thread, and the
|
||||
keepalive heartbeat thread. One session is shared by every ``subscribe_whole_quote``
|
||||
call in the process (``BigQmtXtData`` delegates here), so all subscriptions ride
|
||||
a single push-channel connection and a single heartbeat loop.
|
||||
|
||||
The big-QMT whole-quote callback is INCREMENTAL (only changed symbols), so a
|
||||
subscription does not by itself deliver an initial full snapshot — callers layer
|
||||
a ``get_full_tick`` prime on top (done in ``BigQmtXtData.subscribe_whole_quote``).
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
|
||||
def _norm_topic(code_list):
|
||||
return ",".join(sorted({str(c).strip().upper() for c in (code_list or []) if str(c or "").strip()}))
|
||||
|
||||
|
||||
class WholeQuoteClientSession(object):
|
||||
def __init__(self, rpc_call, push_channel, client_id, heartbeat_interval_seconds=3.0, sub_id_func=None,
|
||||
push_silence_replay_heartbeats=10):
|
||||
"""``rpc_call`` is ``client.call``-shaped: fn(method, params) -> dict.
|
||||
``push_channel`` is a QuotePushChannel used purely as a subscriber.
|
||||
``sub_id_func`` (optional) mints subscription ids; defaults to a counter.
|
||||
``push_silence_replay_heartbeats``: after this many heartbeat rounds
|
||||
without any push, replay subscriptions (covers server restarts where
|
||||
keepalive keeps succeeding because the redis request queue buffers
|
||||
during the restart window but the subscription table was reset)."""
|
||||
self._rpc = rpc_call
|
||||
self._channel = push_channel
|
||||
self.client_id = str(client_id or "")
|
||||
self._heartbeat_interval = float(heartbeat_interval_seconds)
|
||||
self._push_silence_replay_heartbeats = int(push_silence_replay_heartbeats)
|
||||
self._sub_id_func = sub_id_func
|
||||
self._seq = 0
|
||||
self._lock = threading.RLock()
|
||||
self._subscriptions = {} # sub_id -> {"topic": str, "callback": fn, "codes": [...]}
|
||||
self._started = False
|
||||
self._subscriber_active = False
|
||||
self._subscribed_topics = frozenset() # topic set the subscriber covers now
|
||||
self._heartbeat_thread = None
|
||||
self._last_push_time = None # monotonic time of last incoming push
|
||||
|
||||
# -- subscription lifecycle ---------------------------------------------
|
||||
def subscribe_whole_quote(self, code_list, callback=None):
|
||||
codes = [str(c) for c in (code_list or []) if str(c or "").strip()]
|
||||
if not codes:
|
||||
raise ValueError("code_list is required")
|
||||
with self._lock:
|
||||
sub_id = self._next_sub_id()
|
||||
result = self._rpc(
|
||||
"subscribe_whole_quote",
|
||||
{"client_id": self.client_id, "sub_id": sub_id, "codes": codes},
|
||||
) or {}
|
||||
topic = str(result.get("topic") or result.get("combo_key") or _norm_topic(codes))
|
||||
with self._lock:
|
||||
self._subscriptions[sub_id] = {"topic": topic, "callback": callback, "codes": codes}
|
||||
self._sync_subscriber_locked()
|
||||
return sub_id
|
||||
|
||||
def unsubscribe_quote(self, sub_id):
|
||||
with self._lock:
|
||||
entry = self._subscriptions.pop(sub_id, None)
|
||||
if entry is None:
|
||||
return 0
|
||||
try:
|
||||
self._rpc("unsubscribe_whole_quote", {"client_id": self.client_id, "sub_id": sub_id})
|
||||
finally:
|
||||
with self._lock:
|
||||
self._sync_subscriber_locked()
|
||||
return 0
|
||||
|
||||
def has_subscription(self, sub_id):
|
||||
with self._lock:
|
||||
return sub_id in self._subscriptions
|
||||
|
||||
def replay_subscriptions(self):
|
||||
"""Re-send subscribe for every active sub_id (server restart recovery).
|
||||
Idempotent on the server (keyed by client_id+combo), so replays are safe."""
|
||||
with self._lock:
|
||||
items = [(sid, dict(entry)) for sid, entry in self._subscriptions.items()]
|
||||
for sub_id, entry in items:
|
||||
self._rpc(
|
||||
"subscribe_whole_quote",
|
||||
{"client_id": self.client_id, "sub_id": sub_id, "codes": entry["codes"]},
|
||||
)
|
||||
|
||||
# -- heartbeat -------------------------------------------------------------
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self._heartbeat_thread = threading.Thread(
|
||||
target=self._heartbeat_loop, name="bigqmt-quote-keepalive", daemon=True
|
||||
)
|
||||
self._heartbeat_thread.start()
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
self._started = False
|
||||
thread = self._heartbeat_thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=1.0)
|
||||
self._heartbeat_thread = None
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
import time
|
||||
|
||||
consecutive_failures = 0
|
||||
silence_rounds = 0
|
||||
prev_last_push = None
|
||||
while True:
|
||||
with self._lock:
|
||||
if not self._started:
|
||||
return
|
||||
sub_ids = list(self._subscriptions.keys())
|
||||
last_push = self._last_push_time
|
||||
if not sub_ids:
|
||||
time.sleep(self._heartbeat_interval)
|
||||
continue
|
||||
failures = 0
|
||||
for sub_id in sub_ids:
|
||||
try:
|
||||
self._rpc("quote_keepalive", {"client_id": self.client_id, "sub_id": sub_id})
|
||||
except Exception:
|
||||
failures += 1
|
||||
if failures:
|
||||
consecutive_failures += 1
|
||||
elif consecutive_failures >= 3:
|
||||
# Server is back after a restart window: replay subscriptions so
|
||||
# the restarted server re-creates the big-QMT subscriptions (its
|
||||
# state is gone). Idempotent on the server, so replays are safe.
|
||||
self.replay_subscriptions()
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
# Push-silence detection: a server restart can survive with keepalive
|
||||
# succeeding (the redis request queue buffers during the restart
|
||||
# window) while the subscription table was reset, so pushes stop.
|
||||
# Replay when no push arrived for several heartbeat rounds (also
|
||||
# covers the case where the very first prime push never arrived).
|
||||
if last_push != prev_last_push:
|
||||
silence_rounds = 0 # a push arrived since the last round
|
||||
else:
|
||||
silence_rounds += 1
|
||||
prev_last_push = last_push
|
||||
if silence_rounds >= self._push_silence_replay_heartbeats:
|
||||
self.replay_subscriptions()
|
||||
silence_rounds = 0
|
||||
time.sleep(self._heartbeat_interval)
|
||||
|
||||
# -- push routing ------------------------------------------------------------
|
||||
def _on_push(self, topic, data):
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._last_push_time = now
|
||||
callbacks = [
|
||||
entry["callback"]
|
||||
for entry in self._subscriptions.values()
|
||||
if entry["topic"] == topic and entry["callback"] is not None
|
||||
]
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_subscriber_locked(self):
|
||||
"""(Re)start the push-channel subscriber to cover exactly the active
|
||||
topics. Reuses an existing subscriber when the topic set is unchanged;
|
||||
stops it before restarting when the set changed. No-op when nothing is
|
||||
subscribed (and stops the running subscriber in that case)."""
|
||||
topics = sorted({entry["topic"] for entry in self._subscriptions.values()})
|
||||
active = frozenset(topics)
|
||||
if active == self._subscribed_topics:
|
||||
return
|
||||
if not active:
|
||||
if self._subscriber_active:
|
||||
try:
|
||||
self._channel.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._subscriber_active = False
|
||||
self._subscribed_topics = active
|
||||
return
|
||||
if self._subscriber_active:
|
||||
try:
|
||||
self._channel.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._channel.start_subscriber(topics, self._on_push)
|
||||
self._subscriber_active = True
|
||||
self._subscribed_topics = active
|
||||
|
||||
def _next_sub_id(self):
|
||||
if self._sub_id_func is not None:
|
||||
return self._sub_id_func()
|
||||
self._seq += 1
|
||||
return self._seq
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
# coding: utf-8
|
||||
"""Client-side private config example for MiniQMT-compatible replacement.
|
||||
|
||||
Copy this file to:
|
||||
|
||||
src/bigqmt_signal_trader_client_config.py
|
||||
|
||||
Do not commit the real file. It may contain account ids and Redis credentials.
|
||||
"""
|
||||
|
||||
BIGQMT_ACCOUNT_ID = "YOUR_ACCOUNT_ID"
|
||||
BIGQMT_RPC_TIMEOUT_SECONDS = 6.0
|
||||
BIGQMT_DOWNLOAD_WAIT_SECONDS = 1800
|
||||
BIGQMT_DOWNLOAD_POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "",
|
||||
# Transport selection. Must match the QMT-side server config. Default
|
||||
# "redis" works with the standard DRYRUN; use "zmq" when the server runs
|
||||
# with transport=zmq (e.g. the no-redis version or explicit zmq mode).
|
||||
"transport": "redis",
|
||||
# ZMQ-specific settings (only used when transport=zmq):
|
||||
# "zmq": {
|
||||
# # Explicit connect address. The QMT-side server binds a port derived
|
||||
# # from account_id (default 15563 for account 8886800503). If you know
|
||||
# # the exact address, set it here to skip service discovery.
|
||||
# "connect_address": "tcp://127.0.0.1:15563",
|
||||
# # "host": "127.0.0.1",
|
||||
# # "port": 15563,
|
||||
# },
|
||||
}
|
||||
|
||||
# Default direct mode calls get_full_tick through RPC. Set enabled=True only when
|
||||
# you want client-side get_full_tick to read demand-driven Redis snapshots.
|
||||
BIGQMT_FULL_TICK_CACHE_CONFIG = {
|
||||
"enabled": False,
|
||||
"demand_ttl_seconds": 10,
|
||||
"cache_ttl_seconds": 10,
|
||||
"wait_seconds": 3.5,
|
||||
"poll_interval_seconds": 0.2,
|
||||
}
|
||||
|
||||
# Client-side LOCAL market-data cache.
|
||||
# get_market_data_ex(...) writes returned bars under `dir`; get_local_data(...)
|
||||
# then reads them locally with NO RPC to Big QMT (for offline / repeated local
|
||||
# analysis). download_history_data* submits a server-side Big QMT download job.
|
||||
# - dir: cache folder (default ~/.bigqmt_cache), one pickle per (period, code).
|
||||
# - fallback_rpc: if True, get_local_data auto-fetches+caches a cache miss;
|
||||
# if False (default), a cache-missed code is simply omitted (download first).
|
||||
BIGQMT_LOCAL_CACHE_CONFIG = {
|
||||
"enabled": True,
|
||||
"dir": None, # None -> ~/.bigqmt_cache
|
||||
"fallback_rpc": False,
|
||||
# Storage format: "auto" (parquet if pyarrow installed, else pickle),
|
||||
# "parquet" (columnar/compressed/cross-language — recommended), or "pkl".
|
||||
# One file per (period, dividend_type, code); switching format auto-migrates.
|
||||
"format": "auto",
|
||||
}
|
||||
|
||||
# FormulaServer direct read fast-path (port 58600).
|
||||
# Big QMT's built-in C++ quote/reference service. Routing reads straight to it
|
||||
# bypasses the RPC bridge AND the QMT python thread's GIL: ~0.07ms vs ~13ms
|
||||
# over redis. Enabled by default; you normally do not need this block.
|
||||
#
|
||||
# Covers reference/history reads only. Account, position, order, trade and
|
||||
# 五档 (get_full_tick) calls are NOT served by FormulaServer and always go over
|
||||
# RPC. Every miss — unmapped method, untranslatable params, server down —
|
||||
# falls back to RPC automatically, so an unreachable 58600 changes nothing.
|
||||
BIGQMT_FORMULA_SERVER_CONFIG = {
|
||||
"enabled": True, # or set BIGQMT_FORMULA_ENABLED=0 in the environment
|
||||
# "host": "127.0.0.1", # FormulaServer binds 0.0.0.0, so cross-machine works
|
||||
# # if the firewall allows it
|
||||
# "port": 58600, # unset -> read from qmt_root's formulaserver.ini,
|
||||
# # then fall back to 58600
|
||||
# "qmt_root": r"D:\国金证券QMT交易端",
|
||||
# "timeout_seconds": 3.0,
|
||||
# "methods": [...], # restrict routing to a subset (default: all mapped)
|
||||
# "failure_cooldown_seconds": 30.0, # pause routing this long after a failure
|
||||
}
|
||||
|
||||
# Whole-quote PUSH subscription (xtdata.subscribe_whole_quote, aligned with MiniQMT).
|
||||
# Server pushes each incremental tick batch to every client subscribed to the
|
||||
# same combination; the RPC methods above only manage the subscription lifecycle.
|
||||
# Data flows over a separate push channel matching `transport` above
|
||||
# (redis pub/sub, or zmq PUB/SUB when transport="zmq"), msgpack-encoded
|
||||
# (install the `msgpack` extra; falls back to json if absent).
|
||||
#
|
||||
# quote_client_id: process-stable subscriber id. The server counts references
|
||||
# per (client_id, sub_id) and only tears down the shared big-QMT subscription
|
||||
# after EVERY client of a combination unsubscribes or times out. Unset -> a
|
||||
# persisted id is created at ~/.cache/bigqmt/quote_client_id so a restarted
|
||||
# client is recognised as the same subscriber (needed for replay recovery).
|
||||
# Heartbeat: client sends quote_keepalive every BIGQMT_QUOTE_HEARTBEAT_SECONDS
|
||||
# (default 3.0). Server reaps a client after heartbeat_timeout_seconds
|
||||
# (default 30s = 10 periods, configured server-side).
|
||||
BIGQMT_QUOTE_CLIENT_ID = None # e.g. "my-strategy-1"; None -> persisted auto id
|
||||
# BIGQMT_QUOTE_HEARTBEAT_SECONDS = 3.0 # env var; must be < server timeout/periods
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# coding: utf-8
|
||||
"""Big QMT diagnostics for market data and positions.
|
||||
|
||||
This strategy entry never submits orders. It only probes QMT runtime APIs.
|
||||
"""
|
||||
|
||||
|
||||
_ACCOUNT_ID = ""
|
||||
_PROBED = False
|
||||
|
||||
|
||||
def _resolve_runtime_name(name):
|
||||
if name in globals():
|
||||
return globals()[name]
|
||||
try:
|
||||
import builtins
|
||||
return getattr(builtins, name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_attr(obj, name, default=None):
|
||||
return getattr(obj, name, default)
|
||||
|
||||
|
||||
def _detect_account():
|
||||
account_value = _resolve_runtime_name("account")
|
||||
return str(account_value or "")
|
||||
|
||||
|
||||
def _probe_market(ContextInfo):
|
||||
code = "000300.SH"
|
||||
try:
|
||||
ticks = ContextInfo.get_full_tick([code])
|
||||
tick = (ticks or {}).get(code)
|
||||
if not tick:
|
||||
print("[bigqmt_diagnostic] market tick missing code=%s raw=%s" % (code, ticks))
|
||||
else:
|
||||
print(
|
||||
"[bigqmt_diagnostic] market tick ok code=%s lastPrice=%s bid1=%s ask1=%s"
|
||||
% (
|
||||
code,
|
||||
tick.get("lastPrice"),
|
||||
(tick.get("bidPrice") or [None])[0],
|
||||
(tick.get("askPrice") or [None])[0],
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
print("[bigqmt_diagnostic] market tick failed: %s" % exc)
|
||||
|
||||
try:
|
||||
detail = ContextInfo.get_instrumentdetail(code)
|
||||
if not detail:
|
||||
print("[bigqmt_diagnostic] instrument missing code=%s" % code)
|
||||
else:
|
||||
print(
|
||||
"[bigqmt_diagnostic] instrument ok code=%s status=%s up=%s down=%s"
|
||||
% (
|
||||
code,
|
||||
detail.get("InstrumentStatus"),
|
||||
detail.get("UpStopPrice"),
|
||||
detail.get("DownStopPrice"),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
print("[bigqmt_diagnostic] instrument failed: %s" % exc)
|
||||
|
||||
|
||||
def _probe_positions(account_id):
|
||||
query = _resolve_runtime_name("get_trade_detail_data")
|
||||
if query is None:
|
||||
print("[bigqmt_diagnostic] position failed: get_trade_detail_data missing")
|
||||
return
|
||||
if not account_id:
|
||||
print("[bigqmt_diagnostic] position skipped: account is empty")
|
||||
return
|
||||
|
||||
try:
|
||||
positions = query(account_id, "STOCK", "POSITION") or []
|
||||
print("[bigqmt_diagnostic] position ok account=%s count=%s" % (account_id, len(positions)))
|
||||
for pos in positions[:8]:
|
||||
print(
|
||||
"[bigqmt_diagnostic] position item code=%s.%s name=%s volume=%s available=%s"
|
||||
% (
|
||||
_safe_attr(pos, "m_strInstrumentID", ""),
|
||||
_safe_attr(pos, "m_strExchangeID", ""),
|
||||
_safe_attr(pos, "m_strInstrumentName", ""),
|
||||
_safe_attr(pos, "m_nVolume", ""),
|
||||
_safe_attr(pos, "m_nCanUseVolume", ""),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
print("[bigqmt_diagnostic] position failed account=%s error=%s" % (account_id, exc))
|
||||
|
||||
|
||||
def _probe(ContextInfo, reason):
|
||||
global _PROBED
|
||||
if _PROBED:
|
||||
return
|
||||
_PROBED = True
|
||||
print("[bigqmt_diagnostic] probe start reason=%s account=%s" % (reason, _ACCOUNT_ID))
|
||||
_probe_market(ContextInfo)
|
||||
_probe_positions(_ACCOUNT_ID)
|
||||
print("[bigqmt_diagnostic] probe end")
|
||||
|
||||
|
||||
def init(ContextInfo):
|
||||
global _ACCOUNT_ID
|
||||
_ACCOUNT_ID = _detect_account()
|
||||
if _ACCOUNT_ID and hasattr(ContextInfo, "set_account"):
|
||||
ContextInfo.set_account(_ACCOUNT_ID)
|
||||
print("[bigqmt_diagnostic] init ok account=%s" % _ACCOUNT_ID)
|
||||
_probe(ContextInfo, "init")
|
||||
|
||||
|
||||
def handlebar(ContextInfo):
|
||||
if hasattr(ContextInfo, "is_last_bar") and not ContextInfo.is_last_bar():
|
||||
return None
|
||||
return _probe(ContextInfo, "handlebar")
|
||||
|
||||
|
||||
def adjust(ContextInfo):
|
||||
return handlebar(ContextInfo)
|
||||
|
||||
|
||||
def order_callback(ContextInfo, orderInfo):
|
||||
return None
|
||||
|
||||
|
||||
def deal_callback(ContextInfo, dealInfo):
|
||||
return None
|
||||
@@ -0,0 +1,28 @@
|
||||
# coding: utf-8
|
||||
"""Big QMT signal trader dry-run entry.
|
||||
|
||||
Put this file into QMT's python strategy directory and load it from QMT.
|
||||
Current default uses empty signal source and DryRunOrderGateway, so it will not
|
||||
submit real orders.
|
||||
"""
|
||||
|
||||
from bigqmt_signal_trader_strategy import ( # noqa: E402
|
||||
adjust,
|
||||
configure,
|
||||
deal_callback,
|
||||
handlebar,
|
||||
init,
|
||||
order_callback,
|
||||
set_account_id,
|
||||
sync_positions,
|
||||
)
|
||||
|
||||
|
||||
# Fill this before real account testing. Leave empty for dry-run loading tests.
|
||||
ACCOUNT_ID = ""
|
||||
|
||||
|
||||
if ACCOUNT_ID:
|
||||
set_account_id(ACCOUNT_ID)
|
||||
|
||||
configure(mode="dryrun", account_id=ACCOUNT_ID or "dryrun")
|
||||
@@ -0,0 +1,64 @@
|
||||
# coding: utf-8
|
||||
"""Local private config example for the QMT python directory.
|
||||
|
||||
Copy this file to the QMT python directory as:
|
||||
|
||||
bigqmt_signal_trader_local_config.py
|
||||
|
||||
Do not commit the real file. It may contain account ids and Redis credentials.
|
||||
"""
|
||||
|
||||
BIGQMT_ACCOUNT_ID = "YOUR_ACCOUNT_ID"
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "",
|
||||
# Keep order RPC disabled unless you explicitly want remote order/cancel.
|
||||
"rpc_allow_order_methods": False,
|
||||
# Redis and ZMQ can both drain requests through QMT's official
|
||||
# run_time("adjust", ...) callback. This avoids GIL stalls in QMT's process.
|
||||
"rpc_process_in_listener": True,
|
||||
"rpc_listener_methods": ("*",),
|
||||
"rpc_background_threads": False,
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "100nMilliSecond",
|
||||
# The default mode calls get_full_tick through RPC. Enable this cache only
|
||||
# if full-market payloads are too large for your latency/CPU budget.
|
||||
# When a client calls get_full_tick, it renews demand for 10 seconds.
|
||||
# Symbol-list demands refresh every full_tick_refresh_interval_seconds; whole-market
|
||||
# (SH/SZ/BJ/HK) demands refresh on the slower market interval so a ~50k row snapshot
|
||||
# is not pulled every fast tick.
|
||||
"full_tick_cache_enabled": False,
|
||||
"full_tick_demand_ttl_seconds": 10,
|
||||
"full_tick_cache_ttl_seconds": 10,
|
||||
"full_tick_refresh_interval_seconds": 0.5,
|
||||
"full_tick_market_refresh_interval_seconds": 3,
|
||||
# Wall-clock budget for one refresh round; keeps a slow round from stalling the
|
||||
# strategy thread (the in-flight demand always completes).
|
||||
"full_tick_refresh_max_wall_seconds": 0.3,
|
||||
"full_tick_max_requests": 8,
|
||||
# Async download jobs: clients submit download_history_data(2) as a job; the
|
||||
# strategy thread downloads download_job_chunk_size symbols per tick (capped by
|
||||
# download_job_max_wall_seconds), so a long download never blocks the RPC pump.
|
||||
# chunk_size is the smallest per-tick block — keep it modest if downloads are slow.
|
||||
# Disabled: the full terminal's xtdata SDK can't reach a data service to
|
||||
# download. Supplement history via the terminal's 数据管理/补充数据 UI, then read
|
||||
# it over RPC (get_market_data_ex/get_local_data). Enable only where a
|
||||
# MiniQMT/xtdata data service is connectable.
|
||||
"download_jobs_enabled": False,
|
||||
"download_job_chunk_size": 10,
|
||||
"download_job_max_wall_seconds": 0.5,
|
||||
"download_job_ttl_seconds": 3600,
|
||||
# Push order_callback/deal_callback details to Redis so clients get real-time
|
||||
# on_stock_order / on_stock_trade callbacks (MiniQMT style) instead of polling.
|
||||
"exec_events_enabled": True,
|
||||
# Dump the raw order_callback/deal_callback object fields to the QMT output
|
||||
# panel, and attach them to the published event as "raw_fields". Prints on
|
||||
# every callback, so keep it off outside a diagnosis window. Turn it on to
|
||||
# observe what m_nDirection / m_nOffsetFlag actually carry in live callbacks
|
||||
# — the buy/sell mapping in exec_events.py currently assumes 48/49 there.
|
||||
"exec_events_debug_raw_fields": False,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# coding: utf-8
|
||||
"""Big QMT Redis dry-run strategy entry.
|
||||
|
||||
This entry reads Redis db5 test signals and writes Redis state, but orders are
|
||||
DryRunOrderGateway orders only. It does not submit real QMT orders.
|
||||
"""
|
||||
|
||||
from bigqmt_signal_trader_strategy import ( # noqa: E402
|
||||
adjust,
|
||||
configure,
|
||||
deal_callback,
|
||||
handlebar,
|
||||
init,
|
||||
order_callback,
|
||||
set_account_id,
|
||||
sync_positions,
|
||||
)
|
||||
|
||||
|
||||
ACCOUNT_ID = "bigqmt_probe"
|
||||
REDIS_HOST = "127.0.0.1"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_DB = 5
|
||||
REDIS_USERNAME = ""
|
||||
REDIS_PASSWORD = ""
|
||||
|
||||
try:
|
||||
from bigqmt_signal_trader_local_config import BIGQMT_REDIS_CONFIG
|
||||
except Exception:
|
||||
BIGQMT_REDIS_CONFIG = {}
|
||||
|
||||
REDIS_HOST = BIGQMT_REDIS_CONFIG.get("host", REDIS_HOST)
|
||||
REDIS_PORT = int(BIGQMT_REDIS_CONFIG.get("port", REDIS_PORT))
|
||||
REDIS_DB = int(BIGQMT_REDIS_CONFIG.get("db", REDIS_DB))
|
||||
REDIS_USERNAME = BIGQMT_REDIS_CONFIG.get("username", REDIS_USERNAME)
|
||||
REDIS_PASSWORD = BIGQMT_REDIS_CONFIG.get("password", REDIS_PASSWORD)
|
||||
|
||||
|
||||
if ACCOUNT_ID:
|
||||
set_account_id(ACCOUNT_ID)
|
||||
|
||||
configure(
|
||||
mode="dryrun",
|
||||
account_id=ACCOUNT_ID,
|
||||
signal_source_type="redis",
|
||||
state_store_type="redis",
|
||||
position_sync_type="redis",
|
||||
redis={
|
||||
"host": REDIS_HOST,
|
||||
"port": REDIS_PORT,
|
||||
"db": REDIS_DB,
|
||||
"username": REDIS_USERNAME,
|
||||
"password": REDIS_PASSWORD,
|
||||
"stream_key_template": "bigqmt:signals:{account_id}",
|
||||
"group_name": "bigqmt-signal-trader",
|
||||
"consumer_name": "bigqmt-probe",
|
||||
"block_ms": 0,
|
||||
"claim_key_template": "bigqmt:signal_claim:{account_id}:{signal_id}",
|
||||
"status_key_template": "bigqmt:signal_status:{account_id}:{signal_id}",
|
||||
"position_key_template": "bigqmt:positions:{account_id}",
|
||||
"position_event_stream_template": "bigqmt:position_events:{account_id}",
|
||||
},
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user