時系列基盤モデルを用いた投資戦略
免責事項
本記事は投資の勧誘や特定の証券投資を推奨するものではなく、本記事の内容に基づいて生じた損害について一切の責任を負いかねます。
要するに、「投資は自己責任でお願いいたします」ということです。
はじめに
こんにちは。リサーチエンジニアの吉岡です。
研究者の方と一緒に研究を進めたり、プロトタイプを作ったり、クラウド周りのアレコレをやるお仕事をしています。時系列基盤モデルについては特に専門とかそういうのではありません。
わたしゃ老後が不安で仕方ないよ
最近、老後2000万円問題[1]や新NISA、iDeCoなどの制度拡充の影響もあり、投資をしている方や投資に興味を持つ方が増えてきたように感じます。将来のための資産運用となるとインデックスETFをドルコスト平均法で積立するのが合理的だなと個人的には思うのですが、その一方でただ決まった頻度で決まった金額を買い続けるのはつまらないなと思うようになりました。せっかくなので最新の技術を利用してインデックスをアウトパフォームする投資戦略を作ってみたいと思うようになりました。
以前にもビットコインの値動きをLightGBMで予測してがっぽり儲けて億り人になってやると思い色々と画策していたのですがこれまた非常に難しいです。術式は開示することによって効果が増しますが、投資戦略というのは秘匿することによって価値が出るものです[2]。そのため、インターネットを探しても絶対に儲かる戦略などというものは落ちていません。そういったものは自分で見つけるものなのです。工夫のポイントは、特徴量の生成、目的関数の設計、執行戦略の工夫、モデルの組み合わせなど、多岐に渡ります。しかし、そのどれもが一朝一夕でなんとかなる類のものではなく、1日1万回の感謝のバックテスト[3]によって獲得できるものなのです。
わたしゃ楽して儲けたいよ
さて、話は変わりますが、最近のディープラーニングを利用したモデルの発展は著しいです。画像や動画、自然言語の生成などがその最たる例でしょう。そんな中、GoogleのTimeFMなど、時系列データのモデリングにも大規模モデルを利用しようという研究が進んでいます。
基盤モデルの利点とは何でしょうか。多くあると思いますが、一つあげるとすれば特徴量を作成する負荷が著しく低減する点が挙げられるのではないでしょうか。特徴量の作成は非常に骨の折れる作業です。ドメイン知識や統計学の知識を駆使し、ターゲットに対する相関を見て、実際に学習を行いクロスバリデーションを実施し、予測における重要度などのファクターを見る。これをひたすら繰り返します。非常に泥臭い作業です。もし特徴量の作成に労力をかけることなく、ほとんど生に近いデータを入力するだけでそこそこの性能が出てくれたら非常に嬉しいのではないでしょうか。そこで本記事では、時系列基盤モデルを利用して投資戦略を構築してみようと思います。
投資戦略
投資戦略を作る上で個人的に大事だと思っていることを書いていきます。全てを対応するのは大変なので今回はやらないことも多いですがとりあえず書いておきます。
予測ターゲットの設定
まず実験を始めるにあたっては何を予測するかを決めることが重要です。こういった金融時系列の予測タスクを解いて実際の取引を行おうとする場合においては価格そのものを予測する価値はあまりないように思います。なぜなら我々が知りたいのは絶対値の意味での株価ではなく、儲かるかどうかなのです。儲けることを第一に考えれば極論2値分類で問題ないです。つまり入力データの最新時刻から見た時にある特定の売買時刻での株価は上がっているのか下がっているのかを予測します。ある時刻から見てとある時刻の株価が下がっていると分かれば証券をショート(ETFであればベア型を購入)し、その時刻でポジションを解消すればいいのです。逆も然りです。さらに言えば価格が上がるか下がるかではなくても問題ないと思います。要は利益が出ればいいので、利益と関係のある何かしらをモデルから出力し、その結果を元にトレードを行えば良いだけです。
売買対象
どのように売買するのかについても検討が必要です。1つのETFについてロング・ショートを繰り返すのか、それとも複数の株式のロング・ショートを組み合わせてポートフォリオを構築するのか、やり方は多岐に渡ります。コールやプットなどのオプションを加えるやり方もあるでしょうし、信用取引でレバかける方法もあるでしょう。債券や金やREITを混ぜる人もいるかもしれません。ただしそれぞれの証券にはそれぞれの特性があります。何も知らずに色々と手を出すと管理コストがかかるのでそこは注意が必要です。
取引頻度
投資ホライズンの検討も重要なファクターになります。短期売買(デイリーからウィークリー)で取引を繰り返すのか、それとも月1回や四半期に1回の取引で行くのか、それぞれメリット・デメリットがあります。短期の取引を行えば試行回数を稼ぐことができるので戦略の良さを発揮する機会が増えますが取引のたびに手数料がかかるので毎回の売買である程度の値幅が取れないと手数料負けしてしまいます。
ベンチマーク設定
ベンチマークの設定も重要です。例え利益が出る戦略を構築できたとしてもS&P500連動型ETFを買って放置する戦略に負けてしまうのでは意味がありません。黙ってS&P500連動型ETF買っておけって話になります。逆にS&P500連動型ETFに対してトータルリターンで勝っていたとしてもリスクが大きくなっているのであればただ博打して勝ってるだけなのかもしれません。
執行戦略
仮に利益と相関の高いシグナルをモデルから出力できたとしても、そのシグナルに応じて取引を行い約定させなければなりません。その際には、どういうオーダーで、どれだけの数量を、いくらで売り買いするかというロジックを組まなければなりません。指値でオーダー出しても約定しないこともありますし、成行でオーダーを出せば思っているよりも高い(低い)価格で約定してしまうこともあります。その辺をどうコントロールするかもシステムトレードのキモです。
実験
投資を行う対象
本実験は日経225に連動するETFの取引を想定します。S&P500などの米国の代表的な銘柄に関しては正直なところ買って放置で十分なのではと思っている一方で、日経225連動ETFはレンジ相場になりやすいという直感があります。買って放置すれば儲かる銘柄は予測する価値は低いですが、レンジ相場になりやすい銘柄は買って放置するだけでは儲からないため投資の腕が重要になるはずです。
ではなぜそんなレンジ相場になりやすい銘柄を買わなければならないのか、という点ですが、S&P500などの米国株式のみに連動する証券を持つだけではカントリーリスクをテイクしていることになるからです。米国が調子がいいのであれば問題ないですが、米国の経済がこれからも右肩上がりであるかどうかは誰も知りませんし、保証することもできません。リスクを低減した投資運用を考えればできれば多様な国の証券や多種多様な資産クラスを織り交ぜてポートフォリオを構築することでシャープレシオ(リスクに対するリターン)を向上させたいと思うので日本株のポジションも入れておきたいという気持ちがあります。(なら全世界ETFやマルチアセットの投資信託を買えばいいじゃん、というツッコミもあるかと思いますがそれだと面白くないじゃないですか)
実験環境
今回は下記の環境で行います。
- Ubuntu 22.04 LTS
- CPU: 12 Core
- RAM: 40GB
- GPU: NVIDIA L4 (VRAM 24GB) 1枚
- CUDA 12.4
モデルの読み込み
今回は時系列基盤モデルとしてAmazon Chronosのchronos-bolt-baseを利用します。まず初めに必要なパッケージをインストールします。
pip install chronos-forecasting
モデルは下記のように読み込みます。
import pandas as pd # requires: pip install pandas
import torch
from chronos import BaseChronosPipeline
pipeline = BaseChronosPipeline.from_pretrained(
"amazon/chronos-bolt-base",
device_map="cuda",
torch_dtype=torch.bfloat16,
)
データの読み込み
J-Quants APIを用いて取得した日経225に連動するETFを取引対象として利用してみます。
- 対象銘柄コード:1346(MAXIS 日経225上場投信)
- 期間:2年間(無料プランの上限)
from datetime import datetime
from dateutil import tz
import jquantsapi
import os
jquants_token = os.environ['JQUANTS_API_TOKEN']
cli = jquantsapi.Client(refresh_token=jquants_token)
df = cli.get_prices_daily_quotes(
code = "1346",
from_yyyymmdd = "20220927",
to_yyyymmdd = "20240927",
)
df.set_index("Date", inplace=True)
予測
とりあえずモデルへの入力は250日分の価格の終値を差分を取ったものとし、1日先の終値の変化率を予測するというようにしてみます。これでパフォーマンスが出るなら苦労はしないですが、ものは試しにやってみます。
# Context size of Amazon Chronos
max_context_length = 2048
# Context size of input data
context_length = 250
total_rows = len(df)
preds = []
pred_index = []
pred_quantiles_low = []
pred_quantiles_mid = []
pred_quantiles_high = []
start_index = 1
df.loc[:, "ClosePctChange"]=df["Close"].pct_change(1)
while start_index + context_length < total_rows:
context = df.iloc[start_index:start_index + context_length]
quantiles, mean = pipeline.predict_quantiles(
context=torch.tensor(context["ClosePctChange"].values),
prediction_length=1,
quantile_levels=[0.1, 0.5, 0.9],
)
preds.append(mean.numpy()[0, 0])
pred_index.append(df.index[start_index + context_length])
pred_quantiles_low.append(quantiles.numpy()[0, 0, 0])
pred_quantiles_mid.append(quantiles.numpy()[0, 0, 1])
pred_quantiles_high.append(quantiles.numpy()[0, 0, 2])
start_index += 1
df_pred = pd.DataFrame({
"Date": pred_index,
"PredClosePctChange": preds,
"PredQuantileLowClosePctChange": pred_quantiles_low,
"PredQuantileMidClosePctChange": pred_quantiles_mid,
"PredQuantileHighClosePctChange": pred_quantiles_high
}).set_index("Date")
df = pd.merge(df, df_pred, on="Date", how="inner")
実際に予測結果を見てみましょう。この時点でもうかなり不穏な感じになっているのがわかりますね。
実現値と予測値の統計情報を比較してみましょう。
df.describe()[["ClosePctChange", "PredClosePctChange"]]
結果はこのようになりました。平均値はそれなりに同じになっているようですが、最大値、最小値、標準偏差が実現値に対して予測値がかなり小さくなってます。モデルが当たり障りのない値(平均的にはあってそうな値)を出力しているように見えます(まあそもそも論を言えば1日の上がり下がりなんてのはかなりランダム性の高い値なのでそれを予測しようというのが無理な営みではあります)。
ClosePctChange PredClosePctChange
count 242.000000 242.000000
mean 0.001059 0.001064
std 0.016713 0.000956
min -0.132182 -0.008401
25% -0.006148 0.000663
50% 0.000752 0.001130
75% 0.008569 0.001495
max 0.100404 0.004580
予測した値を元に売買のシグナルを生成してみます。例えば以下のような戦略を試してみましょう。
- シグナル: 終値の変化率
- 売買戦略:
- シグナルが正の値の時はロング
- シグナルが負の値の時はショート
- ただしある閾値未満の値幅しか期待できない場合は取引を行わない
- ベンチマーク:
- バイアンドホールド
終値をモデルに入力して結果を見てその日のうちに取引を成立させるのは現実的ではありませんが、とりあえずこれでやってみます。閾値には標準偏差を入れています。大きな変化が予測される時だけ取引を行いたいというお気持ちです。
import numpy as np
def generate_signal(df: pd.DataFrame, target: str, threshold: float) -> np.ndarray:
return np.where(df[target] >= threshold, 1, np.where(df[target] <= -threshold, -1, 0))
df["Signal"] = generate_signal(df, target="PredClosePctChange", threshold=0.000956)
バックテストはbacktestingを使ってやります。手数料は簡単のためにゼロとします。
pip install git+https://github.com/kernc/backtesting.py.git
backtesting.pyを使ってバックテストを行ってみます。
from backtesting import Backtest, Strategy
class BuyAndHold(Strategy):
def init(self):
self.buy()
def next(self):
pass
class AmazonChronos(Strategy):
def init(self):
pass
def next(self):
if self.data["Signal"] == 1 and not self.position.is_long:
self.position.close()
self.buy()
elif self.data["Signal"] == -1 and not self.position.is_short:
self.position.close()
self.sell()
else:
pass
まずはバイアンドホールドの結果をみます。
bt = Backtest(df, BuyAndHold, cash=1000000, commission=.0)
stats = bt.run()
stats
結果はこのようになりました。
Start 2023-10-03 00:00:00
End 2024-09-27 00:00:00
Duration 360 days 00:00:00
Exposure Time [%] 99.586777
Equity Final [$] 1267840.0
Equity Peak [$] 1372620.0
Return [%] 27.495424
Buy & Hold Return [%] 27.048675
Return (Ann.) [%] 28.781619
Volatility (Ann.) [%] 34.060942
Sharpe Ratio 0.845004
Sortino Ratio 1.51427
Calmar Ratio 1.082748
Max. Drawdown [%] -26.582011
Avg. Drawdown [%] -3.429017
Max. Drawdown Duration 103 days 00:00:00
Avg. Drawdown Duration 20 days 00:00:00
# Trades 1
Win Rate [%] 100.0
Best Trade [%] 27.067669
Worst Trade [%] 27.067669
Avg. Trade [%] 27.067669
Max. Trade Duration 359 days 00:00:00
Avg. Trade Duration 359 days 00:00:00
Profit Factor NaN
Expectancy [%] 27.067669
SQN NaN
Kelly Criterion NaN
_strategy BuyAndHold
_equity_curve E...
_trades Size EntryBa...
dtype: object
次にAmazonChronosで作った戦略の結果を見てみます。
bt = Backtest(df, AmazonChronos, cash=1000000, commission=.0)
stats = bt.run()
stats
結果はこのようになりました。
Start 2023-10-03 00:00:00
End 2024-09-27 00:00:00
Duration 360 days 00:00:00
Exposure Time [%] 98.347107
Equity Final [$] 1181340.0
Equity Peak [$] 1341100.0
Return [%] 18.134
Buy & Hold Return [%] 27.048675
Return (Ann.) [%] 18.950318
Volatility (Ann.) [%] 31.106717
Sharpe Ratio 0.609203
Sortino Ratio 0.995923
Calmar Ratio 0.719747
Max. Drawdown [%] -26.329133
Avg. Drawdown [%] -3.594177
Max. Drawdown Duration 103 days 00:00:00
Avg. Drawdown Duration 21 days 00:00:00
# Trades 5
Win Rate [%] 40.0
Best Trade [%] 12.044199
Worst Trade [%] -2.549575
Avg. Trade [%] 3.425115
Max. Trade Duration 281 days 00:00:00
Avg. Trade Duration 71 days 00:00:00
Profit Factor 4.829572
Expectancy [%] 3.622388
SQN 1.11283
Kelly Criterion 0.273802
_strategy AmazonChronos
_equity_curve E...
_trades Size EntryBa...
dtype: object
考察
いくつかの項目を表にしてまとめてみました。今回の戦略はリターンに関してはバイアンドホールドに負けていますが、ボラティリティについてはバイアンドホールドより抑えることができています。しかし最大ドローダウンに関してはほんのちょっとだけ良くなっているだけで平均ドローダウンはむしろ悪化しています。またシャープレシオも低くなっています。端的にいうと今回作った戦略を使うよりはバイアンドホールドする方がリスクリターンがいいと思いますね。
BuyAndHold | AmazonChronos | |
---|---|---|
Return [%] | 27.495424 | 18.134 |
Volatility (Ann.) [%] | 34.060942 | 31.106717 |
Sharpe Ratio | 0.845004 | 0.609203 |
Max. Drawdown [%] | -26.582011 | -26.329133 |
Avg. Drawdown [%] | -3.429017 | -3.594177 |
今後に向けて
まず何より予測する対象を見直すことが先決だと思います。AmazonChronosの出力する予測値は平均的にあってそうな値にとどまっており、実際の値動きをほとんど予測できていないように見えます。そもそも高い分散のあるデータを予測すること自体が厳しいことなので、予測する対象を事前に移動平均をとっておき、その変化率などを予測対象とすることで予測対象の分散を抑えることができるかもしれません。
また今回は大きな値動きが予測される時だけ取引を行うような戦略を構築しましたが、実際には小さな値動きでじわじわと下げていく局面も十分に起こり得ます。そういった状況への対応が今回のシグナル生成の仕方では対応できていないので、何らかの手を打つ必要がありそうです。
また今回はファインチューニングをすることなくモデルを利用しましたがそれもモデルが保守的な値を返すことの一因かもしれません(しかしある程度大きいモデルのファインチューニングは骨も折れるし金も時間もかかるのであまりやりたくありません)。いつか時間があればやってみたいと思います。
何より儲かる戦略は簡単に作れないということですね。これからも感謝のバックテストを繰り返し1ティック[4]を置き去りにできるように精進していきたいと思います。
Discussion