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

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

戦略:前日比x%以下で買い、前日比y%以上で売り その8 一旦完成

A Stock Chart
Photo by energepic.com from Pexels

昨日の記事は下記の通り。

how-to-make-stock-trading-system.dogwood008.com

その後色々試して、やっと完成した。次に詳しく原因を記す。

原因

Cheat on Open の適用と next_open() の利用はセットで行うべき

www.backtrader.com

上記の記事でも述べられているが、

cerebro = bt.Cerebro(cheat_on_open=True)

を有効にした場合、 next_open() メソッドが有効になる。今回の戦略では、このメソッドの中で buy() , sell() or close() のメソッドを呼び、注文を出すのが正しいやり方だった。

今回の戦略は下記の通り。

how-to-make-stock-trading-system.dogwood008.com

「今回の戦略では」と書いたが、今回の戦略では、「当日の始値と前日の終値を比較する」というロジックになっている。

しかし、 Backtrader のバックテストでは、基本的に「注文を出した 翌営業日 の四本値」で注文の執行可否を判断している。

ところが、 next() メソッドでは当日までの四本値までしか見られないので、適切に注文が執行されなかったと考えられる。

タイムゾーンのズレ

また、CSVをパースする KabuPlusJPCSVData クラス( bt.feeds.YahooFinanceCSVData を継承している)の中で、タイムゾーンを付けて日付けをパースしていたが、これが良くなかった。

bt.feedself.p.tz を見てタイムゾーンの判断をしているので、勝手にJSTとしてパースすると注文の有効期間が+9時間(=翌営業日の8:59:59)になってしまうという問題が発生していた。

これは、 KabuPlusJPCSVData の中でタイムゾーン無しとしてパースした上で、 self.p.tzpytz.timezone('Asia/Tokyo') を付与することで解決した。

今後

株価に合わせて、注文数を変更できるようにする。

一旦、単一銘柄でバックテストを実施できるようになったので、複数銘柄でも行っていき、適切な x%y% を探っていく。

シミュレーション結果

あくまでシミュレーションであるが、下記の条件の下、 100万円 の現金(= 300万円 の信用建余力)で始め、シミュレーション終了時点で 238.5万円 の現金(= 538.5万円 の信用建余力)という評価額を達成している。

<シミュレーション条件>

  • 100万円の証拠金を現金で差し入れ
    • 信用取引を使用したとして、300万円まで取引できるものとする
  • 手数料は無料、金利も無料
  • 期間は2020年1月1日〜11月30日の全営業日
  • buy_under_percentage: 5 で実行
    • 当日始値が前日終値と比較し、▲5%なら買い注文
  • sell_over_percentage: 5 で実行
    • 当日始値が前日終値と比較し、+5%なら売り(手仕舞い)注文
Initial Portfolio Value: 3,000,000
Final Portfolio Value:  5,385,350

完成版ソースコード

見やすいよう、Gist にも上げておく。

gist.github.com

# -*- coding: utf-8 -*-

# !pip install backtrader==1.9.76.123 backtrader_plotting

"""## Consts"""

USE_BOKEH = False

"""## Load CSV"""

path_to_csv = '/content/drive/MyDrive/Project/kabu-plus/japan-stock-prices-2_2020_9143_adjc.csv'

import pandas as pd
import backtrader as bt
csv = pd.read_csv(path_to_csv)
pd.set_option('display.max_rows', 500)
csv

"""## FeedsData"""

#############################################################
#    Copyright (C) 2020 dogwood008 (original author: Daniel Rodriguez; https://github.com/mementum/backtraders)
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
#############################################################

import csv
import itertools
import io
import pytz
from datetime import date, datetime
from backtrader.utils import date2num
from typing import Any

class KabuPlusJPCSVData(bt.feeds.YahooFinanceCSVData):
    '''
    Parses pre-downloaded KABU+ CSV Data Feeds (or locally generated if they
    comply to the Yahoo formatg)
    Specific parameters:
      - ``dataname``: The filename to parse or a file-like object
      - ``reverse`` (default: ``True``)
        It is assumed that locally stored files have already been reversed
        during the download process
      - ``round`` (default: ``True``)
        Whether to round the values to a specific number of decimals after
        having adjusted the close
      - ``roundvolume`` (default: ``0``)
        Round the resulting volume to the given number of decimals after having
        adjusted it
      - ``decimals`` (default: ``2``)
        Number of decimals to round to
      - ``swapcloses`` (default: ``False``)
        [2018-11-16] It would seem that the order of *close* and *adjusted
        close* is now fixed. The parameter is retained, in case the need to
        swap the columns again arose.
    '''
    DATE = 'date'
    OPEN = 'open'
    HIGH = 'high'
    LOW = 'low'
    CLOSE = 'close'
    VOLUME = 'volume'
    ADJUSTED_CLOSE = 'adjusted_close'
    
    params = (
        ('reverse', True),
        ('round', True),
        ('decimals', 2),
        ('roundvolume', False),
        ('swapcloses', False),
        ('headers', True),
        ('header_names', {  # CSVのカラム名と内部的なキーを変換する辞書
            DATE: 'date',
            OPEN: 'open',
            HIGH: 'high',
            LOW: 'low',
            CLOSE: 'close',
            VOLUME: 'volumes',
            ADJUSTED_CLOSE: 'adj_close',
        }),
        ('tz', pytz.timezone('Asia/Tokyo'))
    )

    def _fetch_value(self, values: dict, column_name: str) -> Any:
        '''
        パラメタで指定された変換辞書を使用して、
        CSVで定義されたカラム名に沿って値を取得する。
        '''
        index = self._column_index(self.p.header_names[column_name])
        return values[index]

        
    def _column_index(self, column_name: str) -> int:
        '''
        与えたカラム名に対するインデックス番号を返す。
        見つからなければ ValueError を投げる。
        '''
        return self._csv_headers.index(column_name)

    # copied from https://github.com/mementum/backtrader/blob/0426c777b0abdfafbb0988f5c31347553256a2de/backtrader/feed.py#L666-L679
    def start(self):
        super(bt.feed.CSVDataBase, self).start()

        if self.f is None:
            if hasattr(self.p.dataname, 'readline'):
                self.f = self.p.dataname
            else:
                # Let an exception propagate to let the caller know
                self.f = io.open(self.p.dataname, 'r')

        if self.p.headers and self.p.header_names:
            _csv_reader = csv.reader([self.f.readline()])
            self._csv_headers = next(_csv_reader)

        self.separator = self.p.separator


    def _loadline(self, linetokens):
        while True:
            nullseen = False
            for tok in linetokens[1:]:
                if tok == 'null':
                    nullseen = True
                    linetokens = self._getnextline()  # refetch tokens
                    if not linetokens:
                        return False  # cannot fetch, go away

                    # out of for to carry on wiwth while True logic
                    break

            if not nullseen:
                break  # can proceed

        dttxt = self._fetch_value(linetokens, self.DATE)
        dt = date(int(dttxt[0:4]), int(dttxt[5:7]), int(dttxt[8:10]))
        dtnum = date2num(datetime.combine(dt, self.p.sessionend))
        #dtnum = date2num(datetime.combine(dt, self.p.sessionend), tz=pytz.timezone('Asia/Tokyo'))

        self.lines.datetime[0] = dtnum
        o = float(self._fetch_value(linetokens, self.OPEN))
        h = float(self._fetch_value(linetokens, self.HIGH))
        l = float(self._fetch_value(linetokens, self.LOW))
        rawc = float(self._fetch_value(linetokens, self.CLOSE))
        self.lines.openinterest[0] = 0.0

        adjustedclose = float(self._fetch_value(linetokens, self.ADJUSTED_CLOSE))
        v = float(self._fetch_value(linetokens, self.VOLUME))

        if self.p.swapcloses:  # swap closing prices if requested
            rawc, adjustedclose = adjustedclose, rawc

        adjfactor = rawc / adjustedclose

        o /= adjfactor
        h /= adjfactor
        l /= adjfactor
        v *= adjfactor

        if self.p.round:
            decimals = self.p.decimals
            o = round(o, decimals)
            h = round(h, decimals)
            l = round(l, decimals)
            rawc = round(rawc, decimals)

        v = round(v, self.p.roundvolume)

        self.lines.open[0] = o
        self.lines.high[0] = h
        self.lines.low[0] = l
        self.lines.close[0] = adjustedclose
        self.lines.volume[0] = v

        return True

"""## Strategy"""

# https://www.backtrader.com/blog/posts/2017-05-16-stsel-revisited/stsel-revisited/
class StFetcher(object):
    _STRATS = []

    @classmethod
    def register(cls, target):
        cls._STRATS.append(target)

    @classmethod
    def COUNT(cls):
        return range(len(cls._STRATS))

    def __new__(cls, *args, **kwargs):
        idx = kwargs.pop('idx')

        obj = cls._STRATS[idx](*args, **kwargs)
        return obj

import backtrader as bt
from logging import getLogger, StreamHandler, Formatter, DEBUG, INFO, WARN

if USE_BOKEH:
    from backtrader_plotting import Bokeh
    from backtrader_plotting.schemes import Tradimo

# for jupyter
if 'PercentageBuySellStrategyWithLogger' in globals():
    del PercentageBuySellStrategyWithLogger


from typing import Callable, Union, Optional
LazyString = Callable[[str], str]
LazyableString = Union[LazyString, str]

# Create a Stratey
# @StFetcher.register
class PercentageBuySellStrategyWithLogger(bt.Strategy):
    params = (
        ('default_unit_size', 100),  # デフォルトの単元株の株数
        ('buy_under_percentage', 5),  # 前日終値と比較し本日始値が▲x%の場合に買い注文
        ('sell_over_percentage', 5),  # 前日終値と比較し本日始値が+y%の場合に売り注文
        ('min_order_price', 10 * 10000),  # 最低購入金額(円)
        ('max_order_price', 50 * 10000),  # 最高購入金額(円)
        ('smaperiod', 5),
    )

    # def _log(self, txt: LazyLazyableString, dt=None):
    #     ''' Logging function for this strategy '''
    #     dt = dt or self.datas[0].datetime.date(0)
    #     self._logger.debug('%s, %s' % (dt.isoformat(), txt))

    def __init__(self, loglevel):
        # Keep a reference to the "close" line in the data[0] dataseries
        self._dataopen = self.datas[0].open
        self._datahigh = self.datas[0].high
        self._datalow = self.datas[0].low
        self._dataclose = self.datas[0].close
        self._dataadjclose = self.datas[0].adjclose
        self._datavolume = self.datas[0].volume
        self._logger = getLogger(__name__)
        self.handler = StreamHandler()
        self.handler.setLevel(loglevel)
        self._logger.setLevel(loglevel)
        self._logger.addHandler(self.handler)
        self._logger.propagate = False
        self.handler.setFormatter(
                Formatter('[%(levelname)s] %(message)s'))
        self.sma = bt.indicators.SimpleMovingAverage(
            self.datas[0], period=self.params.smaperiod)                

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return

        if order.status in [order.Completed]:
            if order.isbuy():
                buy_or_sell = 'BUY'
            elif order.issell():
                buy_or_sell = 'SELL'
            else:
                buy_or_sell = 'UNDEFINED'
            self._log(lambda: '{b_s:4s} EXECUTED, {price:7.2f}'.format(
                b_s=buy_or_sell,
                price=order.executed.price))

        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self._debug(lambda: 'Order Canceled/Margin/Rejected')
        elif order.status in [order.Expired]:
            # from IPython.core.debugger import Pdb; Pdb().set_trace()
            self._debug(lambda: 'Expired: {b_s:s} ¥{sum:,d} (@{price:.2f} * {unit:4d}), valid: ({valid:s}, {valid_raw:f})'.format(
                sum=int(order.price * order.size),
                b_s=order.ordtypename(), price=order.price, unit=order.size,
                valid=str(bt.utils.dateintern.num2date(order.valid, tz=pytz.timezone('Asia/Tokyo'))),
                valid_raw=order.valid)
            )
        else:
            self._debug(order.getstatusname())
        

        
    def _log(self, txt: LazyableString, loglevel=INFO, dt=None):
        ''' Logging function for this strategy '''
        dt = dt or self.datas[0].datetime.date(0)
        logtext = txt() if callable(txt) else str(txt)
        self._logger.log(loglevel, '%s, %s' % (dt.isoformat(), logtext))

    def _debug(self, txt: LazyableString, dt=None):
        self._log(txt, DEBUG, dt)

    def _info(self, txt: LazyableString, dt=None):
        self._log(txt, INFO, dt)

    def _is_to_buy(self, open_today: float, close_yesterday: float, high_today: Optional[float] = None) -> bool:
        # 本日始値*閾値% <= 前日終値
        to_buy_condition: bool = open_today * (100.0 - self.p.buy_under_percentage) / 100.0 <= close_yesterday
        if high_today:
            # バックテスト実行時に、始値より高値が高いことを確認する。
            # これにより、実際にこの戦略をリアルタイムで動かした場合にも、動作可能であることを確認する。
            return to_buy_condition and open_today <= high_today
        else:
            # リアルタイムで動かした場合は、high_today = None
            return to_buy_condition
            
    def _is_to_close(self, open_today: float, close_yesterday: float, low_today: Optional[float] = None) -> bool:
        # 前日終値*閾値% <= 本日始値
        to_sell_condition: bool = close_yesterday * (100.0 + self.p.sell_over_percentage) / 100.0 <= open_today
        if low_today:
            # バックテスト実行時に、始値より安値が低いことを確認する。
            # これにより、実際にこの戦略をリアルタイムで動かした場合にも、動作可能であることを確認する。
            return to_sell_condition and low_today <= low_today
        else:
            # リアルタイムで動かした場合は、low_today = None
            return to_sell_condition
            

    def size(self) -> int:
        # TODO: min_order_price <= 購入金額 <= max_order_price になるように調整して返す
        return self.p.default_unit_size
        
    def next(self):
        # 当日の始値を見るためにチートする
        return

    def next_open(self): # 当日の始値を見るためにチートする
        open_today = self._dataopen[0]
        high_today = self._datahigh[0]
        low_today = self._datalow[0]
        close_yesterday = self._dataclose[-1]

        if self._is_to_buy(open_today, close_yesterday, high_today):
            size = self.size()
            self._info(lambda: 'BUY CREATE @{price:.2f}, #{unit:4d} (open_today, close_yesterday)=({open_today:f}, {close_yesterday:f})'.format(
                price=open_today, unit=size, open_today=open_today, close_yesterday=close_yesterday)
            )
            self._debug(lambda: '(o, h, l, c) = ({o:}, {h:}, {l:}, {c:})'.format(
                o=self._dataopen[0], h=self._datahigh[0], l=self._datalow[0], c=self._dataclose[0]))
            from datetime import timedelta
            self.buy(size=size, price=open_today, exectype=bt.Order.Limit, valid=bt.Order.DAY)

        elif self._is_to_close(open_today, close_yesterday, low_today):
            size = None  # 自動的に建玉全数が指定される
            self._info(lambda: 'CLOSE (SELL) CREATE @{price:.2f}, #all (open_today, close_yesterday)=({open_today:f}, {close_yesterday:f})'.format(
                price=open_today, open_today=open_today, close_yesterday=close_yesterday)
            )
            from datetime import timedelta
            self.close(size=size, price=open_today, exectype=bt.Order.Limit, valid=bt.Order.DAY)


if USE_BOKEH:
    del PercentageBuySellStrategyWithLogger
    from percentage_buy_sell_strategy_with_logger import PercentageBuySellStrategyWithLogger

"""## main"""

class BackTest:
    def __init__(self, strategy: bt.Strategy, cheat_on_open=True):
        self.cerebro = bt.Cerebro(tz='Asia/Tokyo', cheat_on_open=cheat_on_open)
        # Set cheat-on-close
        self.cerebro.broker.set_coc(True)

        data = KabuPlusJPCSVData(
            dataname=path_to_csv,
            fromdate=datetime(2020, 1, 1),
            todate=datetime(2020, 11, 30),
            reverse=False)

        self.cerebro.adddata(data)
        # Add a strategy
        IN_DEVELOPMENT = False # このフラグにより、ログレベルを切り替えることで、本番ではWARN以上のみをログに出すようにする。
        # フラグの切り替えは、環境変数で行う事が望ましいが今は一旦先送りする。
        loglevel = DEBUG if IN_DEVELOPMENT else WARN
        self.cerebro.broker.setcash(100 * 10000 * 3)  # 信用取引なので3倍
        self.cerebro.addstrategy(strategy, loglevel)
        # self.cerebro.optstrategy(StFetcher, idx=StFetcher.COUNT())

    def run(self):
        initial_cash = self.cerebro.broker.getvalue()
        self.cerebro.run()

        print('Initial Portfolio Value: {val:,}'.format(val=initial_cash))
        print('Final Portfolio Value: {val:,}'.format(val=int(self.cerebro.broker.getvalue())))

        save_file = False
        if USE_BOKEH:
            if save_file:
                b = Bokeh(style='bar', plot_mode='single', scheme=Tradimo(), output_mode='save', filename='chart.html')
            else:
                b = Bokeh(style='bar', plot_mode='single', scheme=Tradimo())
            return self.cerebro.plot(b, iplot=not save_file)
        else:
            return self.cerebro.plot(use='agg')

def num2date(val: float, tz=pytz.timezone('Asia/Tokyo')):
    return bt.utils.dateintern.num2date(val, tz=tz)

def p_order(order: bt.Order):
    print(str(order))

"""## [Order Execution Logic](https://www.backtrader.com/docu/order-creation-execution/order-creation-execution/)

- https://www.backtrader.com/blog/posts/2017-05-01-cheat-on-open/cheat-on-open/
"""

if __name__ == '__main__':
    backtest = BackTest(strategy=PercentageBuySellStrategyWithLogger)
    chart = backtest.run()
    from IPython.display import display
    display(chart[0][0])

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