페이퍼 실험입니다. 아래 표본 수와 비율은 실제 원장에서 나온 값이고, 달러 손익은 일반화했습니다.
대시보드를 열었더니 곡선 두 개가 나란히 우상향하고 있었습니다. 하나는 "만기까지 홀드", 하나는 "만기 직전 조기청산". 그런데 같은 시각 공식 리포트의 누적 손익은 두 봇 모두 마이너스였고, 사전등록 게이트는 이미 KILL을 찍은 상태였습니다.
곡선과 원장이 정면으로 어긋납니다. 둘 중 하나는 거짓말을 하고 있습니다.
당신의 대시보드에서, 파생 지표의 합계가 원장의 실제 손익과 일치하는지 확인해 본 적 있습니까?
값싼 설명부터 의심한다
처음 떠오른 설명은 "차트가 다른 기간을 그리는 것 아니냐"였습니다. 아니었습니다. 두 표본 모두 같은 날짜 범위를 덮고 있었습니다.
다음은 "표본이 좁아서 그렇다"였습니다. 실제로 좁긴 했습니다 — 정산된 415건 중 299건만 곡선에 들어가 있었습니다. 하지만 표본이 좁은 것 자체는 죄가 아닙니다. 문제는 무엇이 빠졌느냐입니다.
둘로 갈라서 봤더니:
| 표본 | 건수 | 승률 |
|---|---|---|
| 곡선에 들어간 것 | 299 | 57.2% |
| 곡선에서 빠진 것 | 116 | 12.9% |
빠진 쪽 승률이 12.9%. 무작위 결측이 아닙니다. 진 판만 골라서 빠졌습니다.
범인은 유효성 검사 한 줄
반사실 계측은 이렇게 동작합니다. 만기 30초쯤 전에 그 포지션의 최우선 매수호가를 찍어두고, "지금 전량 팔았다면 얼마였을까"를 기록해 둡니다. 나중에 실제 결제 결과와 짝지어 비교합니다.
스냅샷 기록 함수 앞에 이런 가드가 있었습니다.
if bid is None or not math.isfinite(bid) or not (0 < bid <= 1):
return False0 < bid. 겉보기엔 정상적인 방어 코드입니다. 가격은 0보다 커야 하니까요.
그런데 예측시장에서 만기 30초 전에 이미 진 포지션은 매수호가가 0으로 사라집니다. 아무도 사주지 않으니까요. 그 순간 이 가드가 스냅샷을 통째로 버립니다. 그리고 집계 쿼리는 WHERE cf_exit_pnl IS NOT NULL이었습니다.
두 줄을 합치면 필터의 실제 의미는 이렇게 됩니다.
"만기 직전까지 값이 붙어 있던 포지션만 계측한다" = "대체로 이긴 포지션만 계측한다"
가격대를 통제해도 결과는 같았습니다. 거래가 가장 몰린 0.30~0.60 진입가 구간에서, 포함된 건 130/218(59.6%), 빠진 건 8/75(10.7%). 가격이 아니라 결과가 결측을 결정하고 있었습니다.
당신이라면 어떻게 고치겠습니까
여기서 갈림길이 있습니다. 빠진 116건에 대해 "그때 호가가 얼마였는지" 이제 와서 알 방법은 없습니다. 기록되지 않았으니까요.
- 결측 행을 계속 빼고, 캡션에 "일부 표본 제외됨"이라고 적는다
- 결측 행을 표본에 넣되, 값을 알 수 없으니 보수적으로 채운다
- 과거 데이터를 버리고 수정 이후부터 다시 센다
저는 2번을 골랐습니다. 이유는 이렇습니다. 호가가 0이라 빠진 포지션은 조기청산해도 결과가 홀드와 같습니다. 0에 팔면 원금 전액 손실이고, 만기까지 들고 있어도 원금 전액 손실입니다. 즉 그 행들의 진짜 차이값은 0입니다 — 모르는 게 아니라 아는 값입니다.
그래서 고친 건 두 군데입니다.
- 스냅샷 가드를
0 <= bid <= 1로 바꿔 호가 0을 유효 관측치로 기록 - 집계 표본을 정산된 전 포지션으로 바꾸고, 스냅샷이 없는 행은 조기청산 = 홀드(차이 0)로 채움
이렇게 하면 부수 효과로 검증 지점이 하나 생깁니다. 반사실 표의 "홀드 손익"이 본 게이트 표의 실제 손익과 정확히 일치해야 합니다. 안 맞으면 표본 정의가 또 어딘가에서 새고 있다는 뜻입니다.
고치고 나니 이야기가 바뀌었다
수정 후 두 곡선은 나란히 아래로 내려갑니다. 두 봇 모두 홀드도 마이너스, 조기청산도 마이너스입니다. 한 봇은 조기청산이 손실을 일부 줄였고, 다른 봇은 오히려 악화시켰습니다.
더 중요한 건 채택 게이트 판정이 수정 전후로 똑같이 AMBIGUOUS였다는 점입니다. 이게 안심되는 신호입니다 — 편향을 걷어냈더니 내게 유리하게 판정이 바뀌었다면, 그건 편향 제거가 아니라 사후 선택이었을 테니까요. 사전등록의 값어치가 여기서 나옵니다.
그리고 남은 편향의 방향도 적어둬야 합니다. 호가 조회 자체가 실패해서 빠진 소수의 이긴 포지션은 차이값 0으로 채워졌지만, 실제로는 음수였을 겁니다(1보다 싼 값에 팔았을 테니). 즉 남은 편향은 조기청산을 과대평가하는 쪽입니다. "조기청산이 낫다"는 결론을 내리기엔 보수적이지 않은 상태라는 뜻이고, 이건 결론을 약하게 만드는 방향이라 그대로 공개해 둡니다.
그리고 게이트 자체가 무의미해졌다
수치를 정직하게 만들고 나니 더 불편한 질문이 남았습니다. 이 게이트는 애초에 무엇을 결정하려던 것인가?
원래 목적은 "만기 전 매도 경로를 새로 만들 가치가 있나"였습니다(조기청산 게이트 편). 그런데 정직한 수치로 보니 조기청산의 상한은 건당 스테이크의 2% 남짓이고, 그건 ROI −8%를 −5%로 만드는 크기입니다. 지는 전략을 덜 지게 만들 뿐, 이기게 만들지 못합니다.
채택 대상인 본체가 이미 KILL인데 채택 게이트를 계속 돌리는 건, 목적을 잃은 관성입니다. 계측을 고쳐놓고 나서야 그게 보였습니다. 실패한 봇을 깨끗하게 죽이는 일이 매번 어려운 이유이기도 합니다 — 죽여야 할 시점은 대개 "조금만 더 데이터를 모으면"이라는 문장 뒤에 숨어 있습니다.
자가진단 3줄
지금 당신의 계측 코드에 이 세 가지를 대보세요.
- 결측이 결과와 상관되는가. 집계에서 빠지는 행을 따로 모아, 남은 행과 승률·성공률을 비교해 보세요. 두 값이 크게 다르면 표본이 아니라 필터를 보고 있는 겁니다.
- 0과 없음을 구분하는가.
if not value류의 검사는 "값이 0"과 "값이 없음"을 같이 삼킵니다. 0이 유의미한 도메인(가격, 잔량, 카운트)에서는 이게 조용한 데이터 손실입니다. - 파생 지표가 원장과 맞는가. 파생 표의 합계 중 하나는 반드시 원장의 원본 수치와 같아야 합니다. 그 앵커가 없으면 표본 정의가 새도 아무도 모릅니다.
솔직한 부분
이건 계산 버그가 아니었습니다. 계산은 처음부터 정확했습니다. 틀린 건 무엇을 계산 대상에 넣을지 정하는 규칙이었고, 그 규칙은 방어 코드처럼 생긴 한 줄에 숨어 있었습니다. 테스트도 통과하고 있었습니다 — 테스트가 검증한 건 "유효한 호가를 올바르게 저장하는가"였지, "무엇을 유효하다고 부르는가"가 아니었으니까요.
3주 동안 이 곡선을 보면서 "조기청산 쪽에 뭔가 있다"고 생각했습니다. 실제로 있던 건 필터였습니다.
당신 파이프라인에서 조용히 행을 버리고 있는 if 문 하나만 찾아보세요. 어떤 가드였는지 알려주시면 좋겠습니다.