Trading & Coding

[Backtest] ATR기반 Trailing Stop전략: 한 줄 버그가 전략을 속임

minstack 2025. 6. 15. 17:41
백테스트 결과가 너무 좋다면, 그냥 그것은 버그다.

 

시스템 트레이딩 백테스트 과정에서 손절/익절라인 설정은 전략 개발에 있어 항상 마주치는 고민거리이다.

간단한 전략이라도 이 둘의 미세한 차이만으로 전체 수익 곡선이 완전히 달라지기 때문.

이번 글에서는 ATR 기반 손절/익절 조합을 히트맵으로 분석한 실험기.

그리고 그 과정에서 발견한 얼탱이 없는 한 줄짜리 오류.

 


기본 청산 전략

전략 신호가 발생한 경우 포지션에 진입한 후, 기본 청산 전략은 다음과 같다.

  • 매수 진입 기준으로 최근 ATR 기준으로 손절(Loss Cut, LC)은 m배수 아래로, 익절(Take Profit, TP)은 n배수 위로 설정한다.
    예를 들어, m=1, n=2 세팅에서는 진입가 - 1 x ATR에 손절 주문, 진입가 + 2 x ATR에 익절 주문이 자동으로 들어가는 방식이다.
# 진입
if position is None and time in signals_by_time:
    signal = signals_by_time[time]
    entry_price = row['close']
    atr = row['ATR']
    if not pd.isna(atr):
        if signal.side == "BUY":
            position = "BUY"
            tp_price = entry_price + atr * atr_tp   # 익절라인 설정
            lc_price = entry_price - atr * atr_lc   # 손절라인 설정
            entry = 1
        elif signal.side == "SELL":
            position = "SELL"
            tp_price = entry_price - atr * atr_tp
            lc_price = entry_price + atr * atr_lc
            entry = -1
  • 여기에 한 가지를 더했다. 양방향 터치 없이 너무 오래 포지션을 보유하는 상황을 피하기 위해 트레일링 스탑(trailing stop) 개념을 추가했다. 익절라인과 현재가 사이의 갭의 일정 비율(a=0.2) 만큼 손절라인을 부분적으로 끌어올리는 방식이다. 즉, 가격이 우상향 하면 손절선도 따라 올라온다. 효율적인 수익 보호를 위한 장치이다.
# 손절 라인 갱신
if position == "BUY":
    stop_range = tp_price - entry_price
    new_sl = lc_price + a * stop_range  # a=0.2로 설정
    lc_price = max(lc_price, new_sl)
elif position == "SELL":
    stop_range = entry_price - tp_price
    new_sl = lc_price - a * stop_range
    lc_price = min(lc_price, new_sl)

 

 


그럼 m, n 값은 어떻게 정해야 할까?

이게 핵심 고민이었다.

손절 m과 익절 n을 이리저리 바꾸어 가면서 백테스트를 돌릴 때마다 수익곡선은 매번 다른 값을 뱉어내는데,

도무지 어느 조합이 제일 좋은 것인지 알 수가 없는 점이다.

 

그래서 Dash*로 시각화 화면을 구현하여 손절 m과 익절 n을 각각 1부터 5까지 0.25 간격으로 바꿔가면서, 총 289개 조합(17x17)을 백테스트했다.

 * python으로 HTML도 모르는데 웹 대시보드 만들고 싶다? 그럼 Dash다.

 

1회 시뮬레이션이 1초도 안 걸리긴 했지만, 무지성으로 289번 돌리니 꽤나 시간이 걸리더라.

노트북 성능은 괜찮았지만 이 간단한 반복도 버겁게 느껴지면서 슬슬 멀티프로세싱이나 비동기화 처리 생각이 고개를 들기 시작했다. 그리고 당연히... CPU를 위시한 장비 업그레이드 욕망도 같이 자라났다.

 

 


첫 번째 히트맵 결과

(그림 1) Dash로 m, n 조합을 바꿔가며 289회 백테스트 결과를 히트맵으로 시각화. 가로가 익절, 세로가 손절 ATR배수

색깔이 진한 파랑일수록 손실, 노란색일수록 높은 수익 결과를 의미한다.

그런데 뭔가 이상했다. 히트맵 우상단, 즉 손절/익절 라인이 둘 다 크면 클수록 성과가 극적으로 좋아졌다.

이게 뭘 의미하냐면...

 

'손절도 익절도 안하고 그냥 냅두는 게 낫다'는 결론이었다.

그럼 나는 뭘 한거지...? 괜히 타이트한 조건으로 진입과 청산을 반복하면서 거래비용만 잔뜩 낸 건가?

 

한편 히트맵이 너무 매끈한 경향성을 보이면서... 너무 예쁘다... 

이쯤에서 늘 등장하는 의심 하나.

 

 


백테스트 결과가 너무 좋다면, 그냥 그것은 버그다.

익히 경험한 바, 이런 매끈한 경향성은 코드 오류에서 비롯된 경우일 뿐이었다.

다시 디버깅 모드로 진입.

이번엔 맨눈으로 확인해보고자 손절 m=10, 익절 n=10으로 극단적인 설정을 넣고 시계열 결과를 직접 들여다봤다.

 

(그림 2) m=n=10 설정 백테스트 그래프. 녹색점선은 익절라인, 빨간점선은 손절라인(trailing)이다.

 

그 결과, 익절라인과 현재가 사이의 갭이 지나치게 클 경우, 손절라인을 말도 안 되게 끌어올리는 버그가 있었다.

손절라인이 현재가보다 위로 끌려 올라간 상황에서 그 가격으로 청산된 것으로 처리되었는데, 도달하지도 않은 저 높은 손절라인에서 익절처럼 처리가 되면서 PnL이 말도 안 되게 끌어올려진 셈이다.

 

라이브 트레이딩에서 손절라인이 현재가보다 위에 있을 수가 없지. 주문 접수부터 안된다.

그래서 아래와 같이 로직을 한 줄 수정했다.

# 손절 라인 갱신
if position == "BUY":
    stop_range = tp_price - entry_price
    new_sl = lc_price + a * stop_range
    # lc_price = max(lc_price, new_sl) 기존 코드
    lc_price = min(row['close'], max(lc_price, new_sl)) # 버그 수정
elif position == "SELL":
    stop_range = entry_price - tp_price
    new_sl = lc_price - a * stop_range
    # new_sl = lc_price - a * stop_range 기존 코드
    lc_price = max(row['close'], min(lc_price, new_sl)) # 버그 수정

이제 손절라인이 현재가 위로는 못 가도록 제한했다.

라이브 트레이딩에서는 해당 상황에서 즉시 시장가 청산 처리하면 된다.

 

 


수정 후 결과: 손절 m=3~4 / 익절 n=2 근처가 sweet spot

(그림 3) 버그 수정 후 heatmap

 

이제야 좀 현실적인 결과가 나왔다.

전반적으로 손절은 조금 넉넉하게 잡되, 익절은 비교적 타이트하게 설정하는 구간에서 수익이 극대화되었다.

 

 


앞으로 개선할 점들

  1. lifting pamameter a 튜닝 필요
    현재는 a=0.2로 고정한 결과이다.
    a=0이면 손절라인이 최초 고정인 것이고 1에 근접할수록 익절라인으로 빠르게 근접하는 파라미터이다.
    이것도 백테스트 히트맵 범주에 넣으면 더 적절한 값을 찾을 수 있을 것 같다.
    다만 그럼 이제 3차원 매트릭스가 되며, 지금도 허덕거리는데 계산량이 또 대폭 증가해버린다.
  2. 자산별 히트맵 결과가 너무 다르다.
    이번 백테스트는 이더리움(ETH) 기준이었는데, 동일한 전략에 동일한 파라미터를 리플(XRP)에 적용하자 손절을 깊게, 익절을 짧게 할 때만 성과가 났다. 
    종목별로 완전히 다른 결과 양상이 나온다는 것은, 이 전략이 아직 충분히 일반화되지 않았다는 증거이다.

 


마치며

시스템 트레이딩이 생각보다 훨씬 어렵다.

코드 한 줄이 전체 전략의 성과를 뿌리째 흔드는 걸 볼 때마다, 허무함과 공포가 동시에 밀려온다.

 

아직 단순한 전략임에도 불구하고 최적화 반복만으로 성능 병목이 생기기 시작했고, 이건 곧 멀티프로세싱 > 서버 자원 증설 > 대규모 프레임워크 구축으로 이어질 게 뻔하다.

 

시작은 분명히 소박한 트레이딩 자동화였다.

그런데 자꾸 코드가 늘고 구조를 고민하다 보니.. 이러다 개발자로 전직할 판이다.