이번 포스팅에서는 제가 금융투자 알고리즘 회사에서 인턴생활을 할 때 진행했던 개인 프로젝트를 정리하여 올려보겠습니다.
프로젝트 목표는 시총 상위 50가지 종목으로 구성된 최적의 포트폴리오를 선정하여 KOSPI지수를 상회하는 것입니다.
(포트폴리오 : 선택한 주식 종목 및 종목 별 투자 비율)
- 들어가며
2021년 동계방학 때, AI기반 금융투자 알고리즘 회사에서 인턴생활을 했습니다.
첫 인턴 생활이다 보니까 들뜬 마음으로 회사에 좋은 아이디어를 내고 좋은 알고리즘을 개발하고 싶어서 1달 가량은 금융관련 책도 많이 읽고, 구글링도 엄청나게 많이 했던거 같아요.
본론으로 들어가기 앞서 혹시나 이 분야에 관심이 있는 분들을 위해서 인상 깊게 읽었던 책 몇 가지를 추천해보려고 합니다.
1. 문병로 교수님의 <<메트릭 스튜디오>>
책 내용이 조금 어렵긴 합니다.
책에서 저자는 시중에 퍼져있는 이평선 돌파, 골든 크로스, 적삼병과 같은 미신에 가까운(확인을 거치지 않은) 정보를 맹신하며 시장의 불확실성을 키우는 개인 투자자들이 상당부분을 차지하는 미숙한 한국 증권시장에 대해서 상당히 비판적인 태도로 일관해요.
책의 요점은 시장은 결국 확률의 게임이고, 확실한 통계적 혹은 수치적 검증을 거친 후에 행동해야 이에 따른 리스크에 대비할 수 있고 지속적인 수익을 낼 수 있다는 것입니다.
공대생으로서 투자에 대해 과학적으로 수치적으로 접근하는 방식이 마음이 갔고 영향을 많이 받았던 책입니다.
2. <<파이썬으로 배우는 알고리즘 트레이딩>>
대신증권 CYBOS나, 키움증권 영웅문과 같은 HTS에 API를 이용하여 접근하는 방식으로, 컴퓨터로 나만의 주식 거래 시스템을 만들어보고 싶은 사람들이 읽어보면 좋을거 같아요.
물론 기초적인 부분만 다루기 때문에 실제로 자기만의 알고리즘으로 거래 시스템을 만드는건 조금 힘들 수도 있습니다.
그리고 zipline과 같은 백테스팅 라이브러리 버전 호환이 잘 안되구요.
파이썬을 조금 알고 기초가 아예 없는 분들에게 추천드립니다.
3. 박준규 <<퀀트전략 파이썬으로 세워라>>
위의 책과 마찬가지로 퀀트투자에 입문하고 싶은 분들을 위한 입문서로 좋다고 생각합니다.
파이썬 기초, 데이터 수집, 처리, 분석, 백테스팅까지 전반적인 프로세스를 모두 다루기 때문에 퀀트투자의 대략적인 A to Z를 느껴보고 싶다면 이 책을 선택하는 것도 나쁘지 않다고 생각합니다.
4. 김문권 <<파이썬과 케라스를 이용한 딥러닝 강화학습 주식투자>>
우선 입문자에게는 추천하지 않습니다.
이전에 강화학습 관련된 프로젝트를 진행하면서 공부했던 적이 있어서 흥미가 갔던 책인데요.
책에서는 강화학습의 정책 신경망을 이용해서 주식투자 알고리즘을 구현하는데 기초 지식이 없으면 이해하기 어려울거라 생각해요.
그냥 이런 방법으로도 구현할 수 있구나 정도만 알고 넘어가시길 바랍니다.
1. 프로젝트 시작 배경
주식 투자를 하는 이유는 당연하게도 좋은 수익률을 내기 위함이죠.
수익률이 곧 성과이자 결과이니까요. 하지만 상황에 따라서 경제는 성장하기도, 침체되기도 합니다.
그렇다면 과거를 기반으로 장기적인 관점에서 경제는 성장한다고 가정했을때, 시장 경제를 잘 반영하는 주가 지수인 KOSPI지수를 추종하는 포트폴리오를 구성한다면 결과적으로 지속적인 수익을 얻을 수 있을 것입니다.
실제로 국내 ETF나 미국의 S&P500 인덱스펀드 모두 이러한 원리에 따라서 지속적으로 좋은 수익률을 내고 있죠.
위는 KOSPI200지수를 추종하는 Kindex ETF의 기간 별 수익률 표입니다.
여기서 ETF는 기본적으로 지수를 추종하는데 어떻게 초과성과가 나올 수 있었을까요? 바로 이 부분에 집중해서 이번 프로젝트 주제를 선정하게 되었습니다.
2. 기본 아이디어
시총 상위 종목으로 구성된 포트폴리오는 아래 그림과 같이 기본적으로 KOSPI지수에 따라 진동하는 경향을 가지고 있습니다.
그렇다면 특정 시점에서 KOSPI지수에 비해서 저평가 되어있는 포트폴리오는 우상향할 가능성이 높습니다.
바로 이 성질을 가지고 초기에 시총 상위 종목 50가지 중 랜덤한 구성의 포트폴리오를 다량으로 발생시킨 후에, 특정 시점에서 리밸런싱(포트폴리오 종목 교체, 혹은 비율 재설정)을 진행합니다.
KOSPI지수에 비해 저평가 돼있는 포트폴리오들을 기준을 가지고 랭킹을 매겨 가장 우선순위가 높은 포트폴리오로 교체해 다음 시점으로 진입합니다.
결과적으로 각 기간마다 계속해서 상향하기 때문에 지수를 상회하는 결과를 확인할 수 있을 것입니다.
3. 데이터 수집 및 전처리
먼저 프로젝트를 진행하기 위한 데이터를 수집하겠습니다. 기존에 있던 데이터 일부와 크롤링으로 부족한 데이터들을 추가적으로 확보했습니다.
먼저 포트폴리오를 구성할 시총 상위 종목 50가지의 종목코드 리스트를 만든 과정입니다.
from util.DbHandler import DbHandler
from util.DataManager import DataManager
import pandas as pd
import numpy as np
from itertools import combinations
import random
import matplotlib.pyplot as plt
import requests
from pykrx import stock
from datetime import datetime, timedelta
if __name__ == "__main__":
# importing data
dbHandler = DbHandler()
df_stock = pd.read_pickle('data/df_stock.pkl')
df_index = pd.read_pickle('data/df_index.pkl')
df_code = pd.read_pickle('data/df_code.pkl')
# simulation data 기간 내 kospi200종목 종가
simulation_duration = ('2018-01-01', '2020-10-15')
df_simulation_data = df_stock.loc[simulation_duration[0]:simulation_duration[1], (slice(None), ['close'])]
df_simulation_kospi = df_index.loc[simulation_duration[0]:simulation_duration[1], ('KOSPI', 'close')]
# 가용종목 얻기
avail_jongmoks, _ = zip(*df_simulation_data.columns)
# 2020-12-24 기준 kospi200종목 시총
df_kospi200_jongmok = pd.read_sql("select * from vf_stockdb_kr.kospi200_jongmok where date = '2020-12-24'",
dbHandler.connection,
index_col='jongmok_code')
# simulation_data 안에 있는 종목만 확인
avail_kospi200_jongmoks = [jongmok_code for jongmok_code in df_kospi200_jongmok.index if jongmok_code in avail_jongmoks]
# 시총 기준으로 정렬(높은 순서대로)
df_kospi200_jongmok.sort_values(by='cap', inplace=True, ascending=False)
# 시총 top50 종목 리스트
top50_jongmok_list = df_kospi200_jongmok.loc[avail_kospi200_jongmoks].iloc[:50].index.values
DB에서 가져온 데이터들이 있으며 DbHandler나 DataManager는 직접 구현한 프로그램이므로 똑같이 코드를 복사하시더라도 실행이 되지 않을겁니다.
각 코드위의 주석을 확인하며 어떠한 흐름으로 프로그램을 작성했는지만 확인하시면서 넘어가시면 될거 같습니다.
4. 기능 구현
# 종목 코드 리스트를 가지고 PER, PBR을 크롤링을 통해 가져오는 함수
def make_df(jongmok_code_list):
resdf = pd.DataFrame()
for num, jongmok_code in enumerate(jongmok_code_list):
# fnguide 사이트 접속하여 크롤링, table 형태로 저장
urls = 'https://comp.fnguide.com/SVO2/ASP/SVD_Invest.asp?pGB=1&gicode=A'+ str(jongmok_code) + '&cID=&MenuYn=Y&ReportGB=&NewMenuID=105&stkGb=701'
pages = requests.get(urls)
tables = pd.read_html(pages.text)
tables = tables[0]
tables.set_index(tables.columns[0],inplace=True)
tables.index.name = None
for _, col in enumerate(tables.columns[1:]):
tmp_df = pd.DataFrame({jongmok_code:tables[col]})
tmp_df = tmp_df.T
tmp_df.columns = [[col] * len(tables),tables.index]
if _ == 0:
total_df = tmp_df
else:
total_df = pd.merge(total_df,tmp_df,how='outer',left_index=True,right_index=True)
if num == 0:
resdf = total_df
else:
resdf = pd.concat([resdf,total_df])
return resdf
# 가져온 PER, PBR을 이용하여 pf(포트폴리오)를 랭킹하는 함수
def rank_by_per_pbr(low_spread_pf_code_list, sub_df_simulation_lastyear):
pf_mean_dict = {}
for n, low_spread_pf in enumerate(low_spread_pf_code_list):
pf_df = make_df(low_spread_pf)
pf_df = pf_df.loc[:, map(lambda x: sub_df_simulation_lastyear in x[0][0], pf_df.columns)].interpolate()
pf_mean_per = pf_df.mean()[2]
pf_mean_pbr = pf_df.mean()[3]
pf_mean_dict[n] = [pf_mean_per]
pf_mean_dict[n].append(pf_mean_pbr)
pf_mean_list = sorted(pf_mean_dict.items(), key=lambda x: x[1][0])
for i in range(len(pf_mean_list)):
pf_mean_list[i][1].append(i + 1)
pf_mean_list = sorted(pf_mean_list, key=lambda x: x[1][1])
for i in range(len(pf_mean_list)):
pf_mean_list[i][1][2] += i + 1
pf_mean_list = sorted(pf_mean_list, key=lambda x: x[1][2])
return pf_mean_list
# 최종적으로 선택한 pf를 저장 및 지수와의 편차 계산(spread diff)
def select_pf_by_PER_PBR(df_simulation_data, df_simulation_kospi, np_pf_jongmok_code_list, OBSERVE_TDAYS):
selected_pf = []
for OBSERVE_DAY in range(0,len(df_simulation_data)-OBSERVE_TDAYS,OBSERVE_TDAYS):
print(OBSERVE_DAY)
OBSERVE_DAY_END = OBSERVE_DAY + OBSERVE_TDAYS
sub_df_simulation_data = df_simulation_data.iloc[OBSERVE_DAY:OBSERVE_DAY_END].loc[:,(slice(None),'close')]
sub_df_simulation_date = df_simulation_data.index[OBSERVE_DAY_END-1]
sub_df_simulation_lastyear = str(eval(str(sub_df_simulation_date)[:4]+'-1'))
normal_sub_data = sub_df_simulation_data/sub_df_simulation_data.iloc[0]
normal_pf_value = pd.DataFrame()
for i in range(n_pf):
pf_idx = i
try:
normal_pf_data = normal_sub_data[np_pf_jongmok_code_list[pf_idx]]
normal_pf_value[str(pf_idx)] = normal_pf_data@weights
except:
pass
sub_df_simulation_kospi = df_simulation_kospi.iloc[OBSERVE_DAY:OBSERVE_DAY_END]
normal_sub_kospi = sub_df_simulation_kospi / sub_df_simulation_kospi.iloc[0]
spread_diff = normal_pf_value.apply(lambda x : x - normal_sub_kospi , axis = 0)
spread_diff = spread_diff.dropna(axis=0)
low_spread_pf_codelist = []
# spread_diff가 음수인 pf목록 없을 경우 하위 10% 선택, 그 외 rank by per,pbr
if sum(spread_diff.iloc[-1,:] < 0) != 0:
for idx in spread_diff.loc[:,spread_diff.iloc[-1,:]<0].columns:
idx = int(idx)
low_spread_pf_codelist.append(np_pf_jongmok_code_list[idx])
else:
_tmp = spread_diff.iloc[-1,:]
_tmp = sorted(_tmp)
for idx in spread_diff.loc[:,spread_diff.iloc[-1,:]<_tmp[6]].columns:
idx = int(idx)
low_spread_pf_codelist.append(np_pf_jongmok_code_list[idx])
ranked_low_spread_pf_idxlist = rank_by_per_pbr(low_spread_pf_codelist, sub_df_simulation_lastyear)
ranked_low_spread_pf = low_spread_pf_codelist[ranked_low_spread_pf_idxlist[0][0]]
selected_pf.append(ranked_low_spread_pf)
return selected_pf
먼저 발생시킨 포트폴리오들의 스프레드 편차(지수와의 차이)를 계산합니다.
편차가 음수인 포트폴리오들을 low_spread_pf_codelist에 저장합니다. 만약에 음수인 포트폴리오가 없을 시, 가장 낮은 하위 10%의 항목들을 저장합니다.
위에서 얻은 포트폴리오들을 기반으로 fnguide 웹사이트에 접속해 종목 별로 PER, PBR을 Dataframe 형태로 받아옵니다. 결측치는 보간법을 이용합니다.
(산업군 별로 정도의 차이는 있으나 PER, PBR이 낮다는건 그만큼 종목이 저평가 돼있다는 뜻)
가져온 PER, PBR 데이터를 기반으로 포트폴리오들의 랭킹을 매깁니다.
발생시킨 모든 포트폴리오 중에 랭킹이 가장 높은 포트폴리오를 선택하여 selected_pf 리스트에 넣어줍니다.
그 결과, 기간 별로 아래와 같이 포트폴리오들이 선택된 것을 확인할 수 있습니다.
5. 백테스팅(시뮬레이션)
드디어 마지막 단계인 시뮬레이션 단계입니다. 우리가 선택한 포트폴리오가 얼마나 좋은 성능을 내는지 확인해봐야겠죠?
# 포트폴리오 10만개 * 5종목 (4종목 + 삼성전자)
n_pf = 100
n_jongmok = 4
pf_jongmok_combis = list(combinations(top50_jongmok_list, n_jongmok))
pf_jongmok_code_list = random.sample(pf_jongmok_combis, n_pf)
np_pf_jongmok_code_list = np.array(pf_jongmok_code_list)
# 삼성전자 추가
np_pf_jongmok_code_list = np.apply_along_axis(lambda x: np.append(x, np.array(['005930'])), axis=1,
arr=np_pf_jongmok_code_list)
# select portfolio
START_TIME = 30
OBSERVE_TDAYS = 30
total_amount = 10000000
weights = np.array([0.2, 0.2, 0.2, 0.2, 0.2])
selected_pf = []
# rank by per pbr select portfolio
selected_pf = select_pf_by_PER_PBR(df_simulation_data, df_simulation_kospi, np_pf_jongmok_code_list, OBSERVE_TDAYS)
# simulation
simulation(selected_pf, weights, START_TIME, df_simulation_data, df_simulation_kospi, OBSERVE_TDAYS)
먼저 포트폴리오는 제 컴퓨터의 성능상 100개만 발생시키겠습니다.
그리고 포트폴리오 별로 종목은 5개로 구성하는데 우리나라 유가 증권 시장 특성 상 삼성전자가 차지하는 시총의 비율이 26.76%나 되기 때문에 삼성전자를 제외한다면 코스피 지수를 추종하는 특성을 잃어버릴거라 판단하여 삼성전자를 포함하여 나머지 4개의 종목을 랜덤하게 구성하였습니다.
투자 진입 시점은 시뮬레이션 기간 시작 후 30일로 잡았으며, 관측 간격도 30일로 잡았습니다. 이 부분은 상황에 따라서 세부적으로 조정할 수 있는 부분이기 때문에 추후에 제대로 다뤄볼 예정입니다.
마지막으로 종목 별 가중치는 여기서 자세하게 다룰 부분이 아니라서 균등하게 0.2로 설정했으며 추후에 딥러닝이나 머신러닝을 이용하여 세부적으로 조정할 방법을 찾아볼 예정입니다.
최종적으로 시뮬레이션 코드는 위와 같습니다. 선택한 포트폴리오들을 이용하여 KOSPI지수와의 스프레드 편차를 기록하여 시각화하였습니다.
결과는 위와 같이 초반에 잠깐 스프레드 편차가 음수로 떨어지다가(지수보다 낮아짐) 이후에는 계속해서 양수를 유지하며 KOSPI지수를 상회하는 것을 확인할 수 있습니다.
6. 마치며
이렇게 이번 포스팅에서는 KOSPI지수를 추종하는 포트폴리오 구성 알고리즘에 대한 프로젝트를 살펴봤는데요.
제가 생각하는 개선점은 종목 별 가중치와 리밸런싱 기간에 대한 세부적인 조정이 가능하다면 더욱 안정적이고 성능 좋은 알고리즘이 될거라고 생각이 드네요.
아쉬운 점은 첫 번째로 기업의 재무제표는 분기 별로 나오기 때문에 실제로 우리가 크롤링을 통해서 조회한 PER, PBR 값은 많게는 3개월 이전의 자료들이 되며, 그 기간동안의 정보의 유출 등을 통해서 이미 주가에 반영돼있을 가능성도 배제할 수는 없겠죠. 실시간으로 기업의 재무지표 값을 조회할 수 있다면 왜곡없는 결과를 낼 수 있을거라고 생각합니다.
두 번째로, 주식 거래에는 수수료가 발생합니다. 리밸런싱 주기가 짧아진다면 그만큼 많은 거래대금을 지불해야 합니다. 이는 자산 손실에 직접적으로 영향을 주며, 어떻게 하면 리밸런싱 주기를 늘리며 성과 또한 좋게 할지 더 고민해봐야 할거 같습니다.
마지막으로, 안전자산에 대한 비율입니다. 프로그램에는 안전 자산에 대한 구현은 하지 않았는데, 경제가 침체되고 위기를 맞을 확률이 높아진다면 그만큼 안전자산의 비율을 높게 설정해야 리스크에 대한 충분한 대비를 할 수가 있습니다. 단순히 KOSPI지수를 상회한다고 했지만, KOSPI지수 자체가 폭락해버린다면 우리 자산에 대한 타격도 그만큼 클 수 밖에 없기 때문입니다.
프로젝트를 마치면서 느낀 점은, 데이터를 다루는 직업은 다시 생각해봐도 정말 매력적인거 같아요. 이렇게 다양한 분야에 걸쳐서 심도 있게 열정을 가지고 공부할 수 있는 원동력이 되어준다는 것만으로도 나의 발전 가능성과 자아 실현의 길을 열어주는 느낌이랄까.. 할 때는 힘들고 막막하더라도 어느정도 끝나고 나니 이렇게 뿌듯할 수가 없네요.
그럼 이상으로 이번 포스팅은 마치겠습니다.
긴 글 읽어주셔서 감사합니다.
'Data Science > Project' 카테고리의 다른 글
좋은 챗봇이란 무엇일까? QA, RM, PPO에 대해서 (1) | 2024.11.19 |
---|---|
[캐글] Cassava leaf disease classification (0) | 2021.03.08 |