GIL's LAB

실험 8. 캔들 패턴 분석: (1) 상승장악형, 하락장악형 본문

퀀트 투자/실험 일지

실험 8. 캔들 패턴 분석: (1) 상승장악형, 하락장악형

GIL~ 2021. 11. 17. 20:15

개요

이번 실험에서는 캔들 패턴을 분석하여 현재 시장에서 매수와 매도 세력 중 어느 세력이 더 강한지를 판단하여 투자하는 전략을 검증해보겠습니다. 굉장히 많은 종류의 캔들 패턴이 있지만, 상승장악형, 하락장악형, 적삼병, 흑삼병, 샛별형이라는 다섯 개의 캔들 패턴을 검증하고, 이번 실험에서는 상승장악형과 하락장악형만 검증해보겠습니다. 구체적으로 각 패턴이 등장했는지 여부를 확인하고, 등장일의 주가와 5, 20, 60, 120 영업일 이후 주가를 비교해보겠습니다

 

데이터 준비

이번 실험에는 주가 데이터만 필요합니다. FinanceDataReader를 이용하여 2011년부터 2021년까지의 전 종목의 주가 데이터를 수집했습니다. 주가 데이터를 올리려고 했더니 파일 크기 제한이 있네요. 

 

이제 데이터를 불러옵니다. 패턴이 드러나려면 어느 정도 데이터가 있어야 하므로, 길이가 300이상인 데이터만 필터링하여 sp_data_dict에 추가하겠습니다.

import os
os.chdir("../../데이터")

import pandas as pd
sp_data_dict = dict()
for file_name in os.listdir("주가데이터"):
    stock_price_data = pd.read_csv("주가데이터/" + file_name, parse_dates = ['Date'])
    if len(stock_price_data) >= 300:
        sp_data_dict[file_name[:-4]] = stock_price_data

 

상승 장악형

상승장악형은 대표적인 매수 신호 패턴으로 다음 그림과 같이 음봉을 완전히 감싸는 양봉이 출현한 패턴을 뜻합니다.

위 그림을 보면 왼쪽에 있는 음봉이 오른쪽에 있는 양봉에 완전히 속함을 알 수 있습니다. 이 패턴을 규칙으로 작성하면 다음과 같습니다. 이처럼 규칙으로 작성해야 프로그래밍하기 좋습니다.

  • 전일에 음봉 생성: 전일 시가 > 전일 종가
  • 당일에 양봉 생성: 당일 시가 < 당일 종가
  • 전일 음봉이 당일 양봉에 포함: 전일 저가 >당일 시가 & 전일 고가 < 당일 종가.

위에서 작성한 규칙을 바탕으로 상승장악형 패턴이 발생했는지 여부를 나타내는 컬럼을 생성해보겠습니다.

for stock_name in sp_data_dict.keys():
    stock_data = sp_data_dict[stock_name]
    cond_1 = (stock_data['Open'] > stock_data['Close']).values[:-1]
    cond_2 = (stock_data['Open'] < stock_data['Close']).values[1:]
    cond_3 = (stock_data['Low'].values[:-1] > stock_data['Open'].values[1:])
    cond_4 = (stock_data['High'].values[:-1] < stock_data['Close'].values[1:])

    cond = cond_1 & cond_2 & cond_3 & cond_4
    cond = np.insert(cond, 0, False)
    stock_data["상승장악형"] = cond

여기서 values 속성을 사용하여 데이터만 취한 이유는 시리즈 간 연산을 했을 때 인덱스가 달라 계산 결과가 꼬이는 것을 방지하기 위함입니다. cond_1은 전일 시가 > 전일 종가, cond_2는 당일 시가 < 당일 종가, cond_3은 전일 저가 > 당일 시가, cond_4는 전일 고가 < 당일 종가를 나타내며, 모든 조건이 True인 경우에만 상승장악형이라 볼 수 있으므로 교집합을 수행하여 cond에 저장합니다. 단, 0번째 요소는 이전 주가 정보가 없으므로 np.insert 함수를 이용하여 False를 추가해줍니다.

 

이제 상승장악형이 발생한 다음 날 매수하고 n 영업일 후에 매도했을 때의 수익과 그렇지 않을 때의 수익을 비교하는 함수를 작성하겠습니다. 

 1 def BE_profit(stock_data, n):
 2     pat_idx_list = stock_data.loc[stock_data["상승장악형"]].index
 3     no_pat_idx_list = stock_data.index.difference(pat_idx_list)
 4     max_idx = max(stock_data.index)
 5     
 6     pat_buy_idx_list = pat_idx_list[pat_idx_list + n < max_idx] + 1
 7     pat_sell_idx_list = pat_buy_idx_list + n
 8     no_pat_buy_idx_list = no_pat_idx_list[no_pat_idx_list + n < max_idx] + 1
 9     no_pat_sell_idx_list = no_pat_buy_idx_list + n
10     
11     pat_buy_price_list = stock_data.loc[pat_buy_idx_list, 'Close'].values
12     pat_sell_price_list = stock_data.loc[pat_sell_idx_list, 'Close'].values
13     pat_profit_list = (pat_sell_price_list - pat_buy_price_list) / pat_buy_price_list * 100
14     
15     no_pat_buy_price_list = stock_data.loc[no_pat_buy_idx_list, 'Close'].values
16     no_pat_sell_price_list = stock_data.loc[no_pat_sell_idx_list, 'Close'].values
17     no_pat_profit_list = (no_pat_sell_price_list - no_pat_buy_price_list) / no_pat_buy_price_list * 100
18     
19     return pat_profit_list.tolist(), no_pat_profit_list.tolist()
  • 라인 2: 상승장악형이 True인 행의 인덱스를 정의합니다.
  • 라인 3: 상승장악형이 False인 행의 인덱스를 정의합니다.
  • 라인 4: 인덱스의 최댓값은 한 인덱스에서 n을 더했을 때 최대 인덱스를 넘어서버리면 오류가 발생하기 때문에 일종의 기준점으로 사용하기 위해 정의합니다.
  • 라인 6: pat_idx_listn을 더했을 때 max_idx보다 작거나 같은 인덱스만 필터링했습니다. 이렇게 하면 라인 7에서 pat_buy_idx_listn을 더하더라도 원 데이터의 인덱스를 넘어서는 일이 발생하지 않습니다.
  • 라인 11 - 17: 상승장악형에서의 수익률과 그렇지 않았을 때의 수익률을 각각 계산합니다. 
  • 라인 19: ndarray 자료형인 pat_profit_list와 no_pat_profit_list를 리스트로 변환합니다. 이는 다른 결과와 손쉽게 합치기 위함입니다. 

이제 전 종목과 n = 5, 20, 60, 120에 대해 위 함수를 적용한 뒤, 상승장악형이 발생했을 때 매수하는 전략을 검증해보겠습니다. 먼저, n에 대해서 모든 종목을 순회하면서 BE_profit 함수를 적용한 결과를 각각 total_pat_profit_listtotal_no_pat_profit_list에 추가한 뒤, 평균을 구하겠습니다.

 1 pat_mean_list = []
 2 no_pat_mean_list = []
 3 for n in [5, 20, 60, 120]:
 4     total_pat_profit_list = []
 5     total_no_pat_profit_list = []
 6     for stock_name in sp_data_dict.keys():
 7         stock_data = sp_data_dict[stock_name]
 8         pat_profit_list, no_pat_profit_list = BE_profit(stock_data, n = n)
 9         total_pat_profit_list += pat_profit_list
10         total_no_pat_profit_list += no_pat_profit_list
11     
12     pat_mean = np.mean(total_pat_profit_list)
13     no_pat_mean = np.mean(total_no_pat_profit_list)
14     
15     pat_mean_list.append(pat_mean)
16     no_pat_mean_list.append(no_pat_mean)

이 결과를 막대 그래프로 시각화해보겠습니다.

1 plt.figure(figsize = (10, 6))
2 plt.bar([0, 2, 4, 6], pat_mean_list, color = "blue", label = "패턴고려O")
3 plt.bar([1, 3, 5, 7], no_pat_mean_list, color = "orange", label = "패턴고려X")
4 plt.ylabel("평균수익률")
5 plt.xticks(range(8), [5, 5, 20, 20, 60, 60, 120, 120])
6 plt.legend(loc = "upper right")

패턴이 발생했을 때 매수하는 것이 그렇지 않았을 때보다 오히려 기대 수익은 더 낮음을 알 수 있습니다.

이번엔 중위수를 확인해보겠습니다.

pat_median_list = []
no_pat_median_list = []
for n in [5, 20, 60, 120]:
    total_pat_profit_list = []
    total_no_pat_profit_list = []
    for stock_name in sp_data_dict.keys():
        stock_data = sp_data_dict[stock_name]
        pat_profit_list, no_pat_profit_list = BE_profit(stock_data, n = n)
        total_pat_profit_list += pat_profit_list
        total_no_pat_profit_list += no_pat_profit_list
    
    pat_median = np.quantile(total_pat_profit_list, 0.5)
    no_pat_median = np.quantile(total_no_pat_profit_list, 0.5)
    
    pat_median_list.append(pat_median)
    no_pat_median_list.append(no_pat_median)
    
plt.figure(figsize = (10, 6))
plt.bar([0, 2, 4, 6], pat_median_list, color = "blue", label = "패턴고려O")
plt.bar([1, 3, 5, 7], no_pat_median_list, color = "orange", label = "패턴고려X")
plt.ylabel("중위수")
plt.xticks(range(8), [5, 5, 20, 20, 60, 60, 120, 120])
plt.legend(loc = "upper right")

중위수 역시 상승장악형이 나왔을 때 투자하는 것이 부적절함을 보여줍니다. 

 

하락 장악형

하락장악형은 대표적인 매도 신호 패턴으로 다음 그림과 같이 양봉을 완전히 감싸는 음봉이 출현한 패턴을 뜻합니다.

위 그림을 보면 왼쪽에 있는 음봉이 오른쪽에 있는 양봉에 완전히 속함을 알 수 있습니다. 이 패턴을 규칙으로 작성하면 다음과 같습니다. 

  • 전일에 양봉 생성: 전일 시가 < 전일 종가
  • 전일 양봉이 당일 음봉에 포함: 전일 고가 < 당일 시가 & 전일 저가 > 당일 종가.
  • 당일에 음봉 생성: 당일 시가 > 당일 종가

 

위에서 정의한 규칙을 바탕으로 다음과 같이 하락장악형 컬럼을 생성하겠습니다.

 1 for stock_name in sp_data_dict.keys():
 2     stock_data = sp_data_dict[stock_name]
 3     cond_1 = (stock_data['Open'] < stock_data['Close']).values[:-1]
 4     cond_2 = (stock_data['Open'] > stock_data['Close']).values[1:]
 5     cond_3 = (stock_data['High'].values[:-1] < stock_data['Open'].values[1:])
 6     cond_4 = (stock_data['Low'].values[:-1] > stock_data['Close'].values[1:])
 7 
 8     cond = cond_1 & cond_2 & cond_3 & cond_4
 9     cond = np.insert(cond, 0, False)
10     stock_data["하락장악형"] = cond

 

이번에는 이전에 작성한 BE_profit 함수를 조금 더 범용적으로 사용할 수 있도록 패턴의 등장 여부를 나타내는 컬럼을 추가로 입력받도록 수정하겠습니다.

 1 def calculate_profit(stock_data, col, n):
 2     pat_idx_list = stock_data.loc[stock_data[col]].index
 3     no_pat_idx_list = stock_data.index.difference(pat_idx_list)
 4     max_idx = max(stock_data.index)
 5     
 6     pat_buy_idx_list = pat_idx_list[pat_idx_list + n < max_idx] + 1
 7     pat_sell_idx_list = pat_buy_idx_list + n
 8     no_pat_buy_idx_list = no_pat_idx_list[no_pat_idx_list + n < max_idx] + 1
 9     no_pat_sell_idx_list = no_pat_buy_idx_list + n
10     
11     pat_buy_price_list = stock_data.loc[pat_buy_idx_list, 'Close'].values
12     pat_sell_price_list = stock_data.loc[pat_sell_idx_list, 'Close'].values
13     pat_profit_list = (pat_sell_price_list - pat_buy_price_list) / pat_buy_price_list * 100
14     
15     no_pat_buy_price_list = stock_data.loc[no_pat_buy_idx_list, 'Close'].values
16     no_pat_sell_price_list = stock_data.loc[no_pat_sell_idx_list, 'Close'].values
17     no_pat_profit_list = (no_pat_sell_price_list - no_pat_buy_price_list) / no_pat_buy_price_list * 100
18     
19     return pat_profit_list.tolist(), no_pat_profit_list.tolist()

라인 1에서 col을 추가로 입력받고, 라인 2에서 stock_data[col]을 사용하는 것을 제외하면 이전에 정의한 BE_profit 함수와 같은 함수입니다.

 

이전과 같은 방식으로 하락장악형이 등장했을 때 기대 수익과 그렇지 않았을 때의 기대 수익을 비교해보겠습니다.

 1 pat_mean_list = []
 2 no_pat_mean_list = []
 3 for n in [5, 20, 60, 120]:
 4     total_pat_profit_list = []
 5     total_no_pat_profit_list = []
 6     for stock_name in sp_data_dict.keys():
 7         stock_data = sp_data_dict[stock_name]
 8         pat_profit_list, no_pat_profit_list = calculate_profit(stock_data, col = "하락장악형", n = n)
 9         total_pat_profit_list += pat_profit_list
10         total_no_pat_profit_list += no_pat_profit_list
11     
12     pat_mean = np.mean(total_pat_profit_list)
13     no_pat_mean = np.mean(total_no_pat_profit_list)
14     
15     pat_mean_list.append(pat_mean)
16     no_pat_mean_list.append(no_pat_mean)
17     
18 plt.figure(figsize = (10, 6))
19 plt.bar([0, 2, 4, 6], pat_mean_list, color = "blue", label = "패턴고려O")
20 plt.bar([1, 3, 5, 7], no_pat_mean_list, color = "orange", label = "패턴고려X")
21 plt.ylabel("평균")
22 plt.xticks(range(8), [5, 5, 20, 20, 60, 60, 120, 120])
23 plt.legend(loc = "upper right")

하락장악형은 대표적인 매도신호이므로 수익률이 음수일 가능성이 나올 것이라 예상했지만, 이러한 캔들패턴이 등장했음에도 수익이 큰 경우가 있음을 알 수 있습니다.

정확히 알아보기 위해, 통계량을 계산하여 비교해보겠습니다.

 1 result = pd.DataFrame()
 2 column_names = []
 3 for n in [5, 20, 60, 120]:
 4     total_pat_profit_list = []
 5     total_no_pat_profit_list = []
 6     for stock_name in sp_data_dict.keys():
 7         stock_data = sp_data_dict[stock_name]
 8         pat_profit_list, no_pat_profit_list = calculate_profit(stock_data, col = "하락장악형", n = n)
 9         total_pat_profit_list += pat_profit_list
10         total_no_pat_profit_list += no_pat_profit_list
11     
12     result = pd.concat([result, pd.Series(total_pat_profit_list).describe()], axis = 1)
13     result = pd.concat([result, pd.Series(total_no_pat_profit_list).describe()], axis = 1)
14     column_names += ["패턴O_{}".format(n), "패턴X_{}".format(n)]
15     
16 result.columns = column_names
17 display(result.round(2))

  • 라인 1: 결과를 데이터프레임으로 초기화합니다.
  • 라인 2: result의 컬럼명을 초기화합니다.
  • 라인 12: total_pat_profit_list를 시리즈로 변환한 뒤 describe 메서드를 사용한 결과를 concat 함수를 사용하여 result의 열로 부착합니다.
  • 라인 14: 라인 12 – 13에서 컬럼 두 개를 부착했으므로 그때의 컬럼명을 추가합니다.
  • 라인 16: result의 컬럼명을 업데이트합니다. 참고로 concat을 사용하여 열 방향으로 시리즈를 부착하게 되면 컬럼명이 0, 1, 2, …로 자동 설정됩니다.

실행 결과를 전반적으로 살펴보면, 패턴이 발생했을 때 매수하는 것이 패턴이 발생하지 않았을 때 매수하는 것보다 낮은 수익을 보임을 알 수 있습니다. 매도 신호이기에 예상한 결과라고 생각할 수도 있지만, 3사분위수나 최댓값은 대부분 양수이고 최댓값이 지나치게 큰 경우도 종종 있습니다.

 

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

 

Comments