FXシストレパラメータの最適化を遺伝的アルゴリズムでやってみる

最適化のランダムサーチでやってみる

の続きです。ランダムサーチの代わりに遺伝的アルゴリズム(GA)を実装してみます。

準備

In [48]:
import numpy as np
import pandas as pd
import indicators as ind #indicators.pyのインポート
from backtest import Backtest,BacktestReport
dataM1 = pd.read_csv('DAT_ASCII_GBPUSD_M1_2016.csv', sep=';',
                     names=('Time','Open','High','Low','Close', ''),
                     index_col='Time', parse_dates=True)
dataM1.index += pd.offsets.Hour(7) #7時間のオフセット
ohlc = ind.TF_ohlc(dataM1, 'H') #1時間足データの作成

最適化するトレードシステム

今回は最適化するパラメータの値の組み合わせを増やすために、2本の移動平均の交差システムに決済用のシグナルを追加。決済用のシグナルは、以下のように定義。

買いポジションの決済:終値が決済用移動平均を下抜けたとき
売りポジションの決済:終値が決済用移動平均を上抜けたとき


このシステムでは、パラメータは3個。今回はそれぞれのパラメータを以下のような範囲で探索することにします。

In [49]:
SlowMAperiod = np.arange(7, 151) #長期移動平均期間の範囲
FastMAperiod = np.arange(5, 131) #短期移動平均期間の範囲
ExitMAperiod = np.arange(3, 111) #決済用移動平均期間の範囲

メインルーチン

遺伝的アルゴリズムのメインルーチンは、前回のランダムサーチとほぼ同じです。

パラメータをランダムに探すところを、後述の遺伝的処理に置き換えるだけです。

また売買シグナルは上記のルールのように決済用のシグナルを追加しています。

もう一つ、前回からの変更点は、SlowMAperiod, FastMAperiod, ExitMAperiodの三つのパラメータの範囲をPrangeというリストにまとめて各関数に渡すようにした点です。

こうすることで、パラメータの数が増えてもそのまま対応することができます。

In [32]:
def Optimize(ohlc, Prange):
    def shift(x, n=1): return np.concatenate((np.zeros(n), x[:-n])) #シフト関数

    SlowMA = np.empty([len(Prange[0]), len(ohlc)]) #長期移動平均
    for i in range(len(Prange[0])):
        SlowMA[i] = ind.iMA(ohlc, Prange[0][i])

    FastMA = np.empty([len(Prange[1]), len(ohlc)]) #短期移動平均
    for i in range(len(Prange[1])):
        FastMA[i] = ind.iMA(ohlc, Prange[1][i])

    ExitMA = np.empty([len(Prange[2]), len(ohlc)]) #決済用移動平均
    for i in range(len(Prange[2])):
        ExitMA[i] = ind.iMA(ohlc, Prange[2][i])

    Close = ohlc['Close'].values #終値

    M = 20 #個体数
    Eval = np.zeros([M, 6])  #評価項目
    Param = InitParam(Prange, M) #パラメータ初期化
    gens = 0 #世代数
    while gens < 100:
        for k in range(M):
            i0 = Param[k,0]
            i1 = Param[k,1]
            i2 = Param[k,2]
            #買いエントリーシグナル
            BuyEntry = (FastMA[i1] > SlowMA[i0]) & (shift(FastMA[i1]) <= shift(SlowMA[i0]))
            #売りエントリーシグナル
            SellEntry = (FastMA[i1] < SlowMA[i0]) & (shift(FastMA[i1]) >= shift(SlowMA[i0]))
            #買いエグジットシグナル
            BuyExit = (Close < ExitMA[i2]) & (shift(Close) >= shift(ExitMA[i2]))
            #売りエグジットシグナル
            SellExit = (Close > ExitMA[i2]) & (shift(Close) <= shift(ExitMA[i2]))
            #バックテスト
            Trade, PL = Backtest(ohlc, BuyEntry, SellEntry, BuyExit, SellExit) 
            Eval[k] = BacktestReport(Trade, PL)
        # 世代の交代
        Param = Evolution(Param, Eval[:,0], Prange)
        gens += 1
        print(gens, Eval[0,0])
    Slow = Prange[0][Param[:,0]]
    Fast = Prange[1][Param[:,1]]
    Exit = Prange[2][Param[:,2]]
    return pd.DataFrame({'Slow':Slow, 'Fast':Fast, 'Exit':Exit, 'Profit': Eval[:,0], 'Trades':Eval[:,1],
                         'Average':Eval[:,2],'PF':Eval[:,3], 'MDD':Eval[:,4], 'RF':Eval[:,5]},
                         columns=['Slow','Fast','Exit','Profit','Trades','Average','PF','MDD','RF'])

遺伝的処理

上の関数で、GA用に追加した関数は、InitParam()とEvolution()です。まず、InitParam()は、各個体のパラメータの初期化です。

In [33]:
from numpy.random import randint,choice

#パラメータ初期化
def InitParam(Prange, M):
    Param = randint(len(Prange[0]), size=M)
    for i in range(1,len(Prange)):
        Param = np.vstack((Param, randint(len(Prange[i]), size=M)))
    return Param.T

Evolution()は、次のようにいくつかの遺伝的処理を含みます。

In [34]:
#遺伝的処理
def Evolution(Param, Eval, Prange):
    #エリート保存付きルーレット選択
    Param = Param[np.argsort(Eval)[::-1]] #ソート
    R = Eval-min(Eval)
    R = R/sum(R)
    idx = choice(len(Eval), size=len(Eval), replace=True, p=R)
    idx[0] = 0 #エリート保存
    Param = Param[idx]
    
    #1点交叉
def Evolution(Param, Eval, Prange):
    N = 10
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    for i in range(0,N,2):
        ix = idx[i:i+2]
        p = randint(1,len(Prange))
        Param[ix] = np.hstack((Param[ix][:,:p], Param[ix][:,p:][::-1]))
    #近傍生成
def Evolution(Param, Eval, Prange):
    N = 10
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    diff = choice([-1,1], size=N).reshape(N,1)
    for i in range(N):
        p = randint(len(Prange))
        Param[idx[i]][p:p+1] = (Param[idx[i]][p]+diff[i]+len(Prange[p]))%len(Prange[p])
    #突然変異 
def Evolution(Param, Eval, Prange): 
    N = 2
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    for i in range(N):
        p = randint(len(Prange))
        Param[idx[i]][p:p+1] = randint(len(Prange[p]))
    return Param

それぞれの処理について説明します。

エリート保存付きルーレット選択

現在の個体の中から次世代に残す個体を選択します。

選択の方法はいくつかあるのですが、Numpyの関数でルーレット選択に便利な関数があったので、それを使ってみます。

ルーレット選択はバックテストの評価値である適応度の大きさに応じて、確率的に次世代に残す個体を選択する方法です。

適応度が高いほど残りやすくなります。

今回使った関数は、numpy.random.choice()という関数で、リストから必要な個数分ランダムに選択するものですが、オプションの引数にpという選択確率のリストを付けると、その確率に合わせて選択してくれます。

これはルーレット選択そのものです。次のようなコードになります。

In [6]:
def Evolution(Param, Eval, Prange):
    #エリート保存付きルーレット選択
    Param = Param[np.argsort(Eval)[::-1]] #ソート
    R = Eval-min(Eval)
    R = R/sum(R)
    idx = choice(len(Eval), size=len(Eval), replace=True, p=R)
    idx[0] = 0 #エリート保存
    Param = Param[idx]

ただ、確率がマイナスだと都合が悪いので、適応度の最小値が0になるように補正してあります。

またルーレット選択だけだと、適応度が高くても運悪く選ばれないこともあるので、適応度をソートして最も高い個体(エリート)は必ず次世代に残るようにしています。

1点交叉

次に遺伝子の交叉を行います。これは、二つの個体を選んで、遺伝子情報の一部を互いに交換することです。

交叉の方法もいくつかありますが、ここではパラメータの並びの1か所を選んで、その前後を交換する方法にしました。

In [7]:
 def Evolution(Param, Eval, Prange):
    N = 10
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    for i in range(0,N,2):
        ix = idx[i:i+2]
        p = randint(1,len(Prange))
        Param[ix] = np.hstack((Param[ix][:,:p], Param[ix][:,p:][::-1]))

ここでもchoice()を使って交叉する個体の個数分の乱数列を生成します。replace=Falseを付けることで、重複のない乱数列が得られます。

そして、2個ずつの個体をixとして選択して、交叉点pの後半のデータを入れ換えることで交叉を実現しています。

近傍生成

通常のGAでは、選択、交叉、突然変異で進化のシミュレーションを行うのですが、今回のように交叉点をパラメータの切れ目のところに限定していると、いつの間にか同じ個体だらけになってしまい、進化が止まってしまいます。

かといって、突然変異を多くすると、ランダムサーチに近くなってしまうので、あまり効率がよくありません。

そこで、今回は、パラメータの一部を+1、あるいは-1する変化を施します。

いわゆる近傍解というやつで、局所探索アルゴリズムでよく利用されるものです。

In [8]:
def Evolution(Param, Eval, Prange):
    N = 10
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    diff = choice([-1,1], size=N).reshape(N,1)
    for i in range(N):
        p = randint(len(Prange))
        Param[idx[i]][p:p+1] = (Param[idx[i]][p]+diff[i]+len(Prange[p]))%len(Prange[p])

交叉と同じく近傍を生成する個体を選択します。

そして、どのパラメータを変化させるかをまた乱数で決めて、そのパラメータを1だけ変化させます。

突然変異

最後に突然変異を実行します。

これもいくつかの方法がありますが、選択した個体のパラメータの一部を新しく乱数で書き換えます。

GAの場合、局所解から抜け出すには、突然変異が重要なのですが、これを多用するとランダム性が高くなってしまうので、ここでは、2個程度にしておきます。

In [9]:
 def Evolution(Param, Eval, Prange): 
    N = 2
    idx = choice(np.arange(1,len(Param)), size=N, replace=False)
    for i in range(N):
        p = randint(len(Prange))
        Param[idx[i]][p:p+1] = randint(len(Prange[p]))

実行結果

以上のように定義した関数を使って遺伝的アルゴリズムを実行してみます。

In [35]:
result = Optimize(ohlc, [SlowMAperiod, FastMAperiod, ExitMAperiod])
result.sort_values('Profit', ascending=False)
1 2245.0
2 2245.0
3 2245.0
4 2245.0
5 2245.0
6 2245.0
7 2245.0
8 2245.0
9 2245.0
10 2245.0
11 2245.0
12 2245.0
13 2245.0
14 2245.0
15 2245.0
16 2245.0
17 2245.0
18 2245.0
19 2245.0
20 2245.0
21 2245.0
22 2245.0
23 2245.0
24 2245.0
25 2245.0
26 2245.0
27 2245.0
28 2245.0
29 2245.0
30 2245.0
31 2245.0
32 2245.0
33 2245.0
34 2245.0
35 2245.0
36 2245.0
37 2245.0
38 2245.0
39 2245.0
40 2245.0
41 2245.0
42 2245.0
43 2245.0
44 2245.0
45 2245.0
46 2245.0
47 2245.0
48 2245.0
49 2245.0
50 2245.0
51 2245.0
52 2245.0
53 2245.0
54 2245.0
55 2245.0
56 2245.0
57 2245.0
58 2245.0
59 2245.0
60 2245.0
61 2245.0
62 2245.0
63 2245.0
64 2245.0
65 2245.0
66 2245.0
67 2245.0
68 2245.0
69 2245.0
70 2245.0
71 2245.0
72 2245.0
73 2245.0
74 2245.0
75 2245.0
76 2245.0
77 2245.0
78 2245.0
79 2245.0
80 2245.0
81 2245.0
82 2245.0
83 2245.0
84 2245.0
85 2245.0
86 2245.0
87 2245.0
88 2245.0
89 2245.0
90 2245.0
91 2245.0
92 2245.0
93 2245.0
94 2245.0
95 2245.0
96 2245.0
97 2245.0
98 2245.0
99 2245.0
100 2245.0
Out[35]:
Slow Fast Exit Profit Trades Average PF MDD RF
10 81 85 56 2308.1 110.0 20.982727 1.684307 936.8 2.463813
0 29 116 92 2245.0 59.0 38.050847 2.420706 455.5 4.928650
6 17 10 14 1767.9 384.0 4.603906 1.261458 644.2 2.744334
4 114 57 27 1582.7 52.0 30.436538 2.821708 184.2 8.592291
7 69 50 109 1119.4 79.0 14.169620 1.448154 508.5 2.201377
2 74 68 82 977.3 103.0 9.488350 1.291967 739.2 1.322105
17 72 61 51 751.2 98.0 7.665306 1.299533 648.0 1.159259
9 83 63 94 724.5 74.0 9.790541 1.244178 702.2 1.031757
12 128 81 16 669.5 52.0 12.875000 1.626228 488.2 1.371364
15 119 94 33 626.6 60.0 10.443333 1.447061 618.8 1.012605
16 117 69 76 355.0 51.0 6.960784 1.161364 595.6 0.596038
13 17 59 19 350.4 116.0 3.020690 1.092293 882.1 0.397234
18 104 42 32 310.4 59.0 5.261017 1.194535 499.6 0.621297
1 94 49 5 -220.3 67.0 -3.288060 0.795886 378.6 -0.581881
3 76 129 4 -270.9 52.0 -5.209615 0.656871 497.4 -0.544632
19 43 127 8 -323.8 66.0 -4.906061 0.770826 641.7 -0.504597
8 10 109 37 -783.5 82.0 -9.554878 0.728234 1574.1 -0.497745
5 85 115 52 -1171.1 57.0 -20.545614 0.607040 1883.1 -0.621900
11 30 28 55 -1190.4 212.0 -5.615094 0.846034 1390.2 -0.856280
14 8 33 24 -1651.3 230.0 -7.179565 0.801727 2490.1 -0.663146

GAも乱数を使っているので、結果は毎回異なります。

以下は結果の一例で、世代毎に最も高い適応度を示したものです。

エリート保存しているので、高い適応度は順次更新されます。

この問題の最適解は調べていないのでわかりませんが、この結果は、まあまあ高い値だと思います。

そもそもGAの目的は、最適解を求めることではなく、総当たりするより短い時間で準最適解を求めることです。

実際、シストレのパラメータで最適解を求めたところで、そのシステムが別の期間でも同じ結果を出すわけではありませんね。

そういうことなので、200万通りの組み合わせの中から2000回の試行でまあまあの解が得られたのであれば、よしとするべきでしょうね。

遺伝的アルゴリズムは、たくさんの個体を評価してその適応度により選択、交叉などの遺伝的処理を行うのですが、それぞれの個体の評価は完全に独立しているので、並列処理に適しています。ですが今回は並列処理をする前の時間を測っときました。。。

Corei7の8スレッドだと11秒だそうです。(私は、Corei5なので下記の結果になりました。)

※並列処理については、時間があれば取り組みたいと思います。

In [36]:
import time

start = time.perf_counter()
result = Optimize(ohlc, [SlowMAperiod, FastMAperiod, ExitMAperiod])
print("elapsed_time = {0} sec".format(time.perf_counter()-start))
1 397.6
2 397.6
3 397.6
4 397.6
5 397.6
6 397.6
7 397.6
8 397.6
9 397.6
10 397.6
11 397.6
12 397.6
13 397.6
14 397.6
15 397.6
16 397.6
17 397.6
18 397.6
19 397.6
20 397.6
21 397.6
22 397.6
23 397.6
24 397.6
25 397.6
26 397.6
27 397.6
28 397.6
29 397.6
30 397.6
31 397.6
32 397.6
33 397.6
34 397.6
35 397.6
36 397.6
37 397.6
38 397.6
39 397.6
40 397.6
41 397.6
42 397.6
43 397.6
44 397.6
45 397.6
46 397.6
47 397.6
48 397.6
49 397.6
50 397.6
51 397.6
52 397.6
53 397.6
54 397.6
55 397.6
56 397.6
57 397.6
58 397.6
59 397.6
60 397.6
61 397.6
62 397.6
63 397.6
64 397.6
65 397.6
66 397.6
67 397.6
68 397.6
69 397.6
70 397.6
71 397.6
72 397.6
73 397.6
74 397.6
75 397.6
76 397.6
77 397.6
78 397.6
79 397.6
80 397.6
81 397.6
82 397.6
83 397.6
84 397.6
85 397.6
86 397.6
87 397.6
88 397.6
89 397.6
90 397.6
91 397.6
92 397.6
93 397.6
94 397.6
95 397.6
96 397.6
97 397.6
98 397.6
99 397.6
100 397.6
elapsed_time = 31.083594000000176 sec