모의투자 계산에 테스트를 처음 붙였더니 총자산이 마이너스 100만 원으로 나왔다
코인 모의투자 사이트에 테스트가 하나도 없어서 청산가 계산과 재연결 소켓부터 Vitest로 테스트를 붙였다. 26개 중 1개가 실패했는데, 교차 포지션이 여러 개면 같은 현금을 여러 번 잃는 것으로 계산하던 버그였다.
#테스트 #Vitest #TypeScript #WebSocket #모의투자
코인 사이트에 선물 모의투자를 붙인 지 한참 됐다. 롱과 숏, 레버리지 13단계, 격리와 교차 마진, 앱이 꺼져 있던 동안의 청산 판정까지 들어가면서 숫자를 계산하는 코드가 꽤 많아졌다. 근데 이걸 확인하는 방법은 지금까지 하나였다. 화면에서 포지션을 열어보고 숫자가 그럴듯한지 눈으로 보는 거다. 눈으로 보는 확인은 그럴듯한지까지만 알려준다. 청산가가 9,050만 원으로 나오면 대충 맞아 보이는데, 그게 정말 맞는 값인지는 계산기를 따로 두드려봐야 안다. 그리고 다음에 코드를 고치면 그 계산을 또 해야 한다. 그래서 이번에 처음으로 테스트를 붙였다. 결과부터 말하면 26개를 짰고 그중 1개가 실패했는데, 그 1개가 실제 버그였다. 무엇부터 테스트할지 처음부터 화면 컴포넌트를 테스트하려고 하면 렌더링 환경, 스토어, 라우터까지 다 세팅해야 해서 시작도 전에 지친다. 그래서 틀려도 눈에 잘 안 띄는 곳부터 골랐다. 하나는 futuresTrading.ts 다. 청산가, 손익, 청산 여부, 청산까지 얼마나 가까운지를 계산하는 파일인데, React도 스토어도 안 쓰고 숫자를 받아서 숫자를 돌려주는 함수만 있다. 이런 파일은 바로 테스트할 수 있다. 다른 하나는 거래소 WebSocket 글 에서 만든 재연결 소켓 래퍼다. 이건 시간과 네트워크가 걸려 있어서 손으로 확인하기가 제일 귀찮은 코드다. 재연결 대기 시간이 1초, 2초, 4초로 늘어나는지 보려면 실제로 연결을 끊고 시계를 보고 있어야 한다. 도구는 Vitest를 썼다. 이 프로젝트는 이미 Vite로 빌드하고 있어서 설정 파일을 따로 만들 필요 없이 기존 설정과 tsconfig를 그대로 쓴다. Jest를 쓰면 TypeScript 변환과 모듈 경로 설정을 따로 맞춰야 하는데 그럴 이유가 없었다. npm install -D vitest@3.2.4 // package.json "test": "vitest run" 청산가 테스트는 공식을 다시 쓰지 않았다 청산가 공식 자체는 단순하다. 유지증거금률이 0.5%라서 10배 롱이면 진입가에서 9.5% 떨어진 곳이 청산가다. 비트코인을 1억에 10배 롱으로 잡으면 9,050만 원이다. it('10배 롱은 9.5% 하락에서 청산된다 (유지증거금 0.5%)', () = { expect(calcLiquidationPrice('long', 100_000_000, 10)).toBeCloseTo(90_500_000); }); it('100배 롱은 0.5%만 빠져도 청산된다', () = { expect(calcLiquidationPrice('long', 100_000_000, 100)).toBeCloseTo(99_500_000); }); 근데 이런 테스트만 있으면 하나가 아쉽다. 기대값 9,050만은 결국 내가 같은 공식으로 머릿속에서 계산한 값이라, 공식을 잘못 이해하고 있었다면 코드와 테스트가 같이 틀린다. 그래서 공식 말고 청산가가 무슨 뜻인지 를 테스트하는 걸 하나 더 넣었다. 청산가는 그 가격에 닿았을 때 증거금에서 유지증거금만큼만 남는 가격이다. 그러니까 청산가에서 손익을 계산하면 손실이 정확히 증거금에서 유지증거금을 뺀 만큼이어야 한다. 이건 청산가 함수와 손익 함수가 서로를 검증하는 구조라서, 둘 중 하나만 틀려도 걸린다. 레버리지 단계를 전부 돌렸다. it('청산가에서 잃는 돈은 증거금에서 유지증거금을 뺀 만큼이다', () = { for (const leverage of [1, 2, 3, 5, 10, 20, 25, 50, 75, 100]) { const pos = makePosition({ leverage }); const notional = pos.margin * leverage; const loss = -calcFuturesPnl(pos, pos.liquidationPrice); expect(loss).toBeCloseTo(pos.margin - notional * 0.005, 4); } }); 이건 통과했다. 격리 마진 쪽 계산은 맞게 짜여 있었다. 실패한 테스트 하나 교차 마진은 증거금만 담보로 잡는 격리와 달리 계좌에 남은 현금까지 담보로 쓴다. 그래서 손익을 담보 한도에서 자를 때 증거금에 여유 현금을 더한 만큼까지 잃을 수 있게 해뒀다. 포지션이 하나일 때는 문제가 없다. 포지션이 두 개면 어떻게 되는지 궁금해서 테스트를 하나 짰다. 현금 100만 원이 남은 계좌에서 증거금 100만 원짜리 10배 교차 롱을 두 개 잡고, 가격이 반토막 났다고 치는 거다. 이 계좌가 잃을 수 있는 최대치는 증거금 200만 원에 현금 100만 원을 더한 300만 원이다. 그 이상은 잃을 돈이 없다. it('교차 포지션 여러 개의 손실 합은 증거금 합과 여유 현금을 넘지 않는다', () = { const balance = 1_000_000; const a = makePosition({ id: 'a', marginMode: 'cross' }); const b = makePosition({ id: 'b', marginMode: 'cross', market: 'KRW-ETH' }); const prices = priceMap({ 'KRW-BTC': ENTRY / 2, 'KRW-ETH': ENTRY / 2 }); const total = calculateFuturesPnl([a, b], prices, balance); // 잃을 수 있는 최대치는 증거금 2백만 + 현금 1백만 expect(total).toBeGreaterThanOrEqual(-3_000_000); }); 이게 실패했다. × 손익 교차 포지션 여러 개의 손실 합은 증거금 합과 여유 현금을 넘지 않는다 7ms → expected -4000000 to be greater than or equal to -3000000 Tests 1 failed | 17 passed (18) 300만 원까지만 잃어야 하는데 400만 원을 잃은 걸로 계산하고 있었다. 원래 코드를 보면 이유가 바로 보인다. // 고치기 전 return futuresPositions.reduce((total, position) = { const price = getPositionPrice(position, krwPrices, markPrices); if (!price) return total; return total + calcClampedFuturesPnl(position, price, balance); }, 0); 포지션마다 손익을 자르면서 매번 balance 를 통째로 넘긴다. 첫 번째 포지션도 현금 100만 원을 자기 담보로 보고, 두 번째 포지션도 같은 100만 원을 자기 담보로 본다. 현금은 한 번뿐인데 두 번 잃은 것 으로 계산된 거다. 포지션이 세 개면 세 번 잃는다. 이 값이 화면 어디에 쓰이는지 따라가 보니 투자 페이지와 홈의 자산 요약에 나오는 총자산이었다. 같은 상황을 실제 요약 함수에 넣고 고치기 전 방식과 비교해봤다. {"oldPnl":-4000000,"oldTotal":-1000000,"newPnl":-3000000,"newTotal":0} 고치기 전에는 총자산이 마이너스 100만 원으로 나왔다. 모의투자에서 가진 돈보다 더 많이 잃는 건 불가능한데 화면에는 그렇게 찍혔을 거다. 교차 포지션을 두 개 이상 잡고 크게 물려야 드러나는 경우라 지금까지 아무도 말을 안 했던 것 같다. 고친 방법 격리와 교차를 나눠서 계산하게 바꿨다. 격리는 지금처럼 포지션마다 증거금에서 자르고, 교차는 손익을 먼저 다 더한 다음 한 번만 자른다. 바닥은 교차 포지션들의 증거금 합에 현금을 더한 값이다. let isolatedPnl = 0; let crossPnl = 0; let crossMargin = 0; futuresPositions.forEach((position) = { const price = getPositionPrice(position, krwPrices, markPrices); if (!price) return; if (getMarginMode(position) === 'cross') { crossPnl += calcFuturesPnl(position, price); crossMargin += position.margin; } else { isolatedPnl += calcClampedFuturesPnl(position, price); } }); const crossFloor = -(crossMargin + Math.max(0, balance)); return isolatedPnl + Math.max(crossPnl, crossFloor); 이렇게 하면 덤으로 실제 거래소의 교차 마진과도 더 비슷해진다. 교차 포지션끼리는 한쪽 수익이 다른 쪽 손실을 메워주기 때문이다. 이것도 테스트로 남겼다. 비트코인 롱이 5% 빠져서 50만 원을 잃고 이더리움 숏이 3% 벌어서 30만 원을 얻으면 합쳐서 20만 원 손실이다. it('교차 포지션끼리는 한쪽 수익이 다른 쪽 손실을 메운다', () = { const a = makePosition({ id: 'a', marginMode: 'cross' }); const b = makePosition({ id: 'b', marginMode: 'cross', direction: 'short', market: 'KRW-ETH' }); const prices = priceMap({ 'KRW-BTC': ENTRY * 0.95, 'KRW-ETH': ENTRY * 0.97 }); expect(calculateFuturesPnl([a, b], prices, 0)).toBeCloseTo(-200_000); }); 같은 문제가 청산 판정에도 있다 손익을 고치고 나서 다른 곳도 찾아봤는데, 청산 판정도 같은 방식이었다. 교차 포지션의 청산가를 계산할 때 포지션마다 여유 현금 전체를 자기 담보로 넘긴다. 포지션이 두 개면 둘 다 현금이 자기 것인 줄 알고 청산가를 실제보다 멀리 잡는다. 이건 이번에 같이 고치지 않았다. 손익은 합쳐서 한 번 자르면 끝나지만, 청산을 계좌 단위로 판정하기 시작하면 포지션마다 청산가를 하나씩 보여주는 지금 화면부터 다시 생각해야 한다. 한 포지션의 청산가가 다른 포지션의 손익에 따라 계속 움직이게 되기 때문이다. 손익 버그를 고치는 김에 끼워 넣을 크기가 아니라서 따로 하기로 했고, 잊어버리지 않게 테스트 파일에 할 일로 남겨뒀다. // 청산 판정은 아직 포지션마다 여유 현금 전체를 자기 담보로 본다. 계좌 단위 판정으로 바꿀 때 채운다. it.todo('교차 포지션이 여러 개면 여유 현금을 나눠 쓰는 것으로 보고 청산을 판정한다'); Vitest는 todo를 실패로 치지 않고 결과에 따로 세어서 보여준다. 테스트를 돌릴 때마다 아직 안 한 일이 하나 있다는 게 눈에 보인다. 재연결 소켓은 가짜 소켓과 가짜 시계로 소켓 래퍼를 테스트할 때 진짜 거래소에 붙을 수는 없다. 그래서 테스트 안에서 연결이 열리고 끊기는 걸 직접 일으킬 수 있는 가짜 WebSocket을 만들어서 전역에 끼웠다. class FakeWebSocket { static OPEN = 1; static instances: FakeWebSocket[] = []; readyState = 0; sent: string[] = []; onopen: (() = void) | null = null; onclose: (() = void) | null = null; // ... constructor(public url: string) { FakeWebSocket.instances.push(this); } send(data: string) { this.sent.push(data); } serverOpens() { this.readyState = FakeWebSocket.OPEN; this.onopen?.(); } serverDrops() { this.readyState = 3; this.onerror?.(); this.onclose?.(); } } beforeEach(() = { vi.stubGlobal('WebSocket', FakeWebSocket); vi.useFakeTimers(); }); 시간은 Vitest의 가짜 타이머로 돌렸다. vi.advanceTimersByTime(1000) 을 부르면 실제로 기다리지 않고 1초가 지난 것처럼 setTimeout이 실행된다. 30초 대기도 테스트에서는 바로 끝난다. 백오프에는 지터가 섞여 있어서 대기 시간이 매번 다르다. 그래서 Math.random 을 1로 고정해 지터를 최대로 두고, 연속으로 여덟 번 끊었을 때 각각 몇 초 뒤에 새 소켓이 생기는지 쟀다. it('연속으로 실패하면 대기 시간이 두 배씩 늘고 30초에서 멈춘다', () = { vi.spyOn(Math, 'random').mockReturnValue(1); createReconnectingSocket({ url: 'wss://x', onMessage: () = {} }); const waits: number[] = []; for (let i = 0; i 8; i++) { const before = FakeWebSocket.instances.length; latest().serverDrops(); let waited = 0; while (FakeWebSocket.instances.length === before) { vi.advanceTimersByTime(100); waited += 100; } waits.push(waited); } expect(waits).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]); }); 이 밖에 연결에 성공하면 실패 횟수가 0으로 돌아가는지, 열려 있는 동안 60초마다 PING을 보내고 끊기면 멈추는지, 화면을 떠나서 close를 부른 뒤에는 다시 연결하지 않는지를 넣었다. 그리고 77번 글에서 제일 오래 헤맸던 것도 넣었다. 재연결될 때마다 구독 메시지를 다시 보내는지다. it('재연결될 때마다 onOpen을 다시 불러 구독을 다시 보낸다', () = { const subscribe = vi.fn((ws: WebSocket) = ws.send('SUBSCRIBE')); createReconnectingSocket({ url: 'wss://x', onMessage: () = {}, onOpen: subscribe }); latest().serverOpens(); latest().serverDrops(); vi.advanceTimersByTime(1000); latest().serverOpens(); expect(FakeWebSocket.instances).toHaveLength(2); expect(subscribe).toHaveBeenCalledTimes(2); expect(latest().sent).toEqual(['SUBSCRIBE']); }); 테스트가 진짜 잡는지 일부러 망가뜨려봤다 테스트가 전부 초록색이면 기분은 좋은데, 그게 코드가 맞아서인지 테스트가 아무것도 안 보고 있어서인지는 구분이 안 된다. 그래서 소켓 래퍼에서 중요한 줄을 하나씩 지우고 돌려봤다. 재연결 시 onOpen 을 부르는 줄을 지우니 구독 테스트 하나가 실패했다. 연결 성공 시 failures = 0 으로 되돌리는 줄을 지우니 실패 횟수 초기화 테스트 하나가 실패했다. 둘 다 정확히 그 줄을 지키는 테스트만 빨간색이 됐고, 원래대로 돌려놓으니 다시 전부 통과했다. 결과 ✓ src/utils/__tests__/reconnectingSocket.test.ts (7 tests) 33ms ✓ src/utils/__tests__/futuresTrading.test.ts (19 tests) 15ms Test Files 2 passed (2) Tests 26 passed (26) Duration 805ms 청산 계산 19개, 소켓 7개, 합쳐서 26개가 1초 안에 끝난다. 그 뒤에 청산 판정 할 일 하나를 todo로 넣어서 지금은 26개 통과에 todo 1개로 나온다. 앞으로 선물 계산을 고칠 때는 화면을 열기 전에 npm test 부터 돌리면 된다. 하나 주의할 게 있었다. 이 프로젝트는 빌드할 때 tsc 로 타입 검사를 먼저 하는데, 테스트 파일도 src 안에 있어서 같이 검사된다. 처음에 테스트에서 시세 객체를 대충 만들었더니 테스트는 통과하는데 타입 검사에서 걸렸다. 테스트 때문에 배포 빌드가 깨질 뻔해서 테스트용 헬퍼에 타입을 맞춰 넣었다. 해보고 나서 테스트를 붙이기 전에는 이 코드에 버그가 있을 거라고 생각을 안 했다. 화면에서 포지션 하나를 열어보면 숫자는 늘 그럴듯했다. 근데 교차 포지션 두 개를 잡고 현금까지 바닥날 만큼 물리는 조합은 손으로 일부러 만들지 않는 이상 볼 일이 없다. 버그는 그런 조합에 숨어 있었다. 테스트를 짜면서 제일 도움이 된 건 테스트 이름을 먼저 문장으로 쓰는 거였다. 손실 합은 증거금 합과 여유 현금을 넘지 않는다, 이렇게 써놓고 나니 그럼 포지션이 두 개면, 하는 질문이 자연스럽게 따라왔다. 숫자를 계산하는 코드가 있는데 확인을 화면으로만 하고 있다면, 그 코드가 지켜야 하는 규칙을 문장으로 한 줄씩 적어보길 추천한다. 그 문장이 그대로 테스트 이름이 된다.