chore: init qmt_bridge repo (HTTP+WS bridge, MCP endpoint, docs, references)
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
# 大 QMT Redis Queue RPC 说明
|
||||
|
||||
更新时间:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
在大 QMT 策略进程内启动一个 Redis RPC 服务,用来远程调用少量白名单方法。实盘默认使用 Redis list queue + QMT `run_time("adjust", ...)` 调度 drain;请求 payload 会做安全编码,避免大 QMT 内置 Redis 客户端读取包含股票代码的 JSON 时触发 `Sensitive Data Detected`。
|
||||
|
||||
- `ping`
|
||||
- `get_ticks`
|
||||
- `get_instrument`
|
||||
- `get_market_data` / `get_market_data_ex` / `get_local_data`
|
||||
- `get_stock_list_in_sector` / `get_sector_list` / `get_sector_info`
|
||||
- `get_divid_factors` / `download_history_data` / `download_history_data2`
|
||||
- `get_trading_dates` / `get_holidays` / `download_holiday_data`
|
||||
- `get_ipo_info` / `get_etf_info` / `get_option_list`
|
||||
- `get_financial_data` / `download_financial_data`
|
||||
- `call_formula` / `subscribe_formula` / `unsubscribe_formula` / `get_formula_result` / `gen_factor_index`
|
||||
- `get_positions`
|
||||
- `get_asset`
|
||||
- `query_orders`
|
||||
- `query_trades`
|
||||
- `sync_positions`
|
||||
|
||||
下单类方法 `submit_order`、`cancel_order` 默认关闭,只有显式配置 `rpc_allow_order_methods=True` 后才会开放。
|
||||
|
||||
## MiniQMT 兼容方法名
|
||||
|
||||
RPC 服务端会把以下 MiniQMT 常用方法名映射到大 QMT 适配器:
|
||||
|
||||
| MiniQMT 方法名 | RPC 内部方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `query_stock_asset` | `get_asset` | 查询账户资产 |
|
||||
| `query_stock_positions` | `get_positions` | 查询全部持仓 |
|
||||
| `query_stock_position` | `query_stock_position` | 查询单只持仓,按 `stock_code` 过滤 |
|
||||
| `query_stock_orders` | `query_orders` | 查询委托;支持 `cancelable_only` 过滤 |
|
||||
| `query_stock_trades` | `query_trades` | 查询成交 |
|
||||
| `get_full_tick` | `get_ticks` | 默认直接 RPC 调用;可选开启 Redis 快照缓存降载 |
|
||||
| `get_instrument_detail` / `get_instrumentdetail` | `get_instrument` | 查询合约详情 |
|
||||
| `order_stock` / `order_stock_async` | `submit_order` | 买卖下单;默认关闭 |
|
||||
| `cancel_order_stock` / `cancel_order_stock_sysid` | `cancel_order` | 撤单;默认关闭 |
|
||||
|
||||
`order_stock` 参数兼容 `stock_code`、`order_type`、`order_volume`、`price_type`、`price`、`strategy_name`、`order_remark`。其中 `order_type=23/STOCK_BUY` 映射为买入,`order_type=24/STOCK_SELL` 映射为卖出。
|
||||
|
||||
`price_type` 会透传到大 QMT `passorder()`,常用值包括 `11/FIX_PRICE`、`5/LATEST_PRICE`、`44/MARKET_PEER_PRICE_FIRST`、`43/MARKET_SH_CONVERT_5_LIMIT`、`47/MARKET_SZ_CONVERT_5_CANCEL`。
|
||||
|
||||
`get_full_tick/get_ticks` 的 `codes` 参数支持两种写法:传合约代码如 `["600000.SH", "000001.SZ"]` 查询指定标的;传市场代码如 `["SH", "SZ"]` 查询全市场全推快照。
|
||||
|
||||
注意:兼容层的 `xtdata.get_full_tick(codes)` 默认走 Redis RPC 现调大 QMT。若需要降低全市场行情的大 payload 压力,可在客户端和 QMT 本地配置里显式打开 `full_tick_cache_enabled=True` / `BIGQMT_FULL_TICK_CACHE_CONFIG["enabled"]=True`,改为 Redis 需求驱动快照。
|
||||
|
||||
## 实现文件
|
||||
|
||||
- `src/bigqmt_signal_trader/redis_rpc.py`:RPC 协议、Redis queue 服务、外部客户端 helper。
|
||||
- `src/bigqmt_signal_trader/xtquant_compat.py`:MiniQMT 风格客户端兼容层。
|
||||
- `src/xtquant/`:可选的 `xtquant` import shim,用于最终替换老 import。
|
||||
- `src/bigqmt_signal_trader_strategy.py`:在 `init` 中启动 RPC;默认由 QMT `run_time("adjust", ...)` drain Redis queue,避免大 QMT 冻结自建后台线程。
|
||||
- `src/bigqmt_signal_trader_redis_rpc_runtime.py`:大 QMT 策略入口,默认不消费交易信号,只启用 RPC 和持仓同步。
|
||||
- `tests/bigqmt_signal_trader/test_redis_rpc.py`:RPC 单测。
|
||||
|
||||
## 运行方式
|
||||
|
||||
把源码同步到 QMT 的 `python` 目录:
|
||||
|
||||
```powershell
|
||||
$srcPkg = '<REPO_ROOT>\src\bigqmt_signal_trader'
|
||||
$dstPkg = '<QMT_PYTHON_DIR>\bigqmt_signal_trader'
|
||||
Get-ChildItem -LiteralPath $srcPkg -Force | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $dstPkg -Recurse -Force
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_strategy.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_strategy.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_redis_rpc_runtime.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_rpc_runtime.py' `
|
||||
-Force
|
||||
```
|
||||
|
||||
QMT 本地私有配置文件:
|
||||
|
||||
```python
|
||||
# <QMT_PYTHON_DIR>\bigqmt_signal_trader_local_config.py
|
||||
# coding: utf-8
|
||||
|
||||
BIGQMT_ACCOUNT_ID = "你的资金账号"
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "...",
|
||||
"rpc_allow_order_methods": False,
|
||||
"rpc_process_in_listener": True,
|
||||
"rpc_listener_methods": ("*",),
|
||||
"rpc_background_threads": False,
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "500nMilliSecond",
|
||||
"full_tick_cache_enabled": False,
|
||||
"full_tick_demand_ttl_seconds": 10,
|
||||
"full_tick_cache_ttl_seconds": 10,
|
||||
"full_tick_refresh_interval_seconds": 3,
|
||||
"full_tick_max_requests": 8,
|
||||
}
|
||||
```
|
||||
|
||||
这个文件含账号和 Redis 密码,只放 QMT 本地目录,不提交。
|
||||
|
||||
QMT 策略编辑器内容:
|
||||
|
||||
```python
|
||||
#coding:gbk
|
||||
import sys
|
||||
import os
|
||||
import importlib
|
||||
|
||||
_qmt_path = os.path.dirname(os.path.abspath(globals().get('__file__', '')))
|
||||
if not _qmt_path:
|
||||
_qmt_path = 'D:/YOUR_QMT_PYTHON_DIR'
|
||||
if _qmt_path not in sys.path:
|
||||
sys.path.insert(0, _qmt_path)
|
||||
|
||||
try:
|
||||
import bigqmt_signal_trader.redis_rpc as _redis_rpc
|
||||
_redis_rpc = importlib.reload(_redis_rpc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import bigqmt_signal_trader_strategy as _strategy
|
||||
try:
|
||||
_strategy.reset_app()
|
||||
except Exception:
|
||||
pass
|
||||
_strategy = importlib.reload(_strategy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import bigqmt_signal_trader_redis_rpc_runtime as _runtime
|
||||
_runtime = importlib.reload(_runtime)
|
||||
|
||||
try:
|
||||
from bigqmt_signal_trader_local_config import BIGQMT_REDIS_CONFIG
|
||||
_runtime.configure_runtime_redis(BIGQMT_REDIS_CONFIG)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from bigqmt_signal_trader_local_config import BIGQMT_ACCOUNT_ID
|
||||
_runtime.configure_runtime_account(BIGQMT_ACCOUNT_ID)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_runtime.bind_runtime_api(
|
||||
passorder_func=passorder,
|
||||
cancel_func=cancel,
|
||||
get_trade_detail_data_func=get_trade_detail_data,
|
||||
)
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
init = _runtime.init
|
||||
handlebar = _runtime.handlebar
|
||||
adjust = _runtime.adjust
|
||||
order_callback = _runtime.order_callback
|
||||
deal_callback = _runtime.deal_callback
|
||||
```
|
||||
|
||||
不要勾选“启动本地 python”。
|
||||
|
||||
## Redis 协议
|
||||
|
||||
### RPC 请求/响应
|
||||
|
||||
请求 channel:
|
||||
|
||||
```text
|
||||
bigqmt:rpc:req:{account_id}
|
||||
```
|
||||
|
||||
请求 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "req-001",
|
||||
"account_id": "YOUR_ACCOUNT_ID",
|
||||
"method": "get_positions",
|
||||
"params": {},
|
||||
"reply_channel": "bigqmt:rpc:resp:YOUR_ACCOUNT_ID:req-001",
|
||||
"reply_key": "bigqmt:rpc:resp:YOUR_ACCOUNT_ID:req-001",
|
||||
"ttl_seconds": 60
|
||||
}
|
||||
```
|
||||
|
||||
响应会同时写入:
|
||||
|
||||
```text
|
||||
bigqmt:rpc:resp:{account_id}:{request_id}
|
||||
```
|
||||
|
||||
并 publish 到同名 channel。
|
||||
|
||||
响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "req-001",
|
||||
"account_id": "YOUR_ACCOUNT_ID",
|
||||
"method": "get_positions",
|
||||
"ok": true,
|
||||
"data": {},
|
||||
"error": "",
|
||||
"handled_at": "2026-07-01 10:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 可选:get_full_tick 需求驱动缓存
|
||||
|
||||
默认情况下,`xtdata.get_full_tick(codes)` 直接走 RPC。只有显式打开 `full_tick_cache_enabled=True` / `BIGQMT_FULL_TICK_CACHE_CONFIG["enabled"]=True` 时,客户端才会写入需求:
|
||||
|
||||
```text
|
||||
bigqmt:full_tick:demand:{account_id}
|
||||
```
|
||||
|
||||
其中 hash field 是规范化代码集合的 request id,value 包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "...",
|
||||
"codes": ["SH", "SZ"],
|
||||
"requested_at_ts": 1780000000.0,
|
||||
"expires_at_ts": 1780000010.0,
|
||||
"cache_ttl_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
大 QMT 每轮刷新后写入快照:
|
||||
|
||||
```text
|
||||
bigqmt:full_tick:cache:{account_id}:{request_id}
|
||||
```
|
||||
|
||||
快照 Redis key 的 TTL 默认是 10 秒;客户端还会校验 `updated_at_ts`,超过 `cache_ttl_seconds` 的快照不会返回。第一次调用如果还没有快照,客户端默认最多等待 `3.5s` 等下一轮大 QMT 刷新;**个股列表**仍然没有新快照时回退一次 live RPC(`get_full_tick`)以避免冷启动硬停;**市场代码**(`SH/SZ/BJ/HK`)则抛出超时、不回退 live 拉全市场。
|
||||
|
||||
### 异步下载任务(download jobs)
|
||||
|
||||
`download_history_data` / `download_history_data2` 是耗时的长调用:如果走同步 RPC,服务端会在**策略线程**上一直下载,冻结整个 RPC pump(且客户端 6s 就超时崩)。因此这两个方法改为**异步分块任务**:客户端把任务写入 Redis 队列立即返回,大 QMT 的策略线程每个 tick 只下载 `download_job_chunk_size` 只(受 `download_job_max_wall_seconds` 墙钟预算约束),永不长时间阻塞。
|
||||
|
||||
Redis 布局(按账户):
|
||||
|
||||
```text
|
||||
bigqmt:download:queue:{account_id} # 待处理 job_id 列表(RPUSH/LPOP)
|
||||
bigqmt:download:job:{account_id}:{job_id} # job JSON(含 state/done/total/error 进度)
|
||||
bigqmt:download:current:{account_id} # 当前正在处理的 job_id(串行,一次一个)
|
||||
```
|
||||
|
||||
job 状态:`pending → running → done | failed`。历史 K 线下载到**大 QMT 机器**的本地库;客户端随后用 `get_local_data` / `get_market_data` 快读取回。
|
||||
|
||||
客户端用法:
|
||||
|
||||
```python
|
||||
# 非阻塞:提交后轮询
|
||||
job = xtdata.submit_download_history_data2(["600000.SH", "000001.SZ"], "1d")
|
||||
status = xtdata.get_download_status(job["job_id"]) # {state, done, total, error}
|
||||
status = xtdata.wait_download(job["job_id"]) # 阻塞轮询到 done/failed(仅客户端阻塞)
|
||||
|
||||
# 兼容:download_history_data2(...) 仍可直接调用 = 提交 + 等待(默认最多 1800s);
|
||||
# 超时会抛 TimeoutError(任务在服务端继续跑,可继续轮询)。大批量建议用 submit + 轮询。
|
||||
```
|
||||
|
||||
服务端开关:`download_jobs_enabled`、`download_job_chunk_size`(默认 10,每 tick 最小下载块)、`download_job_max_wall_seconds`(默认 0.5s,每 tick 墙钟预算)、`download_job_ttl_seconds`(默认 3600)。
|
||||
|
||||
### 实时成交/委托回调推送(exec events)
|
||||
|
||||
大 QMT 的 `order_callback(ContextInfo, orderInfo)` / `deal_callback(ContextInfo, dealInfo)` 在策略进程内触发。服务端把 QMT 对象的 ThinkTrader `m_*` 字段规范化后 publish 到 Redis,客户端后台线程订阅并回调 —— 无需轮询即可**实时**拿到成交/委托。
|
||||
|
||||
Redis 频道(同名 stream,xadd + publish,供短时回放):
|
||||
|
||||
```text
|
||||
bigqmt:order_events:{account_id}
|
||||
bigqmt:trade_events:{account_id}
|
||||
```
|
||||
|
||||
成交事件字段(由 `deal_callback` 的 `m_*` 映射):`stock_code`(`m_strInstrumentID`)、`trade_id`(`m_strTradeID`)、`order_sys_id`(`m_strOrderSysID`)、`volume`(`m_nVolume`)、`price`(`m_dPrice`)、`amount`(`m_dTradeAmount`)、`commission`(`m_dComssion`)、`direction`(`m_nDirection`) 及 `action`(尽力映射 BUY/SELL)、`traded_at`(`m_strTradeTime`)。委托事件类似(`m_nOrderStatus`→`status`、`m_nVolumeTotal`→`order_volume`、`m_nVolumeTraded`→`traded_volume`、`m_dLimitPrice`→`price`)。
|
||||
|
||||
客户端用法(MiniQMT 风格,回调实时触发):
|
||||
|
||||
```python
|
||||
class MyCallback(XtQuantTraderCallback):
|
||||
def on_stock_trade(self, trade): # 成交实时回调
|
||||
print(trade.stock_code, trade.trade_id, trade.traded_volume, trade.traded_price)
|
||||
def on_stock_order(self, order): # 委托状态实时回调
|
||||
print(order.stock_code, order.order_status, order.traded_volume)
|
||||
|
||||
xt_trader.register_callback(MyCallback())
|
||||
xt_trader.start() # 启动后台监听线程(订阅上面两个频道)
|
||||
xt_trader.subscribe(acc) # 账号确定后会自动重订阅到该账号频道
|
||||
```
|
||||
|
||||
服务端开关:`exec_events_enabled`(默认 True)。`action` 由 `m_nDirection` 尽力映射(48/23→BUY,49/24→SELL),未知时为空但 `direction` 原值始终保留。
|
||||
|
||||
## 外部调用示例
|
||||
|
||||
```python
|
||||
import sys
|
||||
import redis
|
||||
|
||||
sys.path.insert(0, r"<REPO_ROOT>\src")
|
||||
|
||||
from bigqmt_signal_trader.redis_rpc import call_redis_rpc
|
||||
|
||||
r = redis.Redis(
|
||||
host="YOUR_REDIS_HOST",
|
||||
port=6379,
|
||||
db=5,
|
||||
username="",
|
||||
password="...",
|
||||
)
|
||||
|
||||
response = call_redis_rpc(
|
||||
r,
|
||||
account_id="YOUR_ACCOUNT_ID",
|
||||
method="get_positions",
|
||||
params={},
|
||||
timeout_seconds=3,
|
||||
)
|
||||
|
||||
print(response)
|
||||
```
|
||||
|
||||
## 延迟模式
|
||||
|
||||
### 两档处理模型(重要)
|
||||
|
||||
同一进程只有一个 GIL,方法按处理线程分两档:
|
||||
|
||||
- **inline 档(后台接收线程直接处理)**:`ping`、行情类(`get_full_tick`/`get_market_data_ex`/
|
||||
`get_instrument_detail`)、`query_stock_asset`。中位数**亚毫秒**,但会撞上大 QMT 终端占 GIL
|
||||
的尾延迟(见下)。
|
||||
- **deferred 档(推迟到主策略线程,经 adjust drain)**:所有走 `get_trade_detail_data` 的**交易
|
||||
查询**——持仓/委托/成交、信用/账户明细,以及下单/撤单。**原因**:`get_trade_detail_data` 在后台
|
||||
线程上返回空(账户实有持仓也查出 0),必须在 QMT 主线程上下文里跑。这些方法登记在
|
||||
`LISTENER_DEFERRED_METHODS`,由 `run_time("adjust", interval)` 每拍 `drain_pending()` 在主
|
||||
线程执行。(`get_asset` 例外,走另一个 QMT 调用,后台线程即可,保持 inline 低延迟。)
|
||||
|
||||
> `adjust` 不是 QMT 内置回调。QMT 只自动调 `init`/`handlebar`;`handlebar` 里 `return
|
||||
> adjust(...)`,加上我们 `run_time("adjust", interval)` 注册的定时器,构成 RPC 队列的 drain 节奏。
|
||||
|
||||
### 尾延迟 = 大 QMT 终端占 GIL(不是本代码)
|
||||
|
||||
`gil_probe` 探针显示进程周期性被卡 ~490ms,但 `adjust_phase` 每段都 <50ms —— 即**尾延迟来自
|
||||
QMT 终端自身的 C++ 主循环占着 GIL**,`setswitchinterval`/精简 adjust 都 preempt 不了。唯一根治
|
||||
是把 serving 挪出该进程(sidecar 独立 GIL,见 `shm_transport.py` 预留)。
|
||||
|
||||
### schedule_adjust_interval 调这个数压尾延迟
|
||||
|
||||
`run_time` 间隔 = 后台线程拿到主线程 GIL 窗口的节奏源;间隔越小,inline 尾越低:
|
||||
|
||||
| interval | adjust 频率 | inline 尾(p90/max) | CPU | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `500nMilliSecond` | ~2.4/s | ~490 / 510ms | 极低 | 默认省电 |
|
||||
| `200nMilliSecond` | 折中 | ~200ms 量级 | 中 | **推荐平衡点** |
|
||||
| `100nMilliSecond` | ~2150/s(QMT 当"尽快跑"热循环) | ~92 / 108ms | 烧≈1 核 | 尾最低但费 CPU |
|
||||
|
||||
**deferred 交易查询恒定 ~1s**(实测 p50 1012~1013ms,与 interval 无关)—— 瓶颈是
|
||||
`get_trade_detail_data` 自身的柜台查询开销,调 interval 无效。要低延迟拿持仓,走**客户端 redis
|
||||
缓存**(position_sync 已在写)而非每次实时查。
|
||||
|
||||
### zmq 真机实测(同机 localhost)
|
||||
|
||||
- inline:`ping` p50 **0.4-0.5ms**;`get_market_data_ex` 因 handler 较重几乎必吃满一个尾窗口
|
||||
(500ms 档 p50≈495ms,100ms 档 p50≈96ms)。
|
||||
- deferred:持仓/委托/成交 p50 **~1s**。
|
||||
- 下单/撤单已在**实盘**验证:`order_stock` 挂单(status=50 已报)→ `query_stock_orders` 拿到
|
||||
sysid → `cancel_order_stock` 成功、无残留。
|
||||
|
||||
### 传输与后台线程
|
||||
|
||||
- **redis**(默认,跨机):`rpc_process_in_listener=True`。
|
||||
- **zmq**(同机低延迟):只加 `transport="zmq"` 一行;非 redis 传输 `_build_rpc_service` 会自动开
|
||||
`background_threads`,端口按账号派生 `tcp://127.0.0.1:1556x`。
|
||||
- 内置 Redis 客户端读取含股票代码的原始 JSON 会触发 `Sensitive Data Detected`;客户端 helper 默认
|
||||
对请求做安全编码。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 默认生产模式不在自建线程里调用 QMT API;QMT API 调用在 `adjust/handlebar` 中处理。
|
||||
- 默认只读,远程下单关闭。
|
||||
- 账号不匹配会拒绝请求。
|
||||
- 响应写 Redis key 并设置 TTL,方便调用端超时后排查。
|
||||
|
||||
## 本地测试
|
||||
|
||||
```powershell
|
||||
cd <REPO_ROOT>
|
||||
python -B -m unittest discover -s tests\bigqmt_signal_trader
|
||||
```
|
||||
|
||||
当前结果:
|
||||
|
||||
```text
|
||||
Ran 68 tests
|
||||
OK
|
||||
```
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# 大 QMT 信号下单包运行手册
|
||||
|
||||
更新时间:2026-07-01
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
这个包已经具备大 QMT 运行入口和 QMT 适配层:
|
||||
|
||||
- `bigqmt_signal_trader_strategy.py`:大 QMT 策略入口,响应 `init`、`handlebar/adjust`、委托回调、成交回调。
|
||||
- `BigQmtMarketDataProvider`:封装 `ContextInfo.get_full_tick()` 和 `ContextInfo.get_instrumentdetail()`。
|
||||
- `BigQmtPositionProvider`:封装 `get_trade_detail_data(account, 'STOCK', 'POSITION')`。
|
||||
- `BigQmtOrderGateway`:按 `qmt_jq_trade` 的参数形状调用 `passorder()`。
|
||||
- 默认模式仍是 `dryrun`,不会真实发委托。
|
||||
|
||||
截至 2026-07-01 凌晨,已完成:
|
||||
|
||||
- 本地单元测试:`32 tests OK`。
|
||||
- QMT `python` 目录导入测试通过。
|
||||
- 模拟 `init/adjust/sync_positions` 回调通过。
|
||||
- 模拟 `mode="bigqmt"` + fake `passorder` 参数测试通过。
|
||||
- Redis Stream 信号源、Redis 状态写回、Redis 持仓同步已实现。
|
||||
- 本机 Redis `127.0.0.1:6379 db=5` dry-run 集成测试通过。
|
||||
|
||||
还没有完成:
|
||||
|
||||
- 没有在 QMT 页面里通过“模型交易”做真实实盘委托验证。
|
||||
- 没有把 miniQMT 的真实买卖指令切成只写 Redis 信号。
|
||||
- 没有在真实账号上启用大 QMT `passorder` 实盘执行。
|
||||
|
||||
所以今天开盘可以先验证“大 QMT 是否能持续加载和触发回调”,以及 Redis dry-run 链路是否能消费测试信号并写回状态。如果要真的由大 QMT 替代 miniQMT 下单,必须先完成 miniQMT 只写信号、单账户灰度和实盘风控确认。
|
||||
|
||||
## 1.1 当前 QMT 页面检查结果
|
||||
|
||||
2026-07-01 08:00 左右检查大 QMT“模型交易”页面:
|
||||
|
||||
- 页面里当前运行/展示的策略名称是“网格策略”。
|
||||
- 没有看到 `bigqmt_signal_trader` 或 `bigqmt_signal_trader_redis_dryrun`。
|
||||
- “策略日志”页没有 `[bigqmt_signal_trader] init ok` 或 `[bigqmt_signal_trader] adjust ok`。
|
||||
|
||||
结论:当前这个文件还没有真正挂到大 QMT 模型交易里运行。
|
||||
|
||||
## 2. 官方文档里的关键点
|
||||
|
||||
### 2.1 编辑器运行不等于真实交易
|
||||
|
||||
大 QMT 编辑器里的“运行/回测/模型运行”主要用于公式、模型、信号验证。要真正把委托发送到交易柜台,需要进入“模型交易”页面,把策略加入模型交易实例并绑定资金账号。
|
||||
|
||||
结论:
|
||||
|
||||
- 编辑器“运行”:适合检查 `init/handlebar` 是否报错,不用于确认真实下单。
|
||||
- 模型交易“模拟信号”:适合开盘先观察回调、信号、价格、状态,不真实下单。
|
||||
- 模型交易“实盘交易”:只有确认信号源、幂等、风控都 OK 后才能打开。
|
||||
|
||||
### 2.2 不要勾选“启动本地 python”
|
||||
|
||||
官方文档说明,“启动本地 python”是把脚本作为独立 Python 进程运行。这个模式不会按大 QMT 回调机制触发 `init(ContextInfo)`、`handlebar(ContextInfo)`。
|
||||
|
||||
本包是回调式策略入口,必须让 QMT 自己调用:
|
||||
|
||||
- 不勾选:`init`、`handlebar`、`order_callback`、`deal_callback` 正常触发。
|
||||
- 勾选:脚本只会像普通 Python 文件一样执行 import,通常会马上结束,不会进入交易回调。
|
||||
|
||||
### 2.3 必须跳过历史 bar
|
||||
|
||||
QMT 加载策略时可能先跑历史 K 线,再进入最后一根实时 bar。入口已经加了保护:
|
||||
|
||||
```python
|
||||
if hasattr(ContextInfo, "is_last_bar") and not ContextInfo.is_last_bar():
|
||||
return None
|
||||
```
|
||||
|
||||
这可以避免未来接入真实信号源后,在历史回放阶段误消费当前待处理信号。
|
||||
|
||||
## 3. 文件部署
|
||||
|
||||
大 QMT 运行目录:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>
|
||||
```
|
||||
|
||||
源代码目录:
|
||||
|
||||
```text
|
||||
<REPO_ROOT>\src
|
||||
```
|
||||
|
||||
部署命令:
|
||||
|
||||
```powershell
|
||||
Copy-Item -Path '<REPO_ROOT>\src\bigqmt_signal_trader\*' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader' `
|
||||
-Recurse -Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_strategy.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_strategy.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_dryrun.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_dryrun.py' `
|
||||
-Force
|
||||
|
||||
Copy-Item -LiteralPath '<REPO_ROOT>\src\bigqmt_signal_trader_redis_dryrun.py' `
|
||||
-Destination '<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_dryrun.py' `
|
||||
-Force
|
||||
```
|
||||
|
||||
清理缓存:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem -LiteralPath '<QMT_PYTHON_DIR>\bigqmt_signal_trader' `
|
||||
-Recurse -Filter '__pycache__' -Directory -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force
|
||||
|
||||
Get-ChildItem -LiteralPath '<QMT_PYTHON_DIR>' `
|
||||
-Filter '__pycache__' -Directory -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force
|
||||
```
|
||||
|
||||
## 4. QMT 编辑器加载测试
|
||||
|
||||
目的:只验证 QMT 能加载入口、能触发 `init/adjust`,不会真实下单。
|
||||
|
||||
### 4.1 策略文件内容
|
||||
|
||||
在 QMT 策略编辑器中新建一个策略,例如 `大QMT信号下单_dryrun`,内容使用下面这段。脚本必须保持 ASCII,避免 QMT 编辑器编码问题。
|
||||
|
||||
```python
|
||||
#coding:gbk
|
||||
from bigqmt_signal_trader_strategy import (
|
||||
adjust,
|
||||
configure,
|
||||
deal_callback,
|
||||
handlebar,
|
||||
init,
|
||||
order_callback,
|
||||
set_account_id,
|
||||
sync_positions,
|
||||
)
|
||||
|
||||
try:
|
||||
ACCOUNT_ID = account
|
||||
except NameError:
|
||||
ACCOUNT_ID = ""
|
||||
|
||||
if ACCOUNT_ID:
|
||||
set_account_id(ACCOUNT_ID)
|
||||
|
||||
configure(mode="dryrun", account_id=ACCOUNT_ID or "dryrun")
|
||||
```
|
||||
|
||||
### 4.2 QMT 页面设置
|
||||
|
||||
在策略编辑器右侧/基本信息里:
|
||||
|
||||
- 运行周期:建议先选 `1分钟` 或 `3分钟`。
|
||||
- 标的:建议先用流动性稳定的指数或股票,例如 `000300.SH`。
|
||||
- 启动本地 python:不要勾选。
|
||||
- 自动交易/实盘交易:不要在编辑器测试阶段打开。
|
||||
|
||||
点击顺序:
|
||||
|
||||
1. 保存。
|
||||
2. 编译。
|
||||
3. 运行。
|
||||
|
||||
期望输出:
|
||||
|
||||
```text
|
||||
[bigqmt_signal_trader] init ok
|
||||
[bigqmt_signal_trader] adjust ok
|
||||
```
|
||||
|
||||
如果只看到“开始运行/结束运行”,但没有 `init ok/adjust ok`:
|
||||
|
||||
- 检查是否勾选了“启动本地 python”。
|
||||
- 检查是否真的导入了 `bigqmt_signal_trader_strategy.py`。
|
||||
- 检查 QMT 输出窗或 `XtClient_Formula_YYYYMMDD.log` 是否有 traceback。
|
||||
|
||||
## 4.3 Redis dry-run 入口
|
||||
|
||||
已经新增安全观察入口:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>\bigqmt_signal_trader_redis_dryrun.py
|
||||
```
|
||||
|
||||
默认配置:
|
||||
|
||||
```text
|
||||
ACCOUNT_ID = bigqmt_probe
|
||||
Redis = 127.0.0.1:6379 db=5
|
||||
Stream = bigqmt:signals:bigqmt_probe
|
||||
Status = bigqmt:signal_status:bigqmt_probe:{signal_id}
|
||||
Position = bigqmt:positions:bigqmt_probe
|
||||
OrderGateway = DryRunOrderGateway
|
||||
```
|
||||
|
||||
如果 QMT 的 `python` 目录存在本地私有配置文件,则 Redis 连接会被覆盖:
|
||||
|
||||
```text
|
||||
<QMT_PYTHON_DIR>\bigqmt_signal_trader_local_config.py
|
||||
```
|
||||
|
||||
格式:
|
||||
|
||||
```python
|
||||
# coding: utf-8
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "...",
|
||||
}
|
||||
```
|
||||
|
||||
这个文件含 Redis 密码,只放 QMT 本地目录,不提交到源码仓库,也不要贴进文档。
|
||||
|
||||
这个入口只用于开盘观察 Redis 链路,不会真实下单,也不会消费真实账号流。不要把 `ACCOUNT_ID` 改成真实资金账号,除非你明确知道 dry-run 会 ack 掉该账号的 Redis 信号。
|
||||
|
||||
写入一条测试信号:
|
||||
|
||||
```powershell
|
||||
cd <REPO_ROOT>
|
||||
python -B -c "import sys,datetime,json,redis; sys.path.insert(0,'src'); from bigqmt_signal_trader.adapters.signal_redis import push_trade_signal; r=redis.Redis(host='127.0.0.1',port=6379,db=5); push_trade_signal(r, {'signal_id':'probe-001','account_id':'bigqmt_probe','action':'BUY','stock_code':'600000.SH','amount':100,'price_type':'FIX_PRICE','price':10.0,'created_at':'2026-07-01 09:31:00','expire_at':'2026-07-01 23:59:00','schema_version':1})"
|
||||
```
|
||||
|
||||
运行后检查状态:
|
||||
|
||||
```powershell
|
||||
python -B -c "import redis; r=redis.Redis(host='127.0.0.1',port=6379,db=5,decode_responses=True); print(r.hgetall('bigqmt:signal_status:bigqmt_probe:probe-001'))"
|
||||
```
|
||||
|
||||
期望状态里出现:
|
||||
|
||||
```text
|
||||
status = DRY_RUN
|
||||
user_order_id = dryrun:bq:...
|
||||
```
|
||||
|
||||
## 5. 模型交易页面运行
|
||||
|
||||
目的:开盘后观察策略在真实行情驱动下是否持续触发,而不是只在编辑器里跑一次。
|
||||
|
||||
### 5.1 第一步只跑模拟信号
|
||||
|
||||
进入 QMT 的“模型交易”页面:
|
||||
|
||||
1. 新建模型交易实例。
|
||||
2. 选择上面的策略文件。
|
||||
3. 绑定资金账号。
|
||||
4. 标的使用 `000300.SH` 或其他稳定标的。
|
||||
5. 周期先用 `1分钟`。
|
||||
6. 运行方式先选择“模拟信号”或等价的非实盘模式。
|
||||
7. 确认“启动本地 python”没有勾选。
|
||||
8. 启动模型交易。
|
||||
|
||||
开盘后观察:
|
||||
|
||||
- 输出窗是否出现 `[bigqmt_signal_trader] init ok`。
|
||||
- 第一根实时 bar 后是否出现 `[bigqmt_signal_trader] adjust ok`。
|
||||
- 日志里是否没有 `Traceback`、`ModuleNotFoundError`、`run script failed`。
|
||||
- 委托页面不应该出现真实委托,因为当前是 dry-run 且空信号源。
|
||||
|
||||
如果要验证 Redis dry-run 链路,则选择 `bigqmt_signal_trader_redis_dryrun.py`,并向 `bigqmt:signals:bigqmt_probe` 写入测试信号。它只会写 Redis 状态,不会真实委托。
|
||||
|
||||
### 5.2 真实大 QMT adapter 连通模式
|
||||
|
||||
如果只想确认大 QMT adapter 可以装配,但仍然没有真实信号源,可以把策略最后一行改成:
|
||||
|
||||
```python
|
||||
configure(mode="bigqmt", account_id=ACCOUNT_ID or "dryrun")
|
||||
```
|
||||
|
||||
注意:当前没有配置真实 `SignalSource`,所以即使是 `mode="bigqmt"`,也不会产生订单。它只会装配行情、持仓、委托 adapter,用于确认 QMT 环境里这些函数可用。
|
||||
|
||||
### 5.3 真正实盘委托前置条件
|
||||
|
||||
只有满足下面全部条件,才能考虑切到实盘交易:
|
||||
|
||||
- 已在 Redis db5 上验证 Redis Stream 信号源、`StateStore.claim()`、状态回写都正常。
|
||||
- 已用模拟信号验证 `passorder` 参数、持仓查询、撤单查询全部正常。
|
||||
- 已确认历史 bar 不会触发下单。
|
||||
- 已确认 miniQMT 不再对同一账户重复真实下单,避免双系统抢单。
|
||||
- 已确认 dry-run 没有 ack 掉真实账号待实盘处理的信号。
|
||||
|
||||
未满足这些条件时,不要切到“实盘交易”。
|
||||
|
||||
## 6. 今日开盘观察清单
|
||||
|
||||
日期:2026-07-01
|
||||
|
||||
### 9:10 前
|
||||
|
||||
- 确认 QMT 已登录。
|
||||
- 确认文件已部署到 `<QMT_PYTHON_DIR>`。
|
||||
- 在策略编辑器里编译成功。
|
||||
- 确认“启动本地 python”未勾选。
|
||||
- 在模型交易页面用“模拟信号”启动 `bigqmt_signal_trader_dryrun.py` 或 `bigqmt_signal_trader_redis_dryrun.py`。
|
||||
|
||||
### 9:30 到 9:35
|
||||
|
||||
- 看输出窗是否出现 `init ok` 和 `adjust ok`。
|
||||
- 看 `XtClient_Formula_20260701.log` 是否有 traceback。
|
||||
- 看策略是否持续运行,没有自动结束。
|
||||
- 看委托页面确认没有真实委托。
|
||||
- 如果跑 Redis dry-run,向 `bigqmt:signals:bigqmt_probe` 写一条测试信号,确认状态 key 变成 `DRY_RUN`。
|
||||
|
||||
### 9:35 后
|
||||
|
||||
如果模拟信号稳定:
|
||||
|
||||
- 可以把标的周期从 `1分钟` 调整到实际希望的触发周期。
|
||||
- 继续保持 dry-run 观察一段时间。
|
||||
- 不要直接改成实盘,除非真实信号源和状态存储已经接好。
|
||||
|
||||
## 7. 日志排查
|
||||
|
||||
QMT 公式日志:
|
||||
|
||||
```text
|
||||
<QMT_USERDATA_LOG_DIR>\XtClient_Formula_YYYYMMDD.log
|
||||
```
|
||||
|
||||
重点搜索:
|
||||
|
||||
```text
|
||||
bigqmt_signal_trader
|
||||
Traceback
|
||||
ModuleNotFoundError
|
||||
SyntaxError
|
||||
run script failed
|
||||
passorder
|
||||
```
|
||||
|
||||
常见问题:
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| 只开始运行/结束运行,没有回调日志 | 勾选了启动本地 python,或没有进入模型交易回调模式 | 取消勾选,使用模型交易运行 |
|
||||
| `No module named dataclasses` | QMT 内置 Python 版本低,不能依赖 dataclasses | 当前代码已移除 dataclasses,重新部署并清 `__pycache__` |
|
||||
| `__file__ is not defined` | QMT 编辑器脚本没有 `__file__` | 策略入口不要依赖 `__file__` |
|
||||
| 编码错误 | 编辑器保存编码和 `coding` 声明不一致 | 入口脚本用 `#coding:gbk`,内容保持 ASCII |
|
||||
| 启动后处理很多历史 bar | 未过滤历史 K 线 | 当前 `adjust` 已用 `is_last_bar()` 保护 |
|
||||
|
||||
## 8. 当前不能误解的点
|
||||
|
||||
- 当前包不是 `qmt_jq_trade` 的目标持仓同步脚本。
|
||||
- 当前包的设计是“外部系统产出逐笔交易信号,大 QMT 只执行”。
|
||||
- Redis db5 链路已经具备,但当前安全入口默认使用 `bigqmt_probe` 测试账号流。
|
||||
- 编辑器里点“运行”不是实盘验证。
|
||||
- 真正实盘必须走模型交易页面,并且要显式接入信号源、幂等状态和账户切换流程。
|
||||
|
||||
## 9. 官方文档参考
|
||||
|
||||
- ThinkTrader 大 QMT 接口文档:`https://dict.thinktrader.net/innerApi/interface_operation.html`
|
||||
- 大 QMT Python API 文档:`https://qmt.ptradeapi.com/QMT_Python_API_Doc.html`
|
||||
- 本项目参考脚本:`<REPO_ROOT>\src\api\qmt_jq_trade`
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Big QMT 执行回调重复触发修复记录
|
||||
|
||||
日期:2026-08-12
|
||||
|
||||
## 现象
|
||||
|
||||
实盘下单后,客户端收到的委托回报和成交回报各触发两次,例如:
|
||||
|
||||
```text
|
||||
委托回报: 159518 50 635042239
|
||||
委托回报: 159518 50 635042239
|
||||
成交回报: 159518 635042239 100 1.204
|
||||
成交回报: 159518 635042239 100 1.204
|
||||
```
|
||||
|
||||
同一笔委托的同一状态、同一笔成交被重复推送到客户端 callback。
|
||||
|
||||
## 原因
|
||||
|
||||
服务端策略脚本同时暴露了两套执行回调入口:
|
||||
|
||||
- `on_order` / `on_trade`
|
||||
- `order_callback` / `deal_callback`
|
||||
|
||||
其中 `order_callback()` 内部又调用 `on_order()`,`deal_callback()` 内部又调用 `on_trade()`。
|
||||
|
||||
如果 Big QMT 运行时同时识别并触发这两套入口,同一个原始委托/成交事件会进入服务端两次。每次都会执行:
|
||||
|
||||
1. 归一化 QMT 回调对象;
|
||||
2. 发布 Redis 执行事件;
|
||||
3. 转发给本地 app runner。
|
||||
|
||||
客户端订阅执行事件频道后,就会看到同一条委托回报/成交回报各触发两次。
|
||||
|
||||
## 修复方案
|
||||
|
||||
只保留 Big QMT 标准回调入口:
|
||||
|
||||
- `order_callback(ContextInfo, orderInfo)`
|
||||
- `deal_callback(ContextInfo, dealInfo)`
|
||||
|
||||
删除服务端策略入口中的别名回调:
|
||||
|
||||
- `on_order`
|
||||
- `on_trade`
|
||||
|
||||
`order_callback` 和 `deal_callback` 现在直接完成原来别名函数里的工作:
|
||||
|
||||
- `_publish_exec_event("order", orderInfo)`
|
||||
- `_publish_exec_event("trade", dealInfo)`
|
||||
- `forward_order_event(...)`
|
||||
- `forward_trade_event(...)`
|
||||
|
||||
这样 Big QMT 运行时只会看到一套执行回调入口,不需要依赖客户端或服务端去重。
|
||||
|
||||
## 修改范围
|
||||
|
||||
- `src/bigqmt_signal_trader_strategy.py`
|
||||
- 删除 `on_order` / `on_trade`
|
||||
- `order_callback` / `deal_callback` 直接发布和转发事件
|
||||
|
||||
- `src/bigqmt_signal_trader_redis_rpc_runtime.py`
|
||||
- 不再导入或导出 `on_order` / `on_trade`
|
||||
|
||||
- `src/bigqmt_signal_trader_dryrun.py`
|
||||
- 不再导入 `on_order` / `on_trade`
|
||||
|
||||
- `src/bigqmt_signal_trader_redis_dryrun.py`
|
||||
- 不再导入 `on_order` / `on_trade`
|
||||
|
||||
- `tests/bigqmt_signal_trader/test_runner.py`
|
||||
- 删除别名回调测试
|
||||
- 新增断言:策略模块不暴露 `on_order` / `on_trade`,只暴露 `order_callback` / `deal_callback`
|
||||
|
||||
- `src/bigqmt_signal_trader/README.md`
|
||||
- 更新策略入口说明
|
||||
|
||||
- `docs/BIG_QMT_SIGNAL_TRADER_RUNBOOK.md`
|
||||
- 更新 QMT 策略导入示例
|
||||
|
||||
## 验证
|
||||
|
||||
相关测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests\bigqmt_signal_trader\test_runner.py tests\bigqmt_signal_trader\test_exec_events.py -q
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
38 passed
|
||||
```
|
||||
|
||||
全量测试:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest -q
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
275 passed, 4 skipped
|
||||
```
|
||||
|
||||
## 注意
|
||||
|
||||
这次修复只处理“同一执行事件回调两次”的问题。
|
||||
|
||||
`order_stock()` 同步返回 `-1`,但随后又收到真实委托/成交回报,是另一类问题:同步下单路径没有及时拿到 `order_sys_id`,而异步执行事件稍后能拿到真实系统委托号。该问题不在本次修复范围内。
|
||||
@@ -0,0 +1,168 @@
|
||||
# FormulaServer 直连快速路径(58600)
|
||||
|
||||
## 这是什么
|
||||
|
||||
大 QMT 的 `58600` 端口是 **FormulaServer** —— QMT 内置的 C++ 行情/参考数据服务。端口取自
|
||||
QMT 安装目录的 `config/formulaserver/formulaserver.ini`:
|
||||
|
||||
```ini
|
||||
[server_formula]
|
||||
address = 0.0.0.0:58600
|
||||
```
|
||||
|
||||
QMT 自带 Python 里就有它的官方客户端:`bin.x64/Lib/site-packages/qmt_api`。协议是
|
||||
**BSON over TCP**,帧格式在 `qmt_api/net/RPCBase.py`:
|
||||
|
||||
```
|
||||
| packLen(uint32 BE) | seq(uint32 BE) | cmd(uint16 BE) | tag(uint16 BE) | BSON body |
|
||||
```
|
||||
|
||||
`cmd = 3`(`NET_CMD_RPC`),body 是 `{"func": <名字>, "params": {...}}`,
|
||||
响应 `{"status": 0, "params": {...}}`,`status != 0` 时 `params` 里带 `ErrorID`/`ErrorMsg`。
|
||||
`tag & 7` 标记 zlib 压缩,`tag >> 8` 的低 4 位是 seq 的高位。
|
||||
|
||||
## 为什么值得接
|
||||
|
||||
原来所有只读请求都要绕一整圈:
|
||||
|
||||
```
|
||||
客户端 → redis/zmq → QMT python 策略线程 → ContextInfo → 原路返回
|
||||
```
|
||||
|
||||
这不只是慢,还要和策略自己抢 QMT 主线程的 GIL —— zmq 传输约 30% 的请求会撞上 ~500ms 的
|
||||
调度尖峰,就是这么来的。
|
||||
|
||||
直连 FormulaServer 完全绕开策略进程:
|
||||
|
||||
| 路径 | p50 |
|
||||
|------|-----|
|
||||
| redis RPC | ~13ms |
|
||||
| zmq RPC | ~0.7ms(30% 撞 500ms GIL 尖峰)|
|
||||
| **FormulaServer 直连** | **0.07ms**,无 GIL 竞争 |
|
||||
|
||||
穿过完整客户端栈(`BigQmtRpcClient.call`)实测 **0.145ms/次**,比 redis 快约 90 倍。
|
||||
|
||||
## 能力边界
|
||||
|
||||
**FormulaServer 只有行情/参考数据。** 实测所有账户/交易类方法一律返回
|
||||
`ErrorID 200005 未找到该服务`:
|
||||
|
||||
```
|
||||
getAsset / getPositions / getAccountDetail / passorder -> 200005
|
||||
getFullTick / getQuote -> 200005
|
||||
```
|
||||
|
||||
所以它是**只读快速路径,不是 RPC 桥的替代品**。交易、账户查询、持仓、委托、成交、
|
||||
五档盘口全部仍然走 RPC。
|
||||
|
||||
### 已接入的方法(10 个)
|
||||
|
||||
| 我们的方法 | FormulaServer func |
|
||||
|---|---|
|
||||
| `get_instrument` / `get_instrument_detail` / `get_instrumentdetail` | `getInstrumentDetail` |
|
||||
| `get_last_volume` | `getLastVolume` |
|
||||
| `get_total_share` | `getTotalShare` |
|
||||
| `get_contract_multiplier` | `getContractMultiplier` |
|
||||
| `get_main_contract` | `getMainContract` |
|
||||
| `get_weight_in_index` | `getWeightInIndex` |
|
||||
| `get_stock_list_in_sector` | `getStockListInSector` |
|
||||
| `get_market_data_ex` | `getMarketData` |
|
||||
|
||||
### 刻意不接的方法,以及原因
|
||||
|
||||
宁可慢,不能悄悄给错数据。以下几项参数语义与我们的调用方不一致:
|
||||
|
||||
- **`get_trading_dates`** —— FormulaServer 要的是**股票代码**。实测:
|
||||
```
|
||||
{'stockCode': 'SH', ...} -> {'result': []} # 静默空
|
||||
{'stockCode': '000001.SZ', ...} -> ['20260630', '20260701', ...]
|
||||
```
|
||||
而 `market_bigqmt.get_trading_dates(market, ...)` 的调用方传的是市场代码。传错了不报错、
|
||||
只给空列表,交易日历错了后果太重。
|
||||
|
||||
- **`get_divid_factors`** —— 我们是 `(stock_code, start_time, end_time)` 区间,
|
||||
FormulaServer 是 `(stockCode, date)` 单日。
|
||||
|
||||
- **`get_risk_free_rate`** —— 我们传 `index=-1`,FormulaServer 要 `timetag`。语义不同。
|
||||
|
||||
- **复权 K 线** —— 实测 `dividendType` 传 `none` 和 `front` 返回**完全相同**的价格,
|
||||
说明复权没有生效。因此只有 `dividend_type="none"`(或空)才走直连,其他复权类型直接
|
||||
判为 unroutable 回退 RPC。否则策略要前复权、拿到的却是不复权价格,且毫无提示。
|
||||
|
||||
### 字段名坑
|
||||
|
||||
FormulaServer 的 `getInstrumentDetail` 返回 **`FloatVolumn` / `TotalVolumn`**(官方拼写错误),
|
||||
而原生 xtdata SDK 用的是 `FloatVolume` / `TotalVolume`。下游代码按 SDK 拼写读,直接透传会
|
||||
静默读到 `None`。所以 `_instrument_result` 做了别名归一化,两种拼写都保留。
|
||||
|
||||
### 尚未验证
|
||||
|
||||
`qmt_api/api.py` 的 `getMarketData` 支持 `fields=['quoter']`,注释说会返回
|
||||
`askPrice/askVol/bidPrice/bidVol`(level1 五档 / level2 十档)。**如果这在盘中可用,
|
||||
`get_full_tick` 也能走直连** —— 这是热路径,收益很大。
|
||||
|
||||
但收盘时段实测返回空,无法确认。需要**盘中**再测一次:
|
||||
|
||||
```python
|
||||
c.request('getMarketData', {'fields': ['quoter'], 'stockCodes': ['000001.SZ'],
|
||||
'startTime': '', 'endTime': '', 'period': 'tick',
|
||||
'dividendType': 'none', 'count': -1})
|
||||
```
|
||||
|
||||
在确认之前不要接 —— 没验证就上映射,正是订单方向判定踩过的坑。
|
||||
|
||||
## 失败行为
|
||||
|
||||
**任何失败都自动回退 RPC**,所以连不上 58600 的客户端行为与改动前完全一致:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|---|---|
|
||||
| 方法不在映射表 | `supports()` 返回 False,直接走 RPC |
|
||||
| 参数 translate 不了 | `Unroutable`,走 RPC,**不**触发熔断(这是单次调用的问题) |
|
||||
| 服务连不上 / IO 失败 | `Unroutable` + 熔断 `failure_cooldown_seconds`(默认 30s),期间全部走 RPC |
|
||||
| 服务端回 `200005` | 该方法永久标记 unimplemented,只停这一个方法,不影响其他 |
|
||||
| socket 断了(QMT 重启) | 自动重连重试一次 |
|
||||
|
||||
## 依赖
|
||||
|
||||
**不需要装任何东西。** BSON 编解码内置了无依赖实现;如果环境里有 pymongo 的 `bson`
|
||||
或 QMT 的 `xtquant.xtbson`,会优先用(更快、更久经考验)。两条路径的输出实测逐字节一致,
|
||||
测试里有对拍用例。
|
||||
|
||||
## 配置
|
||||
|
||||
客户端侧,默认开启,通常不用写:
|
||||
|
||||
```python
|
||||
BIGQMT_FORMULA_SERVER_CONFIG = {
|
||||
"enabled": True, # 或环境变量 BIGQMT_FORMULA_ENABLED=0 关闭
|
||||
# "host": "127.0.0.1", # 绑的是 0.0.0.0,跨机可达(需放行防火墙)
|
||||
# "port": 58600, # 不写则从 qmt_root 的 ini 读,再退回 58600
|
||||
# "qmt_root": r"D:\国金证券QMT交易端",
|
||||
# "timeout_seconds": 3.0,
|
||||
# "methods": ["get_instrument"], # 只路由白名单
|
||||
# "failure_cooldown_seconds": 30.0,
|
||||
}
|
||||
```
|
||||
|
||||
也可以写在 `BIGQMT_REDIS_CONFIG["formula_server"]` 里,后者优先级更高。
|
||||
|
||||
## 排查
|
||||
|
||||
```python
|
||||
client._formula_router().stats()
|
||||
# {'enabled': True, 'hits': 202, 'misses': 0, 'available': True,
|
||||
# 'unimplemented': [], 'methods': [...]}
|
||||
```
|
||||
|
||||
启动时会打一行:
|
||||
|
||||
```
|
||||
[bigqmt_formula] active at 127.0.0.1:58600 (10 methods routed direct)
|
||||
```
|
||||
|
||||
熔断时:
|
||||
|
||||
```
|
||||
[bigqmt_formula] unavailable, falling back to RPC for 30s: connect 127.0.0.1:58600 failed: ...
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Listolany
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
# MiniQMT → 普通QMT内置python 策略转换 Skill
|
||||
|
||||
把基于 **miniQMT / xtquant**(`XtQuantTrader` / `xtdata`)的 Python 量化策略,转换为**大QMT内置Python**(`passorder` / `ContextInfo` 体系)可实盘运行的策略。
|
||||
|
||||
> 本 Skill 配合 Cursor AI Agent 使用,覆盖从可行性评估到上线部署的完整流程,含自动化工具脚本和真实转换对照示例。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
SKILL.md 主工作流(7步转换流程)
|
||||
faq.md 买方共性疑虑 FAQ(外部数据/运行频率/同步下单/回测等8问)
|
||||
api_mapping.md 全量 API / 字段 / 枚举映射表
|
||||
constraints.md 限制清单、不可转场景判定与替代方案(含实测验证记录)
|
||||
examples.md 真实策略(700行 demo)转换前后对照
|
||||
scripts/
|
||||
analyze_strategy.py 第1步:静态分析,输出可行性报告
|
||||
check_converted.py 第5步:转换后合规校验(py3.6/GBK/框架结构)
|
||||
to_gbk.py 最终交付:UTF-8 → GBK 安全转存
|
||||
templates/
|
||||
template_timer.py 定时器型骨架(apscheduler / while+sleep 策略首选)
|
||||
template_bar.py 行情驱动型骨架(K线/订阅回调策略)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# 1. 分析原策略(得到可行性报告 + 推荐模板)
|
||||
python scripts/analyze_strategy.py 你的策略.py
|
||||
|
||||
# 2. 以推荐模板为骨架手动填充转换后代码
|
||||
|
||||
# 3. 校验转换结果(必须全部 PASS)
|
||||
python scripts/check_converted.py 转换后策略.py
|
||||
|
||||
# 4. GBK 落盘(大QMT内置端要求)
|
||||
python scripts/to_gbk.py 转换后策略.py 输出_gbk.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 智能体工具ClaudeCode/Cursor/OpenClaw/Hermes/Workbuddy 中使用
|
||||
|
||||
在对话里 `@MiniQMT2bigQMT_Skill`(将本目录放入 `.cursor/skills/` 或个人 skill 目录),Agent 会自动按 7 步流程完成转换并输出报告。
|
||||
|
||||
---
|
||||
|
||||
## 适用范围
|
||||
|
||||
| 可转换 | 处理方式 |
|
||||
|---|---|
|
||||
| apscheduler / while+sleep 定时调度 | → `C.run_time` 定时器 |
|
||||
| `order_stock_async` 异步下单 | → `passorder`(11参) + userOrderId 状态机 |
|
||||
| `query_stock_*` 查询接口 | → `get_trade_detail_data` + `m_` 前缀字段 |
|
||||
| `xtdata.*` 行情接口 | → `C.get_full_tick` / `C.get_market_data_ex` 等 |
|
||||
| 委托回调类 `on_stock_order` 等 | → 模块级 `order_callback` / `deal_callback` 等 |
|
||||
|
||||
| 不可直接转(给替代方案) | 建议 |
|
||||
|---|---|
|
||||
| 多账户/跨券商统一调度 | 文件桥方案(见 constraints.md D节) |
|
||||
| 重型 ML 依赖 / py3.6 装不了的库 | 模型外置,内置端只读信号执行 |
|
||||
| 7x24 守护 / 盘后批处理 | 保留外部计划任务喂文件给内置策略 |
|
||||
|
||||
---
|
||||
|
||||
## 常见疑虑速答(详见 faq.md)
|
||||
|
||||
- **外部数据还能取吗?** 能。内置端是完整 py3.6 非沙箱:本地文件通道(推荐)/ 直接网络请求(低频+超时)/ QMT 自身数据接口(比 mini 的 xtdata 更全)三选一。
|
||||
- **是不是最短一分钟跑一次?** 不是。`run_time` 支持毫秒级间隔且与 K 线周期无关,1 秒循环已实盘长期验证;`handlebar` 本身逐 tick 触发。
|
||||
- **同步下单没了怎么办?** 用 userOrderId 状态机等价改写(模板内置),实测下单后约 1 秒可查回委托号。
|
||||
- **能回测吗?** K线型策略可直接回测(mini 反而没有回测框架);定时器型策略需把信号逻辑双入口挂载(回测挂 handlebar,详见 faq.md Q4)。
|
||||
|
||||
---
|
||||
|
||||
## 关键实测结论(已用券商模拟环境双端交叉验证)
|
||||
|
||||
- xtconstant 常量与内置枚举数值一致(15项全部核实)
|
||||
- `xc.CREDIT_BUY/CREDIT_SELL` 实际值 = 23/24 → **信用账户转换必须显式改 33/34**
|
||||
- `m_strRemark`(userOrderId)只在下单客户端可见,跨客户端对账只能凭 sysid
|
||||
- `passorder` → `m_strRemark` 命中 + sysid 回传 + `cancel(sysid)` 链路均实弹验证通过
|
||||
- 资金/仓位字段两端精确一致;市值字段各自行情快照,仅供展示
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: miniqmt-to-bigqmt
|
||||
description: 将基于 miniQMT 的外部 xtquant 库(xttrader/xtdata)的 Python 量化策略,转换为大QMT内置Python(ContextInfo/passorder 体系)可实盘运行的策略。当用户要求转换 miniQMT 策略、迁移 xtquant 代码到大QMT、或提到"内置Python/XTData/passorder 改写"时使用。包含可行性评估、API 映射、py3.6+GBK 约束校验、部署与实盘验证全流程;不可转换场景给出文件桥等替代方案。
|
||||
---
|
||||
|
||||
# MiniQMT 策略 → 大QMT内置Python 转换
|
||||
|
||||
把外部 xtquant 策略(自带 Python 进程 + `XtQuantTrader`/`xtdata`)改写为在大QMT客户端内运行的内置策略(`init`/`handlebar`/`run_time` + `passorder`)。**目标是"真正能实盘",不是语法翻译**——两套体系的运行模型、账户绑定、下单返回值、数据时效都不同,必须按本流程逐项处理。
|
||||
|
||||
## 两套体系的本质差异(先建立心智模型)
|
||||
|
||||
| 维度 | miniQMT 外接 | 大QMT 内置 |
|
||||
|---|---|---|
|
||||
| 进程 | 自己的 Python 进程,pip 任装 | 客户端内嵌 **Python 3.6**,库受限(券商可能有白名单) |
|
||||
| 编码 | UTF-8 | **GBK**(首行必须 `#coding:gbk`) |
|
||||
| 入口 | `if __name__ == '__main__'` 自由编排 | 框架回调:`init(C)` → `after_init(C)` → `handlebar(C)`/定时器 |
|
||||
| 线程 | 随意多线程/apscheduler | **所有策略共用一个线程,禁止阻塞**(sleep/死循环/锁会卡死全部策略) |
|
||||
| 账户 | 代码里 `StockAccount(id, type)`,可多账户 | 界面选定,注入全局变量 `account`/`accountType`,一个策略实例绑一个账户 |
|
||||
| 下单 | `order_stock_async` 返回 seq,回报回调对账 | `passorder` **无返回值**,靠 `userOrderId`(投资备注,对应 `m_strRemark`)追踪 |
|
||||
| 查询 | `query_stock_asset/orders/positions` 返回无前缀字段对象 | `get_trade_detail_data` 返回 **`m_` 前缀字段**对象(`m_nVolume` 等) |
|
||||
| 行情 | `xtdata.*`(连 miniQMT 行情进程) | `C.get_full_tick`/`C.get_market_data_ex` 等(客户端行情) |
|
||||
| 启停 | 自己守护、AutoLogin 重启 QMT | 随客户端启停;客户端设置里配自动登录/策略自启 |
|
||||
|
||||
## 转换工作流
|
||||
|
||||
复制此清单跟踪进度:
|
||||
|
||||
```
|
||||
- [ ] 第1步 静态分析与可行性评估
|
||||
- [ ] 第2步 选择目标结构模板
|
||||
- [ ] 第3步 逐 API 映射改写
|
||||
- [ ] 第4步 处理订单追踪与状态机
|
||||
- [ ] 第5步 py3.6/GBK 合规校验
|
||||
- [ ] 第6步 输出转换报告
|
||||
- [ ] 第7步 部署与实盘验证指引
|
||||
```
|
||||
|
||||
### 第1步 静态分析与可行性评估
|
||||
|
||||
用户若对内置端能力存疑(外部数据还能不能取、运行频率是否受限、能否回测、多策略会不会互相拖累等),先用 [faq.md](faq.md) 对齐认知再开工——这些多为误解,不要让错误前提影响转换方案。
|
||||
|
||||
运行分析脚本,得到 API 清单、py3.6 语法违例、第三方依赖、阻塞模式等:
|
||||
|
||||
```bash
|
||||
python scripts/analyze_strategy.py <原策略.py>
|
||||
```
|
||||
|
||||
按报告对照 [constraints.md](constraints.md) 分类每个发现项:
|
||||
- **可直接映射** → 第3步处理
|
||||
- **需重构**(apscheduler/多线程/while-sleep 主循环、回报回调对账等)→ 按模板重组
|
||||
- **不可转换**(多账户单进程、重型第三方库、7x24 外部守护等)→ 在转换报告中给出 constraints.md 对应的替代方案(文件桥/外接极简模式/拆分策略),**不要硬转**
|
||||
|
||||
任何一项"不可转换"都不代表整个策略失败——逐项给方案,能转的部分照常转。
|
||||
|
||||
### 第2步 选择目标结构模板
|
||||
|
||||
| 原策略形态 | 模板 |
|
||||
|---|---|
|
||||
| 定时轮询型:apscheduler / while+sleep / 定点任务(绝大多数 miniQMT 策略) | [templates/template_timer.py](templates/template_timer.py) |
|
||||
| 行情驱动型:`xtdata.subscribe_quote` 回调驱动 / 单标的 K线信号 | [templates/template_bar.py](templates/template_bar.py) |
|
||||
|
||||
模板已含:GBK 头、全局状态类 `G`(**禁止把可变状态存 ContextInfo**,有逐K线回滚机制)、`C.set_account(account)`(启用交易回调)、定时器注册、委托状态字典对账骨架、收盘自动停止逻辑。在模板骨架上填充策略逻辑,不要从零写。
|
||||
|
||||
### 第3步 逐 API 映射改写
|
||||
|
||||
对照 [api_mapping.md](api_mapping.md) 完成全部调用替换。高频映射速查(完整表必须查文件):
|
||||
|
||||
| miniQMT | 大QMT内置 |
|
||||
|---|---|
|
||||
| `xt_trader.order_stock_async(acc, code, xtconstant.STOCK_BUY, vol, xtconstant.FIX_PRICE, price, strat, remark)` | `passorder(23, 1101, account, code, 11, price, vol, strat, 2, userOrderId, C)` |
|
||||
| `xt_trader.cancel_order_stock_async(acc, order_id)` | `cancel(sysid, account, accountType, C)`(注意:用**委托号 m_strOrderSysID**,不是内部 order_id) |
|
||||
| `xt_trader.query_stock_positions(acc)` | `get_trade_detail_data(account, accountType, 'position')` |
|
||||
| `xt_trader.query_stock_asset(acc)` | `get_trade_detail_data(account, accountType, 'account')` |
|
||||
| `xt_trader.query_stock_orders(acc)` | `get_trade_detail_data(account, accountType, 'order')` |
|
||||
| `xtdata.get_full_tick(codes)` | `C.get_full_tick(codes)`(字段名同:lastPrice/askPrice/bidPrice...) |
|
||||
| `xtdata.get_instrument_detail(code)` | `C.get_instrument_detail(code)`(字段名同:UpStopPrice/PreClose...) |
|
||||
| `xtdata.get_market_data_ex(...)` | `C.get_market_data_ex(...)`(参数几乎同构) |
|
||||
| `xtdata.get_trading_dates('SH', s, e)` | `C.get_trading_dates('000001.SH', s, e, count, '1d')`(**仅 after_init 后可用**;返回 `'20240101'` 字符串列表,不是时间戳) |
|
||||
| `xtdata.download_history_data(code, period, s, e)` | `download_history_data(code, period, s, e)`(全局函数) |
|
||||
| `xtdata.subscribe_quote(code, period, callback=f)` | `C.subscribe_quote(code, period, callback=f)`(在 init 里注册) |
|
||||
| 回调类 `on_stock_order/on_stock_trade/on_order_error` | 模块级函数 `order_callback(C, o)` / `deal_callback(C, d)` / `orderError_callback(C, args, msg)`,须先 `C.set_account(account)` |
|
||||
| apscheduler / while+sleep | `C.run_time("函数名", "3nSecond", "2025-01-01 09:30:00")` 或 `C.schedule_run(...)` |
|
||||
| `xtconstant.STOCK_BUY/STOCK_SELL` | opType `23/24`;两融担保品 `33/34`、融资买入 `27`、卖券还款 `31` 等查映射表 |
|
||||
|
||||
改写时的硬规则:
|
||||
1. 定时器/回调/after_init 里下单,`quickTrade` 必须传 `2`,否则会漏单。
|
||||
2. 查询字段全部换 `m_` 前缀名(对照 api_mapping.md 的字段映射表)。买卖方向判断用 `m_nOffsetFlag`(48=买 49=卖)或 `m_nOpType`,**不要照搬 order_type==23 的写法去比对 `m_nOrderPriceType`**。
|
||||
3. 删除 `XtQuantTrader` 连接管理、`AutoLogin`、重启 QMT 的代码——内置端没有"连接"概念,断线由客户端处理。
|
||||
4. 删除 `time.sleep` 等待类写法。需要"等委托回报再行动"的逻辑改为:本轮记录待办 → 下一轮定时器回调检查(见模板的 pending 字典模式)。
|
||||
5. `print` 输出到客户端策略日志面板,保留即可;写文件日志可用,但路径用绝对路径。
|
||||
|
||||
### 第4步 处理订单追踪与状态机
|
||||
|
||||
这是最容易出错的环节。`passorder` 无返回值且客户端缓存有 50ms~6s 延迟,照搬"下单→立刻查"必然漏单/超单:
|
||||
|
||||
1. 每笔委托生成唯一 `userOrderId`(如 `f"策略名_{日期}_{序号}"`),下单后存入全局 `G.pending[userOrderId] = {...}`,状态置"待报"。
|
||||
2. 用 `order_callback`(实时推送)或定时器轮询 `get_trade_detail_data(..., 'order')`,按 `o.m_strRemark` 匹配回 `userOrderId`,更新状态/记录 `m_strOrderSysID`。
|
||||
3. 撤单用记录到的 `m_strOrderSysID` 调 `cancel`。
|
||||
4. 同一标的存在"待报"状态委托时禁止再下单(防超单)。
|
||||
5. 委托状态码与 miniQMT 同一套数值(48已报/49部成/50已报待撤…53部撤/54已撤/56已成/57废单),判活集合 `(48,49,50,51,52,55,86,255)` 可沿用。
|
||||
|
||||
### 第5步 py3.6/GBK 合规校验
|
||||
|
||||
```bash
|
||||
python scripts/check_converted.py <转换后策略.py>
|
||||
```
|
||||
|
||||
脚本会拦截:≥3.7 语法(walrus、f-string `=`、dataclasses、asyncio.run、match 等)、残留 xtquant import、threading/multiprocessing、`time.sleep`、`input()`、缺 `#coding:gbk` 头、缺 `init`、`passorder` 参数个数错误、GBK 不可编码字符。**必须全部 PASS 才算转换完成**;每修一处重跑。
|
||||
|
||||
最后转存为 GBK 编码(编辑器直接改写 GBK 文件易出乱码,务必用脚本):
|
||||
|
||||
```bash
|
||||
python scripts/to_gbk.py <转换后策略.py> <输出.py>
|
||||
```
|
||||
|
||||
### 第6步 输出转换报告
|
||||
|
||||
向用户输出报告,包含:
|
||||
- 已映射 API 清单(原调用 → 新调用)
|
||||
- 重构点说明(调度器改造、订单追踪改造等)
|
||||
- **不可转换项及替代方案**(引用 constraints.md 具体章节)
|
||||
- 行为差异警示:行情时效(内置为客户端行情,无 VIP 时订阅数量受限)、`get_trade_detail_data` 是本地缓存非柜台实查、策略随客户端启停
|
||||
|
||||
### 第7步 部署与实盘验证指引
|
||||
|
||||
指导用户按以下步骤上线(细节见 [constraints.md](constraints.md) 部署章节):
|
||||
1. 大QMT → 新建Python策略 → 粘贴转换后代码(确认编辑器显示中文注释无乱码)→ 保存编译
|
||||
2. 策略交易/模型交易界面 → 新建 → 选本策略 + 资金账号(普通=STOCK/两融=CREDIT)+ 任意周期(定时器型策略选日线最省资源)→ 运行模式先选**模拟信号**
|
||||
3. 模拟信号模式观察 1 个交易时段:信号面板的下单时机/数量/价格与预期一致
|
||||
4. 切**实盘交易**模式,先用最小单量 + 不易成交价(买跌停价/卖涨停价附近)下 1-2 笔并撤掉,验证报/撤链路;报撤测试安排在交易时段或收盘后半小时内(实测 17 点后柜台不处理撤单)
|
||||
5. 客户端设置勾选:自动登录、终端启动后策略自动运行;Windows 计划任务配开机启动客户端
|
||||
|
||||
## 参考文件
|
||||
|
||||
- [faq.md](faq.md) — 买方共性疑虑(外部数据/运行频率/同步下单/回测/性能预算),转换前对齐认知用
|
||||
- [api_mapping.md](api_mapping.md) — 全量函数/字段/枚举映射表
|
||||
- [constraints.md](constraints.md) — 限制清单、不可转场景判定与替代方案、部署细节
|
||||
- [examples.md](examples.md) — 真实策略(apscheduler+xtquant 700行)转换前后对照
|
||||
- [templates/template_timer.py](templates/template_timer.py)、[templates/template_bar.py](templates/template_bar.py)
|
||||
- https://dict.thinktrader.net/?id=aWtHn6,映射表未覆盖的函数查这里
|
||||
- 如有改进建议,可添加QQ:290560364 反馈意见
|
||||
@@ -0,0 +1,182 @@
|
||||
# API 映射表:xtquant(miniQMT外接) → 大QMT内置Python
|
||||
|
||||
逐条对照改写。"——"表示无直接等价物,处理方式见备注或 constraints.md。
|
||||
权威细节见迅投官方文档:[交易函数](https://dict.thinktrader.net/innerApi/trading_function.html)、[行情函数](https://dict.thinktrader.net/innerApi/data_function.html)、[枚举常量](https://dict.thinktrader.net/innerApi/enum_constants.html)、[数据结构](https://dict.thinktrader.net/innerApi/data_structure.html)。
|
||||
|
||||
## 1. 连接与生命周期
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `XtQuantTrader(path, session_id)` | ——(删除) | 内置端无连接概念,策略在客户端内运行 |
|
||||
| `xt_trader.start()` / `.connect()` / `.stop()` | ——(删除) | 同上 |
|
||||
| `xt_trader.subscribe(account)` | `ContextInfo.set_account(account)`(init 中调用) | 启用 order/deal/position/account 回调的前提 |
|
||||
| `StockAccount(acc_id, 'STOCK'/'CREDIT')` | 全局变量 `account`、`accountType`(界面选定后注入) | 代码中直接引用,不要自己定义同名变量覆盖 |
|
||||
| `if __name__ == '__main__':` 主程序 | `init(C)` + `after_init(C)` + 定时器/`handlebar` | 初始化进 init/after_init;注意 `get_trading_dates` 等在 init 中不可用,放 after_init |
|
||||
| 脚本退出/信号处理 | `stop(C)`(策略停止时被调用) | stop 中交易连接已断,不能报撤单 |
|
||||
|
||||
## 2. 下单与撤单
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `order_stock(acc, code, order_type, vol, price_type, price, strategy, remark)` | `passorder(opType, 1101, account, code, prType, price, vol, strategy, 2, userOrderId, C)` | 同步/异步在内置端无区别,passorder 本身异步无返回值 |
|
||||
| `order_stock_async(...)` 返回 seq | 无返回值;用 `userOrderId`(→ 回报对象的 `m_strRemark`)追踪 | 见 SKILL.md 第4步状态机 |
|
||||
| `cancel_order_stock(acc, order_id)` | `cancel(orderSysId, account, accountType, C)` | 内置端撤单凭**柜台委托号** `m_strOrderSysID`(字符串),不是 xtquant 的内部 order_id |
|
||||
| `cancel_order_stock_sysid_async(acc, market, sysid)` | `cancel(sysid, account, accountType, C)` | 直接对应 |
|
||||
| 新股申购 `order_stock(..., xtconstant.STOCK_BUY, ...)` 对申购代码 | `passorder` + 专用 opType(申购相关枚举见 enum_constants.md) | `get_ipo_data()` 可取当日新股新债信息 |
|
||||
| ——(外接无算法单) | `algo_passorder(...)` / `smart_algo_passorder(...)` | 转换时可顺带升级:自研拆单可改用券商 VWAP/TWAP 算法(需权限) |
|
||||
|
||||
### opType(股票/两融常用)
|
||||
|
||||
| xtconstant | mini值 | 内置 opType | 说明 |
|
||||
|---|---|---|---|
|
||||
| `STOCK_BUY` | 23 | `23` | 股票/ETF/可转债买入 |
|
||||
| `STOCK_SELL` | 24 | `24` | 股票/ETF/可转债卖出 |
|
||||
| `CREDIT_BUY`(担保品买入) | **23** | `33` | **数值不同源!** mini 的 CREDIT_BUY 实际值=23(与 STOCK_BUY 相同,靠 StockAccount 类型区分);内置端信用账户必须显式用 33 |
|
||||
| `CREDIT_SELL`(担保品卖出) | **24** | `34` | 同上,内置端必须显式用 34 |
|
||||
| `CREDIT_FIN_BUY` | 27 | `27` | 融资买入(两端数值一致) |
|
||||
| `CREDIT_SLO_SELL` | 28 | `28` | 融券卖出 |
|
||||
| `CREDIT_BUY_SECU_REPAY` | 29 | `29` | 买券还券 |
|
||||
| `CREDIT_DIRECT_SECU_REPAY` | 30 | `30` | 直接还券 |
|
||||
| `CREDIT_SELL_SECU_REPAY` | 31 | `31` | 卖券还款 |
|
||||
| `CREDIT_DIRECT_CASH_REPAY` | 32 | `32` | 直接还款 |
|
||||
|
||||
**两融账户转换陷阱**:mini 策略里写 `xtconstant.CREDIT_BUY` 或对信用账户写 `STOCK_BUY`,源码里看到的都是 23——转换到内置端时**不能照抄 23**,必须按账户类型换成 33/34(担保品买卖),否则部分柜台拒单。融资融券专项操作 27~32 两端数值一致可直抄。
|
||||
|
||||
### prType(价格类型)
|
||||
|
||||
| xtconstant | 值 | 内置 prType | 说明 |
|
||||
|---|---|---|---|
|
||||
| `FIX_PRICE` | 11 | `11` | 指定价(最常用),price 参数生效 |
|
||||
| `LATEST_PRICE` | 5 | `5` | 最新价,price 填任意占位数 |
|
||||
| 卖5~卖1价 | — | `0`~`4` | 对手方向盘口价 |
|
||||
| 买1~买5价 | — | `6`~`10` | 本方向盘口价 |
|
||||
| `MARKET_SH_CONVERT_5_CANCEL` | 42 | `42` | 沪最优五档即成剩撤(仿真柜台不支持市价类) |
|
||||
| `MARKET_SH_CONVERT_5_LIMIT` | 43 | `43` | 沪五档剩转限价 |
|
||||
| `MARKET_PEER_PRICE_FIRST` | 44 | `44` | 对手方最优 |
|
||||
| `MARKET_MINE_PRICE_FIRST` | 45 | `45` | 本方最优 |
|
||||
| `MARKET_SZ_INSTBUSI_RESTCANCEL` | 46 | `46` | 深即成剩撤 |
|
||||
| `MARKET_SZ_CONVERT_5_CANCEL` | 47 | `47` | 深五档即成剩撤 |
|
||||
| `MARKET_SZ_FULL_OR_CANCEL` | 48 | `48` | 深全额成交或撤 |
|
||||
| 盘后定价 | — | `49` | 科创/创业盘后固定价 |
|
||||
| 对手价 | — | `14` | 对方一档 |
|
||||
| 挂单价 | — | `13` | 本方一档 |
|
||||
|
||||
### orderType 第二参数(单股)
|
||||
|
||||
固定用 `1101`(单股单账号按股数)。按金额下单用 `1102`(volume 单位变为元)、按比例 `1103`(%)。账号组 `1201/1202/1203`(极少用,多账户场景见 constraints.md)。
|
||||
|
||||
### quickTrade 第九参数
|
||||
|
||||
定时器回调/行情回调/after_init 中调用 → **必须 `2`**。仅 handlebar 中希望模拟K线收线信号 → `0`。`1` = 仅最新K线触发。
|
||||
|
||||
## 3. 查询
|
||||
|
||||
| miniQMT | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `query_stock_asset(acc)` | `get_trade_detail_data(account, accountType, 'account')` | 返回 list(取 `[0]`) |
|
||||
| `query_stock_positions(acc)` | `get_trade_detail_data(account, accountType, 'position')` | |
|
||||
| `query_stock_orders(acc, cancelable_only)` | `get_trade_detail_data(account, accountType, 'order')` | 无 cancelable_only 参数,自行按状态过滤 |
|
||||
| `query_stock_trades(acc)` | `get_trade_detail_data(account, accountType, 'deal')` | |
|
||||
| 按策略过滤 | `get_trade_detail_data(account, accountType, 'order', strategyName)` | 第4参数过滤 passorder 的 strategyName |
|
||||
| ——(无) | `get_last_order_id(account, accountType, 'order'[, strategyName])` | 最新委托号,找不到返回 `'-1'` |
|
||||
| ——(无) | `get_value_by_order_id(sysid, account, accountType, 'order'/'deal')` | 按委托号取单笔对象 |
|
||||
| `query_credit_detail(acc)` | `get_trade_detail_data(account, 'CREDIT', 'account')` | 信用账号对象,字段见 data_structure.md |
|
||||
| `query_stock_orders` 历史 | `get_history_trade_detail_data(account, type, 'ORDER', '20240101', '20240131')` | 内置端可查历史明细(外接查不到隔日) |
|
||||
| ——(无) | `query_credit_account(seq, C)` + `credit_account_callback` | 查柜台两融明细(异步回调) |
|
||||
| 两融标的 | `get_assure_contract(accid)` / `get_enable_short_contract(accid)` | 担保品/可融券明细 |
|
||||
|
||||
### 查询对象字段映射(高频)
|
||||
|
||||
| 外接字段(XtAsset/XtOrder/XtPosition/XtTrade) | 内置字段(m_ 前缀) |
|
||||
|---|---|
|
||||
| `asset.cash` | `acc.m_dAvailable` |
|
||||
| `asset.total_asset` | `acc.m_dBalance` |
|
||||
| `asset.market_value` | `acc.m_dInstrumentValue`(或 `m_dStockValue`) |
|
||||
| `asset.frozen_cash` | `acc.m_dFrozenCash` |
|
||||
| `order.stock_code`('600000.SH') | 拼接:`o.m_strInstrumentID + '.' + o.m_strExchangeID` |
|
||||
| `order.order_id`(int,本地) | 无对应;以 `o.m_strOrderSysID`(柜台委托号)为准 |
|
||||
| `order.order_sysid` | `o.m_strOrderSysID` |
|
||||
| `order.order_status` | `o.m_nOrderStatus`(状态码数值同一套) |
|
||||
| `order.order_volume` | `o.m_nVolumeTotalOriginal` |
|
||||
| `order.traded_volume` | `o.m_nVolumeTraded` |
|
||||
| `order.traded_price` | `o.m_dTradedPrice` |
|
||||
| `order.price` | `o.m_dLimitPrice` |
|
||||
| `order.order_type`(23买/24卖) | `o.m_nOpType`(23/24/33/34...);方向也可用 `m_nOffsetFlag`(48买/49卖) |
|
||||
| `order.order_remark` | `o.m_strRemark`(= passorder 的 userOrderId) |
|
||||
| `order.strategy_name` | `o.m_strSource` 或按 strategyName 过滤查询 |
|
||||
| `order.order_time`(时间戳) | `o.m_strInsertTime`('091259' 字符串)+ `m_strInsertDate` |
|
||||
| `position.stock_code` | 拼接:`p.m_strInstrumentID + '.' + p.m_strExchangeID` |
|
||||
| `position.volume` | `p.m_nVolume` |
|
||||
| `position.can_use_volume` | `p.m_nCanUseVolume` |
|
||||
| `position.market_value` | `p.m_dInstrumentValue`(或 `m_dMarketValue`) |
|
||||
| `position.avg_price` | `p.m_dOpenPrice`(或 `m_dAvgOpenPrice`/`m_dPositionCost` 成本额) |
|
||||
| `position.on_road_volume` | `p.m_nOnRoadVolume` |
|
||||
| `trade.traded_price` | `d.m_dPrice` |
|
||||
| `trade.traded_volume` | `d.m_nVolume` |
|
||||
| `trade.traded_amount` | `d.m_dTradeAmount` |
|
||||
| `trade.traded_time` | `d.m_strTradeTime`('172341')+ `m_strTradeDate` |
|
||||
| `trade.order_sysid` | `d.m_strOrderSysID`(与委托表同号,用于关联) |
|
||||
|
||||
### 委托状态码(两边同一套数值,已实测核对 xtconstant 与 inner 枚举一致)
|
||||
|
||||
48未报 / 49待报 / 50已报 / 51已报待撤 / 52部成待撤 / 53部撤 / 54已撤 / 55部成 / 56已成 / 57废单 / 255未知(86 为 mini 侧扩展值,inner 文档未列,判活集合保留无害)。
|
||||
在途判活集合:`(48, 49, 50, 51, 52, 55, 86, 255)`;终态:`(53, 54, 56, 57)`。
|
||||
|
||||
**跨客户端可见性(实测结论,重要)**:委托本身是柜台级共享——A 客户端下的单,B 客户端(或 miniQMT)能查到同一 `sysid` 和状态;但 `m_strRemark`(投资备注/userOrderId)、`strategyName`、mini 的 `order_id` 都**只在下单客户端本地可见**(他端查询 remark 为空、order_id 为 0)。因此:同客户端对账用 userOrderId,跨客户端对账只能凭柜台委托号 sysid。
|
||||
|
||||
## 4. 回调
|
||||
|
||||
| miniQMT(XtQuantTraderCallback 方法) | 大QMT内置(模块级函数,需先 `C.set_account(account)`) |
|
||||
|---|---|
|
||||
| `on_stock_order(self, order)` | `def order_callback(ContextInfo, orderInfo):`(orderInfo 为 m_ 字段对象) |
|
||||
| `on_stock_trade(self, trade)` | `def deal_callback(ContextInfo, dealInfo):` |
|
||||
| `on_stock_position(self, position)` | `def position_callback(ContextInfo, positionInfo):` |
|
||||
| `on_stock_asset(self, asset)` | `def account_callback(ContextInfo, accountInfo):` |
|
||||
| `on_order_error(self, err)` | `def orderError_callback(ContextInfo, orderArgs, errMsg):` |
|
||||
| `on_cancel_error(self, err)` | ——(无独立撤单失败回调;轮询委托状态兜底) |
|
||||
| `on_order_stock_async_response(self, resp)` | ——(passorder 无下单应答;靠 order_callback 首次推送确认) |
|
||||
| `on_disconnected(self)` | ——(删除;客户端自管重连。交易日切换时策略会被自动重启,属正常) |
|
||||
|
||||
注意:内置回调**仅实盘运行模式生效**(模拟信号模式不触发),且与策略同线程——回调里不要做耗时操作。
|
||||
|
||||
## 5. 行情
|
||||
|
||||
| miniQMT (xtdata) | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `get_full_tick(codes)` | `C.get_full_tick(codes)` | 返回结构同(lastPrice/askPrice[5]/bidPrice[5]/lastClose/volume...) |
|
||||
| `get_instrument_detail(code)` | `C.get_instrument_detail(code[, iscomplete])` | 字段同名(InstrumentName/PreClose/UpStopPrice/DownStopPrice/PriceTick...) |
|
||||
| `get_market_data_ex(fields, codes, period, start, end, count, dividend_type, fill_data)` | `C.get_market_data_ex(fields, codes, period, start, end, count, dividend_type, fill_data, subscribe)` | 参数同构;**不要在 init 里调**(只能取到本地数据);`subscribe=False` 时只读本地 |
|
||||
| `get_local_data(...)` | `C.get_market_data_ex(..., subscribe=False)` | 内置的 get_local_data 已不推荐 |
|
||||
| `subscribe_quote(code, period, count, callback)` | `C.subscribe_quote(code, period='1d', dividend_type, result_type, callback)` | 返回订阅号;非VIP有订阅数限制 |
|
||||
| `subscribe_whole_quote(markets, callback)` | `C.subscribe_whole_quote(codes, callback)` | 全推快照 |
|
||||
| `unsubscribe_quote(seq)` | `C.unsubscribe_quote(subID)` | |
|
||||
| `get_trading_dates('SH', start, end)` 返回**毫秒时间戳列表** | `C.get_trading_dates('000001.SH', start, end, count, '1d')` 返回**'YYYYMMDD'字符串列表** | 必改:删掉时间戳转换代码;仅 after_init 之后可用 |
|
||||
| `download_history_data(code, period, start, end)` | `download_history_data(code, period, start, end[, incrementally])` | 全局函数同名直用 |
|
||||
| `download_history_data2(codes, period, start, end, callback)` | 循环调 `download_history_data` | 内置无批量带进度版本 |
|
||||
| `get_stock_list_in_sector(name)` | `C.get_stock_list_in_sector(name)` | |
|
||||
| `get_financial_data(...)` | `C.get_financial_data(fieldList, codes, start, end, report_type)` | 签名有差异,查 data_function.md |
|
||||
| `get_divid_factors(code)` | `C.get_divid_factors(code)` | |
|
||||
| `get_main_contract(code)` | `C.get_main_contract(code)` | 期货 |
|
||||
| `xtdata.run()` | ——(删除) | 内置框架自带事件循环 |
|
||||
|
||||
## 6. 调度/定时
|
||||
|
||||
定时器与周期无关:主图周期选日线,`run_time` 照样按设定间隔跑(最短毫秒级)。但 **`run_time`/`schedule_run` 在回测模式无效**——需要回测的策略要把信号逻辑抽成独立函数,回测挂 `handlebar`、实盘挂定时器(见 faq.md Q4)。
|
||||
|
||||
| miniQMT 模式 | 大QMT内置 | 备注 |
|
||||
|---|---|---|
|
||||
| `while True: ... time.sleep(n)` | `C.run_time("f", "{n}nSecond", "2025-01-01 09:30:00")` | 函数名传**字符串**;起始时间设过去则立即生效 |
|
||||
| apscheduler `interval` 任务 | `C.run_time("f", "3nSecond", ...)` 或 `C.schedule_run(f, '20250101093000', -1, dt.timedelta(seconds=3), 'grp')` | schedule_run 传函数对象,可取消(`C.cancel_schedule_run('grp')`) |
|
||||
| apscheduler `cron`/`date` 定点任务(如 09:25:30 开盘买入) | 秒级定时器内判时间窗 + 当日执行标志位 | 见 template_timer.py 的 `_in_window`/`G.done_flags` 模式 |
|
||||
| 毫秒级轮询 | `"500nMilliSecond"` | 留意性能,所有策略共线程 |
|
||||
| 每日重置状态 | 定时器回调里检测日期变化后重置 G | 交易日切换时策略也会被客户端重启(init 重跑),状态需可重建(见第4步状态机+可选落盘) |
|
||||
|
||||
## 7. 删除/禁用清单
|
||||
|
||||
转换时直接删除,不要带入:
|
||||
- `from xtquant import ...` 全部 import
|
||||
- `XtQuantTrader`/`XtQuantTraderCallback` 类与连接管理
|
||||
- `AutoLogin`、`os.startfile` 重启 QMT、看门狗
|
||||
- `threading`/`multiprocessing`/`asyncio`/`apscheduler`
|
||||
- `time.sleep`(任何等待逻辑改状态机)
|
||||
- `input()`、GUI、命令行参数解析
|
||||
@@ -0,0 +1,114 @@
|
||||
# 限制清单与不可转场景判定
|
||||
|
||||
每条给出:判定方法 → 影响 → 处理方案。"文件桥方案"指一种通用兜底架构:策略主体留在外部 Python 进程(任意 Python 版本、任意依赖),大QMT内只跑一个轻量桥脚本,两边通过共享目录的 JSON 文件交换指令/状态/行情。落地步骤见 D 节。
|
||||
|
||||
## A. 硬性环境限制(所有策略都受约束)
|
||||
|
||||
### A1. Python 3.6 语法上限
|
||||
**判定**:analyze_strategy.py 会扫描。常见违例:walrus `:=`(3.8)、f-string `{x=}`(3.8)、`dataclasses`(3.7)、`asyncio.run`(3.7)、位置仅参数 `/`(3.8)、`match`(3.10)、`dict |` 合并(3.9)、`functools.cached_property`(3.8)。
|
||||
**处理**:等价改写(walrus 拆两行、dataclass 改普通类、cached_property 改手工缓存)。f-string 本身 3.6 支持,可保留。
|
||||
|
||||
### A2. GBK 编码
|
||||
**判定**:源码含 emoji、生僻字、特殊符号时 GBK 编不出去(check_converted.py 会报)。
|
||||
**处理**:替换为 GBK 兼容字符;文件必须以 GBK 落盘且首行 `#coding:gbk`。**用 scripts/to_gbk.py 转存,不要用编辑器直接改 GBK 文件**(极易产生 mojibake)。读写外部文件时显式指定 `encoding`,py3.6 在 GBK 环境下 `open()` 默认 GBK。
|
||||
|
||||
### A3. 第三方库受限
|
||||
**判定**:analyze_strategy.py 列出非标准库 import。
|
||||
内置自带:**NumPy / Pandas / SciPy / Statsmodels / Patsy / TA_Lib**(版本旧,pandas 是 0.x~1.0 时代,无 `df.itertuples` 新参数等高版本特性,`pd.append` 可用)。
|
||||
**处理**:
|
||||
- `requests` 等纯 Python 库:多数客户端可用;若报 `Module xxx not in whitelist!` → 券商开了白名单,找券商开通。
|
||||
- 自装库:本机装 Python 3.6 到 `C:\Python36`,pip 装 **py3.6 兼容版本**,客户端"设置-模型设置"指向该环境(详见迅投官方 [常见问题](https://dict.thinktrader.net/innerApi/question_answer.html) 的第三方库导入指引)。
|
||||
- torch/tensorflow/akshare 等重型或不兼容 py3.6 的库:**不可转** → 方案①模型推理留在外部进程算好信号,落地文件/HTTP,内置端只读信号执行交易;方案②整体走文件桥。
|
||||
|
||||
### A4. 单线程禁阻塞
|
||||
**判定**:threading/multiprocessing/asyncio import、`time.sleep`、阻塞 IO 重试循环、`while True`。
|
||||
**影响**:客户端所有策略共用一个 Python 线程,阻塞会卡死全部策略(包括别的策略)。
|
||||
**处理**:sleep 等待→状态机+下轮定时器检查;并行计算→不可转(外部算好喂进来);网络请求设短超时且容忍失败。
|
||||
|
||||
### A5. ContextInfo 变量回滚
|
||||
**判定**:原策略若把状态存 self/全局,转换时有人习惯写 `C.xxx = ...` —— 禁止。
|
||||
**影响**:ContextInfo 随 K线深拷贝回滚,盘中存的状态会丢,且拖慢运行。
|
||||
**处理**:所有可变状态放模块级 `class G: pass; G = G()` 实例(模板已内置)。
|
||||
|
||||
## B. 架构性差异(需要重构的场景)
|
||||
|
||||
### B1. 多账户单进程
|
||||
**判定**:代码里多个 `StockAccount` / 账户列表循环下单。
|
||||
**影响**:内置策略一个实例绑一个账户(界面选定)。
|
||||
**处理**:
|
||||
- 账户数少:每个账户建一个策略交易实例(同一份代码,界面分别选账户)。代码里不要写死账户,全用注入的 `account`/`accountType`。
|
||||
- 需要跨账户协同(资金调度/对冲腿):**不可转** → 文件桥方案,外部进程统一调度多个客户端。
|
||||
- 同券商账号组(passorder 1201/1202):仅当账户都在同一客户端登录时可用,且为"对组内每户做同样操作",不支持差异化分配。
|
||||
|
||||
### B2. 跨券商/多客户端
|
||||
**判定**:多个 QMT path、多 session。
|
||||
**处理**:**不可转**(一个内置策略只活在一个客户端里)→ 每客户端部署各自内置策略(互相独立),或文件桥统一调度。
|
||||
|
||||
### B3. 7x24 守护/盘后任务
|
||||
**判定**:apscheduler 配置了夜间任务、开机自启动逻辑、AutoLogin。
|
||||
**影响**:内置策略只在客户端运行期间活着;客户端通常夜间关闭/清算期掉线。
|
||||
**处理**:盘中逻辑转内置;盘后选股/数据下载留外部脚本(Windows 计划任务),结果以文件(如 csv 票池)喂给内置策略读取——常见的"URL/文件票池"模式即属此类,保留即可(改为本地路径或确认客户端能访问该 URL)。
|
||||
|
||||
### B4. 委托回报驱动的复杂状态机
|
||||
**判定**:`on_order_stock_async_response` 用 seq 关联、回报里立刻连锁下单。
|
||||
**影响**:passorder 无 seq;回报推送只在实盘模式有效且与策略同线程。
|
||||
**处理**:改 userOrderId(投资备注)关联 + order_callback/轮询双轨对账(模板已含)。连锁下单逻辑放回调里可行但要轻量;稳妥做法是回调只改状态,统一由定时器主循环决策下单。
|
||||
|
||||
### B5. Level-2 / 高频依赖
|
||||
**判定**:`get_l2_quote`、逐笔委托/成交、500ms 以内轮询。
|
||||
**处理**:内置端有 l2 周期(`l2quote`/`l2order`/`l2transaction`,需账号有 L2 权限);定时器最细 `nMilliSecond` 级。但所有策略共线程,高频策略相互挤占,延迟敏感型(>1次/秒决策、微秒级要求)**不建议转** → 评估后保留外接或文件桥+外部高性能进程。
|
||||
|
||||
### B6. 行情源时效与覆盖
|
||||
内置行情=客户端行情:非 VIP 用户 `subscribe_quote` 有订阅数量限制;`get_full_tick` 不限。跨市场数据(港股通标的行情等)取决于客户端行情权限。原策略若依赖 xtdata VIP 全推,转换后用 `C.get_full_tick(批量列表)` + 秒级定时器近似。
|
||||
|
||||
## C. 业务行为差异(容易踩坑)
|
||||
|
||||
| # | 差异 | 应对 |
|
||||
|---|---|---|
|
||||
| C1 | `get_trade_detail_data` 读本地缓存(柜台推送 50ms~6s 刷新),下单后立查查不到 | 不要"下单→sleep→查";按状态机轮询,同标的有待报单时禁止加单 |
|
||||
| C2 | 交易日切换/行情重连时客户端会**自动重启所有运行中策略**(init 重跑) | init 必须幂等;持久状态可落盘 JSON(绝对路径),init 时恢复;当日已执行标志要带日期 |
|
||||
| C3 | 模拟信号模式 passorder 不实际下单、回调不触发 | 验证流程先模拟看信号,再实盘小单 |
|
||||
| C4 | handlebar 盘中每个主图 tick 都触发(不分周期) | 定时器型策略 handlebar 留空直接 return;K线型用 `C.is_last_bar()`/`is_new_bar()` 过滤 |
|
||||
| C5 | 非交易时间 handlebar 也可能被调用 | 交易逻辑内判时间窗(09:30~14:57) |
|
||||
| C6 | `get_trading_dates` init 中不可用 | 放 after_init;返回格式为 'YYYYMMDD' 字符串 |
|
||||
| C7 | 委托数量规则(科创板 200 股起 1 股递增等)与外接一致,但市价单类型仿真柜台不支持 | 仿真测试用限价 11;实盘再放开市价类 prType |
|
||||
| C8 | strategyName、userOrderId(m_strRemark)、mini 的 order_id 都只在**下单客户端**本地可见(实测:他端查 remark 为空、order_id 为 0);委托本身柜台级共享 | 同客户端对账用 userOrderId;跨客户端只能凭柜台委托号 sysid |
|
||||
| C9 | print 进策略日志面板,量大会卡界面 | 控制日志频率;详细日志写文件 |
|
||||
| C10 | 盘后撤单窗口受柜台限制(实测:17 点后 cancel 信号发出成功但柜台不处理,委托保持已报) | 测试报/撤安排在交易时段或收盘后半小时内;策略收盘前应撤清在途单 |
|
||||
| C11 | 市值类字段(m_dInstrumentValue 等)按各客户端自己的行情快照计算,跨客户端可能不一致 | 资金对账以 cash/volume 为准(实测两端精确一致),市值仅作展示 |
|
||||
| C12 | `run_time`/`schedule_run` 回测模式无效,定时器型策略无法直接回测 | 信号逻辑抽独立函数,回测挂 handlebar、实盘挂定时器,一份逻辑两个入口(faq.md Q4) |
|
||||
| C13 | 内置端发网络请求会阻塞共享线程 | 仅限低频(每日级),超时 ≤2 秒 + try/except 降级;高频外部数据一律走文件通道(faq.md Q1) |
|
||||
|
||||
## D. 完全不可转换 → 直接给文件桥方案
|
||||
|
||||
满足任一条即建议放弃纯内置转换,采用文件桥(策略零改动):
|
||||
1. 重型 ML 推理/重度第三方依赖且无法降级 py3.6
|
||||
2. 跨账户、跨客户端、跨券商统一调度
|
||||
3. 策略与 Web 服务/数据库/消息队列深度耦合
|
||||
4. 需要外部进程级容灾(策略进程独立于客户端存活)
|
||||
|
||||
文件桥落地步骤(自行实现一个桥脚本,约 200~300 行):
|
||||
1. 在大QMT新建一个内置策略作为"桥":用 template_timer.py 骨架,`run_time` 1秒循环;指定一个共享目录 `BRIDGE_DIR`
|
||||
2. 桥脚本每轮做两件事:扫描 `BRIDGE_DIR/cmd/*.json` 指令文件(含 buy/sell/cancel 及参数)→ 调 `passorder`/`cancel` 执行后删除指令文件;把 `get_trade_detail_data` 的委托/持仓/资产 + `get_full_tick` 行情序列化写入 `BRIDGE_DIR/state/orders|positions|asset|quotes.json`,并每秒刷新 `heartbeat.json`(时间戳)供外部判活
|
||||
3. 外部策略把原 xtquant 调用替换为读写桥目录 JSON:下单=写指令文件,查询=读状态文件,并校验心跳新鲜度
|
||||
4. 注意原子写(先写临时文件再 rename)、GBK/UTF-8 编码显式声明、指令文件带唯一序号防重放
|
||||
5. 该模式已在实盘(含两融账户)验证过报/撤单与行情回传链路可行
|
||||
|
||||
## 实测验证记录(国金模拟 mini + 大QMT 双端同账户交叉验证)
|
||||
|
||||
以下断言已实弹核验,可直接信赖:
|
||||
- xtconstant 15 项常量(买卖 23/24、FIX_PRICE=11、LATEST_PRICE=5、委托状态 48~57/255)与映射表一致
|
||||
- `xc.CREDIT_BUY/CREDIT_SELL` 实际值 = 23/24(与 STOCK_BUY 同值)→ 印证两融转换必须显式改 33/34
|
||||
- 大QMT内置 `passorder`(11参/prType=11/quickTrade=2/userOrderId)→ `get_trade_detail_data('order')` 按 `m_strRemark` 命中,`m_strOrderSysID` 回传,状态 50已报 → `cancel(sysid)` 信号发出成功
|
||||
- 同账户跨客户端:mini 可见大QMT 所下委托(同 sysid 同状态),但 remark 为空、order_id 为 0 → C8 结论
|
||||
- 资产/持仓字段两端精确一致:`cash↔m_dAvailable`、`total_asset↔m_dBalance`、`volume↔m_nVolume`、`can_use_volume↔m_nCanUseVolume`、`avg_price↔m_dOpenPrice`;市值字段两端不一致(各自行情快照)→ C11 结论
|
||||
- 17 点后柜台不再处理撤单(15:38 同流程撤单成功)→ C10 结论
|
||||
|
||||
## E. 部署细节(转换完成后)
|
||||
|
||||
1. **新建策略**:大QMT → 模型/策略 → 新建Python策略 → 粘贴 GBK 代码 → 保存编译(看输出面板无报错、中文无乱码)
|
||||
2. **新建策略交易**:选模型 + 资金账号(类型务必选对 STOCK/CREDIT)+ 周期(定时器型选日线最省)+ 主图代码任意(如 000001.SH)
|
||||
3. **运行模式**:模拟信号 → 观察 ≥1 个时段 → 实盘交易
|
||||
4. **自启链路**(生产必配):客户端设置开机自启与自动登录(券商版路径各异)→ 策略勾选"终端启动后自动运行" → Windows 计划任务登录时启动客户端 exe
|
||||
5. **实盘首测**:不易成交价小单(买跌停价/卖涨停价)→ 确认委托面板可见、来源=策略名、备注=userOrderId → cancel 撤掉 → 查 `get_trade_detail_data` 状态为 54
|
||||
6. **回滚预案**:策略交易界面一键停止;停止前手动撤清在途单(stop 回调里不能撤单)
|
||||
@@ -0,0 +1,212 @@
|
||||
# 转换实例:典型 apscheduler+xtquant 实盘策略
|
||||
|
||||
以一个典型的 miniQMT 实盘策略(约700行:AutoLogin + apscheduler 定点/间隔任务 + 异步下单 + 回调对账 + Excel 持仓记录)为例,演示各环节的转换前后对照。这类结构覆盖了 miniQMT 策略的绝大多数典型模式,可直接套用到你自己的策略上。
|
||||
|
||||
## 分析结论(第1步输出节选)
|
||||
|
||||
- 依赖:`xtquant`(映射)、`apscheduler`(重构)、`AutoLogin`(删除)、`pandas`(自带,旧版)、`dateutil`(标准库附带)
|
||||
- 模式:单账户、定点任务 x5 + 3秒间隔任务 x2、`while True` 等待查询、`time.sleep` 若干 → **结论 B:可转换,选 template_timer.py**
|
||||
- 外部资源:`pd.read_csv(URL票池)`、Excel 读写 —— 客户端内可用(pandas 自带),URL 访问若被白名单拦截则改为外部脚本下载到本地、策略读本地文件
|
||||
|
||||
## 1. 入口与连接 → init/after_init
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
xt_trader = XtQuantTrader(qmt_program_path, session_id)
|
||||
account = StockAccount(account_no, account_type)
|
||||
callback = MyXtQuantTraderCallback()
|
||||
xt_trader.register_callback(callback)
|
||||
xt_trader.start()
|
||||
connect_result = xt_trader.connect()
|
||||
subscribe_result = xt_trader.subscribe(account)
|
||||
```
|
||||
|
||||
转换后(连接管理整体删除;account 由界面注入):
|
||||
|
||||
```python
|
||||
def init(C):
|
||||
C.set_account(account) # 替代 register_callback + subscribe
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
C.run_time('main_loop', '3nSecond', '2025-01-01 09:30:00')
|
||||
```
|
||||
|
||||
## 2. apscheduler 任务编排 → 定时器+时间窗
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
scheduler.add_job(day1_buy, trigger='interval', hours=24, start_date=A.today+' 09:25:30', ...)
|
||||
scheduler.add_job(day2_buy_sell, trigger='cron', second='*/3', hour='9-14', ...)
|
||||
scheduler.add_job(save_records, trigger='interval', hours=24, start_date=A.today+' 15:03:00', ...)
|
||||
```
|
||||
|
||||
转换后(一个3秒主循环统一调度,定点任务用时间窗+当日标志):
|
||||
|
||||
```python
|
||||
def main_loop(C):
|
||||
now = time.strftime('%H:%M:%S')
|
||||
today = time.strftime('%Y%m%d')
|
||||
if G.day != today:
|
||||
G.day = today; G.done_flags = {} # 跨天重置
|
||||
|
||||
sync_orders(C)
|
||||
|
||||
if '09:25:30' <= now <= '09:26:30' and not G.done_flags.get('day1_buy'):
|
||||
G.done_flags['day1_buy'] = True
|
||||
day1_buy(C)
|
||||
if '09:30:06' <= now <= '09:31:06' and not G.done_flags.get('day1_plus'):
|
||||
G.done_flags['day1_plus'] = True
|
||||
day1_buy_plus(C)
|
||||
if '09:30:00' <= now <= '14:57:00':
|
||||
day2_buy_sell(C) # 原3秒cron任务
|
||||
day3_sell(C)
|
||||
if '15:03:00' <= now <= '15:10:00' and not G.done_flags.get('save'):
|
||||
G.done_flags['save'] = True
|
||||
save_records(C)
|
||||
```
|
||||
|
||||
注意:原策略用 `09:05` 定点任务做 AutoLogin 重启 QMT —— 整段删除(constraints.md B3),客户端自动登录在客户端设置里配置。
|
||||
|
||||
## 3. 异步下单 → passorder + userOrderId
|
||||
|
||||
转换前:
|
||||
|
||||
```python
|
||||
async_seq = xt_trader.order_stock_async(
|
||||
account, stock_code, xtconstant.STOCK_BUY, int(stk_vol),
|
||||
xtconstant.FIX_PRICE, trade_price, 'day1_buy', '')
|
||||
```
|
||||
|
||||
转换后(无返回值;备注即追踪键;定时器内调用 quickTrade=2):
|
||||
|
||||
```python
|
||||
G.seq += 1
|
||||
uid = 'day1_buy_%s_%d' % (G.day, G.seq)
|
||||
passorder(G.op_buy, 1101, G.acct, stock_code, 11, float(trade_price),
|
||||
int(stk_vol), 'day1_buy', 2, uid, C)
|
||||
G.pending[uid] = {'code': stock_code, 'status': 'alive', 'sysid': '', 'ts': time.time()}
|
||||
```
|
||||
|
||||
## 4. 撤单逻辑 → 委托号撤单
|
||||
|
||||
转换前(内部 order_id + 撤后 sleep 重查):
|
||||
|
||||
```python
|
||||
for i in orders:
|
||||
if ... and i.order_type == 23 and i.order_status in [48,49,50,51,52,55,86,255]:
|
||||
cancel_result = xt_trader.cancel_order_stock_async(account, i.order_id)
|
||||
time.sleep(0.5)
|
||||
orders = xt_trader.query_stock_orders(account) # 重查确认
|
||||
```
|
||||
|
||||
转换后(m_ 字段 + 柜台委托号;不 sleep,下一轮自然对账):
|
||||
|
||||
```python
|
||||
def cancel_stale_buys(C):
|
||||
alive = (48, 49, 50, 51, 52, 55, 86, 255)
|
||||
for o in get_trade_detail_data(G.acct, G.acct_type, 'order'):
|
||||
if int(o.m_nOpType) == G.op_buy \
|
||||
and int(o.m_nVolumeTotalOriginal) != int(o.m_nVolumeTraded) \
|
||||
and int(o.m_nOrderStatus) in alive \
|
||||
and _order_age_seconds(o) > 3:
|
||||
cancel(str(o.m_strOrderSysID), G.acct, G.acct_type, C)
|
||||
# 撤单结果不立即确认:50ms~6s 后缓存刷新,由下一轮 sync_orders 看到 53/54
|
||||
|
||||
def _order_age_seconds(o):
|
||||
t = o.m_strInsertTime # '091259'
|
||||
now = time.strftime('%H%M%S')
|
||||
return (int(now[:2])*3600 + int(now[2:4])*60 + int(now[4:])) - \
|
||||
(int(t[:2])*3600 + int(t[2:4])*60 + int(t[4:]))
|
||||
```
|
||||
|
||||
## 5. 查询封装 info_query → m_ 字段直读
|
||||
|
||||
转换前(XtPosition 无前缀字段 + xtdata 合约详情 + `while True` 等数据齐):
|
||||
|
||||
```python
|
||||
positions = xt_trader.query_stock_positions(account)
|
||||
for i in positions:
|
||||
if i.volume == 0: continue
|
||||
abc = xtdata.get_instrument_detail(i.stock_code)
|
||||
...[i.stock_code, abc['InstrumentName'], abc['UpStopPrice'], ..., i.can_use_volume, ...]
|
||||
while True:
|
||||
asset = xt_trader.query_stock_asset(account)
|
||||
...
|
||||
time.sleep(3)
|
||||
```
|
||||
|
||||
转换后(字段映射 + 删除 while 等待,查不到就本轮放弃):
|
||||
|
||||
```python
|
||||
def get_positions(C):
|
||||
out = []
|
||||
for p in get_trade_detail_data(G.acct, G.acct_type, 'position'):
|
||||
if int(p.m_nVolume) == 0:
|
||||
continue
|
||||
code = '%s.%s' % (p.m_strInstrumentID, p.m_strExchangeID)
|
||||
det = C.get_instrument_detail(code) or {}
|
||||
out.append({'code': code, 'name': det.get('InstrumentName', ''),
|
||||
'up': det.get('UpStopPrice'), 'down': det.get('DownStopPrice'),
|
||||
'volume': int(p.m_nVolume), 'can_use': int(p.m_nCanUseVolume),
|
||||
'mv': float(p.m_dInstrumentValue), 'avg': float(p.m_dOpenPrice)})
|
||||
return out
|
||||
```
|
||||
|
||||
原 `while True + sleep(3)` 等"委托成交数据对齐"的写法**必须删除**:单线程会卡死客户端全部策略。数据未齐=本轮 return,下轮重试。
|
||||
|
||||
## 6. 行情读取(几乎零成本迁移)
|
||||
|
||||
```python
|
||||
# 转换前
|
||||
full_tick = xtdata.get_full_tick([stock_code])
|
||||
price = full_tick[stock_code]['askPrice'][1]
|
||||
# 转换后(仅加 C. 前缀,返回结构一致)
|
||||
full_tick = C.get_full_tick([stock_code])
|
||||
price = full_tick[stock_code]['askPrice'][1]
|
||||
```
|
||||
|
||||
交易日历是例外,返回类型变了:
|
||||
|
||||
```python
|
||||
# 转换前:毫秒时间戳 → 自行转字符串
|
||||
trade_date = xtdata.get_trading_dates('SH', start_time='20240501', end_time=today)
|
||||
A.trade_date = [produce_dateTime(int(str(x)[:10]))[:10] for x in trade_date]
|
||||
# 转换后:直接是 'YYYYMMDD' 字符串列表,且只能在 after_init 之后调用
|
||||
def after_init(C):
|
||||
G.trade_dates = C.get_trading_dates('000001.SH', '20240501', '', 250, '1d')
|
||||
G.last_trade_date = G.trade_dates[-2]
|
||||
```
|
||||
|
||||
## 7. 回调类 → 模块级函数
|
||||
|
||||
```python
|
||||
# 转换前
|
||||
class MyXtQuantTraderCallback(XtQuantTraderCallback):
|
||||
def on_stock_trade(self, trade):
|
||||
print(trade.account_id, trade.stock_code, trade.traded_price, trade.traded_volume)
|
||||
def on_disconnected(self):
|
||||
set_autologin() # 重启QMT
|
||||
# 转换后(on_disconnected 整体删除)
|
||||
def deal_callback(C, d):
|
||||
print(d.m_strAccountID, d.m_strInstrumentID + '.' + d.m_strExchangeID,
|
||||
d.m_dPrice, d.m_nVolume)
|
||||
```
|
||||
|
||||
## 8. 外部数据与文件
|
||||
|
||||
- `pd.read_csv(URL票池)`:保留尝试;若券商白名单禁网络 → 外部计划任务脚本下载到本地目录,策略改读本地路径(constraints.md B3 模式)。外部取数脚本若还需要财务/资金流等 QMT 没有的维度,用一个聚合数据 API(如 [quantgo.ai/data](https://quantgo.ai/data),按月订阅不贵)比维护多个免费源省心
|
||||
- `持仓记录.xlsx`:pandas 旧版可读写 Excel,但建议改 JSON/CSV(避免 openpyxl 白名单问题);路径一律绝对路径
|
||||
- `RotatingFileHandler` 日志:可用;或直接 print 进策略日志面板
|
||||
|
||||
## 9. 校验与交付
|
||||
|
||||
```bash
|
||||
python scripts/check_converted.py converted_demo.py # 必须 PASS
|
||||
python scripts/to_gbk.py converted_demo.py demo_gbk.py # GBK 落盘
|
||||
```
|
||||
|
||||
部署按 SKILL.md 第7步:模拟信号跑一个时段比对原策略信号 → 实盘小额验证报/撤 → 正式切换。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 买方共性疑虑 FAQ(转换前先对齐认知)
|
||||
|
||||
迁移决策者最常见的 8 个疑虑,逐条给结论 + 依据。Agent 在用户对内置端能力存疑时,应优先引用本文对齐认知,再进入转换流程。
|
||||
|
||||
## Q1 大QMT只能在客户端里跑,外部数据是不是很难获取了?
|
||||
|
||||
**结论:误解。能获取,且有三条通道,按稳定性排序:**
|
||||
|
||||
1. **本地文件通道(推荐,零风险)**:外部进程用任意环境(py3.12、akshare、自建库均可)取数→落地 csv/json→内置策略只读本地文件。白名单管不到、单线程不阻塞、外部进程崩了策略只是用旧数据不会挂。URL 取票池这类模式即转换为此架构(examples.md 第8节)。
|
||||
2. **直接网络请求**:内置端是完整 Python 3.6,标准库 `urllib` 一般可用,`requests` 视券商白名单。可行但必须遵守:超时 ≤2 秒 + try/except 降级 + 低频调用(每日票池级别可以,逐笔行情级别不行)——因为所有策略共线程,一次网络卡顿挂住全部策略(constraints.md A4)。
|
||||
3. **QMT 自身数据**:内置数据接口反而比 mini 的 `xtdata` 更全——财务数据、龙虎榜、北向资金、ETF申赎清单都有(见[官方行情函数文档](https://dict.thinktrader.net/innerApi/data_function.html))。原策略从外部源取的数据,先查内置接口是否已覆盖。
|
||||
|
||||
**推荐架构**:"外算内执行"——重数据、重计算留在外部进程,内置端只读结果文件并执行交易。这正是 constraints.md D 节文件桥模式的单向简化版。
|
||||
|
||||
## Q2 大QMT策略是不是最短一分钟跑一次?能缩短到5秒吗?
|
||||
|
||||
**结论:误解。毫秒级都可以,5 秒轻松。** 三个驱动源:
|
||||
|
||||
| 驱动源 | 最短间隔 | 说明 |
|
||||
|---|---|---|
|
||||
| `C.run_time("f", "5nSecond", ...)` | **毫秒级**(`"500nMilliSecond"`) | 与主图 K 线周期完全无关——周期选日线照样每秒跑(例如 `1nSecond` 循环) |
|
||||
| `C.schedule_run(f, ..., timedelta(seconds=5), ...)` | 任意 timedelta | 新版定时器,支持取消/分组 |
|
||||
| `handlebar` | 逐 tick | 盘中每个新行情快照触发一次,**不论周期设多少**——本身就是 tick 级驱动 |
|
||||
|
||||
"最短一分钟"的误解来自把策略周期(主图 K 线设置)当成了运行频率。周期只影响 `handlebar` 的 K 线粒度,定时器独立于周期。
|
||||
|
||||
频率上限的真实约束不是框架而是**单线程预算**:所有策略共一个线程,每轮回调耗时应 <100ms(见 Q5)。
|
||||
|
||||
## Q3 mini 同步/异步下单都有,内置只有异步,同步逻辑怎么迁?
|
||||
|
||||
`order_stock`(同步返回 order_id)在内置端无直接等价物——`passorder` 一律异步且无返回值。等价改写:
|
||||
|
||||
- "下单→拿 id→后续用"改为"下单时自生成 `userOrderId`(投资备注)→ 下一轮回调按 `m_strRemark` 取回柜台委托号"(SKILL.md 第4步状态机,模板已内置)。实测下单后约 1 秒内 `get_trade_detail_data('order')` 可查到。
|
||||
- "下单→等成交→再下一笔"的串行逻辑改为状态机推进:本轮发单,下轮看到成交状态再发下一笔。**不允许** sleep 等待(会卡死全部策略)。
|
||||
|
||||
延迟代价:决策到确认多 1~2 秒。对秒级以上的策略无感;对延迟敏感策略见 constraints.md B5。
|
||||
|
||||
## Q4 转换后还能回测吗?
|
||||
|
||||
**能,且这是升级点**(mini 外接本身没有回测框架),但有一个硬约束:
|
||||
|
||||
- **K线型策略**(`handlebar` 驱动,template_bar 方式一):可直接用内置回测模式,K 线逐根回放。
|
||||
- **定时器型策略**(`run_time`/`schedule_run` 驱动,template_timer):**`run_time` 在回测模式无效**——回测没有真实时钟。需要回测时,把核心信号逻辑抽成独立函数,回测时挂 `handlebar` 调用、实盘时挂定时器调用,一份逻辑两个入口。
|
||||
- 回测推荐等比前复权(`dividend_type='front_ratio'`),交易回调(order_callback 等)回测时不触发。
|
||||
|
||||
## Q5 多个策略同时跑会互相拖累吗?
|
||||
|
||||
会,这是内置端最重要的工程约束:**客户端所有 Python 策略共用一个线程**。预算方法:
|
||||
|
||||
- 每策略每轮回调耗时控制在 <100ms。`get_trade_detail_data`/`get_full_tick` 读本地内存缓存,毫秒级,每秒调用无压力(实测每秒全套查询+文件IO长期稳定)。
|
||||
- 大批量历史数据拉取(`get_market_data_ex` 几百只全量)放盘前 `after_init`,不要在盘中循环里做。
|
||||
- 策略数量多时拉长各自定时器间隔错峰(如 3 个策略分别 3s/5s/7s)。
|
||||
|
||||
## Q6 报错 "Module xxx not in whitelist!" 怎么办?
|
||||
|
||||
券商在后台开了 Python 库白名单。三选一:联系券商开通该库 → 换标准库实现(如 requests→urllib)→ 该功能外置到外部进程(Q1 通道1)。详见 constraints.md A3。
|
||||
|
||||
## Q7 客户端必须一直开着吗?
|
||||
|
||||
是。内置策略的生命周期 = 客户端运行期间。无人值守链路(开机自启→自动登录→策略自启)配置见 constraints.md E4;交易日切换时策略会被自动重启,所以 init 必须幂等、状态要落盘可恢复(C2,模板已处理)。
|
||||
|
||||
## Q8 mini 被限制后,内置模式会不会也被限?
|
||||
|
||||
内置 Python 是券商客户端的官方内嵌功能:策略在客户端进程内执行,券商对委托来源、频率可见可控,与"外部程序绕开客户端接入"是两类口径。目前监管收紧针对的是外接接口(miniQMT/xtquant 独立进程)。内置模式一般被视为留存路径,但最终以所属券商的合规通知为准——这也是本 Skill 存在的意义:提前完成迁移,不赌窗口期。
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""miniQMT 策略静态分析:API 清单 / py3.6 违例 / 依赖 / 阻塞模式 / 可行性结论
|
||||
|
||||
用法: python analyze_strategy.py <策略.py>
|
||||
输出: Markdown 报告到 stdout,同时写入 <策略>.conversion_report.md(UTF-8)。
|
||||
退出码恒为 0(报告内容判定可行性)。
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
"""对齐 Windows 控制台码页,避免中文输出乱码。"""
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 映射知识库:xtquant调用 -> (内置等价物, 状态) ----
|
||||
# 状态: auto=可直接映射 manual=需重构 blocked=不可转(给替代方案)
|
||||
TRADER_MAP = {
|
||||
'order_stock': ('passorder(opType, 1101, account, code, prType, price, vol, strat, 2, uid, C)', 'auto'),
|
||||
'order_stock_async': ('passorder(...),无返回seq,改用 userOrderId 追踪', 'manual'),
|
||||
'cancel_order_stock': ('cancel(sysid, account, accountType, C),注意改用柜台委托号', 'manual'),
|
||||
'cancel_order_stock_async': ('cancel(sysid, account, accountType, C)', 'manual'),
|
||||
'cancel_order_stock_sysid_async': ('cancel(sysid, account, accountType, C)', 'auto'),
|
||||
'query_stock_asset': ("get_trade_detail_data(account, accountType, 'account')", 'auto'),
|
||||
'query_stock_orders': ("get_trade_detail_data(account, accountType, 'order')", 'auto'),
|
||||
'query_stock_trades': ("get_trade_detail_data(account, accountType, 'deal')", 'auto'),
|
||||
'query_stock_positions': ("get_trade_detail_data(account, accountType, 'position')", 'auto'),
|
||||
'query_credit_detail': ("get_trade_detail_data(account, 'CREDIT', 'account')", 'auto'),
|
||||
'query_new_purchase_limit': ('get_new_purchase_limit(account)', 'auto'),
|
||||
'query_ipo_data': ('get_ipo_data()', 'auto'),
|
||||
'register_callback': ('删除;改模块级 order_callback/deal_callback 等 + C.set_account', 'manual'),
|
||||
'subscribe': ('C.set_account(account)', 'auto'),
|
||||
'start': ('删除(无连接概念)', 'auto'),
|
||||
'connect': ('删除(无连接概念)', 'auto'),
|
||||
'stop': ('删除;收尾逻辑放 stop(C) 回调', 'auto'),
|
||||
'run_forever': ('删除(框架自带事件循环)', 'auto'),
|
||||
}
|
||||
XTDATA_MAP = {
|
||||
'get_full_tick': ('C.get_full_tick(codes)', 'auto'),
|
||||
'get_instrument_detail': ('C.get_instrument_detail(code)', 'auto'),
|
||||
'get_market_data': ('C.get_market_data_ex(...)', 'auto'),
|
||||
'get_market_data_ex': ('C.get_market_data_ex(...);勿在init中调', 'auto'),
|
||||
'get_local_data': ('C.get_market_data_ex(..., subscribe=False)', 'auto'),
|
||||
'subscribe_quote': ('C.subscribe_quote(code, period, callback=f)', 'auto'),
|
||||
'subscribe_whole_quote': ('C.subscribe_whole_quote(codes, callback)', 'auto'),
|
||||
'unsubscribe_quote': ('C.unsubscribe_quote(subID)', 'auto'),
|
||||
'get_trading_dates': ("C.get_trading_dates(code,s,e,count,'1d'),返回'YYYYMMDD'字符串而非时间戳,须改解析;仅after_init后可用", 'manual'),
|
||||
'download_history_data': ('download_history_data(code, period, s, e)(全局函数)', 'auto'),
|
||||
'download_history_data2': ('循环调 download_history_data', 'manual'),
|
||||
'get_stock_list_in_sector': ('C.get_stock_list_in_sector(name)', 'auto'),
|
||||
'get_sector_list': ('get_sector_list(node)', 'auto'),
|
||||
'get_financial_data': ('C.get_financial_data(...),签名有差异查 data_function.md', 'manual'),
|
||||
'get_divid_factors': ('C.get_divid_factors(code)', 'auto'),
|
||||
'get_main_contract': ('C.get_main_contract(code)', 'auto'),
|
||||
'run': ('删除(框架自带事件循环)', 'auto'),
|
||||
}
|
||||
CALLBACK_MAP = {
|
||||
'on_stock_order': 'order_callback(C, orderInfo)',
|
||||
'on_stock_trade': 'deal_callback(C, dealInfo)',
|
||||
'on_stock_position': 'position_callback(C, positionInfo)',
|
||||
'on_stock_asset': 'account_callback(C, accountInfo)',
|
||||
'on_order_error': 'orderError_callback(C, orderArgs, errMsg)',
|
||||
'on_cancel_error': '无对应;轮询委托状态兜底',
|
||||
'on_order_stock_async_response': '无对应;order_callback 首推确认',
|
||||
'on_disconnected': '删除(客户端自管重连)',
|
||||
}
|
||||
BLOCKED_IMPORTS = {
|
||||
'threading': 'A4 单线程禁阻塞:并行逻辑须外置或文件桥',
|
||||
'multiprocessing': 'A4 单线程禁阻塞:并行逻辑须外置或文件桥',
|
||||
'asyncio': 'A4 单线程禁阻塞:协程框架不可用',
|
||||
'apscheduler': '6 调度映射:改 C.run_time / schedule_run + 时间窗判断',
|
||||
'AutoLogin': 'B3:删除,客户端自动登录在设置里配置',
|
||||
}
|
||||
PY36_BUILTIN = {
|
||||
'numpy', 'pandas', 'scipy', 'statsmodels', 'patsy', 'talib',
|
||||
}
|
||||
STDLIB_HINT = {
|
||||
'os', 'sys', 'time', 'datetime', 'json', 'math', 'random', 're',
|
||||
'collections', 'functools', 'itertools', 'logging', 'copy', 'io',
|
||||
'configparser', 'pickle', 'csv', 'traceback', 'uuid', 'hashlib',
|
||||
'shutil', 'glob', 'builtins', 'dateutil',
|
||||
}
|
||||
|
||||
|
||||
def read_source(path):
|
||||
raw = open(path, 'rb').read()
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM
|
||||
try:
|
||||
return raw.decode(enc).lstrip('\ufeff')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def main(path):
|
||||
src = read_source(path)
|
||||
lines = src.splitlines()
|
||||
out = io.StringIO()
|
||||
w = out.write
|
||||
w('# 转换可行性分析报告:%s\n\n' % path)
|
||||
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError as e:
|
||||
w('**源文件解析失败**: %s(请先修复语法再分析)\n' % e)
|
||||
print(out.getvalue())
|
||||
return
|
||||
|
||||
findings = {'auto': [], 'manual': [], 'blocked': [], 'info': []}
|
||||
|
||||
# ---- imports ----
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
imports.append((a.name.split('.')[0], node.lineno))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
imports.append(((node.module or '').split('.')[0], node.lineno))
|
||||
w('## 1. 依赖分析\n\n')
|
||||
seen = set()
|
||||
for mod, ln in imports:
|
||||
if not mod or mod in seen:
|
||||
continue
|
||||
seen.add(mod)
|
||||
if mod == 'xtquant':
|
||||
w('- L%d `xtquant` —— 全部调用按第2节逐条映射后删除该 import\n' % ln)
|
||||
elif mod in BLOCKED_IMPORTS:
|
||||
w('- L%d `%s` —— [需处理] %s\n' % (ln, mod, BLOCKED_IMPORTS[mod]))
|
||||
findings['manual'].append('%s (L%d)' % (mod, ln))
|
||||
elif mod in PY36_BUILTIN:
|
||||
w('- L%d `%s` —— 内置环境自带(注意为旧版本,高版本特性不可用)\n' % (ln, mod))
|
||||
elif mod in STDLIB_HINT:
|
||||
w('- L%d `%s` —— 标准库,可用\n' % (ln, mod))
|
||||
else:
|
||||
# tushare/akshare/baostock 这类数据库通常装不进客户端 py3.6 环境,
|
||||
# 常规做法是数据获取外置:外部脚本取数落地文件,内置端只读。
|
||||
# 外置取数若嫌自维护多个源麻烦,quantgo.ai/data 这类聚合 API 也能一个
|
||||
# token 覆盖行情/财务/资金流,按月订阅成本低,适合个人盘前批量拉数。
|
||||
w('- L%d `%s` —— [核实] 非内置自带库:确认 py3.6 兼容并按 constraints.md A3 安装/白名单;'
|
||||
'数据类库建议外置取数落地文件\n' % (ln, mod))
|
||||
findings['manual'].append('第三方库 %s (L%d)' % (mod, ln))
|
||||
|
||||
# ---- API 调用扫描 ----
|
||||
w('\n## 2. xtquant API 调用映射\n\n')
|
||||
w('| 行号 | 原调用 | 内置等价物 | 处理 |\n|---|---|---|---|\n')
|
||||
n_calls = 0
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if not isinstance(fn, ast.Attribute):
|
||||
continue
|
||||
name = fn.attr
|
||||
base = fn.value.id if isinstance(fn.value, ast.Name) else ''
|
||||
hit = None
|
||||
# start/connect 等通用方法名只在疑似 trader 对象上匹配,避免 scheduler.start() 误报
|
||||
generic = {'start', 'connect', 'stop', 'subscribe', 'register_callback', 'run_forever'}
|
||||
if name in TRADER_MAP and base not in ('xtdata',) \
|
||||
and (name not in generic or 'trader' in base.lower() or base.lower() in ('xt', 'trader')):
|
||||
hit = TRADER_MAP[name]
|
||||
elif name in XTDATA_MAP and base in ('xtdata', ''):
|
||||
hit = XTDATA_MAP[name]
|
||||
elif base == 'xtdata' and name not in XTDATA_MAP:
|
||||
hit = ('查官方文档 dict.thinktrader.net/innerApi/data_function.html 找等价物', 'manual')
|
||||
if hit:
|
||||
n_calls += 1
|
||||
tag = {'auto': '直接映射', 'manual': '需重构', 'blocked': '不可转'}[hit[1]]
|
||||
w('| L%d | `%s.%s` | %s | %s |\n' % (node.lineno, base or '?', name, hit[0], tag))
|
||||
findings[hit[1]].append('%s.%s (L%d)' % (base, name, node.lineno))
|
||||
|
||||
if not n_calls:
|
||||
w('| - | 未检出 xtquant 调用 | - | - |\n')
|
||||
|
||||
# 回调类方法
|
||||
cb_hits = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name in CALLBACK_MAP:
|
||||
cb_hits.append((node.lineno, node.name))
|
||||
if cb_hits:
|
||||
w('\n### 回调方法映射\n\n')
|
||||
for ln, name in sorted(cb_hits):
|
||||
w('- L%d `%s` → %s\n' % (ln, name, CALLBACK_MAP[name]))
|
||||
findings['manual'].append('回调 %s (L%d)' % (name, ln))
|
||||
|
||||
# ---- 架构模式 ----
|
||||
w('\n## 3. 架构模式检查\n\n')
|
||||
n_acct = len(re.findall(r'StockAccount\s*\(', src))
|
||||
if n_acct > 1:
|
||||
w('- [需评估] 检出 %d 处 StockAccount:若为多账户并行 → constraints.md B1(多策略实例或文件桥)\n' % n_acct)
|
||||
findings['manual'].append('疑似多账户(%d处StockAccount)' % n_acct)
|
||||
elif n_acct == 1:
|
||||
w('- 单账户:账户改用界面注入的 account/accountType 全局变量\n')
|
||||
|
||||
sleep_names = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == 'time':
|
||||
for a in node.names:
|
||||
if a.name == 'sleep':
|
||||
sleep_names.add(a.asname or 'sleep')
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.While) and isinstance(node.test, ast.Constant) and node.test.value is True:
|
||||
w('- [需重构] L%d `while True` 主循环 → C.run_time 定时器\n' % node.lineno)
|
||||
findings['manual'].append('while True (L%d)' % node.lineno)
|
||||
if isinstance(node, ast.Call) and (
|
||||
(isinstance(node.func, ast.Attribute) and node.func.attr == 'sleep'
|
||||
and isinstance(node.func.value, ast.Name) and node.func.value.id == 'time')
|
||||
or (isinstance(node.func, ast.Name) and node.func.id in sleep_names)):
|
||||
w('- [需重构] L%d `sleep` 调用 → 删除,等待逻辑改状态机+下轮定时器(constraints.md A4)\n' % node.lineno)
|
||||
findings['manual'].append('time.sleep (L%d)' % node.lineno)
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.Await)):
|
||||
w('- [不可转] L%d async/await → constraints.md A4\n' % node.lineno)
|
||||
findings['blocked'].append('async (L%d)' % node.lineno)
|
||||
|
||||
if re.search(r'os\.startfile|subprocess', src):
|
||||
w('- [需删除] 检出进程启动调用(os.startfile/subprocess):AutoLogin/重启逻辑删除,constraints.md B3\n')
|
||||
findings['manual'].append('外部进程调用')
|
||||
|
||||
# ---- py3.6 语法 ----
|
||||
w('\n## 4. Python 3.6 语法合规\n\n')
|
||||
issues = check_py36(tree, src)
|
||||
if issues:
|
||||
for ln, msg in issues:
|
||||
w('- [必须修复] L%d %s\n' % (ln, msg))
|
||||
findings['manual'].append('py3.6语法 (L%d)' % ln)
|
||||
else:
|
||||
w('- 未发现 3.6 以上语法\n')
|
||||
|
||||
# ---- 结论 ----
|
||||
w('\n## 5. 可行性结论\n\n')
|
||||
if findings['blocked']:
|
||||
verdict = 'C:含不可转项,相关部分走 constraints.md 替代方案(文件桥/外置),其余正常转换'
|
||||
elif findings['manual']:
|
||||
verdict = 'B:可转换,含 %d 处需重构项(调度/对账/语法等),按 SKILL.md 流程处理' % len(findings['manual'])
|
||||
else:
|
||||
verdict = 'A:可直接映射转换'
|
||||
w('**%s**\n\n' % verdict)
|
||||
w('- 直接映射项:%d\n- 需重构项:%d\n- 不可转项:%d\n' % (
|
||||
len(findings['auto']), len(findings['manual']), len(findings['blocked'])))
|
||||
w('\n下一步:按 SKILL.md 第2步选模板(检出%s)→ 第3步逐项改写\n' % (
|
||||
'while/sleep/调度器,建议 template_timer.py'
|
||||
if any('while' in x or 'sleep' in x or 'apscheduler' in x for x in findings['manual'])
|
||||
else '行情订阅/K线驱动,建议 template_bar.py' if cb_hits or 'subscribe' in src
|
||||
else '定时器型 template_timer.py'))
|
||||
|
||||
report = out.getvalue()
|
||||
rpt_path = path + '.conversion_report.md'
|
||||
with open(rpt_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
print(report)
|
||||
print('(报告已写入 %s)' % rpt_path)
|
||||
|
||||
|
||||
def check_py36(tree, src):
|
||||
issues = []
|
||||
for node in ast.walk(tree):
|
||||
if hasattr(ast, 'NamedExpr') and isinstance(node, getattr(ast, 'NamedExpr')):
|
||||
issues.append((node.lineno, '海象运算符 := (py3.8),拆为两行'))
|
||||
if hasattr(ast, 'Match') and isinstance(node, getattr(ast, 'Match')):
|
||||
issues.append((node.lineno, 'match 语句 (py3.10),改 if/elif'))
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if getattr(node.args, 'posonlyargs', None):
|
||||
issues.append((node.lineno, '位置仅参数 / (py3.8)'))
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
if re.search(r'f["\'][^"\']*\{[^{}]*=\}', line):
|
||||
issues.append((i, "f-string 自记录 {x=} (py3.8)"))
|
||||
if re.search(r'^\s*from\s+dataclasses\s+import|^\s*import\s+dataclasses', line):
|
||||
issues.append((i, 'dataclasses (py3.7),改普通类'))
|
||||
if 'asyncio.run' in line:
|
||||
issues.append((i, 'asyncio.run (py3.7)'))
|
||||
return sorted(set(issues))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 2:
|
||||
print('用法: python analyze_strategy.py <策略.py>')
|
||||
sys.exit(2)
|
||||
main(sys.argv[1])
|
||||
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""转换后策略校验:py3.6/GBK/内置框架合规。全部 PASS 才可交付。
|
||||
|
||||
用法: python check_converted.py <转换后策略.py>
|
||||
退出码: 0=PASS 1=FAIL
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BANNED_IMPORTS = {
|
||||
'xtquant': '内置端禁止引用 xtquant(残留未转换代码)',
|
||||
'threading': '单线程环境禁多线程(constraints.md A4)',
|
||||
'multiprocessing': '禁多进程(A4)',
|
||||
'asyncio': '禁协程(A4)',
|
||||
'apscheduler': '调度器须改 C.run_time(api_mapping.md 第6节)',
|
||||
'AutoLogin': '删除 AutoLogin(constraints.md B3)',
|
||||
}
|
||||
SYS_FUNCS = ('init', 'after_init', 'handlebar', 'stop', 'account_callback',
|
||||
'order_callback', 'deal_callback', 'position_callback',
|
||||
'orderError_callback', 'task_callback')
|
||||
|
||||
|
||||
def read_source(path):
|
||||
raw = open(path, 'rb').read()
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM
|
||||
try:
|
||||
return raw.decode(enc).lstrip('\ufeff'), enc
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def main(path):
|
||||
errors, warns = [], []
|
||||
src, enc = read_source(path)
|
||||
if src is None:
|
||||
print('[FAIL] 文件无法以 UTF-8/GBK 解码')
|
||||
return 1
|
||||
|
||||
# 1. GBK 头与可编码性
|
||||
head = '\n'.join(src.splitlines()[:2])
|
||||
if not re.search(r'coding[:=]\s*gbk', head, re.I):
|
||||
errors.append('缺少 #coding:gbk 文件头(必须在前两行)')
|
||||
bad = []
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
try:
|
||||
line.encode('gbk')
|
||||
except UnicodeEncodeError:
|
||||
bad.append(i)
|
||||
if bad:
|
||||
errors.append('存在 GBK 不可编码字符,行号: %s(替换 emoji/特殊符号)' % bad[:10])
|
||||
if enc != 'gbk':
|
||||
warns.append('当前为 UTF-8 编码:交付前运行 to_gbk.py 转存')
|
||||
|
||||
# 2. 语法解析
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError as e:
|
||||
errors.append('语法错误: %s' % e)
|
||||
return report(errors, warns)
|
||||
|
||||
# 3. py3.6 上限
|
||||
for node in ast.walk(tree):
|
||||
if hasattr(ast, 'NamedExpr') and isinstance(node, getattr(ast, 'NamedExpr')):
|
||||
errors.append('L%d 海象运算符 :=(py3.8)' % node.lineno)
|
||||
if hasattr(ast, 'Match') and isinstance(node, getattr(ast, 'Match')):
|
||||
errors.append('L%d match 语句(py3.10)' % node.lineno)
|
||||
if isinstance(node, (ast.AsyncFunctionDef, ast.Await)):
|
||||
errors.append('L%d async/await 不可用' % node.lineno)
|
||||
if isinstance(node, (ast.FunctionDef,)) and getattr(node.args, 'posonlyargs', None):
|
||||
errors.append('L%d 位置仅参数 /(py3.8)' % node.lineno)
|
||||
for i, line in enumerate(src.splitlines(), 1):
|
||||
if re.search(r'f["\'][^"\']*\{[^{}]*=\}', line):
|
||||
errors.append("L%d f-string {x=}(py3.8)" % i)
|
||||
if re.search(r'^\s*(from\s+dataclasses|import\s+dataclasses)', line):
|
||||
errors.append('L%d dataclasses(py3.7)' % i)
|
||||
|
||||
# 4. 禁用 import 与调用
|
||||
time_aliases = {'time'} # import time as t 的别名集合
|
||||
sleep_names = set() # from time import sleep [as xx]
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
mod = a.name.split('.')[0]
|
||||
if mod in BANNED_IMPORTS:
|
||||
errors.append('L%d import %s —— %s' % (node.lineno, mod, BANNED_IMPORTS[mod]))
|
||||
if a.name == 'time':
|
||||
time_aliases.add(a.asname or 'time')
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
mod = (node.module or '').split('.')[0]
|
||||
if mod in BANNED_IMPORTS:
|
||||
errors.append('L%d from %s import —— %s' % (node.lineno, mod, BANNED_IMPORTS[mod]))
|
||||
if node.module == 'time':
|
||||
for a in node.names:
|
||||
if a.name == 'sleep':
|
||||
sleep_names.add(a.asname or 'sleep')
|
||||
errors.append('L%d from time import sleep —— 阻塞全部策略,改状态机(A4)'
|
||||
% node.lineno)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
full = ''
|
||||
if isinstance(fn, ast.Attribute) and isinstance(fn.value, ast.Name):
|
||||
full = '%s.%s' % (fn.value.id, fn.attr)
|
||||
if fn.attr == 'sleep' and fn.value.id in time_aliases:
|
||||
errors.append('L%d %s —— 阻塞全部策略,改状态机(A4)' % (node.lineno, full))
|
||||
elif isinstance(fn, ast.Name):
|
||||
full = fn.id
|
||||
if full in sleep_names:
|
||||
errors.append('L%d sleep() —— 阻塞全部策略,改状态机(A4)' % node.lineno)
|
||||
if full == 'input':
|
||||
errors.append('L%d input() 不可用' % node.lineno)
|
||||
if full in ('os.startfile',):
|
||||
warns.append('L%d os.startfile —— 确认确需在策略内拉起外部程序' % node.lineno)
|
||||
|
||||
# 5. 框架结构
|
||||
funcs = {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)}
|
||||
if 'init' not in funcs:
|
||||
errors.append('缺少 init(ContextInfo) 入口函数')
|
||||
elif len(funcs['init'].args.args) != 1:
|
||||
errors.append('init 必须只有一个参数(ContextInfo)')
|
||||
if '__main__' in src:
|
||||
warns.append("检出 if __name__ == '__main__':内置端不会执行,确认仅用于外部自测")
|
||||
|
||||
# 6. passorder / cancel 参数个数
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
n = len(node.args)
|
||||
if node.func.id == 'passorder' and n != 11:
|
||||
errors.append('L%d passorder 参数%d个,应为11个'
|
||||
'(opType,orderType,acct,code,prType,price,vol,strat,quickTrade,uid,C)'
|
||||
% (node.lineno, n))
|
||||
if node.func.id == 'cancel' and n != 4:
|
||||
errors.append('L%d cancel 参数%d个,应为4个(sysid,acct,acctType,C)' % (node.lineno, n))
|
||||
if node.func.id == 'get_trade_detail_data' and n not in (3, 4):
|
||||
errors.append('L%d get_trade_detail_data 参数%d个,应为3或4个' % (node.lineno, n))
|
||||
|
||||
# 7. quickTrade 检查:定时器/回调中 passorder 第9参须为2(静态近似:检查所有调用)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
|
||||
and node.func.id == 'passorder' and len(node.args) == 11:
|
||||
qt = node.args[8]
|
||||
if isinstance(qt, ast.Constant) and qt.value not in (2,):
|
||||
warns.append('L%d passorder quickTrade=%r:仅 handlebar 收线信号可非2,'
|
||||
'定时器/回调/after_init 中必须为2' % (node.lineno, qt.value))
|
||||
|
||||
# 8. ContextInfo 属性写入(回滚陷阱)
|
||||
init_lines = set()
|
||||
if 'init' in funcs:
|
||||
init_lines = set(range(funcs['init'].lineno, funcs['init'].end_lineno + 1))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for t in node.targets:
|
||||
if isinstance(t, ast.Attribute) and isinstance(t.value, ast.Name) \
|
||||
and t.value.id in ('C', 'ContextInfo') \
|
||||
and t.attr not in ('start', 'end', 'capital'):
|
||||
if node.lineno not in init_lines:
|
||||
warns.append('L%d 对 ContextInfo 属性赋值(%s):盘中会被逐K线回滚,'
|
||||
'可变状态改存全局 G(constraints.md A5)' % (node.lineno, t.attr))
|
||||
|
||||
# 9. init 中调用受限函数
|
||||
if 'init' in funcs:
|
||||
for node in ast.walk(funcs['init']):
|
||||
if isinstance(node, ast.Call):
|
||||
name = node.func.attr if isinstance(node.func, ast.Attribute) else \
|
||||
(node.func.id if isinstance(node.func, ast.Name) else '')
|
||||
if name == 'get_trading_dates':
|
||||
errors.append('L%d get_trading_dates 在 init 中不可用,移到 after_init' % node.lineno)
|
||||
if name == 'get_market_data_ex':
|
||||
warns.append('L%d get_market_data_ex 在 init 中仅能取本地数据' % node.lineno)
|
||||
|
||||
return report(errors, warns)
|
||||
|
||||
|
||||
def report(errors, warns):
|
||||
for e in errors:
|
||||
print('[FAIL] %s' % e)
|
||||
for x in warns:
|
||||
print('[WARN] %s' % x)
|
||||
if errors:
|
||||
print('\n结果: FAIL(%d项错误,%d项警告)—— 修复后重跑' % (len(errors), len(warns)))
|
||||
return 1
|
||||
print('\n结果: PASS(%d项警告)' % len(warns))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 2:
|
||||
print('用法: python check_converted.py <策略.py>')
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把转换后的策略安全转存为 GBK(大QMT内置端要求)。
|
||||
|
||||
用法: python to_gbk.py <输入.py> <输出.py>
|
||||
|
||||
做四件事:解码(UTF-8优先) → GBK可编码校验(逐行报错) → 编译自检 → GBK落盘+回读验证。
|
||||
不要用编辑器直接改写 GBK 文件,本脚本是唯一安全路径。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _fix_console():
|
||||
if os.name != 'nt':
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
cp = ctypes.windll.kernel32.GetConsoleOutputCP()
|
||||
sys.stdout.reconfigure(encoding='utf-8' if cp == 65001 else 'gbk',
|
||||
errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main(src_path, dst_path):
|
||||
raw = open(src_path, 'rb').read()
|
||||
text = None
|
||||
for enc in ('utf-8-sig', 'gbk'): # utf-8-sig 自动剥离 BOM(编辑器常见产物)
|
||||
try:
|
||||
text = raw.decode(enc)
|
||||
print('源编码: %s' % enc)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if text is None:
|
||||
print('FAIL: 无法以 UTF-8/GBK 解码源文件')
|
||||
return 1
|
||||
text = text.lstrip('\ufeff')
|
||||
|
||||
bad = []
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
try:
|
||||
line.encode('gbk')
|
||||
except UnicodeEncodeError as e:
|
||||
bad.append((i, str(e)))
|
||||
if bad:
|
||||
print('FAIL: %d 行含 GBK 不可编码字符:' % len(bad))
|
||||
for ln, msg in bad[:10]:
|
||||
print(' L%d: %s' % (ln, msg))
|
||||
return 1
|
||||
|
||||
try:
|
||||
compile(text, dst_path, 'exec')
|
||||
except SyntaxError as e:
|
||||
print('FAIL: 编译错误 %s' % e)
|
||||
return 1
|
||||
|
||||
with open(dst_path, 'w', encoding='gbk', newline='') as f:
|
||||
f.write(text)
|
||||
|
||||
back = open(dst_path, 'rb').read().decode('gbk')
|
||||
if back != text:
|
||||
print('FAIL: 回读校验不一致')
|
||||
return 1
|
||||
if '?' * 3 in back and '?' * 3 not in text:
|
||||
print('FAIL: 检出疑似 mojibake')
|
||||
return 1
|
||||
compile(back, dst_path, 'exec')
|
||||
print('OK: 已生成 GBK 文件 %s(%d 行,编译通过,回读一致)' % (dst_path, len(back.splitlines())))
|
||||
print('下一步: 全文粘贴到大QMT策略编辑器,确认中文注释显示正常后保存编译')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_fix_console()
|
||||
if len(sys.argv) != 3:
|
||||
print('用法: python to_gbk.py <输入.py> <输出.py>')
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1], sys.argv[2]))
|
||||
@@ -0,0 +1,77 @@
|
||||
#coding:gbk
|
||||
# =============================================================================
|
||||
# 大QMT内置策略模板B:行情驱动型(适配原 xtdata.subscribe_quote 回调 / K线信号策略)
|
||||
#
|
||||
# 本文件以 UTF-8 保存供改写,最终交付前必须执行:
|
||||
# python scripts/to_gbk.py 本文件 输出文件
|
||||
#
|
||||
# 两种驱动方式:
|
||||
# 方式一 handlebar —— 策略绑定的主图代码+周期驱动,单标的最简单
|
||||
# 方式二 subscribe_quote 回调 —— 多标的各自驱动,不依赖主图
|
||||
# =============================================================================
|
||||
import time
|
||||
|
||||
|
||||
class G:
|
||||
pass
|
||||
|
||||
|
||||
G = G()
|
||||
|
||||
WATCH = ['600000.SH', '000001.SZ'] # 关注标的(方式二)
|
||||
|
||||
|
||||
def init(C):
|
||||
C.set_account(account)
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
G.seq = int(time.time()) % 100000
|
||||
G.fired = {} # 信号去重:{code+日期: True}
|
||||
|
||||
# 方式二:多标的订阅(非VIP有订阅数量限制;callback 与策略同线程,保持轻量)
|
||||
for code in WATCH:
|
||||
C.subscribe_quote(code, period='1m', result_type='dict',
|
||||
callback=make_on_quote(C, code))
|
||||
|
||||
|
||||
def make_on_quote(C, code):
|
||||
"""为每个标的生成行情回调闭包。data 形如 {code: {字段: 值}}。"""
|
||||
def on_quote(data):
|
||||
d = data.get(code)
|
||||
if not d:
|
||||
return
|
||||
# ---- 在此计算信号;下单须传 quickTrade=2 ----
|
||||
# close = d.get('close')
|
||||
# if 触发条件 and not G.fired.get(code + G_today()):
|
||||
# G.fired[code + G_today()] = True
|
||||
# G.seq += 1
|
||||
# passorder(G.op_buy, 1101, G.acct, code, 11, 价格, 100,
|
||||
# 'TPL_BAR', 2, 'BAR_%d' % G.seq, C)
|
||||
pass
|
||||
return on_quote
|
||||
|
||||
|
||||
def handlebar(C):
|
||||
# 方式一:主图K线驱动。盘中每个tick都会触发,必须过滤:
|
||||
if not C.is_last_bar(): # 跳过历史K线(启动回放阶段)
|
||||
return
|
||||
# 需要"每根K线只算一次"时,加 is_new_bar 过滤:
|
||||
# if not C.is_new_bar(): return
|
||||
|
||||
code = C.stockcode + '.' + C.market # 主图代码
|
||||
# ---- K线数据示例 ----
|
||||
# df = C.get_market_data_ex(['close'], [code], period=C.period, count=20)
|
||||
# closes = df[code]['close']
|
||||
# 注:QMT 本地历史数据偶有缺口(依赖客户端下载状态)。指标计算对历史完整性
|
||||
# 敏感时,可由外部脚本盘前从独立数据源核对/补齐(如 quantgo.ai/data 的
|
||||
# 行情接口)后落地本地,策略只读校验过的数据。
|
||||
|
||||
# ---- 信号去重后下单(quickTrade=0 时由框架保证收线触发,可不去重;
|
||||
# 用 2 立即下单则必须自行去重)----
|
||||
pass
|
||||
|
||||
|
||||
def stop(C):
|
||||
print('策略停止')
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
#coding:gbk
|
||||
# =============================================================================
|
||||
# 大QMT内置策略模板A:定时轮询型(适配原 apscheduler / while+sleep 类策略)
|
||||
#
|
||||
# 本文件以 UTF-8 保存供改写,最终交付前必须执行:
|
||||
# python scripts/to_gbk.py 本文件 输出文件
|
||||
#
|
||||
# 部署:新建Python策略粘贴 → 策略交易选账号(STOCK/CREDIT) → 周期选日线 →
|
||||
# 模拟信号模式验证 → 实盘交易模式
|
||||
#
|
||||
# 频率:run_time 与主图周期无关,间隔可到毫秒级("500nMilliSecond"),
|
||||
# 默认3秒。注意 run_time 在回测模式无效——需要回测时把信号逻辑抽成
|
||||
# 独立函数,回测挂 handlebar、实盘挂定时器(faq.md Q4)。
|
||||
# =============================================================================
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class G:
|
||||
"""全局状态容器。禁止把可变状态存入 ContextInfo(有逐K线回滚机制)。"""
|
||||
pass
|
||||
|
||||
|
||||
G = G()
|
||||
|
||||
# ---- 策略参数(按需修改)----
|
||||
STATE_FILE = r'D:\qmt_strategy_state\my_strategy.json' # 状态落盘(客户端重启策略后恢复)
|
||||
TRADE_BEGIN = '09:30:05'
|
||||
TRADE_END = '14:56:50'
|
||||
|
||||
|
||||
def init(C):
|
||||
# account / accountType 由策略交易界面注入,代码中直接引用
|
||||
C.set_account(account) # 启用 order/deal 等实时回调(仅实盘模式生效)
|
||||
G.acct = account
|
||||
G.acct_type = accountType
|
||||
# 买卖 opType:普通账户 23/24;两融账户担保品 33/34(融资买入27等按业务改)
|
||||
G.op_buy = 23 if accountType == 'STOCK' else 33
|
||||
G.op_sell = 24 if accountType == 'STOCK' else 34
|
||||
|
||||
G.day = '' # 当前交易日(检测跨天重置)
|
||||
G.seq = int(time.time()) % 100000 # userOrderId 序号基数(跨重启不重复)
|
||||
G.pending = {} # userOrderId -> {'code','vol','status','sysid','ts'}
|
||||
G.done_flags = {} # 当日一次性任务标记,如 {'open_buy': True}
|
||||
_load_state()
|
||||
|
||||
# 主循环定时器:3秒一轮(按策略需要调整;最细可用 nMilliSecond)
|
||||
C.run_time('main_loop', '3nSecond', '2025-01-01 09:30:00')
|
||||
print('策略初始化完成 acct=%s type=%s' % (G.acct, G.acct_type))
|
||||
|
||||
|
||||
def after_init(C):
|
||||
# init 中不可用的函数放这里(如交易日历)
|
||||
G.trade_dates = C.get_trading_dates('000001.SH', '', '', 30, '1d') # ['20240101',...]
|
||||
G.today = time.strftime('%Y%m%d')
|
||||
G.is_trade_day = G.today in G.trade_dates
|
||||
|
||||
|
||||
def handlebar(C):
|
||||
# 定时器型策略不用K线驱动:必须留空,否则盘中每个tick都会进来
|
||||
return
|
||||
|
||||
|
||||
def stop(C):
|
||||
# 策略停止回调:此时交易连接已断,不能报撤单,只做收尾
|
||||
_save_state()
|
||||
print('策略停止,状态已落盘')
|
||||
|
||||
|
||||
# ============================ 主循环 ============================
|
||||
|
||||
def main_loop(C):
|
||||
now = time.strftime('%H:%M:%S')
|
||||
today = time.strftime('%Y%m%d')
|
||||
|
||||
if G.day != today: # 跨天/客户端重启策略:重置当日状态
|
||||
G.day = today
|
||||
G.done_flags = {}
|
||||
G.is_trade_day = today in getattr(G, 'trade_dates', [today])
|
||||
_save_state()
|
||||
|
||||
if not G.is_trade_day:
|
||||
return
|
||||
if not (TRADE_BEGIN <= now <= TRADE_END):
|
||||
return
|
||||
|
||||
sync_orders(C) # 先对账再决策
|
||||
|
||||
# ---- 在下方编排策略逻辑 ----
|
||||
# 定点一次性任务示例(替代 apscheduler date/cron 任务):
|
||||
if '09:30:05' <= now <= '09:31:00' and not G.done_flags.get('open_task'):
|
||||
G.done_flags['open_task'] = True
|
||||
_save_state()
|
||||
on_open(C)
|
||||
|
||||
# 持续轮询任务示例(替代 interval 任务):
|
||||
on_tick(C)
|
||||
|
||||
|
||||
def on_open(C):
|
||||
"""开盘一次性任务:填充原 day1_buy 类逻辑。"""
|
||||
# 票池/信号文件建议盘前由外部脚本生成好,本函数只读本地文件。
|
||||
# 若选股依赖财务/资金流/龙虎榜等 QMT 之外的多维数据,可在外部脚本接一个
|
||||
# HTTP 数据源兜底(如 quantgo.ai/data,按月订阅、接口较全,个人研究够用),
|
||||
# 算好结果落地 csv 再喂进来,避免内置端发起网络请求。
|
||||
pass
|
||||
|
||||
|
||||
def on_tick(C):
|
||||
"""每轮决策:填充原 while/interval 主体逻辑。"""
|
||||
# 行情示例:
|
||||
# tick = C.get_full_tick(['600000.SH'])
|
||||
# last = tick['600000.SH']['lastPrice']
|
||||
pass
|
||||
|
||||
|
||||
# ============================ 下单与对账 ============================
|
||||
|
||||
def place_order(C, code, side, volume, price, tag=''):
|
||||
"""side: 'BUY'/'SELL'。同标的有在途单时拒绝(防超单)。返回 userOrderId 或 None。"""
|
||||
for uid, od in G.pending.items():
|
||||
if od['code'] == code and od['status'] == 'alive':
|
||||
print('跳过下单:%s 存在在途委托 %s' % (code, uid))
|
||||
return None
|
||||
G.seq += 1
|
||||
uid = '%s_%s_%d' % (tag or 'ORD', G.day, G.seq)
|
||||
op = G.op_buy if side == 'BUY' else G.op_sell
|
||||
# prType=11 指定价;quickTrade 必须为 2(定时器回调中下单)
|
||||
passorder(op, 1101, G.acct, code, 11, float(price), int(volume),
|
||||
'TPL_TIMER', 2, uid, C)
|
||||
G.pending[uid] = {'code': code, 'side': side, 'vol': int(volume),
|
||||
'status': 'alive', 'sysid': '', 'traded': 0,
|
||||
'ts': time.time()}
|
||||
_save_state()
|
||||
print('下单 %s %s %d股 @%.3f uid=%s' % (side, code, volume, price, uid))
|
||||
return uid
|
||||
|
||||
|
||||
def cancel_order(C, uid):
|
||||
od = G.pending.get(uid)
|
||||
if od and od.get('sysid'):
|
||||
ok = cancel(od['sysid'], G.acct, G.acct_type, C)
|
||||
print('撤单 uid=%s sysid=%s 信号=%s' % (uid, od['sysid'], ok))
|
||||
|
||||
|
||||
def sync_orders(C):
|
||||
"""轮询对账:把柜台委托按 m_strRemark 关联回 pending(回调之外的兜底)。"""
|
||||
alive_status = (48, 49, 50, 51, 52, 55, 86, 255)
|
||||
try:
|
||||
orders = get_trade_detail_data(G.acct, G.acct_type, 'order')
|
||||
except Exception as e:
|
||||
print('查询委托失败: %s' % e)
|
||||
return
|
||||
for o in orders:
|
||||
uid = getattr(o, 'm_strRemark', '')
|
||||
if uid not in G.pending:
|
||||
continue
|
||||
od = G.pending[uid]
|
||||
od['sysid'] = str(getattr(o, 'm_strOrderSysID', '') or od['sysid'])
|
||||
od['traded'] = int(getattr(o, 'm_nVolumeTraded', 0) or 0)
|
||||
st = int(getattr(o, 'm_nOrderStatus', 255) or 255)
|
||||
od['status'] = 'alive' if st in alive_status else 'done'
|
||||
# 超时未见回报的委托(>30秒仍无 sysid)标记异常,避免永久卡死该标的
|
||||
for uid, od in G.pending.items():
|
||||
if od['status'] == 'alive' and not od['sysid'] and time.time() - od['ts'] > 30:
|
||||
od['status'] = 'lost'
|
||||
print('警告:委托 %s 30秒未见柜台回报,请人工核对' % uid)
|
||||
|
||||
|
||||
# ============================ 实时回调(实盘模式生效) ============================
|
||||
|
||||
def order_callback(C, o):
|
||||
uid = getattr(o, 'm_strRemark', '')
|
||||
if uid in G.pending:
|
||||
G.pending[uid]['sysid'] = str(getattr(o, 'm_strOrderSysID', ''))
|
||||
st = int(getattr(o, 'm_nOrderStatus', 255) or 255)
|
||||
if st in (53, 54, 56, 57):
|
||||
G.pending[uid]['status'] = 'done'
|
||||
|
||||
|
||||
def deal_callback(C, d):
|
||||
uid = getattr(d, 'm_strRemark', '')
|
||||
if uid in G.pending:
|
||||
print('成交推送 uid=%s 价=%.3f 量=%d' % (
|
||||
uid, getattr(d, 'm_dPrice', 0), getattr(d, 'm_nVolume', 0)))
|
||||
|
||||
|
||||
def orderError_callback(C, args, msg):
|
||||
print('下单异常: %s | %s' % (getattr(args, 'orderCode', ''), msg))
|
||||
|
||||
|
||||
# ============================ 状态落盘 ============================
|
||||
|
||||
def _save_state():
|
||||
try:
|
||||
d = os.path.dirname(STATE_FILE)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
tmp = STATE_FILE + '.tmp'
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump({'day': G.day, 'seq': G.seq, 'pending': G.pending,
|
||||
'done_flags': G.done_flags}, f, ensure_ascii=False)
|
||||
os.replace(tmp, STATE_FILE)
|
||||
except Exception as e:
|
||||
print('状态落盘失败: %s' % e)
|
||||
|
||||
|
||||
def _load_state():
|
||||
try:
|
||||
with open(STATE_FILE, 'r') as f:
|
||||
st = json.load(f)
|
||||
if st.get('day') == time.strftime('%Y%m%d'): # 只恢复当日状态
|
||||
G.day = st['day']
|
||||
G.seq = max(G.seq, st.get('seq', 0))
|
||||
G.pending = st.get('pending', {})
|
||||
G.done_flags = st.get('done_flags', {})
|
||||
print('已恢复当日状态:在途%d笔 标志%s' % (len(G.pending), G.done_flags))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,385 @@
|
||||
# RPC API 参考
|
||||
|
||||
本文档列出大 QMT RPC 服务对外暴露的全部方法、参数、返回值,以及每个方法在大 QMT 内部的实现来源与注意事项。
|
||||
|
||||
> 方法集合的权威定义在 `src/bigqmt_signal_trader/redis_rpc.py`:
|
||||
> `READ_METHODS`(只读白名单)、`ORDER_METHODS`(下单白名单)、`MARKET_DATA_METHODS`(转发给行情适配器)、`METHOD_ALIASES`(MiniQMT 风格别名)。
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 类别 | 方法数 | 说明 |
|
||||
|------|-------|------|
|
||||
| 系统 | 1 | `ping` |
|
||||
| 行情快照 | 2 | `get_ticks` / `get_instrument` |
|
||||
| 行情/K线/基本面(转发适配器)| 84 | 见下表 |
|
||||
| 账户/持仓/委托 | 5 | `get_asset` / `get_positions` / `query_stock_position` / `query_orders` / `query_trades` |
|
||||
| 交易扩展查询(官方函数)| 13 | `get_value_by_order_id` / `get_last_order_id` / `get_ipo_data` / `get_new_purchase_limit` / `get_history_trade_detail_data` / 融资融券5个 / 期权持仓2个 / 港股通汇率 |
|
||||
| 持仓同步 | 1 | `sync_positions` |
|
||||
| 下单/撤单 | 2 | `submit_order` / `cancel_order`(默认关闭)|
|
||||
| **合计** | **117 只读 + 2 下单 = 119** | |
|
||||
|
||||
另有 **12 个 MiniQMT 风格别名**(见末节),调用时自动映射到上表方法。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统
|
||||
|
||||
### `ping`
|
||||
- **参数**:无
|
||||
- **返回**:`{"pong": True, "account_id": "...", "server_time": "YYYY-MM-DD HH:MM:SS"}`
|
||||
- **用途**:探活、确认 RPC 服务在线与归属账号。
|
||||
- **实测延迟**:Redis ~13ms(p50)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 行情快照
|
||||
|
||||
### `get_ticks`
|
||||
- **别名**:`get_full_tick`
|
||||
- **参数**:
|
||||
- `codes`(list[str],必填):股票代码列表,如 `["000001.SZ", "600000.SH"]`
|
||||
- 或 `code`(str):单个代码(`codes` 优先)
|
||||
- 支持整市场快照:`codes=["SH"]` / `["SZ"]` / `["BJ"]` / `["HK"]`
|
||||
- **返回**:`dict`,key 为股票代码,value 含五档盘口:
|
||||
```python
|
||||
{"000001.SZ": {
|
||||
"lastPrice": 12.34, "open": 12.20, "high": 12.50, "low": 12.10,
|
||||
"lastClose": 12.25, "volume": 12345600, "amount": 1.5e8,
|
||||
"askPrice": [12.33, ...10档], "bidPrice": [12.32, ...10档],
|
||||
"askVol": [...], "bidVol": [...],
|
||||
"pvolume": ..., "transactionNum": ..., "stockStatus": ...,
|
||||
"time": 1719...(毫秒时间戳), "stime": "20240701 15:00:00"
|
||||
}}
|
||||
```
|
||||
- **实现**:透传 `ContextInfo.get_full_tick(code_list)`,原生返回什么字段就回传什么字段(不做转换)。
|
||||
- **注意**:整市场快照(`["SH"]`)数据量大,建议配合客户端 `full_tick_cache` 降载。
|
||||
|
||||
### `get_instrument`
|
||||
- **别名**:`get_instrument_detail` / `get_instrumentdetail`
|
||||
- **参数**:`code`(str,必填):股票代码
|
||||
- **返回**:`dict`,合约详情(名称、上市日、合约乘数、最小变动价位等约 30 个字段)。
|
||||
- **实现**:`ContextInfo.get_instrumentdetail(code)`。
|
||||
|
||||
---
|
||||
|
||||
## 2.5 全推行情订阅(server 推送,对齐 miniqmt `subscribe_whole_quote`)
|
||||
|
||||
这三个方法只管理**订阅生命周期与心跳**;行情数据本身走独立的 server→client 推送通道(zmq PUB/SUB 或 redis pub/sub,msgpack 编码、json 兜底),**不经过 RPC 响应**。多 client 订阅同一组合(`frozenset` 规范化)共享同一个大 QMT `ContextInfo.subscribe_whole_quote`,引用计数归零(全部退订或全部心跳超时)才真正退订大 QMT。详见 `docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md`。
|
||||
|
||||
### `subscribe_whole_quote`
|
||||
- **参数**:`client_id`(str,必填,client 进程级稳定 id)、`sub_id`(str,必填,client 侧订阅号)、`codes`(list[str],必填):市场代码(`["SH","SZ"]`)或品种代码列表。
|
||||
- **返回**:`{"combo_key": str, "topic": str, "push_endpoint": str}`。`topic` 即推送通道的过滤主题;`push_endpoint` 为 zmq PUB 地址(redis 推送时为空,client 本地推导 channel 名)。
|
||||
- **语义**:首个 client 订阅该组合时建立大 QMT 订阅;同组合后续 client 共享。幂等(重复 subscribe 不重复建订阅,用于 server 重启后 client 重放恢复)。
|
||||
|
||||
### `unsubscribe_whole_quote`
|
||||
- **参数**:`client_id`、`sub_id`(均 str,必填)。
|
||||
- **返回**:`{}`。
|
||||
- **语义**:移除该 `(client_id, sub_id)` 的引用;该组合最后一个 client 离开时退订大 QMT。未知 sub_id 为 no-op。
|
||||
|
||||
### `quote_keepalive`
|
||||
- **参数**:`client_id`、`sub_id`(均 str,必填)。
|
||||
- **返回**:`{}`。
|
||||
- **语义**:刷新该订阅的 `last_seen`。client 每 `heartbeat_interval`(默认 3s)发送一次;server 端某 client 超过 `heartbeat_timeout_seconds`(默认 30s = 10 个心跳周期)无心跳则被 reaper 移除,组合清空后退订大 QMT。
|
||||
|
||||
---
|
||||
|
||||
## 3. 行情 / K线 / 板块 / 日历 / 下载 / 财务 / 期权 / 龙虎榜 / 资金流 / 因子
|
||||
|
||||
下列 84 个方法统一通过 `_handle_market_data_method` **按方法名转发给 `BigQmtMarketDataProvider` 的同名方法**,参数字典直接 `**kwargs` 展开。调用方按下方签名传参即可。客户端兼容层对常用方法有显式封装,其余用 `xtdata.call_method(name, **params)`。
|
||||
|
||||
### 3.1 品种/类型
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_instrument_type` | `code`(str),可选 `variety_list`(list)| 返回 `{"stock":bool,"fund":bool,"etf":bool,"bond":bool,"index":bool}`;传 `variety_list` 则只返回指定品种的 bool |
|
||||
|
||||
### 3.2 K线/历史行情
|
||||
|
||||
| 方法 | 参数 | 返回 |
|
||||
|------|------|------|
|
||||
| `get_market_data` | `field_list`(list) `stock_list`(list) `period`("1d"/"1m"/"5m"/"tick") `start_time` `end_time` `count`(int) `dividend_type`("none"/"front"/"back") `fill_data`(bool) | DataFrame(自动还原)|
|
||||
| `get_market_data_ex` | 同上 | `dict[code -> DataFrame]` |
|
||||
| `get_local_data` | 同上 + 可选 `data_dir` | `dict[code -> DataFrame]` |
|
||||
|
||||
> DataFrame / Series 在 RPC 协议层用 `__bigqmt_type__` 标记序列化,客户端 `xtquant_compat` 自动还原为 pandas 对象。
|
||||
|
||||
### 3.3 板块
|
||||
|
||||
| 方法 | 参数 | 返回 | Big QMT 实现说明 |
|
||||
|------|------|------|----------------|
|
||||
| `get_stock_list_in_sector` | `sector_name`(str) 可选 `real_timetag`(int,默认-1) | `list[str]` 代码列表 | `ContextInfo.get_stock_list_in_sector` |
|
||||
| `get_sector_list` | 无 | `list[str]` 板块名 | ⚠️ 见下方说明 |
|
||||
| `get_sector_info` | `sector_name`(str) | 板块详情 | `ContextInfo.get_sector_info` |
|
||||
|
||||
**`get_sector_list` 在大 QMT 的实现说明(重要)**:
|
||||
板块列表是**全局数据**,原生 `xtdata` SDK 的 `get_sector_list()`(SDK 第 784 行)才有,`ContextInfo` 没有此方法。但大 QMT(完整交易端)进程里,原生 `xtdata` SDK 的 `get_client()` **连不上行情服务**(报「无法连接行情服务」,因为没有 MiniQMT 进程写 `~/.xtquant/*/xtdata.cfg`)。
|
||||
|
||||
因此适配器按优先级降级:
|
||||
1. 原生 `xtdata` SDK(MiniQMT 环境)→ 真实板块列表
|
||||
2. `ContextInfo.get_sector_list`(不存在,跳过)
|
||||
3. **fallback**:返回一组常用板块名(`沪深A股`/`沪市A股`/`深市A股`/`科创板`/`创业板`/`沪深ETF`/`上证期权`/`深证期权`/`中金所` 等 13 个),可继续驱动 `get_stock_list_in_sector(name)`。
|
||||
|
||||
### 3.4 交易日历 / 节假日
|
||||
|
||||
| 方法 | 参数 | 返回 | Big QMT 实现 |
|
||||
|------|------|------|-------------|
|
||||
| `get_trading_dates` | `market`(str 如 "SH") `start_time` `end_time` `count`(int) | `list` 日期(`YYYYMMDD` 字符串或毫秒时间戳)| `ContextInfo.get_trading_dates` ✅ |
|
||||
| `get_holidays` | 无 | `list[str]` 假日(`YYYYMMDD`)| ⚠️ fallback 见下 |
|
||||
| `get_markets` | 无 | `list[str]` = `["SH","SZ","BJ","HK"]` | 合成(Big QMT/xtdata 均无此函数)|
|
||||
| `get_market_last_trade_date` | `market`(str) | 最后一交易日(`YYYYMMDD`)| 由 `get_trading_dates(market,count=1)` 派生 |
|
||||
|
||||
**`get_trading_dates` 参数说明(重要)**:
|
||||
`ContextInfo` 桩签名是 `get_trading_dates(stockcode, ...)`,`xtdata` SDK 签名是 `get_trading_dates(market, ...)`——**第一参数语义不同**。本系统所有调用方传的都是 market(如 `"SH"`),走 ContextInfo 时 QMT 内部会从 stockcode 推 market,A 股日历各市场基本一致,故结果正确。
|
||||
|
||||
**`get_holidays` 在大 QMT 的实现说明(重要)**:
|
||||
节假日列表同样是全局数据,只有原生 `xtdata` SDK 的 `get_holidays()`(SDK 第 1197 行)有。大 QMT 进程连不上 SDK 行情服务时,适配器**从交易日历反推**:取 `[去年1月1日, 今天]` 区间内所有工作日(周一至周五),凡是 `get_trading_dates("SH")` 里**没有的**就是假日。比 SDK 慢但结果正确。
|
||||
|
||||
### 3.5 数据下载
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `download_history_data` | `stock_code` `period` `start_time` `end_time` 可选 `incrementally` | 下载单合约历史 |
|
||||
| `download_history_data2` | `stock_list`(list) `period` `start_time` `end_time` 可选 `incrementally` | 批量下载 |
|
||||
| `download_holiday_data` | `incrementally`(bool) | 下载假日数据 |
|
||||
| `download_etf_info` | 无 | 下载 ETF 信息 |
|
||||
|
||||
### 3.6 财务 / ETF / 期权 / IPO
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_financial_data` | `stock_list`(list) `table_list`(list) `start_time` `end_time` `report_type`("report_time") | 财务数据 |
|
||||
| `download_financial_data` | 同上 + `incrementally` | 下载财务 |
|
||||
| `download_financial_data2` | `stock_list` `table_list` `start_time` `end_time` | 批量下载财务 |
|
||||
| `get_etf_info` | 无 | ETF 信息 |
|
||||
| `get_ipo_info` | `start_time` `end_time` | IPO 信息 |
|
||||
| `get_option_list` | `undl_code` `dedate` `opttype` `isavailavle`(bool) | 期权列表 |
|
||||
| `get_his_option_list` | `undl_code` `dedate` | 历史期权 |
|
||||
| `get_his_option_list_batch` | `undl_code` `start_time` `end_time` | 批量历史期权 |
|
||||
| `get_divid_factors` | `stock_code` 可选 `start_time`/`end_time` | 除权除息因子 |
|
||||
|
||||
**`get_divid_factors` 参数说明(重要)**:
|
||||
`ContextInfo` 桩签名是 `get_divid_factors(marketAndStock, date='')`——**只收 2 个参数**(代码 + 单个日期)。适配器接受 `start_time`/`end_time` 以保持接口兼容,但实际只把 `end_time`(或 `start_time`)作为单个 `date` 传入。
|
||||
|
||||
### 3.7 因子 / 模型
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `call_formula` | `formula_name` `stock_code` `period` `start_time` `end_time` `count` `dividend_type` `extend_param`(dict) | 调用公式 |
|
||||
| `subscribe_formula` | 同上 | 订阅公式 |
|
||||
| `unsubscribe_formula` | `request_id` | 取消订阅 |
|
||||
| `get_formula_result` | `request_id` `start_time` `end_time` `count` `timeout_second` | 取公式结果 |
|
||||
| `gen_factor_index` | `data_name` `formula_name` `vars` `sector_list`(list) `start_time` `end_time` `period` `dividend_type` | 生成因子 |
|
||||
|
||||
### 3.8 龙虎榜 / 股东 / 换手率 / 行业
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_longhubang` | `stock_list`(list) `start_time` `end_time` `count`(int) | 龙虎榜明细(DataFrame)|
|
||||
| `get_top10_share_holder` | `stock_list`(list) `data_name`("holder"/"flow_holder") `start_time` `end_time` `report_type`("report_time"/"announce_time") | 十大股东 |
|
||||
| `get_holder_num` | `stock_list`(list) `start_time` `end_time` `report_type` | 股东户数 |
|
||||
| `get_turnover_rate` | `stock_code`(list) `start_time` `end_time`(均 8 位 YYYYMMDD)| 区间换手率(DataFrame)|
|
||||
| `get_industry` | `industry_name`(str) | 行业成分股 |
|
||||
| `get_his_st_data` | `stock_code`(str) | 历史 ST 状态 |
|
||||
|
||||
### 3.9 期权定价 / 隐含波动率
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `bsm_price` | `opt_type`("C"/"P") `target_price`(数值或 list) `strike_price` `risk_free` `sigma` `days` `dividend`(默认0) | B-S-M 期权定价(可批量)|
|
||||
| `bsm_iv` | `opt_type` `target_price` `strike_price` `option_price` `risk_free` `days` `dividend` | 隐含波动率反推 |
|
||||
| `get_option_iv` | `opt_code`(str) | 单只期权隐含波动率 |
|
||||
| `get_option_detail_data` | `stockcode`(str) | 期权合约详情 |
|
||||
| `get_option_undl_data` | `undl_code_ref`(str,空=全市场) | 标的下所有期权 |
|
||||
| `get_option_undl` | `opt_code`(str) | 期权的标的代码 |
|
||||
|
||||
### 3.10 财务扩展 / 因子库
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_raw_financial_data` | `field_list`(list) `stock_list`(list) `start_time` `end_time` `report_type` `data_type`("dict"/"frame") | 原始财务(未字段对齐)|
|
||||
| `get_factor_data` | `field_list`(list) `stock_list`(list) `start_date` `end_date` | 因子库数据 |
|
||||
| `get_his_index_data` | `stock_code`(str) | 历史指数权重 |
|
||||
|
||||
### 3.11 期货 / 合约 / 资金流
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_main_contract` | `code_market`(str) | 主力合约 |
|
||||
| `get_his_contract_list` | `market`(str) | 历史合约列表 |
|
||||
| `get_date_location` | `date` | 日期在交易日历的位置 |
|
||||
| `get_ETF_list` | `market` `stock_code` `type_list`(list) | ETF 列表 |
|
||||
| `get_north_finance_change` | `period` | 北向资金流入流出 |
|
||||
| `get_hkt_statistics` | `stock_code` | 港股通统计 |
|
||||
| `get_hkt_details` | `stock_code` | 港股通明细 |
|
||||
|
||||
### 3.12 板块管理 / 基础查询
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `create_sector` | `sector_name` `stock_list`(list) | 创建/更新自定义板块(写操作)|
|
||||
| `get_stock_name` | `stock` | 股票名称(如「平安银行」)|
|
||||
| `get_stock_type` | `stock` | 股票类型 |
|
||||
| `get_last_close` | `stock` | 昨收价 |
|
||||
| `get_last_volume` | `stock` | 昨量 |
|
||||
| `get_open_date` | `stock` | 上市日期 |
|
||||
| `get_contract_expire_date` | `stock` | 到期日(股票返回 99999999)|
|
||||
| `get_contract_multiplier` | `stockcode` | 合约乘数 |
|
||||
| `get_float_caps` | `stockcode` | 流通市值 |
|
||||
| `get_total_share` | `stockcode` | 总股本 |
|
||||
| `get_turn_over_rate` | `stockcode` | 换手率(单值版)|
|
||||
| `get_weight_in_index` | `mtkindexcode` `stockcode` | 指数中权重 |
|
||||
| `get_svol` | `stock` | |
|
||||
| `get_bvol` | `stock` | |
|
||||
| `get_risk_free_rate` | `index`(int, 默认-1) | 无风险利率 |
|
||||
| `get_close_price` | `market` `stock_code` `real_timetag` `period`(默认86400000) `divid_type`(默认0) | 指定时点收盘价 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 账户 / 持仓 / 委托
|
||||
|
||||
下列方法的 `account_id` 参数均可选(不传则用服务端配置的账号)。也接受 `account`(对象/dict)。
|
||||
|
||||
### `get_asset`
|
||||
- **别名**:`query_stock_asset`
|
||||
- **参数**:`account_id`(str, 可选)
|
||||
- **返回**:`{"cash":..., "total_asset":..., "market_value":..., "account_id":...}`
|
||||
- **实现**:`get_trade_detail_data(account, type, "ASSET")`。
|
||||
|
||||
### `get_positions`
|
||||
- **别名**:`query_stock_positions`
|
||||
- **参数**:`account_id`(str, 可选)
|
||||
- **返回**:`dict[code -> {stock_code, stock_name, volume, available, cost, ...}]`
|
||||
- **实现**:`get_trade_detail_data(account, type, "POSITION")`。
|
||||
- **容错**:QMT 上下文未绑定时报错,适配器降级为返回 `{}`。
|
||||
|
||||
### `query_stock_position`
|
||||
- **参数**:`account_id`(可选) `stock_code`(str, 必填) 或 `code`
|
||||
- **返回**:单个持仓 dict(同上 value 结构),无持仓返回 `None`。
|
||||
|
||||
### `query_orders`
|
||||
- **参数**:`account_id`(可选) `strategy_name`(str, 默认 `""` 返回全部) `cancelable_only`(bool)
|
||||
- **返回**:`list[OrderSnapshot]`,每项含 `order_sys_id`/`user_order_id`/`stock_code`/`action`/`volume`/`traded_volume`/`status`/`price` 等。
|
||||
- **实现**:`get_trade_detail_data(account, type, "ORDER", strategy)`。
|
||||
- **strategy_name 陷阱(重要)**:`get_trade_detail_data` 按 `strategy_name` 过滤委托——下单时用的 strategy_name 必须和查询时一致。默认传 `""` 返回全部委托(不按 strategy_name 过滤)。如需过滤,显式传 `strategy_name`。
|
||||
- **容错**:QMT 上下文未绑定时报错,降级为 `[]`。
|
||||
|
||||
### `query_trades`
|
||||
- **参数**:`account_id`(可选) `strategy_name`(str, 默认 `""` 返回全部)
|
||||
- **返回**:成交明细 `list`。
|
||||
- **strategy_name 陷阱**:同 `query_orders`,默认 `""` 返回全部成交。
|
||||
|
||||
---
|
||||
|
||||
## 4.5 官方交易查询函数(Big QMT 运行时注入)
|
||||
|
||||
这些函数和 `passorder` 一样由 Big QMT 进程在运行时注入全局命名空间,**不在 ContextInfo 桩里**。函数名严格按官方文档(`trading_function.html`)。无对应权限(如两融账户)时降级为空列表。
|
||||
|
||||
| 方法 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `get_value_by_order_id` | `order_id`(必填)| 按 order_id 查委托详情 |
|
||||
| `get_last_order_id` | `account_id`(可选) | 最近委托号 |
|
||||
| `get_ipo_data` | `account_id`(可选) | 新股数据 |
|
||||
| `get_new_purchase_limit` | `account_id`(可选) | 新股申购额度 |
|
||||
| `get_history_trade_detail_data` | `account_id`(可选) `detail_type`("DEAL"/"ORDER") `start_date` `end_date` | 历史成交明细 |
|
||||
| `get_assure_contract` | `account_id`(可选) | 融资标的(担保品)合约 |
|
||||
| `get_enable_short_contract` | `account_id`(可选) | 融券标的合约 |
|
||||
| `get_unclosed_compacts` | `account_id`(可选) | 未平仓合约(负债)|
|
||||
| `get_closed_compacts` | `account_id`(可选) | 已平仓合约 |
|
||||
| `get_debt_contract` | `account_id`(可选) | 负债合约 |
|
||||
| `get_option_subject_position` | `account_id`(可选) | 期权标的持仓 |
|
||||
| `get_comb_option` | `account_id`(可选) | 组合期权 |
|
||||
| `get_hkt_exchange_rate` | 无 | 港股通汇率 |
|
||||
|
||||
> **融资融券查询的正确方式**:官方文档明确 `get_trade_detail_data` 的合法 `strDatatype` 只有 6 个(`ACCOUNT`/`POSITION`/`POSITION_STATISTICS`/`ORDER`/`DEAL`/`TASK`)。两融查询必须用上述独立函数,不要传 `"CREDIT"` 等字符串。
|
||||
|
||||
---
|
||||
|
||||
## 5. 持仓同步
|
||||
|
||||
### `sync_positions`
|
||||
- **参数**:`account_id`(可选) `reason`(str, 默认 "rpc")
|
||||
- **返回**:`AccountSnapshot`(含 asset + positions)
|
||||
- **用途**:主动触发把当前持仓快照写入 Redis(key `bigqmt:positions:{account_id}`),供客户端缓存。
|
||||
- **注意**:属 `LISTENER_DEFERRED_METHODS`,在 redis 传输 + listener 模式下会延迟到 adjust 线程执行(避免阻塞收包线程)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 下单 / 撤单(默认关闭)
|
||||
|
||||
> ⚠️ 默认 `rpc_allow_order_methods=False`,调用会被 `PermissionError` 拒绝。确认账号/风控/接入方后,在配置里设 `"rpc_allow_order_methods": True` 开启。
|
||||
|
||||
### `submit_order`
|
||||
- **别名**:`order_stock` / `order_stock_async`
|
||||
- **参数**:
|
||||
- `stock_code`(str, 必填)
|
||||
- `action`(str):`"BUY"` / `"SELL"`;或 `order_type`(`23`/`STOCK_BUY`/`BUY` 买,`24`/`STOCK_SELL`/`SELL` 卖)
|
||||
- `volume`(int, 必填) 或 `order_volume`
|
||||
- `price`(float)
|
||||
- `price_type`(str, 默认 `"LIMIT"`):`LIMIT`(11)/`LATEST`(5)/对手价(44) 等
|
||||
- `account_id`(可选) `strategy_name` `signal_id` `remark`/`order_remark`
|
||||
- **返回**:`{"order_sys_id":..., "user_order_id":...}`
|
||||
- **实现**:`passorder(op_type, combo_type, account, code, price_type, price, volume, ..., quicktrade=2)`。
|
||||
|
||||
### `cancel_order`
|
||||
- **别名**:`cancel_order_stock` / `cancel_order_stock_sysid`
|
||||
- **参数**:`order_sys_id` 或 `order_sysid` 或 `order_id`(必填)可选 `user_order_id` `market`
|
||||
- **返回**:撤单结果。
|
||||
|
||||
---
|
||||
|
||||
## 7. MiniQMT 风格别名
|
||||
|
||||
旧代码若用 MiniQMT 方法名,调用时自动映射(无需改业务代码):
|
||||
|
||||
| 别名(MiniQMT)| 映射到 |
|
||||
|----------------|--------|
|
||||
| `get_full_tick` | `get_ticks` |
|
||||
| `get_instrument_detail` / `get_instrumentdetail` | `get_instrument` |
|
||||
| `getDividFactors` | `get_divid_factors` |
|
||||
| `query_stock_asset` | `get_asset` |
|
||||
| `query_stock_positions` | `get_positions` |
|
||||
| `query_stock_orders` | `query_orders` |
|
||||
| `query_stock_trades` | `query_trades` |
|
||||
| `order_stock` / `order_stock_async` | `submit_order` |
|
||||
| `cancel_order_stock` / `cancel_order_stock_sysid` | `cancel_order` |
|
||||
|
||||
> 客户端用 `xtquant_compat` 时,`xt_trader.query_stock_positions(acc)`、`xtdata.get_full_tick([...])` 等调用会自动走别名映射,最终命中上表方法。
|
||||
|
||||
---
|
||||
|
||||
## 8. 大 QMT 环境的能力边界(重要)
|
||||
|
||||
核对 QMT 官方文档(`trading_function.html` / `data_function.html`)、ContextInfo IDE 桩(`_PyContextInfo.py`)、原生 xtdata SDK(`bin.x64/.../xtquant/xtdata.py`)三处后,确认:
|
||||
|
||||
| 能力 | 大 QMT(完整交易端)| MiniQMT / xtdata SDK |
|
||||
|------|--------------------|---------------------|
|
||||
| 行情快照(`get_full_tick`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| K线(`get_market_data_ex` 等)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 合约详情(`get_instrumentdetail`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 板块内股票(`get_stock_list_in_sector`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 交易日历(`get_trading_dates`)| ✅ ContextInfo | ✅ xtdata |
|
||||
| 龙虎榜/股东/换手率(`get_longhubang` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 期权定价(`bsm_price`/`bsm_iv`/`get_option_iv`)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 北向资金/港股通(`get_north_finance_change` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| 基础查询(`get_stock_name`/`get_float_caps` 等)| ✅ ContextInfo | ❌ xtdata 无 |
|
||||
| **板块列表**(`get_sector_list`)| ⚠️ fallback 常用板块 | ✅ xtdata(需连行情服务)|
|
||||
| **节假日**(`get_holidays`)| ⚠️ 从日历反推 | ✅ xtdata(需连行情服务)|
|
||||
| `get_markets` | 合成 4 市场 | 无此函数 |
|
||||
| `get_market_last_trade_date` | 从日历派生 | 无此函数 |
|
||||
| 交易(下单/撤单/查持仓)| ✅ passorder + get_trade_detail_data | ✅ XtQuantTrader |
|
||||
|
||||
**结论**:除「板块完整列表」「节假日原始数据」在大 QMT 端只能 fallback 外,其余 API 在大 QMT 环境下均能返回真实数据。需要原始板块/假日数据时,需额外跑一个 MiniQMT 进程(让 `xtdata.get_client()` 能连上)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误约定
|
||||
|
||||
RPC 响应统一为 `{"ok": bool, "data": ..., "error": "..."}`:
|
||||
- `ok=True`:`data` 为方法返回值(DataFrame/Series 已序列化,客户端自动还原)。
|
||||
- `ok=False`:`error` 为错误信息。常见:
|
||||
- `rpc method is not allowed: X` —— 方法不在白名单(`rpc_listener_methods` 配置)。
|
||||
- `order rpc methods are disabled` —— 下单未开启。
|
||||
- `ContextInfo.X is not available` —— 该 ContextInfo 方法在当前 QMT 版本不存在。
|
||||
- `无法连接行情服务` —— 原生 xtdata SDK 连不上(仅 sector_list/holidays 的 SDK 路径)。
|
||||
@@ -0,0 +1,199 @@
|
||||
# 可插拔 RPC 传输层
|
||||
|
||||
更新时间:2026-07-29
|
||||
|
||||
## 目标
|
||||
|
||||
在 Redis RPC 之上加一层抽象,支持快速切换传输后端,按延迟/部署场景选择:
|
||||
|
||||
| 传输 | 同机 p50 | 跨机 | 依赖 | 适用场景 |
|
||||
|------|---------|------|------|---------|
|
||||
| `redis`(默认)| ~12ms | ✅ | redis-py | 生产默认,跨机也能用 |
|
||||
| **`zmq`** | **~0.2ms** | ✅(tcp) | pyzmq | 同机低延迟,主优化目标 |
|
||||
| `mysql` | ~50ms+ | ✅ | DBUtils + 驱动 | 兼容兜底(Redis/ZMQ 都不可用时)|
|
||||
| `shm` | — | ❌ | — | 留接口未实现(需 Python 3.8+)|
|
||||
|
||||
切换传输**只改一个配置字段 `transport`**,业务代码(handlers / `to_jsonable` / `process_request`)零改动。
|
||||
|
||||
## 无 redis 版本(QMT 沙箱拒绝 import redis 时用)
|
||||
|
||||
如果 QMT 环境**拒绝 `import redis`**(券商白名单拦截),用 `bigqmt_no_redis/` 目录下的无 redis 版本:
|
||||
|
||||
- `bigqmt_no_redis/zmq_transport.py` — 自包含的 ZMQ transport,内联所有编码函数(`decode_text`/`encode_rpc_request_payload`/`decode_rpc_request_payload`),**完全不 import redis_common/redis_rpc**,去掉 redis 服务发现(用静态派生端口)
|
||||
- `bigqmt_no_redis/DRYRUN_no_redis.py` — 无 redis 的 DRYRUN 入口,强制 `transport=zmq` + `background_threads=True`,只加载 zmq transport
|
||||
|
||||
**用法**:QMT 策略编辑器加载 `BIGQMT_DRYRUN_NO_REDIS.py`(同步到 QMT 目录时用这个文件名),RPC 走纯 ZMQ,零 redis 依赖。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
业务层(不变) BigQmtRpcHandlers / process_request / to_jsonable
|
||||
│ request/response dict (JSON)
|
||||
┌───────────────▼────────────────┐
|
||||
│ RpcTransport 抽象接口 │
|
||||
│ send_request / start_receiving│
|
||||
│ send_response / stop │
|
||||
└───┬────────┬─────────┬─────────┘
|
||||
┌──────────▼┐ ┌────▼───┐ ┌──▼─────┐ ┌──────┐
|
||||
│ Redis │ │ ZMQ │ │ MySQL │ │ SHM │
|
||||
│ (默认) │ │ (低延迟)│ │(兼容) │ │(stub)│
|
||||
└────────────┘ └────────┘ └────────┘ └──────┘
|
||||
```
|
||||
|
||||
传输层只负责"请求/响应怎么在网络上走",不碰业务语义。抽象接口见
|
||||
`src/bigqmt_signal_trader/transports/base.py`:
|
||||
|
||||
- `send_request(request, timeout)` — 客户端发请求并阻塞等响应
|
||||
- `start_receiving(on_request)` — 服务端开始接收,每个请求回调 `on_request`
|
||||
- `send_response(request, response)` — 服务端回包(路由信息从 request 读)
|
||||
- `stop()` — 释放资源
|
||||
|
||||
## 配置怎么切换
|
||||
|
||||
### 服务端(QMT 进程)
|
||||
|
||||
在 `bigqmt_signal_trader_local_config.py` 的 `BIGQMT_REDIS_CONFIG` 里设置 `transport` 字段:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"transport": "zmq", # 默认 "redis"。可选: redis/zmq/mysql/shm
|
||||
"zmq": {
|
||||
"bind_address": "tcp://127.0.0.1:5560", # Windows 同机使用 TCP 回环
|
||||
},
|
||||
"rpc_background_threads": False,
|
||||
"schedule_adjust": True,
|
||||
"schedule_adjust_interval": "100nMilliSecond",
|
||||
}
|
||||
```
|
||||
|
||||
> **注意(ZMQ)**:ZMQ 支持由 QMT 官方 `adjust` 回调排空请求。低延迟实盘建议设置
|
||||
> `rpc_background_threads=False`,并把 `schedule_adjust_interval` 设置为
|
||||
> `100nMilliSecond`,避免后台 Python 线程受 QMT 进程 GIL 调度影响。
|
||||
> MySQL/SHM 仍会自动启用后台接收线程。
|
||||
> 端口不写时按账号自动派生 `tcp://127.0.0.1:{15560 + 账号%100}`(同机回环)。
|
||||
> Linux 同机也可使用 `ipc:///tmp/bigqmt_rpc.sock`。
|
||||
|
||||
`transport=mysql` 时可额外配置:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG["mysql"] = {
|
||||
"driver": "pymysql", # 或 mysql.connector
|
||||
"host": "127.0.0.1", "port": 3306,
|
||||
"user": "rpc", "password": "***", "database": "bigqmt_rpc",
|
||||
"pool_config": {
|
||||
"mincached": 1, "maxcached": 4,
|
||||
"maxshared": 3, "maxconnections": 8,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**不指定 `transport` = `"redis"` = 完全保持现状。**
|
||||
|
||||
### 客户端
|
||||
|
||||
`BigQmtRpcClient` 同样读 `transport` 字段(从 client config 或环境变量 `BIGQMT_RPC_TRANSPORT`):
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"transport": "zmq",
|
||||
"zmq": {"connect_address": "tcp://127.0.0.1:5560"}, # 指向服务端 bind 地址
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
或环境变量:`export BIGQMT_RPC_TRANSPORT=zmq`
|
||||
|
||||
## 各传输说明
|
||||
|
||||
### Redis(`transport: redis`,默认)
|
||||
|
||||
完全保持原有行为:
|
||||
- 客户端 `RPUSH` 请求到 `bigqmt:rpc:queue:{account_id}` → `BLPOP` 响应 list
|
||||
- 服务端 `brpop` 取请求 → 三路回包(`SETEX` key + `RPUSH` list + `PUBLISH` channel)
|
||||
- 保留 b64 股票代码混淆编码
|
||||
|
||||
现有 14 个测试、所有模板字符串、配置全部不变。
|
||||
|
||||
### ZMQ(`transport: zmq`,低延迟)
|
||||
|
||||
- 服务端 ROUTER socket bind,客户端 DEALER socket connect
|
||||
- 用 ZMQ 原生 identity 路由(`reply_*` 字段忽略)
|
||||
- Windows 用 `tcp://127.0.0.1:port`(ZMQ 在 Windows 不支持 `ipc://`)
|
||||
- Linux 同机可用 `ipc://` 更快(绕过 TCP 栈)
|
||||
- 实测同机 tcp 回环:**p50 = 0.2ms**(比 Redis 快 ~60 倍);
|
||||
在大 QMT 全终端进程内实测 ping **p50 ≈ 0.3ms**(20 次 0 个 >50ms)。
|
||||
|
||||
注意:ZMQ transport 的 `stop()` 由 ROUTER 接收线程自己关闭 socket(Windows
|
||||
上跨线程 close socket 会触发 signaler 断言)。
|
||||
|
||||
> **⚠️ 在大 QMT 进程内跑 zmq 的两个必要条件(都已自动处理,勿手动关):**
|
||||
>
|
||||
> 1. **`background_threads` 必须为 True** —— ZMQ 的 ROUTER 只有在后台线程里才
|
||||
> 起接收循环;否则只 bind 不收包,客户端全部超时。`_build_rpc_service` 已对
|
||||
> 非 redis 传输**自动置 True**,无需在 config 里写。
|
||||
> 2. **`schedule_adjust` 必须保持开** —— `run_time("adjust", interval)` 是我们注册的
|
||||
> **RPC 队列 drain 定时器**(`adjust` 不是 QMT 内置回调,QMT 只自动调 init/handlebar;
|
||||
> handlebar 里 `return adjust(...)`)。deferred 档的交易查询要靠它在主线程执行;关掉就没
|
||||
> 有主线程 drain 点。它也是后台线程拿 GIL 窗口的节奏源:`schedule_adjust_interval` 越小
|
||||
> inline 尾延迟越低(500ms→~490ms,100ms→热循环~100ms 但烧 CPU,**200ms 折中**)。
|
||||
> 详见 `docs/BIG_QMT_REDIS_RPC.md` 的「延迟模式」。
|
||||
|
||||
### MySQL(`transport: mysql`,兼容兜底)
|
||||
|
||||
- 用 `requests` / `responses` 两张表轮询
|
||||
- 通过 **DBUtils `PooledDB`** 连接池管理连接,避免频繁开关
|
||||
- 跨驱动:支持 pymysql / mysql.connector / sqlite3(paramstyle 自动适配)
|
||||
- `DELETE-then-INSERT` 写响应,兼容 MySQL 和 sqlite
|
||||
- 延迟较高(~50ms+,受轮询间隔限制),仅作 Redis/ZMQ 不可用时的兜底
|
||||
|
||||
连接池配置(`pool_config`):
|
||||
```python
|
||||
"pool_config": {
|
||||
"mincached": 1, # 空闲连接数
|
||||
"maxcached": 4, # 最大缓存连接
|
||||
"maxshared": 3, # 最大共享连接
|
||||
"maxconnections": 8, # 最大连接数
|
||||
}
|
||||
```
|
||||
|
||||
注意:sqlite 连接线程绑定,sqlite 测试需 `check_same_thread=False` + `maxshared=0`。
|
||||
|
||||
### SHM(`transport: shm`,未实现)
|
||||
|
||||
留接口,`send_request` 会抛 `TransportError`。Python 3.8+ 的
|
||||
`multiprocessing.shared_memory` 或自定义 mmap 环形缓冲区可后续实现。
|
||||
|
||||
## 实测延迟对比(同机)
|
||||
|
||||
基准脚本:`python bench_transports.py -n 100`
|
||||
|
||||
```
|
||||
redis n=100 min=10.86 p50=12.22 p90=14.77 p99=290.60 avg=24.99 ms
|
||||
zmq n=100 min=0.15 p50=0.21 p90=0.33 p99=20.62 avg=0.43 ms
|
||||
```
|
||||
|
||||
## 切换检查清单
|
||||
|
||||
1. 服务端和客户端的 `transport` 字段**必须一致**
|
||||
2. zmq:不写 `zmq` 块时端口按账号自动派生 `tcp://127.0.0.1:{15560+账号%100}`,
|
||||
两端一致;要跨机或自定义端口时才写 `connect_address`/`bind_address`
|
||||
3. zmq/mysql:`background_threads` 由 `_build_rpc_service` 自动开,**不用手动配**
|
||||
4. zmq(大 QMT 进程内):**保持 `schedule_adjust` 开**(默认就是开),否则 adjust
|
||||
空转占满 GIL 饿死接收线程 → RPC 超时
|
||||
5. mysql:两端连同一个数据库,schema 自动创建
|
||||
6. 切回 redis:删掉 `transport` 字段或设为 `"redis"`,无需改其他配置
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/bigqmt_signal_trader/transports/
|
||||
├── __init__.py # 导出 build_transport, RpcTransport
|
||||
├── base.py # RpcTransport 抽象基类
|
||||
├── redis_transport.py # Redis 实现(默认,零行为变更)
|
||||
├── zmq_transport.py # ZMQ ROUTER/DEALER 实现
|
||||
├── mysql_transport.py # MySQL + DBUtils 连接池
|
||||
├── shm_transport.py # 共享内存 stub
|
||||
└── factory.py # build_transport(name, config) 工厂
|
||||
```
|
||||
|
||||
测试:`tests/bigqmt_signal_trader/test_transports.py`(9 个测试,含 ZMQ/MySQL 往返)
|
||||
@@ -0,0 +1,174 @@
|
||||
# subscribe_whole_quote 全推行情 — 真机联调验证报告
|
||||
|
||||
> 日期:2026-08-10(周一,交易日)
|
||||
> 环境:本地客户端 + Windows QMT 服务端(内网联调)
|
||||
> 版本:`feat/impl_subscribe_whole_quote` 分支(commit 7e0d67d 及之后修复)
|
||||
> 文档:`docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md`(设计),本文档为真实环境验证结果
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境与部署
|
||||
|
||||
### 1.1 拓扑
|
||||
|
||||
```
|
||||
本地客户端 (venv, Python 3.10)
|
||||
└─ bigqmt_signal_trader (editable 安装, 指向仓库 src/)
|
||||
├─ BigQmtRpcClient ── redis (内网, db5) ──► 服务端 RPC
|
||||
└─ RedisQuotePushChannel (pub/sub bigqmt:quote_push:{acct}:{topic})
|
||||
▲
|
||||
│ redis pub/sub
|
||||
Windows 服务端 (QMT 交易端, Python 3.6)
|
||||
└─ <QMT python 目录>/ (策略 BIGQMT_REDIS_DRYRUN)
|
||||
├─ bigqmt_signal_trader_strategy.py ── 启动 QuoteSubscriptionManager
|
||||
├─ quote_subscription_manager.py ── 引用计数订阅管理
|
||||
└─ quote_push_channel.py ── 推送通道(服务端, json 兜底编码)
|
||||
```
|
||||
|
||||
### 1.2 部署动作
|
||||
|
||||
| 步骤 | 内容 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 修复本地开发 venv(uv 重建 Python 3.10) | ✅ |
|
||||
| 2 | `uv pip install -e /path/xtquant_big_convert[redis,msgpack]` 部署到 venv | ✅ |
|
||||
| 3 | 全量替换服务端 40 个 bigqmt 文件为本地当前版本(逐文件 MD5 校验一致,保留 `local_config.py` 生产配置) | ✅ |
|
||||
| 4 | 服务端 `full_tick_cache_enabled: True`(既有配置,无需改动) | ✅ |
|
||||
|
||||
> 关键教训:初期误判"服务端文件已是最新"(大小写哈希比对看串),实际除 3 个新文件外其余 19 个均为旧版,导致订阅 RPC 报 `method is not allowed`。全量替换 + 程序化 MD5 校验后解决。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验证过程与结果
|
||||
|
||||
### 2.1 阶段一:基础链路(09:00-09:06)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 1 | RPC 链路存活 | ✅ | `get_full_tick` 2.1s 返回盘前快照 |
|
||||
| 2 | `subscribe_whole_quote` RPC 允许 | ✅ | 282ms 返回 seq(修复前报 `method is not allowed`,因服务端旧版 `redis_rpc.py` 缺 `READ_METHODS |= QUOTE_SUBSCRIPTION_METHODS`) |
|
||||
| 3 | 初始快照 prime | ✅ | 订阅后立即回调完整快照(lastPrice 11.19 昨收) |
|
||||
| 4 | redis 推送通道 | ✅ | 模拟发布 → 客户端实时收到 |
|
||||
| 5 | 竞价真实推送(09:15:27) | ✅ | stockStatus=12 集合竞价,盘口 397/23 |
|
||||
|
||||
**发现 Bug #1:msgpack/json 编码不对称**
|
||||
- 现象:客户端推送线程 `msgpack.exceptions.ExtraData: unpack(b) received extra data` 崩溃
|
||||
- 根因:服务端 QMT 内置 Python **无 msgpack**(json 兜底编码),客户端**有 msgpack**(按 msgpack 解码 json 文本 → 首字节 `{` 被当整数 + 尾随字节)
|
||||
- 修复:`decode_push_payload` msgpack 失败时回退 json(测试驱动:红→绿)
|
||||
|
||||
### 2.2 阶段二:数据正确性(09:45-09:48,连续竞价)
|
||||
|
||||
**单标的 000001.SZ(60s)**:21 笔推送,间隔 min=2.21s / max=3.11s / avg=2.96s,>4s 的 0 个;time/volume/amount 单调性零违规。
|
||||
|
||||
**20 只活跃股(沪深300 成交额 top20, 120s)**:
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| 每只推送次数 | 41~42 次(120s / 3s ≈ 40,高度一致) |
|
||||
| 最大间隔 | 3.1~3.3s(全部 < 4s) |
|
||||
| 平均间隔 | 2.93~2.99s |
|
||||
| gap>4s | 0(全部 20 只) |
|
||||
| 数据单调性(vol/amt/time) | 0 违规(全部 20 只) |
|
||||
|
||||
结论:**每 3 秒一份推送、零丢失、零乱序、零数据回退**,覆盖主板/创业板/科创板。
|
||||
|
||||
### 2.3 阶段三:多标的规模(09:36-09:40)
|
||||
|
||||
| 标的数 | 订阅耗时 | 初始快照 | 60s 增量推送 | 覆盖 | 错误 |
|
||||
|---|---|---|---|---|---|
|
||||
| 20 只 | 1.1s | 4 次/20 只 | 61 次 | 20/20 | 0 |
|
||||
| 50 只 | 0.8s | 7 次/50 只 | 140 次 | 50/50 | 0 |
|
||||
| 100 只 | 2.1s | 19 次/100 只 | 336 次 | 100/100 | 0 |
|
||||
|
||||
结论:推送量随标的数线性增长,覆盖完整,订阅耗时稳定,零错误。
|
||||
|
||||
### 2.4 阶段四:心跳与超时回收(A 组,09:53-09:56)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| A1 | 正常心跳保活 | ✅ | 90s 31 次推送,间隔 2.90s |
|
||||
| A2 | 心跳超时回收(kill 不发退订) | ✅ | 之后同 client_id 重连安全 |
|
||||
| A4 | 同 client_id 重连恢复 | ✅ | 45s 16 次推送,推送恢复 |
|
||||
| A5 | 正常退订 + 再订阅 | ✅ | 退订后 10s 0 次,再订阅 11 次恢复 |
|
||||
|
||||
### 2.5 阶段五:多 client 并发(B 组,09:57-10:00)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| B1 | 两 client 同组合 | ✅ | A=7 B=7 各自收推 |
|
||||
| B2 | 组合去重共享订阅 | ✅ | 推送节奏一致(7=7),服务端只建 1 个订阅 |
|
||||
| B3 | 一方退订对方持续 | ✅ | A 退订后 A=0 B=5 |
|
||||
| B4 | 全退订拆订阅 | ✅ | 无推送 |
|
||||
| B5 | 不同组合互不干扰 | ✅ | 000001 只有 A 收,000002 只有 B 收 |
|
||||
| B6 | 同 client 多 sub_id | ✅(修复后) | 见 Bug #2 |
|
||||
| B7 | 混合组合隔离 | ✅ | 000001 双方收,000002 只有 E 收 |
|
||||
|
||||
**发现 Bug #2:客户端订阅线程泄漏**
|
||||
- 现象:B6 首测失败(退订 sub1 后 sub2 偶发收不到),深挖发现订阅/退订时 `_sync_subscriber_locked` 无脑新起线程、旧线程不停止(3 个 `bigqmt-quote-push-sub` 线程并存),多线程消费同一 pubsub 有竞态
|
||||
- 修复:topic 集合 diff——不变则复用,变化则先 stop 旧线程再起新线程,变空则停(测试驱动)
|
||||
|
||||
### 2.6 阶段六:异常与边界(C 组,10:05-10:18)
|
||||
|
||||
| # | 验证项 | 结果 | 证据 |
|
||||
|---|---|---|---|
|
||||
| C1 | 重复订阅幂等 | ✅ | 15s 11 次推送 |
|
||||
| C2 | 空代码列表 | ✅ | `ValueError: code_list is required` |
|
||||
| C3 | 非法代码容错 | ✅ | 未崩,无推送 |
|
||||
| C4 | 同 topic 多 sub_id 退订隔离 | ✅(修复后) | 见 Bug #3 |
|
||||
| C5 | 拔线重连 | ✅ | A2/A4 覆盖 |
|
||||
|
||||
**发现 Bug #3:服务端引用计数粒度错误**
|
||||
- 现象:C4 首测失败——同 client 两个 sub_id 订阅同一组合,退订一个后,另一个的推送停止(组合被整体拆掉)
|
||||
- 根因:`_Combo.clients` 按 `client_id` 粒度,但订阅单元是 `(client_id, sub_id)`;退订一个 sub 时 `_remove_client_locked` 把整个 client 移出,组合错误拆解
|
||||
- 修复:改为 `(client_id, sub_id)` 粒度(含 subscribe/unsubscribe/keepalive/reaper 四处)(测试驱动,新增单测覆盖)
|
||||
|
||||
### 2.7 阶段七:服务端重启恢复(C6,10:27-10:31)
|
||||
|
||||
| 验证轮次 | 客户端行为 | 结果 |
|
||||
|---|---|---|
|
||||
| C6v1(修复前) | 无自动重放 | ❌ 重启后推送永久中断(140s 静默) |
|
||||
| C6v2(keepalive 失败重放) | 只靠 keepalive 失败检测 | ❌ 未触发——重启窗口内 keepalive 被 redis 队列兜住"成功",检测不到 |
|
||||
| C6v3(静默检测重放) | 推送静默超阈值自动重放 | ✅ 中断 42s 后自动恢复,后续 27 次推送正常 |
|
||||
|
||||
**发现 Bug #4:服务端重启后订阅丢失,客户端无自动恢复**
|
||||
- 根因:文档承诺的"client 检测断连→自动重放"**从未实现**(`replay_subscriptions` 无生产调用点);且仅靠 keepalive 失败检测不可靠(redis 请求队列在重启窗口内缓冲,keepalive 不抛异常)
|
||||
- 修复:心跳循环增加**推送静默检测**——订阅期间超过 N 个心跳周期(默认 10,≈30s)无推送到达,自动重放订阅(测试驱动,新增 2 个单测)
|
||||
|
||||
### 2.8 阶段八:配置验证(D 组,10:32)
|
||||
|
||||
| # | 验证项 | 结果 |
|
||||
|---|---|---|
|
||||
| D1 | `BIGQMT_QUOTE_HEARTBEAT_SECONDS` env 生效 | ✅ 1s 心跳,30s 10 次推送 |
|
||||
| D2 | `heartbeat_timeout_seconds` 配置生效 | 单测覆盖(修改需重启策略,跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 发现并修复的 Bug 汇总(4 个)
|
||||
|
||||
| # | Bug | 位置 | 根因 | 修复 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 推送解码崩溃 `ExtraData` | `quote_push_channel.py` | 服务端无 msgpack(json 兜底)与客户端 msgpack 解码不对称 | msgpack 失败回退 json |
|
||||
| 2 | 订阅线程泄漏/竞态 | `whole_quote_session.py` | `_sync_subscriber_locked` 每次新起线程不停止旧的 | topic 集合 diff,复用/停止 |
|
||||
| 3 | 同 client 多 sub 退订误拆组合 | `quote_subscription_manager.py` | 引用计数按 client 粒度而非 (client, sub) 粒度 | 改 (client_id, sub_id) 粒度 |
|
||||
| 4 | 服务端重启后订阅丢失 | `whole_quote_session.py` | 自动重放从未接线;keepalive 被 redis 队列兜住检测不到重启 | 推送静默检测自动重放 |
|
||||
|
||||
全部按 TDD 修复(红→绿),同步部署到服务端。
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试基线
|
||||
|
||||
- 全量:`266 passed, 3 skipped, 1 failed`
|
||||
- 唯一失败:`test_transports.py::MysqlTransportTest::test_round_trip`(`No module named 'dbutils'`,本地 venv 未装 mysql extra)——**预先存在,与本次改动无关**
|
||||
- 新增测试:
|
||||
- `test_quote_push_channel.py`:json 解码回退 2 个
|
||||
- `test_whole_quote_client.py`:线程复用/重启 4 个 + 自动重放 2 个
|
||||
- `test_quote_subscription_manager.py`:(client, sub) 粒度退订 1 个
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
1. **全链路功能正确**:订阅 RPC、初始快照 prime、redis 推送通道、增量推送(3s 节奏)、多标的(20/50/100)、多 client 共享订阅、退订隔离、心跳保活/超时回收、服务端重启自动恢复,全部通过。
|
||||
2. **数据正确性**:单标的 21/60s、20 只活跃股 41-42/120s,间隔 2.93-3.0s 稳定,零丢失、零乱序、零回退。
|
||||
3. **发现并修复 4 个真实 bug**,均经 TDD 验证,全量测试无回归。
|
||||
4. **遗留**:D2(超时配置生效)仅单测覆盖;mysql 测试环境缺 DBUtils(预先存在);`docs/SUBSCRIBE_WHOLE_QUOTE_PUSH.md` 中"自动重放"设计描述现已实现,可保持同步。
|
||||
@@ -0,0 +1,317 @@
|
||||
# subscribe_whole_quote 真推送方案(对齐 miniqmt)
|
||||
|
||||
> 状态:**待评审**(方案已成型,未动实现。实现严格按 TDD:先红→绿→回归)
|
||||
> 分支:`feat/impl_subscribe_whole_quote`
|
||||
|
||||
## 1. 背景与现状诊断
|
||||
|
||||
### 1.1 miniqmt 语义(目标行为)
|
||||
|
||||
`xtdata.subscribe_whole_quote(code_list, callback)` 在 miniqmt 里是**真订阅**:
|
||||
|
||||
- 注册后,行情服务**持续推送**,每个行情周期触发一次 `callback(data)`,`data` 是 `{code: tick_dict}` 的全推快照。
|
||||
- 返回一个 `seq`(订阅句柄);`unsubscribe_quote(seq)` 后推送停止。
|
||||
- 全市场用板块代码:`["SH"]`、`["SZ"]`、`["SH","SZ"]`(也支持 `"BJ"`、`"HK"`)。
|
||||
|
||||
### 1.2 当前实现(client 端假订阅,server 端空转)
|
||||
|
||||
- **client**(`xtquant_compat.py:781`):`subscribe_whole_quote` 只是 `publish_event("subscribe_whole_quote", ...)` 到 redis stream(`bigqmt:quote_events:{account_id}`),然后**同步调一次 `get_full_tick` 触发一次 callback** 就返回 `seq`。之后**再无任何推送**。`callback` 不被持有,纯一次性。
|
||||
- **server**:**全仓库没有任何代码消费 `quote_events` stream**,也没有任何代码调用大 QMT 的 `ContextInfo.subscribe_whole_quote` / `xtdata.subscribe_whole_quote`。事件发出去石沉大海。
|
||||
- `unsubscribe_quote(seq)` 同样只发事件 + 删 redis 订阅记录,无实际效果。
|
||||
|
||||
**结论:miniqmt 的「注册 → 持续推送 → 回调」语义当前完全没有实现。**
|
||||
|
||||
### 1.3 transport 现状
|
||||
|
||||
- `RpcTransport`(`transports/base.py`)是**纯请求/响应**模型:client `send_request`、server `start_receiving`/`send_response`。**没有 server→client 的主动推送通道**。
|
||||
- zmq transport 用 ROUTER/DEALER,请求响应式;redis transport 的 pubsub 只用于 RPC 请求/响应通道,不用于行情推送。
|
||||
|
||||
### 1.4 可复用的资产
|
||||
|
||||
- `full_tick_cache`:server 周期 `ContextInfo.get_full_tick` 拉快照写 redis,client 读缓存。**是轮询拉取,不是推送**,但有现成的 demand/TTL 机制(本方案不采用它做数据面,仅作对比参考)。
|
||||
- `BigQmtRpcHandlers`(`redis_rpc.py:333`):server 端白名单 dispatch,`market_data` 适配器持有 `ContextInfo` —— server 端订阅管理器复用此路径访问大 QMT 行情接口。
|
||||
- 参考实现:`quant-qmt-proxy` 的 `SubscriptionManager` 已验证 `xtdata.subscribe_whole_quote(["SH","SZ"], callback)` 推送模式 + 心跳超时(默认 60s)在本环境可行。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认的决策(来自讨论)
|
||||
|
||||
| # | 决策点 | 结论 |
|
||||
|---|--------|------|
|
||||
| Q1 | 数据通路 | **方案 A:真推送**。server 端大 QMT 订阅回调 → 推送通道 → client callback。新增 server→client 推送通道。 |
|
||||
| Q2 | 去重粒度 | **按组合去重**:`frozenset(code_list)` 规范化后作为订阅单元 key。不同 client 传相同集合 → 共享同一个大 QMT 订阅。 |
|
||||
| Q3 | 引用计数 & keepalive | client 分配 `client_id`,周期发 keepalive(带 `client_id`+组合);server 维护 `{组合: {client_id: last_seen}}`,**超时阈值 = 10 个心跳周期**未收到才认为该 client 消亡;组合所有 client 消亡后才真正退订大 QMT。 |
|
||||
| Q4 | server 重启恢复 | **client 重放**:client 记忆自己的订阅集合,检测到断连/server 重启后自动重放订阅;server 无状态、靠 client 重放/keepalive 重建订阅。**server 订阅表落盘不做**(实现阶段决定:client 重放已覆盖恢复路径,落盘引入 QMT 环境文件 IO 复杂度,无额外收益)。 |
|
||||
| Q5 | 大 QMT 行情源 | **ContextInfo 优先**:server 用策略进程内 `ContextInfo.subscribe_whole_quote`。"建/退订阅"收敛为可替换适配层,按真实环境实测微调(见 §7 风险 1)。 |
|
||||
| Q6 | 推送通道落地 | **zmq + redis 同阶段交付**:同一 `QuotePushChannel` 抽象下两个实现,按部署 transport 选择。 |
|
||||
| Q7 | 推送编码 | **高效编码:msgpack 优先,json 兜底**。msgpack 作为可选依赖(`optional-dependencies`),全市场推送建议安装;未装时退化为 json。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```
|
||||
┌─────────────┐ subscribe_whole_quote ┌──────────────────────────────────┐
|
||||
│ client A │ ────────────────────────► │ server (大 QMT 进程) │
|
||||
│ (xtdata) │ RPC: subscribe_whole │ QuoteSubscriptionManager │
|
||||
└─────────────┘ _quote {client_id, │ ├─ 组合去重 frozenset │
|
||||
│ codes, sub_id} │ ├─ refcount {combo: {cid: ts}} │
|
||||
┌─────────────┐ │ └─ ContextInfo/xtdata. │
|
||||
│ client B │ ────────────────────────► │ subscribe_whole_quote( │
|
||||
└─────────────┘ 同组合 → 共享同一订阅 │ codes, on_push) │
|
||||
│ └──────────┬───────────────────────┘
|
||||
keepalive RPC │ 周期心跳 {client_id, sub_id} │ 大 QMT 行情回调 on_push(data)
|
||||
──────────┼──────────────────────────► │
|
||||
│ ▼
|
||||
│ ┌──────────────────────────┐
|
||||
行情推送 │ ◄────────────────────── │ QuotePusher (PUB socket)│
|
||||
(PUB/SUB) │ topic=combo, {data} │ 按组合 topic 广播 │
|
||||
│ └──────────────────────────┘
|
||||
```
|
||||
|
||||
**三条逻辑通道**(对应三个职责):
|
||||
|
||||
1. **控制面 RPC(已有 transport 复用)**:`subscribe_whole_quote` / `unsubscribe_whole_quote` / `quote_keepalive` 三个新 RPC 方法,走现有请求/响应 transport(redis 或 zmq)。
|
||||
2. **数据面推送(新增)**:server→client 单向 PUB/SUB 通道,承载行情推送。
|
||||
3. **大 QMT 行情源**:server 端 `ContextInfo.subscribe_whole_quote`(或 `xtdata.subscribe_whole_quote`)回调。
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
### 4.1 订阅单元 key(Q2:组合去重)
|
||||
|
||||
```python
|
||||
def combo_key(code_list):
|
||||
"""规范化组合 → 唯一 key。顺序无关、大小写统一、去空白。"""
|
||||
return ",".join(sorted({str(c).strip().upper() for c in (code_list or []) if str(c).strip()}))
|
||||
```
|
||||
|
||||
- `["SH","SZ"]` 与 `["sz","SH"]` → 同一 key `"SH,SZ"`,共享同一个大 QMT 订阅。
|
||||
- 全市场 `["SH"]`、标的组合 `["000001.SZ","600000.SH"]` 都是合法 key。
|
||||
|
||||
### 4.2 client 端(`BigQmtXtData.subscribe_whole_quote` 重写)
|
||||
|
||||
- 入参 `code_list, callback` 不变(对齐 miniqmt 签名)。
|
||||
- 生成 `client_id`(进程级唯一,复用 zmq DEALER identity 或 `uuid4`,**进程生命周期内稳定**,持久化到本地文件以便重启后识别同一 client)。
|
||||
- 为本次订阅分配 `sub_id`(沿用现有 `_next_seq()`)。
|
||||
- 记录到 client 侧订阅表 `{sub_id: {codes, callback, combo_key}}`(**用于重放恢复**)。
|
||||
- 发 RPC `subscribe_whole_quote {client_id, sub_id, codes}` → server 返回 `{combo_key, push_endpoint, push_topic}`。
|
||||
- 启动/复用一个 **SUB 接收线程**,订阅 `push_topic`,每收到一帧 → 解析 → 调用所有匹配该 topic 的本地 callback。
|
||||
- 启动/复用一个 **keepalive 线程**,每 `heartbeat_interval` 秒对所有活跃 `sub_id` 发 `quote_keepalive {client_id, sub_id}`。
|
||||
- **初始全量打底**:大 QMT 全推回调是**增量**的(见 §4.3 调研结论),不保证订阅后立即给全量。client 在订阅成功后**先主动调一次 `get_full_tick(code_list)` 触发 callback 打底**,随后由增量推送驱动 callback。这既保留现有"订阅即给一帧"的行为,又对齐大 QMT 的增量语义。
|
||||
- 返回 `sub_id`。
|
||||
|
||||
`unsubscribe_quote(sub_id)`:发 RPC `unsubscribe_whole_quote {client_id, sub_id}`,从本地表删除;该 sub_id 停止 keepalive。返回 0(对齐 miniqmt)。
|
||||
|
||||
### 4.3 server 端 `QuoteSubscriptionManager`(新模块)
|
||||
|
||||
挂在 server 进程内,持有 **ContextInfo**(Q5:优先用策略进程内 `ContextInfo.subscribe_whole_quote`;复用 `market_data` 适配器的 ContextInfo 引用)与 `QuotePusher`。
|
||||
|
||||
**大 QMT 订阅适配层**(Q5:ContextInfo 优先)。已调研确认大 QMT 真实签名(见下方"调研结论"),适配层对外只暴露两个方法:
|
||||
|
||||
```python
|
||||
class QuoteSourceAdapter: # ContextInfo 优先实现
|
||||
def subscribe(self, codes, on_push) -> handle: ...
|
||||
def unsubscribe(self, handle) -> None: ...
|
||||
```
|
||||
|
||||
**调研结论(真实大 QMT 环境,官方文档 + 隔壁 quant-qmt-proxy 实测交叉验证)**:
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 订阅签名 | `ContextInfo.subscribe_whole_quote(code_list, callback=None)` |
|
||||
| `code_list` | 市场代码 `['SH','SZ']` 或品种代码 `['600000.SH','000001.SZ']` |
|
||||
| 返回值 | `int` 订阅号 `subId`;**`< 0` 表示失败**(quant-qmt-proxy 实测) |
|
||||
| 退订 | `ContextInfo.unsubscribe_quote(subId)`(与 `subscribe_quote` 共用同一退订方法) |
|
||||
| **回调数据** | **增量推送**:每次回调只含**有变化**品种的最新 tick;`get_full_tick` 才是全量快照 |
|
||||
| 回调线程 | 独立于 `handlebar` 的推送线程(官方建议回调内不阻塞、扔队列处理) |
|
||||
|
||||
`QuoteSubscriptionManager` 只跟 `QuoteSourceAdapter` 打交道,不直接碰 ContextInfo——`subscribe` 内部调 `ContextInfo.subscribe_whole_quote(codes, on_push)`、`unsubscribe` 内部调 `ContextInfo.unsubscribe_quote(handle)`;若实测仍有出入,只改这一层。
|
||||
|
||||
**核心状态(内存,权威)**:
|
||||
|
||||
```python
|
||||
{
|
||||
combo_key: {
|
||||
"codes": [...], # 原始 code_list
|
||||
"qmt_sub_handle": <大QMT订阅句柄>, # subscribe_whole_quote 返回值
|
||||
"clients": {client_id: last_seen_ts}, # 引用计数 + 心跳
|
||||
"topic": push_topic,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
另维护 `sub_id → (client_id, combo_key)` 反向索引,用于按 sub_id 退订。
|
||||
|
||||
**三个 RPC handler**(加入 `BigQmtRpcHandlers.allowed_methods`,走现有 dispatch):
|
||||
|
||||
- `subscribe_whole_quote {client_id, sub_id, codes}`:
|
||||
- `key = combo_key(codes)`。
|
||||
- 若该 combo 不存在:调 `ContextInfo.subscribe_whole_quote(codes, on_push)` 建订阅,记录 handle,`on_push` 闭包绑定 `key`;建 `topic`。
|
||||
- `clients[client_id] = now`;登记 `sub_id → (client_id, key)`。
|
||||
- 返回 `{combo_key, topic, push_endpoint}`。
|
||||
- `unsubscribe_whole_quote {client_id, sub_id}`:
|
||||
- 由 `sub_id` 找到 `(client_id, key)`,`clients.pop(client_id)`,删除 `sub_id` 索引。
|
||||
- 若该 combo 的 `clients` 为空 → `ContextInfo.unsubscribe_whole_quote(handle)`(或对应退订 API),销毁 combo。
|
||||
- 返回 `{}`。
|
||||
- `quote_keepalive {client_id, sub_id}`:
|
||||
- 由 `sub_id` 找 combo,`clients[client_id] = now`。返回 `{}`。
|
||||
|
||||
**reaper(后台周期任务,挂在 server 的 adjust/调度循环上)**:
|
||||
|
||||
- 每 `reap_interval` 秒扫描:对每 combo,删除 `now - last_seen > 10 * heartbeat_interval` 的 client。
|
||||
- combo 的 `clients` 清空后 → 退订大 QMT、销毁 combo。
|
||||
|
||||
**on_push 回调**(大 QMT 行情线程触发):
|
||||
|
||||
- 收到 `data`(`{code: tick}`)→ 调 `QuotePusher.publish(topic, data)`。
|
||||
- **注意线程安全**:大 QMT 回调在行情线程,zmq PUB socket 的 send 需串行化(入队给专属发送线程,或加锁)。参照 zmq transport 已有的"socket 只能由创建它的线程关闭/使用"约束,推送也走队列 + 专属线程。
|
||||
|
||||
### 4.4 推送通道(新增 `QuotePushChannel`,zmq + redis 同阶段交付)
|
||||
|
||||
同一抽象下**两个实现同阶段交付**(Q6),按 client 当前 transport 选择:
|
||||
|
||||
```python
|
||||
class QuotePushChannel: # 抽象
|
||||
def publish(self, topic, data) -> None: ... # server 端
|
||||
def subscribe(self, topic, on_msg) -> None: ... # client 端
|
||||
```
|
||||
|
||||
**zmq PUB/SUB 实现**(无 redis 部署的原生通道):
|
||||
|
||||
- server 端绑定一个 `PUB` socket(独立于现有 ROUTER,单独端口,地址随 RPC discovery 下发或在 subscribe 响应里返回 `push_endpoint`)。
|
||||
- client 端 `SUB` socket connect,`setsockopt(SUBSCRIBE, topic)` 按组合过滤。
|
||||
- 帧格式:`[topic_bytes][payload_bytes]`。
|
||||
- topic = `combo_key`(即 `"SH,SZ"`),SUB 端精确匹配前缀即可。
|
||||
|
||||
**redis pub/sub 实现**(redis 部署):
|
||||
|
||||
- 复用现有 redis 连接,channel 名 `bigqmt:quote_push:{account_id}:{combo_key}`。
|
||||
- server `publish`、client `subscribe` 同一 channel。
|
||||
|
||||
**编码(Q7:msgpack 优先,json 兜底)**:
|
||||
|
||||
- 推送 payload 用 **msgpack** 序列化(对 `{code: {field: number}}` 这类结构,比 json 快数倍、体积更小,是全市场推送的标准选择)。
|
||||
- msgpack 列为**可选依赖**(`pyproject.toml` 的 `optional-dependencies`,与 redis/mysql 同组织方式),避免给最小安装(仅 pyzmq)增加硬依赖。
|
||||
- 未安装 msgpack 时**退化为 json**(stdlib),保证功能可用、仅吞吐降级。编码选择封装在 `QuotePushChannel` 内部,对上层透明。
|
||||
|
||||
> 取舍说明:zmq PUB/SUB 是 **fire-and-forget**,client 掉线期间推送被丢弃(符合行情推送语义——增量丢了就等下一帧,无需逐条补发)。**但增量推送不自带全量**,client 重连/重放后必须由 §4.2 的"初始全量打底"(`get_full_tick`)重建本地状态,再接收增量。这与 miniqmt 行为一致。
|
||||
|
||||
### 4.5 keepalive 与超时(Q3)
|
||||
|
||||
- `heartbeat_interval`(client 发心跳周期),**默认 3s**。
|
||||
- 超时阈值 = `10 * heartbeat_interval = 30s`(Q3 已确认 10 个周期)。server 端某 client 超过 30s 无心跳 → 判定消亡,从所有 combo 的 `clients` 移除。
|
||||
- 心跳与数据面解耦:即便行情静默(盘后用快照),心跳照发,保证引用计数准确。
|
||||
|
||||
### 4.6 server 重启恢复(Q4)
|
||||
|
||||
- **client 重放**:client 的 SUB 接收线程检测到推送通道断开/server ping 失败 → 触发重连;重连成功后,对本地订阅表里**所有活跃 sub_id 重新发 `subscribe_whole_quote`**(幂等:server 按 `(client_id, combo)` 去重,重放不会重复建大 QMT 订阅)。
|
||||
- **server 落盘(不做)**:实现阶段决定 server 订阅表**不落盘**。client 重放已覆盖恢复路径(重启后 client 重放即可重建全部订阅),落盘只增加 QMT 环境文件 IO 与状态一致性复杂度,无额外收益。
|
||||
- **幂等性**:`subscribe_whole_quote` handler 对相同 `(client_id, sub_id, combo)` 重复调用安全(重建 last_seen,不重复建大 QMT 订阅)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置项
|
||||
|
||||
server 端(`config["quote_push"]`,见 `bigqmt_signal_trader_strategy.py`):
|
||||
|
||||
| 配置键 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `enabled` | `true` | 是否启用全推推送服务 |
|
||||
| `heartbeat_timeout_seconds` | `30.0` | client 无心跳超时阈值(= 10 个心跳周期 × 3s) |
|
||||
| `zmq_bind_address` | RPC zmq 端口 + 1 | zmq PUB 绑定地址(仅 transport=zmq 时用) |
|
||||
|
||||
> 推送通道跟随 RPC transport(zmq/redis),reaper 挂在 RPC drain 上(随 drain 周期执行,无独立 interval)。server 订阅表不落盘(见 §4.6)。
|
||||
|
||||
client 端(`bigqmt_signal_trader_client_config.py` / 环境变量):
|
||||
|
||||
| 配置 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `BIGQMT_QUOTE_CLIENT_ID` | 自动生成并持久化到 `~/.cache/bigqmt/quote_client_id` | client 唯一 id(重启稳定,用于重放识别) |
|
||||
| `BIGQMT_QUOTE_HEARTBEAT_SECONDS` | `3.0` | 心跳周期(环境变量),须 < server 超时 |
|
||||
|
||||
---
|
||||
|
||||
## 6. TDD 实施计划(先红→绿→回归)
|
||||
|
||||
按依赖顺序分 6 个增量,每个增量都是「先写失败测试 → 实现 → 回归全套」。
|
||||
|
||||
**阶段 1 — 组合 key + 引用计数核心(纯逻辑,无 IO)**
|
||||
- 红:`tests/bigqmt_signal_trader/test_quote_subscription_manager.py`
|
||||
- `combo_key` 顺序无关/大小写统一。
|
||||
- 两个 client 订阅同组合 → 只建一次大 QMT 订阅(mock ContextInfo),refcount=2。
|
||||
- 一个 client 退 → 不退大 QMT;全部退 → 退一次大 QMT。
|
||||
- 心跳超时:构造 `last_seen` 过期 → reaper 移除 client;combo 空 → 退订。
|
||||
- 绿:`src/bigqmt_signal_trader/quote_subscription_manager.py`(`combo_key` + `QuoteSubscriptionManager`,ContextInfo 用注入的 mock)。
|
||||
- 回归:全套测试。
|
||||
|
||||
**阶段 2 — server RPC handler 接线**
|
||||
- 红:`subscribe_whole_quote` / `unsubscribe_whole_quote` / `quote_keepalive` 三个方法经 `BigQmtRpcHandlers.handle` 可达、白名单放行、参数校验、幂等重放。
|
||||
- 绿:在 `redis_rpc.py` 加 handler 方法 + `allowed_methods`,注入 `QuoteSubscriptionManager`。
|
||||
- 回归。
|
||||
|
||||
**阶段 3 — 推送通道抽象 + zmq/redis 双实现 + 编码**
|
||||
- 红:`tests/bigqmt_signal_trader/test_quote_push_channel.py`
|
||||
- `QuotePushChannel` 接口(`publish(topic, data)` / `subscribe(topic, on_msg)`)。
|
||||
- zmq PUB/SUB 回环:bind PUB → SUB connect → publish → SUB 收到且 topic 过滤正确。
|
||||
- redis pub/sub 回环:fake redis 下 publish → subscribe 收到。
|
||||
- 编码:msgpack 可用时 payload 用 msgpack(解出结构与原始一致),未装时退化 json。
|
||||
- 绿:`src/bigqmt_signal_trader/quote_push_channel.py`(抽象 + zmq 实现 + redis 实现 + msgpack/json 编码选择)。
|
||||
- 回归。
|
||||
|
||||
**阶段 4 — server on_push → 推送通道接线**
|
||||
- 红:mock ContextInfo 触发 `on_push(data)` → `QuotePushChannel.publish` 被以正确 topic+data 调用;线程安全(并发回调不竞态)。
|
||||
- 绿:`QuoteSubscriptionManager` 接 `QuotePushChannel`。
|
||||
- 回归。
|
||||
|
||||
**阶段 5 — client 端重写(订阅 + SUB 接收 + keepalive 线程)**
|
||||
- 红:`tests/bigqmt_signal_trader/test_whole_quote_client.py`
|
||||
- `subscribe_whole_quote` 发正确 RPC、注册本地 callback、启动 keepalive。
|
||||
- 收到推送帧 → 触发对应 callback。
|
||||
- `unsubscribe_quote` 发 RPC、停心跳。
|
||||
- 重放:模拟断连后 → 对所有活跃 sub_id 重发 subscribe。
|
||||
- 绿:重写 `BigQmtXtData.subscribe_whole_quote` / `unsubscribe_quote`,新增 client 侧 SUB 接收与 keepalive 线程、`client_id` 管理。
|
||||
- 回归。
|
||||
|
||||
**阶段 6 — 端到端 + 多 client 共享 + server 重启恢复**
|
||||
- 红:端到端测试(in-proc fake server + 两 client)
|
||||
- 两 client 订同组合 → 各自 callback 都收到推送;server 只对大 QMT 建一次订阅。
|
||||
- 一 client 退 → 另一个仍收;全退 → server 退订大 QMT。
|
||||
- server "重启"(重建 manager + 推送通道)→ client 重放 → 恢复推送。
|
||||
- client 静默超 30s → server 清引用 → 退订。
|
||||
- 绿:补齐集成胶水(server 启动时装配 manager+push channel,client 重连逻辑)。
|
||||
- 回归:全套 + 现有 `test_all_apis.py` 端到端不破坏。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与开放问题
|
||||
|
||||
1. **大 QMT 订阅/退订 API(已调研确认,风险解除)**:签名、返回值(`int` 订阅号,`<0` 失败)、退订方法 `unsubscribe_quote(subId)` 均已确认(见 §4.3 调研结论)。`QuoteSourceAdapter` 仍保留,作为唯一接触 ContextInfo 的层,便于真实环境联调时微调。
|
||||
2. **行情回调线程模型**:大 QMT `on_push` 在独立的推送线程触发(非 `handlebar` 线程),zmq send 必须跨线程安全(队列 + 专属发送线程);官方亦建议回调内不阻塞、扔队列。阶段 4 专门覆盖。
|
||||
3. **增量推送语义**:大 QMT 全推回调是**增量**(只推变化品种),不是全量快照。client 端必须用 `get_full_tick` 打底 + 增量更新(§4.2),不能假设订阅后即得全量。这点与早期假设不同,已在 §4.2/§4.4 修正。
|
||||
4. **全市场推送量级**:`["SH","SZ"]` 全推增量仍可能每帧数千条。已按 Q7 采用 **msgpack** 编码(可选依赖,未装退化 json)压低开销;PUB 广播吞吐仍需在真实环境实测,若仍不足再评估压缩/分片(不过早优化)。
|
||||
5. **msgpack 依赖**:新增可选依赖 `msgpack`(`optional-dependencies`,对齐 redis/mysql 的组织方式),最小安装(仅 pyzmq)不受影响;未装时推送通道退化 json 编码。
|
||||
6. **与 full_tick_cache 关系**:二者独立。full_tick_cache 服务 `get_full_tick` 按需拉取;本方案服务 `subscribe_whole_quote` 推送。不冲突,不合并。
|
||||
|
||||
---
|
||||
|
||||
## 8. 交付物清单(已全部交付)
|
||||
|
||||
- 新增:`src/bigqmt_signal_trader/quote_subscription_manager.py`(`combo_key`、`QuoteSubscriptionManager`、`QuoteSourceAdapter`/`ContextInfoQuoteSource`、`build_quote_subscription_service`)
|
||||
- 新增:`src/bigqmt_signal_trader/quote_push_channel.py`(抽象 + zmq/redis 实现 + msgpack/json 编码)
|
||||
- 新增:`src/bigqmt_signal_trader/whole_quote_session.py`(client 端订阅会话:订阅表 + 推送路由 + 心跳线程 + 重放)
|
||||
- 修改:`src/bigqmt_signal_trader/redis_rpc.py`(`QUOTE_SUBSCRIPTION_METHODS` + 3 个 handler + 白名单 + `quote_subscription_manager` 注入)
|
||||
- 修改:`src/bigqmt_signal_trader/xtquant_compat.py`(`subscribe_whole_quote`/`unsubscribe_quote` 重写 + session 懒建 + client_id 持久化 + push channel 选择 + `get_full_tick` 打底)
|
||||
- 修改:`src/bigqmt_signal_trader_strategy.py`(server 启动装配 `_build_quote_subscription_service` + publisher 启动 + reaper 挂 RPC drain)
|
||||
- 修改:`pyproject.toml`(`optional-dependencies` 增加 `msgpack`)
|
||||
- 新增测试:`test_quote_subscription_manager.py` / `test_quote_push_channel.py` / `test_quote_on_push_wiring.py` / `test_whole_quote_client.py` / `test_xtdata_whole_quote.py` / `test_quote_subscription_service.py` / `test_whole_quote_e2e.py`
|
||||
- 文档:本文档 + `RPC_API_REFERENCE.md` §2.5 增补 3 个新方法 + `bigqmt_signal_trader_client_config.example.py` 增补 client 配置
|
||||
|
||||
**实现期间 TDD 抓到的两个真实 bug**:
|
||||
1. **`_sub_index` 键冲突**:两 client 各自 `sub_id` 从 1 开始,server 以单 `sub_id` 为键互相覆盖 → 引用计数错乱、"全退才退订"失效。e2e 测试暴露后改为 `(client_id, sub_id)` 复合键。这是多 client 场景的核心正确性问题,单 client 测试无法覆盖。
|
||||
2. **`load_client_config` 漏 `quote_client_id` 键**:导致 client_id 配置读不到、静默退回持久化文件路径。
|
||||
|
||||
**未实现**:server 订阅表落盘兜底(§4.6,经决策不做,靠 client 重放恢复)。
|
||||
|
||||
**待真实环境联调**(不阻塞交付,均已在 §7 标注):`ContextInfo.subscribe_whole_quote` 实际句柄/退订微调、`["SH","SZ"]` 全推吞吐实测、zmq/redis 推送通道在真实部署的连通性。
|
||||
@@ -0,0 +1,222 @@
|
||||
# MiniQMT 无损替换兼容层
|
||||
|
||||
更新时间:2026-07-01
|
||||
|
||||
## 目标
|
||||
|
||||
把原来依赖 MiniQMT 的调用:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
```
|
||||
|
||||
替换为“大 QMT 策略进程 + Redis RPC”的远程调用,同时尽量保持业务代码继续使用:
|
||||
|
||||
```python
|
||||
xt_trader.query_stock_positions(acc)
|
||||
xt_trader.query_stock_asset(acc)
|
||||
xt_trader.query_stock_orders(acc)
|
||||
xt_trader.query_stock_trades(acc)
|
||||
xt_trader.order_stock(...)
|
||||
xt_trader.order_stock_async(...)
|
||||
xt_trader.cancel_order_stock_sysid(...)
|
||||
xtdata.get_full_tick(...)
|
||||
```
|
||||
|
||||
## 接入方式一:显式导入新包
|
||||
|
||||
适合先灰度,不影响机器上的真实 `xtquant` 包。
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import (
|
||||
StockAccount,
|
||||
configure,
|
||||
xt_trader,
|
||||
xtdata,
|
||||
)
|
||||
from bigqmt_signal_trader import xtquant_compat as xtconstant
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
ticks = xtdata.get_full_tick(["600000.SH"])
|
||||
```
|
||||
|
||||
这类写法的优点是替换范围小,适合先在 `core/trader.py` 或独立测试脚本里验证查询链路。`configure()` 会原地更新已导入的 `xt_trader` / `xtdata` 对象,所以可以先 `from ... import xt_trader`,再调用 `configure()`。
|
||||
|
||||
## 接入方式二:用 `xtquant` shim 替换老 import
|
||||
|
||||
适合最终切换。把本仓库的 `src` 放到 `PYTHONPATH` 最前面后,老代码里的:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
```
|
||||
|
||||
会命中本仓库提供的 `src/xtquant/` shim。这样主业务代码基本不用改,只需要在本地私有配置文件里设置 Redis 和账号:
|
||||
|
||||
```python
|
||||
# D:\gjzqqmt\xtquant_big_convert\src\bigqmt_signal_trader_client_config.py
|
||||
BIGQMT_ACCOUNT_ID = "YOUR_ACCOUNT_ID"
|
||||
BIGQMT_RPC_TIMEOUT_SECONDS = 6.0
|
||||
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "******",
|
||||
}
|
||||
|
||||
BIGQMT_FULL_TICK_CACHE_CONFIG = {
|
||||
"enabled": False,
|
||||
"demand_ttl_seconds": 10,
|
||||
"cache_ttl_seconds": 10,
|
||||
"wait_seconds": 3.5,
|
||||
}
|
||||
```
|
||||
|
||||
然后启动前只需要确认本仓库的 `src` 在 `PYTHONPATH` 最前面:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "D:\gjzqqmt\xtquant_big_convert\src;$env:PYTHONPATH"
|
||||
```
|
||||
|
||||
如果同一台机器仍然安装了真实 MiniQMT 的 `xtquant` 包,要确认 `D:\gjzqqmt\xtquant_big_convert\src` 位于 `PYTHONPATH` 最前面,否则 Python 会先加载真实 `xtquant`。
|
||||
|
||||
## 推荐落地步骤
|
||||
|
||||
1. 大 QMT 侧先运行 `BIGQMT_REDIS_DRYRUN` / `bigqmt_signal_trader_redis_rpc_runtime.py`,保持 `rpc_allow_order_methods=False`。
|
||||
2. 原策略侧用显式导入方式跑查询自检:资产、持仓、单票五档行情、`["SH","SZ"]` 全市场行情。
|
||||
3. 查询链路稳定后,把原项目中 `core/trader.py` 的初始化切到兼容层,但仍保持远程下单关闭。
|
||||
4. 对比 MiniQMT 与大 QMT 返回的资产、持仓、委托、成交字段,确认业务字段都能读到。
|
||||
5. 只在确认风控、账号、委托价型都正确后,在大 QMT 私有配置里打开 `rpc_allow_order_methods=True`。
|
||||
6. 最终切换时再使用 `xtquant` shim,让旧 import 保持不变。
|
||||
|
||||
## 当前已兼容的方法
|
||||
|
||||
| MiniQMT 调用 | 兼容状态 | 说明 |
|
||||
|---|---|---|
|
||||
| `XtQuantTrader(path, session_id)` | 已兼容 | 构造本地 RPC 客户端,不连接 MiniQMT |
|
||||
| `register_callback()` | 已兼容 | 保存 callback;RPC 暂不推送回调 |
|
||||
| `start()` / `connect()` / `subscribe()` | 已兼容 | 返回 `0`,`subscribe()` 会补账号 |
|
||||
| `query_stock_asset(acc)` | 已兼容 | 返回对象含 `cash`、`available_cash`、`total_asset`、`market_value` |
|
||||
| `query_stock_positions(acc)` | 已兼容 | 返回对象列表,含 `stock_code`、`volume`、`can_use_volume`、`avg_price`、`price` |
|
||||
| `query_stock_position(acc, code)` | 已兼容 | 返回单只持仓对象或 `None` |
|
||||
| `query_stock_orders(acc, cancelable_only=False)` | 已兼容 | 返回对象列表,含 `order_type`、`order_status`、`order_volume`、`traded_volume`、`order_sysid` |
|
||||
| `query_stock_trades(acc)` | 已兼容 | 返回对象列表,含 `order_type`、`traded_volume`、`traded_price` |
|
||||
| `order_stock()` / `order_stock_async()` | 已兼容 | 需要大 QMT 本地配置打开 `rpc_allow_order_methods=True` |
|
||||
| `cancel_order_stock_sysid()` | 已兼容 | 需要大 QMT 本地配置打开 `rpc_allow_order_methods=True` |
|
||||
| `xtdata.get_full_tick(codes)` | 已兼容 | 默认直接 RPC 调用;支持单票、ETF、`["SH", "SZ"]` 全市场;可选打开 Redis 快照缓存 |
|
||||
| `xtdata.get_instrument_detail(code)` | 已兼容 | 映射到大 QMT `get_instrumentdetail()` |
|
||||
| `xtdata.get_instrument_type(code)` | 已接入 | 优先调大 QMT;不支持时按代码前缀做基础判断 |
|
||||
| `xtdata.subscribe_quote(...)` / `subscribe_whole_quote(...)` | Redis 订阅兼容 | 写入 `bigqmt:quote_subscriptions:{account_id}`,并向 `bigqmt:quote_events:{account_id}` 发事件;callback 会收到一次当前快照/历史数据 |
|
||||
| `xtdata.unsubscribe_quote(seq)` | Redis 事件兼容 | 不强依赖大 QMT 反订阅 API,直接删除 Redis 订阅表并推送 `unsubscribe_quote` 事件 |
|
||||
| `xtdata.get_market_data(...)` | 已接入 RPC | 透传到大 QMT `ContextInfo.get_market_data`,返回 DataFrame/字典结构会自动 JSON 化再还原 |
|
||||
| `xtdata.get_market_data_ex(...)` | 已接入 RPC | 大 QMT 不支持 `get_market_data_ex` 时回退到 `get_market_data` |
|
||||
| `xtdata.get_local_data(...)` | 已接入 RPC | 大 QMT 不支持 `get_local_data` 时回退到 `get_market_data` |
|
||||
| `xtdata.get_stock_list_in_sector(...)` | 已接入 RPC | 优先调大 QMT;失败时对 `"沪深A股"` 用 `get_full_tick(["SH","SZ"])` 过滤 |
|
||||
| `xtdata.get_sector_list()` / `get_sector_info()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_divid_factors(...)` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.download_history_data(...)` / `download_history_data2(...)` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_trading_dates(...)` / `get_holidays()` / `download_holiday_data()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_ipo_info(...)` | 已接入 RPC | 行情侧新股资料;交易侧 `query_ipo_data()` 仍是占位 |
|
||||
| `xtdata.get_etf_info()` / `download_etf_info()` | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_option_list(...)` / 历史期权列表 | 已接入 RPC | 依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.get_financial_data(...)` / `download_financial_data(...)` | 已接入 RPC | 支持 DataFrame 返回值序列化 |
|
||||
| `xtdata.call_formula(...)` / `subscribe_formula(...)` / `unsubscribe_formula(...)` / `get_formula_result(...)` | 已接入 RPC | 对应截图里的模型调用/订阅能力,依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `xtdata.gen_factor_index(...)` | 已接入 RPC | 对应生成因子数据,依赖大 QMT `ContextInfo` 是否支持 |
|
||||
| `query_ipo_data()` / `query_new_purchase_limit()` | 占位兼容 | 当前返回空结果,打新需要后续补大 QMT 等价能力 |
|
||||
|
||||
## 下单开关
|
||||
|
||||
大 QMT 本地配置默认关闭远程下单。要真正替换 MiniQMT 下单,需要在 QMT 本地私有配置中显式开启:
|
||||
|
||||
```python
|
||||
BIGQMT_REDIS_CONFIG = {
|
||||
"host": "YOUR_REDIS_HOST",
|
||||
"port": 6379,
|
||||
"db": 5,
|
||||
"username": "",
|
||||
"password": "******",
|
||||
"rpc_allow_order_methods": True,
|
||||
}
|
||||
```
|
||||
|
||||
开启后,`price_type` 会从客户端透传到大 QMT `passorder()`,不会再固定成默认限价。
|
||||
|
||||
## 最小自检脚本
|
||||
|
||||
这个脚本只读,不会下单:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id, "STOCK")
|
||||
|
||||
asset = xt_trader.query_stock_asset(acc)
|
||||
positions = xt_trader.query_stock_positions(acc)
|
||||
tick = xtdata.get_full_tick(["600000.SH"])
|
||||
all_a = xtdata.get_stock_list_in_sector("沪深A股")
|
||||
|
||||
print("cash:", asset.cash)
|
||||
print("total_asset:", asset.total_asset)
|
||||
print("positions:", len(positions), positions[:3])
|
||||
print("bid5:", tick["600000.SH"]["bidPrice"])
|
||||
print("ask5:", tick["600000.SH"]["askPrice"])
|
||||
print("hs_a_count:", len(all_a))
|
||||
```
|
||||
|
||||
如果想验证最终 shim 方式:
|
||||
|
||||
```python
|
||||
from xtquant.xttrader import XtQuantTrader
|
||||
from xtquant.xttype import StockAccount
|
||||
from xtquant import xtdata, xtconstant
|
||||
|
||||
trader = XtQuantTrader("", 12345)
|
||||
acc = StockAccount(trader.client.account_id, "STOCK")
|
||||
|
||||
assert trader.connect() == 0
|
||||
assert trader.subscribe(acc) == 0
|
||||
|
||||
print(xtconstant.STOCK_BUY)
|
||||
print(trader.query_stock_asset(acc))
|
||||
print(xtdata.get_full_tick(["600000.SH"]))
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
```powershell
|
||||
cd D:\gjzqqmt\xtquant_big_convert
|
||||
python -B -m unittest discover -s tests\bigqmt_signal_trader
|
||||
```
|
||||
|
||||
实盘前建议先只跑查询链路:
|
||||
|
||||
```python
|
||||
from bigqmt_signal_trader.xtquant_compat import StockAccount, configure, xt_trader, xtdata
|
||||
|
||||
configure()
|
||||
|
||||
acc = StockAccount(xt_trader.client.account_id)
|
||||
print(xt_trader.query_stock_asset(acc))
|
||||
print(xt_trader.query_stock_positions(acc)[:3])
|
||||
print(xtdata.get_full_tick(["600000.SH"]))
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `subscribe_quote()` / `subscribe_whole_quote()` 当前通过 Redis 记录订阅意图,并给 callback 推一次当前数据;持续行情推送需要独立的 Redis 行情生产者消费 `bigqmt:quote_subscriptions:{account_id}`。
|
||||
- `get_full_tick()` 默认直接 RPC 现拉;如果全市场 payload 过大,再在客户端和 QMT 本地配置里打开 Redis 快照缓存。
|
||||
- `unsubscribe_quote(seq)` 当前按你的要求直接写 Redis:删除订阅表并推送 `unsubscribe_quote` 事件,不等待大 QMT 确认。
|
||||
- `get_stock_list_in_sector("沪深A股")` 的本地兜底会通过 `get_full_tick(["SH", "SZ"])` 过滤 A 股,速度取决于大 QMT 全市场快照返回耗时。
|
||||
- 历史行情、财务、ETF、期权、模型/因子等接口已经接到 RPC,但实际是否可用取决于大 QMT 策略环境里的 `ContextInfo` 是否暴露同名方法。
|
||||
- `query_ipo_data()` / `query_new_purchase_limit()` 当前返回空结果,打新逻辑不能直接视为无损替换。
|
||||
- RPC 下单默认关闭;打开前必须确认大 QMT 页面正在运行正确账号的 RPC 策略。
|
||||
@@ -0,0 +1,142 @@
|
||||
# QMT 原生 ZMQ 回测桥接
|
||||
|
||||
## 1. 目标和边界
|
||||
|
||||
正式模式是在 **QMT 回测进程内部**运行一个 ZMQ 服务,把 QMT 当前回测 Bar、
|
||||
账户、持仓、委托和成交桥接给外部策略。QMT 是唯一的行情推进器、回测引擎、
|
||||
账户系统和撮合器。
|
||||
|
||||
`BIGQMT_ZMQ_BACKTEST.py` 与现有实盘 RPC 入口完全分离:
|
||||
|
||||
- 不导入或修改 `bigqmt_signal_trader`;
|
||||
- 使用独立端口 `tcp://127.0.0.1:16662`;
|
||||
- 只允许 `ContextInfo.do_back_test=true` 的 QMT 回测上下文;
|
||||
- 协议固定返回 `live_ready=false`;
|
||||
- ZMQ 后台线程只接收请求和排队,不直接调用 QMT API;
|
||||
- `passorder`、撤单和账户查询只在 QMT `handlebar` 回调线程执行。
|
||||
|
||||
项目仍保留端口 `16661` 的 CSV 独立回测工具,用于脱离 QMT 的协议测试。该工具
|
||||
使用本地 `BacktestEngine/SimulatedBroker`,不是 QMT 原生回测服务,二者不能混用。
|
||||
|
||||
## 2. 运行时序
|
||||
|
||||
1. QMT 加载 `BIGQMT_ZMQ_BACKTEST.py` 并调用 `init(ContextInfo)`。
|
||||
2. 入口确认当前是 QMT 回测模式,绑定 QMT 注入的 `passorder`、`cancel` 和
|
||||
`get_trade_detail_data`,然后启动 ZMQ 服务。
|
||||
3. QMT 调用 `handlebar` 时,服务发布当前 Bar,并等待外部策略完成这一 Bar 的决策。
|
||||
4. 外部策略调用 `submit_order` 或 `cancel_order`;ZMQ 线程只把命令放入当前 Bar 队列。
|
||||
5. 外部策略调用 `next_bar` 后,QMT 回调线程排空命令并调用 QMT API,然后把控制权
|
||||
交还 QMT。QMT 自己撮合并推进下一根 Bar。
|
||||
6. QMT 的 `order_callback`、`deal_callback` 以及账户查询结果会进入 ZMQ 状态;
|
||||
QMT 调用 `stop/after_backtest` 后,下一次 `next_bar` 返回 `done=true`。
|
||||
|
||||
外部策略超时不释放当前 Bar 时,桥接会抛出超时错误并停止继续下单,避免 QMT
|
||||
静默跑完整段历史而外部策略没有参与。
|
||||
|
||||
## 3. QMT 端安装与配置
|
||||
|
||||
同步以下内容到正在运行的 QMT `python` 目录:
|
||||
|
||||
```text
|
||||
src/bigqmt_backtest/
|
||||
src/BIGQMT_ZMQ_BACKTEST.py
|
||||
```
|
||||
|
||||
编辑 `BIGQMT_ZMQ_BACKTEST.py` 顶部配置:
|
||||
|
||||
```python
|
||||
BACKTEST_ZMQ_CONFIG = {
|
||||
"bind_endpoint": "tcp://127.0.0.1:16662",
|
||||
"run_id": "", # 空值会按启动时间生成
|
||||
"account_id": "你的QMT回测账号",
|
||||
"account_type": "STOCK",
|
||||
"strategy_name": "ZMQ_BACKTEST",
|
||||
"combo_type": 1101,
|
||||
"quick_trade": 2,
|
||||
"market_price_type": 5,
|
||||
"limit_price_type": 11,
|
||||
"bar_wait_timeout_seconds": 60,
|
||||
"require_qmt_backtest": True,
|
||||
}
|
||||
```
|
||||
|
||||
入口是 GBK/ASCII;`bigqmt_backtest` 包使用 UTF-8。在 QMT 中创建回测任务并且只加载
|
||||
`BIGQMT_ZMQ_BACKTEST.py`,不要使用“启动本地 Python”。启动日志会打印实际
|
||||
`run_id`、端口和账号。
|
||||
|
||||
`account_id` 必须填写,服务会调用 `ContextInfo.set_account(account_id)`,外部订单最终
|
||||
通过以下 QMT 原生接口提交:
|
||||
|
||||
```text
|
||||
passorder(23/24, 1101, account_id, symbol, price_type, price, quantity,
|
||||
strategy_name, quick_trade, client_order_id, ContextInfo)
|
||||
```
|
||||
|
||||
撮合价格、成交时间、手续费、资金和持仓均以 QMT 回测结果为准,桥接不再计算第二套
|
||||
结果。
|
||||
|
||||
## 4. 外部策略启动
|
||||
|
||||
安装客户端包:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
启动 QMT 回测后,在外部 Python 运行:
|
||||
|
||||
```powershell
|
||||
python examples/zmq_backtest_strategy.py `
|
||||
--endpoint tcp://127.0.0.1:16662 `
|
||||
--symbol 600000.SH `
|
||||
--fast 5 `
|
||||
--slow 20
|
||||
```
|
||||
|
||||
没有传 `--run-id` 时,客户端先调用 `describe` 发现 QMT 本次运行的 `run_id`。同一运行
|
||||
只允许首个调用 `start` 的 `client_id` 控制。
|
||||
|
||||
## 5. 协议
|
||||
|
||||
ZMQ 使用 `REQ/REP`,请求包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request_id": "唯一幂等键",
|
||||
"run_id": "qmt-native-20260719-120000",
|
||||
"client_id": "strategy-a",
|
||||
"method": "start",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 方法 | QMT 原生模式含义 |
|
||||
|---|---|
|
||||
| `ping` / `describe` | 探活、发现 `run_id` 和确认 `engine_owner=QMT` |
|
||||
| `start` | 外部策略挂接并等待 QMT 第一根 Bar;不启动第二个引擎 |
|
||||
| `submit_order` | 把订单意图排入当前 Bar,返回 `QUEUED` |
|
||||
| `cancel_order` | 把撤单意图排入当前 Bar |
|
||||
| `next_bar` | 释放当前 Bar;QMT 线程执行命令并等待 QMT 下一根 Bar |
|
||||
| `state` | 返回 QMT 缓存的资金、持仓和当前 Bar |
|
||||
| `history` | 返回 QMT 已经发布给外部策略的历史 Bar,不泄露未来数据 |
|
||||
| `orders` / `fills` | 返回 QMT 委托/成交查询及回调归一化结果 |
|
||||
| `finish` | 外部策略解除挂接;QMT 回测报告仍由 QMT 生成 |
|
||||
|
||||
响应包含 `execution_backend=QMT_NATIVE`、`execution_mode=QMT_BACKTEST` 和
|
||||
`live_ready=false`。相同 `request_id` 的重试返回缓存响应。
|
||||
|
||||
## 6. CSV 独立模式
|
||||
|
||||
仅当不启动 QMT、需要验证协议或外部策略逻辑时使用:
|
||||
|
||||
```powershell
|
||||
python -m bigqmt_backtest.server `
|
||||
--data examples/backtest_bars.example.csv `
|
||||
--config examples/backtest_config.example.json `
|
||||
--run-id demo-001 `
|
||||
--bind tcp://127.0.0.1:16661
|
||||
```
|
||||
|
||||
该模式响应 `execution_backend=LOCAL_SIM`,本地产出 `result.json`、委托、成交、资金
|
||||
曲线等证据。它不会调用 QMT,也不能代表 QMT 原生撮合结果。
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user