지난 편에서 용의자는 하나로 좁혀졌다. trailing_stop. 6/17~6/26 열흘 동안 63건, 합계 -44.68%. 다른 어떤 매도 사유보다 압도적으로 큰 손실이었다. 그런데 막상 코드를 열어보니, 예상했던 그림과는 좀 달랐다.
처음엔 부호가 잘못됐을 거라고 생각했다. 낙폭을 음수로 계산해놓고 조건식에서 실수했겠거니 — 실제로 그 줄만 따로 떼어놓고 보면 peak_drop <= -trailing_stop은 멀쩡한 비교식이다. 음수는 음수끼리 비교되고 있었다. 부호 버그는 없었다. 진짜 문제는 그 아래, 코드 세 줄에 숨어 있었다.
if peak_drop <= -trailing_stop:
if dynamic.get("use"):
peak_pct = (peak_price - buy_price) / buy_price * 100
t1 = float(dynamic.get("trigger1", 1.0))
if peak_pct < t1:
return None # 여기서 트레일링이 통째로 무효화된다
낙폭이 손절 기준을 넘어도, 그 앞에 조건이 하나 더 있었다. "매수 이후 최고가가 매수가 대비 t1%(기본 1%) 이상 올라본 적이 있는가." 없으면 return None — 손절 자체가 발동하지 않고 그냥 통과된다.
말이 되는 설계이긴 하다. 트레일링 스탑은 원래 "올랐다가 다시 빠질 때" 수익을 지키기 위한 장치니까. 한 번도 오른 적 없는 포지션에 "피크 대비 낙폭"을 적용하는 건 애초에 이상하다. 그래서 t1이라는 문턱을 걸어둔 거다. 문제는 이 문턱이 걸리는 조건이었다.
peak_price가 어떻게 초기화되는지 찾아봤다. 매수 체결 시점에, peak_price는 그 순간의 매수가로 그대로 시작한다. 그러니까 매수 직후 가격이 한 번도 위로 튀지 못하고 계속 눌리기만 하면, peak_price는 buy_price 근처에 머물고 peak_pct는 계속 t1 밑에 있게 된다.
숫자로 한 번 그려보면 이렇다. 어떤 코인을 100만 원에 매수했다고 하자. 체결 직후 peak_price도 100만 원으로 잡힌다. 이후 가격이 99만 원, 98만 원으로 계속 밀리기만 하고 단 한 번도 100만 원을 넘어서지 못했다면, peak_pct는 시종일관 0% 근처다. t1 기본값 1%를 넘을 일이 없다. 이 상태에서 가격이 97만 원까지 떨어져 낙폭이 트레일링 임계값(가령 -2.5%)을 넘어서도, 코드는 peak_pct < t1을 확인하고 조용히 None을 반환한다. 손절 신호 자체가 발생하지 않는다.
그리고 6월은 ⑧편에서 이미 확인했듯 bear 아니면 neutral, bull은 단 한 건도 없는 달이었다. BTC가 6/1 108,946,000원에서 6/23 93,972,000원까지 내내 미끄러진 그 구간이다. 이런 장에서 매수 직후 반등 한 번 없이 바로 눌리는 포지션이 흔했을 거라는 건 짐작이 아니라, 국면 자체가 그렇게 생겨먹었었다.
6월 누적 손익 곡선 (거래당 손익률 단순 합산)
6/16까지 완만하던 곡선이 6/17부터 열흘 만에 -60.60%까지 꺾인다. 이 구간(6/17~6/26)에 trailing_stop 63건, -44.68%가 몰려 있다. 말일 이틀 반등으로 -53.59%까지 일부 회복하며 6월을 마감했다.
이 곡선에서 가장 가파른 6/17~6/26 구간이 정확히 trailing_stop이 무력화됐던 구간과 겹친다는 게 우연이 아니다. 조정 국면에서 수익을 지키도록 설계된 장치가, 애초에 지킬 수익이 생기지도 않는 하락장에서는 그냥 잠들어 있었던 셈이다.
정리하면 이렇다. 트레일링 스탑은 "피크 대비 낙폭"을 감시하도록 설계됐는데, 그 감시가 작동하려면 먼저 피크가 있어야 한다. 그런데 하락장에서 매수한 포지션 상당수는 애초에 피크를 한 번도 못 만들어봤다. 감시 대상이 없으니 감시도 없었다. 코드가 부서진 게 아니라, 상승 후 조정을 관리하려고 만든 로직을 상승 자체가 없는 장에 그대로 얹어놓은 거였다.
남아있던 안전장치는 고정 stop_loss 하나뿐이었다. 국면별 stop_loss는 bear 1.5%, neutral 2.0% — 트레일링보다 더 타이트해 보이지만, 이건 매수가 대비 낙폭이지 피크 대비 낙폭이 아니다. 조금씩 오르내리며 서서히 밀리는 포지션은 고정 손절선에 닿기 전까지 계속 물려 있을 수 있다. 두 장치 사이에 낀 사각지대가 있었던 셈이다.
여기서 한 가지 더 짚을 게 있다. 이 로직은 애초에 "매수 후 한 번은 올랐다가 다시 빠지는" 그림을 전제로 짜여 있었다. 그런데 국면 판정 로직(④편)이 bear·neutral·bull을 나누면서도, trailing_stop의 t1 문턱 자체는 국면과 무관하게 고정값으로 걸려 있었다. 상승장 대응용 파라미터가 하락장에도 똑같이 적용된 거다. 국면별로 stop_loss·take_profit은 다르게 설정해놓고, 정작 이 무효화 조건의 기준은 국면을 타지 않았다.
결국 6월 열흘 동안의 손실은 어느 한 줄의 버그가 아니라, 설계 전제와 실제 시장 국면이 어긋난 결과에 가까웠다. "오른 뒤 떨어지는 상황"을 지키려던 로직이, "오른 적 없이 떨어지기만 하는 상황"에서는 그냥 침묵했다.
다음 편에서는 이 사각지대를 어떻게 메웠는지, 그리고 그 수정이 백테스트와 실거래에서 실제로 어떤 차이를 만들었는지를 본다.
본 게시글은 자동매매 봇의 실거래 로그를 바탕으로 한 운영 기록이며, 특정 종목이나 전략의 수익을 보장하지 않습니다.
지난 편: 용의자는 좁혀졌다. A, B, D를 지우고 남은 건 하나, trailing_stop이었다 — ⑧ 6월 -53% ① 용의자 지우기
다음 편: 사각지대를 메운 수정, 그리고 그 결과 — ⑩ 6월 -53% ③ 자동시술
자동매매는 원금 손실 위험이 있고, 이 봇은 실제로 손실을 냈습니다. 전략 파라미터 일부는 구체적 수치 대신 상대적 표현으로 적었습니다.
이 글을 쓴 사람
코인랩 운영자
업비트 역추세 반등 실거래 전략은 운용을 종료했습니다. 현재 업비트에서는 V자 회복형·이중바닥형·BB 땅굴회복형의 V2 세 전략을 각각 독립 모의계좌로 검증하고, 빗썸에서는 추세추종(변동성 돌파) 전략을 실제 자금으로 운용합니다. 과거 게시글의 역추세 반등 기록은 당시 실거래 결과와 전략 전환 과정을 보존한 자료입니다.
근거 데이터 과거 업비트 역추세 실거래 로그·현재 V2 모의매매 기록·빗썸 추세추종 실거래 로그 및 공개 시세 API