知道得太多
前视偏差(look-ahead)是指回测使用了决策时刻并不可得的信息。它是策略纸面上很漂亮、实盘却亏钱的最常见原因。它很少是一行显眼的代码,而是藏在对齐方式里(用今天收盘的信号在今天收盘成交)、藏在用整个样本算出的统计量里、藏在后来被修订的数据里、藏在事后选出来的指数里。解药是一种习惯:对决策用到的每个数,都问一句它是什么时候才知道的。
import numpy as np
rng = np.random.default_rng(5)
returns = rng.normal(0, 0.01, size=250) # pure noise: no strategy can really make money here
signal = returns.copy() # "signal" = the return of the same day
position = np.sign(signal)
biased = np.prod(1 + position * returns) - 1 # trades on the return it is about to earn
honest_position = np.concatenate([[0.0], position[:-1]])
honest = np.prod(1 + honest_position * returns) - 1
print(round(float(biased), 2), round(float(honest), 2)) # 6.0 0.01: a 600% "return" on noise, against 1%对齐:一天的滞后
对齐偏差的解药是滞后:第 t 天算出的值,要从第 t + 1 天起才能使用。在数组中就是开头补零的平移,out[t] = signal[t - periods]。检验方法很直接。噪声序列没有任何优势,所以任何靠噪声发财的规则都是在作弊。把有偏版本和滞后版本放在同样的数据上比较,差距就是偏差的大小。
# lag_signal: delay a series, with zeros where there is nothing to look back at
import numpy as np
def lag_signal(signal, periods=1):
out = np.zeros(len(signal))
if periods < len(signal):
out[periods:] = signal[:len(signal) - periods]
return out
s = np.array([1.0, -1.0, 1.0, 1.0, -1.0])
print(lag_signal(s)) # [ 0. 1. -1. 1. 1.]
print(lag_signal(s, 2)) # [ 0. 0. 1. -1. 1.]# trailing_mean: the mean of the last `window` values, using only today and the past
import numpy as np
def trailing_mean(values, window):
total = np.cumsum(values)
out = np.full(len(values), np.nan)
out[window - 1:] = (total[window - 1:] - np.concatenate([[0.0], total[:len(values) - window]])) / window
return out
x = np.array([1.0, 2.0, 6.0, 7.0])
print(trailing_mean(x, 2)) # [ nan 1.5 4. 6.5]
print(trailing_mean(x[:3], 2)) # [ nan 1.5 4. ]: adding day 4 changed nothing before it统计量必须随时间增长
第二种泄露更隐蔽。用整个样本的均值和标准差去标准化一个序列,用到的数要到最后才存在:早期的值是被来自未来的信息缩放的。只能用过去:滚动窗口(到今天为止的最近 n 个值)或扩展窗口(到今天为止的所有值)。检验这类特征的办法:只用前 k 天计算,结果必须等于完整计算的前 k 个值。
# Whole-sample z-scores leak the future; expanding ones do not
import numpy as np
x = np.array([1.0, 3.0, 2.0, 8.0, 4.0])
whole = (x - x.mean()) / x.std()
n = np.arange(1, len(x) + 1)
mean = np.cumsum(x) / n
var = np.cumsum(x ** 2) / n - mean ** 2
with np.errstate(divide="ignore", invalid="ignore"):
expanding = (x - mean) / np.sqrt(var)
expanding[0] = np.nan
print(np.round(whole, 3))
print(np.round(expanding, 3))
# Prefix test: computing on x[:3] gives the first three expanding values, but not the first three whole-sample ones
print(np.round((x[:3] - x[:3].mean()) / x[:3].std(), 3))未来藏身的其他地方事后因拆股调整的价格、按期末日而不是披露日标注的财务报表、按今天成分股列表取的指数、在全样本上拟合后又在其中一段上检验的模型、在测试期上调好的超参数。每一种都是同一个错误:在数字被知道之前就用了它。
- 前视偏差就是在信息被知道之前使用它;对齐问题是经典形式:第
t天的信号从第t + 1天才能交易。 - 特征只能由过去构建:用滚动或扩展窗口,绝不用全样本的均值或标准差。
- 前缀检验:只用前
k天的特征或回测,必须等于完整运行的前k个结果;噪声序列不能赚钱。
下一节:交易成本,一个干净的回测首先要付出的东西。
实验:评分内容
- 用
lag_signal延迟信号25 分 - 用
biased_vs_honest度量作弊的程度25 分 - 用
trailing_mean只对过去求平均25 分 - 用
expanding_zscore不偷看地标准化25 分