土法炼钢兴趣小组的算法知识备份

【量化交易】量化交易系统架构:研究、回测、模拟、实盘四套环境

文章导航

分类入口
quant
标签入口
#trading-system#architecture#research#paper-trading#live

目录

一个量化策略从「在 notebook 里看到一条漂亮的累计收益曲线」走到「在真实账户里持续产生 PnL」之间,至少要穿过四套环境:研究(research)、回测(backtest)、模拟交易(paper trading)、实盘(live)。每一步都会过滤掉一批本以为成立的假设。如果这四套环境各自写一份代码、各自维护一份配置、各自定义一份「股票」「订单」「持仓」的数据结构,那么策略上线后的第一次事故几乎一定来自接缝处——研究里用了某个字段,回测里那个字段是 nan,模拟里能成交的限价单实盘被拒因为 tick size 不对,或者实盘风控以毫秒级触发,而模拟里风控只在收盘后跑一次。

这种事故的难点不在排查具体 bug,而在它永远以新的形式重现。修了 tick size 还会有 lot size,修了 lot size 还会有最小金额下限,修了金额下限还会有交易所半天临时停牌的代码;只要架构上允许「同一份策略在不同环境里走不同代码路径」,问题就是按下葫芦起来瓢。这一篇要解决的是架构问题:把这四套环境用一组共享接口连起来,让策略代码本身只写一次,让环境差异收敛到几个适配器(adapter)里。

接下来的内容沿这条主线展开:先把四套环境的角色和它们各自必须解决的问题说清楚,再给出共用核心抽象的六层接口;然后分别讲研究、回测、模拟、实盘四个环境的工程要点;最后是配置、灰度发布、特性开关,以及跨环境一致性怎么持续保证。

代码示例使用 Python 3.11、pandas 2.2、numpy 1.26、pydantic 2.6、PyYAML 6.0,部分位置引用 MLflow 2.10 与 SQLAlchemy 2.0。所有数据均为本地仿真,不涉及任何具体券商、交易所或第三方数据源。架构思路与 《回测引擎》《HFT 架构》 一脉相承,并且与 fintech 系列里的 《撮合引擎》《可靠性与容灾》 在「订单状态机」「对账」「灰度发布」三处相互呼应。

风险提示:本文聚焦工程架构,不构成任何投资建议或交易系统认证。文中给出的接口、状态机、阈值仅用于阐释方法论,具体生产环境必须根据所在司法辖区的监管要求(在中国大陆涉及证监会、交易所会员业务规则;在美国涉及 SEC Rule 15c3-5、Reg SCI;在欧盟涉及 MiFID II RTS 6 算法交易治理)单独审计。模拟交易给出的盈利从来不能保证实盘可复制,「能在 paper 上稳定赚钱」只是上线的必要条件,远不是充分条件。任何把本文代码直接接入真实账户的做法都需要先经过该券商或交易所的合规、风控、IT 三方联合评审;本文不替代任何审计、压测与备案流程。


一、四套环境的角色

一点一、为什么必须分四套,不是三套也不是五套

很多团队最初只有两套环境:notebook 和实盘。代码长这样——研究员在 Jupyter 里写一段 pandas 跑历史,看着 PnL 不错,把 pandas 替换成实时行情接口,挂到 cron 上每分钟执行一次。这种结构在策略数量为一、参与者只有写策略的人本身、品种只有一个、监管不强制留痕时确实最快。它会在三件事发生后崩溃:第一,策略数量变成两个,两个策略要共享同一份组合层风险预算;第二,研究员从一个变成五个,每个人改一行就要重新部署整个进程;第三,监管要求事前风控、事后留痕、订单全链路可审计。这三件事一旦发生,「notebook 直连交易所」的结构就再也撑不住。

合理的下一步是把环境拆分成 research → backtest → live 三套:研究只做假设和探索,回测做严格的历史复现,实盘把回测过的策略接到真实账户上。这种结构能应付大多数中等规模的团队,但它会在一个具体场景上反复出事——实盘第一天。前两套环境再严谨,都有一类问题不会出现:交易所返回了一个回测里没见过的错误码,券商网关把订单状态从 PendingNew 直接跳到 Rejected 而没经过 New,行情线程在交易所午休前自己重连了一次而 cron 拉起来的策略不知道,等等。这些问题不是回测能发现的,因为它们根本不属于「策略对不对」,而属于「生产路径走不走得通」。

模拟交易(paper trading)就是为这件事而存在的第四套环境:走完整的生产路径,但不真实成交。它读实时行情、跑实时风控、把订单送到一个「影子撮合器」(shadow matcher)或券商提供的 paper 账户、记录所有时间戳、产出每日 PnL,唯一区别是订单不会变成市场上的真实成交。它能发现的问题集合,刚好是回测发现不了、又必须在动真钱之前发现的那些。

所以四套环境对应四类不同的风险:

少一套,就少一道独立的过滤器。多一套,例如再分出「Sandbox」和「UAT」,对中小团队是过度工程;交易所或券商的官方 sandbox 通常已经被 paper 环境覆盖。四套是一个经验上稳定的最小切分。

一点二、四套环境的边界条件

把它们各自能做、不能做、必须做的事列清楚,比泛泛说「四套环境很重要」有价值得多。

环境 时钟 数据来源 订单去向 必须保证的不变量 不应该承担的事
Research 任意 历史 + 探索性 不发单 PIT 正确性、可复现 不做完整撮合模拟、不算手续费精度
Backtest 离线虚拟 历史 进入引擎模拟撮合 与研究环境特征一致、确定性 不接真实行情、不发实盘
Paper 实时 实时行情 影子撮合或 broker paper 与实盘走完全相同的代码路径 不能真实成交、不能与实盘共享风险预算
Live 实时 实时行情 真实交易所 / 券商 事前风控、对账、可审计 不做大规模参数搜索、不做未冻结代码的实验

「不应该承担的事」这一列经常被忽视。研究环境最常见的错误是「为了精度把回测引擎搬进 notebook」——这会让研究的迭代速度从分钟级掉到小时级,研究员逐渐放弃尝试新假设。实盘环境最常见的错误是「为了灵活就允许直接热加载策略代码」——这会让任何一次 push 都变成监管意义上的「未经测试上线」,一旦出事追责无门。把这两类错误压住,是架构层最先要做的事。

一点三、四套环境的失败模式各不相同

把每套环境最容易出的事故列出来,能让架构选择更有依据。

研究环境的典型事故是「在 notebook 里看到一条令人激动的累计收益曲线,但回测里复现不出来」。原因几乎一定属于以下几类之一:第一,notebook 用了未来信息——例如某个 rolling 窗口默认 center=True,相当于偷看未来;第二,notebook 引用了一个还没发布的数据快照;第三,notebook 里随机种子没设,每次跑结果不一样;第四,notebook 用了 fillna(method=“ffill”) 跨越了停牌段,把停牌前后的价格当连续序列处理。这四类问题在没有 PIT 与版本固化的研究环境里几乎必然发生。

回测环境的典型事故是「过拟合到一个回测引擎都不知道的细节」。常见的形式是手续费率、tick size、最小报单量、竞价段成交规则在回测引擎里以一种简化形式存在,于是策略学到了利用这种简化的”漏洞”——例如假定每一根 K 线收盘价可以直接成交,不考虑 5% 涨跌停板、不考虑收盘集合竞价的实际撮合机制。这种过拟合在样本外检查里也未必能立刻露馅,因为它伪装成「真的有 alpha」。

模拟环境的典型事故是「行情线程或订单线程在某个边界时刻挂掉但策略没察觉」。比如交易所午休前最后一秒发了一条 tick,策略基于它生成了一个订单,但订单送出时网关已经收到「即将停盘」的信号,订单被丢弃且没有任何回报。或者夜间某次网络抖动让 WebSocket 重连,重连后落了一条 fill 事件,paper 的持仓与影子撮合不一致直到第二天对账才被发现。

实盘环境的典型事故是「策略本身没问题,但生产路径上某一处具体实现没扛住边界条件」。最经典的例子是 2012 年 Knight Capital 事件——一段未及时下线的旧代码被部分服务器加载,结果 45 分钟产生 4 亿美元亏损。这种事故的特征是:测试环境(包括 paper)里那一段代码不会被触发,因为触发它需要的是「部署时四台机器有三台部署了新版本、一台部署了旧版本」这种纯生产路径的状态。

这些失败模式各不相同,对应的过滤器也各不相同。把它们映射到环境,就解释了为什么必须四套都存在。

一点四、环境之间的边界产物

环境与环境之间不是凭口头交接,而是凭产物(artifact)交接。每跨一道环境,必须有一份可被下游自动消费、可被审计回溯的工件。

这是一个闭环。如果只单向流动——研究做完丢给回测,回测做完丢给实盘——那么实盘里学到的东西永远进不到下一次研究里。下一次同样的滑点假设错误还会再犯一次。架构层必须为「实盘 → 研究」这条反馈路径单独留出位置,否则四套环境就退化成「三套独立筒仓 + 一个生产环境」。

四套环境拓扑图

Four Env Topology

二、共用核心抽象

四套环境之所以能复用同一份策略代码,靠的是把整个交易系统垂直切成一组接口,让策略只依赖接口、不依赖实现。这一节给出六层抽象:数据接入、特征、信号、组合、订单、风险。每一层在四个环境里有不同实现,但接口签名完全相同。

二点一、为什么是六层

要理解为什么是这六层,先想一个反例:把所有事情塞到一个 Strategy 类里。这个类会同时负责拉数据、算特征、出信号、决定下多少手、把订单发出去、检查风险。它在研究阶段最快,因为没有任何文件分割,所有逻辑在一个文件里。但只要参与者超过一个人,或者要支持多个策略共享同一份风控,这个类就会迅速膨胀成「上帝对象」(god object)。任何一个改动都可能伤到任何一处。

把它拆成六层,每一层只回答一个问题:

这种切法是经验性的,不是唯一选择。但它有一个具体好处:每一层的输入输出都是纯数据(dataframe、dataclass、dict),层与层之间没有共享可变状态。这让单元测试、跨环境复用、灰度发布都变成可能。

共用核心抽象层次图

Core Abstractions

二点二、六层接口的 Python 定义

下面这段代码用 Protocol 表达接口,避免把环境实现耦合进策略层。Protocol 是 PEP 544 引入的结构化子类型,调用方不需要显式继承。

# core/interfaces.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Iterator, Mapping, Optional, Protocol

import pandas as pd


class Side(str, Enum):
    BUY = "BUY"
    SELL = "SELL"


class OrderType(str, Enum):
    MARKET = "MARKET"
    LIMIT = "LIMIT"


class OrderStatus(str, Enum):
    PENDING_NEW = "PENDING_NEW"
    NEW = "NEW"
    PARTIALLY_FILLED = "PARTIALLY_FILLED"
    FILLED = "FILLED"
    CANCELED = "CANCELED"
    REJECTED = "REJECTED"


@dataclass(frozen=True)
class Bar:
    symbol: str
    ts: datetime
    open: float
    high: float
    low: float
    close: float
    volume: float


@dataclass(frozen=True)
class Order:
    client_order_id: str
    symbol: str
    side: Side
    qty: float
    order_type: OrderType
    price: Optional[float] = None
    ts_create: Optional[datetime] = None


@dataclass(frozen=True)
class Fill:
    client_order_id: str
    ts: datetime
    qty: float
    price: float
    fee: float


@dataclass(frozen=True)
class Position:
    symbol: str
    qty: float
    avg_price: float


class DataSource(Protocol):
    def historical_bars(
        self, symbol: str, start: datetime, end: datetime, freq: str
    ) -> pd.DataFrame: ...
    def stream_bars(self, symbols: list[str], freq: str) -> Iterator[Bar]: ...
    def pit_query(self, table: str, asof: datetime) -> pd.DataFrame: ...


class FeaturePipeline(Protocol):
    version: str
    def transform(self, bars: pd.DataFrame) -> pd.DataFrame: ...


class SignalGenerator(Protocol):
    def predict(self, features: pd.DataFrame) -> pd.Series: ...


class PortfolioConstructor(Protocol):
    def target_weights(
        self, alpha: pd.Series, current: Mapping[str, Position]
    ) -> pd.Series: ...


class OrderRouter(Protocol):
    def submit(self, order: Order) -> str: ...
    def cancel(self, client_order_id: str) -> None: ...
    def on_fill(self, callback) -> None: ...


class RiskManager(Protocol):
    def pre_trade_check(self, order: Order) -> Optional[str]:
        """返回 None 表示通过,返回字符串表示拒因。"""
        ...
    def post_trade_update(self, fill: Fill) -> None: ...
    def kill_switch(self) -> bool: ...

这一段有几个细节值得反复读。

第一,OrderStatus 显式列出了 PENDING_NEWNEW 两个状态。这是 FIX 协议的传统:客户端把订单生成后处于 PENDING_NEW,只有收到交易所回报 35=8 ExecType=0 时才转 NEW。回测里很容易把这两个状态合并掉,一旦合并,回测里测过的状态机就和实盘对不上。

第二,Order 携带 client_order_id,这是客户端生成的幂等键。任何重连、补单、对账都以此为锚,不依赖交易所返回的 order_id。这与 fintech 系列 《撮合引擎》 中的 clOrdID 概念一致。

第三,pre_trade_check 返回一个 Optional[str],而不是 bool。失败时返回拒因字符串,成功时返回 None。这种设计的好处是:拒因本身就是审计日志的一部分,不需要再多一个字段。生产里几乎每一次合规事故的复盘都需要回答「为什么这单被拒了/为什么这单没被拒」,把拒因放在接口签名里,等于强制每一层实现都把这件事说清楚。

二点三、Strategy 层只依赖接口

有了上述接口,Strategy 类只需要写一遍:

# core/strategy.py
from datetime import datetime
import pandas as pd
import uuid
from .interfaces import (
    DataSource, FeaturePipeline, SignalGenerator,
    PortfolioConstructor, OrderRouter, RiskManager,
    Order, Side, OrderType,
)


class Strategy:
    def __init__(
        self,
        symbols: list[str],
        data: DataSource,
        feat: FeaturePipeline,
        signal: SignalGenerator,
        portfolio: PortfolioConstructor,
        router: OrderRouter,
        risk: RiskManager,
    ):
        self.symbols = symbols
        self.data = data
        self.feat = feat
        self.signal = signal
        self.portfolio = portfolio
        self.router = router
        self.risk = risk
        self.positions: dict[str, float] = {s: 0.0 for s in symbols}

    def step(self, asof: datetime) -> None:
        if self.risk.kill_switch():
            return
        bars = pd.concat([
            self.data.historical_bars(s, asof - pd.Timedelta("60D"), asof, "1D")
            for s in self.symbols
        ])
        feats = self.feat.transform(bars)
        alpha = self.signal.predict(feats)
        target = self.portfolio.target_weights(alpha, current={})
        for symbol, w in target.items():
            delta = w - self.positions.get(symbol, 0.0)
            if abs(delta) < 1e-6:
                continue
            order = Order(
                client_order_id=str(uuid.uuid4()),
                symbol=symbol,
                side=Side.BUY if delta > 0 else Side.SELL,
                qty=abs(delta),
                order_type=OrderType.MARKET,
                ts_create=asof,
            )
            reject = self.risk.pre_trade_check(order)
            if reject is not None:
                continue
            self.router.submit(order)

注意这一段没有任何「if backtest else if live」的分支。它在四套环境里跑的是同一段字节码。环境差异完全收敛到注入进来的六个对象的实现里。


三、研究环境

三点一、Notebook 不是问题,但要有约束

研究环境的核心生产力工具是 Jupyter Notebook。围绕它的常见争论是「notebook 该不该进生产」。从架构角度看,这个问题问错了方向:notebook 是研究工具,本来就不该进生产;问题是研究产物该不该进生产。如果研究员在 notebook 里写了一个 compute_alpha(df) 函数,下游回测、模拟、实盘要用同一个函数,那么这个函数不能留在 notebook 里——它必须以 .py 模块形式 commit 到代码仓,notebook 通过 import 调用它。

约定上至少需要这几条:

这些约束看起来琐碎,但它们把「notebook」从一种「随便写写」的工具变成「研究台账」(research log)。一年之后回看,能清楚知道某个想法是哪天提出来的、哪些 notebook 验证过、最终是否落地。

三点二、PIT 数据访问层

研究环境最容易出的事故是 point-in-time(PIT)违规。研究员写一段 SQL SELECT * FROM fundamentals WHERE asof <= '2020-01-01',看似只用了 2020 年 1 月 1 日之前的数据,但如果 fundamentals 表是事后修订的(财报会重述、盈利会调整),那么这段查询拿到的是「2020 年 1 月 1 日之前的口径,但已经被后续修订过」的数据,本质上发生了未来信息泄露。

解决办法是数据访问层(DAL)强制走 PIT 接口。所有数据表必须有两个时间字段:asof(数据所对应的时点)和 ingest_ts(数据入库的时点)。PIT 查询长这样:

# research/pit_dal.py
from datetime import datetime
import pandas as pd
from sqlalchemy import create_engine, text


class PITDataSource:
    def __init__(self, dsn: str):
        self.engine = create_engine(dsn)

    def pit_query(self, table: str, asof: datetime) -> pd.DataFrame:
        sql = text(f"""
            SELECT * FROM {table}
            WHERE ingest_ts <= :asof
              AND asof <= :asof
            QUALIFY ROW_NUMBER() OVER (
              PARTITION BY entity_id, asof
              ORDER BY ingest_ts DESC
            ) = 1
        """)
        with self.engine.connect() as conn:
            return pd.read_sql(sql, conn, params={"asof": asof})

ingest_ts <= :asof 这一行是关键。它说的是「在那个时点,数据库里能查到的最新版本是哪一行」。少了它,PIT 就不成立。这一段在 《幸存者偏差》 一文里展开过。

三点三、MLflow 把每次实验固化下来

研究的另一面是实验管理。同一个 alpha 想法,研究员可能尝试 30 种特征组合、5 种模型、3 种训练窗口,一共 450 次实验。如果没有自动记录,第 200 次跑完之后,已经没人记得第 47 次的参数和结果是什么。

MLflow 是这件事最常用的工具。最小可用的接入大概是:

# research/track.py
import mlflow
import mlflow.sklearn


def run_experiment(name: str, params: dict, metrics: dict, model=None):
    mlflow.set_experiment(name)
    with mlflow.start_run():
        mlflow.log_params(params)
        mlflow.log_metrics(metrics)
        if model is not None:
            mlflow.sklearn.log_model(model, artifact_path="model")

调用一次写一行。它的真正价值不是「能查历史」,而是把「这次实验用了什么数据切片、什么参数、什么模型」自动固化成一份可被回测引擎下游消费的工件。strategy.yaml 会引用 MLflow 里某次 run 的 model_uri,回测加载的就是那次实验产生的模型——不是任何「最近一次手动 pickle」的版本。

三点四、研究环境的算力切片

研究环境的另一个工程问题是算力共享。一个团队往往有 5 到 20 个研究员同时在做实验,如果共用一台 GPU 服务器或一个 K8s 集群,互相之间的资源抢占会让效率剧烈波动。常见的解决思路:

这一层的设计经常被低估。如果研究员每次跑实验都要等 30 分钟排队,那么任何精巧的接口、任何精确的 PIT,都无法弥补「迭代速度本身被堵死」造成的损失。研究环境的第一性指标永远是「研究员从想法到拿到反馈的中位数时长」,把这个数字压到分钟级,比任何其他工程优化都更值得。

三点五、研究环境的「发布」

当研究员认为某个想法值得做严肃回测时,要有一个明确的「发布」动作,把 notebook 里的探索固化成下游可消费的产物。生产里典型的发布动作是:

  1. 把 notebook 里的核心函数挪到 core/strategies/<strategy_name>/ 模块。
  2. strategy.yaml 描述参数、依赖、模型 URI、特征版本。
  3. 跑一次单元测试 pytest tests/strategies/<strategy_name>/
  4. 在 git 里打一个 tag strategy/<name>/v0.1
  5. 通知回测系统拉取这个 tag 跑全量。

这种结构看起来繁琐,但它把「研究」从一种私人活动变成团队可协作的活动,所有发布都有版本号、有 owner、有可追溯链。


四、回测环境

四点一、与第十九篇的联动

回测引擎本身在 《回测引擎》 那一篇已经详细讲过:事件循环、撮合假设、滑点模型、手续费精度、资金曲线计算。这一节不重复,只补两件架构层的事:怎么把回测引擎装进六层接口、怎么支持大规模并发任务。

回测里的六层实现大致这样:

# backtest/adapters.py
from datetime import datetime
import pandas as pd
import uuid
from core.interfaces import (
    DataSource, OrderRouter, RiskManager,
    Order, Fill, OrderStatus,
)


class BacktestDataSource:
    def __init__(self, store: dict[str, pd.DataFrame]):
        self.store = store
        self.cursor: datetime | None = None

    def set_cursor(self, ts: datetime) -> None:
        self.cursor = ts

    def historical_bars(self, symbol, start, end, freq):
        df = self.store[symbol]
        end = min(end, self.cursor) if self.cursor else end
        return df[(df.index >= start) & (df.index <= end)]

    def stream_bars(self, symbols, freq):
        raise NotImplementedError("backtest 不消费实时流")

    def pit_query(self, table, asof):
        raise NotImplementedError("backtest 由调用方保证 PIT")


class BacktestOrderRouter:
    def __init__(self, slippage_bps: float = 2.0):
        self.fills: list[Fill] = []
        self.slippage_bps = slippage_bps
        self._on_fill = None
        self._next_price: dict[str, float] = {}

    def set_next_price(self, symbol: str, price: float) -> None:
        self._next_price[symbol] = price

    def on_fill(self, callback):
        self._on_fill = callback

    def submit(self, order: Order) -> str:
        px = self._next_price[order.symbol]
        adj = 1 + (self.slippage_bps / 1e4) * (1 if order.side.value == "BUY" else -1)
        fill_px = px * adj
        fill = Fill(
            client_order_id=order.client_order_id,
            ts=order.ts_create,
            qty=order.qty,
            price=fill_px,
            fee=fill_px * order.qty * 1e-4,
        )
        self.fills.append(fill)
        if self._on_fill:
            self._on_fill(fill)
        return order.client_order_id

    def cancel(self, client_order_id):
        pass


class BacktestRiskManager:
    def __init__(self, max_gross: float = 1.0):
        self.max_gross = max_gross
        self.gross = 0.0
        self._tripped = False

    def pre_trade_check(self, order):
        if self._tripped:
            return "kill_switch"
        if self.gross + order.qty > self.max_gross:
            return "gross_limit"
        return None

    def post_trade_update(self, fill):
        self.gross += abs(fill.qty)

    def kill_switch(self):
        return self._tripped

注意这里的 BacktestOrderRouter 仍然实现了 on_fill,回测引擎在每一次撮合后会调用一遍。这种「事件回调」式的接口让 backtest、paper、live 的成交事件流统一——上层根本不关心这个回调是来自历史数据驱动的虚拟撮合还是来自真实交易所推送。

四点二、为什么回测要支持大规模并发

只跑一次回测的回测引擎是工具,能并发跑一万次回测的回测引擎才是平台。研究阶段需要做的事情中,最吃算力的是参数搜索(grid / Bayesian)和 walk-forward CV:在 50 个参数组合 × 10 年 × 250 个标的池上做交叉验证,单线程跑要数天。

把回测包装成可水平扩展的任务调度,至少需要:

最简单的实现是用 Ray 或 Dask 的 task graph,把每个 backtest 当成一个 remote function。比这更朴素的做法是 multiprocessing.Pool + 对象存储——对中小团队完全够用。

四点三、回测引擎与策略代码的隔离

回测引擎是基础设施,策略代码是业务逻辑。两者必须严格分层。一个常见的反模式是:策略类继承自回测引擎提供的 Strategy 基类,基类里有 self.broker.buy(...) 这样的方法。这种结构让策略代码与回测引擎深度耦合,迁移到 paper/live 时几乎要重写。

正确的分层是:回测引擎作为一个「驱动器」(driver)存在,它的职责是按时间步 step(asof) 推进时钟、给 BacktestDataSource 设置 cursor、给 BacktestOrderRouter 设置可成交价格、调用 Strategy.step(asof)、收集 fills。策略本身不知道有「回测引擎」这种东西,它只知道自己被注入了六个对象。

伪代码:

# backtest/runner.py
def run_backtest(strategy: Strategy, bars: pd.DataFrame) -> BacktestResult:
    timestamps = bars.index.unique()
    for ts in timestamps:
        ds: BacktestDataSource = strategy.data
        router: BacktestOrderRouter = strategy.router
        ds.set_cursor(ts)
        for sym in strategy.symbols:
            row = bars.loc[(bars.index == ts) & (bars["symbol"] == sym)]
            if not row.empty:
                router.set_next_price(sym, float(row["close"].iloc[0]))
        strategy.step(ts)
    return BacktestResult(fills=router.fills)

这段代码里的 strategy.step(ts) 调用与 paper、live 中那个 step 完全是同一个方法。差别只是「时钟从哪里来」「成交价从哪里来」。这就是六层抽象的全部价值。

四点四、回测缓存的常见坑

回测缓存最常见的坑是 key 不全。一个回测的产出依赖很多变量:策略代码、特征代码、数据快照、参数组合、随机种子、撮合假设、手续费率。如果缓存 key 只考虑了 (参数组合, 时间窗口),那么策略代码改了一行而 key 没变,下游会拿到旧的回测结果还以为是新的。研究员发现「参数稍微动一下结果就变得乱七八糟」,本质上是缓存命中了过期工件。

正确的做法是把所有「会影响结果的输入」哈希进 key。一个经验上稳定的实现:

# backtest/cache.py
import hashlib
import json


def cache_key(strategy_version: str,
              feature_version: str,
              data_snapshot: str,
              params: dict,
              cost_model_version: str,
              seed: int) -> str:
    blob = json.dumps({
        "sv": strategy_version,
        "fv": feature_version,
        "ds": data_snapshot,
        "p": params,
        "cm": cost_model_version,
        "seed": seed,
    }, sort_keys=True).encode()
    return hashlib.sha256(blob).hexdigest()[:16]

只要这六个变量任何一个变了,key 就变。命中即复用,未命中就重算。把缓存命中率作为研究环境的一个监控指标观察,一旦异常下降,往往意味着上游有版本漂移。


四点五、回测引擎的确定性

回测必须确定性。同一份代码 + 同一份输入 + 同一份种子,跑两次必须输出完全相同的结果。听起来理所当然,但生产里出问题的地方很多:

确定性的工程价值是:当回测结果在某次合入后突然变了,可以用 git bisect 精确定位到是哪一次 commit 引入的偏差。如果回测本身不确定,bisect 就退化成靠运气。

五、模拟交易(Paper Trading)

五点一、Paper 不是「带实时行情的回测」

很多团队的 paper 实现是「把回测引擎改成实时数据」,这个理解会引出大量微妙错误。Paper 与 backtest 在概念上的根本区别是:paper 必须走完整的生产代码路径。具体来说,研究/回测里允许 monkey patch、允许内嵌函数、允许全局变量;paper 里必须用 production 路径——同一份订单网关代码(除了真实送单一步)、同一份风控代码、同一份日志代码、同一份监控埋点。

如果 paper 只是「带实时行情的回测」,那么任何只在生产路径上才会出现的问题——序列化、时区、网关心跳、消息丢失、状态机断点——在 paper 里都不会出现。一旦上实盘,第一周一定会被这些问题击穿一两次。Paper 存在的意义就是替实盘提前承担这些教训。

五点二、Shadow Trading

Paper 的最强形态是 shadow trading:策略和实盘并行运行,订单一份发往交易所,一份发往影子撮合器,每天对比两份的差异。这种做法的成本是「策略要跑两份」,回报是「每一次实盘异常都有一份独立基线可对照」。

在 broker 不提供 paper 账户、或者 paper 账户行为偏离实盘较大时(这是常态),shadow 反而比官方 paper 账户可信——因为它的撮合逻辑在自己手里,可以做严格的「按 best bid/ask + queue position 估算」。Shadow 的实现要点是:

如果偏差超过某个阈值(比如 hit rate 偏离 5pp 以上),就要触发告警——要么实盘出了问题,要么 shadow 模型已经过时。

五点三、Paper 的时钟与回放

Paper 的另一个工程难点是时钟。理论上 paper 用「真实时钟」(wall clock),但出于调试、回放、压测的需要,又希望能在 paper 里跑「加速回放」——把昨天一整天的行情按 5 倍速回放,半小时跑完,提前看一遍策略在昨天市况下会做什么。这要求时钟是可注入的(injectable):

# core/clock.py
from datetime import datetime
from typing import Protocol


class Clock(Protocol):
    def now(self) -> datetime: ...
    def sleep_until(self, ts: datetime) -> None: ...


class WallClock:
    def now(self) -> datetime:
        return datetime.utcnow()

    def sleep_until(self, ts: datetime) -> None:
        import time
        delta = (ts - self.now()).total_seconds()
        if delta > 0:
            time.sleep(delta)


class ReplayClock:
    def __init__(self, start: datetime, speed: float = 1.0):
        self._virtual = start
        self._speed = speed

    def now(self) -> datetime:
        return self._virtual

    def sleep_until(self, ts: datetime) -> None:
        import time
        delta = (ts - self._virtual).total_seconds() / self._speed
        if delta > 0:
            time.sleep(delta)
        self._virtual = ts

策略代码任何对「现在几点」的依赖都走 clock.now(),不直接调用 datetime.utcnow()。这条规则一旦被打破,时钟相关的回放/压测就全部失效。Paper 里默认装 WallClock,回放模式装 ReplayClock,回测里装由引擎驱动的 BacktestClock

五点四、Paper 的合规边界

不要小看 paper 的合规问题。在某些司法辖区(特别是欧盟 MiFID II 算法交易测试要求、美国 SEC Rule 15c3-5 准入测试),paper 测试本身就是上线前必须留痕的步骤。具体到要求的留痕内容:

这些数据不是事后能补出来的。架构层就要把 paper 环境的全量审计日志强制写入只读存储(WORM 或带写入限制的对象存储),保留期至少匹配该司法辖区的最长要求(在中国证券业一般 20 年,在 MiFID II 一般 5 到 7 年)。


六、实盘环境

六点一、券商/交易所对接

实盘的核心是订单网关。市场上的接入协议大致分三类:

不管协议怎么变,订单网关在六层接口里的角色都是 OrderRouter 的实现。它需要做的事:

六点二、订单状态机

订单状态机看起来简单,但在实盘里出 bug 的概率非常高。一个能用的最小状态机是:

            submit()
PENDING_NEW ────────► NEW
   │                  │
   │ reject           │ partial fill
   ▼                  ▼
REJECTED       PARTIALLY_FILLED ─► FILLED
                      │
                      │ cancel
                      ▼
                  CANCELED

实盘里常见的状态机 bug:

把状态机写对的最稳妥做法是用 transitions 这类库定义合法迁移,并且在每次迁移时记录 (prev_state, event, next_state, ts)。出事时这份日志是唯一的真相。

六点三、对账

实盘必须对账。对账的输入是三份独立来源的数据:

  1. 本地策略系统记录的成交(来自 OrderRouter.on_fill)。
  2. 券商/交易所端日终回报(成交清单、持仓清单、资金清单)。
  3. 资管/托管系统的独立记录(如果有)。

三份必须两两 reconciled。任何一笔记录在两边对不上,都必须有一个明确的处理流程:是本地多记了?是券商漏报了?是网关在重连期间丢了一条?这种事在 fintech 系列的 《可靠性与容灾》 中讲到,量化系统继承同样的工程纪律。

对账的频率至少有两层:

六点四、断网与重连

实盘环境必须假设网络会断。无论是 FIX session 还是 WebSocket 连接,断线只是时间问题。断线本身不可怕,可怕的是断线之后系统不知道自己断了,或者重连之后状态没有正确恢复。

断线检测靠心跳。FIX 4.4 的 HeartBtInt 字段约定双方在 N 秒内没消息则发心跳,K×N 秒(典型 K=2)没收到任何消息则视为断线。WebSocket 一般用 ping/pong frame。无论协议如何,本地必须有独立的「连通性看门狗」,定期检查最近一条消息的时间戳,超阈值就主动断连重建。

重连的关键问题是:断线期间发生的事件怎么补回来?FIX 4.4 提供 ResendRequest 机制,重连后用 35=2 请求缺失序号区间。WebSocket 行情通道一般没有 replay,必须靠快照接口主动拉取最新状态。订单通道则要靠 OrderStatusRequest(35=H)+ MassStatusRequest(35=AF)查询所有未成交订单当前状态。

重连之后必须立刻做的几件事:

这套流程不能省。Knight Capital 事件的根因之一就是部署版本不一致,但放大它的原因之一是断线后的状态恢复机制有缺陷。

六点五、实盘的事前风控

事前风控(pre-trade risk)是实盘环境与回测、paper 最大的不同。回测里风控可以「事后看一眼」,实盘里风控必须在订单送出之前完成检查,并且必须在毫秒到亚毫秒级完成。SEC Rule 15c3-5(俗称 Market Access Rule)要求 broker 必须在订单送出前完成净敞口、单笔金额、错指令防护(fat-finger)三项检查。在中国境内,证监会对程序化交易也有类似的事前风控要求。

最朴素的事前风控规则集:

这些规则应当独立于策略代码,运行在订单网关与策略之间的一个独立进程或独立线程里。一个常见的做法是把风控做成共享内存里的规则表,由独立的「风控官」服务维护,每个策略进程调用规则做检查。任何规则触发都立刻在审计日志里留痕,并且大于阈值时触发短信/电话告警。


七、配置、灰度发布、特性开关

七点一、配置即数据,不是代码

策略的所有可变参数都不应该硬编码在 Python 文件里。应当走配置——strategy.yaml、参数表、特性开关(feature flags)。配置变更不需要重新部署进程,只需要发一次配置 reload。这不是为了灵活,而是为了可审计:每一次参数调整都有一条独立 commit、独立 reviewer、独立时间戳。

一个最小的策略配置长这样:

# strategies/momentum_v3/strategy.yaml
name: momentum_v3
version: 0.4.2
owner: ltl
symbols:
  - 600519.SH
  - 000858.SZ
feature_pipeline:
  module: features.momentum
  version: "1.2"
signal:
  module: signals.momentum_xgb
  model_uri: mlflow://experiments/123/runs/abc/model
portfolio:
  max_gross: 1.0
  max_per_symbol: 0.1
risk:
  max_order_notional: 1_000_000
  max_orders_per_minute: 30
  fat_finger_pct: 0.05
schedule:
  cron: "0 30 9-15 * * MON-FRI"
  timezone: "Asia/Shanghai"

这份 YAML 在四套环境里复用。环境特定的差异(数据源 DSN、broker 凭证、日志目录)放在 env.<environment>.yaml 里,由部署系统注入。

七点二、灰度发布

策略上线不能「一把切」。哪怕回测漂亮、paper 跑了一个月,第一次真实成交也必须从小开始。常见的灰度路径:

这套路径能否落地,取决于配置是否支持「按账户、按策略、按标的」分别设置上限。最直接的实现是把所有上限做成一个 (scope, key, limit) 三元组表,scope 是 account/strategy/symbol,key 是具体的账户号/策略名/标的代码,limit 是金额或数量。运行期由风控官查这张表。

七点三、灰度的退路

灰度发布的对偶问题是「退路」。任何一次小步放量后,必须有「立刻退回上一档」的能力。这条退路的具体形式:

这种「双保险」结构在 fintech 系列 《可靠性与容灾》 里有更完整讨论。量化系统的特殊之处是:「平仓」本身也会成交、也有市场冲击,平仓策略的设计本身又是一个小型策略问题(直接市价砸 vs TWAP 平仓 vs 用对冲腿覆盖)。在架构层就要把「平仓 path」当成一等公民,不要等出事时临时拍脑袋。

七点四、特性开关

特性开关解决「策略代码已经合入主干,但还不想全量启用」的问题。比如新增了一种执行算法,开发完毕但只想给某一个标的用。开关的实现可以非常简单:

# core/feature_flags.py
import json
from pathlib import Path


class FeatureFlags:
    def __init__(self, path: Path):
        self.path = path
        self._cache: dict = {}
        self.reload()

    def reload(self) -> None:
        if self.path.exists():
            self._cache = json.loads(self.path.read_text())

    def is_on(self, key: str, scope: dict | None = None) -> bool:
        rule = self._cache.get(key)
        if rule is None:
            return False
        if rule.get("enabled") is True:
            return True
        if scope is None:
            return False
        for filt in rule.get("scopes", []):
            if all(scope.get(k) == v for k, v in filt.items()):
                return True
        return False

这种结构允许把开关切到「仅 momentum_v3 策略 + 仅 600519.SH 标的」这种细粒度,给生产里做小步实验留出空间。重要的是开关切换走配置变更流程,每次切换都有审计记录。


八、跨环境一致性

八点一、代码复用的硬底线

四套环境共用同一份策略代码,听起来是约定,落实下来需要架构层强制。最直接的做法是:

听起来教条,但只有这种「机器层面强制」的依赖方向,才能在团队人数变多、代码量变大之后不退化。任何「靠 reviewer 自觉」的约定,半年内一定会被打破。

八点二、配置漂移

四套环境共享同一份 strategy.yaml,但环境特定配置(DSN、broker 凭证、日志路径)单独存放,这种结构容易出现「配置漂移」(config drift):研究环境改了一个手续费率参数,没同步到 paper 和 live。下一周复盘时,没人知道为什么 paper 的 PnL 和 live 差了一截。

减少漂移的几个办法:

八点三、监控覆盖

四套环境都需要监控,但监控指标不应该是各写各的。共用一份指标定义(一个 metrics.yaml 列出所有 metric 的 name、type、unit、SLO),四套环境分别上报。这样 dashboard 可以并排比较「同一个指标在四套环境里的表现」,差异立刻显形。

最小指标集合至少包括:

每一个都设 SLO,超阈值告警。回测里这些指标也要算(虽然「下单延迟」在回测里恒为零),因为它们决定了模型对延迟的假设是否成立。

八点四、用一致性测试盯住接缝

最强的一致性保障是自动化的一致性测试:让 backtest 和 paper 在同一段历史 + 同一个策略版本上跑,比较两份输出,差异超过阈值就 fail。具体做法:

这种测试不能保证 paper 等于 live(live 还有真实滑点、真实拒单),但它能保证 backtest 等于 paper——也就是说,「策略代码、特征代码、信号代码」在两个环境里的行为完全一致。这是 four-env 架构能成立的最低保证。

八点五、四套环境的依赖注入入口

把前面所有内容收束成一段代码,让读者看到「同一份策略在四个环境里启动」的入口长什么样。这段代码不是完整工程,是把六层接口、配置、Strategy 类拼起来的最小骨架:

# bootstrap.py
import yaml
from pathlib import Path
from core.strategy import Strategy


def load_strategy(env: str, strategy_yaml: Path) -> Strategy:
    cfg = yaml.safe_load(strategy_yaml.read_text())
    env_cfg = yaml.safe_load(Path(f"envs/{env}/env.yaml").read_text())

    if env == "research":
        from research.pit_dal import PITDataSource
        from research.runners import NotebookOrderRouter, NoopRiskManager
        data = PITDataSource(env_cfg["dsn"])
        router = NotebookOrderRouter()
        risk = NoopRiskManager()
    elif env == "backtest":
        from backtest.adapters import (
            BacktestDataSource, BacktestOrderRouter, BacktestRiskManager,
        )
        data = BacktestDataSource(store=load_snapshot(env_cfg["snapshot"]))
        router = BacktestOrderRouter(slippage_bps=cfg["risk"]["slippage_bps"])
        risk = BacktestRiskManager(max_gross=cfg["portfolio"]["max_gross"])
    elif env == "paper":
        from paper.adapters import (
            LiveMarketDataSource, ShadowOrderRouter, ProductionRiskManager,
        )
        data = LiveMarketDataSource(env_cfg["md_url"])
        router = ShadowOrderRouter(env_cfg["shadow_url"])
        risk = ProductionRiskManager.from_yaml(cfg["risk"])
    elif env == "live":
        from live.adapters import (
            LiveMarketDataSource, BrokerOrderRouter, ProductionRiskManager,
        )
        data = LiveMarketDataSource(env_cfg["md_url"])
        router = BrokerOrderRouter(env_cfg["broker_dsn"])
        risk = ProductionRiskManager.from_yaml(cfg["risk"])
    else:
        raise ValueError(f"unknown env: {env}")

    feat = load_feature_pipeline(cfg["feature_pipeline"])
    signal = load_signal(cfg["signal"])
    portfolio = load_portfolio(cfg["portfolio"])
    return Strategy(
        symbols=cfg["symbols"],
        data=data, feat=feat, signal=signal,
        portfolio=portfolio, router=router, risk=risk,
    )

这段 bootstrap.py 是整套架构的胶水。它的形状决定了四套环境之间的接缝在哪。if env == "..." 看起来不优雅,但比起任何动态注册框架,它的好处是「每一个环境分支都能被 grep 出来、被 review、被静态分析」。架构层的可读性,长期来看比设计上的优雅更重要。

注意 featsignalportfolio 三层在四个环境里都共用同一份实现——这正是六层抽象的目的:把环境相关的部分(data、router、risk)做成可替换适配器,把环境无关的部分(特征、信号、组合)固定不变。一个团队真正花时间研究的、应该被复用的,就是后面这三层。


九、把全文压成五个断言

断言一:四套环境对应四类不同的过滤器;任何「为了简化」合并环境的做法,都是把对应过滤器的发现能力让出去。研究、回测、模拟、实盘各自必须存在,各自不能替代。

断言二:四套环境共用一份策略代码的前提是六层接口(DataSource、FeaturePipeline、SignalGenerator、PortfolioConstructor、OrderRouter、RiskManager)。Strategy 只依赖接口、不依赖实现;环境差异收敛到适配器。

断言三:研究环境的核心工程不是 notebook 怎么写,而是 PIT 数据访问层和 MLflow 类的实验管理。「在 notebook 里看到漂亮的 PnL」如果不能被 PIT 与版本固化锚住,下游一定会复现不出来。

断言四:模拟交易必须走完整的生产代码路径,否则就退化成「带实时行情的回测」,捕捉不到实盘第一周才会出现的接缝问题。Shadow trading 是 paper 的最强形态,与实盘并行运行 + 每日 diff。

断言五:实盘环境的工程重点不是策略,而是订单状态机、对账、事前风控、灰度发布。这四件事任何一件没做扎实,策略再好也会被生产路径上的事故抹平。

把这五条贴在团队墙上,比记住任何一个具体框架都更长期有用。


十、参考文献

规范与标准

  1. FIX Trading Community. FIX Protocol Specification, Version 4.4, 2003. https://www.fixtrading.org/standards/
  2. FIX Trading Community. FIX Protocol Specification, Version 5.0 SP2, 2011.
  3. U.S. SEC. Risk Management Controls for Brokers or Dealers with Market Access, Rule 15c3-5, Release No. 34-63241, 2010.
  4. U.S. SEC. Regulation Systems Compliance and Integrity, Reg SCI, Release No. 34-73639, 2014.
  5. ESMA. MiFID II RTS 6: Organisational Requirements of Investment Firms Engaged in Algorithmic Trading, 2016.
  6. ESMA. Guidelines on the Calibration of Circuit Breakers and Publication of Trading Halts under MiFID II, 2017.
  7. CSRC. 《证券期货经营机构合规管理办法》, 2017.
  8. CSRC. 《证券期货市场程序化交易管理规定》(征求意见稿), 2023.

论文与书

  1. López de Prado M. Advances in Financial Machine Learning. Wiley, 2018.
  2. Chan E P. Machine Trading: Deploying Computer Algorithms to Conquer the Markets. Wiley, 2017.
  3. Jansen S. Machine Learning for Algorithmic Trading. 2nd ed. Packt, 2020.
  4. Cartea Á, Jaimungal S, Penalva J. Algorithmic and High-Frequency Trading. Cambridge University Press, 2015.
  5. Bailey D H, Borwein J M, López de Prado M, Zhu Q J. The probability of backtest overfitting. Journal of Computational Finance, 2017, 20(4): 39-69.
  6. Harris L. Trading and Exchanges: Market Microstructure for Practitioners. Oxford University Press, 2003.
  7. Aldridge I. High-Frequency Trading: A Practical Guide to Algorithmic Strategies and Trading Systems. 2nd ed. Wiley, 2013.

软件与项目文档

  1. MLflow 项目文档. https://mlflow.org/docs/latest/index.html
  2. Ray 项目文档. https://docs.ray.io/
  3. pydantic 项目文档. https://docs.pydantic.dev/
  4. SQLAlchemy 项目文档. https://docs.sqlalchemy.org/
  5. transitions(Python 状态机库)项目文档. https://github.com/pytransitions/transitions
  6. importlinter 项目文档. https://import-linter.readthedocs.io/
  7. PEP 544 — Protocols: Structural subtyping (static duck typing). https://peps.python.org/pep-0544/

系列内引用

  1. 本系列第六篇 《幸存者偏差与 PIT 数据》
  2. 本系列第十九篇 《回测引擎设计》
  3. 本系列第二十篇 《回测中的常见坑》
  4. 本系列第二十一篇 《Walk-Forward 与交叉验证》
  5. 本系列第二十六篇 《HFT 架构》
  6. fintech 系列 《撮合引擎》
  7. fintech 系列 《可靠性与容灾》

导航:上一篇 【量化交易】HFT 架构:内核旁路、FPGA、共置与撮合 | 下一篇 【量化交易】运维与合规:监控、审计、灾备、监管报送


写到这里,从「四套环境的角色」开始,到「跨环境一致性测试」结束,构成一条把策略从 notebook 推进到真实账户的完整工程链路。六层接口、四个环境、八套配置切片,看起来像是过度工程,但每一处都在解决一个能被独立举出反例的实际问题。任何一层缺失,策略上线第一周就会因为那一处被击穿;这就是为什么把这套架构当成默认起点,比把它当成「成熟之后再做」更合算。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。

2026-05-01 · quant

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。


By .