回测是什么
回测是把交易规则原样套用到过去行情上的做法。事先定好什么时候买、什么时候卖,然后一天一天地推过去几个月或几年,算出照此买卖如今账户会是多少。它是在真金白银入场之前,确认规则是否说得通的一道程序。
之所以值得做,是因为凭感觉立下的规则大多在数字面前站不住。脑子里听着合理的规则,一放到真实行情上,信号要么太慢,要么太频繁,要么在关键区间反向而动。回测让你在付出学费之前就知道这件事。
同时回测并不是预测未来的工具。过去有效并不保证将来有效。回测真正筛掉的是明显糟糕的规则,剩下的也只是当时不糟糕的规则而已。一开始不接受这个区别,就会过度依赖那些数字。
从前要做这件事,得先把行情数据弄到手、整理成表,再自己写循环。现在两样东西就够了:一个提供行情的 API,以及一个在自己电脑上写代码并运行的 AI 代理。
先取数据 — 用 K 线接口拿日线 OHLC
先说清一个词。K 线把一天(或一分钟)的价格波动压缩成四个数字:开盘价、最高价、最低价、收盘价,取首字母叫 OHLC。一根 K 线代表一天就是日线,一串日线正是回测所需的最少原料。
Toss 证券 Open API 通过 K 线接口提供这份数据。有上一篇里拿到的访问令牌就够,不需要账户请求头,因为行情是对所有用户一视同仁的客观数据。
实际使用时会卡住的地方有两处。第一,单次调用最多返回 200 根。一年的日线约 250 根,一次取不完,要拿着响应里附带的游标往更早的方向续接。第二,必须取复权价。有过分红或拆股的标的若用原始价格,拆股当天价格会腰斩,回测会把那天误当成暴跌。
取回的 K 线按日期升序排好,重复日期合并成一条。分页续接时边界上常会重复同一天,放着不管这天就被算两次,收益率会被悄悄抬高。
TOKEN=eyJ...
curl -s 'https://openapi.tossinvest.com/api/v1/candles?symbol=KORU&interval=1d&count=200&adjusted=true' \
-H "Authorization: Bearer $TOKEN"
# 响应(节选)
# {"result":{
# "candles":[{"timestamp":"2026-07-27T00:00:00",
# "openPrice":18.02,"highPrice":18.9,
# "lowPrice":17.71,"closePrice":18.45,"volume":...}],
# "nextBefore":"..."}}import os, requests
BASE = "https://openapi.tossinvest.com/api/v1/candles"
H = {"Authorization": f"Bearer {os.environ['TOSS_TOKEN']}"}
def daily_ohlc(symbol, need=400):
rows, before = {}, None
while len(rows) < need:
p = {"symbol": symbol, "interval": "1d",
"count": 200, "adjusted": "true"}
if before:
p["before"] = before
r = requests.get(BASE, params=p, headers=H, timeout=10).json()["result"]
if not r.get("candles"):
break
for c in r["candles"]: # 用日期作键去重
d = c["timestamp"][:10]
rows[d] = {"d": d, "o": float(c["openPrice"]),
"h": float(c["highPrice"]), "l": float(c["lowPrice"]),
"c": float(c["closePrice"])}
before = r.get("nextBefore")
if not before:
break
return [rows[d] for d in sorted(rows)] # 由旧到新把规则交给代理时必须钉死的四件事
这才是本文的核心。把规则一讲,脚本几分钟就出来了。问题在于一句话的规则里总留着空白,而代理不会就此反问。它会用看起来最像样的值悄悄填上,然后给你一张非常像样的收益表。
空白通常有四处。把这四项和规则一起写下来,结果的摆动幅度会大幅收窄。
第一是判定时点。一天当中什么时候检查条件?按收盘价判定,和盘中价格一碰到就触发,是两套完全不同的策略。尤其买入信号若按盘中判定,那些收盘时早已站不住的假突破也会被全部买进。
第二是成交价格。条件满足后,按什么价格算作买卖成交?当天收盘价、条件线本身,还是次日开盘价?后面会看到,这一行对结果的影响最大。
第三是交易成本。手续费、税费以及买卖价差造成的损耗,按往返百分之几计入。若设为 0,频繁交易的策略会比实际好看得多。
第四是数据区间。用哪一段时间?仅仅挪动起始日,排名反转的情况很常见。所以只在一个区间里胜出,还不能算结论。
| 项目 | 不指定会怎样 | 这样写清楚 |
|---|---|---|
| 判定时点 | 盘中信号全被计入,混进现实中根本买不到的交易 | 买入条件仅按收盘价判定,忽略盘中瞬时突破 |
| 成交价格 | 被填成最有利的价格,收益率被抬高 | 平仓按条件线价格成交;跳空时按当天开盘价成交 |
| 交易成本 | 按 0 计算,频繁交易的策略被高估 | 每笔交易扣除单边 0.05%、往返 0.1% |
| 数据区间 | 挑到最好看的区间,结论被推翻 | 最近 1 年、3 年、全区间分别输出 |
请用这份 K 线数据写一个回测以下规则的 Python 脚本。
[规则]
- 区间开始时以满仓持有状态起步
- 持有期间自最高点回撤 20% 即全部卖出(最高点按盘中最高价更新)
- 空仓期间收盘价突破此前 20 个交易日新高即全部买入
- 持有期间 RSI(14, Wilder) 超过 80 即全部卖出
[假设 — 必须照此执行]
- 买入与 RSI 按收盘价判定,移动止盈按盘中触碰判定
- 移动止盈成交价 = 最高点 x 0.8;开盘跳空更低时按开盘价成交
- 每笔交易扣除往返 0.1% 交易成本
- 区间分别输出最近 1 年 / 3 年 / 全区间
[输出]
- 成交明细(日期、方向、成交价、原因)
- 各区间收益率、最大回撤、与单纯持有的对比同一套规则为何分出 +15% 与 +95%
光靠说不够直观,于是我实际跑了一遍。标的是 KORU,一只以三倍跟踪韩国市场的美国上市杠杆 ETF。规则就是上面那套:自最高点回撤 20% 全部卖出、收盘突破 20 日新高全部买入、RSI 超过 80 时过热止盈。区间为 2026 年 2 月 26 日至 7 月 22 日,约五个月。
同样的规则、同样的 K 线,跑了两遍。改动只有一处:平仓成交假设。第一遍按条件满足当天的收盘价成交,第二遍按价格触碰条件线那一刻的价格成交。
收盘成交为 15%,触碰成交为 95%。同期若只是持有则是负 33%。规则一个字都没改,结果却拉开了八十个百分点。
差距只来自一天。六月初见顶之后,这只基金几天之内腰斩。若按触碰条件线成交,是在 49 美元附近离场;若等到当天收盘才卖,则是 30 美元离场。因为是三倍产品,单日跌幅巨大,于是这一项假设支配了整个结果。
哪一边正确,取决于自己的系统实际如何运作。把条件单挂在券商服务器上,价格触及监视线的瞬间就会下单,接近触碰成交;反之,每天看一次收盘价由人判断、次日再下单,则比收盘成交还要吃亏。所以这不是口味问题,而是要照搬自己实际操作方式的一个取值。
而且无论取哪一边,真实成交都更差。急跌之中未必能按想要的价格全部卖出,市价单还会随着价差扩大而进一步滑点。这正是不能把回测数字直接当作预期收益来读的原因。
# A. 收盘成交 - 按收盘价确认条件,并按该收盘价计为卖出
if close[i] <= high * 0.8:
fill = close[i]
# B. 触碰成交 - 盘中最低价触及条件线,即按该线计为卖出
level = high * 0.8
if low[i] <= level:
fill = min(level, open[i]) # 开盘跳空更低则按开盘价成交
# 同一规则 · 同一数据 · 2026-02-26 ~ 07-22
# A -> +15% B -> +95% 单纯持有 -> -33%在 KORU 上实际验证过的变体
假设固定下来之后,真正的实验才开始:在同一骨架上一次只改一处。在三倍杠杆 ETF 上,这些变体大致分出了如下结果。
出现得最多的想法是让再入场更容易,因为等 20 日新高显得太慢。于是把等待窗口缩到 10 天,把门槛打折让它在略低于新高时就买,又换成 RSI 30 或均线交叉这类反弹信号。这些方向各异的尝试,全都不如原版。
反倒只有两处改动带来改善:把等待窗口拉长到 30 天,以及在 RSI 超过 80 的过热区间不等移动止盈先行止盈。也就是说,这套策略的力量不来自聪明的入场信号,而来自等到新高被确认为止的耐心。
我也试过把仓位拆成两份、分别设置不同止盈比例的所谓口袋分割。一半按 15%、另一半按 20% 卖出,看起来能缓和回撤,实际并没有。同一只标的,两份仓位一起承受同一场暴跌,只是离场日期错开几天,最大回撤几乎没变;反倒因为混入了始终更差的那组参数,收益被削掉一块。拆分相关性为 1 的资产并不是分散。
把在别的市场行得通的逻辑搬过来同样失败。分钟级加密交易里用的多指标 z 分数集成,一旦铺到日线 ETF 上就产生了数千笔交易。成本设为 0 时看着像样,单边算 0.05% 就崩了。问题不在逻辑质量,而在时间尺度与成本结构不匹配。
还有一点。在两倍产品上表现最好的 200 日均线过滤,放到三倍上反而有害。波动更大,于是在均线附近反复买卖,只累积了亏损。即便是同一系列的产品,倍数不同,合适的过滤器也不同。
| 改动 | 意图 | 结果 |
|---|---|---|
| 再入场窗口 20 天 → 10 天 | 更快再入场 | 大幅落后 |
| 入场门槛打 10% 折扣 | 在新高之前先买 | 单调恶化 |
| 改用反弹信号再入场(10 种) | 抄底 | 全部落后 |
| 再入场窗口 20 天 → 30 天 | 确认更久 | 改善 |
| 加入 RSI 80 过热止盈 | 在高点附近先行离场 | 改善 |
| 止盈比例分割(15+20、20+25) | 缓和回撤 | 三个区间全部落后 |
| 移植分钟级 z 分数集成 | 复用其他市场的逻辑 | 计入成本后崩溃 |
| 200 日均线过滤 | 跌破趋势即离场 | 两倍最优,三倍不适用 |
回测悄悄撒谎的五个地方
结果好看时,这几处是最该先怀疑的。五项都不会报错,只会给出一个漂亮的数字,所以必须靠眼睛去找。
第一是前面看到的成交假设。若按在条件线上精确卖出来计算,暴跌行情中的表现会远好于现实。加上一点滑点重跑一遍,看看结论是否还站得住。
第二是未来信息渗入。如果某个需要知道当天收盘价才能算出的值,被用来在当天盘中交易,那么现实中根本不可能的交易就进入了业绩。指标计算里包含了当前这根 K 线,是最常见的出事点。
第三是成本为 0。先看交易次数,乘上往返成本,算一算扣掉之后还剩不剩。不剩的话,这个策略就不必再回测了。
第四是过拟合。一点点调参去找最好成绩,最后会落到只有那个取值才好的地方。前面采用的 RSI 80,也只在 78 到 82 这段狭窄区间里表现良好。窄峰大多是偶然。要选那种上下摇动之后仍然平缓保持的取值。
第五是数据与可复现性。不用复权价,除权日和拆股会被当成暴跌;只用当前在市的标的验证,已消失的标的被剔除,结果会变好看。而把回测脚本放在临时目录里,最后只剩结论、无法复现。我自己就踩过这个坑:几个月前跑过的脚本没了,这次只好从头重写。请把规则、假设和脚本连同结果一起放进代码仓库。
把验证过的规则搬到真实账户时
通过回测并不等于可以立刻上实盘。填补两者之间落差是有顺序的。
首先,让回测假定的判定时点与实际系统的判定时点对齐。如果是以收盘确认为前提做的验证,实盘下单也必须只在收盘前那段时间发出。这一点不对齐,就会不断被盘中假突破触发,变成与回测不同的另一套策略。
接着,把需要计算的判定与单纯的监视分开。像移动止盈那样基准线每天变动,或像 RSI 那样需要计算的条件,交给自己的脚本;单纯的价格触达监视交给券商条件单,这样自己的电脑关着,券商服务器也在盯。应对突发跳空下跌的安全网也挂在这里。
而实盘下单的闸门,就保持上一篇里做好的样子:默认演练模式,实盘下单同时要求执行标志与标的确认,超过金额上限即拒绝,用幂等键防止重复。回测越好看,越会想把这些闸门松开;而越好看的回测,需要被验证的假设越多。
最后是规模。即便是验证过的规则,一开始也要用输得起的金额,杠杆产品更是如此。自动交易在规则错了的时候,会又快又忠实地重复那个错误。本文数字来自特定标的与特定区间,不构成投资建议。判断与责任均在本人。
