넓어져야 할 폭이 한 번도 안 넓어졌다
트레일링 스톱에는 계단을 넣어뒀다. 수익이 붙을수록 추적 폭을 넓혀서, 조금 흔들렸다고 일찍 잘리지 않게 하려는 장치다.
- 고점 수익 2% 이상이면 폭을 1.5배로
- 5% 이상이면 2배로
의도는 단순하다. 3% 벌고 있는 종목을 1% 눌렸다고 파는 건 아깝다는 것이다.
석 달치 매도를 모아 보니 트레일링으로 나간 스무 건이 전부 기본 폭에서 잘려 있었다. 하나도 예외가 없었다. 계단이 한 번이라도 작동했다면 넓은 폭에서 잘린 건이 섞여 있어야 하는데 그런 게 없었다.
코드는 멀쩡했다
계단 로직을 열어봤다. 상수도 있고 분기도 있다. 2%를 넘으면 배수를 곱하고, 5%를 넘으면 더 큰 배수를 곱한다. 읽어서는 이상한 데가 없었다.
테스트도 있었다. 그리고 통과하고 있었다.
이름과 어서션이 반대였다
그 테스트를 열어보고서야 뭔가 이상하다는 걸 알았다.
함수 이름이 이랬다.
trailing_step1_widens_and_holds
1단에서 폭이 넓어져서 버틴다는 뜻이다. 주석도 같은 말을 하고 있었다. 그런데 정작 검사하는 내용은 이랬다.
assert!(result.is_some());
is_some()은 매도가 발동했다는 뜻이다.
이름은 버틴다고 하고, 검사는 발동한다고 한다. 정반대다.
어떻게 이런 게 통과하고 있었나
경위는 짐작이 어렵지 않다. 계단을 넣던 시점에 테스트를 먼저 쓰고, 돌려보니 의도와 다르게 매도가 발동했을 것이다. 그리고 어서션을 실제 동작에 맞춰 뒤집었다.
그러면 초록불이 켜진다. 이름은 고치지 않은 채로.
이 순간 테스트의 역할이 뒤바뀐다. 회귀를 막는 장치가 아니라 현재 동작을 고정하는 장치가 된다. 이후에 누가 계단을 제대로 고치면 이 테스트가 빨간불을 낸다. 고친 쪽이 틀렸다고 말하면서.
기능이 도입 이래 한 번도 작동하지 않았다는 증거가 여기 있었다. 운영 데이터에서 스무 건 전량이 기본 폭이었던 것과 같은 이야기를, 테스트 한 줄이 먼저 하고 있었다.
왜 계단이 켜질 수 없었나
근본 원인은 판정 축이었다. 계단에 들어갈지 말지를 현재가 수익률로 보고 있었다.
이게 왜 구조적으로 불가능한지는 순서를 따라가 보면 드러난다.
- 고점에서 +3% 수익이 나 있다. 계단 1단 조건을 만족한다
- 가격이 밀린다. 트레일링이 발동할지 판정하는 시점이 온다
- 그런데 이 시점의 현재가 수익률은 이미 +1%대로 내려와 있다
- 계단 조건(2% 이상)에서 탈락한다. 기본 폭이 적용된다
- 기본 폭은 좁으니까 매도가 발동한다
판정하러 오는 순간에는 항상 수익률이 떨어져 있다. 트레일링은 눌렸을 때 발동하는 장치이므로, 눌리지 않으면 애초에 이 판정에 도달하지 않는다. 계단 조건을 현재가로 보는 한 조건은 영원히 성립하지 않는다.
고점 수익률로 바꿨다. 고점은 뒤로 물러나지 않으므로 한 번 2%를 찍었으면 그 사실이 남는다.
같은 날 오전에 그 손해가 났다
고치고 나서 그날 오전 매도를 다시 봤다.
한 종목이 트레일링으로 나가 있었다. 고점 대비 1.60% 밀려서 한도 1.5%를 넘겼고, 수익 0.65%에 세전 2,500원으로 정리됐다.
그런데 그 종목의 고점 수익은 2.29%였다. 계단 1단 조건인 2%를 넘긴 상태였다. 새 로직이었다면 폭이 2.25%로 넓어져서 1.60% 하락으로는 발동하지 않았다.
매도한 뒤 그 종목은 다시 올랐다. 그날 캐시에 남은 마지막 값이 진입가 대비 +2.18%였다.
고친 당일에 그 수정이 필요했던 이유가 실물로 나온 셈이다. 다만 이건 하루치 한 건이고, 반대로 계단 때문에 더 크게 물리는 날도 올 것이다. 근거로 삼은 건 이 한 건이 아니라 스무 건 전량이 기본 폭이었다는 사실이다.
정정할 때 남긴 것
어서션을 뒤집으면서 주석에 이유를 적어뒀다.
assert!(
result.is_none(),
"고점수익 +3.0%는 계단 1단(폭 1.5%) -> -1.1% 하락으로는 미발동 (구버그: 현재가 기준이면 여기서 발동)"
);
괄호 안의 문장이 핵심이다. 이전에는 왜 반대로 통과했는지를 적어두면, 나중에 누가 판정 축을 되돌렸을 때 이 테스트가 즉시 깨지면서 그 이유까지 알려준다. 실패 메시지에 "구버그"라는 단어가 뜨는 것과 그냥 값이 안 맞는다고 뜨는 것은 다르다.
💡 테스트가 통과한다는 것은 코드가 의도대로 동작한다는 뜻이 아니라, 코드가 지금 이렇게 동작한다는 뜻이다. 둘이 어긋나는 순간은 대개 테스트를 처음 쓸 때 온다. 돌렸는데 빨간불이면 두 가지 길이 있고, 어서션을 실제 값에 맞추는 쪽이 언제나 빠르다. 그렇게 맞춘 테스트는 통과하지만 아무것도 지키지 않는다.
찾아낸 방법은 특별하지 않았다. 이름과 어서션이 맞는지 읽어본 것뿐이다. 테스트 이름은 그 코드가 무엇을 해야 하는지에 대한 유일한 서술인데, 통과하는 동안에는 아무도 읽지 않는다. 이름과 검사 내용이 어긋난 테스트가 있다면 그 자체로 결함 신호다. 둘 중 하나는 틀렸고, 대개는 검사 쪽이 나중에 수정된 쪽이다.