勉強メモ(python機械学習で仮想通貨bot作成)

richmanbtcさんチュートリアルに対する理解と、理解するためにインプットしたことまとめメモ。あくまでもメモなのでわかりづらい。ぼくはpythonも機械学習も初心者。ミス理解してたら指摘ください。

■基礎

<pythonの書き方とか>
チュートリアル内のコード1行1行調べながら理解。

<機械学習の基礎知識>
下記1冊でインプット。
スッキリわかるPythonによる機械学習入門 (スッキリわかる入門シリーズ)

インプットしたことまとめ↓。一般的な機械学習の流れとか。
https://docs.google.com/spreadsheets/d/1BGC5We97begVRclmlP2iVRXY-bGYKq3KBPiliWK-X34/edit?usp=sharing

<トレードについて>
・販売所ではなく取引所でトレードする
・デモトレはbybitで(bitflyerでデモできないよね?これ以外におすすめある?)
・デリバティブ取引をする
・銘柄はビットコイン
・レバは2倍(個人投資家の場合)
・取引所はbitflyer

<bot開発に使うツール>
・開発&検証
VSCode(無料)でコード書いて検証。richmanさんは有料のIntelliJ IDEAを使用。コードのバージョン管理はGitで。richmanさん本にはJupyter Notebookも書かれているが、Jupyter NotebookでできることはVSCodeで出来るので不要?

・bot運用
docker、AWS(もしくはAzure)

<Jupyter Notebookとは?>
Pythonファイルをwebブラウザ上で記述・実行できる統合開発環境

<dockerとは?>
・バーチャル上で使える新しいパソコン(パソコンの中にもう一つのパソコン)
・無料版で十分使える
・ネット経由で持ち運べる
・エンジニアの必須ツール
・簡単にアプリ(今ならbot)を動かす

<bot作成における重要項目>
①リークのないバリデーション(=漏れがない検証)
②相関係数の改善
③シャープレシオの改善
④ミスなく実装

<フォワードとバックテストの乖離ポイント>
①元データの違い→バグとして扱う
②特徴量の違い→バグとして扱う
③シグナルの違い→バグとして扱う
④約定の違い→必要コスト

■bot開発コードの大まかな流れ

1. 必要なライブラリ、モジュール、関数などインポート

2. ティックデータを取引所からインポート

3. トレード手数料を計算
取引所サイトのニュースで確認できる。過去に何度か手数料を変更してる取引所がある(例えばGMOコインとか)ので、すべてのニュースに目を通すこと。

4. 特徴量を計算
特徴量はSMAやRSIなどインジケータの値とか

5. 特徴量を選択
学習に使用する特徴量を選択。どれを選択するかで未来予測が変わるので超大事。richmanさんは公開してない(←公開してほしい( ̄(工) ̄))。

6. トレード損益式を作成

richmanさんの損益式:(エグジット価格/エントリー価格)-1-2*fee

式の意味を自分なりに紐解く。一般的に損益は、ざっくりと「エントリー〜エグジットの価格差」から「手数料」を引いた額だけど、richmanさんは「エントリー〜エグジットの価格差」を割合にしてる。エントリーから〇〇%上昇(下落)したレートでエグジット、みたいな。richmanさんは、ここ以外にも割合に変換している箇所が多い。割合にする理由は、おそらく機械学習の精度の向上。機械学習は例えばRSIのように-1〜1の決まった範囲内で変化する特徴量を選択した方が精度が高い。生データのままだと、学習モデルが時期を勝手に予想して、その時期の相場に都合の良い予測をしてしまう、たぶん。なお、「-2*fee」は往復手数料と思われる。

7. 学習モデル作成

8. ティックデータを学習用とテスト用に分割

9. 学習用ティックデータを使ってモデルに学習させる
「6.」の損益分布にフィットする近似線を作成するイメージ

10. テスト用データで擬似的に未来予測
実運用では、未来予測がプラス収支のときだけ、トレードしていく。

11. バックテスト(ここが一番理解に苦しんだ)
実運用で偶然にポジションサイズが大きくなったりするトラブルを避けるために、トレードルールを追加。「10.」の未来予測と少し差が出るが、だいたい似たような成績になる。

12. 実運用
richmanbtcさんのチュートリアルでは省略されてた。下記noteが大変参考になりました。ありがとうございます。
https://note.com/adagami/n/n34071f8b586e

■bot開発コードの詳細

richmanbtcさんチュートリアルにコメント(#)つけながらpython基礎とコードの意味を理解。

1. 必要なライブラリ、モジュール、関数などインポート

##計算系##
import math # 数学ライブラリ
import numpy as np #統計分析の処理や数学処理などを高速に行える。多次元配列を定義可能。
import pandas as pd #データフレーム処理に特化したライブラリ。Excelのように「行」「列」を持つ表形式のデータを簡単に作成できる。
from scipy.stats import ttest_1samp #統計関数を集めたモジュール内の「ttest_1samp」関数。t検定する関数。scipy(サイパイ)

##グラフ描画系
import matplotlib.pyplot as plt #グラフ描画ライブラリ
import seaborn as sns #描画ライブラリ。matplotlibと比べて少ないコードで図が描ける。

##仮想通貨関連
import ccxt # 仮想通貨の取引所のAPIを使うためのライブラリ
from crypto_data_fetcher.gmo import GmoFetcher # ティックデータ取得用のライブラリ。利用する取引所のライブラリをインポートする
import talib #金融データを分析するためのライブラリ。200以上のインジケータを利用できる。

##処理速度向上系
import joblib # 並列処理を簡単に書ける
import numba #処理高速化ライブラリ

##学習モデル
import lightgbm as lgb # 決定木の勾配ブースティング(Gradient Boosting)
from sklearn.ensemble import BaggingRegressor #アンサンブル学習で使用するBaggingRegressorという関数。非線形な回帰分析が可能。
from sklearn.linear_model import RidgeCV #リッジ回帰のモジュール。交差検証できる
from sklearn.model_selection import cross_val_score, KFold, TimeSeriesSplit #cross_val_scoreはk分割交差検証法の結果を出力。KFoldは交差検証できる。TimeSeriesSplitは時系列データの交差検証に使用。

自分なりに見やすいように、ジャンル分け。

ライブラリとかモジュールの関係は以下。
「ライブラリ」の中に「パッケージ」があって
「パッケージ」の中に「モジュール」があって
「モジュール」の中に「クラスや関数」がある。
ライブラリごと一括インポートすればいいじゃんと思うけど、今後のコード短縮のため、必要なものだけインポートするのが常識っぽい。

2. ティックデータを取引所からインポート

memory=joblib.Memory('/tmp/gmo_fetcher_cache', verbose=0) #キャッシュ保存先。verbose=0にするとログが出力されない。
fetcher=GmoFetcher(memory=memory) #richmanさん作の関数

#GMOコインのBTC/JPYのヒストリカルデータを取得。
df=fetcher.fetch_ohlcv(
   market='BTC_JPY',
   interval_sec=15 * 60 #15分足
)

#実験に使うデータ期間を限定する
df=df[df.index<pd.to_datetime('2021-04-01 00:00:00Z')] #Z時間(zulu時間、協定世界時間UTC+0)。日本時間(JST)はUTCより9時間早い
display(df)#dfを表示
df.to_pickle('df_ohlcv.pkl')#dfを保存

richmanさん自作のcrypto_data_fetcherの詳細は後で要チェック。display(df)の実行結果は以下。15分ごとのohlcvデータが取得できてる。5列88275行のデータ。ちなみにrichmanさんは外れ値処理はしてない(https://note.com/btcml/n/ne5f730bb7c64)。

スクリーンショット 2022-02-01 15.13.53

3. トレード手数料を計算

#maker(指値注文)の手数料(約定金額*maker_fee。maker_feeの単位は%なので0.01掛け)
maker_fee_history=[#リストに辞書を3つ保管している
   {'changed_at':'2020/08/05 06:00:00Z', 'maker_fee':-0.00035},
   {'changed_at':'2020/09/09 06:00:00Z', 'maker_fee':-0.00025},
   {'changed_at':'2020/11/04 06:00:00Z', 'maker_fee':0.0},
]

#dfを保存したpickleファイルを読み込む
df=pd.read_pickle('df_ohlcv.pkl')

#初期の手数料
#dfに新しいカラム'fee'を追加。
df['fee']=0.0

#期間ごとに正しい手数料に変更する
for config in maker_fee_history:#変数configに、maker_fee_historyリスト要素である3つの辞書を1辞書ずつ代入
   df.loc[pd.to_datetime(config['changed_at']) <= df.index,'fee'] = config['maker_fee']

#新しく追加したカラム'fee'を描画する
df['fee'].plot() #描画
plt.title('maker手数料の推移') #グラフのタイトル
plt.show() #グラフを表示

display(df) #dfを表示
df.to_pickle('df_ohlcv_with_fee.pkl')#dfを保存

以下、plt.show()の実行結果。取引手数料の時間推移。マイナス手数料は、つまり取引するたびに手数料が収益になることだが、手数料目当てのトレードはなかなか約定しなかったりでそんなに上手くいかないらしい。
ちなみに取引手数料の単位はFXと違って%。「約定金額に対して◯%」みたいな形(https://coin.z.com/jp/news/2020/08/6482/)。なお、グラフ↓のラベルの文字化けは、「matplotlibの日本語文字化けを解消するwindows編」で解決できた(macでもok)。

スクリーンショット 2022-02-01 14.58.10

続いて、以下はdisplay(df)の実行結果。列feeを追加できてる。

スクリーンショット 2022-02-01 15.13.24

4. 特徴量を計算

#特徴量を計算する関数を定義する
def calc_features(df):

   #dfの特定列を変数に代入
   open=df['op']#シリーズ型
   high=df['hi']
   low=df['lo']
   close=df['cl']
   volume=df['volume']

   #今のdfの全ての列を変数に代入
   orig_columns=df.columns

   hilo=(df['hi']+df['lo'])/2 #足の中央値
   
   #インジケータの列を新規作成
   #ボリンジャーバンド
   df['BBANDS_upperband'],df['BBANDS_middleband'],df['BBANDS_lowerband']=talib.BBANDS(close,timeperiod=5,nbdevup=2,nbdevdn=2,matype=0)
   df['BBANDS_upperband'] -= hilo #hilo(足の中央値)を引いて上下片側のみの値にする。
   df['BBANDS_middleband'] -= hilo
   df['BBANDS_lowerband'] -= hilo
   df['DEMA'] = talib.DEMA(close, timeperiod=30) - hilo
   df['EMA'] = talib.EMA(close, timeperiod=30) - hilo
   df['HT_TRENDLINE'] = talib.HT_TRENDLINE(close) - hilo
   df['KAMA'] = talib.KAMA(close, timeperiod=30) - hilo
   df['MA'] = talib.MA(close, timeperiod=30, matype=0) - hilo
   df['MIDPOINT'] = talib.MIDPOINT(close, timeperiod=14) - hilo
   df['SMA'] = talib.SMA(close, timeperiod=30) - hilo
   df['T3'] = talib.T3(close, timeperiod=5, vfactor=0) - hilo
   df['TEMA'] = talib.TEMA(close, timeperiod=30) - hilo
   df['TRIMA'] = talib.TRIMA(close, timeperiod=30) - hilo
   df['WMA'] = talib.WMA(close, timeperiod=30) - hilo
   
   df['ADX'] = talib.ADX(high, low, close, timeperiod=14)
   df['ADXR'] = talib.ADXR(high, low, close, timeperiod=14)
   df['APO'] = talib.APO(close, fastperiod=12, slowperiod=26, matype=0)
   df['AROON_aroondown'], df['AROON_aroonup'] = talib.AROON(high, low, timeperiod=14)
   df['AROONOSC'] = talib.AROONOSC(high, low, timeperiod=14)
   df['BOP'] = talib.BOP(open, high, low, close)
   df['CCI'] = talib.CCI(high, low, close, timeperiod=14)
   df['DX'] = talib.DX(high, low, close, timeperiod=14)
   df['MACD_macd'], df['MACD_macdsignal'], df['MACD_macdhist'] = talib.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
   # skip MACDEXT MACDFIX たぶん同じなので
   df['MFI'] = talib.MFI(high, low, close, volume, timeperiod=14)
   df['MINUS_DI'] = talib.MINUS_DI(high, low, close, timeperiod=14)
   df['MINUS_DM'] = talib.MINUS_DM(high, low, timeperiod=14)
   df['MOM'] = talib.MOM(close, timeperiod=10)
   df['PLUS_DI'] = talib.PLUS_DI(high, low, close, timeperiod=14)
   df['PLUS_DM'] = talib.PLUS_DM(high, low, timeperiod=14)
   df['RSI'] = talib.RSI(close, timeperiod=14)
   df['STOCH_slowk'], df['STOCH_slowd'] = talib.STOCH(high, low, close, fastk_period=5, slowk_period=3, slowk_matype=0, slowd_period=3, slowd_matype=0)
   df['STOCHF_fastk'], df['STOCHF_fastd'] = talib.STOCHF(high, low, close, fastk_period=5, fastd_period=3, fastd_matype=0)
   df['STOCHRSI_fastk'], df['STOCHRSI_fastd'] = talib.STOCHRSI(close, timeperiod=14, fastk_period=5, fastd_period=3, fastd_matype=0)
   df['TRIX'] = talib.TRIX(close, timeperiod=30)
   df['ULTOSC'] = talib.ULTOSC(high, low, close, timeperiod1=7, timeperiod2=14, timeperiod3=28)
   
   df['WILLR'] = talib.WILLR(high, low, close, timeperiod=14)
   df['AD'] = talib.AD(high, low, close, volume)
   df['ADOSC'] = talib.ADOSC(high, low, close, volume, fastperiod=3, slowperiod=10)
   df['OBV'] = talib.OBV(close, volume)
   
   df['ATR'] = talib.ATR(high, low, close, timeperiod=14)
   df['NATR'] = talib.NATR(high, low, close, timeperiod=14)
   df['TRANGE'] = talib.TRANGE(high, low, close)
   
   df['HT_DCPERIOD'] = talib.HT_DCPERIOD(close)
   df['HT_DCPHASE'] = talib.HT_DCPHASE(close)
   df['HT_PHASOR_inphase'], df['HT_PHASOR_quadrature'] = talib.HT_PHASOR(close)
   df['HT_SINE_sine'], df['HT_SINE_leadsine'] = talib.HT_SINE(close)
   df['HT_TRENDMODE'] = talib.HT_TRENDMODE(close)
   
   df['BETA'] = talib.BETA(high, low, timeperiod=5)
   df['CORREL'] = talib.CORREL(high, low, timeperiod=30)
   df['LINEARREG'] = talib.LINEARREG(close, timeperiod=14) - close
   df['LINEARREG_ANGLE'] = talib.LINEARREG_ANGLE(close, timeperiod=14)
   df['LINEARREG_INTERCEPT'] = talib.LINEARREG_INTERCEPT(close, timeperiod=14) - close
   df['LINEARREG_SLOPE'] = talib.LINEARREG_SLOPE(close, timeperiod=14)
   df['STDDEV'] = talib.STDDEV(close, timeperiod=5, nbdev=1)
   
   return df
   
df=pd.read_pickle('df_ohlcv_with_fee.pkl') #df読み込み
df=df.dropna() #NAになってる行を削除
df=calc_features(df) #各特徴量を計算してdfの列に追加
display(df)
df.to_pickle('df_features.pkl')

各特徴量を計算してdfの列に追加。一部のインジケーター(トレンド系だけ?)は、hilo(足の中央値)を引いて上下片側のみの値にしている。オシレータ系インジには何もしてないように見える(あとで調べる)。
以下display(df)の実行結果。ちゃんと列数が68個に増えてる。

スクリーンショット 2022-02-01 15.34.36

5. 特徴量を選択

#学習に使う特徴量カラム
features=sorted([ #以下、文字列リストを昇順に並び替え
   'ADX',
   'ADXR',
   'APO',
   'AROON_aroondown',
   'AROON_aroonup',
   'AROONOSC',
   'CCI',
   'DX',
   'MACD_macd',
   'MACD_macdsignal',
   'MACD_macdhist',
   'MFI',
#     'MINUS_DI',
#     'MINUS_DM',
   'MOM',
#     'PLUS_DI',
#     'PLUS_DM',
   'RSI',
   'STOCH_slowk',
   'STOCH_slowd',
   'STOCHF_fastk',
#     'STOCHRSI_fastd',
   'ULTOSC',
   'WILLR',
#     'ADOSC',
#     'NATR',
   'HT_DCPERIOD',
   'HT_DCPHASE',
   'HT_PHASOR_inphase',
   'HT_PHASOR_quadrature',
   'HT_TRENDMODE',
   'BETA',
   'LINEARREG',
   'LINEARREG_ANGLE',
   'LINEARREG_INTERCEPT',
   'LINEARREG_SLOPE',
   'STDDEV',
   'BBANDS_upperband',
   'BBANDS_middleband',
   'BBANDS_lowerband',
   'DEMA',
   'EMA',
   'HT_TRENDLINE',
   'KAMA',
   'MA',
   'MIDPOINT',
   'T3',
   'TEMA',
   'TRIMA',
   'WMA',
])
print(features)

コメントアウト(#)で取捨選択して検証。ちなみにrichmanさんは130個くらいの特徴量を使ってるらしい(https://note.com/btcml/n/ne5f730bb7c64)。今の約2倍の特徴量の数?!!(あとで調べる)。

以下print(features)の実行結果。featuresは、df(データフレーム)じゃなく普通のリスト型変数なのでprintで表示。

スクリーンショット 2022-02-01 15.45.09

6. トレード損益式を作成

#注文の損益式:(エグジット価格/エントリー価格)-1-2*fee
#fep:約定価格。ForceEntryPriceの略
#fet:約定までにかかった時間。ForceEntryTimeの略

# @numba.njit:デコレータ。処理高速化。numbaを除くと遅いけど同じ動作。
# 買いの約定価格を計算する関数定義
# 足データを想定
# entry_price: numpy array。買い指値。i番目の足の最後で価格entry_price[i]で指値を出し、i + 1番目の足の最後でキャンセルする想定。
# lo: numpy array。低値
# 戻り値: y[i]は、i番目の足の最後から執行を始めた場合に、実際に約定する価格
@numba.njit
def calc_force_entry_price(entry_price=None,lo=None,pips=None):
   y= entry_price.copy() #指値価格entry_priceのコピーをyに代入。yは約定価格(fep)。
   y[:]=np.nan #yの全ての値(要素)をNaNにする
   force_entry_time=entry_price.copy()
   force_entry_time[:]=np.nan
   for i in range(entry_price.size): #指値価格entry_priceの要素数だけfor文回す。size:行列の全要素数を求める
       for j in range(i+1,entry_price.size): #1本後(i+1)以降のどの足で約定したか調べる
           if round(lo[j]/pips)<round(entry_price[j-1]/pips): #買い指値価格が1本後の足の安値より大きい=約定してる。なお、pipsで割ることで割合に変換。
               y[i]=entry_price[j-1] #約定してたら指値価格をy(約定価格fep)に代入する。
               force_entry_time[i]=j-i
               break
   return y,force_entry_time

#dfを読み込む
df=pd.read_pickle('df_features.pkl')

#呼び値(取引所、取引ペアごとに異なる)
pips=1

#ATRで指値距離を計算
limit_price_dist = df['ATR']*0.5 #ATRは一定期間のだいたいの値幅。0.5はOptunaのによるハイパーパラメータチューニングで算出。
limit_price_dist = np.maximum(1,(limit_price_dist/pips).round().fillna(1))*pips
#dist/pips.round()*pipsという式は、pipsの刻み値(最小単位)を考慮した指値距離にするための式。
#例えば、pips=0.5でdist=2.4のとき、コード通り計算すると、指値距離=dist/pips.round()*pips=2.5になる。
#一方、pipsで割ったり掛けたりしなかったら、指値距離=dist.round()=2になる。2.5か2なら、2.5で近似するのがより正確。
#最初pipsで割ることで単位pipsあたりの指値距離に変換し、整数に丸めた後で、pipsを掛けて本来の指値距離に戻すことで、pipsの刻み値(最小単位)を考慮したより正確な指値距離を算出している。
#また、maximum(1、x)は、指値距離の最小値を板の刻み値にするよ、ということ。

#終値から両側にlimit_price_distだけ離れたところに、買い指値と売り指値を出す
df['buy_price']=df['cl']-limit_price_dist
df['sell_price']=df['cl']+limit_price_dist

#買いのForce Entry Priceの計算
df['buy_fep'],df['buy_fet']=calc_force_entry_price(
   entry_price=df['buy_price'].values,
   lo=df['lo'].values,
   pips=pips
   )

#売りのForce Entry Priceの計算(買いの入力と出力をマイナスにする)
df['sell_fep'],df['sell_fet']=calc_force_entry_price(
   entry_price=-df['sell_price'].values,
   lo=-df['hi'].values, #売りは高値
   pips=pips
   )

df['sell_fep'] *= -1

horizon=1 #エントリーしてからエグジットを始めるまでの待ち時間 (1以上である必要がある)
fee=df['fee'] #指値の手数料

# 指値が1本後に約定したかどうかを判定 (0, 1) 。falseをastype('float64')すると0に、trueをastype('float64')すると1になる。「1本後の足の安値」と「指値」で比較したいので、shift(-1)を使って['lo']列の値を1行上にずらして、「指値」行と同じ行にしている。
df['buy_executed'] = ((df['buy_price'] / pips).round() > (df['lo'].shift(-1) / pips).round()).astype('float64')
df['sell_executed'] = ((df['sell_price'] / pips).round() < (df['hi'].shift(-1) / pips).round()).astype('float64')

# トレード損益y_buy,y_sellを計算(損益は、買い指値からの上昇率に往復分の手数料を引いた額)
df['y_buy']=np.where( #例 where(A,x,y)はAがtrueならx、falseならyを使う関数。買い指値が約定してたら損益を、約定してないなら0をdf['y_buy']に代入する。
   df['buy_executed'],#約定してたらtrue、してなかったらfalse
   df['sell_fep'].shift(-horizon)/df['buy_price']-1-2*fee,#エクジット価格df['sell_fep']は、horizon時間後(行列ならhorizon行下)の売りの約定価格を使用。shift(-horizon)して行を上にずらす理由は、df['y_buy']やdf['buy_price']と同じ行にするため。例)40000 / 30000 - 1 = 0.3333.... = 買い指値から33%上昇したレートで売り約定(買い決済)という意味。2*feeは往復分の手数料。
   0
)

df['y_sell'] = np.where(
   df['sell_executed'],
   -(df['buy_fep'].shift(-horizon) / df['sell_price'] - 1) - 2 * fee,
   0
)

#バックテストで利用
df['buy_cost']=np.where(
   df['buy_executed'],#約定してたらtrue、してなかったらfalse
   df['buy_price']/df['cl']-1+fee,#buy_price<cl。例)20000 / 30000 - 1 = -0.3333.... = 終値から33%下落したレートに買い指値、という意味。feeの値はマイナス(上部参照)。
   0
)

df['sell_cost']=np.where(
   df['sell_executed'],
   -(df['sell_price']/df['cl']-1)+fee,
   0
)

print('約定確率を可視化。時期によって約定確率が大きく変わると良くない')
df['buy_executed'].rolling(1000).mean().plot(label='買い') #rolling(1000)で上から順に1000個ずつデータが選択されmean()で平均が算出される。
df['sell_executed'].rolling(1000).mean().plot(label='売り')
plt.title('約定確率の推移')
plt.legend(bbox_to_anchor=(1.05,1))
plt.show()

print('エグジットまでの時間分布を可視化。長すぎるとロングしているだけとかショートしているだけになるので良くない。')#エクジットするまでの時間=約定するまでの時間
df['buy_fet'].rolling(1000).mean().plot(label='買い')
df['sell_fet'].rolling(1000).mean().plot(label='売り')
plt.title('エグジットまでの平均時間推移')
plt.legend(bbox_to_anchor=(1.2, 1))
plt.show()

#エグジットまでの時間分布(ヒストグラム)
df['buy_fet'].hist(alpha=0.3, label='買い')#引数alphaでは、ヒストグラムの棒の透明度を指定します。0(透明)~1(不透明)までの値を指定します。
df['sell_fet'].hist(alpha=0.3, label='売り')
plt.title('エグジットまでの時間分布')
plt.legend(bbox_to_anchor=(1.2, 1))
plt.show()

print('毎時刻、この執行方法でトレードした場合の累積リターン')
df['y_buy'].cumsum().plot(label='買い')
df['y_sell'].cumsum().plot(label='売り')
plt.title('累積リターン')
plt.legend(bbox_to_anchor=(1.05, 1))
plt.show()

df.to_pickle('df_y.pkl')

トレード条件はシンプルで、各足の終値から両側にlimit_price_distだけ離れたところに、買い指値と売り指値を出して、あとは約定を待つだけ。

まだ機械学習してない段階で、損益グラフが右肩上がりなのは謎。。。

スクリーンショット 2022-02-01 18.53.24

ちなみにATRをてきとうに別の特徴量に変えてみると普通に右肩下がり。おそらくトレード条件は、機械学習前の段階で、ある程度、右肩上がりである必要がありそう。あきらかに右肩下がりな手法を機械学習にかけても、未来予測も右肩下がりな可能性が高いだろうから。

また、チュートリアルでは2021年04月までのグラフだったので、それ以降の期間も表示させてみた↓。右肩上がり。すげぇ

スクリーンショット 2022-02-02 11.13.26

7. 学習モデル作成

df=pd.read_pickle('df_y.pkl')
df=df.dropna()

#モデル(コメントアウトで他モデルも試してみてください)
# model = RidgeCV(alphas=np.logspace(-7, 7, num=20))#★★★★★★★★★★★★★モデルを理解する
model = lgb.LGBMRegressor(n_jobs=-1, random_state=1)
# アンサンブル (コメントアウトを外して性能を比較してみてください)
# model = BaggingRegressor(model, random_state=1, n_jobs=1)

#本番用モデルの学習(このチュートリアルでは使わない)
#実稼働する用のモデルはデータ全体で学習させると良い
model.fit(df[features], df['y_buy'])#df[features]はデータフレーム型(行と列のラベル付き)
joblib.dump(model, 'model_y_buy.xz', compress=True) 
model.fit(df[features], df['y_sell'])
joblib.dump(model, 'model_y_sell.xz', compress=True)

あとで、紹介されてる3つのモデルすべて検証してみる。

8. ティックデータを学習用とテスト用に分割

# 通常のCV
cv_indicies = list(KFold().split(df)) #データ分割。型はlist。listの要素はカンマで区切られた配列2個(←1つのデータを前半と後半に2分割してるため)を1要素としている。分割箇所が違うデータを複数用意してるイメージ。[(array1(前半),array2(後半)),(array3(前半),array4(後半)),(array5(前半),array6(後半))...]
# ウォークフォワード法
# cv_indicies = list(TimeSeriesSplit().split(df))

9. 学習用ティックデータを使ってモデルに学習させる
10. テスト用データで擬似的に未来予測

# OOS予測値を計算する関数を定義
def my_cross_val_predict(estimator, X, y=None, cv=None):
   y_pred = y.copy()
   y_pred[:] = np.nan
   for train_idx, val_idx in cv: #cvはリスト。1つ目の要素は([前半データ1],[後半データ1])。2つ目の要素は([前半データ2],[後半データ2])みたいなイメージ。前半データをtrain_idxに、後半データをval_idxに代入していく。
       estimator.fit(X[train_idx], y[train_idx]) #モデルに学習させる。Xとyには次でndarray型のデータを代入する。インデックスを指定して要素を取り出す。
       y_pred[val_idx] = estimator.predict(X[val_idx]) #学習させたモデルにテストデータを使ってy(トレード損益)を予測させる
   return y_pred
   
# OOS予測値を計算
df['y_pred_buy'] = my_cross_val_predict(model, df[features].values, df['y_buy'].values, cv=cv_indicies) #OOS予測値を計算する関数にデータを代入してy(トレード損益)を予測。df[features].valuesとdf['y_buy'].valuesはndarray型(数値のみ取り出す)。N-dimensional array(多次元配列)の略。
df['y_pred_sell'] = my_cross_val_predict(model, df[features].values, df['y_sell'].values, cv=cv_indicies)

# 予測値が無い(nan)行をドロップ
df = df.dropna()

print('毎時刻、y_predがプラスのときだけトレードした場合の累積リターン')
df[df['y_pred_buy'] > 0]['y_buy'].cumsum().plot(label='買い')#df['y_pred_buy'] > 0がtrueの行を抽出して、かつ、'y_buy'列のみ抽出。
df[df['y_pred_sell'] > 0]['y_sell'].cumsum().plot(label='売り')
(df['y_buy'] * (df['y_pred_buy'] > 0) + df['y_sell'] * (df['y_pred_sell'] > 0)).cumsum().plot(label='買い+売り') #買い+売り
plt.title('累積リターン')
plt.legend(bbox_to_anchor=(1.05, 1))
plt.show()

df.to_pickle('df_fit.pkl')

richmanさんチュートリアルで記載されてた改良ポイント↓。

このチュートリアルで使う目的変数(y)は、 将来のリターンから計算するので、 計算に未来のデータが使われています。 特徴量の計算には過去のデータが使われています。 つまり、ある時刻のデータには前後のデータの情報が含まれています。なので、 KFoldやTimeSeriesSplitで分割すると、 テストデータに学習データの情報が混入する可能性があります。

「計算に未来のデータが使われている」ってどこが?と思ったのでメモ。おそらく、horizon秒後(つまり、未来)のdf['sell_fep']、df['buy_fep']。richmanさん案の「パージ」で解決できるんだろうけど、そもそもインポートするデータを2分割(学習、テスト)じゃなくて、3分割(学習、検証、テスト)にしてみるのはどうだろう?モデル学習までの諸々は「学習&検証データ」を使って、最終的な未来予測で「テストデータ」を使う形。

11. バックテスト(ここが一番理解に苦しんだ)

@numba.njit
def backtest(cl=None, hi=None, lo=None, pips=None,
             buy_entry=None, sell_entry=None,
             buy_cost=None, sell_cost=None
           ):
   n=cl.size #'終値'列の要素数をnに代入
   y=cl.copy()*0.0 #終値'列のコピーに0をかけたものをyに代入(つまり、全要素0)
   poss=cl.copy()*0.0
   ret=0.0#リターン(損益) ※リターンの計算は、少し複雑で、単純に買いと売りの価格差益ではなく、下記①〜③のように注文時(終値-指値の距離)、決済時(終値-決済指値の距離)、最後調整(指値足の終値-決済足の終値の距離)の3つに分けて計算して、最後に合計している。
   pos=0.0#ポジション数
   for i in range(n):
       prev_pos=pos #prev_posは保有中のポジション数。買いポジ保有中なら1、売りポジ保有中なら-1,未保有なら0
       
       #参考
       #ポジションを入れ替えてドテンするパターンはあるけど、exit後に同じ方向のポジションを再構築するパターンがない(単純なexitのみになる)。「買→決済→買」と「売→決済→売」のパターンは無いということ。
       #https://twitter.com/reo3313/status/1429970478681133057?s=20&t=01yMH3GYx9BY-DnpmJxsIg
       
       #exit
       if buy_cost[i]: #売り指値の決済。「買いの取引コスト」に値が入っているなら正。コストは手数料的な意味じゃなくてリターン(損益)
           vol=np.maximum(0,-prev_pos) #ポジションの有無確認。売りポジ保有中ならprev_pos=-1なのでvol=1
           ret-=buy_cost[i]*vol #buy_costはマイナス値なので、retはプラス値
           pos+=vol #
       
       if sell_cost[i]: #買い指値の決済。「売りの取引コスト」に値が入っているなら正。
           vol=np.maximum(0,prev_pos) #ポジションの有無確認。
           ret-=sell_cost[i]*vol #損益②。sell_cost[i]自体は上部であったように、-(df['sell_price']/df['cl']-1)+feeなので、マイナス値。なのでretに減算する→(-1)*(-1)=+1で'加算'を意味している(つまり利益)。損益②は、終値から売り指値までのレート割合+fee(手数料)。
           pos-=vol #
       
       #entry
       if buy_entry[i] and buy_cost[i]: #「買いのOOS予測値がプラス」、かつ「買いの取引コスト」に値が入っているなら正。
           vol = np.minimum(1.0, 1 - prev_pos) * buy_entry[i] #ポジ未保有ならprev_pos=0なので、vol=1*1=1(つまり買い指値だす)。買いポジ保有中ならprev_pos=1なので、vol=0*true(1)=0(つまり買い指値ださない)。売りポジ保有中ならprev_pos=-1なので、vol=1*1=1(つまり買い指値だす)。
           ret -= buy_cost[i] * vol #損益①。buy_cost[i]自体は上部であったように、df['buy_price']/df['cl']-1+feeなので、マイナス値。なのでretに減算する→(-1)*(-1)=+1で'加算'を意味している(つまり利益)。損益①は、終値から買い指値までのレート割合+fee(手数料)。
           pos += vol
       if sell_entry[i] and sell_cost[i]:
           vol = np.minimum(1.0, prev_pos + 1) * sell_entry[i] #ポジ未保有ならprev_pos=0なので、vol=1*1=1(つまり売り指値だす)。買いポジ保有中ならprev_pos=1なので、vol=1*1=1(つまり売り指値だす)。売りポジ保有中ならprev_pos=-1なので、vol=0*1=0(つまり売り指値ださない)。
           ret -= sell_cost[i] * vol
           pos -= vol
       
       if i + 1 < n:
           ret += pos * (cl[i + 1] / cl[i] - 1) #損益③。posが-1か1なら、つまりポジションを保有している限り、現足の終値と次足の終値の差分の割合を足していく。
           
       y[i] = ret #損益①+損益②+損益③の合計。
       poss[i] = pos
       
   return y, poss

df = pd.read_pickle('df_fit.pkl')            

# バックテストで累積リターンと、ポジション推移を計算
df['cum_ret'], df['poss'] = backtest(
   cl=df['cl'].values,
   buy_entry=df['y_pred_buy'].values > 0,#買いのOOS予測値がプラスならTrue、マイナスならfalseをbuy_entryに代入。buy_entryは値が[True, False, False, .....False, True, False]のような配列になる。valuesをつけて、Series型->array型に変換
   sell_entry=df['y_pred_sell'].values > 0,
   buy_cost=df['buy_cost'].values,
   sell_cost=df['sell_cost'].values,
)

df['cum_ret'].plot()
plt.title('累積リターン')
plt.show()

print('ポジション推移です。変動が細かすぎて青色一色になっていると思います。')
print('ちゃんと全ての期間でトレードが発生しているので、正常です。')
df['poss'].plot()
plt.title('ポジション推移')
plt.show()

print('ポジションの平均の推移です。どちらかに偏りすぎていないかなどを確認できます。')
df['poss'].rolling(1000).mean().plot()
plt.title('ポジション平均の推移')
plt.show()

print('取引量(ポジション差分の絶対値)の累積です。')
print('期間によらず傾きがだいたい同じなので、全ての期間でちゃんとトレードが行われていることがわかります。')
df['poss'].diff(1).abs().dropna().cumsum().plot()
plt.title('取引量の累積')
plt.show()

print('t検定')
x = df['cum_ret'].diff(1).dropna()
t, p = ttest_1samp(x, 0)
print('t値 {}'.format(t))
print('p値 {}'.format(p))

# p平均法 https://note.com/btcml/n/n0d9575882640
def calc_p_mean(x, n):
   ps = []
   for i in range(n):
       x2 = x[(i * x.size // n) : ((i + 1) * x.size // n)] #データをn分割する。//は割り算して小数点以下を除いた整数。スライス構文。例) x=[a,b,c,d,e,f]でn=3ならi=0のときx2=x[0:2]=[a,b]になる
       if np.std(x2) == 0: #標準偏差が0。ゆらぎが無いという意味だと思うので、つまり、[a,a]みたいな状況のときかな。
           ps.append(1) #appendでリストの最後に要素「1」を追加
       else:
           t, p = ttest_1samp(x2, 0)#t検定。対象となるデータx2と、帰無仮説で仮定している平均値の値0の2つ。2つの平均を比べて、有意差があるかを調べる。t値が大きいほど、有意差がある。
           if t > 0: #t値が0より大きいなら、有意差は偶然じゃなく、2つのデータに繋がりあるとみなしてp値を追加。t値が0以下なら、有意差は偶然で1(100%)を追加
               ps.append(p) #p値とは「たまたま、t値が○○よりも大きくなる確率」。pは5%以下であることが望ましいらしい(正規分布において2σの外(だいたい95%)なので有意そうだ、みたいな話)。
           else:
               ps.append(1)
   return np.mean(ps)

#エラー値を算出。エラー率は100000分の1以下が良い
def calc_p_mean_type1_error_rate(p_mean, n):
   return (p_mean * n) ** n / math.factorial(n)

x = df['cum_ret'].diff(1).dropna()
p_mean_n = 5
p_mean = calc_p_mean(x, p_mean_n)
print('p平均法 n = {}'.format(p_mean_n))
print('p平均 {}'.format(p_mean))
print('エラー率 {}'.format(calc_p_mean_type1_error_rate(p_mean, p_mean_n)))
t検定:2つの平均を比べて、有意差があるかを調べる。有意差とは、意味のある差。有意差がある=t値が大きい。

トレードでは、例えば検証データでの成績とテストデータでの成績の差に意味があるかどうか調べれる。
http://fxfxtrade.blog81.fc2.com/blog-entry-40.html?sp
p値:たまたま、t値が○○よりも大きくなる確率

p値が大きい=t値が大きいのは偶然=有意差は偶然=2つのデータに繋がりなし

p値が小さい=t値が大きいのは偶然じゃない=有意差は偶然じゃない=2つのデータに繋がりあり

P平均法(richmanbtcさんオリジナル)
1. リターン時系列をN個の期間に分割
2. 各期間でt検定してp値を計算する
3. 得られたN個のp値の平均を取る
4. p値平均を判定に使う(★p値平均は低いほうが良い)

期間を区切ることで、より精度をあげることができる。一つでも大きいpがあると、 p値平均が大きくなってしまう。p値平均が小さい=すべての期間で安定して儲かる

■大変勉強になったサイト(ありがとうございます!)

・黒枝さんnote「richmanbtcさんのチュートリアルをじっくり読んでみた

■やよいさんコメント回答用

コメントに画像を追加できないのでここに貼り付け

画像8


この記事が気に入ったらサポートをしてみませんか?