回测引擎事件驱动Python可转债架构设计

自建量化回测引擎V2:从零搭建事件驱动回测框架,替代商业平台


为什么自建回测引擎

聚宽、优矿、米筐等平台很好用,但有三个问题:

  1. 数据限制:免费版不支持5分钟K线、不支持可转债分钟数据
  2. 策略泄露:策略代码运行在云端,你不确定是否被他人看到
  3. 成本:专业版年费数千到数万元

我们的解决方案是自建了一套49个Python文件、845KB代码的回测引擎V2,完全本地运行,支持5分钟K线回测、限价单订单簿模拟、动态流动性滑点。

更重要的是,在开发过程中我们发现了7个致命BUG——这些BUG让所有策略看起来都赚钱,修复后年化收益直接打了3-7折。

引擎架构

┌──────────────────────────────────────────────────────┐
│                  回测引擎V2 架构                       │
├──────────────────────────────────────────────────────┤
│                                                        │
│  BacktestEngineV2 (主编排器)                           │
│  ├── DataLoader          数据加载层                     │
│  │   └── 5分钟K线 / 日线 / 流动性数据                    │
│  │                                                     │
│  ├── TransactionAccount  模拟券商账户                   │
│  │   ├── Order           订单管理                       │
│  │   ├── Position         持仓管理                       │
│  │   ├── FillRecord       逐笔成交记录                   │
│  │   └── TradeRecord      买卖配对记录                   │
│  │                                                     │
│  ├── ExecutionEngine     撮合引擎                       │
│  │   ├── OrderBook        限价单订单簿                   │
│  │   ├── IntraBarSimulator bar内布朗桥插值              │
│  │   └── LiquidityProfile  流动性画像                   │
│  │                                                     │
│  ├── GridStrategyV2      网格策略                       │
│  │   └── GridLayerState   逐层网格状态                   │
│  │                                                     │
│  └── Report              报告生成                       │
│      └── Walk-Forward验证                               │
│                                                        │
└──────────────────────────────────────────────────────┘

核心模块详解

1. 模拟券商账户(account.py)

这是最基础也最重要的模块——模拟真实券商账户的资金、持仓和费用。

class OrderSide(Enum):
    BUY = "buy"
    SELL = "sell"

class OrderType(Enum):
    LIMIT = "limit"            # 限价单
    STOP_LIMIT = "stop_limit"  # 止损限价单
    MARKET = "market"          # 市价单

class OrderStatus(Enum):
    PENDING = "pending"       # 挂单中
    PARTIAL = "partial"       # 部分成交
    FILLED = "filled"         # 全部成交
    CANCELLED = "cancelled"   # 已撤单

@dataclass
class Order:
    id: int
    code: str
    side: OrderSide
    type: OrderType = OrderType.LIMIT
    price: float = 0.0          # 限价价格
    stop_price: float = 0.0     # 止损触发价
    quantity: int = 0           # 总数量
    filled_quantity: int = 0    # 已成交数量
    status: OrderStatus = OrderStatus.PENDING
    fills: List[FillRecord] = field(default_factory=list)
    slippage: float = 0.0       # 实际滑点

@dataclass
class Position:
    code: str
    quantity: int = 0             # 持有张数
    avg_cost: float = 0.0         # 平均成本
    total_buy_amount: float = 0.0 # 总买入金额
    realized_pnl: float = 0.0     # 已实现盈亏
    grid_layer: int = 0           # 当前网格层
    buy_price: float = 0.0        # 买入价
    sell_target: float = 0.0      # 目标卖出价

精确的费用模型

费用模型是回测真实性的关键。可转债的费用结构:

"""费用明细(可转债):
- 佣金: 万0.5 (免五)
- 经手费: 万0.004 (双向)
- 证管费: 万0.002 (双向)
- 印花税: 0 (可转债免)
- 过户费: 0 (可转债免)
"""

对比股票的费用:

"""费用明细(股票):
- 佣金: 万0.5 (最低5元)
- 印花税: 千1 (卖出单边)
- 过户费: 万0.1 (双边)
- 经手费: 万0.487 (双边)
- 证管费: 万0.2 (双边)
"""

最容易遗漏的是”最低5元佣金”。如果你的回测中每笔交易佣金只有0.5元(万0.5×1万),但实际券商最低收5元,那么小单交易的成本被低估了10倍。

2. 撮合引擎(execution.py)

这是引擎的技术核心——模拟真实交易所的限价单撮合逻辑。

流动性画像

不同标的的流动性差异巨大。我们根据日均成交额将标的分为5个流动性等级:

@dataclass
class LiquidityProfile:
    """流动性画像"""
    code: str
    avg_daily_amount: float = 0.0    # 日均成交额
    avg_5min_amount: float = 0.0     # 平均5分钟成交额
    liquidity_tier: int = 3          # 1(高)~5(低)
    slippage_bp: float = 0.5         # 基础滑点(bp)
    max_fill_ratio: float = 0.15     # 单笔最大成交量占比

    @classmethod
    def from_daily_amount(cls, code: str, daily_amount: float):
        if daily_amount >= 1e9:        # ≥ 10亿
            tier, slippage, max_ratio = 1, 0.3, 0.25
        elif daily_amount >= 5e8:      # ≥ 5亿
            tier, slippage, max_ratio = 2, 0.5, 0.20
        elif daily_amount >= 1e8:      # ≥ 1亿
            tier, slippage, max_ratio = 3, 1.0, 0.15
        elif daily_amount >= 3e7:      # ≥ 3000万
            tier, slippage, max_ratio = 4, 2.0, 0.15
        else:                          # < 3000万(低流动性)
            tier, slippage, max_ratio = 5, 5.0, 0.10
        return cls(code=code, avg_daily_amount=daily_amount,
                   liquidity_tier=tier, slippage_bp=slippage,
                   max_fill_ratio=max_ratio)
流动性等级日均成交额基础滑点单笔最大成交量占比
Tier 1≥10亿0.3bp25%
Tier 2≥5亿0.5bp20%
Tier 3≥1亿1.0bp15%
Tier 4≥3000万2.0bp15%
Tier 5<3000万5.0bp10%

关键设计:你的订单不能吃掉整个bar的成交量。如果一个5分钟bar只成交了10万元,你的订单最多只能成交1.5万元(15%)。这防止了回测中出现”一笔交易吃掉整个市场”的不现实情况。

限价单订单簿

class OrderBook:
    """
    限价单订单簿 - 模拟真实交易所撮合:
    - 价格优先,同价格时间优先
    - 支持部分成交
    - 成交量约束
    """

    def place_limit_order(self, order: Order) -> bool:
        """挂限价单到订单簿"""
        book = self._buy_orders if order.side == OrderSide.BUY else self._sell_orders
        if order.price not in book:
            book[order.price] = []
        book[order.price].append((order.id, order.quantity))
        return True

    def match_orders(self, bar_high, bar_low, bar_volume):
        """在当前bar内撮合订单"""
        for price in sorted(self._buy_orders.keys(), reverse=True):
            if price <= bar_high:  # 买入限价 ≤ bar最高价 → 可成交
                self._fill_at_price(price, bar_volume)

        for price in sorted(self._sell_orders.keys()):
            if price >= bar_low:   # 卖出限价 ≥ bar最低价 → 可成交
                self._fill_at_price(price, bar_volume)

3. Bar内模拟器

传统回测用OHLC判断是否触发限价单——如果限价买入价在[low, high]之间,就认为成交了。但这忽略了价格在bar内的路径

我们用布朗桥(Brownian Bridge)插值模拟bar内价格路径:

class IntraBarSimulator:
    """
    Bar内价格路径模拟器
    
    在OHLC之间用布朗桥插值,生成更真实的bar内价格路径。
    这对于网格策略至关重要——因为网格的触发完全取决于
    价格是否精确触及网格线。
    """

    def simulate_path(self, o, h, l, c, n_steps=12):
        """
        在给定的OHLC之间生成n_steps个中间价格点。
        
        使用约束布朗桥:
        - 起点=open, 终点=close
        - 路径必须触及high和low
        - 中间路径随机但有约束
        """
        prices = [o]
        for i in range(1, n_steps):
            t = i / n_steps
            # 布朗桥线性插值 + 随机扰动
            bridge = o + t * (c - o)
            noise = self.rng.gauss(0, (h - l) * 0.15)
            p = bridge + noise
            p = max(l, min(h, p))  # 约束在[low, high]内
            prices.append(p)
        prices.append(c)
        return prices

4. 网格策略引擎

@dataclass
class GridConfig:
    """网格策略配置"""
    codes: List[str]                     # 标的列表
    grid_pct: float = 0.003              # 网格间距 0.3%
    layers: int = 5                      # 网格层数
    qty_per_layer: int = 30              # 每层买入张数
    stop_loss_pct: float = 0.02          # 止损 2%
    total_capital: float = 100000.0      # 总资金
    max_positions_per_code: int = 5      # 单标的最大持仓层数
    order_timeout_bars: int = 6          # 订单超时撤单
    commission_rate: float = 0.00005     # 佣金万0.5
    slippage_bp: float = 0.5             # 基础滑点
    max_fill_ratio: float = 0.15         # 单笔最大成交量占比
    dynamic_rebalance: bool = True       # 动态调整网格间距
    max_drawdown_pct: float = 0.0        # 最大回撤约束

网格策略的核心状态管理——每层网格独立挂单,而不是一次性全部触发:

@dataclass
class GridLayerState:
    """单层网格状态"""
    layer: int            # 层号 (1~N)
    buy_price: float      # 买入触发价
    sell_target: float    # 卖出目标价
    buy_order_id: Optional[int] = None   # 买入挂单ID
    sell_order_id: Optional[int] = None  # 卖出挂单ID
    status: str = "ready"  # ready/buying/bought/selling/sold

5. 回测编排流程

class BacktestEngineV2:
    def run(self) -> dict:
        """执行完整回测"""
        # 1. 获取流动性数据
        liquidity_map = self.loader.get_liquidity_data(
            self.config.codes, lookback_days=60
        )

        # 2. 加载历史5分钟K线
        all_data = {}
        for code in self.config.codes:
            df = self.loader.load_5min_bars(code, self.start_date, self.end_date)
            all_data[code] = df

        # 3. 初始化账户和策略
        self.account = TransactionAccount(
            capital=self.config.total_capital,
            commission_rate=self.config.commission_rate
        )
        self.strategy = GridStrategyV2(self.config)

        # 4. 按时间顺序逐bar回放
        all_timestamps = sorted(set().union(*[set(df.index) for df in all_data.values()]))

        for ts in all_timestamps:
            for code in self.config.codes:
                if ts not in all_data[code].index:
                    continue

                bar = all_data[code].loc[ts]

                # 策略生成订单
                orders = self.strategy.on_bar(code, bar, self.account)

                # 订单进入订单簿
                for order in orders:
                    self.order_book.place_limit_order(order)

                # bar内撮合
                self.order_book.match_orders(
                    bar['high'], bar['low'], bar['volume']
                )

                # 更新账户
                self.account.update_positions(code, bar['close'])

        # 5. 统计和报告
        return self._generate_report()

7个致命BUG:那些让策略看起来很赚钱的错误

这是本文最重要的部分。在开发过程中,我们通过系统化诊断发现了7个BUG,这些BUG叠加后虚增年化收益20-40%

BUG 1: Look-ahead Bias(前瞻偏差)🔴 高危

# ❌ 错误:用T日收盘价同时做信号和成交价
if df['close'][i] > df['close'][i-1]:  # 信号
    buy(code, price=df['close'][i])     # 成交

# ✅ 正确:T日信号 → T+1日开盘成交
if df['close'][i] > df['close'][i-1]:   # T日信号
    buy(code, price=df['open'][i+1])     # T+1开盘成交

影响:修复后年化降30-60%。因为你”看到”了收盘价才决定买入,但实际上收盘时你还没做决定。

BUG 2: 佣金无最低限制 🔴 高危

# ❌ 错误
commission = max(amount * 0.00005, 0)  # 万0.5,无最低

# ✅ 正确
commission = max(amount * 0.00005, 5.0)  # 万0.5,最低5元

影响:可转债一张约100元,买入10张=1000元,佣金应为5元(最低)。但错误代码只收0.05元,成本被低估100倍

BUG 3: 交易成本低估3倍 🟡 中危

# ❌ 错误:单边0.1%
cost = 0.001

# ✅ 正确:双边含滑点约0.3%
cost = 0.003  # 佣金万0.5×2 + 印花税千1 + 滑点0.1%×2

BUG 4: 网格策略过拟合 🔴 高危

优化期(4月): 年化+1,800%, Sharpe 31.68, 回撤 0.00%
验证期(2月): 年化+2,616%, Sharpe 38.60, 回撤 0.00%

问题:5分钟K线 + 布朗桥插值 = 过度拟合微观结构
0.00%回撤在真实交易中不可能出现

BUG 5: 幸存者偏差 🟡 中危

退市/到期的可转债数据不完整,但被保留在回测池中。已退市的转债通常经历了大跌,排除它们会高估策略收益。

BUG 6: 年化计算错误 🟢 低危

# ❌ 错误:per-trade compounding
daily_return = 0.001
annual = (1 + daily_return) ** 250 - 1  # 假设每天交易

# ✅ 正确:资金曲线法
equity_curve = [100000]
for trade in trades:
    equity_curve.append(equity_curve[-1] * (1 + trade.return_))
total_return = equity_curve[-1] / equity_curve[0] - 1
annual = (1 + total_return) ** (252 / n_days) - 1

BUG 7: 不复权数据 🟡 中危

使用不复权数据做回测,除权除息日的跳空缺口会产生虚假信号。例如一只股票10送10,价格从20元跳到10元,回测会误判为”暴跌50%“。

修复后的真实回测结果

修复全部BUG后,重新回测的对比:

策略BUG修复前BUG修复后衰减
网格激进型年化+262%年化+80%(估)-70%
网格均衡型年化+194%年化+50%(估)-74%
双低轮动年化+25%年化+15%-40%
低价轮动年化+20%年化+12%-40%

修复后的最佳策略——低成交额+低价复合因子:

年份年度收益月胜率
2020+26.55%73%
2021+55.31%82%
2022+8.51%64%
2023+1.80%70%
2024+26.01%55%
2025+76.16%100%
6年平均+32.4%74%

Walk-Forward验证

防止过拟合的终极武器——用优化期的参数在验证期测试:

策略优化期年化验证期年化衰减比结论
网格激进型+1,800%+2,616%1.45✅ 通过
网格均衡型+1,520%+1,924%1.27✅ 通过
网格稳健型+664%+840%1.26✅ 通过

衰减比>1表示验证期优于优化期(这在网格策略中是正常的,因为波动越大网格越赚钱)。但如果衰减比<0.5,说明策略严重过拟合。

Backtest Guardrails:11项强制检查

我们最终形成了一套11项回测检查清单,每次回测必须通过:

检查项编号说明
幸存者偏差B1排除退市/停牌股票
交易日历B2对齐实际交易日
数据排除B3排除新股/ST/科创板
T+1成交B4信号close → 成交open_next
交易成本B5含佣金+印花税+滑点
退市处理B6缺exit_price → 亏损80%退出
年化计算B7资金曲线cumprod法
随机基线B830次随机选中位数对比
参数鲁棒B9参数扰动测试
逐年表现B10逐年收益不能只靠1-2年
样本外验证B11Walk-Forward验证
复权方式B12明确前复权/后复权/不复权

与商业平台对比

维度聚宽免费版自建引擎V2
数据频率日线5分钟/日线/自定义
可转债数据部分支持988只全量支持
限价单模拟简单OHLC判断订单簿+成交量约束
滑点模型固定滑点5级流动性动态滑点
费用模型基础逐项精细(佣金/经手费/证管费/印花税)
Walk-Forward不支持内置支持
随机基线不支持30次自动对比
数据隐私云端运行100%本地
成本免费(有限制)0元(全功能)

总结

自建回测引擎的最大收获不是省了平台费,而是通过理解每个细节,发现了7个让自己被骗的BUG

如果你的回测显示年化+262%、Sharpe 25、回撤0.2%——不要兴奋,先检查是否有前瞻偏差。真实的Alpha来之不易,那些看起来太好的结果,几乎都是BUG。

回测引擎的代码量分布

  • 账户和费用模型:4,500行
  • 撮合和执行引擎:6,600行
  • 网格策略:4,100行
  • 数据加载器:2,800行
  • 回测编排器:4,200行
  • 全策略横评框架:4,400行
  • 其他辅助脚本:总计49个文件

推荐的自建路径

  1. 先写最小可用版本——日线 + OHLC判断 + 固定滑点
  2. 加入精确费用模型(最低佣金!)
  3. 加入成交量约束和流动性分级
  4. 升级到分钟K线 + 订单簿
  5. 最后加入Walk-Forward验证和随机基线

本文所有代码和回测结果均来自实际运行的系统。BUG诊断报告基于2026年4月的系统审计。

💬 评论