토이프로젝트 정리 노트. 3편에서 1–10초짜리 약한 OFI 신호를 찾았다. 이번엔 그 신호를 집행에 써본다(predict → act). 대량 주문을 잘게 쪼개 시장충격을 줄이는 문제. 데이터는 3편과 같은 Binance
depth20@100msBTCUSDT 스냅샷 약 2만 7천 장(약 45분). 코드는 repo. 금융 배경 없이 읽도록 썼다.
큰 주문은 한 번에 못 던진다. 50 BTC를 지금 다 팔면 매수 호가를 위에서부터 먹어 내려가서 첫 체결은 비싸게, 마지막 체결은 싸게 팔린다. 파는 행위 자체가 가격을 끌어내린다. 이걸 시장충격(market impact)이라 하고, 그래서 주문을 몇 분에 걸쳐 잘게 나눠 흘려보낸다. 이 나눠 파는 계획이 집행 스케줄이다.
스케줄의 점수는 implementation shortfall(IS)로 잰다. 팔기로 결정한 순간의 가격(arrival price)과 실제 평균 체결가의 차이를, bp 단위로(1bp = 0.01%) 잰 거다. 낮을수록 좋다. 빨리 팔면 가격이 새기 전에 끝나지만 충격을 더 문다. 천천히 팔면 충격은 작지만 그동안 가격이 떠내려갈 위험에 더 오래 노출된다. 이 빠름과 느림의 trade-off를 푼 게 Almgren-Chriss(AC)고, 가장 무던한 극단이 매 구간 같은 양을 파는 TWAP다.
3편의 OFI는 호가창의 매수·매도 압력 불균형을 한 숫자로 요약한 신호였다. 다음 1–10초 가격 방향을 약하게 맞힌다. 파는 입장에서 OFI가 양수(매수 압력, 곧 오를 듯)면 좋은 소식이니 지금은 덜 팔고 잠깐 기다린다. OFI가 음수(매도 압력, 곧 빠질 듯)면 나쁜 소식이니 떨어지기 전에 더 판다. 나쁜 흐름을 거슬러 기울이는 거다. TWAP를 기준으로 두고 매 구간 OFI 신호만큼 슬라이스를 키우거나 줄인다. 기울기 세기는 손잡이 하나 κ로 잡았다(κ=0.6).
미래 차단이 전부다. 지금 얼마 팔지 정할 때 미래 가격이나 미래 OFI를 절대 못 쓴다. 스케줄을 매번 Q에 맞춰 재정규화하는 흔한 코드가 몰래 뒤쪽 슬라이스를 엿보기 때문에, 미래 구간은 고정된 기본 가중치만 쓰도록 짰다. 미래가 새면 결과는 환상적으로 좋아지고 전부 가짜가 된다.
비교 대상이 TWAP면 안 된다
OFI 스케줄을 그냥 TWAP와 비교하면 안 된다. 함정이 있다. TWAP에서 조금이라도 벗어난 스케줄은 전부 충격을 더 문다(같은 양을 균등하게 파는 게 Σqᵢ²를 최소화한다). 그러니 OFI로 기울인 스케줄은 TWAP보다 나빠 보이는데, 신호 없이 무작위로 기울인 스케줄도 똑같이 나빠 보인다. 추가 비용은 기울였다는 사실에서 나온 거지 OFI가 똑똑해서가 아니다.
옳은 비교는 OFI 스케줄 대 같은 OFI 값을 무작위로 섞어 넣은 스케줄이다. 둘 다 똑같이 세게 기울여서 충격 비용은 같고, 신호의 타이밍이 진짜인지만 갈린다. 그 차이가 genuine edge다. 충격 비용이 양쪽에서 상쇄돼서 순수한 타이밍 실력만 남는다. 3편에서 OFI를 PCA 통제와 비교했던 것과 같은 습관이다. 기준선은 0이 아닌 구조 맞춤 null, 여기선 shuffle이다(25번 섞어 turnover를 맞췄다).
결과는 깨끗한 음의 null
집행 길이를 5초, 20초, 60초로 늘려가며 genuine edge를 쟀다(+면 OFI가 비용을 줄인다는 뜻).
| 집행 길이 | 창 수 | TWAP 평균 IS | genuine edge ± SE | t | 판정 |
|---|---|---|---|---|---|
| 50슬라이스 / 5초 | 400 | +5.52 bps | −0.037 ± 0.006 | −6.66 | 유의하게 음수(OFI가 해침) |
| 200 / 20초 | 134 | +4.60 bps | +0.002 ± 0.010 | +0.21 | null |
| 600 / 60초 | 44 | +4.80 bps | +0.054 ± 0.019 | +2.91 | null( |

decay하다 사라질 양의 edge가 애초에 없다. 점추정은 우상향하지만, IS 표준편차가 구간이 길어질수록 0.9 → 2.1 → 3.8로 커지며 밴드가 그만큼 오른쪽으로 벌어져 그 점을 계속 삼킨다. 60초는 t=+2.91, |t|>3 게이트 바로 아래라 게이트 기준 null이지 밴드 한복판은 아니다. 분명하게 0이 아닌 칸은 5초 하나뿐인데, 그게 음수다. 게다가 어디서든 IS 표준편차의 1.4% 이하라 경제적으로는 무의미하다.
도와야 할 자리에서 가장 해롭다
결과를 보기 전에 작은 regime 격자를 미리 등록해뒀다. 변동성 3분위, 스프레드 3그룹, 주문 크기(Q=10 vs 200), 8칸 × = 16개 검정. 다중검정 게이트는 |t|>3(≡ Bonferroni 0.05/16).
신호가 가장 잘 살아 있어야 할 자리가 어디냐면, 가격이 가장 많이 움직이는 고변동에서 신호가 가장 신선한 최단 구간이다. 5초, vol_high. 바로 그 칸이 가장 음수인 축에 든다(t=−5.19).

변동성을 따라 −0.004 → −0.042 → −0.074, 주문 크기를 따라 −0.025(Q10) → −0.091(Q200)로 해악이 단조로 커진다. 단조로 커진다는 게 메커니즘의 지문이다. 무작위 노이즈라면 칸마다 들쭉날쭉할 텐데, 신호를 살짝 거꾸로 기울이는 정책이라 노출이 클수록 손해가 커진다. 20초 칸은 전부 |t|<1.7로 죽는다. 게이트를 넘은 양의 칸은 어느 구간에도 없다(exploratory_candidates: []). 음의 결과가 다중검정 보정에 기대고 있지 않다는 뜻이다. 보정 전에도 양수가 0이니까.
null을 믿어도 되는 이유, 그리고 처음에 틀렸던 것
null은 그걸 뽑아낸 배관이 멀쩡할 때만 값어치가 있다. 두 가지를 짚었다.
엔진은 closed-form AC와 맞춰봤다. risk-aversion λ를 0으로 보내면 AC 최적 스케줄이 정확히 TWAP가 된다(max|Δqty| = 7×10⁻¹⁵, 사실상 기계 정밀도). 해석적으로 계산한 충격 비용도 시뮬레이터 값과 모든 λ에서 기계 정밀도로 일치한다. 그래서 "edge 없음"은 신호에 대한 진술이다. 망가진 시뮬레이터가 만든 게 아니다.
처음엔 양의 edge가 나왔고, 그게 구간이 길어질수록 강해졌다. 1–10초 신호가 60초에서 더 세진다는 게 말이 안 됐다. 그 위화감이 단서였다. genuine edge의 SE를 shuffle 평균의 몬테카를로 오차(섞기 횟수의 √으로 나눈 값)로 잡은 게 버그였다. 이 값은 섞기를 늘릴수록 0으로 간다. 그러니 섞기만 많이 하면 +0.002 bps짜리 미동도 ">3σ 유의"로 찍힌다. 고쳐 쓴 건 paired cross-window SE다. 겹치지 않는 독립 창마다 (섞은 IS − 실제 IS)를 구해 그 분산을 본다. 시장을 다시 뽑았을 때 edge가 버티는지를 실제로 지배하는 불확실성이다. 올바른 SE로 다시 그리니 "성장"은 밴드 안으로 무너졌다. 자라던 건 신호가 아니라 IS 표준편차였다. 유의성 검정은 분모만큼만 정직하다.
호가창 한 장으로는 못 하는 말
한 번 더 거래하는 방법이 있다. 내가 가격을 가져가는(taking) 대신 매수·매도 호가를 직접 걸어두고(posting) 체결을 기다리는 market-making이다. Avellaneda-Stoikov 모델로 재고에 따라 호가를 기울이는 행동까지 시뮬레이션했고, 재고가 0으로 평균회귀하고 재고가 없을 때 호가가 대칭이 되는 등 정성적으로 옳게 움직인다.
그런데 PnL 숫자는 어디에도 없다. 일부러 안 적었다. market-making이 돈이 되는지 말하려면 내가 건 호가가 언제 체결되는지를 알아야 하는데, 그 체결은 보통 시장이 나에게 불리하게 막 움직이려는 순간에 일어난다(adverse selection). 스냅샷 데이터는 큐 위치도, 체결 여부도, adverse selection도 구조적으로 못 본다. "가격이 내 호가를 건드렸으니 체결됐다"는 흔한 착각이 이 셋을 조용히 지워버리고 가짜 수익을 만든다. 그래서 못 하는 말이 뭔지 정확히 긋고 PnL 주장을 거부하는 게 결과물이다.
남는 것
집행 비용이 (가정한) 충격 계수 η, γ에 깔려 있다. 절대 IS bps는 예시고, 주장은 모양(frontier, regime 패턴, null)이다. shuffle이 충격 비용을 양쪽에서 상쇄하니 타이밍 null 자체는 계수 가정에서 1차로 분리돼 있긴 하다. 종목 하나, 45분 하나다. 60초 창은 44개뿐이라 분위로 못 쪼갰다. 체결 보장·충격만 모델링하는 marketable 가정도 스냅샷의 한계 안이다. 그리고 거스르는 방향은 미리 등록한 거다. 음수가 나왔다고 with-flow로 부호를 뒤집어 양수를 사냥하지 않았다. 그건 이 프로젝트가 피하려는 낚시니까.
세 프로젝트에서 같은 모양이 반복됐다. 변동성 크기는 HAR, 비용 낮은 헤지는 Black-Scholes, 단기 방향은 원본 호가창. 신경망이든 신호든 교과서 가정이 깨지는 좁은 자리에서만 값을 했다. 여기선 한 걸음 더 갔다. 단기 알파가 진짜로 살아 있는 5초에서조차, 그걸 집행에 기대면 비용이 외려 늘었다. 예측에서 행동으로 넘어가며 신호가 살아남지 못한 거다. RL 집행 에이전트(상태 = 남은 수량·시간·OFI·스프레드, 보상 = −shortfall)로 다른 렌즈를 대보면 이 null이 재현되는지가, 다음에 가지고 갈 질문이다.
Data: ≈27k live L2 snapshots (Binance depth20@100ms, BTCUSDT, ≈45 min, reused from Deep OFI). Almgren-Chriss impact, marketable slices, IS = timing + impact 정확 분해. genuine edge = raw − shuffle(25), paired cross-window SE, disjoint windows. Regime grid pre-registered, |t|>3 ≡ Bonferroni(0.05/16). 충격 계수 η, γ는 fit이 아닌 명시된 가정. impact/IS 항등식·AC closed-form·leak-free·shuffle 통제 테스트 green.