페이퍼 실험입니다. 표본 수·비율·주당 가격은 실제 수집 데이터에서 나온 값이고, 계좌 규모는 쓰지 않았습니다.
예측시장의 5분짜리 up/down 라운드에서 엣지를 찾고 있었습니다. LLM에게 방향을 판정시키던 축은 이미 죽은 뒤였습니다 — 실제 승수 186건 대 시장 호가가 함의한 190.8건, z = −0.49. 시장 가격이 이미 담은 확률에 LLM이 더한 정보량은 0이었습니다.
예측이 죽었으면 남는 후보는 가격 자체의 편향뿐입니다. 그래서 4일치 6,639라운드(8자산)를 백필해 놓고 격자를 훑기 시작했습니다. 잔여시간 버킷 × 가격대 밴드.
그날 하루에 "찾았다"를 세 번 했고, 세 번 다 제 손으로 죽였습니다.
당신의 백테스트는 결과를 내기까지 셀을 몇 개 열어봤는지 세고 있습니까?
1차 사망: 겹친 창이 신호 위치를 속였다
첫 발견은 "만기 60초 이내에 열세측이 고평가돼 있다"였습니다. 꽤 그럴듯했습니다. 만기가 가까울수록 커지는 단조 패턴에, 8자산 전부 같은 부호였습니다.
문제는 제가 창을 이렇게 잡았다는 것입니다.
# "만기 60초 시점" 이라고 이름 붙였던 것
WHERE p.ts <= r.end_ts - 60 AND p.ts >= r.end_ts - 60 - 90이건 만기 60초 이내가 아니라 60~150초 사이입니다. 세 시점을 이런 식으로 잡았더니 창끼리 겹쳐서, 넓은 창이 좁은 창의 관측치를 그대로 포함하고 있었습니다. 이름과 내용이 달랐습니다.
겹치지 않게 다시 잘랐습니다.
| 잔여 | n | 이득 | 일블록 CI |
|---|---|---|---|
| 0–60s | 1,616 | +0.0274 | [−0.0051, +0.0521] |
| 60–120s | 2,661 | +0.0260 | [+0.0109, +0.0419] |
| 120–180s | 3,778 | +0.0083 | [−0.0168, +0.0245] |
| 180–240s | 5,044 | −0.0050 | [−0.0145, +0.0070] |
| 240–300s | 5,616 | −0.0110 | [−0.0176, −0.0051] |
제가 "만기 직전"이라고 부르며 사전등록까지 해둔 구간은, 실제로는 한 버킷 옆이었습니다. 창을 겹쳐 잡으면 신호의 크기뿐 아니라 위치를 틀립니다.
2차 사망: 한 종목이 만든 신호였다
새로 잡은 0–60초 버킷은 +0.0274로 여전히 커 보였습니다. 자산별로 쪼개 봤습니다.
8개 자산 중 유동성이 가장 얇은 한 종목이 +0.1460이었고, 나머지 일곱을 합치면 +0.0015였습니다. 사실상 0입니다.
살아남은 건 60–120초 버킷뿐이었습니다. 그 종목을 빼도 +0.0227, 일블록 클러스터 CI가 [+0.0080, +0.0404]. 8자산 전부 양수, 5일 중 4일 양수.
여기서 저는 이걸 사전등록했습니다. 규모 계산도 했고(거래당 상한, 일 상한), 검정력 계산도 했고(판정일 15일 뒤), 대조군도 붙였습니다. 절차상 흠잡을 데가 없었습니다.
당신이라면 여기서 멈추겠습니까
상황을 정리하면 이렇습니다.
- 단일 셀 p = 0.025
- 자산 8종 전부 같은 부호
- 5일 중 4일 같은 부호
- 스프레드·오버라운드 실측으로 검증했고, 차감 후에도 양수
여기서 두 갈래입니다.
- 사전등록해 두고 15일치 표본 밖 데이터로 확인한다 (제가 실제로 한 것)
- 확인 들어가기 전에, 내가 이 셀을 어떻게 찾았는지를 한 번 더 의심한다
1번만 해도 나쁘지 않습니다. 표본 밖 검정은 결국 진실을 말해주니까요. 다만 2주를 쓰게 됩니다.
3차 사망: 완벽히 효율적인 시장을 시뮬레이션해 봤다
2번을 하는 방법은 의외로 간단합니다. 귀무가설을 시장이 완벽히 효율적인 상태로 두고, 내가 실제로 훑은 셀 수만큼 똑같이 훑어보는 것입니다.
# 귀무: 가격이 곧 참확률
outcome = 1 if random.random() < price else 0
# 내가 본 15셀(잔여 5버킷 × 밴드 3개)을 전부 계산하고
# 그중 "최댓값"만 기록한다. 2,000번 반복.핵심은 셀 하나의 분포가 아니라 최댓값의 분포를 봐야 한다는 것입니다. 격자를 훑을 때 제가 실제로 하는 일은 "최댓값 고르기"니까요.
| 값 | |
|---|---|
| 내 최대 셀 (60–120s · 0.40~0.50) | +0.0373 |
| 귀무 최댓값의 중앙값 | +0.0322 |
| 귀무 95% 지점 | +0.0630 |
| 족별(family-wise) p | 0.349 |
| (참고) 단독 셀 p | 0.025 |
완벽히 효율적인 시장에서도 15셀을 훑으면 3번 중 1번은 이만한 셀이 나옵니다. 제 값은 귀무 최댓값 분포의 한복판에 앉아 있었습니다. 중앙값보다 겨우 0.005 위입니다.
게다가 이 귀무는 자산 간 상관(같은 시각 8자산이 함께 움직임, ρ ≈ 0.23)까지 무시해서 분포를 실제보다 좁게 만듭니다. 즉 진짜 p값은 0.349보다 큽니다.
단일 셀 p = 0.025는 거짓말이 아닙니다. 다만 그건 셀을 미리 지목했을 때만 유효한 숫자입니다. 저는 지목하지 않았습니다. 훑어서 찾았습니다.
죽고 나니 보이는 것들
기각하고 나서 같은 데이터를 다시 보니, 애초에 세 가지가 어긋나 있었습니다.
엣지가 정체된 호가에 몰려 있었습니다. 직전 1분 동안 가격이 안 움직인 라운드는 +0.1583(n=56), 소폭 움직인 쪽은 +0.0518, 제대로 움직인 다수(n=1,067)는 +0.0227에 라운드 클러스터 CI가 0을 포함했습니다. 표본의 27%가 총 엣지의 52%를 만들고 있었습니다.
스프레드 가정은 오히려 정확했습니다. 라이브 호가를 따로 수집해 확인하니 ask − mid 중앙값이 정확히 +0.010, 오버라운드(양쪽 ask 합 − 1)가 +0.020이었습니다. 계산은 맞았습니다. 계산의 입력인 엣지가 없었을 뿐입니다.
기전이 모양을 설명하지 못했습니다. "만기가 가까울수록 시장이 시간가치를 덜 반영한다"는 이야기라면 값이 단조 증가해야 합니다. 실제로는 120–180초 +0.027 → 60–120초 +0.037 → 0–60초 +0.002. 한 버킷에만 있습니다. 어떤 기전도 이 모양을 예측하지 않습니다. 노이즈만 예측합니다.
자가진단 3줄
지금 당신의 백테스트에 이 세 가지를 대보세요.
- 셀을 몇 개 열었는지 세어 봤는가. 파라미터·구간·필터 조합을 곱한 값이 당신이 실제로 한 검정 횟수입니다. 그 수만큼 귀무를 시뮬레이션해 최댓값 분포를 그려 보세요. 20줄이면 됩니다.
- 구간이 서로 겹치는가. 겹친 창은 크기뿐 아니라 신호의 위치를 속입니다. 겹치지 않게 다시 자르고 값이 그대로인지 확인하세요.
- 하나 빼면 무너지는가. 종목 하나, 날짜 하루를 빼고 다시 돌려 보세요. 한 축이 통째로 만들던 신호였던 적이 저는 있었습니다.
솔직한 부분
이 축은 이걸로 닫았습니다. 예측(LLM), 가격 편향, 모형(확산 공정가), 청산 정책까지 네 갈래를 다 쳤고 남은 게 없습니다. 하루 종일 판 결과물이 "없다"입니다.
그런데 하루에 같은 실수를 세 번 하고 세 번 다 스스로 잡았다는 게, 결과보다 값이 컸습니다. 남은 건 재사용 가능한 도구 세 개입니다 — 족별 p값 시뮬레이터, 겹치지 않는 구간 분해기, 라운드/일자 두 수준 클러스터 부트스트랩. 다음 실험은 이걸 들고 시작합니다.
사전등록은 "데이터를 보기 전에 규칙을 적어두는" 습관입니다. 이번에 배운 건 그 짝입니다. 데이터를 보고 나서 뭔가를 찾았다면, 찾기까지 몇 번 봤는지도 같이 적어야 합니다. 그 숫자가 없으면 p값은 의미가 없습니다. 계측 자체가 이긴 판만 세고 있었던 편과 실패한 봇을 깨끗하게 죽이는 편도 결국 같은 이야기의 다른 얼굴입니다.
당신의 마지막 백테스트에서 조합을 몇 개 돌려봤는지, 한번 세어 보시겠습니까? 그 수를 알려주시면 좋겠습니다.