I added tests to my mock trading math for the first time, and total assets came out as minus 1 million won
My crypto mock trading site had no tests at all, so I started with liquidation math and the reconnecting socket using Vitest. 1 of 26 tests failed, and it was a real bug: with several cross positions, the same cash was counted as lost more than once.
#Testing #Vitest #TypeScript #WebSocket #Mock trading
It has been a while since I added futures mock trading to my crypto site. Long and short, 13 leverage steps, isolated and cross margin, and even liquidation checks for the time the app was closed. A lot of code that calculates numbers piled up along the way. But until now there was only one way to check it. Open a position on screen and see if the numbers look about right. Checking by eye only tells you whether something looks about right. If the liquidation price shows 90.5 million won it seems fine, but to know it is actually correct you have to work it out on a calculator. And the next time you touch the code, you have to do that math again. So this time I added tests for the first time. To give away the ending, I wrote 26 of them, 1 failed, and that 1 was a real bug. What to test first If you start by testing screen components, you have to set up the rendering environment, the store, and the router before you even begin, and you get tired before writing a single test. So I picked the places where mistakes are hard to notice. One was futuresTrading.ts . It calculates liquidation price, profit and loss, whether a position is liquidated, and how close it is to liquidation. It does not use React or the store, it only has functions that take numbers and return numbers. Files like that can be tested right away. The other was the reconnecting socket wrapper I built in the exchange WebSocket post . It depends on time and the network, so it is the most annoying code to check by hand. To see if the reconnect wait grows from 1 second to 2 to 4, you have to actually cut the connection and watch the clock. I used Vitest. The project already builds with Vite, so there was no need for a separate config file. It just uses the existing config and tsconfig. With Jest I would have had to set up TypeScript transforms and module paths separately, and there was no reason to. npm install -D vitest@3.2.4 // package.json "test": "vitest run" The liquidation tests do not rewrite the formula The liquidation formula itself is simple. The maintenance margin rate is 0.5%, so for a 10x long the liquidation price is 9.5% below the entry. A 10x long on Bitcoin at 100 million won liquidates at 90.5 million. it('a 10x long liquidates at a 9.5% drop (0.5% maintenance margin)', () = { expect(calcLiquidationPrice('long', 100_000_000, 10)).toBeCloseTo(90_500_000); }); it('a 100x long liquidates after just a 0.5% drop', () = { expect(calcLiquidationPrice('long', 100_000_000, 100)).toBeCloseTo(99_500_000); }); But tests like these alone leave something missing. The expected 90.5 million is a value I worked out in my head with the same formula, so if I had misunderstood the formula, the code and the test would be wrong together. So I added one more that tests what a liquidation price means instead of the formula. The liquidation price is the price where, once you hit it, only the maintenance margin is left out of your margin. So if you calculate profit and loss at the liquidation price, the loss should be exactly the margin minus the maintenance margin. This makes the liquidation function and the PnL function check each other, so it fails if either one is wrong. I ran it over every leverage step. it('the loss at the liquidation price is margin minus maintenance margin', () = { 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); } }); This one passed. The isolated margin math was written correctly. The one test that failed Unlike isolated margin, which only puts the position margin up as collateral, cross margin also uses the cash left in the account. So when clamping PnL at the collateral limit, I let it lose up to the margin plus the free cash. With one position there is no problem. I wondered what happens with two positions, so I wrote a test. An account with 1 million won of cash left opens two 10x cross longs with 1 million won of margin each, and then the price gets cut in half. The most this account can lose is 2 million of margin plus 1 million of cash, 3 million won. There is no more money to lose beyond that. it('total loss of several cross positions never exceeds total margin plus free cash', () = { 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); // the most it can lose is 2M margin + 1M cash expect(total).toBeGreaterThanOrEqual(-3_000_000); }); This failed. × PnL total loss of several cross positions never exceeds total margin plus free cash 7ms - expected -4000000 to be greater than or equal to -3000000 Tests 1 failed | 17 passed (18) It should lose at most 3 million won, but it was calculating a 4 million won loss. Looking at the original code, the reason is right there. // before the fix return futuresPositions.reduce((total, position) = { const price = getPositionPrice(position, krwPrices, markPrices); if (!price) return total; return total + calcClampedFuturesPnl(position, price, balance); }, 0); It clamps each position separately and passes the whole balance every time. The first position treats the 1 million won of cash as its own collateral, and the second position treats the same 1 million as its own too. There is only one pile of cash, but it was counted as lost twice . With three positions it is lost three times. When I followed where this value shows up on screen, it was the total assets in the asset summary on the investment page and the home page. I put the same case into the real summary function and compared it with the old logic. {"oldPnl":-4000000,"oldTotal":-1000000,"newPnl":-3000000,"newTotal":0} Before the fix, total assets came out as minus 1 million won. In mock trading you cannot lose more money than you have, but that is what the screen would have shown. It only shows up when you hold two or more cross positions and take a big loss, so it seems nobody had brought it up until now. How I fixed it I split the calculation between isolated and cross. Isolated is clamped per position at its margin like before, and cross adds up all the PnL first and then clamps once. The floor is the total margin of the cross positions plus the cash. 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); As a bonus this is also closer to how cross margin works on a real exchange, because between cross positions the profit on one side covers the loss on the other. I left a test for that too. If a Bitcoin long drops 5% and loses 500,000 won while an Ethereum short gains 3% and makes 300,000 won, together it is a 200,000 won loss. it('between cross positions, profit on one side covers loss on the other', () = { 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); }); The same problem exists in the liquidation check After fixing PnL I looked around for other places, and the liquidation check works the same way. When it calculates the liquidation price of a cross position, it passes the entire free cash to each position as its own collateral. With two positions, both think the cash is theirs and put the liquidation price further away than it really is. I did not fix this one at the same time. PnL is done once you add it up and clamp it once, but if liquidation is decided at the account level, I have to rethink the current screen that shows one liquidation price per position. One position's liquidation price would keep moving with the PnL of the other positions. It was not something to squeeze in while fixing the PnL bug, so I decided to do it separately and left it in the test file as a to do so I would not forget. // The liquidation check still treats all free cash as each position's own collateral. Fill this in when it moves to an account-level check. it.todo('with several cross positions, decide liquidation as if they share the free cash'); Vitest does not count a todo as a failure, it counts it separately in the results. Every time I run the tests, I can see there is one thing still left to do. Fake socket and fake clock for the reconnecting socket You cannot connect to a real exchange when testing the socket wrapper. So I made a fake WebSocket where the test itself can open and drop the connection, and put it in place of the global one. 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(); }); Time ran on Vitest's fake timers. Calling vi.advanceTimersByTime(1000) runs setTimeout as if a second had passed, without actually waiting. Even a 30 second wait finishes instantly in a test. The backoff has jitter mixed in, so the wait is different every time. So I fixed Math.random to 1 to keep the jitter at its maximum, dropped the connection eight times in a row, and measured how long it took each time before a new socket was created. it('consecutive failures double the wait and stop at 30 seconds', () = { 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]); }); Besides that, I added tests for whether the failure count goes back to 0 after a successful connection, whether it sends a PING every 60 seconds while open and stops when dropped, and whether it stays disconnected after close is called when you leave the screen. I also added the thing I struggled with the most in post 77, whether the subscribe message is sent again on every reconnect. it('calls onOpen again on every reconnect to resend the subscription', () = { 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']); }); I broke the code on purpose to see if the tests catch it It feels good when every test is green, but you cannot tell whether that is because the code is right or because the tests are not looking at anything. So I deleted important lines from the socket wrapper one at a time and ran the tests. When I deleted the line that calls onOpen on reconnect, the subscription test failed. When I deleted the line that resets f