回测是什么,为什么需要它
看投资类视频时,总是在同一个地方卡住:有人说"现在就是底部"的那一刻。理由听起来合理,屏幕上的图表也很有说服力,但没有办法核实,于是最终只能在相信和不相信之间二选一。
回测开辟了第三条路。把一个交易判断转换成规则,让这条规则原样跑过历史数据,然后数一数它实际上是否赚钱。它不是预测未来的工具,而是核实你此刻听到的论断在过去是否管用的工具。
人们容易以为这需要专业软件和付费数据,其实不用。一个免费的字幕提取工具,加上两个公开提供股价的API,半天就能跑完。下面是实际验证一段视频时所用的完整顺序。
第一步 — 用yt-dlp把视频字幕导出为文本
首先把内容变成文字。与其把17分钟的视频反复倒带观看,不如把字幕下载成文本文件,这样就能用搜索找到每个论断出现在哪里。
yt-dlp是做这件事的免费命令行工具。名字表明它是youtube-dl的后继版本,能从包括YouTube在内的大量网站下载视频和字幕。Mac上在终端运行brew install yt-dlp,有Python环境则用pip install yt-dlp。
安装完成后,跳过视频只取字幕。--skip-download表示不下载视频文件,--write-auto-sub表示在没有人工字幕时也要取YouTube自动生成的字幕。得到的是vtt格式的字幕文件,里面混有时间戳和重复的行,因此需要做一次清理只留下文本。
清理完成后,17分钟的视频会变成一万字左右的文章。从这一刻起就不必再打开视频了。
# 安装(二选一)
brew install yt-dlp # macOS
pip install yt-dlp # Python 环境
# 跳过视频,只取韩语和英语字幕
yt-dlp --skip-download \
--write-auto-sub --write-sub \
--sub-lang "ko,en" --sub-format vtt \
-o "vid.%(ext)s" "<视频地址>"
# 结果: vid.ko.vttimport re
lines, prev = [], None
for line in open("vid.ko.vtt", encoding="utf-8"):
# 丢弃时间戳行和头部
if "-->" in line or line.startswith(("WEBVTT", "Kind:", "Language:")):
continue
text = re.sub(r"<[^>]+>", "", line).strip() # 去掉标签
if text and text != prev: # 去掉连续重复
lines.append(text)
prev = text
open("transcript.txt", "w", encoding="utf-8").write("\n".join(lines))
print(len(lines), "行")第二步 — 把论断翻译成数字规则
这一步就是验证的全部。在字幕里找到核心论断,把人话写成的句子转换成计算机能够判定的条件。
比如"跌了很多之后大幅反弹,所以底部已经确认"这句话,原样是无法验证的。跌多少算跌得多?几天内涨多少算大幅反弹?到底什么时候买?把它改写成"从最近60个交易日的最高收盘价下跌35%以上的状态下,当日收盘价比前一日上涨20%以上,就在当日收盘买入",计算机才能在历史数据中找出所有符合条件的日期。
翻译时有一条必须遵守的原则:条件里只能放买入时刻实际能知道的信息。如果混进"触底之后"这类只有事后才知道的表述,就变成了偷看未来的规则,结果必然好看。这叫做前视偏差,也是初学者做的回测在实盘中不管用的最常见原因。
而且在这一步会筛掉一半。因为有些论断根本无法写成数字条件。"是有人故意把它打下去的"这样的句子,连判定真伪的条件都构造不出来。这不是验证失败,而是发现了它本来就无法验证 — 这个判定本身就是结果。仅仅把可验证的论断和不可验证的论断分开,一段视频的可信度就已经相当清楚了。
第三步 — 用免费公开API获取历史行情
意外的是这一步最简单。不需要购买数据。美股由纳斯达克、韩股由Naver金融通过公开地址直接提供每日行情。无需注册,无需API密钥,无需付费。
纳斯达克在api.nasdaq.com下的historical路径接受股票代码和时间区间,返回包含日期、开盘价、收盘价、成交量的JSON。只是响应中的价格是带美元符号和逗号的字符串,需要一行处理转成数字,而且可查询区间大致到最近十年。
韩股则在Naver金融的行情地址中填入六位股票代码和起止日期即可。三星电子是005930,SK海力士是000660。响应不是标准JSON而是混有单引号的数组形式,无法直接解析,因此要替换引号或用正则表达式提取数值。
两者都是按照人用浏览器访问的前提做的,所以从程序调用时,附上看起来像普通浏览器的请求头(User-Agent)比较安全。短时间内调用过多也会被暂时拦截,因此每个股票间隔约0.5秒获取,并把取到的数据保存成文件重复使用。
import json, urllib.request
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"}
def daily(symbol, asset="stocks", frm="2016-01-01", to="2026-07-31"):
url = (f"https://api.nasdaq.com/api/quote/{symbol}/historical"
f"?assetclass={asset}&fromdate={frm}&todate={to}&limit=99999")
req = urllib.request.Request(url, headers=UA)
rows = json.load(urllib.request.urlopen(req, timeout=30))
rows = rows["data"]["tradesTable"]["rows"]
out = []
for r in rows:
close = float(r["close"].replace("$", "").replace(",", "")) # "$207.12" → 207.12
mm, dd, yy = r["date"].split("/") # "07/30/2026"
out.append({"d": f"{yy}-{mm}-{dd}", "c": close})
out.sort(key=lambda x: x["d"])
return out
bars = daily("MU")
print(len(bars), bars[-1])import re, urllib.request
UA = {"User-Agent": "Mozilla/5.0"}
def daily_kr(code, start="20160101", end="20260731"):
url = ("https://api.finance.naver.com/siseJson.naver"
f"?symbol={code}&requestType=1&startTime={start}&endTime={end}&timeframe=day")
req = urllib.request.Request(url, headers=UA)
text = urllib.request.urlopen(req, timeout=30).read().decode("utf-8")
# 不是标准JSON而是单引号数组,用正则提取
# ['20260730', 开盘, 最高, 最低, 收盘, 成交量, 外资持股率]
rows = re.findall(r"\['(\d{8})',\s*[\d.]+,\s*[\d.]+,\s*[\d.]+,\s*([\d.]+)", text)
out = [{"d": f"{d[:4]}-{d[4:6]}-{d[6:]}", "c": float(c)} for d, c in rows]
out.sort(key=lambda x: x["d"])
return out
print(daily_kr("005930")[-1]) # 三星电子第四步 — 跑规则,但一定要和对照组一起看
数据齐了之后,找出所有符合第二步条件的日期,计算此后一周、一个月、三个月的收益率。到这里都是机械的工作。
真正重要的是接下来。一定要同时计算对照组。如果不管信号只是一直持有同样的股票,同期能赚多少;指数(比如标普500)涨了多少 — 把它们并排放好。漏掉这一步,在上涨的市场里任何规则看起来都不错,因为那是随便哪天买都赚钱的区间。
实际用AI相关股票篮子跑一遍会发现,暴跌后买入信号的三个月收益率平均12.6%,看起来不错。但同样的股票随便哪天买、持有三个月的平均值也是12.6%。也就是说信号创造的部分接近零。如果没算对照组,这条规则看起来会像了不起的发现。
样本数也要一并写上。条件卡得越紧信号越少,而只有三四次的规则,其平均收益率是偶然而不是统计。把条件换成多种组合分别跑(参数扫描),如果看起来好的组合全都只有三四个样本,那不是发现而是过拟合。
import statistics as st
def backtest(bars, dd_thresh=-0.35, pop=0.20, horizon=63):
"""从60日高点跌破dd_thresh后,单日上涨pop以上 → 当日收盘买入"""
signals, baseline = [], []
for i in range(60, len(bars) - horizon):
c, prev = bars[i]["c"], bars[i - 1]["c"]
high60 = max(b["c"] for b in bars[i - 60:i + 1])
fwd = bars[i + horizon]["c"] / c - 1 # horizon日后的收益率
baseline.append(fwd * 100) # 对照组:所有交易日
if c / prev - 1 >= pop and c / high60 - 1 <= dd_thresh:
signals.append(fwd * 100) # 只取信号出现日
return signals, baseline
sig, base = backtest(bars)
print(f"信号 n={len(sig):4d} 平均 {st.mean(sig):6.1f}% 中位数 {st.median(sig):6.1f}%")
print(f"对照组 n={len(base):4d} 平均 {st.mean(base):6.1f}% 中位数 {st.median(base):6.1f}%")
# 信号的平均值若超不过对照组,这条规则就没有实力如何读结果 — 只看平均值会读反
数字出来后人们首先看平均值,而在回测里只看平均值几乎必然导致误读。必须把平均值和中位数并排放。
有一个很清楚的实例。把"从60日高点下跌超过35%的状态下单日反弹20%以上"这个条件跑过十年数据,会抓到24个信号。这24个的三个月后收益率平均是38.2%,看起来很惊人。但中位数是-1.0%,盈利的只有24个中的11个,不到一半。
发生了什么?是几次暴利(其中一次三个月258%)独自把平均值拉了上去。剩下的一半在原地继续下跌。只看平均值就会得出"暴跌后反弹是强力买入信号"的结论;同时看中位数,得到的是完全相反的结论 — 这是一场胜率46%、期望值全押在少数暴利上的赌博。
另一个要当心的是幸存者偏差。用今天表现好的股票组成篮子去跑过去十年,结果当然好看,因为这十年里消失的股票不在名单上。如果篮子里混进了上市才一年的股票,就等于在用那之前根本不存在的股票计算收益率。要处理成每个时点只纳入当时确实存在的股票,如果有因数据不足而剔除的股票,就在结果里一并写明。
常见的卡点
行情API突然返回429或Too Many Requests,说明短时间内调用过多。每个股票间隔0.5秒获取,并且务必把取到的数据保存成文件,重跑时不要再次调用。
也有完全取不到字幕的视频。既没有人工字幕,自动字幕也被关闭,这时需要单独的语音识别环节。另外自动字幕经常把数字和专有名词弄错,所以从字幕中提取的数值不要直接当作事实使用,要回到原始出处再确认一次。
美股代码在公司合并或改名后会消失。验证过去的案例时如果数据返回0条,先确认是不是股票已经不存在了。悄悄剔除这类股票会让结果比实际更好看。
最后要注意不要把时间不够的信号计入平均。如果看的是三个月表现,而信号发生在两周前,那一条还没有结果。硬塞进去会让近期行情污染整个结果集。没有结果的样本要从统计中剔除,并写明剔除了几条。