知りすぎていること
先読みバイアス(ルックアヘッド)とは、バックテストが判断時点では入手できなかった情報を使ってしまうことです。机上では素晴らしい戦略が実運用で損をする、最も一般的な理由です。明白な1行であることはまれで、位置合わせ(今日の終値のシグナルを同じ終値で売買する)、標本全体で計算した統計量、後から改訂されたデータ、後知恵で選んだ指数などに隠れています。対策は習慣で、判断に使うあらゆる数について、それがいつ分かったかを問うことです。
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%位置合わせ: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統計量は時間とともに伸びるべき
2つ目の漏れは目立ちません。系列を標本全体の平均と標準偏差で標準化すると、最後にならないと存在しない数を使うことになり、初期の値が未来の情報でスケールされます。過去だけを使い、移動窓(今日までの直近 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点