퀀트 / 트레이딩12 분 읽기

내 엣지의 p값은 0.025였다 — 셀을 몇 개 봤는지 세기 전까지는

하루에 '엣지'를 세 번 찾고 세 번 죽였다. 마지막 사망 원인은 완벽히 효율적인 시장을 시뮬레이션해 본 것이었다.

#trading#paper-trading#methodology#statistics
왼쪽은 단일 셀의 유의해 보이는 엣지, 오른쪽은 훑은 15셀 중 최댓값의 귀무분포 안에 그 값이 들어앉은 2패널 다이어그램
왼쪽: 내가 보고한 셀 하나. 오른쪽: 같은 15셀을 완벽히 효율적인 시장에서 훑었을 때 나오는 최댓값 분포. 내 값은 그 분포 한복판에 있다.

페이퍼 실험입니다. 표본 수·비율·주당 가격은 실제 수집 데이터에서 나온 값이고, 계좌 규모는 쓰지 않았습니다.

예측시장의 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일 같은 부호
  • 스프레드·오버라운드 실측으로 검증했고, 차감 후에도 양수

여기서 두 갈래입니다.

  1. 사전등록해 두고 15일치 표본 밖 데이터로 확인한다 (제가 실제로 한 것)
  2. 확인 들어가기 전에, 내가 이 셀을 어떻게 찾았는지를 한 번 더 의심한다

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줄

지금 당신의 백테스트에 이 세 가지를 대보세요.

  1. 셀을 몇 개 열었는지 세어 봤는가. 파라미터·구간·필터 조합을 곱한 값이 당신이 실제로 한 검정 횟수입니다. 그 수만큼 귀무를 시뮬레이션해 최댓값 분포를 그려 보세요. 20줄이면 됩니다.
  2. 구간이 서로 겹치는가. 겹친 창은 크기뿐 아니라 신호의 위치를 속입니다. 겹치지 않게 다시 자르고 값이 그대로인지 확인하세요.
  3. 하나 빼면 무너지는가. 종목 하나, 날짜 하루를 빼고 다시 돌려 보세요. 한 축이 통째로 만들던 신호였던 적이 저는 있었습니다.

솔직한 부분

이 축은 이걸로 닫았습니다. 예측(LLM), 가격 편향, 모형(확산 공정가), 청산 정책까지 네 갈래를 다 쳤고 남은 게 없습니다. 하루 종일 판 결과물이 "없다"입니다.

그런데 하루에 같은 실수를 세 번 하고 세 번 다 스스로 잡았다는 게, 결과보다 값이 컸습니다. 남은 건 재사용 가능한 도구 세 개입니다 — 족별 p값 시뮬레이터, 겹치지 않는 구간 분해기, 라운드/일자 두 수준 클러스터 부트스트랩. 다음 실험은 이걸 들고 시작합니다.

사전등록은 "데이터를 보기 전에 규칙을 적어두는" 습관입니다. 이번에 배운 건 그 짝입니다. 데이터를 보고 나서 뭔가를 찾았다면, 찾기까지 몇 번 봤는지도 같이 적어야 합니다. 그 숫자가 없으면 p값은 의미가 없습니다. 계측 자체가 이긴 판만 세고 있었던 편실패한 봇을 깨끗하게 죽이는 편도 결국 같은 이야기의 다른 얼굴입니다.

당신의 마지막 백테스트에서 조합을 몇 개 돌려봤는지, 한번 세어 보시겠습니까? 그 수를 알려주시면 좋겠습니다.

관련 글