Skip to content

유튜브에서 발견한 backtest 전략 추가하기 #3

사이드 프로젝트 링크 : https://www.github.com/fromitive/backtest

코드 작성 전 고려해야 할 사항

지난 시간에는 Stocastic RSI 값이 실제 거래소 값과 차이가 있어서 이를 줄여보았다.

이번에는 buy signal이 어디에 발생했는지 확인 후 수정할 사항이 없나 살펴볼 것이다.

결과는 아래의 그림과 같으며, BUY 시그널이 연속적으로 나타나는 구간이 다수 존재하는걸 볼 수 있다.

Image title

그림 1 - BUY 시그널 분포

이렇게 되면 사고 팔때, 돈이 부족하거나, 백테스팅의 결과가 불규칙적으로 나타날 수 있으므로1 인접한 BUY 시그널은 없애도록 하자

그렇다면 아래의 요구사항을 추가하여 가상코드를 작성하자.

추가 요구사항
1. 인접한 캔들차트에 BUY시그널이 붙어있으면, KEEP으로 변환한다.

가상 코드 작성

구매한 주식을 어느 시점에 팔지는 처음 글에서도 설명했으나, 아래의 매도 조건을 참고하여 작성한다.

롱 포지션 함수 작업
준비작업 : EMA 200 곡선, Stocastic RSI, RSI

- 매수 
  1. ema 200 값보다 캔들이 위에 있어야 함
  2. stocastic rsi가 25 미만이어야 함
  3. k(파란색 선)가 d(주황색 선)보다 위에 있어야 한다(이전 캔들과 비교하여 돌파 하는 걸 캐치하면 될 것 같다.)

- 매도 :
  1. 매수를 진행하고 나서 3퍼센트 이상 넘으면 매도하라고 하는데 여기서는 2 퍼센트 이상일 때 매도 한다.
  2. 손절을 1퍼센트 밑으로 떨어질 때 매도

매도는 매수를 해야 할 수 있으므로, 매수한 주식을 기준으로 수익율을 계산한다.

만약 매도 조건이 나타나지 않았을 경우, 어떻게 해야 할까? 그건 위의 추가 요구사항을 응용하여

다음 BUY 시그널이 일정 캔들차트 개수 이상 떨어져 있을 경우 그자리에서 매도하는 걸로 결정한다.

그렇다면 아래와 같이 추가 요구사항이 생겨야 한다.

추가 요구사항2
1. 인접한 캔들차트에 BUY시그널이 붙어있으면, KEEP으로 변환한다.
2. 다음 BUY 시그널이 인접한 차트에 있지 않고, 이전 BUY 시그널이 SELL을 하지 않았을 경우 
   그 즉시 SELL을 하고, 다음 캔들에 BUY 시그널을 넣는다. 

이제 본격적으로 가상코드를 작성한다. buy시그널을 발견하면, bucket이라는 공간에 담아서 수익율이 기준점 이상이거나 손절 포인트 이하일 경우 매도 한다.

가상 코드.py
def stocastic_rsi_ema_mix_funnction(...)
  ...(매수 시그널 세팅 완료)

  bucket = []
  for 날짜 in df:
    if df[날짜] == 매수 시그널:
      if empty(bucket):
        bucket = 해당 날짜
      else:  # 매수 시그널이 있는데 아직 팔지 못한 경우
        # case 1: 인접한 BUY 시그널일 경우
        if abs(매수 날짜 - 날짜) < 일정 기간: 
          df[날짜] == KEEP  # 매수 시그널을 지운다
        # case 2: 인접하지 않지만, 팔지 못했을 경우
        else:
          df[날짜] = 매도 시그널
          if 날짜 is not 마지막 날짜:
            df[날짜 + 1] == 매수 시그널
    # 매수 신호가 아닌 모든 날에 대해서
    if not empty(bucket):
      if 수익율 >= 수익율 기준점 or 수익율 <= 손절 포인트:
        df[날짜] = 매도 시그널
        bucket = []  # 판매한 bucket은 비운다.

이로서 추가 요구사항을 만족하며 유튜브에서 소개한 전략을 코드로 옮길 준비가 완료 된 것이다.

코드는 아래와 같다. 22번 줄 부터 46번 줄까지가 추가한 매도 루틴이다.

stocastic_rsi_ema_mix_funnction_complete.py
def stocastic_rsi_ema_mix_funnction(data: StockData, weight: int, name: str, timeperiod: int = 200, rsi_period: int = 14, fastk_period=3, fastd_period=3, fastd_matype=0,
                                buy_rate: float = 25.0, sell_profit: float = 0.02, sell_lose: float = -0.015):
    df = data.data
    df['ema'] = talib.EMA(df['close'], timeperiod=timeperiod)
    df['RSI'] = talib.RSI(df['close'], timeperiod=rsi_period)
    df['fastk'], df['fastd'] = talib.STOCH(df['RSI'], df['RSI'], df['RSI'], fastk_period=14,slowk_period=3,slowk_matype=0,slowd_period=3, slowd_matype=0)
    df['before_fastk'] = df['fastk'].shift(1)
    df['before_fastd'] = df['fastd'].shift(1)

    def _buy_signal(r: pd.Series):
        if r.close > r.ema:  # buy condition 1
            # buy condition 2
            if r.fastk < buy_rate and r.fastd < buy_rate:
                # buy_condition 3
                if r.before_fastk < r.fastk and r.before_fastd < r.fastd:
                    if r.fastk > r.fastd:
                        return (StrategyResultColumnType.BUY, weight)
        return (StrategyResultColumnType.KEEP, 0)

    df['result'] = df.apply(lambda r: _buy_signal(r), axis=1)

    # add sell strategy
    buy_buffer = {'count': -1, 'idx': ""}

    for count, idx in enumerate(df.index):
        strategy, _ = df.at[idx, 'result']
        # buy signal meet
        if strategy == StrategyResultColumnType.BUY:
            if buy_buffer['count'] < 0 and buy_buffer['idx'] == "":
                buy_buffer['count'] = count
                buy_buffer['idx'] = idx
            else:  # buy_buffer already exist
                if abs(count - buy_buffer['count']) < 7:
                    df.at[idx, 'result'] = (StrategyResultColumnType.KEEP, 0)
                else:
                    df.at[idx, 'result'] = (StrategyResultColumnType.SELL, weight)
                    if idx != df.index[-1]:
                        df['result'].iat[count + 1] = (StrategyResultColumnType.BUY, weight)
                    buy_buffer = {'count': -1, 'idx': ""}  # init buy_buffer

        if buy_buffer['count'] >= 0 and buy_buffer['idx'] != "":
            buy_buffer_idx = buy_buffer['idx']
            profit_rate = (df.at[idx, 'close'] - df.at[buy_buffer_idx, 'close']) / df.at[buy_buffer_idx, 'close'] # calc profit_rate
            if profit_rate >= sell_profit or profit_rate <= sell_lose: 
                df.at[idx, 'result'] = (StrategyResultColumnType.SELL, weight)
                buy_buffer = {'count': -1, 'idx': ""}  # init buy_buffer

    df[name] = df['result']
    return df[[name, 'ema', 'fastk', 'fastd']]

백테스팅 결과 확인

해당 전략을 그래프로 그린 결과, 얼추 수익을 창출(?)하는 것 같은 그래프가 완성되었다. 이제는 해당 전략이 적절한지 검증하기 위해 vectorbt 모듈을 추가로 사용 할 것이다.

Image title

그림 2 - 최종 시그널 분포 결과

vectorbt 사용

vectorbt는 지금 만들고 있는 프로젝트와 동일하게 주식에 대한 전략을 검색해주는 모듈이다.

동작 구조는 다음과 같다.

매수 신호가 나오면, 그 다음날은 매도 신호가 나와야 수익율 계산을 한다. 두 번 매수하거나, 두 번 매도하는 신호가 나오면 무시가 된다. 즉, 매수 신호와 매도 신호가 짝을 지어야 한다.

위의 동작 구조에 만족시키도록 전략 함수를 작성하였고, vectorbt를 이용해 해당전략이 적절하게 동작했는지 확인한다.

코드는 아래와 같다.

주의사항

해당 코드는 이해하기 쉽게 하기 위한 가상코드이고 실제 코드와는 차이가 있습니다.

backtest.py
import vectorbt as vbt

# stock_df.close - dataframe 중 close(종가) 값 세팅
# buy_result - stock_df와 같은 인덱스를 가지며 매수일 경우 True 그렇지 않을 경우 False인 DataFrame
# sell_result - stock_df와 같은 인덱스를 가지며 매도일 경우 True 그렇지 않을 경우 False인 DataFrame
pf = vbt.Portfolio.from_signals(stock_df.close,buy_result,sell_result)
pf.stats() # 결과검증 

코드에 대한 결과값은 다음과 같이 나타나며, 수익을 내지 못했음을 확인했다.

backtest 결과 값
Start                         2023-06-19 09:00:00
End                           2023-07-12 22:00:00
Period                                       1124
Start Value                                 100.0
End Value                               98.500282
Total Return [%]                        -1.499718
Benchmark Return [%]                     9.296149
Max Gross Exposure [%]                      100.0
Total Fees Paid                               0.0
Max Drawdown [%]                         6.122818
Max Drawdown Duration                       413.0
Total Trades                                   17
Total Closed Trades                            17
Total Open Trades                               0
Open Trade PnL                                0.0
Win Rate [%]                            41.176471
Best Trade [%]                           2.734218
Worst Trade [%]                         -1.909587
Avg Winning Trade [%]                    1.230347
Avg Losing Trade [%]                    -0.996401
Avg Winning Trade Duration              26.428571
Avg Losing Trade Duration                    20.7
Profit Factor                            0.849372
Expectancy                              -0.088219
dtype: object

자세한 그래프를 그리기 위해서는 아래와 같이 pf.plot().show()를 호출하면 나타나게 된다.

plot.py
pf.plot().show()

Image title

그림 3 - backtest 시각화 결과

다음은?

아쉽게도 해당 전략은 수익을 내지 못했다. 그 이유는 이 전략은 롱 포지션 이며 상승장에서 통하는 전략이기 때문이다.

따라서, 다음 게시글은 유튜브에 나와있는 것처럼 하이킨아시 차트를 적용하여, 상승장, 하락장을 구별하여 매수하는 기능을 추가할 것이다.



  1. 밑에서 설명하겠지만 백테스팅 검증 시 vectorbt를 이용해서 검증하기 때문이다. 

Comments