GIL's LAB

실험 6. 증권사의 의견에 따라 투자해보기 본문

퀀트 투자/실험 일지

실험 6. 증권사의 의견에 따라 투자해보기

GIL~ 2021. 10. 3. 13:20

개요 

이번 실험에서는 증권사 리포트를 참고하여 투자했을 때 얻는 기대 수익을 계산해볼 것이다.

구체적으로 확인해보고자 하는 내용은 아래와 같다.

  • 리포트의 평가 의견 분포 확인하기
  • 리포트에 자주 등장하는 기업 확인하기
  • 리포트의 의견에 따라 구매했을 때 얻게될 기대 수익 계산하기
  • 리포트에서 언급하는 적정 주가를 도달하는데 소요되는 시간 계산하기
  • 어느 증권사의 의견이 잘 맞는지 확인하기

 

실험 데이터

실험 데이터는 2016년 9월 1일부터 2021년 8월 31일까지의 코스피와 코스닥 종목의 주가 데이터 (수집 방법은 여기 참고)와 요약된 증권사 리포트 (수집 방법은 여기 참고)를 사용한다.

 

 

실험 내용

데이터 준비 및 전처리

먼저 실험에 필요한 모듈을 불러오고, 기본적인 세팅을 한다. 

# 모듈 불러오기
import pandas as pd
import os
import warnings
import seaborn as sns
from matplotlib import pyplot as plt

sns.set()
warnings.filterwarnings("ignore")

리포트 데이터도 불러와서 확인해보자.

# 리포트 데이터 불러오기
report_data = pd.read_csv("../../QUANT_DATA/201609~202108/기업리포트/기업리포트_요약.csv", encoding = "cp949")
report_data.head()

아래 그림처럼 작성일, 종목명, 적정가격, 평가의견 등이 있고, 적정가격이나 평가의견을 제시하지 않는 경우도 있음을 확인할 수 있다.

그럼 이제 자주 등장하는 종목명을 확인해보자.

# 리포트가 자주 등장하는 상위 20개 종목 확인
report_data['종목명'].value_counts().head(20)

예상한대로 대기업이 많았고, 삼성전자는 전체 리포트의 1.8%를 차지하는 것을 확인했다.

 

이제 리포트 데이터를 정제해보자.

먼저, 적정 가격에 콤마(,)가 포함되어 있어 문자열로 인식되는 문제가 있으니, 다음과 같이 숫자로 변환해준 뒤 적정 가격의 분포를 확인한다.

 

# 적정 가격 숫자로 변환하기
report_data['적정가격'] = report_data['적정가격'].str.replace(",", "").astype(int)
# 적정 가격 분포 --- 0으로 되어 있는 것이 존재 --> 삭제
report_data['적정가격'].describe()

균일하게 잘 분포한 것 같지만, 최소값이 0이라는 것이 마음에 걸린다.

그래서 적정 가격이 0을 초과하는 리포트만 필터링한다.

# 적정 가격이 0이 아닌 것으로 필터링 수행
report_data = report_data.loc[report_data['적정가격'] > 0]

이제는 평가의견을 확인해보자.

# 평가의견 통일하기
report_data['평가의견'].unique()

위와 같이 다양한 의견이 있고, 사실상 같은 의미인데 표현만 다른 것이 보인다.

예를 들어, 매수, Buy, 적극매수는 모두 buy로 통일시킬 수 있을 것이다.

통일되지 않으면 매수따로, Buy따로 해석하는 문제가 생길 수 있으므로 딕셔너리를 정의해서 평가의견에 있는 내용들을 통일시켜준다.

전부다 사용하진 않고, buy, sell, neutral만 사용한다.

# 의견 분리 -- buy / neutral / sell
# 확실한 주장이 없는 의견 무시
temp = {"Buy":"buy", "TradingBuy":"buy", "매수":"buy", "StrongBuy":"buy", "적극매수":"buy",
       "중립":"neutral", "보유":"neutral", "중립":"neutral", "보유":"neutral", "Neutral":"neutral", "Hold":"neutral",
       "Reduce":"sell", "축소":"sell"}

report_data['평가의견'] = report_data['평가의견'].replace(temp)

그리고나서 분포를 확인해보자.

# buy가 압도적으로 많고, 그 다음 neutral --> 이 둘만 고려하기
# sell은 역시 거의 없음
report_data['평가의견'].value_counts()

예상대로 buy가 압도적이었고, sell은 거의 없었다. 

그래서 buy와 neutral인 평가의견만 필터링하자.

report_data = report_data.loc[report_data['평가의견'].isin(["buy", "neutral"])]

이번엔 작성일에 대한 전처리를 하자.

먼저 작성일을 datetime으로 바꿔주고, 작성일이 2020년 9월 1일 이전인 것만 가져온다.

2020년 9월 1일로 필터링한 이유는 보유중인 데이터 기간과 실험 내용때문이다.

# 작성일 날짜로 바꾸기
report_data['작성일'] = pd.to_datetime(report_data['작성일'])

# 최대 1년 후까지 예측해야 하므로 작성일이 2020년 9월 1일 이전인 것만 필터링
# 현재 보유 중인 데이터 기간이 2016년 9월 1일 ~ 2021년 8월 31일까지임
report_data = report_data.loc[report_data['작성일'] < pd.to_datetime("2020/09/01")]

 

이제 리포트 데이터의 종목명을 기준으로 주가 데이터를 가져온 뒤, stock_price_data라는 딕셔너리에 추가하자.

stock_name_list를 stock_name으로 순회하면서 stock_name이 코스피 폴더에 있으면 코스피 데이터를, 코스닥 폴더에 있으면 코스닥 데이터를 가져온다.

여기서 날짜가 YYYYMMDD (숫자)로 되어 있기 때문에, 날짜 형식으로 바꿔준 뒤, 인덱스로 설정해준다.

astype(str)을 사용해서 문자로 바꾼 뒤에 특정 위치의 문자열만 가져오도록 하자 (조만간 나만 알고 있기 아까운 판다스 함수 사용 방법 정리 좀 해야겠다.)

# 자주 등장하는 종목 주가 데이터 붙이기
# 리포트에 등장한 종목 목록 정의
stock_name_list = report_data['종목명'].unique()

stock_price_data = dict() # key: 종목 이름, value: 종목의 주가 데이터 (2016-09-01 ~ 2021-09-30)
for stock_name in stock_name_list:
    # 코스피 폴더에 있으면 코스피 폴더에서 데이터를 가져와서 stock_price_data에 추가
    if stock_name + ".csv" in os.listdir("../../QUANT_DATA/201609~202108/주가/일/KOSPI"):
        data = pd.read_csv("../../QUANT_DATA/201609~202108/주가/일/KOSPI/" + stock_name + ".csv", encoding = "cp949")
        # 날짜를 datetime 형태로 변환
        YYYY = data["날짜"].astype(str).str[:4]
        MM = data["날짜"].astype(str).str[4:6]
        DD = data["날짜"].astype(str).str[6:8]
        data["날짜"] = pd.to_datetime(YYYY + "/" + MM + "/" + DD)
        
        data.set_index("날짜", inplace = True) # 날짜를 인덱스로 설정
        stock_price_data[stock_name] = data
        
        
    # 코스닥 폴더에 있으면 코스닥 폴더에서 데이터를 가져와서 stock_price_data에 추가
    elif stock_name + ".csv" in os.listdir("../../QUANT_DATA/201609~202108/주가/일/KOSDAQ"):
        data = pd.read_csv("../../QUANT_DATA/201609~202108/주가/일/KOSDAQ/" + stock_name + ".csv", encoding = "cp949")
        YYYY = data["날짜"].astype(str).astype(str).str[:4]
        MM = data["날짜"].astype(str).astype(str).str[4:6]
        DD = data["날짜"].astype(str).astype(str).str[6:8]
        data["날짜"] = pd.to_datetime(YYYY + "/" + MM + "/" + DD)

        data.set_index("날짜", inplace = True) # 날짜를 인덱스로 설정        
        stock_price_data[stock_name] = data
        
    # 어디에도 없으면 레포트에서 해당 종목 삭제
    else:
        report_data = report_data.loc[report_data["종목명"] != stock_name]

 

리포트 의견에 따라 구매했을 때의 기대 수익 계산

먼저 특정 날짜의 주가를 가져오는 함수를 만들자.

이 함수는 주가 데이터(data)에 날짜(date)를 입력하면, 해당 날짜의 주가를 가져온다.

그런데 date에 장이 열리지 않았다면, 가장 최근에 열린 장의 데이터를 가져오도록 설계했다.

조금 위험한 함수인게, 인덱스에 날짜 자체가 없어버리면 무한루프에 빠진다 (사실 오버플로우 에러가 나기 때문에 빠져나오긴한다...).

def get_nearest_close_price(data, date):
    # data에서 날짜가 date일 때의 종가 가져오기
    # 단, date일 때 장이 열리지 않았다면, 가장 최근에 열린 장의 데이터를 사용
    # 예: 2021년 10월 2일 가격이 없으면 10월 1일 가격을 가져옴
    while True:
        if date in data.index:
            return data.loc[date, '종가']
            break
        else:
            date = date - pd.to_timedelta(1, unit = "D")

 

이제 평가의견이 buy인 종목을 구매한뒤, 1개월, 3개월, 6개월, 1년 후의 가격을 바탕으로 기대 수익을 계산해보자.

리포트 데이터의 모든 행을 돌면서, 리포트 작성일의 주가, 1개월, 3개월, 6개월, 1년 후의 주가를 계산하여 result에 추가한다.

여기서 한 달 후의 날짜를 찾는데 pd.to_timedelta(1, "M")를 사용하지 않고 pd.to_timedelta(30, "D")를 쓴 이유는 한 달이라는 기간이 달마다 다르기에 (어떤 달은 30일, 어떤 달은 31일), timedelta로 쓰기에는 모호하다고 더 이상 판다스에서 지원을 안 한다고 한다. 

단순히 다음 달이 필요한 거라면 현재 날짜에서 달 값만 바꾸면 되긴 할 것 같다.

result = []
for _, record in report_data.iterrows(): # 모든 행을 돌면서 
    if record["평가의견"] == "buy":
        stock_name = record['종목명']
        data = stock_price_data[stock_name]
        date = record['작성일']
        
        # 날짜 범위에 데이터가 없으면 무시
        if date < data.index.min() or date + pd.to_timedelta(365, "D") > data.index.max():
            continue
        
        # 날짜에 맞는 데이터 가져오기
        # to_timedelta에서 1M, 1Y 등이 모호하기때문에 더 이상 지원을 안함
        # 따라서 한달을 30일로 간주하여 day를 계산함
        current_price = get_nearest_close_price(data, date)
        price_after_1M = get_nearest_close_price(data, date + pd.to_timedelta(30, "D"))
        price_after_3M = get_nearest_close_price(data, date + pd.to_timedelta(90, "D"))
        price_after_6M = get_nearest_close_price(data, date + pd.to_timedelta(180, "D"))
        price_after_1Y = get_nearest_close_price(data, date + pd.to_timedelta(365, "D"))

        # 결과를 result에 추가
        result.append([stock_name, date, current_price, price_after_1M, price_after_3M, price_after_6M, price_after_1Y])

# 결과를 데이터프레임으로 변환
result = pd.DataFrame(result, columns = ["종목명", "날짜", "현재가", "1개월후가격", "3개월후가격", "6개월후가격", "1년후가격"])

 

그리고 가격을 바탕으로 상승폭 (=수익)을 계산해보자.

# 결과 시각화: 한 장에 증가폭을 그려보기
temp = result[['현재가', "1개월후가격", "3개월후가격", "6개월후가격", "1년후가격"]].copy()

# 상승폭 계산
temp["1개월후상승폭"] = (temp["1개월후가격"] - temp["현재가"]) / temp["현재가"]
temp["3개월후상승폭"] = (temp["3개월후가격"] - temp["현재가"]) / temp["현재가"]
temp["6개월후상승폭"] = (temp["6개월후가격"] - temp["현재가"]) / temp["현재가"]
temp["1년후상승폭"] = (temp["1년후가격"] - temp["현재가"]) / temp["현재가"]

그리고 결과를 시각화해보자 (지금 생각하니, 박스플롯으로 시각화하는게 더 적합했을 것 같다...)

 

# 포인트가 너무 많아서 해석이 어렵지만, 육안으로 봤을 때 절반가량은 오른 것 같음 (기준선 위)
fig = plt.figure(figsize = (16, 6))
plt.scatter(range(len(temp)), temp['1개월후상승폭'], alpha = 0.8)
plt.axhline(y = 0, color = "red")

포인트가 너무 많아서 빨간선을 기준으로 어느쪽에 더 포인트가 많은지 정확히 알긴 어렵지만, 대체적으로 위쪽에 좀 더 많은 것 같다. 같은 방법으로 3개월 후, 6개월 후, 1년 후도 시각화할 수 있다.

 

그런데 시각화는 큰 의미가 없는 듯 하니, 바로 기술통계분석을 수행해보자.

# 기술적인 분석 수행
temp[["1개월후상승폭", "3개월후상승폭", "6개월후상승폭", "1년후상승폭"]].describe()

# 모두 평균적으로는 이익을 봄
# 그러나 절반 이상이 결국 손해로 이어짐 (중위수가 모두 음수)
# 최악의 경우 84%나 떨어짐...

다행히도(?), 평균은 모두 양수이다.

그런데 중위수가 모두 음수이다. 다시 말하면, 절반 가량은 리포트 말대로 buy를 할 것이 아니라 sell을 했어야 하는 종목들이다. 

같은 방식으로 의견이 neutral인 종목에 대해서도 분석한 결과는 다음과 같다.

역시 모두 평균이 양수이고, 중위수가 음수인데, 놀라운 점은 neutral이라고 평가한 종목을 구매하는게 buy라고 평가한 종목을 구매하는 것보다 더 이익이라는 것이다. 

물론 상위 종목만 사용했거나, 데이터의 기간을 늘리거나 하면 결과는 달라졌을 수도 있겠지만, 어찌되었건 5년치에 대한 내용은 놀랍다.

 

목표가 도달까지 걸리는 시간 및 비율 계산

사실 목표가는 고사하고 손실로 이어지는 경우도 있다는 것을 확인했기 때문에, 그리 중요한 내용은 아닐 수도 있지만, 증권사가 이야기하는 목표가 도달까지 걸리는 시간이나 도달하는 비율은 얼마나 되는지를 확인해보자.

 

코드는 리포트 데이터를 모두 순회하면서, 미래에 적정가격에 도달했다면 미래와 작성일의 차이를 time_to_fair_price에 저장한다. 만약 도달하지 못했다면 해당 값을 -1로 둔채말이다. 

result = pd.DataFrame()
for _, record in report_data.iterrows(): # 모든 행을 돌면서 평가의견이 buy라면
    if record["평가의견"] == "buy":
        stock_name = record['종목명']
        data = stock_price_data[stock_name]
        date = record['작성일']
        fair_price = record["적정가격"]
        time_to_fair_price = -1
        
        # 날짜 범위에 데이터가 없으면 무시
        if date < data.index.min() or date + pd.to_timedelta(365, "D") > data.index.max():
            continue
        
        if sum((data['종가'] >= fair_price) & (data.index > date)): # 적정가격까지 도달한 기록이 있다면
            date_fair_price = data.loc[(data['종가'] >= fair_price) & (data.index > date)].iloc[-1].name
            time_to_fair_price = (date_fair_price - date).days
        
        # 소요기간 추가
        record.loc["소요기간"] = time_to_fair_price
        result = result.append(record)

이제 적정 가격에 도달한 종목들이 적정 가격까지 도달하는데 소요된 기간에 대한 분포를 확인해보자.

# 적정 가격까지 도달하는데 소요되는 시간에 대한 분포
# 최소값이 1인 것은 이상.... 하루만에 적정 가격 도달?
result.loc[result['소요기간'] > -1, '소요기간'].describe()

평균적으로 434일이 걸리는 걸 알 수 있는데, 사실 1년 보유하면 어지간하면 오르진않나 라는 생각이 들게 한다.

그런데 눈에 거슬리는 것은 최소값이 1이라는 것이다. 

즉, 리포트를 내고 다음 날 리포트에서 이야기하는 적정 가격에 도달했다는 이야기인데, 코드가 잘못됐거나 리포트 작성자가 신기가 있거나라고 생각할 수 있다. 

확인해보니, buy라고 해놓고 적정가격이 오늘 종가랑 거의 유사하거나, 아니면 갑자기 상한가를 치면서 바로 도달하는 경우가 있어서 최소값이 1이 나오는 것이었다.

 

 

그래서 최소한 10% 이상 상승할 것이라고 이야기한 리포트만 필터링해서 위 작업을 다시 수행한다.

# 현 주가와 적정 가격의 차이가 10% 이상 나는데도 잘 맞춘 케이스만 필터링
result = pd.DataFrame()
for _, record in report_data.iterrows(): # 모든 행을 돌면서 평가의견이 buy라면
    if record["평가의견"] == "buy":
        stock_name = record['종목명']
        data = stock_price_data[stock_name]
        date = record['작성일']
        fair_price = record["적정가격"]
        
        time_to_fair_price = -1
        
        # 날짜 범위에 데이터가 없으면 무시
        if date < data.index.min() or date + pd.to_timedelta(365, "D") > data.index.max():
            continue
        
        current_price = get_nearest_close_price(data, date)
        if abs(fair_price - current_price) / current_price < 0.1:
            continue
        
        if sum((data['종가'] >= fair_price) & (data.index > date)): # 적정가격까지 도달한 기록이 있다면
            date_fair_price = data.loc[(data['종가'] >= fair_price) & (data.index > date)].iloc[-1].name
            time_to_fair_price = (date_fair_price - date).days
        
        # 소요기간 추가
        record.loc["소요기간"] = time_to_fair_price
        result = result.append(record)

드라마틱하게 변하진 않았지만, 전반적으로 값들이 커졌다.

정리하면, Buy 의견을 낸 리포트를 보고 구매하면 평균적으로 444일 후에는 10% 이상 수익이 나는 목표가에 도달한다.

함정은 목표가 도달은 고사하고 손실을 보는 경우가 절반을 넘는다는 것이다.

어느 증권사가 잘 맞추나??

마지막으로 누가누가 잘 맞췄나를 확인해보자.

작성 기관을 순회하면서, 목표 주가를 맞춘 비율을 계산한다.

count_result = pd.Series()
for idx in result['작성기관'].value_counts().index:
    count_result[idx] = result.loc[result['소요기간'] > 0, '작성기관'].value_counts().loc[idx] / sum(report_data['작성기관'] == idx)

그리고나서 내림차순으로 정렬한 뒤, 확인한다.

count_result.sort_values(ascending = False)

50% 이상의 정답률 보이는 증권사는 아래와 같다.

메리츠증권과 NH농협증권은 리포트 자체가 너무 적기 때문에 100%라고 해도 큰 의미는 없다.

이들을 제외한 상위권인 증권사는 대신증권, 현대차증권, 교보증권 등으로 약 55% 이상의 정답률을 보인다.

최하위는 KB증권이었는데 정답률이 41.2%에 불과하다.

그렇다고 KB증권 리포트는 믿지말고, 대신증권 리포트는 맹신하자라고 결론지을 수도 없다. 

왜냐하면 애초에 목표가를 높게 잡으면 정답률이 떨어질테고, 낮게 잡으면 정답률이 높아질테니 말이다.

 

 

오늘의 실험 코드는 아래에서 다운받을 수 있다.

실험6. 증권사 의견에 따라 투자해보기.ipynb
0.61MB

 

수집하고 싶은 금융 데이터나 실험하고 싶은 퀀트 관련 아이디어가 있으면 댓글로 남겨주세요! 
관련 포스팅을 준비하도록 하겠습니다!

 

Comments