前四十四篇的终点都是「回测」:数据、信号、撮合、评估,全部发生在历史数据的世界里。从本篇起进入合集最后一站——把策略送上真实的战场。实盘与回测的差距,远不止「换一个数据源」:滑点会吃掉利润、委托会部分成交、网络会断线、资金会不够……本篇先建立全景认知:国内个人量化的主流实盘通道有哪些、各自门槛如何、该怎么选。
本篇目标
- 能说清实盘与回测的五个本质差异:滑点、部分成交、断线、资金约束、行为偏差
- 能列出国内主流实盘通道及其特点:QMT、PTrade、掘金量化、vn.py、easytrader 类
- 能根据自己的资金量、技术栈、策略频率判断适合哪条通道
- 能理解券商开通条件(门槛、费用)的一般框架,并知道以券商最新政策为准
- 能评估「自建轻量方案」的风险,不盲目裸奔
核心概念
实盘与回测的五个本质差异
滑点(Slippage):回测里你按收盘价成交,实盘里市价单要穿过买卖价差、大单还会推动价格。第 22 篇在 Backtrader 里模拟过 0.1%~0.2% 的滑点,真实滑点是随行情状态变化的:流动性差的时点(开盘、尾盘、小市值票)可能数倍于此。
部分成交:你想买 10000 股,盘口只有 3000 股在心理价位,剩下 7000 股要么追价、要么等。回测里不存在的「成交确定性」问题,实盘天天发生。
断线与故障:策略服务器重启、券商接口超时、行情断流——实盘系统必须有「断线后重新登录、恢复持仓状态」的能力(第 43 篇的心跳监控在这里真正派上用场)。
资金约束:回测可以满仓满融,实盘里可用资金、最小下单单位(A 股整百股)、涨跌停无法成交,都会让实际仓位偏离计划。
行为偏差:回测里你冷静如机器,实盘里浮亏 5% 时手会抖。参数冻结纪律(第 46 篇)就是对治它的制度设计。
主流通道全景
| 通道 | 形态 | 门槛(一般情况) | 适合人群 |
|---|---|---|---|
| QMT | 券商提供的本地客户端+Python API | 通常要求资金量(各家不同,常见数十万级),需申请开通 | 有一定资金、策略日频/分钟频、想要本地化 Python 开发的个人 |
| PTrade | 券商托管的服务端策略平台 | 同为券商开通制,资金门槛与 QMT 相近 | 不想自己管服务器、接受券商托管运行的个人 |
| 掘金量化 | 第三方终端+模拟/实盘接口 | 终端免费,实盘需绑定券商账户 | 想统一回测/模拟/实盘工作流的进阶个人 |
| vn.py | 开源交易框架(自建) | 免费,但需自接券商/期货柜台(通常走 CTP 等) | 期货为主、工程能力强的开发者 |
| easytrader 类 | 开源「模拟人工」下单库 | 免费挂券商客户端 | 资金小、频率低、能接受不稳定性的尝鲜者 |
划重点:通道没有绝对优劣,只有匹配度。资金量、策略频率、工程能力三个变量定选择:日频小资金用 easytrader 类或先纸面交易(第 46 篇);分钟频以上且资金达标,QMT/PTrade 是主流答案;期货高频方向再考虑 vn.py + CTP。另外,所有券商开通条件(资金门槛、交易权限、费用)都以你所在券商的最新政策为准,本篇表格只是结构性认知,不是开户指引。
自建轻量方案的风险
用 easytrader 这类「控制券商客户端模拟点击」的方案,门槛最低但风险最实:券商升级客户端导致接口失效(交易时段突然下不了单)、图形界面自动化不稳定、以及与券商服务协议的合规灰区。它适合「验证流程」的过渡阶段,不适合承载认真资金。
完整代码
本篇的代码目标是通道无关的抽象层设计:无论底层接 QMT 还是 PTrade,策略代码不应该重写。这是实盘工程化的第一课。
# ---------- 1. 交易接口抽象层:定义「渠道无关」的下单协议 ----------
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Order:
symbol: str
side: str # "buy" / "sell"
price: float
volume: int
status: str = "pending" # pending/filled/partial/rejected
filled_volume: int = 0
msgs: list = field(default_factory=list)
class BrokerBase(ABC):
"""所有实盘/模拟通道都要实现的接口"""
@abstractmethod
def buy(self, symbol, price, volume) -> Order: ...
@abstractmethod
def sell(self, symbol, price, volume) -> Order: ...
@abstractmethod
def position(self, symbol) -> int: ...
@abstractmethod
def cash(self) -> float: ...
class PaperBroker(BrokerBase):
"""纸面交易通道(第46篇主角):本地记账,验证流程用"""
def __init__(self, init_cash=100000.0, fee=0.001, slip=0.001):
self._cash = init_cash
self._pos = {}
self.fee, self.slip = fee, slip
def _fill(self, symbol, side, price, volume):
# 模拟滑点:买入向上滑、卖取向下滑
fill_px = price * (1 + self.slip if side == "buy" else -self.slip)
cost = volume * fill_px * (1 + self.fee if side == "buy"
else -self.fee)
self._cash -= cost
self._pos[symbol] = self._pos.get(symbol, 0) + \
(volume if side == "buy" else -volume)
o = Order(symbol, side, price, volume, "filled", volume,
[f"{datetime.now():%H:%M:%S} 成交价{fill_px:.2f}"])
return o
def buy(self, symbol, price, volume):
return self._fill(symbol, "buy", price, volume)
def sell(self, symbol, price, volume):
return self._fill(symbol, "sell", price, volume)
def position(self, symbol):
return self._pos.get(symbol, 0)
def cash(self):
return self._cash
# ---------- 2. 模拟「断线重连」的健壮下单封装 ----------
import time
def robust_order(broker, fn, symbol, price, volume,
retries=3, wait=2.0):
"""带重试的下单:网络类异常退避重试,业务类异常立刻报告"""
for attempt in range(1, retries + 1):
try:
return fn(symbol, price, volume)
except ConnectionError as e: # 瞬时:重试
if attempt == retries:
raise RuntimeError(f"下单失败(重试耗尽): {e}")
time.sleep(wait * attempt)
except ValueError as e: # 业务:如资金不足
raise RuntimeError(f"订单被拒: {e}")
return None
# ---------- 3. 演示:通道切换不改策略代码 ----------
def strategy_entry(broker: BrokerBase, symbol, price, volume):
"""策略只依赖抽象接口——明天把 PaperBroker 换成 QmtBroker,
这行代码一个字都不用改"""
if broker.position(symbol) == 0:
order = robust_order(broker, broker.buy, symbol, price, volume)
print(f"买入 {symbol} {volume}股 @ {price}, "
f"剩余现金 {broker.cash():,.0f}")
return order
return None
paper = PaperBroker()
strategy_entry(paper, "510300", 4.020, 10000)
print(f"持仓: {paper.position('510300')} 股")
# ---------- 4. QMT 侧的对接示意(伪代码,需开通后按官方文档实现) ----------
class QmtBroker(BrokerBase):
"""示意结构:实际方法名/参数以所开通 QMT 的官方文档为准"""
def __init__(self, path, session_id):
# from xtquant import xttrader ...
# self.xt = xttrader.XtQuantTrader(path, session_id)
# self.acc = 资金账号对象
raise NotImplementedError("需开通QMT后按官方SDK实现")
def buy(self, symbol, price, volume):
# 委托: self.xt.order_stock(self.acc, symbol, 买,
# volume, 限价, price...)
...
def sell(self, symbol, price, volume): ...
def position(self, symbol):
# 查询: self.xt.query_stock_positions(self.acc) ...
...
def cash(self): ...
代码逐段解释
- 抽象层:
BrokerBase用抽象基类定义四个动作(买、卖、查持仓、查现金)。Order数据类记录委托状态与成交信息。这层薄薄的接口是整个实盘工程的支点:策略、回测、纸面、实盘四种环境只在中途换实现,两头(策略代码、记账逻辑)不动。 - PaperBroker:本地记账的纸面通道,内置 0.1% 费率与 0.1% 滑点——把第 45 篇讲的滑点差异直接做进成交模拟里。它是第 46 篇纸面交易的主角。
- robust_order:把第 43 篇的异常分类用到下单上——
ConnectionError类瞬时错误退避重试,ValueError类业务错误(资金不足、废单)立即抛出。下单是实盘中最不能含糊的动作,重试与告警必须到位。 - QmtBroker 示意:展示实盘通道如何「插入」抽象层——真实实现需开通 QMT 后按官方 SDK(xtquant)文档补全,本篇只给结构。接口名与参数以券商提供的文档为准,这正是第 47 篇要提醒的「文档为准、不凭记忆写接口」。
示例输出(示意):
买入 510300 10000股 @ 4.02, 剩余现金 59,548
持仓: 10000 股
常见坑与小练习
常见坑
- 回测代码直接改两行就上实盘:成交假设、异常处理、状态恢复全没做。正确路径是回测 → 纸面(第 46 篇)→ 小资金实盘,三段每段至少一个月。
- 把通道示例代码当文档:包括本篇在内的任何教程,接口名与参数都可能过时。开通通道后第一件事是读官方 SDK 文档与示例。
- 忽视最小变动价位与整手限制:限价单价格没按 tick 取整、股数不是 100 的整数倍,直接被拒单。下单前规范化是必备预处理。
- 实盘机器当日常电脑用:跑策略的机器上打游戏、装全家桶,一个弹窗顶掉会话。实盘环境专机专用(哪怕是台旧笔记本或云服务器)。
- 不读券商协议就用自动化工具:easytrader 类方案与部分券商的用户协议存在冲突可能,账户被限制的先例存在。入场前读协议、问客服,别让资金通道本身成为最大风险。
小练习
- 给
PaperBroker加上「部分成交」模拟:随机只成交委托量的一部分,剩余撤销,观察策略层如何处理partial状态。思路提示:
_fill里用rng.random()决定成交比例,Order 的 status 与 filled_volume 如实记录。 - 把 robust_order 的重试日志接入第 43 篇的 logging 体系(INFO 记重试、ERROR 记耗尽),并加告警调用。
思路提示:把 print 换成 log.warning / log.error,耗尽分支里调用 alert()。
- 写一个
Reconciler类:每天收盘后比对「策略记录的持仓」与「通道查询的持仓」,不一致就告警——这是实盘对账的最小实现。思路提示:两边各取 position 字典求差集,任何非零差异都值得告警(第 47 篇坑 16 的对治方案)。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。