@katanaperps/katana-perps-sdk 2.1.0-beta.13 → 2.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,773 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const chai = tslib_1.__importStar(require("chai"));
5
+ const _pipmath_1 = require("#pipmath");
6
+ const orderbook = tslib_1.__importStar(require("#orderbook/index"));
7
+ const testHelpers = tslib_1.__importStar(require("#tests/testHelpers"));
8
+ const request_1 = require("#types/enums/request");
9
+ const { expect } = chai;
10
+ /**
11
+ * Default market: ETH-USD, index price 100, 10x leverage (IMF 0.1), MMF 0.05,
12
+ * a flat 0.1% taker fee, no incremental IMF, and no execution price limits.
13
+ */
14
+ const defaultMarket = {
15
+ market: 'ETH-USD',
16
+ indexPrice: '100.00000000',
17
+ maximumPositionSize: '1000000.00000000',
18
+ initialMarginFraction: '0.10000000',
19
+ maintenanceMarginFraction: '0.05000000',
20
+ basePositionSize: '1000000.00000000',
21
+ incrementalPositionSize: '1.00000000',
22
+ incrementalInitialMarginFraction: '0.01000000',
23
+ marketOrderExecutionPriceLimit: '0.00000000',
24
+ limitOrderExecutionPriceLimit: '0.00000000',
25
+ };
26
+ /**
27
+ * Default wallet: 1,000 quote balance, no positions, no held collateral.
28
+ */
29
+ const defaultWallet = {
30
+ equity: '1000.00000000',
31
+ heldCollateral: '0.00000000',
32
+ quoteBalance: '1000.00000000',
33
+ marginRatio: '0.00000000',
34
+ makerFeeRate: '0.00000000',
35
+ takerFeeRate: '0.00100000',
36
+ positions: [],
37
+ };
38
+ const level = (price, size, numOrders = 1) => ({
39
+ price: (0, _pipmath_1.decimalToPip)(price),
40
+ size: (0, _pipmath_1.decimalToPip)(size),
41
+ numOrders,
42
+ type: 'limit',
43
+ });
44
+ const position = (overrides) => ({
45
+ quantity: '0.00000000',
46
+ maximumQuantity: '0.00000000',
47
+ entryPrice: '0.00000000',
48
+ exitPrice: '0.00000000',
49
+ markPrice: '0.00000000',
50
+ indexPrice: '0.00000000',
51
+ liquidationPrice: '0.00000000',
52
+ value: '0.00000000',
53
+ realizedPnL: '0.00000000',
54
+ unrealizedPnL: '0.00000000',
55
+ marginRequirement: '0.00000000',
56
+ leverage: '0.00000000',
57
+ totalFunding: '0.00000000',
58
+ totalOpen: '0.00000000',
59
+ totalClose: '0.00000000',
60
+ adlQuintile: 0,
61
+ openedByFillId: '',
62
+ lastFillId: '',
63
+ time: 0,
64
+ ...overrides,
65
+ });
66
+ /** A 5-unit long ETH-USD position at index price 100, 10x leverage (margin 50). */
67
+ const longEthPosition = position({
68
+ market: 'ETH-USD',
69
+ quantity: '5.00000000',
70
+ indexPrice: '100.00000000',
71
+ marginRequirement: '50.00000000',
72
+ });
73
+ /** A wallet holding {@link longEthPosition} (equity 100, free collateral 50). */
74
+ const longEthWallet = {
75
+ ...defaultWallet,
76
+ quoteBalance: '-400.00000000',
77
+ equity: '100.00000000',
78
+ marginRatio: '0.25000000',
79
+ positions: [longEthPosition],
80
+ };
81
+ const runEstimate = (args) => orderbook.calculateBuySellPanelEstimate({
82
+ market: defaultMarket,
83
+ wallet: defaultWallet,
84
+ orderBook: { asks: [], bids: [] },
85
+ ...args,
86
+ });
87
+ describe('orderbook/buySellPanelEstimate', () => {
88
+ describe('calculateBuySellPanelEstimate', () => {
89
+ it('estimates a market buy that crosses multiple levels (base quantity)', () => {
90
+ const estimate = runEstimate({
91
+ orderBook: {
92
+ asks: [level('100', '10'), level('101', '10')],
93
+ bids: [],
94
+ },
95
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('15') },
96
+ });
97
+ // 10 @ 100 + 5 @ 101 = 1,505 quote
98
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('15'));
99
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('1505'));
100
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
101
+ expect(estimate.selfTradeEncountered).to.equal(false);
102
+ expect(estimate.freeCollateralExceeded).to.equal(false);
103
+ expect(estimate.maximumPositionSizeExceeded).to.equal(false);
104
+ });
105
+ it('computes cost as the change in available collateral (margin + fees)', () => {
106
+ const estimate = runEstimate({
107
+ orderBook: { asks: [level('100', '100')], bids: [] },
108
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
109
+ });
110
+ // Notional 500, IMF 0.1 => margin 50; taker fee 500 * 0.1% = 0.5
111
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('50.5'));
112
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('500'));
113
+ });
114
+ it('charges the taker gas fee per matched maker order', () => {
115
+ const estimate = runEstimate({
116
+ // A single level of 5 made up of 2 maker orders
117
+ orderBook: { asks: [level('100', '5', 2)], bids: [] },
118
+ takerTradeGasFee: (0, _pipmath_1.decimalToPip)('0.1'),
119
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
120
+ });
121
+ // Margin 50 + taker trade fee 0.5 + gas 0.1 * 2 orders = 50.7
122
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('50.7'));
123
+ });
124
+ it('caps the taker trade fee at 5% of the fill quote', () => {
125
+ const estimate = runEstimate({
126
+ wallet: { ...defaultWallet, takerFeeRate: '0.10000000' }, // 10%, above the cap
127
+ orderBook: { asks: [level('100', '100')], bids: [] },
128
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
129
+ });
130
+ // Notional 500: margin 50 + taker fee capped at 5% of 500 = 25 (not 50) => 75
131
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('75'));
132
+ });
133
+ it('gives the trade fee priority over the gas fee within the 5% cap', () => {
134
+ const estimate = runEstimate({
135
+ wallet: { ...defaultWallet, takerFeeRate: '0.05000000' }, // exactly 5%
136
+ takerTradeGasFee: (0, _pipmath_1.decimalToPip)('5'), // would apply, but no budget remains
137
+ orderBook: { asks: [level('100', '100')], bids: [] },
138
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
139
+ });
140
+ // Trade fee consumes the whole 5% (25 of 500), so the gas fee is coerced to 0 => 75
141
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('75'));
142
+ });
143
+ it('supports quote-denominated quantities', () => {
144
+ const estimate = runEstimate({
145
+ orderBook: { asks: [level('100', '100')], bids: [] },
146
+ order: { side: request_1.OrderSide.buy, quoteQuantity: (0, _pipmath_1.decimalToPip)('500') },
147
+ });
148
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
149
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('500'));
150
+ });
151
+ it('rests the unfilled remainder of a crossing limit order (gtc)', () => {
152
+ const estimate = runEstimate({
153
+ orderBook: { asks: [level('100', '4')], bids: [] },
154
+ order: {
155
+ side: request_1.OrderSide.buy,
156
+ baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
157
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
158
+ timeInForce: request_1.TimeInForce.gtc,
159
+ },
160
+ });
161
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('4'));
162
+ // 6 remaining rests on the books
163
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('6'));
164
+ });
165
+ it('cancels the remainder of an ioc limit order', () => {
166
+ const estimate = runEstimate({
167
+ orderBook: { asks: [level('100', '4')], bids: [] },
168
+ order: {
169
+ side: request_1.OrderSide.buy,
170
+ baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
171
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
172
+ timeInForce: request_1.TimeInForce.ioc,
173
+ },
174
+ });
175
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('4'));
176
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
177
+ // Some liquidity matched, so the order executes.
178
+ expect(estimate.immediateOrCancelWouldNotExecute).to.equal(false);
179
+ });
180
+ it('flags an ioc order that matches no liquidity', () => {
181
+ const estimate = runEstimate({
182
+ orderBook: { asks: [level('100', '10')], bids: [] },
183
+ order: {
184
+ side: request_1.OrderSide.buy,
185
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
186
+ limitPrice: (0, _pipmath_1.decimalToPip)('99'), // below the best ask: does not cross
187
+ timeInForce: request_1.TimeInForce.ioc,
188
+ },
189
+ });
190
+ expect(estimate.immediateOrCancelWouldNotExecute).to.equal(true);
191
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
192
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
193
+ });
194
+ it('flags a non-zero-slider ioc order that matches no liquidity', () => {
195
+ const estimate = runEstimate({
196
+ orderBook: { asks: [level('100', '10')], bids: [] },
197
+ order: {
198
+ side: request_1.OrderSide.buy,
199
+ // Non-zero slider, but the limit price does not cross and ioc can't
200
+ // rest, so it resolves to a zero base quantity.
201
+ availableCollateralRatio: (0, _pipmath_1.decimalToPip)('0.5'),
202
+ limitPrice: (0, _pipmath_1.decimalToPip)('99'),
203
+ timeInForce: request_1.TimeInForce.ioc,
204
+ },
205
+ });
206
+ expect(estimate.immediateOrCancelWouldNotExecute).to.equal(true);
207
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
208
+ });
209
+ it('sets no time-in-force flags for a zero-quantity order', () => {
210
+ for (const timeInForce of [
211
+ request_1.TimeInForce.gtc,
212
+ request_1.TimeInForce.gtx,
213
+ request_1.TimeInForce.ioc,
214
+ request_1.TimeInForce.fok,
215
+ ]) {
216
+ // Crosses the spread (gtx would otherwise flag) with a zero quantity.
217
+ const estimate = runEstimate({
218
+ orderBook: { asks: [level('100', '10')], bids: [] },
219
+ order: {
220
+ side: request_1.OrderSide.buy,
221
+ baseQuantity: (0, _pipmath_1.decimalToPip)('0'),
222
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
223
+ timeInForce,
224
+ },
225
+ });
226
+ expect(estimate.postOnlyWouldCross, timeInForce).to.equal(false);
227
+ expect(estimate.immediateOrCancelWouldNotExecute, timeInForce).to.equal(false);
228
+ expect(estimate.fillOrKillWouldNotExecute, timeInForce).to.equal(false);
229
+ }
230
+ // A 0% slider resolves to a zero quantity as well.
231
+ const sliderEstimate = runEstimate({
232
+ orderBook: { asks: [level('100', '10')], bids: [] },
233
+ order: {
234
+ side: request_1.OrderSide.buy,
235
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
236
+ timeInForce: request_1.TimeInForce.ioc,
237
+ availableCollateralRatio: (0, _pipmath_1.decimalToPip)('0'),
238
+ },
239
+ });
240
+ expect(sliderEstimate.immediateOrCancelWouldNotExecute).to.equal(false);
241
+ });
242
+ it('flags a post-only (gtx) order that would cross the spread', () => {
243
+ const estimate = runEstimate({
244
+ orderBook: { asks: [level('100', '10')], bids: [] },
245
+ order: {
246
+ side: request_1.OrderSide.buy,
247
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
248
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
249
+ timeInForce: request_1.TimeInForce.gtx,
250
+ },
251
+ });
252
+ expect(estimate.postOnlyWouldCross).to.equal(true);
253
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
254
+ });
255
+ it('rests a non-crossing post-only (gtx) order in full', () => {
256
+ const estimate = runEstimate({
257
+ orderBook: { asks: [level('100', '10')], bids: [] },
258
+ order: {
259
+ side: request_1.OrderSide.buy,
260
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
261
+ limitPrice: (0, _pipmath_1.decimalToPip)('90'),
262
+ timeInForce: request_1.TimeInForce.gtx,
263
+ },
264
+ });
265
+ expect(estimate.postOnlyWouldCross).to.equal(false);
266
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
267
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
268
+ });
269
+ it('flags a fill-or-kill order that cannot be fully filled', () => {
270
+ const estimate = runEstimate({
271
+ orderBook: { asks: [level('100', '4')], bids: [] },
272
+ order: {
273
+ side: request_1.OrderSide.buy,
274
+ baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
275
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
276
+ timeInForce: request_1.TimeInForce.fok,
277
+ },
278
+ });
279
+ expect(estimate.fillOrKillWouldNotExecute).to.equal(true);
280
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
281
+ });
282
+ it('fills a quote-denominated fill-or-kill order to the largest quote not exceeding the request', () => {
283
+ const estimate = runEstimate({
284
+ // Price 7 does not divide 100 evenly; ample liquidity (700 quote).
285
+ orderBook: { asks: [level('7', '100')], bids: [] },
286
+ order: {
287
+ side: request_1.OrderSide.buy,
288
+ quoteQuantity: (0, _pipmath_1.decimalToPip)('100'),
289
+ limitPrice: (0, _pipmath_1.decimalToPip)('10'),
290
+ timeInForce: request_1.TimeInForce.fok,
291
+ },
292
+ });
293
+ // 14.28571428 @ 7 = 99.99999996 (largest quote <= 100); not killed.
294
+ expect(estimate.fillOrKillWouldNotExecute).to.equal(false);
295
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('14.28571428'));
296
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('99.99999996'));
297
+ expect(estimate.tradeQuoteQuantity <= (0, _pipmath_1.decimalToPip)('100')).to.equal(true);
298
+ });
299
+ it('kills a quote-denominated fill-or-kill order without enough liquidity', () => {
300
+ const estimate = runEstimate({
301
+ // Only 7 of crossable quote (1 @ 7) vs a 100 quote request.
302
+ orderBook: { asks: [level('7', '1')], bids: [] },
303
+ order: {
304
+ side: request_1.OrderSide.buy,
305
+ quoteQuantity: (0, _pipmath_1.decimalToPip)('100'),
306
+ limitPrice: (0, _pipmath_1.decimalToPip)('10'),
307
+ timeInForce: request_1.TimeInForce.fok,
308
+ },
309
+ });
310
+ expect(estimate.fillOrKillWouldNotExecute).to.equal(true);
311
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
312
+ });
313
+ it('detects self-trades and frees the matched order’s held collateral', () => {
314
+ // The book’s ask at 100 (size 10) includes the wallet’s own 3-unit sell.
315
+ const estimate = runEstimate({
316
+ wallet: { ...defaultWallet, heldCollateral: '30.00000000' },
317
+ orderBook: { asks: [level('100', '10')], bids: [] },
318
+ walletsStandingOrders: [
319
+ {
320
+ market: 'ETH-USD',
321
+ side: request_1.OrderSide.sell,
322
+ price: '100.00000000',
323
+ originalQuantity: '3.00000000',
324
+ executedQuantity: '0.00000000',
325
+ status: 'open',
326
+ },
327
+ ],
328
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
329
+ });
330
+ expect(estimate.selfTradeEncountered).to.equal(true);
331
+ // Only the 2 units not belonging to the wallet are actually traded
332
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
333
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('200'));
334
+ // Freeing the 30 held by the canceled own order more than offsets the new
335
+ // position’s margin (20) and fee (0.2): collateral increases.
336
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('-9.8'));
337
+ });
338
+ it('flags an order that exceeds free collateral', () => {
339
+ const estimate = runEstimate({
340
+ wallet: {
341
+ ...defaultWallet,
342
+ equity: '60.00000000',
343
+ quoteBalance: '60.00000000',
344
+ },
345
+ orderBook: { asks: [level('100', '1000')], bids: [] },
346
+ // Notional 50,000 => margin 5,000, far above 60 of equity
347
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('500') },
348
+ });
349
+ expect(estimate.freeCollateralExceeded).to.equal(true);
350
+ });
351
+ it('flags a resting order that exceeds available collateral', () => {
352
+ const estimate = runEstimate({
353
+ wallet: {
354
+ ...defaultWallet,
355
+ equity: '100.00000000',
356
+ quoteBalance: '100.00000000',
357
+ },
358
+ orderBook: { asks: [level('100', '10')], bids: [] },
359
+ order: {
360
+ side: request_1.OrderSide.buy,
361
+ // 30 @ 50 => 1,500 quote, margin 150 > 100 available
362
+ baseQuantity: (0, _pipmath_1.decimalToPip)('30'),
363
+ limitPrice: (0, _pipmath_1.decimalToPip)('50'),
364
+ timeInForce: request_1.TimeInForce.gtc,
365
+ },
366
+ });
367
+ expect(estimate.availableCollateralExceeded).to.equal(true);
368
+ expect(estimate.freeCollateralExceeded).to.equal(false);
369
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('30'));
370
+ // 30 @ 50 => 1,500 notional, held 150; available was 100, so the cost is
371
+ // allowed to exceed available collateral (150, not clamped to 100).
372
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('150'));
373
+ });
374
+ it('flags available collateral when a partially-crossing limit order rests an unaffordable remainder', () => {
375
+ const estimate = runEstimate({
376
+ wallet: {
377
+ ...defaultWallet,
378
+ equity: '100.00000000',
379
+ quoteBalance: '100.00000000',
380
+ },
381
+ // Only 2 of liquidity at the limit price; the rest rests on the books.
382
+ orderBook: { asks: [level('100', '2')], bids: [] },
383
+ order: {
384
+ side: request_1.OrderSide.buy,
385
+ baseQuantity: (0, _pipmath_1.decimalToPip)('100'),
386
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
387
+ timeInForce: request_1.TimeInForce.gtc,
388
+ },
389
+ });
390
+ // 2 fills (affordable), 98 rests => held 98 * 100 * 0.1 = 980 > ~80 free
391
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
392
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('98'));
393
+ expect(estimate.freeCollateralExceeded).to.equal(false);
394
+ expect(estimate.availableCollateralExceeded).to.equal(true);
395
+ });
396
+ it('flags an order that exceeds the maximum position size', () => {
397
+ const estimate = runEstimate({
398
+ market: { ...defaultMarket, maximumPositionSize: '4.00000000' },
399
+ orderBook: { asks: [level('100', '100')], bids: [] },
400
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
401
+ });
402
+ expect(estimate.maximumPositionSizeExceeded).to.equal(true);
403
+ });
404
+ it('flags a market order that exceeds the execution price limit', () => {
405
+ const estimate = runEstimate({
406
+ market: {
407
+ ...defaultMarket,
408
+ marketOrderExecutionPriceLimit: '0.00500000', // 0.5%
409
+ },
410
+ orderBook: {
411
+ asks: [level('100', '10'), level('101', '10')],
412
+ bids: [level('99', '10')],
413
+ },
414
+ // baseline = (100 + 99) / 2 = 99.5; max execution price = 100.0
415
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('15') },
416
+ });
417
+ expect(estimate.executionPriceLimitExceeded).to.equal(true);
418
+ // Only the 10 available at 100 (within the limit) are filled
419
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('10'));
420
+ });
421
+ it('does not flag the execution price limit when the order fills entirely within it', () => {
422
+ const estimate = runEstimate({
423
+ market: {
424
+ ...defaultMarket,
425
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%
426
+ },
427
+ orderBook: {
428
+ // best ask 100.1, plus worse levels beyond the 1% limit (max ~101.01)
429
+ asks: [level('100.1', '10'), level('102', '10'), level('103', '10')],
430
+ bids: [level('99.9', '10'), level('98', '10')],
431
+ },
432
+ // baseline 100; fully fills 5 @ 100.1 and never reaches the 102 level
433
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
434
+ });
435
+ expect(estimate.executionPriceLimitExceeded).to.equal(false);
436
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
437
+ });
438
+ it('does not flag the execution price limit for a quote order that fills within it', () => {
439
+ const estimate = runEstimate({
440
+ market: {
441
+ ...defaultMarket,
442
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%
443
+ },
444
+ orderBook: {
445
+ asks: [level('100.1', '10'), level('102', '10'), level('103', '10')],
446
+ bids: [level('99.9', '10')],
447
+ },
448
+ // 50 quote fills within the best ask (well under its 1,001 quote of
449
+ // depth); the worse 102 level is never reached.
450
+ order: { side: request_1.OrderSide.buy, quoteQuantity: (0, _pipmath_1.decimalToPip)('50') },
451
+ });
452
+ expect(estimate.executionPriceLimitExceeded).to.equal(false);
453
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('0')).to.equal(true);
454
+ });
455
+ it('matches a slider order through the book past the execution price limit and flags it', () => {
456
+ const estimate = runEstimate({
457
+ market: {
458
+ ...defaultMarket,
459
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%, max ~101.01
460
+ },
461
+ wallet: {
462
+ ...defaultWallet,
463
+ equity: '100.00000000',
464
+ quoteBalance: '100.00000000',
465
+ },
466
+ orderBook: {
467
+ // Only 2 of liquidity within the limit; the rest is beyond it.
468
+ asks: [level('100.1', '2'), level('102', '10'), level('103', '10')],
469
+ bids: [level('99.9', '10')],
470
+ },
471
+ order: { side: request_1.OrderSide.buy, availableCollateralRatio: _pipmath_1.oneInPips }, // 100%
472
+ });
473
+ // The slider sizes the order to consume the available collateral by
474
+ // matching beyond the limit, and flags the breach rather than capping.
475
+ expect(estimate.executionPriceLimitExceeded).to.equal(true);
476
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('2')).to.equal(true);
477
+ const target = (0, _pipmath_1.decimalToPip)('100');
478
+ expect(estimate.cost <= target).to.equal(true);
479
+ expect(target - estimate.cost <= (0, _pipmath_1.decimalToPip)('1')).to.equal(true);
480
+ });
481
+ it('flags a limit order whose price is outside the allowed range', () => {
482
+ const estimate = runEstimate({
483
+ market: {
484
+ ...defaultMarket,
485
+ limitOrderExecutionPriceLimit: '0.05000000', // 5%
486
+ },
487
+ orderBook: {
488
+ asks: [level('100', '10')],
489
+ bids: [level('100', '10')],
490
+ },
491
+ order: {
492
+ side: request_1.OrderSide.buy,
493
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
494
+ limitPrice: (0, _pipmath_1.decimalToPip)('200'), // far above baseline 100
495
+ },
496
+ });
497
+ expect(estimate.executionPriceLimitExceeded).to.equal(true);
498
+ });
499
+ it('computes the account-wide liquidation price for a new long', () => {
500
+ const estimate = runEstimate({
501
+ wallet: {
502
+ ...defaultWallet,
503
+ equity: '60.00000000',
504
+ quoteBalance: '60.00000000',
505
+ },
506
+ orderBook: { asks: [level('100', '100')], bids: [] },
507
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
508
+ });
509
+ // quoteBalanceAfter = 60 - 500 - 0.5 = -440.5; long 5 @ MMF 0.05:
510
+ // -440.5 + 5P = 0.25P => P = 440.5 / 4.75 = 92.73684210
511
+ testHelpers.assertBigintsEqual(estimate.liquidationPrice ?? BigInt(-1), (0, _pipmath_1.decimalToPip)('92.73684210'));
512
+ });
513
+ it('returns a null liquidation price when the position is fully closed', () => {
514
+ const estimate = runEstimate({
515
+ wallet: {
516
+ ...defaultWallet,
517
+ quoteBalance: '-400.00000000',
518
+ equity: '100.00000000',
519
+ marginRatio: '0.25000000',
520
+ positions: [longEthPosition],
521
+ },
522
+ orderBook: { bids: [level('100', '10')], asks: [] },
523
+ order: {
524
+ side: request_1.OrderSide.sell,
525
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
526
+ reduceOnly: true,
527
+ },
528
+ });
529
+ expect(estimate.liquidationPrice).to.equal(null);
530
+ // Closing the long frees its 50 of margin (less the 0.5 fee)
531
+ testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('-49.5'));
532
+ });
533
+ it('clamps a reduce-only order to the position size', () => {
534
+ const estimate = runEstimate({
535
+ wallet: {
536
+ ...defaultWallet,
537
+ quoteBalance: '-400.00000000',
538
+ equity: '100.00000000',
539
+ marginRatio: '0.25000000',
540
+ positions: [longEthPosition],
541
+ },
542
+ orderBook: { bids: [level('100', '100')], asks: [] },
543
+ order: {
544
+ side: request_1.OrderSide.sell,
545
+ baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
546
+ reduceOnly: true,
547
+ },
548
+ });
549
+ // Only 5 (the position size) can be reduced
550
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
551
+ });
552
+ it('flags a reduce-only order that would not reduce the position', () => {
553
+ const estimate = runEstimate({
554
+ wallet: {
555
+ ...defaultWallet,
556
+ quoteBalance: '-400.00000000',
557
+ equity: '100.00000000',
558
+ marginRatio: '0.25000000',
559
+ positions: [longEthPosition],
560
+ },
561
+ orderBook: { asks: [level('100', '100')], bids: [] },
562
+ // A buy does not reduce an existing long
563
+ order: {
564
+ side: request_1.OrderSide.buy,
565
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
566
+ reduceOnly: true,
567
+ },
568
+ });
569
+ expect(estimate.reduceOnlyWouldNotReducePosition).to.equal(true);
570
+ });
571
+ it('flags a reduce-only order placed with no open position', () => {
572
+ const estimate = runEstimate({
573
+ orderBook: { bids: [level('100', '10')], asks: [] },
574
+ order: {
575
+ side: request_1.OrderSide.sell,
576
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
577
+ reduceOnly: true,
578
+ },
579
+ });
580
+ expect(estimate.reduceOnlyNoOpenPosition).to.equal(true);
581
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
582
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
583
+ });
584
+ it('rests a reduce-only limit order up to the open position size', () => {
585
+ const estimate = runEstimate({
586
+ wallet: longEthWallet,
587
+ orderBook: { asks: [], bids: [] },
588
+ order: {
589
+ side: request_1.OrderSide.sell,
590
+ baseQuantity: (0, _pipmath_1.decimalToPip)('3'),
591
+ limitPrice: (0, _pipmath_1.decimalToPip)('110'), // above the market: does not cross
592
+ reduceOnly: true,
593
+ timeInForce: request_1.TimeInForce.gtc,
594
+ },
595
+ });
596
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
597
+ // The reducing portion (<= position size of 5) rests on the books
598
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
599
+ expect(estimate.reduceOnlyOpenPositionSizeExceeded).to.equal(false);
600
+ });
601
+ it('flags a reduce-only limit order whose resting quantity exceeds the open position size', () => {
602
+ const estimate = runEstimate({
603
+ wallet: longEthWallet,
604
+ orderBook: { asks: [], bids: [] },
605
+ order: {
606
+ side: request_1.OrderSide.sell,
607
+ baseQuantity: (0, _pipmath_1.decimalToPip)('8'), // exceeds the position of 5
608
+ limitPrice: (0, _pipmath_1.decimalToPip)('110'),
609
+ reduceOnly: true,
610
+ timeInForce: request_1.TimeInForce.gtc,
611
+ },
612
+ });
613
+ // The order is still reported as resting, but flagged as not fully reducing
614
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('8'));
615
+ expect(estimate.reduceOnlyOpenPositionSizeExceeded).to.equal(true);
616
+ });
617
+ it('fills a reduce-only market order up to the full position size regardless of same-side standing orders', () => {
618
+ const estimate = runEstimate({
619
+ wallet: longEthWallet,
620
+ orderBook: { bids: [level('100', '100')], asks: [] },
621
+ walletsStandingOrders: [
622
+ {
623
+ market: 'ETH-USD',
624
+ side: request_1.OrderSide.sell, // same side as the reduce-only sell
625
+ price: '100.00000000',
626
+ originalQuantity: '3.00000000',
627
+ executedQuantity: '0.00000000',
628
+ status: 'open',
629
+ },
630
+ ],
631
+ order: {
632
+ side: request_1.OrderSide.sell,
633
+ baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
634
+ reduceOnly: true,
635
+ },
636
+ });
637
+ // The standing sell does not cap the market reduce-only fill: the full
638
+ // position of 5 is reduced
639
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
640
+ });
641
+ it('does not flag maximum position size for a crossing limit order on account of standing orders', () => {
642
+ const estimate = runEstimate({
643
+ market: { ...defaultMarket, maximumPositionSize: '10.00000000' },
644
+ wallet: {
645
+ ...defaultWallet,
646
+ equity: '100000.00000000',
647
+ quoteBalance: '100000.00000000',
648
+ },
649
+ orderBook: { asks: [level('100', '3')], bids: [] },
650
+ walletsStandingOrders: [
651
+ {
652
+ market: 'ETH-USD',
653
+ side: request_1.OrderSide.buy,
654
+ price: '90.00000000',
655
+ originalQuantity: '8.00000000',
656
+ executedQuantity: '0.00000000',
657
+ status: 'open',
658
+ },
659
+ ],
660
+ order: {
661
+ side: request_1.OrderSide.buy,
662
+ baseQuantity: (0, _pipmath_1.decimalToPip)('6'), // <= MPS of 10
663
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
664
+ timeInForce: request_1.TimeInForce.gtc,
665
+ },
666
+ });
667
+ // Order qty 6 <= MPS 10; the 8 standing buy is excluded from the check
668
+ // for a crossing order (it would get canceled after the incoming order
669
+ // is executed)
670
+ expect(estimate.maximumPositionSizeExceeded).to.equal(false);
671
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
672
+ testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
673
+ });
674
+ it('flags maximum position size for a market order whose requested quantity exceeds it despite thin liquidity', () => {
675
+ const estimate = runEstimate({
676
+ market: { ...defaultMarket, maximumPositionSize: '4.00000000' },
677
+ orderBook: { asks: [level('100', '2')], bids: [] },
678
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
679
+ });
680
+ expect(estimate.maximumPositionSizeExceeded).to.equal(true);
681
+ // Only the 2 of available liquidity is fillable
682
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
683
+ });
684
+ it('excludes untriggered stop orders (status active) from self-trade detection', () => {
685
+ const estimate = runEstimate({
686
+ orderBook: { asks: [level('100', '10')], bids: [] },
687
+ walletsStandingOrders: [
688
+ {
689
+ market: 'ETH-USD',
690
+ side: request_1.OrderSide.sell,
691
+ price: '100.00000000',
692
+ originalQuantity: '3.00000000',
693
+ executedQuantity: '0.00000000',
694
+ status: 'active', // untriggered stop order
695
+ },
696
+ ],
697
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
698
+ });
699
+ // The untriggered stop order is ignored: no self-trade, full 5 is traded
700
+ expect(estimate.selfTradeEncountered).to.equal(false);
701
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
702
+ });
703
+ it('resolves the available-collateral slider so that a ratio of 1 consumes all of it', () => {
704
+ const ratio = _pipmath_1.oneInPips; // 100%
705
+ const estimate = runEstimate({
706
+ orderBook: { asks: [level('100', '1000')], bids: [] },
707
+ order: {
708
+ side: request_1.OrderSide.buy,
709
+ availableCollateralRatio: ratio,
710
+ },
711
+ });
712
+ // available collateral = 1,000; cost should approach but not exceed it
713
+ const target = (0, _pipmath_1.multiplyPips)((0, _pipmath_1.decimalToPip)('1000'), ratio);
714
+ expect(estimate.cost <= target).to.equal(true);
715
+ expect(target - estimate.cost <= (0, _pipmath_1.decimalToPip)('1')).to.equal(true);
716
+ // The quantity is bound by collateral (margin 10/unit + fee), ~99 base —
717
+ // NOT the full 1,000 of available book liquidity, and it stays feasible.
718
+ expect(estimate.tradeBaseQuantity < (0, _pipmath_1.decimalToPip)('150')).to.equal(true);
719
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('90')).to.equal(true);
720
+ expect(estimate.freeCollateralExceeded).to.equal(false);
721
+ });
722
+ it('does not gulp all crossable liquidity for a 100% slider on a limit order', () => {
723
+ const estimate = runEstimate({
724
+ orderBook: { asks: [level('100', '1000')], bids: [] },
725
+ order: {
726
+ side: request_1.OrderSide.buy,
727
+ limitPrice: (0, _pipmath_1.decimalToPip)('100'),
728
+ timeInForce: request_1.TimeInForce.gtc,
729
+ availableCollateralRatio: _pipmath_1.oneInPips,
730
+ },
731
+ });
732
+ // Collateral-bound (~99), not the 1,000 of liquidity up to the limit price.
733
+ expect(estimate.tradeBaseQuantity < (0, _pipmath_1.decimalToPip)('150')).to.equal(true);
734
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('90')).to.equal(true);
735
+ expect(estimate.freeCollateralExceeded).to.equal(false);
736
+ });
737
+ it('applies an initial margin fraction override (higher margin, higher cost)', () => {
738
+ const baseArgs = {
739
+ orderBook: { asks: [level('100', '100')], bids: [] },
740
+ order: {
741
+ side: request_1.OrderSide.buy,
742
+ baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
743
+ },
744
+ };
745
+ const withoutOverride = runEstimate(baseArgs);
746
+ const withOverride = runEstimate({
747
+ ...baseArgs,
748
+ walletInitialMarginFractionOverrides: [
749
+ {
750
+ wallet: '0xwallet',
751
+ market: 'ETH-USD',
752
+ initialMarginFractionOverride: '0.20000000', // 20% vs 10%
753
+ },
754
+ ],
755
+ });
756
+ // Margin doubles from 50 to 100, raising the cost by ~50
757
+ testHelpers.assertBigintsEqual(withOverride.cost - withoutOverride.cost, (0, _pipmath_1.decimalToPip)('50'));
758
+ });
759
+ it('requires exactly one quantity input', () => {
760
+ expect(() => orderbook.calculateBuySellPanelEstimate({
761
+ market: defaultMarket,
762
+ wallet: defaultWallet,
763
+ orderBook: { asks: [], bids: [] },
764
+ // @ts-expect-error intentionally providing two quantity inputs
765
+ order: {
766
+ side: request_1.OrderSide.buy,
767
+ baseQuantity: (0, _pipmath_1.decimalToPip)('1'),
768
+ quoteQuantity: (0, _pipmath_1.decimalToPip)('1'),
769
+ },
770
+ })).to.throw();
771
+ });
772
+ });
773
+ });