본문 바로가기
파이썬/주식

Python으로 주식 알파 찾기, Open-to-Close 전략 수행 With Backtrader 백테스팅

by 행복론자 2020. 1. 16.

Python으로 주식 알파 찾기라는 거창한 제목을 썼지만 사실 알파라기 보다 이런 것은 어떨까? 하는 생각으로 시작하는 글입니다.

 

수행하고자 하는 전략은 Open-to-Close 전략입니다. 

간략히 설명하면 KOSPI 상장 종목 중 시가총액 상위 200개 중 전일 종가 대비 

당일 종가가 제일 하락한 순서대로 10종목을 골라내어 다음날 

Open Price(시가)에 사서 Close Price(종가)에 파는 전략입니다.

이 전략의 가정은 '하루의 10% 이상씩 부지기수로 왔다갔다 하는 기업이 아니라, 어느정도 규모를 갖춘 우량 기업이라면 큰 하락폭을 보고 다시 오를 것이라는 사람들의 심리에 힘입어 다음날 종가에는 어느정도 가격이 회복할 것이다.' 입니다. 

 

이와 같은 전략이 과연 통하는지 국내주식시장에서 2018-01-02부터 2020-01-03까지 백테스팅을 해보겠습니다.

 

 

1.준비

(1) DB(SQLITE3)

import sqlite3
#DB connection
con = sqlite3.connect('datas/stock_price.db', timeout=10)
cursor = con.cursor()

 

 

KOSPI 상장 종목들의 일봉데이터(일자, 시가, 종가, 저가, 고가, 거래량)을  담은 DB를 만들어 사용하겠습니다.

저장한 대상 일자는 2018-01-02 ~ 2020-01-03입니다.

 

 

(2) 시총 상위 200개 기업 고르기

제 블로그에 이전글인 Naver 크롤링을 보시면 시가총액 순으로 정렬된 기업정보를 얻어올 수 있습니다.

이렇게 저장한 엑셀을 읽어옵니다.

import FinanceDataReader as fdr
import pandas as pd

df_krx = fdr.StockListing('KRX')
df_finance = pd.read_excel('NaverFinance.xlsx')
top_200_list = df_finance[df_finance['매출액']>'0']['종목명'][:200] .tolist() #시총 상위 200개 기업, 매출액이 없는 우선주 등을 골라내기 위해 '매출액 > 0'을 했습니다.
codes = df_krx['Symbol']
datas_for_daily =[] #당일 종가 <> 전일 종가 하락폭이 큰 순서대로 담을 리스트 

 

 

2.수행

위의 단계를 거쳐 수행 전에 필요한 작업이 있습니다.

'제일 하락폭이 큰 기업 10개 골라내기'입니다. 

 

이 과정은 아래와 같이 수행했습니다.

daily = {}
for i in range(0,10):
    for base_index, ticker in stocks.idxmin(axis=1).items():
        next_index = base_index + pd.DateOffset(1)
        stocks[ticker][base_index:next_index]=1
        daily.setdefault(base_index.strftime('%Y-%m-%d'), []).append(ticker)

 

 

여기서 stocks에는 시총 200개 기업의 2018-01-02부터 2019-01-03까지의 Change(당일종가와 전일종가 변동률)을 담았습니다.

이 중 제일 작은 값을 갖는 기업(ticker)은 하락폭이 제일 크다고 할 수 있습니다. 이 기업을 daily라는 dictionary에 넣고 이 값을 backtrader에 parmeter로 전달하여 backtrader에서 sell과 order를 할 대상으로 선정합니다.

stocks[ticker][base_index:next_index]=1

 

 

위 과정을 하는 이유는 제일 작은 값을 찾고 나서 daily에 넣었으면 또 필요하지 않기 때문에 대상에서 제외하고 다음 작은 값을 찾기 위함입니다만 이 방법 말고 더 좋은 방법이 있을 것도 같습니다

 

아무튼 이렇게 daily라는 dictionary에 하락폭이 제일 컸던 기업을 날짜별로 10개씩 담았습니다.

다음은 backtrader에 전달하여 매매를 수행하겠습니다.

import backtrader as bt
from datetime import timedelta, date, datetime

#Variable for our starting cash
startcash = 10000000

#Create an instance of cerebro
cerebro = bt.Cerebro()

# >> cerebro 객체에 Data를 Feed해야 합니다. 이 부분은 sqlite3 db를 읽어서 수행해야합니다.

#Add our strategy and set params
cerebro.addstrategy(OpenCloseStrategy,toBuyList=daily) #OpenCloseStrategy은 따로 첨부하겠습니다, toBuyList에 위에서 구한 daily를 전달합니다.

# Set our desired cash start
cerebro.broker.setcash(startcash)

# Run over everything
cerebro.run()

#Get final portfolio Value
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash

#Print out the final result
print('Final Portfolio Value: ${}'.format(portvalue))
print('P/L: ${}'.format(pnl))

 

 

OpenCloseStrategy Class 

class OpenCloseStrategy(bt.Strategy):
    params = (
        ('toBuyList', {}),
        ('oneplot', True)
    )
    buy_order = None  # default value for a potential buy_order

    def __init__(self):
        self.order = None        

    def next(self):
        for i, d in enumerate(self.datas):
            if d._name not in self.p.toBuyList[self.datetime.date(ago=0).strftime('%Y-%m-%d')]:
                continue
            self.order = self.buy(data=d,size=1)
            self.order = self.sell(exectype=bt.Order.Close,data=d,size=1)

    def stop(self):
        print('Finish')

    def log(self, txt, dt=None):
        ''' Logging function fot this strategy'''
        dt = dt or self.datas[0].datetime.date(0)
        print('%s, %s' % (dt.isoformat(), txt))

    def notify_order(self, order):
        # Check if an order has been completed
        # Attention: broker could reject order if not enough cash
        if order.status in [order.Completed]:
            if order.isbuy():
                self.log('BUY EXECUTED, %.2f' % order.executed.price)
            elif order.issell():
                self.log('SELL EXECUTED, %.2f' % order.executed.price)

            self.bar_executed = len(self)

        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log('Order Canceled/Margin/Rejected')

        # Write down: no pending order
        self.order = None

 

 

주어진 DataSet을 가지고 일별로 수행되는 next 함수에서는 다건의 데이터에 접근할 때 self.datas를 이용합니다.

self.p.toBuyList는 아까 구한 하락폭이 제일 큰 10개의 기업들이 들어가 있습니다

 

따라서 아래 코드의 의미는 self.datas로 넣은 것은 시총 상위 200개 기업들 모두지만 그 중 self.p.toBuyList에 들어가 있는 기업들, 하락폭이 커서 내가 사려고 봐둔 기업들만 대상으로 order가 들어가야 하기에 그렇지 않은 기업들은 pass합니다. 

if d._name not in self.p.toBuyList[self.datetime.date(ago=0).strftime('%Y-%m-%d')]:
	continue

 

 

그러면 여기서 조금 의아하실 수도 있는 부분이 있습니다.

def next(self):
    for i, d in enumerate(self.datas):
        if d._name not in self.p.toBuyList[self.datetime.date(ago=0).strftime('%Y-%m-%d')]:
            continue
        self.order = self.buy(data=d, size=1)
        self.order = self.sell(exectype=bt.Order.Close, data=d, size=1)

 

 

일자별로 제일 하락폭이 큰 기업들 목록이 self.p.toBuyList에 있습니다. 그래서 우리는 d._name(기업이름)이 있는지 보고 사고, 팔고를 수행합니다.

그런데 언뜻 순서대로 로직을 보면 당일날 하락폭이 제일 큰 기업을 그냥 당일날 사고 파는 것 같습니다.

그렇게 된다면 당일날 하락폭이 제일 큰 기업을 다음날 시가에 사서 종가에 판다는 전략의 아이디어에 위배 됩니다.

하지만 위 코드는 걱정과 달리 정상적으로 돌아갑니다.

 

왜냐하면 Backtrader의 Order 수행과 관련이 있습니다. Buy Order를 수행할 때 exectype을 넣지 않을 경우

Market type을 Default로 사용합니다. Market type은 다음날 시가에 알아서 사고 팔라는 의미입니다.

반대로 sell 메소드의 parameter로 exectype에 전달된 bt.Order.Close는 다음날 종가에 팔라는 의미입니다.

 

그렇기 때문에 Order를 수행할 때 exectype parameter로 아무 것도 넣지 않았을 때(Market Type)와 bt.Order.Close를 전달했을 때는 parameter로 price를 전달해도 무시하고 시가/종가에 사고 팔아버립니다. 

 

따라서 하나씩 로그를 따라가보면

2018-01-02의 제일 하락한 기업들 10개를 대상으로 buy, sell Order가 2019-01-03에 제대로 들어감을 확인할 수 있습니다. 그 다음 2018-01-03은 그날을 기준으로 제일 하락한 기업들 10개를 가지고 2018-01-04에 Order를 넣습니다.

이를 2020-01-03까지 돌려보면,..

 

 

 

 

startcash, 10,000,000으로 333,180원을 벌었읍니다.. 복리로 따지면 은행에 넣어둔 만도 못하고 

위의 코드에서 세팅하지 않은 수수료까지 포함하면 거의 돈을 잃었다고 볼 수 있습니다.

 

그러면 돈을 최대로 잃었을 때는 얼마정도일까요?

보통은 손실을 파악하기 위해 MDD(Maximum DrawDown)을 사용합니다만

이는 전고점대비 얼마나 하락했는지를 나타내기 위함이고

그저 잔액이 최저점일 때 금액을 알고 싶기 때문에 단순히 계산해보겠습니다.

 

아래는 최저점 계산에 대한 간단한 예시입니다. 

def __init__(self):
	self.order = None
	self.lowest_cash = self.broker.getcash() # 초기 금액
    
def next(self):
'''
위의 전략 수행
'''
    self.lowest_cash = min(self.lowest_cash , self.broker.getvalue())    

 

 

getcash()를 통해서 운용할 수 있는 현금을 알 수 있습니다, 

예를 들어 초기금액이 10,000원이고 5,000원짜리 주식을 샀으면 getcash()를 통해 얻는 금액은 5,000입니다.

 

반대로 getvalue()는 들고 있는 주식 평가금액 + 현금입니다. 

위의 상황에서 주식이 6,000원으로 오르고 현금이 똑같이 5,000원 남아있다면 getvalue()를 통해 얻는 금액은 11,000입니다.

 

이렇게 수행한 결과는 초기금액 10,000,000원으로 9,948,879입니다.

최대로 잃을 때는 약 5만원 정도고 딸 때는 약 33만원이면 그렇게 나쁘지는 않아보입니다.(은행에 두면 잃을 일 조차 없는데..)

 

 

3.코멘트

>> 하락폭이 큰 10개의 종목을 대상으로 했지만 15개로 진행하면 수익이 922,001으로 약 3배 가까이 올랐습니다.

     반대로 종목수를 5개로 정해서 진행하면 10개 대상일 때보다 돈을 더 못 법니다.

    (무작정 개수를 늘린다고 수익 올라가는 건 아니겠지만 대충보면 어느정도 상관성이 있어 보입니다.)

    

>> 최저점 형성도 비슷한 양상을 보입니다. 종목수를 늘이면 늘일수록 더 안전합니다.

     10개의 대상으로 전략을 수행했을 때, 최대 잃은 금액은 51,121원입니다.

     5개를 대상으로 하면 최대 65,245원을 잃었습니다. 

     15개를 대상으로 하면 최대 19,864원을 잃습니다. 

 

>> 알파 찾기란 어렵지만 분산하면 할수록 크게 망할 일은 없는 것 같습니다. 

반응형
이 포스팅은 쿠팡파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

댓글