Arithmion 回测引擎
研究阶段用向量化筛想法,上引擎前用事件驱动验成交。Arithmion 的引擎是事件驱动的:成交、成本、保证金、验证都显式建模。搞清分工能省下好几周的 debug 时间。
向量化回测跑得快,但对成交的处理是悄悄在骗你。事件驱动回测跑得慢,但成交是真实的。这两句话是整篇文章的主线:研究阶段你会(也应该)用向量化的方式广撒网,而 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 是一等配置,平台默认关闭,打开后由策略的持仓期解析逻辑统一生效。
引擎没有内建市场冲击模型——你自己的单子推动价格这件事,目前通过保守的滑点假设兜底。如果你的规模大到冲击开始要紧,标准的量级估计是经验平方根律:
其中 是成交量, 是日成交量, 是日波动率。成交 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 超过基准”的概率。基准 不是零——它是你的试验次数 n_trials 隐含的期望最大值 Sharpe:哪怕全是噪声,测 100 个变体也会碰出一个不错的最大值, 就是这个碰运气水平。观测 Sharpe 要在计入收益偏度、峰度和样本量之后,以高概率(默认门槛 min_dsr 0.95)超过 才算过。完整公式是
其中 是偏度, 是峰度, 就是试验次数隐含的基准 , 是标准正态 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 算:把数据切成 块,枚举所有等半划分(共 个切分),每个切分在一半上选出样本内赢家、看它在另一半的相对排名,赢家排名低于样本外中位数的切分占比就是 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.