<2. 각 지표 순위별 수익률 백테스팅 프로그래밍 - (1) >


1. 시가 총액/PER/PBR 기준 변동성 돌파 전략 분석 프로그래밍 - 개요


해당 포스트를 읽기 전에 위 포스트를 읽길 추천한다.


이번 포스트는 각 지표(시가총액, PBR, PER)을 기준으로 모든 주식을 10등분 해 변동성 돌파 전략을 적용한 수익률을 계산하는 프로그램을 구현할 것이다. 이 프로그램을 통해 각 지표와 수익률의 관계를 파악할 수 있다. 아래는 프로그램 결과 화면이다. PBR 기준으로 코스닥 주식을 10등분해 2008년부터 2018년까지의 수익률을 계산했다. Rank 10이 pbr 하위 10%이다. 


프로그램 설명


코드를 보기 전에 위 프로그램의 주요 기능을 살펴보자. 프로그램에 대해 알아야 코드가 쉽게 이해 된다. 사용자는 PER, 시가 총액, PBR 중에 하나를 선택한다. 선택한 지표를 기준으로 코스닥 또는 코스피 종목을 나눈다. 몇 등분으로 나눌 지도 사용자가 설정한다. 나눈 주식은 연마다 리밸런싱한다. 또한 변동성 돌파 전략의 Scope 값을 어떻게 할 지도 설정한다. 과거 6개월 기준 최적화된 값을 Scope로 설정할 지 12개월 기준 최적화된 값으로 Scope를 설정할 지 선택한다. 마지막으로 백테스팅할 기간을 설정한다. 백테스팅 기간은 2008년 1월 1일부터 2018년 1월 1일까지 1년 단위로 선택한다. 


프로그램 코드


먼저 DB관련 코드, 실제 계산 코드 등 공통적으로 사용할 상수나 함수를 정의한 코드에 대해 알아보자. 먼저, 전체 코드를 보고 세부 사항을 설명하겠다.

import logging as log

KOSPI = "kospi"
KOSDAQ = "kosdaq"

start_2008 = "2008-01-01"
start_2009 = "2009-01-01"
start_2010 = "2010-01-01"
start_2011 = "2011-01-01"
start_2012 = "2012-01-01"
start_2013 = "2013-01-01"
start_2014 = "2014-01-01"
start_2015 = "2015-01-01"
start_2016 = "2016-01-01"
start_2017 = "2017-01-01"
start_2018 = "2018-01-01"

TMVALUE = "tmvalue"
PER = "per"
PBR = "pbr"

_07_3Q = "07_3Q"
_07_4Q = "07_4Q"
_08_1Q = "08_1Q"
_08_2Q = "08_2Q"
_08_3Q = "08_3Q"
_08_4Q = "08_4Q"
_09_1Q = "09_1Q"
_09_2Q = "09_2Q"
_09_3Q = "09_3Q"
_09_4Q = "09_4Q"
_10_1Q = "10_1Q"
_10_2Q = "10_2Q"
_10_3Q = "10_3Q"
_10_4Q = "10_4Q"
_11_1Q = "11_1Q"
_11_2Q = "11_2Q"
_11_3Q = "11_3Q"
_11_4Q = "11_4Q"
_12_1Q = "12_1Q"
_12_2Q = "12_2Q"
_12_3Q = "12_3Q"
_12_4Q = "12_4Q"
_13_1Q = "13_1Q"
_13_2Q = "13_2Q"
_13_3Q = "13_3Q"
_13_4Q = "13_4Q"
_14_1Q = "14_1Q"
_14_2Q = "14_2Q"
_14_3Q = "14_3Q"
_14_4Q = "14_4Q"
_15_1Q = "15_1Q"
_15_2Q = "15_2Q"
_15_3Q = "15_3Q"
_15_4Q = "15_4Q"
_16_1Q = "16_1Q"
_16_2Q = "16_2Q"
_16_3Q = "16_3Q"
_16_4Q = "16_4Q"
_17_1Q = "17_1Q"
_17_2Q = "17_2Q"
_17_3Q = "17_3Q"

yearly_days = [start_2008, start_2009, start_2010, start_2011, start_2012, start_2013, start_2014, start_2015,
               start_2016, start_2017, start_2018]
_1quarters = [_08_1Q, _09_1Q, _10_1Q, _11_1Q, _12_1Q, _13_1Q, _14_1Q, _15_1Q, _16_1Q, _17_1Q]
_3quarters = [_07_3Q, _08_3Q, _09_3Q, _10_3Q, _11_3Q, _12_3Q, _13_3Q, _14_3Q, _15_3Q, _16_3Q]


def get_settings(start, end, index):
    try:
        start_index = yearly_days.index(start)
        end_index = yearly_days.index(end)

        start_days =  yearly_days[start_index : end_index]
        end_days = yearly_days[start_index+1: end_index+1]

        if index==TMVALUE:
            indexs = _1quarters[start_index: end_index]
        else:
            indexs = _3quarters[start_index: end_index]
    except IndexError as e:
        log.warning("Settings index Error : {}".format(repr(e)))
        raise
    return zip(start_days, end_days, indexs)

_09_1Q 는 2009년 1분기를 일컫는다. _YY_XQ는 20YY년 X분기를 말한다. 이 상수가 필요한 이유는 시가총액을 기준으로 주식을 나눌 때 해당 년도 1분기를 기준으로 나누고 PER, PBR 기준으로 나눌 때는 작년도 3분기를 기준으로 나누기 때문이다. "_1quarters"와 "_3quarters"는 모든 년도 1분기 상수, 모든 년도 3분기 상수 데이터가 들어있다. "get_settings" 메서드는 계산할 날짜 범위와 분기를 반환한다. 예로 2008년 1월 1일부터 2010년 1월 1일까지 pbr을 기준으로 백테스팅한다고 가정하자. 그러면 "get_settings("2008-01-01","2010-01-01",PBR)을 호출한다. 결과로 zip(["2008-01-01", "2009-01-01"] , ["2009-01-01", "2010-01-01"], ["_07_3Q" , _08_3Q])가 리턴된다. 따라서 결과물에서 순차적으로 ("2008-01-01", "2009-01-01",  "_07_3Q") , ("2009-01-01", "2010-01-01", "_08_3Q") 를 접근할 수 있다.  즉, 백테스팅 시작과 끝 기간을 연도별로 쪼개고 그에 맞는 분기 변수를 얻을 수 있다. 연도별로 쪼개야 되는 이유는 연도가 리밸런싱 기준이기 때문이다.


DB 관련 코드를 살펴보자. 우리는 DB를 비동기적으로 접근할 것이다. 그 이유는 접근하고 계산해야할 데이터가 방대하기 때문이다. 2008년부터 2018년 모든 주식의 일봉 데이터만 220만개나 된다. 따라서 빠른 속도를 위해 비동기 프로그래밍 방식을 사용할 것이고 이를 위해 "aiomysql" 패키지를 사용한다. "aiomysql"은 "asyncio"를 이용해 DB에 비동기적으로 접근하게 해주는 패키지다. 아래는 전체 코드이다.
import aiomysql as aio
import logging as log
import pandas as pd
import ConstVar


class StockDB():
    def __init__(self, during, index):
        log.info("Connecting to database")
        self.during = during
        self.index = index


    async def init_pool(self,loop):
        log.info("Connection to Connection Pool")
        try:
            self.__pool = await aio.create_pool(host='127.0.0.1', port=3306, user='root', password='qhdks12#$', db='stock',loop=loop)
        except:
            log.warning("Connecting Pool Error {}".format(repr(0)))
            raise

    async def req_stock_index_count(self,market, quarter):
        log.debug("Selecting {0} market {1} index {2} row count".format(market, self.index, quarter))

        if market == ConstVar.KOSDAQ:
            sql = "select count(kos.Code) from (select distinct Code from kosdaq_profit_" + self.during + ") as kos join (select Code from "
        elif market == ConstVar.KOSPI:
            sql = "select count(kos.Code) from (select distinct Code from kospi_profit_" + self.during + ") as kos join (select Code from "

        if self.index == ConstVar.TMVALUE:
            sql = sql + self.index+" where "+quarter+" != 0 ) as tmv on kos.Code=tmv.Code"
        elif self.index == ConstVar.PBR:
            sql = sql + self.index + " where " + quarter + " >= 0.2 ) as pb on kos.Code=pb.Code"
        elif self.index == ConstVar.PER:
            sql = sql + self.index + " where " + quarter + " >= 2 ) as pe on kos.Code=pe.Code"

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    result = await cur.fetchone()
        except aio.Error as e:
            log.warning("Selecting stock index Error : {}".format(repr(e)))
            raise

        log.debug("Stock index count : {}".format(result[0]))

        return result[0]


    async def req_backtesting_data(self,market, start, end, quarter, offset, limit):
        log.info("Selecting backtesting data")

        sql = "select kos.Code, kos.Date, kos.Profit from " \
              "(select Code, Date, Profit from "+market+"_profit_"+self.during+" where Date>\'"+start+"\' and Date<\'"+end+"\') " \
              "as kos join (select Code from "

        if self.index == ConstVar.TMVALUE:
            sql = sql + self.index+" where "+quarter+" != 0  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"
        elif self.index == ConstVar.PBR:
            sql = sql + self.index + " where "+quarter+" >= 0.2  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"
        elif self.index == ConstVar.PER:
            sql = sql + self.index + " where "+quarter+" >= 2  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"

        log.info("sql {} ".format(sql))

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    rows = await cur.fetchall()
                    result = pd.DataFrame.from_records(list(rows))
                    result.columns = ['Code','Date', 'Profit']
        except aio.Error as e:
            log.warning("Selecting backtesting data Error : {}".format(repr(e)))
            raise

        return result


    async def req_index_data(self,market, start, end):
        log.info("Selecting {} index data".format(market))

        sql = "select Date, Close from "+market+" where Date<\'"+end+"\' and Date >=\'"+start+"\';"

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    rows = await cur.fetchall()
                    result = pd.DataFrame.from_records(list(rows))
                    result.columns = ['Date', 'Close']
                    result = result.set_index('Date')
        except aio.Error as e:
            log.warning("Selecting backtesting data Error : {}".format(repr(e)))
            raise

        return result


세부적으로 살펴보자. 초기화 부분이다. "init_pool()" 로 DB와 커낵션을 생성할 풀을 초기화한다. "index"는 백테스팅할 지표를 나타낸다. 위에서 본 ConstVar 클래스의 TMVALUE(시가총액), PER, PBR 변수가 들어간다. "during"은 scope 값을 결정하는 변수이다. 이전 포스트에서 말했듯이 scope 값을 어떻게 결정할 지가 중요하다. 우리는 scope 값을 과거 몇 개월 동안 가장 수익율을 많이 주는 값으로 정하거나 상수로 정할 수 있다. during이 이 역할을 한다. during이 "6"이면 과거 6개월동안 종목마다 최적화된 Scope, "12"이면 과거 12개월동안 최적화된 Scope다. 이 Scope 값은 매번 계산하면 매우 시간이 오래 걸리기 때문에 DB에 저장되어 있고 저장된 테이블 형식은 "kospi_profit_during", "kosdaq_profit_during" 이 된다. 즉 during이 "6" 이면 "kospi_profit_6" 테이블을 참조할 수 있다.  

    def __init__(self, during, index):
        log.info("Connecting to database")
        self.during = during
        self.index = index


    async def init_pool(self,loop):
        log.info("Connection to Connection Pool")
        try:
            self.__pool = await aio.create_pool(host='127.0.0.1', port=3306, user='root', password='qhdks12#$', db='stock',loop=loop)
        except:
            log.warning("Connecting Pool Error {}".format(repr(0)))
            raise



다음 메서드는 지표를 등분하기 위해서 특정 범위에 해당하는 종목 수를 리턴한다. 만약 per 기준으로 등분한다면 per이 3이상인 모든 주식 수를 리턴한다. 시가총액 기준으로 분등한다면 시가총액이 0이 아닌 모든 종목 수를 리턴한다. 이 값을 구하는 이유는 10등분한다면 (전체 종목 수)/10을 구해서 0에서 10% , 10%에서 20% ...의 구체적 수를 정해야 하기 때문이다.

    async def req_stock_index_count(self,market, quarter):
        log.debug("Selecting {0} market {1} index {2} row count".format(market, self.index, quarter))

        if market == ConstVar.KOSDAQ:
            sql = "select count(kos.Code) from (select distinct Code from kosdaq_profit_" + self.during + ") as kos join (select Code from "
        elif market == ConstVar.KOSPI:
            sql = "select count(kos.Code) from (select distinct Code from kospi_profit_" + self.during + ") as kos join (select Code from "

        if self.index == ConstVar.TMVALUE:
            sql = sql + self.index+" where "+quarter+" != 0 ) as tmv on kos.Code=tmv.Code"
        elif self.index == ConstVar.PBR:
            sql = sql + self.index + " where " + quarter + " >= 0.2 ) as pb on kos.Code=pb.Code"
        elif self.index == ConstVar.PER:
            sql = sql + self.index + " where " + quarter + " >= 3 ) as pe on kos.Code=pe.Code"

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    result = await cur.fetchone()
        except aio.Error as e:
            log.warning("Selecting stock index Error : {}".format(repr(e)))
            raise

        log.debug("Stock index count : {}".format(result[0]))

        return result[0]


아래 함수는 코스닥, 코스피 지수의 종가 데이터를 불러온다. 코스피와 코스닥 지수 일봉 데이터는 'kospi", "kosdaq" 테이블에 있다. 해당 종가 데이터는 코스닥, 코스피 지수를 buy and hold 했을 때 수익율 그래프를 그리기 위해 필요하다. 이를 통해 백테스팅 결과와 비교할 수 있다.
    async def req_index_data(self,market, start, end):
        log.info("Selecting {} index data".format(market))

        sql = "select Date, Close from "+market+" where Date<\'"+end+"\' and Date >=\'"+start+"\';"

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    rows = await cur.fetchall()
                    result = pd.DataFrame.from_records(list(rows))
                    result.columns = ['Date', 'Close']
                    result = result.set_index('Date')
        except aio.Error as e:
            log.warning("Selecting backtesting data Error : {}".format(repr(e)))
            raise

        return result



아래 메서드는 market(코스피, 코스닥) 시장 내 self.index의 quarter 분기 지표를 기준으로 내림차순으로 정렬했을 때 offset 순위부터 offset+limit 순위까지 주식의 during 범위 수익율 데이터를 가져온다. 예로 req_backtesting_data(ConstVar.KOSPI, ConstVar.TMVALUE, "2008-01-01", "2009-01-01", ConstVar._08_01, 0 ,100) 을 호출한다 하자. 그러면 시가총액 2008년 1분기 값 상위 1위부터 100위까지의 종목 중 코스닥 시장에 해당하는 종목들의 2008년 1월 1일부터 2009년 1월 1일까지의 수익률 데이터를 리턴한다. 이 수익률 데이터는 self.during 값에 의해서 kosdaq_profit_self.during 테이블에 있는 값을 가져온다. 

    async def req_backtesting_data(self,market, start, end, quarter, offset, limit):
        log.info("Selecting backtesting data")

        sql = "select kos.Code, kos.Date, kos.Profit from " \
              "(select Code, Date, Profit from "+market+"_profit_"+self.during+" where Date>\'"+start+"\' and Date<\'"+end+"\') " \
              "as kos join (select Code from "

        if self.index == ConstVar.TMVALUE:
            sql = sql + self.index+" where "+quarter+" != 0  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"
        elif self.index == ConstVar.PBR:
            sql = sql + self.index + " where "+quarter+" >= 0.2  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"
        elif self.index == ConstVar.PER:
            sql = sql + self.index + " where "+quarter+" >= 2  order by "+quarter+" desc limit "+str(offset)+", "+str(limit)+") as tmv on kos.Code=tmv.Code;"

        log.info("sql {} ".format(sql))

        try:
            async with self.__pool.acquire() as conn:
                async with conn.cursor() as cur:
                    await cur.execute(sql)
                    rows = await cur.fetchall()
                    result = pd.DataFrame.from_records(list(rows))
                    result.columns = ['Code','Date', 'Profit']
        except aio.Error as e:
            log.warning("Selecting backtesting data Error : {}".format(repr(e)))
            raise

        return result


이번 포스트는 여기까지 마무리하고 다음 포스트는 DB에서 불러온 데이터를 가지고 계산해 차트로 보여주는 부분을 구현할 것이다. 



참고로 필자는 컴퓨터 공학과를 재학 중인 대학생입니다. 따라서 코드가 완벽할 수 없습니다. 알고리즘이나 코드가 비효율적이거나 오류가 있다면 댓글 달아주세요..

+ Recent posts