chore: init qmt_bridge repo (HTTP+WS bridge, MCP endpoint, docs, references)

This commit is contained in:
Docker
2026-08-26 16:53:15 +08:00
commit 22a5b8ca04
210 changed files with 68176 additions and 0 deletions
@@ -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 idvalue 包含:
```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 频道(同名 streamxadd + 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→BUY49/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.7ms30% 撞 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: ...
```
@@ -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.
@@ -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内置PythonContextInfo/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 映射表:xtquantminiQMT外接) → 大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. 回调
| miniQMTXtQuantTraderCallback 方法) | 大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 留空直接 returnK线型用 `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、userOrderIdm_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 存在的意义:提前完成迁移,不赌窗口期。
@@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
"""miniQMT 策略静态分析:API 清单 / py3.6 违例 / 依赖 / 阻塞模式 / 可行性结论
用法: python analyze_strategy.py <策略.py>
输出: Markdown 报告到 stdout,同时写入 <策略>.conversion_report.mdUTF-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_timeapi_mapping.md 第6节)',
'AutoLogin': '删除 AutoLoginconstraints.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 dataclassespy3.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线回滚,'
'可变状态改存全局 Gconstraints.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('策略停止')
@@ -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 ~13msp50)。
---
## 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/submsgpack 编码、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 接收线程自己关闭 socketWindows
上跨线程 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 / sqlite3paramstyle 自动适配)
- `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 方法,走现有请求/响应 transportredis 或 zmq)。
2. **数据面推送(新增)**server→client 单向 PUB/SUB 通道,承载行情推送。
3. **大 QMT 行情源**server 端 `ContextInfo.subscribe_whole_quote`(或 `xtdata.subscribe_whole_quote`)回调。
---
## 4. 详细设计
### 4.1 订阅单元 keyQ2:组合去重)
```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 订阅适配层**Q5ContextInfo 优先)。已调研确认大 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。
**编码(Q7msgpack 优先,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 transportzmq/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 移除 clientcombo 空 → 退订。
- 绿:`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 channelclient 重连逻辑)。
- 回归:全套 + 现有 `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()` | 已兼容 | 保存 callbackRPC 暂不推送回调 |
| `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