第 14 课 回测究竟在模拟什么¶
回测把一条研究规则放回历史时间中,让数据、信号、订单、成交、持仓和账户逐日发生。

图的左侧是交易中的市场,中间是收盘后才能完成的判断,右侧是下一交易日订单。三幅画面的空间间隔代表不可省略的时间间隔。
净值曲线是账本的结果,不是回测的起点¶
小岚拿到一段回测代码,运行后立刻出现一条向上的净值曲线。她知道最终收益,却回答不了周四为什么持有一份资产,买入订单在何时产生,费用在哪一天扣除,也不知道订单若未成交会发生什么。曲线把许多日期压成一个结果,错误也可能被一起压在里面。
回测是让交易规则按历史时间顺序运行,并模拟当时可能发生的数据读取、信号、订单、成交、持仓、费用和账户变化。它不会真的把订单送到市场,却要用公开假设近似市场会怎样回应。每个历史日期都只能使用当时可见的信息,已经过去的日期不能因后来结果而被改写。
账本是逐日保存这段过程的表。最终净值从账本汇总而来,因此任何收益都应该能沿着成交和持仓向前追查。先让六天小账本完全对上,再扩展到十年数据,会比先画大曲线更容易发现错误。
先把研究规则翻译成一天一天的事件¶
沿用第 1 课的虚构资产“纸飞机”。规则规定,收盘价连续两天高于前一天时,收盘后生成买入信号;下一交易日开盘买入一份,再下一个交易日开盘卖出。初始现金为 100 元,每次买卖固定收取 0.01 元教学费用,并假设订单能够按表中开盘价完整成交。
| 日期 | 开盘价 | 收盘价 | 当日收盘后能作出的判断 |
|---|---|---|---|
| 周一 | 10.00 | 10.10 | 历史不足,不能判断连续上涨 |
| 周二 | 10.12 | 10.30 | 只完成一次上涨比较 |
| 周三 | 10.32 | 10.50 | 连续两次上涨,安排周四买入 |
| 周四 | 10.55 | 10.62 | 早上执行买入,收盘时仍持有 |
| 周五 | 10.58 | 10.70 | 早上按预定规则卖出 |
| 下周一 | 10.66 | 10.40 | 账户已经空仓 |
这里的事件是某个时点发生并改变系统状态的事情。完整收盘价到达是一项事件,策略形成信号是一项事件,订单成交又是另一项事件。状态表示某个时点持续保存的信息,例如现金余额、持仓数量和等待中的订单。回测从一天走到下一天,就是用当天事件更新这些状态。
周三收盘以后只生成一张待办单¶
周三开盘时,收盘价 10.50 元还不存在,账户没有买入依据。周三交易结束后,程序得到完整收盘数据,检查出 10.50 高于 10.30,同时 10.30 高于 10.10,于是生成信号。信号接下来被翻译成“周四开盘买入一份”的计划订单。
这个时点现金仍是 100 元,持仓仍是零。计划订单只说明下一个可交易时点准备做什么,没有成交回报就不能提前扣除现金或增加资产。把周三信号直接写成周三持仓,会让账户获得信号形成以前的价格变化。
周四开盘时,回测读取昨天留下的订单,按教学假设用 10.55 元成交一份,并扣除 0.01 元费用。现金变为 100 减 10.55 再减 0.01,也就是 89.44 元,持仓变为一份。为了按规则持有到下一个开盘,系统同时留下“周五开盘卖出一份”的待办订单。
周四收盘价为 10.62 元。若以收盘价给持仓估值,资产市值为 10.62 元,账户总权益为现金 89.44 加持仓市值 10.62,得到 100.06 元。这 0.06 元尚未通过卖出变成现金,叫作未实现盈亏。它反映按照当前估值价格计算的账户变化。
周五开盘以 10.58 元卖出一份,再扣 0.01 元费用。现金增加 10.57 元,从 89.44 变为 100.01 元,持仓回到零。买入价与卖出价带来 0.03 元价差,两次费用合计 0.02 元,最终净盈利 0.01 元。卖出完成以后,盈亏已经通过交易进入现金,叫作已实现盈亏。
六日账本把每项变化留在自己的列里¶
| 日期与时点 | 收盘后信号 | 开盘执行 | 日末现金 | 日末持仓 | 日末估值价 | 日末总权益 |
|---|---|---|---|---|---|---|
| 周一日末 | 无 | 无 | 100.00 | 0 | 10.10 | 100.00 |
| 周二日末 | 无 | 无 | 100.00 | 0 | 10.30 | 100.00 |
| 周三日末 | 买入信号 | 无 | 100.00 | 0 | 10.50 | 100.00 |
| 周四日末 | 持仓中不重复下单 | 买入一份,费用 0.01 | 89.44 | 1 | 10.62 | 100.06 |
| 周五日末 | 无 | 卖出一份,费用 0.01 | 100.01 | 0 | 10.70 | 100.01 |
| 下周一日末 | 无 | 无 | 100.01 | 0 | 10.40 | 100.01 |
表中收盘后信号与开盘执行属于一天的不同阶段。周三信号出现在行末,周四订单出现在下一行开头。周四日末总权益使用收盘价估值,周五卖出却使用周五开盘成交价,因此周四账面的 100.06 元不会自动成为最终现金。估值价、成交价和信号价各有职责,混成一列会让盈亏无法解释。

图中每条箭头都表示一次有时间顺序的传递。收盘数据形成信号,信号生成下一时点订单,成交回报改变现金与持仓,估值再把账户状态换算成总权益。
回测的一天应有固定处理顺序¶
下图把一天拆成八个连续动作。请先沿箭头读一遍,再检查信号是否留在收盘以后,订单是否等到下一个可执行时点。
flowchart TB
accTitle: 日线回测的一天
accDescr: 每个交易日开始先读取前一日留下的订单,在可执行时点模拟成交并更新现金和持仓,随后记录期间盈亏,收盘数据完整后才计算下一日信号并保存账本
start["新交易日开始"] --> old["读取昨日留下的计划订单"]
old --> execute["按当日可执行价格模拟成交"]
execute --> update["记录费用、现金、持仓与未成交"]
update --> value["按照规定价格为账户估值"]
value --> close["当日收盘数据完整"]
close --> signal["生成下一可交易时点的计划"]
signal --> save["保存当日账本与系统状态"]
classDef action fill:#eef3ff,stroke:#3157d5,stroke-width:2px,color:#172b65
classDef finish fill:#e9f8f1,stroke:#15805d,stroke-width:2px,color:#103d30
class start,old,execute,update,value,close,signal action
class save finish
处理顺序会随策略频率与订单类型改变。分钟策略可能在每根分钟线结束后重新计算,限价单可能跨越多个事件继续等待,多资产组合还要在同一时点分配有限现金。顺序没有唯一模板,同一项研究却必须前后一致,并能解释信息何时到达、订单何时有效、账户何时更新。
把信号、成交和估值挤进同一行向量计算时,时间错误很难被肉眼发现。第一次实现策略,先写事件顺序和小账本,再考虑用 pandas 加速重复计算。
订单状态决定持仓能不能更新¶
订单提交以后可能等待、部分成交、全部成交、撤销或被市场拒绝。回测若假设下一期开盘全部成交,应把它写成执行模型的一部分。执行模型是回测用来近似订单如何得到价格与数量的一组规则。
一条成交记录至少需要订单编号、资产、买卖方向、计划数量、实际数量、成交价格、费用和时间。订单只完成 60% 时,账本只能增加已经成交的 60%,剩余数量继续等待还是当天撤销,要由规则明确决定。把目标持仓直接当作实际持仓,会抹去市场容量和未成交风险。
第 9 课的订单簿已经说明,最低卖价附近可能没有足够数量。日线回测无法还原精细队列,可以先使用保守的成交价格、单日成交量上限和未成交规则。研究频率越高、订单相对市场越大,简单的“全部按开盘价成交”越需要受到压力测试。
成本从显性费用扩展到执行摩擦¶
显性费用包括佣金、税费和交易场所收费,它们通常能从正式费率表查到。买卖价差是立即买入时支付卖方报价、立即卖出时接受买方报价形成的距离。市场冲击表示自己的订单消耗可用数量后,把后续成交推向更不利价位。三类成本来源不同,应在报告中分别说明。
最小教学模型可以先使用与交易金额成比例的单边成本率。持仓从 0 变为 1 时发生买入换手,从 1 变为 0 时发生卖出换手,持仓保持不变时不应重复扣买卖费用。单资产教学可以用相邻持仓差的绝对值描述换手。
\(w_{t-1}\) 是交易前持仓比例,\(w_t\) 是交易后目标持仓比例。若单边成本率为 \(c\),简化成本率就是换手乘以 \(c\)。组合研究要对多项资产的权重变化汇总,并说明是否把买卖两边相加后除以二,因为不同资料的换手定义可能不同。
成本不适合在全部回测结束后统一减去一个常数。交易更频繁的日期费用更多,大订单可能获得更差价格,无法成交还会改变后来持仓。先用简单而保守的成本筛查脆弱策略,再逐步增加最低佣金、方向性税费、价差和容量模型。
两条账本关系每天都应成立¶
第一条关系说明账户在某个时点拥有什么。总权益等于现金加上所有持仓按规定估值价格计算的市值。若费用已经从现金扣除,就不能在总权益中再次扣除。买入只是把一部分现金换成资产,同时支付成本,不会在成交瞬间凭空创造利润。
\(q_{j,t}\) 是第 \(j\) 项资产的持仓数量,\(P_{j,t}\) 是当前估值价格。第二条关系说明相邻两日权益变化必须能被价格变化、交易费用、现金收入和外部资金流解释。出现无法解释的差额时,通常有成交漏记、费用重复、数量方向错误或价格口径混用。
每个交易日都检查这两条关系,比最后只比较净值终点更有效。错误第一次发生的日期往往仍然很小,继续滚动几千日以后会被复利放大,也会与许多后续交易纠缠。
先用循环复现手算账本¶
下面的程序输入六天开盘价与收盘价,任务是严格按照本课事件顺序执行一次买入和卖出。运行前可以先预测三个关键结果。周三日末持仓仍为 0,周四日末现金为 89.44 元,周五卖出后现金为 100.01 元。
prices = [
{"date": "周一", "open": 10.00, "close": 10.10},
{"date": "周二", "open": 10.12, "close": 10.30},
{"date": "周三", "open": 10.32, "close": 10.50},
{"date": "周四", "open": 10.55, "close": 10.62},
{"date": "周五", "open": 10.58, "close": 10.70},
{"date": "下周一", "open": 10.66, "close": 10.40},
]
cash = 100.00
shares = 0
pending_order = None
fee = 0.01
ledger = []
for i, row in enumerate(prices):
executed = "无"
if pending_order == "buy":
cash = cash - row["open"] - fee
shares = 1
pending_order = "sell"
executed = "买入一份"
elif pending_order == "sell":
cash = cash + row["open"] - fee
shares = 0
pending_order = None
executed = "卖出一份"
signal = False
if i >= 2 and shares == 0 and pending_order is None:
up_today = row["close"] > prices[i - 1]["close"]
up_yesterday = prices[i - 1]["close"] > prices[i - 2]["close"]
signal = up_today and up_yesterday
if signal:
pending_order = "buy"
equity = cash + shares * row["close"]
ledger.append([row["date"], signal, executed, cash, shares, equity])
for record in ledger:
print(record)
程序开始时保存现金、持仓、待执行订单和费用。循环进入新日期后,先处理昨天留下的订单;买入会减少现金并把持仓改为一份,同时留下下一日卖出计划。订单处理结束后,程序才使用当日完整收盘价判断是否出现新信号。最后用现金加收盘持仓市值计算权益,并把当天结果加入账本。
i >= 2 保证至少已有三个收盘价,两个上涨比较才有足够历史。shares == 0 和 pending_order is None 防止持仓或已有计划期间重复买入。这个程序仍然采用全部成交与固定费用,目的只是在答案已知的小表上复现时间和现金变化。
输出每一行的顺序依次是日期、信号、开盘执行、现金、持仓和总权益。若周三行已经出现持仓一份,说明订单被提前执行;若周五现金不是 100.01,应逐步检查买入支出、卖出收入和两次费用。先核对关键行,再考虑把列表整理成 pandas 表格。
向量化写法为什么常出现 shift¶
pandas 能让整列数据同时计算,适合大量日期。向量化表示把相同操作应用到一整列,而不是显式写出每日循环。它速度快、代码短,却会把事件顺序压在列之间的相对位置中。
若某列 signal 在周三收盘后形成,代表它能够决定周四的目标持仓,就需要把信号向后移动到下一行。signal.shift(1) 所做的事情,是让周三值出现在周四位置。移动一行来自本例“下一交易日执行”的规则,并非所有策略都固定移动一行。
阅读这行以前,应先在纸上写出信号列与持仓列的日期。分钟数据、收盘竞价订单或跨市场时区可能需要不同移动方式。看见 shift 就机械照抄,仍然可能把时间移错。
三个答案已知的小测试¶
常数价格测试把所有价格设为相同值。没有成本时,策略不应凭空获得价格收益;发生交易时,净结果还应因费用下降。禁止信号测试把所有信号设为 False,持仓、换手和交易费用应保持为零。单一交易测试使用本课六天价格,最终现金必须与 100.01 元手算结果一致。
这类使用小输入、已知答案检查程序的做法叫作测试。完整历史含有太多日期和事件,最终数字错了也难以定位。小表能够逐行手算,程序第一次偏离的位置清楚可见。以后增加分红、部分成交或多资产时,都应该为新增规则补一项小测试。
给五张时间卡重新排序¶
信号时间机会给出收盘数据、信号、订单、成交和持仓收益五张卡。先故意把成交放到信号前,观察页面怎样指出矛盾;再按数据完整、产生判断、生成订单、市场成交、更新账本的顺序排列。
回测需要同时守住五本账¶
| 账本 | 每天记录什么 | 缺少以后会发生什么 |
|---|---|---|
| 数据账 | 当时可见的字段、时间与版本 | 可能提前使用未来或修订信息 |
| 信号账 | 哪条规则在何时产生判断 | 无法解释订单从哪里来 |
| 订单账 | 准备买卖什么、多少、价格条件和有效期 | 容易把计划当作成交 |
| 成交账 | 实际模拟价格、数量、费用与状态 | 高估可执行性,现金与持仓无法核对 |
| 持仓账 | 每日数量、市值、现金与总权益 | 收益无法沿账户变化还原 |
容易弄错的地方¶
| 账本症状 | 常见原因 | 检查起点 |
|---|---|---|
| 信号日立刻获得此前收益 | 信号与收益所属时间没有分开 | 画出信息完整、订单和持有区间 |
| 现金减少,持仓没有增加 | 成交只记录了资金一侧 | 核对价格乘数量、费用和资产数量 |
| 买入瞬间凭空产生利润 | 成交价与估值价使用混乱 | 明确成交后何时、按什么价格估值 |
| 每日都扣一笔买卖费用 | 成本没有与持仓变化相连 | 根据实际成交或换手计算 |
| 订单全部按理想价格成交 | 执行模型忽略队列、价差和容量 | 增加更差价格、未成交和数量上限测试 |
一分钟自测¶
周三收盘后生成买入信号,程序能否让账户获得周三开盘到周三收盘的上涨?
查看答案
不能。那段上涨在信号完整以前已经发生。程序只能让信号影响之后仍可执行的订单,并按照成交后的持仓记录后续盈亏。本课术语¶
| 词语 | 先这样理解 |
|---|---|
| 回测 | 让规则按历史时间顺序运行并模拟账户变化 |
| 事件 | 某个时点发生并改变系统状态的事情 |
| 状态 | 现金、持仓和待执行订单等持续保存的信息 |
| 账本 | 逐日记录信号、成交、现金、持仓和权益的表 |
| 执行模型 | 近似订单怎样得到成交价格和数量的规则 |
| 未实现盈亏 | 按当前估值计算、尚未通过卖出进入现金的变化 |
| 滑点 | 预期参考价格与模拟或实际成交价格之间的差异 |
| 换手 | 相邻时点持仓变化的程度 |
课后练习¶
1 理解状态变化¶
周三收盘后出现信号但订单尚未成交,此时现金和持仓应怎样记录?
提示
判断出现与账户得到资产是两个事件。答案
现金仍保持原值,持仓仍为零,只在订单账中留下下一时点的买入计划。2 手算账户¶
账户有 1000 元现金,以 10 元买入 50 份并支付 2 元费用。成交后现金、持仓市值和按 10 元估值的总权益是多少?
提示
买入支出为价格乘数量,费用额外从现金扣除。答案
现金为 `1000 - 10×50 - 2 = 498` 元,持仓市值为 500 元,总权益为 998 元。与初始金额的 2 元差异就是费用。3 寻找费用错误¶
策略持仓连续五天不变,程序每天都按完整仓位扣一次买入费。哪里不合理?
提示
买卖费用应由哪类事件触发?答案
持仓不变表示没有新的成交,买卖费用不应每天重复。成本应根据实际订单成交或持仓变化计算。4 动手修改¶
把程序的单次费用从 0.01 元改成 0.05 元,先手算最终现金,再运行核对。
提示
价差仍为 0.03 元,两次费用合计变为 0.10 元。答案
最终现金应为 99.93 元,相对于 100 元亏损 0.07 元。5 开放思考¶
为什么答案已知的六天数据,比直接使用十年行情更适合检查第一版回测器?
评价要点
六天数据能够逐行手算,时间、现金、持仓与费用第一次偏离的位置很容易发现。十年结果会把大量事件汇总,错误可能被后续交易和复利遮住。现在再看周三那行账本,信号只生成周四的交易计划,成交以前现金和持仓都不能提前变化。只要每日事件顺序能画出来,总权益又始终等于现金加持仓市值,这本小账就有了继续扩展的基础。