joda!!

jodaによるプログラミング芸

richmanbtc さんの 機械学習チュートリアル を理解していく ①目的変数編

はじめに

richmanbtc さんの 機械学習チュートリアルを理解する上でのメモ書きです。

qiita.com

今回は、目的変数の計算 の部分について理解を深めていこうと思います。

目次

概要

ざっくり申し上げますと、
ロウソク足更新の際に、現在レートからATR/2はなれたところに指値注文を入れた時、
累積リターンがどのようなものになるか、という内容です。
累積リターンのほかに、ポジションを保有する時間、
約定確率なども求めてらっしゃいます。

コード

@numba.njit
def calc_force_entry_price(entry_price=None, lo=None, pips=None):
    y = entry_price.copy()
    y[:] = np.nan
    force_entry_time = entry_price.copy()
    force_entry_time[:] = np.nan
    for i in range(entry_price.size):
        for j in range(i + 1, entry_price.size):
            if round(lo[j] / pips) < round(entry_price[j - 1] / pips):
                y[i] = entry_price[j - 1]
                force_entry_time[i] = j - i
                break
    return y, force_entry_time

df = pd.read_pickle('/content/drive/My Drive/Colab Notebooks/deliverables/df_features.pkl')

# 呼び値 (取引所、取引ペアごとに異なるので、適切に設定してください)
pips = 0.1

# ATRで指値距離を計算します
limit_price_dist = df['ATR'] * 0.5
limit_price_dist = np.maximum(1, (limit_price_dist / pips).round().fillna(1)) * pips

# 終値から両側に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,
)

# calc_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'] # maker手数料

# 指値が約定したかどうか (0, 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を計算
df['y_buy'] = np.where(
    df['buy_executed'],
    df['sell_fep'].shift(-horizon) / df['buy_price'] - 1 - 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'],
    df['buy_price'] / df['cl'] - 1 + 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='買い')
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='買い')
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('/content/drive/My Drive/Colab Notebooks/deliverables/df_y.pkl')

ちょっとずつ理解していきます

呼び値 ATRの計算

# 呼び値 (取引所、取引ペアごとに異なるので、適切に設定してください)
pips = 0.1

# ATRで指値距離を計算します
limit_price_dist = df['ATR'] * 0.5
limit_price_dist = np.maximum(1, (limit_price_dist / pips).round().fillna(1)) * pips

ここでいう呼び値とは、価格の刻み幅のことです。
最小の変動レートとも言えます。

ATRはざっくりと申し上げますと、ロウソク足の大きさの平均です。
ボラティリティの予測に利用できますが、直近のロウソク足に依存しており、
突発的な変動には変動が遅れます。

コードを見ていきましょう。
まずはpipsという変数に取引所や通貨ペアごとの呼び値を入れていきます。

そして、limit_price_dist = df['ATR'] * 0.5により、
ATRの半分の値が格納されたpandas.Seriesを作成。

limit_price_dist = np.maximum(1, (limit_price_dist / pips).round().fillna(1)) * pips  

は、
まず(limit_price_dist / pips).round().fillna(1)
limit_price_distをpipsで除し、丸めます。そして、欠損値には1を代入します。
その後、np.maximumで、1と値を比較し、大きい方を代入します。
最後に、最低変動幅のpipsをかけたら終了です。
出来上がったシリーズは、
注文を入れるレートから、どれだけ離れた位置に指値を入れるかを、
呼び値を考慮して格納してあります。

指値が約定したかどうかのカラムを作成

# 終値から両側に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,
)

# calc_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

ここでは、コード先頭の、

@numba.njit
def calc_force_entry_price(entry_price=None, lo=None, pips=None):
    y = entry_price.copy()
    y[:] = np.nan
    force_entry_time = entry_price.copy()
    force_entry_time[:] = np.nan
    for i in range(entry_price.size):
        for j in range(i + 1, entry_price.size):
            if round(lo[j] / pips) < round(entry_price[j - 1] / pips):
                y[i] = entry_price[j - 1]
                force_entry_time[i] = j - i
                break
    return y, force_entry_time

を利用して、指値がヒットしたかどうかを示すカラムを作っていきます。
まずは買い部分です。

チュートリアルをすすめていきますと、現在のdfはこのようになっているはずです。
f:id:jodawithforce:20211211124153p:plain
ここで、y[i]に入る値として、1行ずつ着目してみていきます。

まずbuy_priceに着目します。
i行目の buy_price は i行目の終値の時点で出す指値の値になります。
この指値が約定するには、 i+1 行目の安値が、指値価格を下回っている必要があります。
i+1 行目で約定することができた場合、
約定した指値は、i行目 buy_price になります。
これを約定するまで繰り返します。

最終的にどうなるかといいますと
i行目に買うというシグナルが出た時、
y[i]には、
i行目で指値をいれ、約定するまで指値を更新し続けた時、
どの価格で約定したか
という値が格納されます。
また、force_entry_time[i]には、j本目のろうそく足、
すなわち、
i行目で指値をいれ、約定するまで指値を更新し続けた時、
i+j行目に約定している、というような値が入ります。

次に、sell_priceついてです。
これが非常にテクニカルで好きなのですが、
(エンジニア界隈では当たり前なのかもしれません)
入力と出力にマイナスをかけるだけで売りように転用することができます。
やっていることは買いのときと全く同じなので、割愛いたします。

損益(割合)を算出する

まずはシンプルに変数を定義して行きます。

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

次に、
その行の価格データで、指値が約定しているかどうか
というカラムを作っていきます。
コードに記載のとおりです。
i行目の指値価格を次の行の高値や安値がオーバーしているか、
という感じのコードになっております。

df.shift()だけわかれば問題ないですね。
pandasでデータを行・列(縦・横)方向にずらすshift | note.nkmk.me

# 指値が約定したかどうか (0, 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" │ 求めたい値
"buy_executed"指値が約定したかどうか(0 or 1)
"sell_fep" │ 決済される価格
"buy_price" │ 買指値を入れる価格

次にnp.where()についてです。
以下に記載されております。
ざっくりいうと
配列に第1引数の条件式を当てはめ、
tureなら第2引数の値を、
falseなら第3引数の値を入れる、
3項演算子のようなメソッドとなっております。

それではコードを見ていきましょう!

# yを計算
df['y_buy'] = np.where(
    df['buy_executed'],
    df['sell_fep'].shift(-horizon) / df['buy_price'] - 1 - 2 * fee,
    0
)
df['y_sell'] = np.where(
    df['sell_executed'],
    -(df['buy_fep'].shift(-horizon) / df['sell_price'] - 1) - 2 * fee,
    0
)

df["y_buy"]について見ていきましょう。

np.whereの条件式は、df['buy_executed']。
すなわち、買いが行われたかどうかの判定です。
df['buy_executed']は、0 か 1 の値しか取りません。
1の時がTrue、 0のときが Falseとして判定されます。

買いが行われたときは、
df['sell_fep'].shift(-horizon) / df['buy_price'] - 1 - 2 * fee
がyに入ります。
この式を紐解いていきましょう。
df['sell_fep'].shift(-horizon)は、
買ってから決済が行われない期間を反映しています。
horizonのぶんだけdf["sell_fep"]を上にずらすことで、
決済が行われない行の、次の行のdf["sell_fep"]を参照することができます。
これをdf'buy_price'で割ります。

すると、
決済された価格/指値約定価格
というのが求められます。
そこから1を引くことで、騰落率を算出しております。

- 2 * fee は単純に、
手数料を2回(エントリーとエグジット)損益から引くということです。
ここで気をつけなければならないのが、feeの単位です。
df['sell_fep'].shift(-horizon) / df['buy_price'] - 1 は割合となりますので、
feeが百分率であると、手数料が100倍になり、このように、、、
f:id:jodawithforce:20211212005902p:plain
焦ります。

取引のリスクを算出

次に、df['buy_cost']と、df['sell_cost']です。
こちらも先程と同様に、np.where()を利用しています。
条件式自体は同様です。
しかし、指値約定時の値が異なります。

# バックテストで利用する取引コストを計算  
df['buy_cost'] = np.where(
    df['buy_executed'],
    df['buy_price'] / df['cl'] - 1 + fee,
    0
)
df['sell_cost'] = np.where(
    df['sell_executed'],
    -(df['sell_price'] / df['cl'] - 1) + fee,
    0
)

買いについて見ていきましょう。
df['buy_price'] / df['cl'] - 1 + fee
となっています。
こちらは、含み損について表しています。
指値約定してから、終値までは下落しているということになりますので、
df['buy_price'] / df['cl'] - 1
という割合の含み損が発生しております。
また、
df['buy_price'] > df['cl'] なので、
df['buy_price'] / df['cl'] - 1 は含み損を正の値で表しています。

実際には、含み損 + 手数料が真の含み損になりますので、
feeに関しても、足し算をします。

売りについても同様です。

視覚化

こちらの部分については、そのままです。

print('約定確率を可視化。時期によって約定確率が大きく変わると良くない。')
df['buy_executed'].rolling(1000).mean().plot(label='買い')
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='買い')
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をいじる部分ですね。
この短いコードでも、自分だけでは絶対に思いつかない細かいテクニックが散見されます。