株のシステムトレードをしよう - 1から始める株自動取引システムの作り方

株式をコンピュータに売買させる仕組みを少しずつ作っていきます。できあがってから公開ではなく、書いたら途中でも記事として即掲載して、後から固定ページにして体裁を整える方式で進めていきます。

Qlibを使った機械学習パイプライン環境の構築 投資の取引戦略最適化と機械学習モデル作成の省力化を目指して

概要

本記事では、Qlibを使用して、機械学習パイプライン環境を構築する第一歩について述べる。

はじめに

このブログの趣旨としては、当初は「戦略作成」→「戦略検証」→「戦略稼働」→「成果の評価」→「戦略へフィードバック」といったサイクルを管理できるような自動トレーディングシステムを作ることを考えていた。

最近、すこし株取引から離れていたのだが、最近になってまたやり始めようかなと思い、色々と現在の状況を調べはじめた。

その中で、MicrosoftのリポジトリにQlibというものがあるのを見つけた。これが2020年の8月から作られたもので、現在でもメンテされており、もしかするとこれがやりたいことにかなり近いことができるのではないかと感じた。

github.com

本記事では、そもそもQlibをどういう目的で使用できるのかを調査することを第一目標として、READMEにしたがってモデルの訓練・テストを自動で行い、その結果を図示するところをハンズオンとして実施している。

なお、Qlibについては論文になっているので、興味がある人は参考にしてほしい。

arxiv.org

PDF: https://arxiv.org/pdf/2009.11189.pdf

Qlibの試用

動作条件

早速手引きに沿って動かしてみる。ただ普通になぞっても面白くないので、一部パラメータを変更してやることにする。

また、Docker環境+Pipenvを用意してその中で必要なパッケージ等はインストール済みの状態にする。

使用したrequirements.txt

当初はPipenvでやろうとしていましたが、色々と想定通りに動かなかったのと解決に時間がかかりそうだったので、素直に Python 3.8 を直接macに入れて、 pip install -r requirements.txt で直接パッケージを入れることにした。

requirements.txtを表示する

matplotlib
seaborn
catboost
xgboost
numpy==1.23.5
japanize_matplotlib
pyqlib
qlib
torch
nbformat>=4.2.0

データの取得

公式の手順書と一部異なり、ホームディレクトリ以下ではなくプロジェクトルート以下にデータを置くようにした。 また、カレントディレクトリの ./qlib_originalhttps://github.com/microsoft/qlib をクローンしたディレクトリを配置した。

python -m qlib.run.get_data qlib_data --target_dir ./qlib_data/cn_data --region cn

予測の実施

データの保存場所を変えたため、予め、データのパスを変更しておく。

qlib_init:
    # provider_uri: "~/.qlib/qlib_data/cn_data"
    provider_uri: "./qlib_data/cn_data"

YAMLファイル全体

Original: https://github.com/microsoft/qlib/blob/main/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml

qlib_init:
    # provider_uri: "~/.qlib/qlib_data/cn_data"
    provider_uri: "./qlib_data/cn_data"
    region: cn
market: &market csi300
benchmark: &benchmark SH000300
data_handler_config: &data_handler_config
    start_time: 2008-01-01
    end_time: 2020-08-01
    fit_start_time: 2008-01-01
    fit_end_time: 2014-12-31
    instruments: *market
port_analysis_config: &port_analysis_config
    strategy:
        class: TopkDropoutStrategy
        module_path: qlib.contrib.strategy
        kwargs:
            model: <MODEL> 
            dataset: <DATASET>
            topk: 50
            n_drop: 5
    backtest:
        start_time: 2017-01-01
        end_time: 2020-08-01
        account: 100000000
        benchmark: *benchmark
        exchange_kwargs:
            limit_threshold: 0.095
            deal_price: close
            open_cost: 0.0005
            close_cost: 0.0015
            min_cost: 5
task:
    model:
        class: LGBModel
        module_path: qlib.contrib.model.gbdt
        kwargs:
            loss: mse
            colsample_bytree: 0.8879
            learning_rate: 0.2
            subsample: 0.8789
            lambda_l1: 205.6999
            lambda_l2: 580.9768
            max_depth: 8
            num_leaves: 210
            num_threads: 20
    dataset:
        class: DatasetH
        module_path: qlib.data.dataset
        kwargs:
            handler:
                class: Alpha158
                module_path: qlib.contrib.data.handler
                kwargs: *data_handler_config
            segments:
                train: [2008-01-01, 2014-12-31]
                valid: [2015-01-01, 2016-12-31]
                test: [2017-01-01, 2020-08-01]
    record: 
        - class: SignalRecord
          module_path: qlib.workflow.record_temp
          kwargs: 
            model: <MODEL>
            dataset: <DATASET>
        - class: SigAnaRecord
          module_path: qlib.workflow.record_temp
          kwargs: 
            ana_long_short: False
            ann_scaler: 252
        - class: PortAnaRecord
          module_path: qlib.workflow.record_temp
          kwargs: 
            config: *port_analysis_config

予測は下記のコマンドで実施できる。

qrun ./qlib_original/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml 

その実行結果が以下。

Dockerで動かした時の出力を全て見る

$ qrun ./examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml 
$ qrun qlib_original/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml 
[15744:MainThread](2023-05-28 19:22:40,109) INFO - qlib.Initialization - [config.py:416] - default_conf: client.
[15744:MainThread](2023-05-28 19:22:40,114) INFO - qlib.Initialization - [__init__.py:74] - qlib successfully initialized based on client settings.
[15744:MainThread](2023-05-28 19:22:40,114) INFO - qlib.Initialization - [__init__.py:76] - data_path={'__DEFAULT_FREQ': PosixPath('/workspaces/qlib_trading/qlib_data/cn_data')}
[15744:MainThread](2023-05-28 19:22:40,146) WARNING - qlib.workflow - [expm.py:230] - No valid experiment found. Create a new experiment with name workflow.
[15744:MainThread](2023-05-28 19:22:40,173) INFO - qlib.workflow - [exp.py:258] - Experiment 1 starts running ...
[15744:MainThread](2023-05-28 19:22:40,382) INFO - qlib.workflow - [recorder.py:341] - Recorder 5aed6c0fc2d44d2ca39de90bf232e5f5 starts running under Experiment 1 ...
Killed
/home/vscode/.local/share/virtualenvs/qlib_trading-k3iKtIfp/lib/python3.8/site-packages/joblib/externals/loky/backend/resource_tracker.py:310: UserWarning: resource_tracker: There appear to be 1 leaked folder objects to clean up at shutdown
  warnings.warn(
/home/vscode/.local/share/virtualenvs/qlib_trading-k3iKtIfp/lib/python3.8/site-packages/joblib/externals/loky/backend/resource_tracker.py:326: UserWarning: resource_tracker: /tmp/joblib_memmapping_folder_15744_014189611f674ea3af7965863fcdc02f_19ca69f69ecf414b98fc15c95140d315: FileNotFoundError(2, 'No such file or directory')
  warnings.warn(f'resource_tracker: {name}: {e!r}')

一時ファイルの削除に失敗したのか?

Docker内で動かすのをやめて、macネイティブで実行した場合はうまくいった。

ネイティブで動かした時の出力を全て見る

$ qrun qlib_original/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml 
[31202:MainThread](2023-05-28 21:41:23,258) INFO - qlib.Initialization - [config.py:416] - default_conf: client.
[31202:MainThread](2023-05-28 21:41:23,261) INFO - qlib.Initialization - [__init__.py:74] - qlib successfully initialized based on client settings.
[31202:MainThread](2023-05-28 21:41:23,262) INFO - qlib.Initialization - [__init__.py:76] - data_path={'__DEFAULT_FREQ': PosixPath('/Users/user/projects/github/qlib_trading/qlib_data/cn_data')}
[31202:MainThread](2023-05-28 21:41:23,264) WARNING - qlib.workflow - [expm.py:230] - No valid experiment found. Create a new experiment with name workflow.
[31202:MainThread](2023-05-28 21:41:23,267) INFO - qlib.workflow - [exp.py:258] - Experiment 1 starts running ...
[31202:MainThread](2023-05-28 21:41:23,361) INFO - qlib.workflow - [recorder.py:341] - Recorder e46d7eb676da4bba92bfa9bb8b368ade starts running under Experiment 1 ...
[31202:MainThread](2023-05-28 21:42:30,807) INFO - qlib.timer - [log.py:128] - Time cost: 53.933s | Loading data Done
[31202:MainThread](2023-05-28 21:42:33,888) INFO - qlib.timer - [log.py:128] - Time cost: 0.676s | DropnaLabel Done
/Users/user/.pyenv/versions/3.8.16/lib/python3.8/site-packages/qlib/data/dataset/processor.py:322: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  df[cols] = df[cols].groupby("datetime", group_keys=False).apply(self.zscore_func)
[31202:MainThread](2023-05-28 21:42:39,297) INFO - qlib.timer - [log.py:128] - Time cost: 5.408s | CSZScoreNorm Done
[31202:MainThread](2023-05-28 21:42:39,351) INFO - qlib.timer - [log.py:128] - Time cost: 8.543s | fit & process data Done
[31202:MainThread](2023-05-28 21:42:39,352) INFO - qlib.timer - [log.py:128] - Time cost: 62.478s | Init data Done
Training until validation scores don't improve for 50 rounds
[20]    train's l2: 0.981377    valid's l2: 0.993566
[40]    train's l2: 0.973811    valid's l2: 0.993977
[60]    train's l2: 0.967152    valid's l2: 0.994648
Early stopping, best iteration is:
[18]    train's l2: 0.982182    valid's l2: 0.993497
[31202:MainThread](2023-05-28 21:42:56,334) INFO - qlib.workflow - [record_temp.py:196] - Signal record 'pred.pkl' has been saved as the artifact of the Experiment 1
'The following are prediction results of the LGBModel model.'
                          score
datetime   instrument          
2017-01-03 SH600000   -0.027139
           SH600008    0.007009
           SH600009    0.009770
           SH600010    0.012275
           SH600015   -0.112909
{'IC': 0.04680587323833807,
 'ICIR': 0.3815683918932706,
 'Rank IC': 0.049049290457489736,
 'Rank ICIR': 0.406748756941287}
[31202:MainThread](2023-05-28 21:42:57,993) INFO - qlib.backtest caller - [__init__.py:93] - Create new exchange
[31202:MainThread](2023-05-28 21:43:10,722) WARNING - qlib.online operator - [exchange.py:219] - $close field data contains nan.
[31202:MainThread](2023-05-28 21:43:10,724) WARNING - qlib.online operator - [exchange.py:219] - $close field data contains nan.
[31202:MainThread](2023-05-28 21:43:10,735) WARNING - qlib.online operator - [exchange.py:226] - factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.
[31202:MainThread](2023-05-28 21:43:10,736) WARNING - qlib.online operator - [exchange.py:228] - trade unit 100 is not supported in adjusted_price mode.
[31202:MainThread](2023-05-28 21:43:19,935) WARNING - qlib.data - [data.py:666] - load calendar error: freq=day, future=True; return current calendar!
[31202:MainThread](2023-05-28 21:43:19,936) WARNING - qlib.data - [data.py:669] - You can get future calendar by referring to the following document: https://github.com/microsoft/qlib/blob/main/scripts/data_collector/contrib/README.md
[31202:MainThread](2023-05-28 21:43:19,965) WARNING - qlib.BaseExecutor - [executor.py:121] - `common_infra` is not set for <qlib.backtest.executor.SimulatorExecutor object at 0x12fac2dc0>
backtest loop:   0%|                                                                                                                                       | 0/871 [00:00<?, ?it/s]/Users/user/.pyenv/versions/3.8.16/lib/python3.8/site-packages/qlib/utils/index_data.py:482: RuntimeWarning: Mean of empty slice
  return np.nanmean(self.data)
backtest loop:  65%|████████████████████████████████████████████████████████████████████████████████▊                                            | 563/871 [00:08<00:04, 64.38it/s]/Users/user/.pyenv/versions/3.8.16/lib/python3.8/site-packages/qlib/utils/index_data.py:482: RuntimeWarning: Mean of empty slice
  return np.nanmean(self.data)
/Users/user/.pyenv/versions/3.8.16/lib/python3.8/site-packages/qlib/utils/index_data.py:482: RuntimeWarning: Mean of empty slice
  return np.nanmean(self.data)
backtest loop: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 871/871 [00:14<00:00, 61.96it/s]
[31202:MainThread](2023-05-28 21:43:34,948) INFO - qlib.workflow - [record_temp.py:505] - Portfolio analysis record 'port_analysis_1day.pkl' has been saved as the artifact of the Experiment 1
'The following are analysis results of benchmark return(1day).'
                       risk
mean               0.000477
std                0.012295
annualized_return  0.113561
information_ratio  0.598699
max_drawdown      -0.370479
'The following are analysis results of the excess return without cost(1day).'
                       risk
mean               0.000530
std                0.005718
annualized_return  0.126029
information_ratio  1.428574
max_drawdown      -0.072310
'The following are analysis results of the excess return with cost(1day).'
                       risk
mean               0.000339
std                0.005717
annualized_return  0.080654
information_ratio  0.914486
max_drawdown      -0.086083
[31202:MainThread](2023-05-28 21:43:34,964) INFO - qlib.workflow - [record_temp.py:530] - Indicator analysis record 'indicator_analysis_1day.pkl' has been saved as the artifact of the Experiment 1
'The following are analysis results of indicators(1day).'
     value
ffr    1.0
pa     0.0
pos    0.0
[31202:MainThread](2023-05-28 21:43:34,979) INFO - qlib.timer - [log.py:128] - Time cost: 0.012s | waiting `async_log` Done

出力

次に1つずつ出力を見ていく。まずこれ。「1日のベンチマークリターンの解析結果」とある。

'The following are analysis results of benchmark return(1day).'
                       risk
mean               0.000477
std                0.012295
annualized_return  0.113561
information_ratio  0.598699
max_drawdown      -0.370479

2つ目。これは「コストを考慮しない1日の超過収益の解析結果」になる。

'The following are analysis results of the excess return without cost(1day).'
                       risk
mean               0.000530
std                0.005718
annualized_return  0.126029
information_ratio  1.428574
max_drawdown      -0.072310

3つ目。これは「コストを考慮した1日の超過収益の解析結果」である。

'The following are analysis results of the excess return with cost(1day).'
                       risk
mean               0.000339
std                0.005717
annualized_return  0.080654
information_ratio  0.914486
max_drawdown      -0.086083

最後。これは「1日のインジケータの解析結果」とある。

'The following are analysis results of indicators(1day).'
     value
ffr    1.0
pa     0.0
pos    0.0

図示

ソースコード

これだけ見てもさっぱりわからないのでグラフにする。 examples/workflow_by_code.ipynb に視覚化するJupyterNotebook が置いてあるので、それを一部修正して使用する。

# examples/workflow_by_code.ipynb
#  Original: Copyright (c) Microsoft Corporation., Modified by dogwood008
#  Licensed under the MIT License.

import sys, site
from pathlib import Path

################################# NOTE #################################
#  Please be aware that if colab installs the latest numpy and pyqlib  #
#  in this cell, users should RESTART the runtime in order to run the  #
#  following cells successfully.                                       #
########################################################################

try:
    import qlib
except ImportError:
    # install qlib
    ! pip install --upgrade numpy
    ! pip install pyqlib
    if "google.colab" in sys.modules:
        # The Google colab environment is a little outdated. We have to downgrade the pyyaml to make it compatible with other packages
        ! pip install pyyaml==5.4.1
    # reload
    site.main()

scripts_dir = Path.cwd().parent.joinpath("scripts")
if not scripts_dir.joinpath("get_data.py").exists():
    # download get_data.py script
    scripts_dir = Path("~/tmp/qlib_code/scripts").expanduser().resolve()
    scripts_dir.mkdir(parents=True, exist_ok=True)
    import requests

    with requests.get("https://raw.githubusercontent.com/microsoft/qlib/main/scripts/get_data.py", timeout=10) as resp:
        with open(scripts_dir.joinpath("get_data.py"), "wb") as fp:
            fp.write(resp.content)


import qlib
import pandas as pd
from qlib.constant import REG_CN
from qlib.utils import exists_qlib_data, init_instance_by_config
from qlib.workflow import R
from qlib.workflow.record_temp import SignalRecord, PortAnaRecord
from qlib.utils import flatten_dict


# use default data
# NOTE: need to download data from remote: python scripts/get_data.py qlib_data_cn --target_dir ~/.qlib/qlib_data/cn_data
provider_uri = "./qlib_data/cn_data"  # target_dir  <---- [MODIFIED] ≪ここを実際にデータが置かれたパスに変更≫
if not exists_qlib_data(provider_uri):
    print(f"Qlib data is not found in {provider_uri}")
    sys.path.append(str(scripts_dir))
    from get_data import GetData

    GetData().qlib_data(target_dir=provider_uri, region=REG_CN)
qlib.init(provider_uri=provider_uri, region=REG_CN)


market = "csi300"
benchmark = "SH000300"


# [MODIFIED] この次にある2つのブロックをスキップ


from qlib.contrib.report import analysis_model, analysis_position
from qlib.data import D

ba_rid = 'e200c6a4771a44bc8d6cf21fc44d643c'  # <--- [MODIFIED] ここは mlruns/1/e200c6a4771a44bc8d6cf21fc44d643c のように、 qrun した際に出力されたディレクトリの下にあるUUIDっぽいものを指定
recorder = R.get_recorder(recorder_id=ba_rid, experiment_name="backtest_analysis")
print(recorder)
pred_df = recorder.load_object("pred.pkl")
report_normal_df = recorder.load_object("portfolio_analysis/report_normal_1day.pkl")
positions = recorder.load_object("portfolio_analysis/positions_normal_1day.pkl")
analysis_df = recorder.load_object("portfolio_analysis/port_analysis_1day.pkl")

バックテストでのポートフォリオ分析

この状態で、 analysis_position.report_graph(report_normal_df) を 実行すると数のような折れ線グラフを得られる。

レポート

それぞれの凡例はここにもあるが、日本語でも書いておく。(誤訳あれば教えてください)

  • cum bench
    • ベンチマークの累積リターン系列
    • Cumulative returns series of benchmark
  • cum return wo cost
    • ポートフォリオの累積リターン系列(コスト含まず)
    • Cumulative returns series of portfolio without cost
  • cum return w cost
    • ポートフォリオの累積リターン系列(コスト含む)
    • Cumulative returns series of portfolio with cost
  • return wo mdd
    • 累積リターンの最大ドローダウン系列(コスト含まず)
    • Maximum drawdown series of cumulative return without cost
  • return w cost mdd
    • 累積リターンの最大ドローダウン系列(コスト含む)
    • Maximum drawdown series of cumulative return with cost
  • cum ex return wo cost
    • ベンチマークと比較したポートフォリオの累積異常リターン系列(コスト含まず)
    • The CAR (cumulative abnormal return) series of the portfolio compared to the benchmark without cost.
  • cum ex return w cost
    • ベンチマークと比較したポートフォリオの累積異常リターン系列(コスト含む)
    • The CAR (cumulative abnormal return) series of the portfolio compared to the benchmark with cost.
  • turnover
    • 売買回転率系列
    • Turnover rate series
  • cum ex return wo cost mdd
    • 累積異常リターンのドローダウン系列(コスト含まず)
    • Drawdown series of CAR (cumulative abnormal return) without cost
  • cum ex return w cost mdd
    • 累積異常リターンのドローダウン系列(コスト含む)
    • Drawdown series of CAR (cumulative abnormal return) with cost

リスク分析、分析モデル

他にも、次のような図を得られる。先ずはリスク分析結果。

analysis_position.risk_analysis_graph(analysis_df, report_normal_df)

リスク分析(比較)

リスク分析(年率リターン)

リスク分析(最大ドローダウン)

リスク分析(インフォメーションレシオ・情報レシオ)

リスク分析(標準偏差)

続いて、分析モデル。

# [ADDED] FROM
import yaml
# read yaml and convert it into dict
path_to_config_yaml = './qlib_original/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml'
with open(path_to_config_yaml, 'r') as stream:
    try:
        config = yaml.safe_load(stream)
    except yaml.YAMLError as exc:
        print(exc)
# [ADDED] UNTIL

dataset = init_instance_by_config(task["task"]["dataset"])  #  <--- [MODIFIED]
label_df = dataset.prepare("test", col_set="label")
label_df.columns = ["label"]
pred_label = pd.concat([label_df, pred_df], axis=1, sort=True).reindex(label_df.index)
analysis_position.score_ic_graph(pred_label)

情報係数

analysis_model.model_performance_graph(pred_label)

グループ毎の累積リターン

リターンの分布

情報係数

月毎の情報係数

情報係数の分布

予測シグナルの自己相関

おわりに

本記事では、Qlibを使ってYAMLで設定したデータセットやアルゴリズム等にしたがって自動的にモデルを訓練・テストする方法を説明した。また、それによって出力される結果をグラフによって図示する方法を紹介した。

多くの図や数値を結果として受け取ることはできたものの、それらをどう読み解くかが重要である。次回以降ではそれぞれの示す意味を理解して説明できるようになるところまでを1つの記事にしたい。

(C) 2020 dogwood008 禁無断転載 不許複製 Reprinting, reproducing are prohibited.