バックテスト時のパラメータの保持と文字表現に関わる工夫

みなさん、はじめまして！
兼業botterの8oki（やおき）です。


今年の [仮想通貨botter Advent Calendar 2021](https://qiita.com/advent-calendar/2021/botter) では、バックテスト時に工夫している点を紹介します。


# なぜ工夫しようと思ったか
バックテストにおいて、設定やパラメータの過剰最適化はあまりよろしくないのですが、
傾向を探るために過剰でなくても、複数の設定を試すことはよくやると思います。

例えば、短期と長期の移動平均のクロスでドテンするストラテジーを試すとして、
取引対象の銘柄・時間軸では順張り傾向が強いか、などということを外観として知りたいとします。
すると、以下のような形で集計することになるのですよね。

|短期移動平均  |長期移動平均 | 総損益
|:---:|:---:|:---:|
|5  |25  |-200  |
|5 |50  |-100  |
|5 |75  |50  |
|10 |25  |100  |
|10 |50  |200  |
|10 |75  |170  |

バックテストの出力について上記のように集計だけならいいのですが、

- 細かく取引履歴を保存しておきたい
- 損益グラフを保存しておきたい
 
 
ということもあると、行ごとに、
つまりは **パラメータの組合せごとに名前を決めて** ファイル名として使用する必要があります。
> ファイルではなくDBであっても一つの検証IDとして名前を定める必要があるでしょう

例えば、次のように対応づけたくなるということです。

|短期移動平均  |長期移動平均 | 検証名称
|:---:|:---:|:---:|
|5  |25  |MACROSS_shortperiod-5_longperiod-25  |
|5 |50  |MACROSS_shortperiod-5_longperiod-50 |
|5 |75  |MACROSS_shortperiod-5_longperiod-75 |

取引履歴のファイル名を`MACROSS_shortperiod-5_longperiod-25_history.csv`にしたり、
損益グラフのファイル名を`MACROSS_shortperiod-5_longperiod-25_graph.png`
という感じです。

こういった **複数の設定の文字表現**をうまいこと自動生成する仕組みを作ってあげないと、
**設定名が変わるごとに**、**設定項目が増えるごとに**、設定を司るコード部分の改修が必要になってしまいます。

この辺に面倒臭さを感じて、一つ便利そうなクラスを作ってみました。



# パラメータの保持と文字表現を実現するクラス

* BaseConfigクラス
* 
```python
class BaseConfig():
    """バックテストの設定を保持して見やすい文字列で表現するクラス

    * 【使い方】
    * 当該クラスを継承する個別の設定クラスを用意
    * 設定をメンバ変数として定義
    * 必要に応じてフォーマットを指定
    * __repr__で文字列により設定と値の関係を連ねた文字列で取得

    """

    def __init__(
            self,
            name,
            sep_valiable='_',
            sep_value='-',
            ignore_list=[
                'name',
                'sep_valiable',
                'sep_value',
                'ignore_list',
                'init_counter'],
            init_counter=2):
        """コンストラクタ

        Args:
            name (str): 設定名（ストラテジー名）
            sep_valiable (str, optional): 設定と設定の間の区切り文字. Defaults to '_'.
            sep_value (str, optional): 設定と設定値の間の区切り文字. Defaults to '-'.
            ignore_list (list, optional): メンバ変数の中で文字列表現の対象から除外する変数名のリスト. Defaults to [ 'name', 'sep_valiable', 'sep_value', 'ignore_list', 'init_counter'].
            init_counter (int, optional): 文字列表現を生成する中で重複した場合の初期カウンター. Defaults to 2.
        """
        self.name = name
        self.sep_valiable = sep_valiable
        self.sep_value = sep_value
        self.ignore_list = ignore_list
        self.init_counter = init_counter

    def __repr__(self):
        class_str = self.name
        valiables = self.__dict__
        multiple_cnt = dict()
        for key, value in valiables.items():
            if self.should_ignore(key):
                continue

            # 変数が存在するので区切り文字を追加
            class_str += self.sep_valiable

            key_elements = key.split(self.sep_valiable)
            key_str = ''
            for elements in key_elements:
                # 先頭の文字を追加
                key_str += elements[0]

            # keyの重複を確認
            key_cnt = multiple_cnt.get(key_str)
            if key_cnt is None:
                # keyが存在しない場合
                multiple_cnt[key_str] = self.init_counter
            else:
                # keyが存在した場合 -> カウントアップ
                multiple_cnt[key_str] = key_cnt + 1
                key_str += str(key_cnt)

            # 生成したkeyを追記
            class_str += key_str

            # 変数文字列を生成したあとに値を追加
            class_str += self.sep_value
            key_format = valiables.get(key + '_format')
            if key_format is None:
                class_str += str(value)
            else:
                class_str += key_format.format(value)

        return class_str

    def __str__(self):
        return self.__repr__()

    def should_ignore(self, key):
        if key in self.ignore_list:
            return True
        elif '_format' in key:
            return True
        else:
            return False

    def to_string(self):
        return self.__str__()

    def to_csv(self, sep=','):
        csv_str = self.name

        valiables = self.__dict__
        for key, value in valiables.items():
            if self.should_ignore(key):
                continue

            csv_str += sep
            csv_str += str(value)

        return csv_str

    def to_header(self, sep=',', add_name=True):
        header_str = ''
        if add_name:
            header_str += self.name

        valiables = self.__dict__
        for key, value in valiables.items():
            if self.should_ignore(key):
                continue

            header_str += sep
            header_str += key

        return header_str
```

# 使い方のサンプル
BaseConfigを継承した個別の設定クラスを用意します。
設定をメンバ変数として定義し、必要に応じてフォーマットを指定します。

```Python
class SampleConfig(BaseConfig):
    def __init__(self):
        super().__init__('MACROSS')
        self.long_period = 105
        self.long_period_format = '{:03d}'
        self.short_period = 9
        self.short_period_format = '{:03d}'
        self.large_performance = 10
        self.large_performance_format = '{:03d}'
```

このクラスを実体化して文字列として出力すると

```python
config = SampleConfig()
print(config)
```

次のようになります。

```bash
MACROSS_lp-105_sp-009_lp2-010
```

様式は、次のような構成にしていて

```
[ストラテジー名]_[設定1]-[設定1の値]_[設定2]-[設定2の値]・・・
```

設定項目が増えるとOSのファイル名やファイルパスの上限にひっかかることもあるので、
設定項目の変数名をアンダースコアで区切り、先頭文字だけつなげて**省略設定名**として出力しています。

また、省略することで重複する場合があるため、**名前を分けるための数字を後付け**してあげるようにもしています。
設定をクラスとして記述することで、エディタの補完機能を使って**シンボルを間違えないとういメリット**もあるんですよね。
dictで設定を記述すると'long_perod'とかって打ち間違えても、実行時にしか気が付かないのでね・・・。

こうした背景からも、**設定のコード化とそれを一意に特定する仕組み**には一定の便利さがあるなと感じています。

# 実際のサンプル
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2309136/1f0a1860-030d-dc9f-d65a-913019839770.png)


# まとめ
botterの皆さんは、表には出ていないけれど小さい工夫を積み重ねていると思うんですよね。
エンジニアリングは、「うっわっ。ダッるー。」みたいなことを、完全でなくても少しずつ効率化して、昨日の自分より今日の自分の方が効率的、という環境を作り出していくものなので、引き続き何かネタを提供できるようにしていきたいと思います。
他の人の工夫も是非是非学んでいきたいので、この記事がきっかけになればと思います。
うっわっ。slackでアラート飛んでる。それでは今回はこの辺で！