跳到正文

Arithmion 回测引擎

研究阶段用向量化筛想法,上引擎前用事件驱动验成交。Arithmion 的引擎是事件驱动的:成交、成本、保证金、验证都显式建模。搞清分工能省下好几周的 debug 时间。

第 7 篇 · 共 8 篇 约 10 分钟

向量化回测跑得快,但对成交的处理是悄悄在骗你。事件驱动回测跑得慢,但成交是真实的。这两句话是整篇文章的主线:研究阶段你会(也应该)用向量化的方式广撒网,而 Arithmion 的回测引擎本身是事件驱动的——成交、成本、保证金、部分成交这些向量化装不下的东西,它逐一显式建模。搞清楚哪个阶段该信哪个数字,能省下好几周的 debug 时间。

向量化:快在哪,骗在哪

向量化回测一次性处理整个时间序列。算信号是 DataFrame 操作,算仓位是 DataFrame 操作,算收益还是 DataFrame 操作,几行 pandas 风格的代码搞定:

signal = features.rolling(20).mean()
position = signal.rank(axis=1).apply(lambda x: long_top_decile(x))
returns = prices.pct_change().shift(-1)
pnl = (position * returns).sum(axis=1)
sharpe = pnl.mean() / pnl.std() * np.sqrt(252)

提示词

用 pandas 和 NumPy 写一个简单的向量化回测,计算滚动均值信号、按排名建仓,再算年化 Sharpe。

速度是它存在的理由:1000 个 ticker、10 年日线数据,毫秒级;上面这段 pipeline 整个跑下来不到一秒。研究阶段这点是决定性的——你要测的是 100 个变体,不是 1 个。

但代价藏在一个不起眼的假设里:你按收盘价成交,而且总是全部成交。没有”我 10 点挂了单、10:15 成交、滑点 2bp”这种概念。于是有一整类东西向量化根本建不了模:日内止损(它跑的是日线 bar,日间发生了什么它一无所知);部分成交的真实性(现实里你很少按收盘价满仓拿到);多资产协同(“A 成交了 B 没成交”在向量化里不存在,你永远满仓);订单簿动态(看不到盘口,排队位置、撮合优先、限价单成交概率都免谈);还有延迟(向量化假设信号到成交零延迟,真实延迟是 1–100ms)。

所以向量化的正确用途是那些”成交细节不主导结论”的活儿:因子研究、机器学习的标签和特征生成、组合构建的优化器重放、跨上千个变体的统计汇总、超参数扫描。这些场景要的是迭代速度,向量化给得起。

事件驱动:引擎怎么跑一根 bar

事件驱动回测按时间顺序处理每一个事件:“bar 收盘”触发信号计算,“信号生成”触发下单,“下单”触发成交模拟,“成交回报”更新仓位。概念上的循环长这样:

for event in timeline:
    if event.type == BAR_CLOSE:
        signal = compute_signal(event.data)
        if signal != current_position:
            order = submit_order(signal, current_position)
    elif event.type == FILL:
        position = update_position(event.fill)
        pnl.record(event)
    elif event.type == RISK_CHECK:
        if portfolio.var > limit:
            flatten_all_positions()

提示词

用 Python 写一个事件驱动回测循环,处理收盘、成交和风控事件。

代价也很直接:比向量化慢 10–100 倍,复杂度高得多——部分成交、撮合优先、延迟、订单状态、仓位更新都要处理。换来的是成交的真实性。任何持仓时间不到一天的策略、任何带止损止盈的策略、多资产协同的策略、以及部署前的最终验证和容量估计,都必须走事件驱动——真实容量接近事件驱动容量,不是向量化容量。

Arithmion 的 BacktestEngine 就是这一侧的实现。几个设计决定值得知道,因为它们直接决定你的回测数字含义:

成交时点是显式的。 策略在 bar 收盘时给出信号,成交发生在下一根 bar 的开盘价,再叠加滑点——买单按 next_open × (1 + slippage) 成交,卖单反向。没有”按当根收盘价成交”这种向量化式的宽容。这一个约定就消灭了一大类前视偏差。

部分成交是一等公民。 引擎带成交量上限,吃不完的单子按 partial_fill_policy 处理,默认 carry_forward——残量在后续 bar 里继续尝试成交,最多顺延若干根 bar。想买 10% ADV 的策略在这里会看到自己真实的成交节奏,而不是回测魔法。

离场规则在引擎里,不在事后。 止盈、止损、追踪止损、时间退出(持仓超过 max_bars_held 强制清出僵尸仓位)都由引擎逐 bar 检查。向量化里”假装有止损”和事件驱动里”真的触发止损”,收益分布完全是两回事。

策略接口有两种形态。 逐 bar 策略继承 BaseStrategy,实现 on_bar(bar),返回一个 Signal(BUY/SELL/SHORT/COVER/HOLD)或一个挂单对象;生命周期钩子 on_start、on_end、on_fill、on_regime_change 按需覆写。截面调仓类策略继承 PortfolioStrategy,实现 on_rebalance(view),返回目标权重字典。骨架长这样:

from apexquant.core.strategy import BaseStrategy, Signal

class MyStrategy(BaseStrategy):
    def on_bar(self, bar) -> Signal:
        if self._entry_condition(bar):
            return Signal.BUY
        if self._exit_condition(bar):
            return Signal.SELL
        return Signal.HOLD

提示词

写一个继承 BaseStrategy 的最小策略类,在 on_bar 里根据条件返回 BUY/SELL/HOLD 信号。

引擎负责其余一切:成交模拟、成本计提、仓位账本、指标计算。

成本与约束:引擎里有什么,默认是多少

每次回测都挂着一套可配置的成本与约束模型。默认值如下(config 的 backtest 段):

"backtest": {
    "initial_capital": 100_000,
    "commission": 0.001,      # 每边 0.1%(10 bp)
    "slippage": 0.0005,       # 5 bp,作用在次一开盘的成交价上
    "min_commission": 1.0,    # 每边最低 $1
    "position_size": 0.25,
    "max_positions": 6,
}

提示词

给出 Arithmion 回测引擎 backtest 配置段的默认成本参数,并解释每项含义。

这套默认是刻意保守的——10 bp 佣金加 5 bp 滑点对不少市场偏贵。这是特性:回测成本宁可偏高,让”扣完成本还活着”的策略更可信。做低成本市场或有真实成本数据时,按你的市场改。

保守默认之外,还有几个默认关闭、按需打开的约束开关。保证金:margin_multiplier 默认 1.0,即纯现金账户;调到 2.0 就是 Reg-T 风格的 2:1 杠杆,引擎会按 cash × margin_multiplier 追踪购买力。空头保证金占用可以用 margin_requirement_pct 单独打开——只按名义额的保证金比例占用现金,而不是全额。借券成本:short_borrow_fee_rate 按日利率解释,逐 bar 按真实流逝的日历时间计提到持仓上——日线 bar 恰好每 bar 计一天,日内 bar 按不足一天的比例计。持仓上限:backtest.max_bars 是一等配置,平台默认关闭,打开后由策略的持仓期解析逻辑统一生效。

引擎没有内建市场冲击模型——你自己的单子推动价格这件事,目前通过保守的滑点假设兜底。如果你的规模大到冲击开始要紧,标准的量级估计是经验平方根律:

Impact=σ⋅QV\text{Impact} = \sigma \cdot \sqrt{\frac{Q}{V}}

其中 QQ 是成交量,VV 是日成交量,σ\sigma 是日波动率。成交 1% 的日成交量,价格冲击量级大约是 σ⋅0.1\sigma \cdot 0.1。这条规律来自对真实成交数据的经验测量,对小盘股偏低估、对超大盘股偏高估,但有量级总比没量级好——把它算出来跟你的滑点假设比一比,就知道保守兜底还够不够用。

顺带把成本认知里最常见的两个误区说掉。第一,价差被普遍低估:真实有效 spread 通常比供应商报的宽,因为报价 spread 假设你主动吃单,而有效成本要用成交数据估——Roll measure(以 1984 年提出它的 Richard Roll 命名)用价格变化估有效 spread,估出来通常是报价 spread 的 2–3 倍。第二,成本对年化拖累的换算取决于换手口径:如果年化换手率 100% 指一年交易的名义额等于组合整体一次,2 bp 来回一次的价差只贡献年化 0.02%;按”一年 100 个回合”的口径才是 2%。算拖累之前先把口径钉死。

验证闸门

回测跑完不等于策略成立。平台在回测和晋级之间设了验证闸门,两个验证器都要过。

统计验证器算的是 probabilistic / deflated Sharpe(Bailey & López de Prado, 2014):不看裸 Sharpe,看”真实 Sharpe 超过基准”的概率。基准 S∗S^* 不是零——它是你的试验次数 n_trials 隐含的期望最大值 Sharpe:哪怕全是噪声,测 100 个变体也会碰出一个不错的最大值,S∗S^* 就是这个碰运气水平。观测 Sharpe 要在计入收益偏度、峰度和样本量之后,以高概率(默认门槛 min_dsr 0.95)超过 S∗S^* 才算过。完整公式是

DSR=Φ((SR−SR0)⋅n−11−γ3⋅SR+γ4−14⋅SR2)\text{DSR} = \Phi\left(\frac{(\text{SR} - \text{SR}_0) \cdot \sqrt{n - 1}}{\sqrt{1 - \gamma_3 \cdot \text{SR} + \frac{\gamma_4 - 1}{4} \cdot \text{SR}^2}} \right)

其中 γ3\gamma_3 是偏度,γ4\gamma_4 是峰度,SR0\text{SR}_0 就是试验次数隐含的基准 S∗S^*,Φ\Phi 是标准正态 CDF。注意自相关不是这条公式里的直接一项:如果收益有依赖关系,解读概率之前先做独立的相关性修正——有效样本量或 block bootstrap 都行。

DSR 想表达的事很朴素:100 个变体里挑出来的 Sharpe 1.5,远不如 1 个变体跑出来的 Sharpe 1.5 有说服力。变体越多,惩罚越大。100 个变体挑出的 1.5,在 DSR 约定下可能只剩 0.3——即按这套约定,最佳变体有三成概率是瞎猫碰上死耗子(注意这不是字面上的”70% 后验概率过拟合”)。所以对验证器诚实报告 n_trials 不是道德要求,是让这个数字有意义的前提。

统计验证器同时要求样本外稳健性:训练窗之外的表现要撑得住。平台另有 walk-forward 回测设施,把”训练—滚动—样本外评估”做成标准流程,而不是一次性的 80/20 切分。第二道验证器是 AI 审查器,查的是统计器管不到的东西——策略逻辑与数据使用方式里的结构性问题。

闸门之外,还有两套方法论工具属于”你应该自己跑”的范畴,引擎不替你跑。其一是 purged 交叉验证:时间序列 CV 里,purge 剔除标签区间与测试集标签区间重叠的训练观测,embargo 在特征或标签构造会把信息往后传时、把紧邻测试集的一段训练观测额外拿掉。embargo 的时长取决于你的标签区间和特征依赖,不会自动等于目标持有期——持仓期造成的标签重叠归 purge 和执行逻辑管,别把 embargo 当成普适的持仓期规则。其二是 PBO(回测过拟合概率),用 CSCV 算:把数据切成 NN 块,枚举所有等半划分(共 (NN/2)\binom{N}{N/2} 个切分),每个切分在一半上选出样本内赢家、看它在另一半的相对排名,赢家排名低于样本外中位数的切分占比就是 PBO。目标 PBO < 0.25;0.5 以上,样本内冠军多半是过拟合出来的。

为什么向量化跟事件驱动对不上

经常让人惊讶:同一个策略,向量化下 Sharpe 1.5,事件驱动下 Sharpe 0.8。同样的数据、同样的信号、同样的仓位,Sharpe 跌了 47%。

差距有四个来源,前三个显而易见,第四个经常被忘。成交时点:向量化假设收盘价成交,事件驱动按次一开盘加滑点——你的信号是”收盘买入”,路由要 30 秒,30 秒里价格可能已经动了 5 bp。部分成交:向量化假设全部成交,事件驱动按可用成交量建模——想买 10% ADV 的策略在目标价上可能只成交一半,剩下的成交价更差。止损和风控:向量化没有止损概念,事件驱动有——触发的止损会锁定亏损,一个 -10% 止损的策略的收益分布跟没止损的完全不一样。第四个是市场冲击:向量化不建模你对市场的影响,而一个在向量化下”成交” 30% ADV 的策略,真跑起来会自己推动价格。

这个差距不是 bug,是研究预期和实盘现实之间的真实距离。不去量它,你就不知道实盘 Sharpe 会是多少。

常见的失败模式

在向量化数字上做优化。 调超参数最大化向量化 Sharpe,Sharpe 上去了,切到事件驱动,Sharpe 跌了。因为向量化 Sharpe 被不成立的成交假设污染了,在它上面做优化是在为虚构调参。修法:向量化广撒网,事件驱动确认;部署决策不能只看向量化 Sharpe。

不管成本校准。 引擎的默认成本是一套保守的通用值。你做的市场跟默认口径差得远——小盘股、低价股、非美市场——成本就是错的,成本错则 Sharpe 错、资金分配错。用自己的成交数据估有效 spread(Roll measure 之类),把 commission 和 slippage 改成你的市场的数。

忘了 embargo。 为了跑得快设 embargo=0,5 日 forward return 目标把测试集污染了,Sharpe 是假的。embargo 不是可选项,是时间序列交叉验证的内在要求;时长按你的标签区间和特征依赖去定。

用错了年化。 日线回测报年化 Sharpe,假设 252 个交易日——但策略不是每天都交易,按 252 年化会高估。引擎的指标层在这里做对了一件事:年化系数从收益序列观测到的采样密度推断,而不是硬编码 252。自己在引擎外算指标时,把这个约定带上。

名义上的无幸存者偏差。 数据里标了退市票,但信号只在当前 S&P 500 的票上触发——退市票在数据里,却从来没被交易过,幸存者偏差还在。修法:信号计算跑完整的时间点股票池,不是当前股票池。

一个诚实的坑

如果你只跑过向量化回测,你根本不知道实盘 Sharpe 会是多少。执行惩罚一般是向量化 Sharpe 的 30–60%,有时候更多。几条经验量级:多头股票、低换手,向量化跟事件驱动 Sharpe 通常差 10–20%;多空股票、中换手,通常差 20–40%;高频或高换手策略,通常差 50–70%,有时候更糟;日内策略,向量化基本没意义,只能用事件驱动。

引擎的价值就在于让这个差距可测量:研究数字和事件驱动数字都拿到手,差值预算进预期收益。如果差距大到没法接受,改的是策略设计,不是回测。

放在哪一层

回测引擎是研究和部署之间的闸门。通过验证的策略是 paper trading 的候选;没通过的打回研究。下一篇文章讲从研究到实盘部署的完整生命周期——各个阶段和阶段之间的闸门。

参考文献

  • López de Prado, M. Advances in Financial Machine Learning, 第 11 章(回测的危险)、§11.6 (PBO / Strategy Selection)、第 12 章(交叉验证回测)、§12.4 (CPCV)、以及第 14 章(Backtest Statistics)。Wiley, 2018. ISBN: 978-1-119-48208-6.
  • Chan, E. Quantitative Trading: How to Build Your Own Algorithmic Trading Business, Ch. 3 (Backtesting). Wiley, 2009. ISBN: 978-0-470-28488-9.
  • Almgren, R. & Chriss, N. “Optimal Execution of Portfolio Transactions,” Journal of Risk, 2000.
  • Bailey, D. & López de Prado, M. “The Deflated Sharpe Ratio,” Journal of Portfolio Management, 2014.