為替・株価の予測で注意すべきこと (2017/09/16)

為替・株価の予測で注意すべきことについて説明する。

予測時点で存在しないデータを使わない

それは「予測時点で存在しないデータを使わない」ということである。「当たり前だ」と思うかもしれないが、これは分かっていても、うっかり間違えることがあるので要注意だ。

FXの場合、日足始値は業者によってGMTの設定時間が違うことがある。これをごちゃまぜにすると間違える。

例えば、ニューヨーク・クロージングとGMTの0時は冬時間の場合、日本時間でそれぞれ午前7時と午前9時である。するとGMT0時での前日終値はニューヨーク・クロージングの当日始値より未来のデータになる。「前日のデータだから使っても大丈夫」というわけにはいかない。

これに夏時間の問題が加わる。欧米には夏時間があるが、日本にはない。欧州と米国は夏時間があるが、夏時間を適用する期間にずれがある。こういう点にも注意しなければならない。

株式市場の場合、また別の問題がある。国によって取引時間が違うし、同じ国でも取引所によって違う場合がある。

それにFXは24時間取引だから前日終値と当日始値はほとんど同時である。したがって、前日終値の時点で予測するのも当日始値の時点で予測するのも週明けを除けば同じと考えていい。

だが、株式市場の取引時間は1/3日程度であり、前日終値と当日始値との間には2/3日程度のずれがある。したがって、前日終値の時点で予測するのと当日始値の時点で予測するのとでは大変な違いがある。つまり、当日終値が当日始値より上か下かを予測するのと、当日終値が前日終値より上か下かを予測するのとは全くの別物なのである。

失敗例

予測時点で存在しないデータを使った、しかも、その影響力の大きさからさっさと修正なり削除なりするべきだと私が考えるのはGoogleの以下のレポートである。

https://github.com/corrieelston/datalab/blob/master/FinancialTimeSeriesTensorFlow.ipynb

これによると、ディープラーニングの手法を用いてS&P500の騰落を予測し、的中率が70%を超えたらしい。しかし、この的中率は予測時点で存在しないデータを使うという過ちによってもたらされたものなのである。

詳しく見ると、当日のS&P500の騰落を予測するのにS&P500、ダウ、ナスダック、NYSEの1-3日前のデータを使っていた。これらは取引時間が同じであり、1日以上空けてデータを利用するのだから問題ない。

問題は米国より先に始まるアジアや欧州の当日の株価指数を使っていることである。アジアや欧州の取引終了時間は米国より早い。だから使ってもいいと考えては間違える。

注意しなければならないことはレポートにおける騰落とは当日終値が当日始値より上か下かではなくて、当日終値が前日終値より上か下かだということだ。つまり、予測時点は当日始値ではなくて、前日終値である。

日経平均株価はS&P500が終わってから始まり、S&P500は日経平均株価が終わってから始まるので、取引時間が重なることはない。このため、当日の日経平均株価が終わった後なら、そのデータを使ってもいいように勘違いしやすい。

だが、予測時点はS&P500の前日終値なのであり、当日の日経平均株価のデータはその時点では存在しないのだから、使ってはいけないのである。まして取引時間が近く、重なってもいる欧州の株価指数を使えばさらにありえない予測力を持つことになる。

未来のデータなし+ディープラーニング

レポートによると、2回予測が行われており、1回目は二項分類を用いたモデル、2回目は隠れ層を加えたモデルによっている。そして1回目の的中率は60.1%、2回目は72.2%という結果になっている。

そこで、私はレポートのプログラムの予測部分をほぼそのまま利用、しかし説明変数として0-2日前のデータが使われていた株価指数については1-3日前に変更して検証しなおしてみた。

○以下のプログラムを実行する。

# coding: utf-8
import numpy as np
import pandas as pd
import tensorflow as tf
import time
from datetime import datetime
from pandas_datareader import data as web

# ヒストリカルデータの開始日と終了日を設定する。
start = datetime(2010, 1, 1)
end = datetime(2015, 10, 1)
# ヒストリカルデータをダウンロードする(間隔を空けないとエラーになる)。
second = 10
snp = web.DataReader('^GSPC', 'yahoo', start, end)
time.sleep(second)
nyse = web.DataReader('^NYA', 'yahoo', start, end)
time.sleep(second)
djia = web.DataReader('^DJI', 'yahoo', start, end)
time.sleep(second)
nikkei = web.DataReader('^N225', 'yahoo', start, end)
time.sleep(second)
hangseng = web.DataReader('000001.SS', 'yahoo', start, end)
time.sleep(second)
ftse = web.DataReader('^FTSE', 'yahoo', start, end)
time.sleep(second)
dax = web.DataReader('^GDAXI', 'yahoo', start, end)
time.sleep(second)
aord = web.DataReader('^AORD', 'yahoo', start, end)
# 終値を格納する。
closing_data = pd.DataFrame()
closing_data['snp_close'] = snp['Close']
closing_data['nyse_close'] = nyse['Close']
closing_data['djia_close'] = djia['Close']
closing_data['nikkei_close'] = nikkei['Close']
closing_data['hangseng_close'] = hangseng['Close']
closing_data['ftse_close'] = ftse['Close']
closing_data['dax_close'] = dax['Close']
closing_data['aord_close'] = aord['Close']
# 終値の欠損値を前のデータで補間する。
closing_data = closing_data.fillna(method='ffill')
# 終値の対数変化率を格納する。
log_return_data = pd.DataFrame()
log_return_data['snp_log_return'] = (np.log(closing_data['snp_close'] /
    closing_data['snp_close'].shift()))
log_return_data['nyse_log_return'] = (np.log(closing_data['nyse_close'] /
    closing_data['nyse_close'].shift()))
log_return_data['djia_log_return'] = (np.log(closing_data['djia_close'] /
    closing_data['djia_close'].shift()))
log_return_data['nikkei_log_return'] = (np.log(closing_data['nikkei_close']
    / closing_data['nikkei_close'].shift()))
log_return_data['hangseng_log_return'] = (
    np.log(closing_data['hangseng_close'] /
    closing_data['hangseng_close'].shift()))
log_return_data['ftse_log_return'] = (np.log(closing_data['ftse_close'] /
    closing_data['ftse_close'].shift()))
log_return_data['dax_log_return'] = (np.log(closing_data['dax_close'] /
    closing_data['dax_close'].shift()))
log_return_data['aord_log_return'] = (np.log(closing_data['aord_close'] /
    closing_data['aord_close'].shift()))
# S&P500の対数変化率が0以上なら1、さもなければ0を格納した列を加える。
log_return_data['snp_log_return_positive'] = 0
log_return_data.loc[log_return_data['snp_log_return'] >= 0,
                   'snp_log_return_positive'] = 1
# S&P500の対数変化率が0未満なら1、さもなければ0を格納した列を加える。
log_return_data['snp_log_return_negative'] = 0
log_return_data.loc[log_return_data['snp_log_return'] < 0,
                   'snp_log_return_negative'] = 1
# 学習・テスト用データを作成する。
training_test_data = pd.DataFrame(
    columns=[
        'snp_log_return_positive', 'snp_log_return_negative',
        'snp_log_return_1', 'snp_log_return_2', 'snp_log_return_3',
        'nyse_log_return_1', 'nyse_log_return_2', 'nyse_log_return_3',
        'djia_log_return_1', 'djia_log_return_2', 'djia_log_return_3',
        'nikkei_log_return_1', 'nikkei_log_return_2', 'nikkei_log_return_3',
        'hangseng_log_return_1', 'hangseng_log_return_2',
            'hangseng_log_return_3',
        'ftse_log_return_1', 'ftse_log_return_2', 'ftse_log_return_3',
        'dax_log_return_1', 'dax_log_return_2', 'dax_log_return_3',
        'aord_log_return_1', 'aord_log_return_2', 'aord_log_return_3'])
for i in range(7, len(log_return_data)):
    snp_log_return_positive = (
        log_return_data['snp_log_return_positive'].iloc[i])
    snp_log_return_negative = (
        log_return_data['snp_log_return_negative'].iloc[i])
    # 先読みバイアスを排除するため、当日のデータを使わない。
    snp_log_return_1 = log_return_data['snp_log_return'].iloc[i-1]
    snp_log_return_2 = log_return_data['snp_log_return'].iloc[i-2]
    snp_log_return_3 = log_return_data['snp_log_return'].iloc[i-3]
    nyse_log_return_1 = log_return_data['nyse_log_return'].iloc[i-1]
    nyse_log_return_2 = log_return_data['nyse_log_return'].iloc[i-2]
    nyse_log_return_3 = log_return_data['nyse_log_return'].iloc[i-3]
    djia_log_return_1 = log_return_data['djia_log_return'].iloc[i-1]
    djia_log_return_2 = log_return_data['djia_log_return'].iloc[i-2]
    djia_log_return_3 = log_return_data['djia_log_return'].iloc[i-3]
    nikkei_log_return_1 = log_return_data['nikkei_log_return'].iloc[i-1]
    nikkei_log_return_2 = log_return_data['nikkei_log_return'].iloc[i-2]
    nikkei_log_return_3 = log_return_data['nikkei_log_return'].iloc[i-3]
    hangseng_log_return_1 = log_return_data['hangseng_log_return'].iloc[i-1]
    hangseng_log_return_2 = log_return_data['hangseng_log_return'].iloc[i-2]
    hangseng_log_return_3 = log_return_data['hangseng_log_return'].iloc[i-3]
    ftse_log_return_1 = log_return_data['ftse_log_return'].iloc[i-1]
    ftse_log_return_2 = log_return_data['ftse_log_return'].iloc[i-2]
    ftse_log_return_3 = log_return_data['ftse_log_return'].iloc[i-3]
    dax_log_return_1 = log_return_data['dax_log_return'].iloc[i-1]
    dax_log_return_2 = log_return_data['dax_log_return'].iloc[i-2]
    dax_log_return_3 = log_return_data['dax_log_return'].iloc[i-3]
    aord_log_return_1 = log_return_data['aord_log_return'].iloc[i-1]
    aord_log_return_2 = log_return_data['aord_log_return'].iloc[i-2]
    aord_log_return_3 = log_return_data['aord_log_return'].iloc[i-3]
    # 各データをインデックスのラベルを使用しないで結合する。
    training_test_data = training_test_data.append(
        {'snp_log_return_positive':snp_log_return_positive,
        'snp_log_return_negative':snp_log_return_negative,
        'snp_log_return_1':snp_log_return_1,
        'snp_log_return_2':snp_log_return_2,
        'snp_log_return_3':snp_log_return_3,
        'nyse_log_return_1':nyse_log_return_1,
        'nyse_log_return_2':nyse_log_return_2,
        'nyse_log_return_3':nyse_log_return_3,
        'djia_log_return_1':djia_log_return_1,
        'djia_log_return_2':djia_log_return_2,
        'djia_log_return_3':djia_log_return_3,
        'nikkei_log_return_1':nikkei_log_return_1,
        'nikkei_log_return_2':nikkei_log_return_2,
        'nikkei_log_return_3':nikkei_log_return_3,
        'hangseng_log_return_1':hangseng_log_return_1,
        'hangseng_log_return_2':hangseng_log_return_2,
        'hangseng_log_return_3':hangseng_log_return_3,
        'ftse_log_return_1':ftse_log_return_1,
        'ftse_log_return_2':ftse_log_return_2,
        'ftse_log_return_3':ftse_log_return_3,
        'dax_log_return_1':dax_log_return_1,
        'dax_log_return_2':dax_log_return_2,
        'dax_log_return_3':dax_log_return_3,
        'aord_log_return_1':aord_log_return_1,
        'aord_log_return_2':aord_log_return_2,
        'aord_log_return_3':aord_log_return_3},
        ignore_index=True)
# 3列目以降を説明変数として格納する。
predictors_tf = training_test_data[training_test_data.columns[2:]]
# 1、2列目を目的変数として格納する。
classes_tf = training_test_data[training_test_data.columns[:2]]
# 学習用セットのサイズを学習・テスト用データの80%に設定する。
training_set_size = int(len(training_test_data) * 0.8)
# 説明変数の初めの80%を学習用データにする。
training_predictors_tf = predictors_tf[:training_set_size]
# 目的変数の初めの80%を学習用データにする。
training_classes_tf = classes_tf[:training_set_size]
# 説明変数の残りの20%をテスト用データにする。
test_predictors_tf = predictors_tf[training_set_size:]
# 目的変数の残りの20%をテスト用データにする。
test_classes_tf = classes_tf[training_set_size:]

def tf_confusion_metrics(model, actual_classes, session, feed_dict):
    #
    predictions = tf.argmax(model, 1)
    #
    actuals = tf.argmax(actual_classes, 1)
    #
    ones_like_actuals = tf.ones_like(actuals)
    zeros_like_actuals = tf.zeros_like(actuals)
    ones_like_predictions = tf.ones_like(predictions)
    zeros_like_predictions = tf.zeros_like(predictions)
    tp_op = tf.reduce_sum(
        tf.cast(
            tf.logical_and(
            tf.equal(actuals, ones_like_actuals), 
            tf.equal(predictions, ones_like_predictions)
            ), 
            "float"
        )
    )
    tn_op = tf.reduce_sum(
        tf.cast(
            tf.logical_and(
            tf.equal(actuals, zeros_like_actuals), 
            tf.equal(predictions, zeros_like_predictions)
            ), 
            "float"
         )
    )    
    fp_op = tf.reduce_sum(
        tf.cast(
            tf.logical_and(
            tf.equal(actuals, zeros_like_actuals), 
            tf.equal(predictions, ones_like_predictions)
            ), 
            "float"
        )
    )    
    fn_op = tf.reduce_sum(
        tf.cast(
            tf.logical_and(
            tf.equal(actuals, ones_like_actuals), 
            tf.equal(predictions, zeros_like_predictions)
            ), 
            "float"
        )
    )
    tp, tn, fp, fn = \
        session.run(
            [tp_op, tn_op, fp_op, fn_op], 
            feed_dict
        )
    tpr = float(tp)/(float(tp) + float(fn))
    accuracy = ((float(tp) + float(tn))/(float(tp) + float(fp) + float(fn) +
        float(tn)))
    recall = tpr
    precision = float(tp)/(float(tp) + float(fp))  
    f1_score = (2 * (precision * recall)) / (precision + recall)  
    print('Precision = ', precision)
    print('Recall = ', recall)
    print('F1 Score = ', f1_score)
    print('Accuracy = ', accuracy)
#
sess = tf.Session()
# 説明変数と目的変数の数を格納する。
num_predictors = len(training_predictors_tf.columns)
num_classes = len(training_classes_tf.columns)
# 
feature_data = tf.placeholder("float", [None, num_predictors])
actual_classes = tf.placeholder("float", [None, num_classes])
# 重みとバイアスを格納する変数を生成する。
weights = tf.Variable(
    tf.truncated_normal([num_predictors, num_classes], stddev=0.0001))
biases = tf.Variable(tf.ones([num_classes]))
# モデルを定義する。
model = tf.nn.softmax(tf.matmul(feature_data, weights) + biases)
# コスト関数を定義する。
cost = -tf.reduce_sum(actual_classes*tf.log(model))
# 学習率を設定する。
training_step = tf.train.AdamOptimizer(learning_rate=0.0001).minimize(cost)
# 
init = tf.global_variables_initializer()
sess.run(init)
# 正解した予測を格納する。
correct_prediction = tf.equal(tf.argmax(model, 1),
                              tf.argmax(actual_classes, 1))
# 正解率を格納する。
accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))
# 学習を実行する。
print('***二項分類を用いたモデル***')
print('学習用データを用いた正解率')
for i in range(1, 30001):
    sess.run(
        training_step, 
        feed_dict={
            feature_data: training_predictors_tf.values, 
            actual_classes: training_classes_tf.values.reshape(
                len(training_classes_tf.values), 2)
        }
    )
    if i%5000 == 0:
        print(i, sess.run(
            accuracy,
            feed_dict={
                feature_data: training_predictors_tf.values, 
                actual_classes: training_classes_tf.values.reshape(
                    len(training_classes_tf.values), 2)
            }
        ))
# 
feed_dict= {
    feature_data: test_predictors_tf.values,
    actual_classes: test_classes_tf.values.reshape(
        len(test_classes_tf.values), 2)
}
#
print('テスト用データを用いた検証結果')
tf_confusion_metrics(model, actual_classes, sess, feed_dict)
print('\n')
#
sess1 = tf.Session()
# 説明変数と目的変数の数を格納する。
num_predictors = len(training_predictors_tf.columns)
num_classes = len(training_classes_tf.columns)
# 
feature_data = tf.placeholder("float", [None, num_predictors])
actual_classes = tf.placeholder("float", [None, 2])
# 重み1-3、バイアス1-3を格納する変数を生成する。
weights1 = tf.Variable(tf.truncated_normal([24, 50], stddev=0.0001))
biases1 = tf.Variable(tf.ones([50]))
weights2 = tf.Variable(tf.truncated_normal([50, 25], stddev=0.0001))
biases2 = tf.Variable(tf.ones([25]))
weights3 = tf.Variable(tf.truncated_normal([25, 2], stddev=0.0001))
biases3 = tf.Variable(tf.ones([2]))
# 隠れ層を生成する。
hidden_layer_1 = tf.nn.relu(tf.matmul(feature_data, weights1) + biases1)
hidden_layer_2 = tf.nn.relu(tf.matmul(hidden_layer_1, weights2) + biases2)
# モデルを定義する。
model = tf.nn.softmax(tf.matmul(hidden_layer_2, weights3) + biases3)
# コスト関数を定義する。
cost = -tf.reduce_sum(actual_classes*tf.log(model))
# 学習率を設定する。
train_op1 = tf.train.AdamOptimizer(learning_rate=0.0001).minimize(cost)
# 
init = tf.global_variables_initializer()
sess1.run(init)
# 正解した予測を格納する。
correct_prediction = tf.equal(tf.argmax(model, 1),
                              tf.argmax(actual_classes, 1))
# 正解率を格納する。
accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))
# 学習を実行する。
print('***隠れ層を加えたモデル***')
print('学習用データを用いた正解率')
for i in range(1, 30001):
    sess1.run(
        train_op1, 
        feed_dict={
            feature_data: training_predictors_tf.values, 
            actual_classes: training_classes_tf.values.reshape(
                len(training_classes_tf.values), 2)
        }
    )
    if i%5000 == 0:
        print(i, sess1.run(
            accuracy,
            feed_dict = {
                feature_data: training_predictors_tf.values, 
                actual_classes: training_classes_tf.values.reshape(
                    len(training_classes_tf.values), 2)
            }
        ))
#
feed_dict= {
    feature_data: test_predictors_tf.values,
    actual_classes: test_classes_tf.values.reshape(
        len(test_classes_tf.values), 2)
}
#
print('テスト用データを用いた検証結果')
tf_confusion_metrics(model, actual_classes, sess1, feed_dict)

***二項分類を用いたモデル***
学習用データを用いた正解率
5000 0.559028
10000 0.559896
15000 0.5625
20000 0.567708
25000 0.567708
30000 0.565104
テスト用データを用いた検証結果
Precision =  0.39285714285714285
Recall =  0.0763888888888889
F1 Score =  0.12790697674418605
Accuracy =  0.4809688581314879


***隠れ層を加えたモデル***
学習用データを用いた正解率
5000 0.55816
10000 0.565972
15000 0.563368
20000 0.563368
25000 0.563368
30000 0.563368
テスト用データを用いた検証結果
Precision =  0.5102040816326531
Recall =  0.1736111111111111
F1 Score =  0.2590673575129534
Accuracy =  0.5051903114186851

「Accuracy」がテスト用データにおける正解率、つまり的中率である。二項分類を用いたモデルでは48.1%、隠れ層を加えたモデルでは50.5%で、いずれもほぼ50%となっている。これはコイントスと同じで、予測力はまったくないと言っていいだろう。

未来のデータあり+超単純モデル

今度は逆に、未来のデータを使えば、ディープラーニングでなくてもほぼ同等のパフォーマンスを叩き出すことができることを示そうと思う。

Googleのレポートで行われたことを簡単に説明すると、当日のS&P500が上がるか下がるかを、S&P500、NYSE総合、NYダウの1-3日前の対数変化率、日経平均株価、香港ハンセン、FTSE100、DAX、オーストラリアASXの0-2日前の対数変化率、合計8*3=24個のデータを説明変数としてディープラーニングで予測するというものである。

その結果、実に72.2%という的中率を実現した、と主張する。私は未来のデータを使うことが許されるなら、もっと少ない説明変数で、もっと単純な手法で同等の的中率を実現できることをこれから示す。

先ず、説明変数には0日前、つまり当日のFTSE100が上がったか下がったかの1個だけを使用する。次に学習は行わず、単にFTSE100が上がればS&P500も上がる、FTSE100が下がればS&P500も下がると予測することにする。およそディープラーニングとはほど遠い超単純なモデルである。

○以下のプログラムを実行する。

# coding: utf-8
import numpy as np
import pandas as pd
import time
from datetime import datetime
from pandas_datareader import data as web

# ヒストリカルデータの開始日と終了日を設定する。
start = datetime(2010, 1, 1)
end = datetime(2015, 10, 1)
# ヒストリカルデータをダウンロードする。
snp = web.DataReader('^GSPC', 'yahoo', start, end)
time.sleep(10)
ftse = web.DataReader('^FTSE', 'yahoo', start, end)
# 終値を格納する。
closing_data = pd.DataFrame()
closing_data['snp_close'] = snp['Close']
closing_data['ftse_close'] = ftse['Close']
# 終値の欠損値を補間する。
closing_data = closing_data.fillna(method='ffill')
# 終値の対数変化率を格納する。
log_return_data = pd.DataFrame()
log_return_data['snp_log_return'] = (
np.log(closing_data['snp_close'] /
    closing_data['snp_close'].shift()))
log_return_data['ftse_log_return'] = (np.log(closing_data['ftse_close'] /
    closing_data['ftse_close'].shift()))
# 最初の行はNaNなので削除する。
log_return_data = log_return_data[1:]
correct_prediction = (log_return_data['snp_log_return'] *
    log_return_data['ftse_log_return'])
correct_prediction[correct_prediction>=0] = 1
correct_prediction[correct_prediction<0] = 0
accuracy = correct_prediction.sum() / len(correct_prediction)
print('***超単純モデル***')
print('Accuracy = ', accuracy)

***超単純モデル***
Accuracy =  0.7159640635798203

的中率は71.6%でGoogleレポートの72.2%とほぼ同じである。つまり的中率70%はディープラーニングのおかげではなくて、未来のデータを使ったことによるのである。

日本株の予測でさらに注意すべきこと

予測をするのに当日のデータを使ってはいけない、ということが分かった。だが、前日のデータなら使って安心、とはならない。これは日本などのアジア株特有の問題である。

もしS&P500の前日終値が前日始値(または前々日終値)より上か下かで日経平均株価の当日終値が当日始値より上か下かを予測するのであれば問題ない。予測時点は日経平均株価の当日始値であり、S&P500の前日終値のデータはすでに存在するからである。

だが、S&P500の前日終値が前日始値(または前々日終値)より上か下かで日経平均株価の当日終値が前日終値より上か下かを予測するのであればこれは大問題である。予測時点は日経平均株価の前日終値であり、S&P500の前日終値のデータはまだ存在しないからである。

つまり、当日の日本の株価を予測する場合、予測時点が前日終値であれば、前日の欧米の株価を使ってはならないのであり、前々日以前のものを使わなければならないのである。

政府の市場介入によるバイアス (2017/05/27)

政府の市場介入によるバイアスによってパフォーマンスがどうなるか、そして、バイアスを除いたあとのパフォーマンスがどうなるかを見てみる。

スイスフランショック

政府の市場介入は価格形成に歪みを与える。その歪みを利用して利益を上げることも可能ではある。だが、恐ろしいのは政府が方針を変更したときである。2015年1月15日に起きたスイスフランショックは記憶に新しい。

スイスフラン高を嫌ったスイス中銀は2011年9月、ユーロスイスの下限を1.2スイスフランに設定して無制限介入を開始した。ユーロスイスが1.2スイスフランを割ることはないとスイス中銀が保証してくれたわけだ。これをバックにして買えば低リスクで利益を上げられると考えたトレーダーも少なくなかっただろう。

かく言う私もスイス中銀の介入をバックにして下がったら買う逆張り戦略をブログで記事にした。ただ、スイス中銀が無制限介入を行ったときにユーロスイスが暴騰したことを挙げて、スイス中銀が方針を変換したら逆のことが起きると警告した。また、1.2スイスフランを割ったら損切りすべきだとも書いた。だが、私の認識は甘すぎた。

スイス中銀が下限を撤廃したことが伝わると、スプレッドが大きく広がり、そしてレート配信が止まった。運よく損切りできても想定以上の損失を受けただろうし、損切りできず、ロスカットすらできずに全資金を失い、さらにはFX会社に対して負債すら抱えてしまったトレーダーも少なくなかった。FX会社が負債を回収できずに倒産するというケースもあった。幸い、私は記事にしただけで実際には運用しなかった。だが、いい加減な記事を書いてはいけないと痛感した。

一時的には勝てるが…

政府の介入によって歪められた市場のデータを使ってバックテストしても、その寿命はその介入が継続している間だけに限られる。そして、介入は何のアナウンスもなく突然撤廃され、後は阿鼻叫喚の地獄がやってくるのである。

例として直近安値をバックにしてユーロスイスを買う戦略を考えてみる。具体的には

  • 終値が直近安値*(1+エントリー閾値/100)以下になったら買いエントリー、
  • 終値が直近安値*(1+(エントリー閾値+エグジット閾値)/100)以上になったら買いエグジット、

という売買ルールである。

では、上記の売買ルールに基づき、2012年1月1日から2015年1月1日までの期間でバックテストしてみる。

以下のコードを「swiss_franc_shock.py」ファイルとして「~/py」フォルダーに保存する。

# coding: utf-8

import forex_system as fs
import pandas as pd

# パラメータの設定
PERIOD = 1500
ENTRY_THRESHOLD = 0.002
PROFIT_THRESHOLD = 0.08
PARAMETER = [PERIOD, ENTRY_THRESHOLD, PROFIT_THRESHOLD]

# 最適化の設定
START_PERIOD = 250
END_PERIOD = 2500
STEP_PERIOD = 250
START_ENTRY_THRESHOLD = 0.001
END_ENTRY_THRESHOLD = 0.01
STEP_ENTRY_THRESHOLD = 0.001
START_PROFIT_THRESHOLD = 0.01
END_PROFIT_THRESHOLD = 0.1
STEP_PROFIT_THRESHOLD = 0.01
RRANGES = (
    slice(START_PERIOD, END_PERIOD, STEP_PERIOD),
    slice(START_ENTRY_THRESHOLD, END_ENTRY_THRESHOLD, STEP_ENTRY_THRESHOLD),
    slice(START_PROFIT_THRESHOLD, END_PROFIT_THRESHOLD, STEP_PROFIT_THRESHOLD),
)

get_model = None

def strategy(parameter, symbol, timeframe):
    '''戦略を記述する。
    Args:
        parameter: パラメーター。
        symbol: 通貨ペア。
        timeframe: 期間。
    Returns:
        買いエントリー、買いエグジット、売りエントリー、売りエグジット、ロット数。
    '''
    # パラメータを格納する。
    period = int(parameter[0])
    entry_threshold = float(parameter[1])
    profit_threshold = float(parameter[2])
    # 戦略を記述する。
    close1 = fs.i_close(symbol, timeframe, 1)
    hl_band1 = fs.i_hl_band(symbol, timeframe, period, 1)
    buy_entry = close1 <= hl_band1['low'] * (1 + entry_threshold / 100)
    buy_exit = (close1 >= hl_band1['low'] *
                (1 + (entry_threshold + profit_threshold) / 100))
    sell_entry = close1 != close1  # 売りはやらないので無意味な条件を与える。
    sell_exit = close1 != close1  # 同上。
    index = close1.index
    lots = pd.Series(1.0, index=index)
    return buy_entry, buy_exit, sell_entry, sell_exit, lots

if __name__ == '__main__':
    fs.platform()

以下のコマンドを実行してバックテストする。

%run -t ~/py/swiss_franc_shock.py --mode backtest --symbol EURCHF --timeframe 5 --spread 2.0 --start 2012.01.01 --end 2015.01.01

       start         end trades   apr sharpe    kelly drawdowns durations              parameter
  2012.01.01  2014.12.31    115  0.01  1.661  269.309     0.005        55  [1500.0, 0.002, 0.08]

シャープレシオはそれほど高くはないが、資産曲線は比較的きれいな右肩上がりとなっている。

地獄は突然やってくる

2015年でも同様のパフォーマンスを期待したいが、残念ながらスイス中銀は1月15日、突如としてスイスフランの下限を撤廃する。では、すでに最適化されたパラメータを使い、期間を2016年1月1日まで延長したデータでどうなるかを見てみる。

以下のコマンドを実行してバックテストする。

%run -t ~/py/swiss_franc_shock.py --mode backtest --symbol EURCHF --timeframe 5 --spread 2.0 --start 2012.01.01 --end 2016.01.01

      start         end trades    apr sharpe  kelly drawdowns durations              parameter
  2012.01.01  2015.12.31    155 -0.032  -0.41 -5.408     0.187       258  [1500.0, 0.002, 0.08]

一体何が起きたのか、という感じである。3年もかけてコツコツと積み上げた利益など一瞬で消えてしまい、約10%以上の負けとなっている。

しかも、これはレバレッジが1倍ならの話である。もしレバレッジが10倍だったら、全資金を失うばかりか、負債まで抱えることとなっていただろう。実際にはスプレッドは広がるし、レート配信は停止するしで、バックテストよりもっとひどいことになっていたかもしれない。

トレードで利益を上げるには単に市場の歪みを捉えるだけではなく、それが継続性のあるものでなければならない。政府によって一時的に作られた歪みによって利益を得ようとすることは後々とんでもないしっぺ返しを受けることになる。

データ・スヌーピング・バイアス (2017/05/27)

データ・スヌーピング・バイアスによってパフォーマンスがどうなるか、そして、バイアスを除いたあとのパフォーマンスがどうなるかを見てみる。

売買ルールを複雑にすればパフォーマンスは向上する?

ある売買ルールでパフォーマンスがいまいちな場合、何らかのルールを追加することでパフォーマンスを改善させようとする人が多いように思う。それが一概に悪いとは言えない。だが、例え無意味なルールでも、それを加えることでバックテスト上のパフォーマンスを向上させることはできてしまうのである。

例えば当日の終値が上昇するか下落するかは大体1:1の半々である。前日終値が上昇した場合に当日の終値が上昇するか下落するかも大体1:1の半々である。前日終値が下落した場合に当日の終値が上昇するか下落するかもやはり大体1:1の半々である。では前々日、3日前、4日前と条件を加えていくと、それでも大体1:1の半々となるだろうか。

データには偶然起きる偏りがあるものである。コインを投げて表が上になるか、裏が上になるかを何回も試した場合、「表、裏、表、裏、表、裏、...」のように交互に現れるとは限らない。表、あるいは裏が続くこともある。それでも全体として表と裏は大体1:1の半々で現れるのである。

したがって、条件をどんどん加えていって細分化すれば、必ず偏りが生じる。上昇、下落の比率が3:2以上、あるいは2:3以下となることも起きる。すると比率が3:2以上となった条件では買い、2:3以下となった条件では売りとすれば勝率60%以上の戦略の出来上がりである。FXなんて簡単だ!

だが、出来上がった戦略を別の期間のデータで試すと大抵はボロボロになる。これがつまりデータ・スヌーピング・バイアスだ。やたらとルールを加えてバックテスト上のパフォーマンスを向上させることは多くの場合、ただのお遊びで終わる。

このような過ちを避けるためには売買ルールは可能な限りシンプルにすることだ。そして、パラメータの最適化に使用しなかったデータでも売買ルールが有効か確認することである。

最近、機械学習が流行しているが…

最近、投資の世界では機械学習が流行しているようだ。機械学習の一種である決定木では説明変数で場合分けし、それを更に場合分けするということを繰り返し、複雑な条件の組み合わせによって目的変数を予測する。これもやりすぎるとデータ・スヌーピングになる。

例えば1本前の足のリターンを目的変数とし、2本前の足のリターンを説明変数とする。説明変数は1つだけだが、リターンなので、いくらでも細分化できる。「x <= 0.0」で場合分けし、yesなら更に「x <= -0.1」で、noなら更に「x <= 0.1」で場合分けするというようにである。

これでモデルを作成してから1本前の足のリターンを今度は説明変数としてモデルに与えれば、現在の足のリターンを予測してくれることになる。そして予測したリターンがプラスなら買い、マイナスなら売りとする。

では、先ず2011年1月1日から2014年1月1日までのドル円1時間足のデータを使ってモデルを作成し、同期間でバックテストしてみる。

以下のコードを「data_snooping_bias.py」ファイルとして「~/py」フォルダーに保存する。

# coding: utf-8

import forex_system as fs
import pandas as pd
from datetime import datetime
from sklearn import tree

# パラメータの設定
PARAMETER = None

# 最適化の設定
RRANGES = None

get_model = None

def strategy(parameter, symbol, timeframe):
    '''戦略を記述する。
    Args:
        parameter: パラメーター。
        symbol: 通貨ペア。
        timeframe: 期間。
    Returns:
        買いエントリー、買いエグジット、売りエントリー、売りエグジット、ロット数。
    '''
    ret = fs.i_roc(symbol, timeframe, 1, 1)
    index = ret.index
    start = datetime.strptime('2011.01.01', '%Y.%m.%d')
    end = datetime.strptime('2014.01.01', '%Y.%m.%d')
    clf = tree.DecisionTreeRegressor()
    y = ret[start:end]
    x = ret.shift(1)[start:end]
    x = x.values.reshape(len(x), 1)
    clf.fit(x, y)
    pred = clf.predict(ret.values.reshape(len(ret), 1))
    pred = pd.Series(pred, index=index)
    buy_entry = pred > 0.0
    buy_exit = buy_entry != 1
    sell_entry = pred < 0.0
    sell_exit = sell_entry != 1
    lots = pd.Series(1.0, index=index)
    return buy_entry, buy_exit, sell_entry, sell_exit, lots

if __name__ == '__main__':
    fs.platform()

以下のコマンドを実行してバックテストする。

%run -t ~/py/data_snooping_bias.py --mode backtest --symbol USDJPY --timeframe 60 --spread 0.4 --start 2011.01.01 --end 2014.01.01

       start         end trades     apr  sharpe    kelly drawdowns durations
  2011.01.01  2013.12.31   9768  424615  58.548  731.352     0.015         0

シャープレシオは58.55で、すごいパフォーマンスである。

再現性がなければ意味はない

では、上で作ったモデルを使い、2014年1月1日から2017年1月1日までの期間でバックテストし、果たしてモデルに再現性があるかどうかを見てみる。

以下のコマンドを実行してバックテストする。

%run -t ~/py/data_snooping_bias.py --mode backtest --symbol USDJPY --timeframe 60 --spread 0.4 --start 2014.01.01 --end 2017.01.01

       start         end trades    apr sharpe  kelly drawdowns durations
  2014.01.01  2016.12.31   9363 -0.095 -1.121 -11.76     0.418       781

シャープレシオは-1.12となっており、ひどいパフォーマンスである。複雑な売買ルールでバックテスト上でのパフォーマンスを上げたところで、再現性があるかどうかはまた別の問題であるということが分かるだろう。

先読みバイアス (2017/05/27)

先読みバイアスによってパフォーマンスがどうなるか、そして、バイアスを除いたあとのパフォーマンスがどうなるかを見てみる。

先読みバイアスは不注意から生まれる

先読みバイアスはありえないパフォーマンスを生む。もしタイムマシーンがあって未来の情報を手に入れることができるなら、投資もギャンブルも百戦百勝である。だがもちろん、そのようなことはできない話である。

先読みバイアスはバックテストのときに誤って未来のデータを使ってしまった場合に起きる。MT4では日付をずらしたデータを作成して使ったりしないかぎり、未来のデータを使ってバックテストはできない仕組みになっている。しかし、自分でバックテストシステムを作った場合、先読みバイアスの過ちを犯してしまうリスクがある。

先読みバイアスはプログラミング上の誤りではないので、エラーが発生しない。このため、問題があることに気づきにくいのでやっかいである。

先読みバイアスを避けるにはトレードの時点で存在しないデータを使わないよう注意するしかない。だが、もしありえないようなパフォーマンスが得られたなら、先ず先読みバイアスを疑うべきである。

先読みバイアスが生む異常なパフォーマンス

例えば1本前の足の終値が2本前の足の終値より上であれば買い、下であれば売りという戦略を考えたとする。このような単純な戦略では取引コストを無視すれば勝つということもないが負けることもない。だが取引コストを考慮するなら、右肩下がりの資産曲線となる。

ところが間違えて現在の足の終値が1本前の足の終値より上であれば買い、下であれば売り、とプログラムしたとする。現在の足の終値が上がるか下がるか、始値の時点では分からないのだから、このようなことは現実には実行できない。だがバックテストではできてしまう。

これはつまり終値が上がっていたら始値の時点に戻って買い、終値が下がっていたら始値の時点に戻って売るということである。あたかもタイムマシンで未来に行って結果を見てきてから現在に戻って売買するのと同じである。これでは上がるか下がるの予測は百発百中となるに決まっている。

もし、ある戦略のシャープレシオが3.0くらいあるとしたら、それは優秀な戦略と言っていいと思う。このような戦略を作ることは不可能ではないが、決して簡単なことではない。もし、シャープレシオが3.0を超えるような戦略がふと出来上がってしまったとしたら、先ず先読みバイアスを疑うべきである。

では、上で説明した間違ったコードを2014年1月1日から2017年1月1日までUSDJPYの5分足に適用して検証してみる。

以下のコードを「look_ahead_bias.py」ファイルとして「~/py」フォルダーに保存する。

# coding: utf-8

import forex_system as fs
import pandas as pd

# パラメータの設定
PARAMETER = None

# 最適化の設定
RRANGES = None

get_model = None

def strategy(parameter, symbol, timeframe):
    '''戦略を記述する。
    Args:
        parameter: パラメーター。
        symbol: 通貨ペア。
        timeframe: 期間。
    Returns:
        買いエントリー、買いエグジット、売りエントリー、売りエグジット、ロット数。
    '''
    close1 = fs.i_close(symbol, timeframe, 0)  # 「1」を誤って「0」にしている。
    close2 = fs.i_close(symbol, timeframe, 1)  # 「2」を誤って「1」にしている。
    buy_entry = close2 < close1
    buy_exit = close2 > close1
    sell_entry = close2 > close1
    sell_exit = close2 < close1
    index = close1.index
    lots = pd.Series(1.0, index=index)
    return buy_entry, buy_exit, sell_entry, sell_exit, lots

if __name__ == '__main__':
    fs.platform()

以下のコマンドを実行してバックテストする。

%run -t ~/py/look_ahead_bias.py --mode backtest --symbol USDJPY --timeframe 5 --spread 0.4 --start 2014.01.01 --end 2017.01.01

       start         end  trades          apr   sharpe    kelly drawdowns durations
  2014.01.01  2016.12.31  112760  9.04702e+18  185.484  2307.89     0.009         0

資産曲線は右肩上がりの曲線を描いている。複利で計算しているので、最後は爆発的に利益が増えている。シャープレシオは185.48もあるが、これは明らかに異常なパフォーマンスである。このような結果になったら先ず、先読みバイアスを疑って、コードを確認することである。

バイアスを排除した後の惨めなパフォーマンス

実際にコードを確認すると、やはり間違いがあった。そこで間違いを訂正して実行したところ、以下のようになった。

       start         end  trades    apr  sharpe    kelly drawdowns durations
  2014.01.01  2016.12.31  112759 -0.331 -16.286 -163.036   133.332       782

資産曲線は右肩下がりの曲線を描いている。シャープレシオは-16.29で、実際には全く使い物にならない戦略だということが分かる。