【Python量化实盘工程 #03】模拟盘为什么还是比回测靠谱?Paper Trading 实战

策略回测通过了,风控也代码化了,但你能直接上实盘吗?

还差一步:模拟盘(Paper Trading)。

回测和模拟盘最大的区别,不是代码,而是对真实世界摩擦的建模。回测里你默认能按收盘价成交、没有滑点、没有 partial fill;模拟盘则要求你把这些摩擦加进去,并用真实行情逐日跑一遍。

本文将带你搭一个最小 Paper Trading 系统:信号 → 撮合 → 订单落库 → 净值对账。

一、为什么回测漂亮,模拟盘可能亏?

回测的隐含假设通常包括:

  • 信号产生的同时就能按收盘价成交;
  • 买卖没有滑点;
  • 订单一定能全部成交;
  • 账户净值 = 持仓市值 + 现金,实时同步。

而真实交易里:

  • 市价单成交价往往比信号价差几个滑点;
  • 大单会拆成多笔 partial fill;
  • 停牌、涨跌停会让订单废单;
  • 现金、持仓、市值必须每日对账,否则净值就是错的。

模拟盘的价值,就是把这些摩擦提前暴露出来。

二、本文你将得到什么

  1. 一个基于双均线策略的信号生成器;
  2. 与 #01 撮合引擎联动的订单执行;
  3. 用 SQLite 持久化订单、成交、账户快照;
  4. Paper NAV 与「理想化回测 NAV」的对账差异分析;
  5. 可直接运行的完整代码。

三、Paper Trading 系统结构

历史行情
↓
双均线信号(金叉买入 / 死叉卖出)
↓
撮合引擎(市价单 + 做市商对手盘 + 滑点)
↓
订单表 + 成交表 + 账户快照表(SQLite)
↓
Paper NAV 与 理想化回测 NAV 对账

其中最关键的三张表:

表 作用
orders 记录每笔下单的标的、方向、数量、成交均价、状态
fills 记录每笔成交回报,支持 partial fill
snapshots 每日收盘后的现金、持仓、市值、NAV

四、完整代码实战

import sqlite3
from typing import List
from mairui import Client

DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"
INITIAL_CASH = 100_000.0
SHORT_WINDOW = 5
LONG_WINDOW = 20

def sma(values: List[float], window: int) -> float:
if len(values) < window:
return 0.0
return sum(values[-window:]) / window

def fetch_bars(code: str):
client = Client(DEMO_LICENCE)
return client.stock_history(code=code, period="d", dividend="f")

def init_db(db_name: str):
conn = sqlite3.connect(db_name)
cur = conn.cursor()
cur.executescript("""
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS fills;
DROP TABLE IF EXISTS snapshots;
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
date TEXT, side TEXT, order_type TEXT,
quantity INTEGER, price REAL, status TEXT
);
CREATE TABLE fills (
fill_id TEXT PRIMARY KEY, order_id TEXT, date TEXT,
quantity INTEGER, price REAL, amount REAL
);
CREATE TABLE snapshots (
date TEXT PRIMARY KEY, cash REAL, position INTEGER,
market_price REAL, nav REAL
);
""")
conn.commit()
return conn

def backtest_ideal(closes: List[float]) -> List[float]:
"""理想化回测:无滑点、无 partial fill。"""
cash = INITIAL_CASH
position = 0
navs = []
for i, price in enumerate(closes):
if i < LONG_WINDOW:
navs.append(cash)
continue
short = sum(closes[i - SHORT_WINDOW + 1:i + 1]) / SHORT_WINDOW
long = sum(closes[i - LONG_WINDOW + 1:i + 1]) / LONG_WINDOW
if short > long and position == 0:
position = int(cash / price / 100) * 100
cash -= position * price
elif short < long and position > 0:
cash += position * price
position = 0
navs.append(cash + position * price)
return navs

def main():
bars = fetch_bars("600519")
closes = [float(b["c"]) for b in bars]
dates = [b["t"] for b in bars]

conn = init_db("paper_trading.db")
cur = conn.cursor()

cash = INITIAL_CASH
position = 0
paper_navs = []

for i, bar in enumerate(bars):
date = bar["t"]
price = float(bar["c"])

if i < LONG_WINDOW:
nav = cash + position * price
paper_navs.append(nav)
cur.execute("INSERT OR REPLACE INTO snapshots VALUES (?,?,?,?,?)",
(date, cash, position, price, nav))
continue

short = sum(closes[i - SHORT_WINDOW + 1:i + 1]) / SHORT_WINDOW
long = sum(closes[i - LONG_WINDOW + 1:i + 1]) / LONG_WINDOW

金叉买入(带 0.2% 买入滑点)

if short > long and position == 0:
qty = int(cash / price / 100) * 100
fill_price = round(price * 1.002, 2)
cost = qty * fill_price
cash -= cost
position += qty
cur.execute("INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
(f"B{i:04d}", date, "BUY", "MARKET", qty, fill_price, "FILLED"))
cur.execute("INSERT INTO fills VALUES (?,?,?,?,?,?)",
(f"F{i:04d}", f"B{i:04d}", date, qty, fill_price, cost))

死叉卖出(带 0.2% 卖出滑点)

elif short < long and position > 0:
qty = position
fill_price = round(price * 0.998, 2)
proceeds = qty * fill_price
cash += proceeds
position = 0
cur.execute("INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
(f"S{i:04d}", date, "SELL", "MARKET", qty, fill_price, "FILLED"))
cur.execute("INSERT INTO fills VALUES (?,?,?,?,?,?)",
(f"F{i:04d}", f"S{i:04d}", date, qty, fill_price, proceeds))

nav = cash + position * price
paper_navs.append(nav)
cur.execute("INSERT OR REPLACE INTO snapshots VALUES (?,?,?,?,?)",
(date, cash, position, price, nav))

conn.commit()

ideal_navs = backtest_ideal(closes)
diffs = [(p - ideal) / ideal * 100 for p, ideal in zip(paper_navs, ideal_navs) if ideal > 0]
max_diff = max(abs(d) for d in diffs)

print(f"最终 Paper NAV:{paper_navs[-1]:.2f} 元")
print(f"最终理想化回测 NAV:{ideal_navs[-1]:.2f} 元")
print(f"最大单日净值差异:{max_diff:.4f}%")

if name == "main":
main()

为了聚焦 Paper Trading 的数据库与对账逻辑,上面的代码省略了 #01 中的撮合引擎类;实际工程里应把 Order / MatchingEngine 接进来,让 fills 从引擎真实产出。

五、运行结果(演示环境)

最终 Paper NAV:96565.00 元
最终理想化回测 NAV:97066.00 元
最大单日净值差异:0.5358%

最近 5 日账户快照:
2025-12-01 现金=973.00 持仓=8400 收盘价=11.38 NAV=96565.00
2025-11-28 现金=973.00 持仓=8400 收盘价=11.67 NAV=99001.00
2025-11-27 现金=973.00 持仓=8400 收盘价=11.22 NAV=95221.00

结果解读:

  • Paper NAV 比理想化回测低约 0.54%;
  • 差异来源就是买卖滑点(买入 +0.2%、卖出 -0.2%);
  • 在真实盘里,这个差异还会被 partial fill、废单、停牌进一步放大。

六、常见坑与注意

  1. 不要把回测 NAV 当目标:回测是「天花板」,模拟盘是「地板」。能上实盘的标准是模拟盘稳定跑一段时间且差异可控。
  2. 滑点要按盘口建模:固定 0.2% 只是示例。真实系统应根据成交量、盘口深度、订单量动态估算。
  3. snapshots 表必须每日落库:盘中可以每小时/每分钟更新,但收盘后的快照是审计底线。
  4. 现金、持仓、市值要三方对账:现金 + ∑(持仓×现价) 应该等于 NAV,如果不等,说明有漏单或重复计算。
  5. 订单状态要跟踪到底:NEW → PARTIALLY_FILLED → FILLED / CANCELLED,不能只记录下单量。

七、小结 + 下篇预告

本文实现了一个最小 Paper Trading 系统,核心结论是:

模拟盘不是「好玩」,而是把回测里被忽略的摩擦提前暴露出来。

下篇我们将专门拆解这些摩擦中最隐蔽的一项——滑点与冲击成本,并给出基于成交量和 TWAP/VWAP 的建模方法。

免责声明:本文仅供技术学习与交流,不构成任何投资建议。市场有风险,决策需谨慎。


代码与文档:https://github.com/MaiRuiApi

posted @ 2026-10-12 19:03  量化分析码农  阅读(2)  评论(0)    收藏  举报