1本ずつ
イベント駆動型バックテスターは実運用のシステムのように動きます。時間をバーごとに進め、各バーでプログラムは過去だけを見て判断し、注文を出し、口座を更新します。ベクトル化版より遅い代わりに、状態(現金・株数)を持ち、注文や約定をモデル化でき、同じ戦略コードを後でライブのデータでも動かせます。要はループで、バーに対する for の中で口座が次の反復へ引き継がれます。
def crossover_signal(history, fast, slow):
if len(history) < slow:
return 0 # not enough history yet
fast_mean = sum(history[-fast:]) / fast
slow_mean = sum(history[-slow:]) / slow
return 1 if fast_mean > slow_mean else 0
prices = [100, 101, 103, 102, 105, 107, 104, 103, 99, 98]
print(crossover_signal(prices[:3], 2, 4)) # 0: only 3 prices
print(crossover_signal(prices[:6], 2, 4)) # 1: the 2-bar mean is above the 4-bar mean
print(crossover_signal(prices[:9], 2, 4)) # 0: the trend has turned注文は口座を変える
注文は現金を株に、あるいはその逆に変えます。株数が整数なら int(cash // price) 株を買うと少し現金が残り、口座の価値は常に cash + shares * price です。注文のロジックを小さな関数 execute_order にまとめておくと、ループが短くなり、手数料やスリッページを後で1か所に足せます。全部売るとは、cash + shares * price を現金にして株数をゼロにすることです。
def execute_order(cash, shares, price, target):
if target == 1:
bought = int(cash // price) # whole shares only
return cash - bought * price, shares + bought
return cash + shares * price, 0
cash, shares = execute_order(1000.0, 0, 103.0, 1)
print(cash, shares, cash + shares * 103.0) # 73.0 9 1000.0
cash, shares = execute_order(cash, shares, 99.0, 0)
print(cash, shares) # 964.0 0ループと、それが知ってよいこと
バー t では、戦略は前の終値までの prices[:t] を使ってよく、prices[t] で取引します。prices[:t] とスライスすることで未来が入らなくなります。次の価格がそもそもリストにないからです。ここからイベント駆動エンジンの良いテストが得られます。最初の k 本のバーだけで走らせた結果は、全体を走らせた結果の最初の k 個と一致しなければなりません。違えば、戦略が覗き見をしています。
def crossover_signal(history, fast, slow):
if len(history) < slow:
return 0
return 1 if sum(history[-fast:]) / fast > sum(history[-slow:]) / slow else 0
def execute_order(cash, shares, price, target):
if target == 1:
bought = int(cash // price)
return cash - bought * price, shares + bought
return cash + shares * price, 0
def run_backtest(prices, fast, slow, cash):
shares = 0
values = []
for t, price in enumerate(prices):
if t > 0:
target = crossover_signal(prices[:t], fast, slow) # only what was known at the last close
cash, shares = execute_order(cash, shares, price, target)
values.append(cash + shares * price)
return values
prices = [100, 101, 103, 102, 105, 107, 104, 103, 99, 98]
full = run_backtest(prices, 2, 4, 1000.0)
print([round(v, 2) for v in full])
print(run_backtest(prices[:6], 2, 4, 1000.0) == full[:6]) # True: the future never leaks# The benchmark: buy once at the start and hold
import random
def buy_and_hold(prices, cash):
shares = int(cash // prices[0])
left = cash - shares * prices[0]
return [left + shares * price for price in prices]
random.seed(3)
prices = [100.0]
for _ in range(99):
prices.append(round(prices[-1] * (1 + random.gauss(0.0004, 0.012)), 2))
held = buy_and_hold(prices, 10000.0)
print(round(held[0], 2), round(held[-1], 2))同じコードを本番へループは1本のバーを見て行動します。これは、次のバーがリストではなくデータフィードから届く実運用のプログラムとまったく同じ形です。シグナル・注文・口座のロジックを別々の関数に分けておけば、戦略を書き換えずに過去のフィードをライブのフィードに差し替えられます。
- イベント駆動型は状態(現金・株数)を持ってバーをループし、バー
tではprices[:t]しか使えない - 注文は現金と整数株の間で動く。口座の価値は
cash + shares * price。注文のロジックは1つの関数にまとめる - 先読みの検査として、最初の
k本での実行結果は全体の最初のk個と一致するはず。バイ・アンド・ホールドとも比べる
次は先読みバイアスです。バックテストを良すぎて信じられない結果にする、静かな間違いです。
ラボ:採点される内容
crossover_signalで2つの平均を比べる25点execute_orderで注文を約定させる25点run_backtestでバーを1本ずつたどる25点buy_and_holdで比較基準を作る25点