@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,774 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculateBuySellPanelEstimate = void 0;
4
+ const _pipmath_1 = require("#pipmath");
5
+ const quantities_1 = require("#orderbook/quantities");
6
+ const request_1 = require("#types/enums/request");
7
+ /**
8
+ * The taker (trade fee + gas fee) on a single fill is limited to 5% of the
9
+ * fill's quote quantity.
10
+ *
11
+ * @see {@link https://github.com/idexio/idex-contracts-ikon} (contracts)
12
+ */
13
+ const maximumTradeFeeFraction = (0, _pipmath_1.decimalToPip)('0.05');
14
+ function makeEmptyEstimate() {
15
+ return {
16
+ tradeBaseQuantity: BigInt(0),
17
+ tradeQuoteQuantity: BigInt(0),
18
+ makerBaseQuantity: BigInt(0),
19
+ cost: BigInt(0),
20
+ liquidationPrice: null,
21
+ selfTradeEncountered: false,
22
+ freeCollateralExceeded: false,
23
+ availableCollateralExceeded: false,
24
+ executionPriceLimitExceeded: false,
25
+ maximumPositionSizeExceeded: false,
26
+ postOnlyWouldCross: false,
27
+ immediateOrCancelWouldNotExecute: false,
28
+ fillOrKillWouldNotExecute: false,
29
+ reduceOnlyWouldNotReducePosition: false,
30
+ reduceOnlyNoOpenPosition: false,
31
+ reduceOnlyOpenPositionSizeExceeded: false,
32
+ };
33
+ }
34
+ /**
35
+ * @private
36
+ */
37
+ function wasPositionReduced(before, after) {
38
+ return (after === BigInt(0) ||
39
+ (before > BigInt(0) && after > BigInt(0) && after < before) ||
40
+ (before < BigInt(0) && after < BigInt(0) && after > before));
41
+ }
42
+ /**
43
+ * @private
44
+ * Margin requirement (held collateral) for a single open order quantity.
45
+ */
46
+ function calculateMarginRequirementForOpenOrderQuantity(context, openBaseQuantity, limitPrice) {
47
+ if (openBaseQuantity === BigInt(0)) {
48
+ return BigInt(0);
49
+ }
50
+ const orderOpenQuoteQuantity = (0, _pipmath_1.multiplyPips)(openBaseQuantity, limitPrice);
51
+ return (0, _pipmath_1.multiplyPips)(orderOpenQuoteQuantity, (0, quantities_1.calculateInitialMarginFractionWithOverride)({
52
+ baseQuantity: openBaseQuantity,
53
+ initialMarginFractionOverride: context.initialMarginFractionOverride,
54
+ leverageParameters: context.leverageParameters,
55
+ }));
56
+ }
57
+ /**
58
+ * @private
59
+ * Held collateral required for a set of standing orders in a single market,
60
+ * given a signed position quantity. Orders that reduce the position do not
61
+ * require margin, up to a combined quantity that equals the position size.
62
+ */
63
+ function calculateHeldCollateralForMarket(context, orders, positionQuantity) {
64
+ let marginRequirement = BigInt(0);
65
+ // The reducing side (sells for a long position, buys for a short position),
66
+ // processed best price first, offsets against the position.
67
+ const positionIsLong = positionQuantity > BigInt(0);
68
+ const reducingOrders = orders
69
+ .filter((order) => order.isBuy === !positionIsLong && order.openQuantity > BigInt(0))
70
+ .sort((a, b) =>
71
+ // Sells: lowest price first; buys: highest price first
72
+ a.isBuy ? Number(b.price - a.price) : Number(a.price - b.price));
73
+ const nonReducingOrders = orders.filter((order) => order.isBuy === positionIsLong && order.openQuantity > BigInt(0));
74
+ let remainingPositionQuantity = (0, _pipmath_1.absBigInt)(positionQuantity);
75
+ for (const order of reducingOrders) {
76
+ let openBaseQuantity = order.openQuantity;
77
+ if (remainingPositionQuantity > BigInt(0)) {
78
+ if (openBaseQuantity > remainingPositionQuantity) {
79
+ openBaseQuantity -= remainingPositionQuantity;
80
+ remainingPositionQuantity = BigInt(0);
81
+ }
82
+ else {
83
+ remainingPositionQuantity -= openBaseQuantity;
84
+ openBaseQuantity = BigInt(0);
85
+ }
86
+ }
87
+ marginRequirement += calculateMarginRequirementForOpenOrderQuantity(context, openBaseQuantity, order.price);
88
+ }
89
+ for (const order of nonReducingOrders) {
90
+ marginRequirement += calculateMarginRequirementForOpenOrderQuantity(context, order.openQuantity, order.price);
91
+ }
92
+ return marginRequirement;
93
+ }
94
+ /**
95
+ * @private
96
+ * Solves for the index price at which the account's value equals its
97
+ * maintenance margin requirement (the liquidation price), holding the index
98
+ * prices of all other markets constant (cross-margin, account-wide).
99
+ *
100
+ * Returns `null` if the resulting position is zero, and `0n` if no positive
101
+ * liquidation price exists (the account is not liquidatable by an adverse
102
+ * move).
103
+ */
104
+ function calculateLiquidationPrice(args) {
105
+ const { newPositionQuantity, maintenanceMarginFraction, quoteBalanceAfter, otherPositionsNotionalAtIndex, otherPositionsMaintenanceMarginRequirement, } = args;
106
+ if (newPositionQuantity === BigInt(0)) {
107
+ return null;
108
+ }
109
+ // accountValue(P) = MMR(P), solved for P (in pips):
110
+ // P = (otherMMR - otherNotional - quoteBalance) * oneInPips^2
111
+ // / (newQty * oneInPips - abs(newQty) * mmf)
112
+ const numerator = (otherPositionsMaintenanceMarginRequirement -
113
+ otherPositionsNotionalAtIndex -
114
+ quoteBalanceAfter) *
115
+ _pipmath_1.oneInPips *
116
+ _pipmath_1.oneInPips;
117
+ const denominator = newPositionQuantity * _pipmath_1.oneInPips -
118
+ (0, _pipmath_1.absBigInt)(newPositionQuantity) * maintenanceMarginFraction;
119
+ if (denominator === BigInt(0)) {
120
+ return BigInt(0);
121
+ }
122
+ const liquidationPrice = numerator / denominator;
123
+ return liquidationPrice > BigInt(0) ? liquidationPrice : BigInt(0);
124
+ }
125
+ /**
126
+ * @private
127
+ * Walks the maker side of the book, accumulating the crossing (trade) portion
128
+ * of the taker order, distinguishing the wallet's own resting liquidity
129
+ * (self-trades) from other liquidity, and applying execution price limits and
130
+ * taker fees.
131
+ */
132
+ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity,
133
+ // When `false`, the order keeps matching through the book past the execution
134
+ // price limit (the breach is still flagged on the result) rather than stopping
135
+ // at it. Used when sizing the slider so it can consume the target collateral.
136
+ enforceExecutionPriceLimit) {
137
+ const isQuantityInQuote = 'quoteQuantity' in quantity;
138
+ // Working copy of own maker-side resting liquidity, indexed by price.
139
+ const standingOrdersCopy = context.marketStandingOrders.map((order) => ({
140
+ ...order,
141
+ }));
142
+ const ownMakerOrdersByPrice = new Map();
143
+ for (const order of standingOrdersCopy) {
144
+ if (order.isBuy !== context.isBuy && order.openQuantity > BigInt(0)) {
145
+ const list = ownMakerOrdersByPrice.get(order.price) ?? [];
146
+ list.push(order);
147
+ ownMakerOrdersByPrice.set(order.price, list);
148
+ }
149
+ }
150
+ const result = {
151
+ tradeBaseQuantity: BigInt(0),
152
+ tradeQuoteQuantity: BigInt(0),
153
+ makerOrdersMatched: BigInt(0),
154
+ takerTradeFee: BigInt(0),
155
+ takerGasFee: BigInt(0),
156
+ selfTradeBaseQuantity: BigInt(0),
157
+ selfTradeEncountered: false,
158
+ executionPriceLimitExceeded: false,
159
+ standingOrdersAfterSelfTrade: standingOrdersCopy,
160
+ remainingBaseQuantity: BigInt(0),
161
+ };
162
+ let remainingBase = isQuantityInQuote ? null : quantity.baseQuantity;
163
+ let remainingQuote = isQuantityInQuote ? quantity.quoteQuantity : null;
164
+ // Tracks reduce-only fill capacity (real, position-reducing fills only)
165
+ let remainingReduceOnly = reduceOnlyMaximumBaseQuantity;
166
+ for (const level of context.makerLevels) {
167
+ // Stop once the taker order's quantity is fully consumed.
168
+ const exhausted = remainingBase !== null ?
169
+ remainingBase <= BigInt(0)
170
+ : remainingQuote <= BigInt(0);
171
+ if (exhausted) {
172
+ break;
173
+ }
174
+ // doOrdersMatch
175
+ if (!context.isMarketOrder) {
176
+ const crosses = context.isBuy ?
177
+ context.limitPrice >= level.price
178
+ : context.limitPrice <= level.price;
179
+ if (!crosses) {
180
+ break;
181
+ }
182
+ }
183
+ // Determine how much of this level the taker consumes.
184
+ let consumedBase;
185
+ if (remainingBase !== null) {
186
+ consumedBase = (0, _pipmath_1.minBigInt)(remainingBase, level.size);
187
+ }
188
+ else {
189
+ const levelQuoteIfFull = (0, _pipmath_1.multiplyPips)(level.size, level.price, context.isMarketOrder && context.isBuy);
190
+ if (levelQuoteIfFull <= remainingQuote) {
191
+ consumedBase = level.size;
192
+ }
193
+ else {
194
+ // Partial: reduce base proportionally to the quote budget
195
+ consumedBase = (level.size * remainingQuote) / levelQuoteIfFull;
196
+ }
197
+ }
198
+ // Nothing more can be filled at this (best remaining) price — e.g. a
199
+ // sub-pip quote remainder left by flooring. The order is complete, so worse
200
+ // levels it never reaches must not trip the execution price limit.
201
+ if (consumedBase <= BigInt(0)) {
202
+ break;
203
+ }
204
+ // A trade occurs at this level's price, so apply the execution price limit.
205
+ // When not enforcing (slider sizing), matching continues but the breach is
206
+ // still recorded on the result.
207
+ if ((context.minimumExecutionPrice !== null &&
208
+ level.price < context.minimumExecutionPrice) ||
209
+ (context.maximumExecutionPrice !== null &&
210
+ level.price > context.maximumExecutionPrice)) {
211
+ result.executionPriceLimitExceeded = true;
212
+ if (enforceExecutionPriceLimit) {
213
+ break;
214
+ }
215
+ }
216
+ // Split into self-trade (own liquidity) and real fill (other liquidity).
217
+ const ownOrders = ownMakerOrdersByPrice.get(level.price) ?? [];
218
+ let ownSizeAtLevel = BigInt(0);
219
+ for (const order of ownOrders) {
220
+ ownSizeAtLevel += order.openQuantity;
221
+ }
222
+ const selfPart = (0, _pipmath_1.minBigInt)(consumedBase, ownSizeAtLevel);
223
+ let realPart = consumedBase - selfPart;
224
+ // Reduce-only orders may only reduce the position; cap the real fill.
225
+ if (remainingReduceOnly !== null && realPart > remainingReduceOnly) {
226
+ realPart = (0, _pipmath_1.maxBigInt)(remainingReduceOnly, BigInt(0));
227
+ // The self-trade portion is unaffected by reduce-only fill capacity, but
228
+ // the order stops once its reduce-only capacity is consumed.
229
+ consumedBase = selfPart + realPart;
230
+ }
231
+ if (selfPart > BigInt(0)) {
232
+ result.selfTradeEncountered = true;
233
+ result.selfTradeBaseQuantity += selfPart;
234
+ // Reduce the wallet's own resting orders at this price.
235
+ let toReduce = selfPart;
236
+ for (const order of ownOrders) {
237
+ if (toReduce <= BigInt(0)) {
238
+ break;
239
+ }
240
+ const reduction = (0, _pipmath_1.minBigInt)(order.openQuantity, toReduce);
241
+ order.openQuantity -= reduction;
242
+ toReduce -= reduction;
243
+ }
244
+ }
245
+ if (realPart > BigInt(0)) {
246
+ const realQuote = (0, _pipmath_1.multiplyPips)(realPart, level.price, context.isMarketOrder && context.isBuy);
247
+ if (realQuote === BigInt(0)) {
248
+ // Sub-pip quote quantity is invalid; the engine rejects such trades.
249
+ break;
250
+ }
251
+ result.tradeBaseQuantity += realPart;
252
+ result.tradeQuoteQuantity += realQuote;
253
+ // Taker fees for this fill. The sum of the trade and gas fee is capped at
254
+ // 5% of the fill's quote quantity, with priority given to the trade fee:
255
+ // the trade fee is capped at 5%, and the gas fee may only consume whatever
256
+ // of the 5% budget the trade fee leaves (zero once the trade fee hits 5%).
257
+ const maxFee = (0, _pipmath_1.multiplyPips)(realQuote, maximumTradeFeeFraction);
258
+ const tradeFee = (0, _pipmath_1.minBigInt)((0, _pipmath_1.multiplyPips)(realQuote, context.takerFeeRate), maxFee);
259
+ const realSizeAtLevel = (0, _pipmath_1.maxBigInt)(level.size - ownSizeAtLevel, BigInt(0));
260
+ const ordersAtLevel = realSizeAtLevel <= BigInt(0) ?
261
+ BigInt(0)
262
+ : (0, _pipmath_1.maxBigInt)(BigInt(1), (BigInt(level.numOrders) * realPart + realSizeAtLevel - BigInt(1)) /
263
+ realSizeAtLevel);
264
+ const gasFee = (0, _pipmath_1.minBigInt)(context.gasFeePerOrder * ordersAtLevel, (0, _pipmath_1.maxBigInt)(maxFee - tradeFee, BigInt(0)));
265
+ result.makerOrdersMatched += ordersAtLevel;
266
+ result.takerTradeFee += tradeFee;
267
+ result.takerGasFee += gasFee;
268
+ if (remainingReduceOnly !== null) {
269
+ remainingReduceOnly -= realPart;
270
+ }
271
+ }
272
+ // Consume the taker order's remaining budget (self-trades consume it too).
273
+ if (remainingBase !== null) {
274
+ remainingBase -= consumedBase;
275
+ }
276
+ else {
277
+ remainingQuote -= (0, _pipmath_1.multiplyPips)(consumedBase, level.price, context.isMarketOrder && context.isBuy);
278
+ }
279
+ if (remainingReduceOnly !== null && remainingReduceOnly <= BigInt(0)) {
280
+ break;
281
+ }
282
+ }
283
+ result.remainingBaseQuantity =
284
+ remainingBase !== null ? (0, _pipmath_1.maxBigInt)(remainingBase, BigInt(0)) : BigInt(0);
285
+ return result;
286
+ }
287
+ /**
288
+ * @private
289
+ * Produces a complete estimate for a concrete base or quote quantity.
290
+ */
291
+ function runEstimate(context, quantity,
292
+ // Whether the *input* quantity is zero. A zero-quantity order does nothing, so
293
+ // none of the time-in-force feasibility flags apply to it. This is based on
294
+ // the input (e.g. the slider ratio) rather than the resolved base quantity: a
295
+ // non-zero slider on an order that cannot execute or rest resolves to a base
296
+ // quantity of zero, yet its time-in-force flags should still be evaluated.
297
+ isZeroQuantity = ('baseQuantity' in quantity ?
298
+ quantity.baseQuantity
299
+ : quantity.quoteQuantity) <= BigInt(0),
300
+ // When `false`, matching continues past the execution price limit (still
301
+ // flagged) instead of stopping at it. Used for slider sizing so the order can
302
+ // be sized to consume the target collateral.
303
+ enforceExecutionPriceLimit = true) {
304
+ const estimate = makeEmptyEstimate();
305
+ // Reduce-only validity and fill capacity.
306
+ let reduceOnlyMaximumBaseQuantity = null;
307
+ if (context.reduceOnly) {
308
+ const positionQuantity = context.currentPositionQuantity;
309
+ if (positionQuantity === BigInt(0)) {
310
+ estimate.reduceOnlyNoOpenPosition = true;
311
+ return estimate;
312
+ }
313
+ const reduces = (context.isBuy && positionQuantity < BigInt(0)) ||
314
+ (!context.isBuy && positionQuantity > BigInt(0));
315
+ if (!reduces) {
316
+ estimate.reduceOnlyWouldNotReducePosition = true;
317
+ return estimate;
318
+ }
319
+ reduceOnlyMaximumBaseQuantity = (0, _pipmath_1.absBigInt)(positionQuantity);
320
+ }
321
+ // Post-only (gtx) orders may not cross the spread.
322
+ const bestMakerPrice = context.makerLevels.length > 0 ? context.makerLevels[0].price : null;
323
+ const crossesSpread = bestMakerPrice !== null &&
324
+ (context.isMarketOrder ||
325
+ (context.isBuy ?
326
+ context.limitPrice >= bestMakerPrice
327
+ : context.limitPrice <= bestMakerPrice));
328
+ if (context.timeInForce === request_1.TimeInForce.gtx &&
329
+ crossesSpread &&
330
+ !isZeroQuantity) {
331
+ estimate.postOnlyWouldCross = true;
332
+ return estimate;
333
+ }
334
+ // Limit orders are rejected if their price is outside the allowed range.
335
+ if (context.limitOrderPriceExceedsLimit) {
336
+ estimate.executionPriceLimitExceeded = true;
337
+ return estimate;
338
+ }
339
+ const fill = matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity, enforceExecutionPriceLimit);
340
+ estimate.selfTradeEncountered = fill.selfTradeEncountered;
341
+ estimate.executionPriceLimitExceeded = fill.executionPriceLimitExceeded;
342
+ // Immediate-or-cancel: the unfilled remainder is canceled rather than rested,
343
+ // so an order that matched no liquidity would not execute at all.
344
+ estimate.immediateOrCancelWouldNotExecute =
345
+ context.timeInForce === request_1.TimeInForce.ioc &&
346
+ !isZeroQuantity &&
347
+ fill.tradeBaseQuantity === BigInt(0);
348
+ // Fill-or-kill: the order must be fully filled by crossing liquidity.
349
+ if (context.timeInForce === request_1.TimeInForce.fok && !isZeroQuantity) {
350
+ let fullyFillable;
351
+ if ('baseQuantity' in quantity) {
352
+ // Base quantities are matched exactly (no rounding), so the bounded fill
353
+ // determines fillability directly.
354
+ fullyFillable =
355
+ fill.tradeBaseQuantity + fill.selfTradeBaseQuantity >=
356
+ quantity.baseQuantity;
357
+ }
358
+ else {
359
+ // Quote fills are floored to whole base pips, so the achievable quote is
360
+ // the largest value not exceeding the requested quote and rarely equals it
361
+ // exactly. Decide fillability from the *total* crossable quote liquidity
362
+ // instead: re-match with an unbounded budget (every crossable level fills
363
+ // in full, so no flooring occurs).
364
+ const unboundedQuote = context.makerLevels.reduce((sum, level) => sum + (0, _pipmath_1.multiplyPips)(level.size, level.price), BigInt(1));
365
+ const maxFill = matchTakerOrder(context, { quoteQuantity: unboundedQuote }, reduceOnlyMaximumBaseQuantity, enforceExecutionPriceLimit);
366
+ fullyFillable =
367
+ maxFill.tradeQuoteQuantity +
368
+ (0, _pipmath_1.multiplyPips)(maxFill.selfTradeBaseQuantity, context.indexPrice) >=
369
+ quantity.quoteQuantity;
370
+ }
371
+ if (!fullyFillable) {
372
+ estimate.fillOrKillWouldNotExecute = true;
373
+ return estimate;
374
+ }
375
+ }
376
+ estimate.tradeBaseQuantity = fill.tradeBaseQuantity;
377
+ estimate.tradeQuoteQuantity = fill.tradeQuoteQuantity;
378
+ // Determine the resting (maker) portion. Reduce-only limit orders may rest on
379
+ // the books (their reducing portion); whether the resting quantity is valid is
380
+ // checked below via `reduceOnlyOpenPositionSizeExceeded`.
381
+ const canRest = !context.isMarketOrder &&
382
+ (context.timeInForce === request_1.TimeInForce.gtc ||
383
+ context.timeInForce === request_1.TimeInForce.gtx);
384
+ let makerBaseQuantity = BigInt(0);
385
+ if (canRest) {
386
+ if ('baseQuantity' in quantity) {
387
+ makerBaseQuantity = fill.remainingBaseQuantity;
388
+ }
389
+ else {
390
+ // Convert the remaining quote budget to base at the limit price.
391
+ const remainingQuote = (0, _pipmath_1.maxBigInt)(quantity.quoteQuantity -
392
+ fill.tradeQuoteQuantity -
393
+ (0, _pipmath_1.multiplyPips)(fill.selfTradeBaseQuantity, context.limitPrice), BigInt(0));
394
+ makerBaseQuantity =
395
+ context.limitPrice > BigInt(0) ?
396
+ (0, _pipmath_1.dividePips)(remainingQuote, context.limitPrice)
397
+ : BigInt(0);
398
+ }
399
+ }
400
+ estimate.makerBaseQuantity = makerBaseQuantity;
401
+ // A reduce-only order's resting (maker) quantity may not exceed the open
402
+ // position size that remains after the reducing fills (it would otherwise no
403
+ // longer be fully reducing). A zero remaining position with a resting quantity
404
+ // (i.e. the position is closed) is likewise flagged.
405
+ if (context.reduceOnly && makerBaseQuantity > BigInt(0)) {
406
+ const remainingOpenPositionSize = (0, _pipmath_1.absBigInt)(context.currentPositionQuantity) - fill.tradeBaseQuantity;
407
+ if (makerBaseQuantity > remainingOpenPositionSize) {
408
+ estimate.reduceOnlyOpenPositionSizeExceeded = true;
409
+ }
410
+ }
411
+ // Resulting position quantity in the order's market.
412
+ const newPositionQuantity = context.isBuy ?
413
+ context.currentPositionQuantity + fill.tradeBaseQuantity
414
+ : context.currentPositionQuantity - fill.tradeBaseQuantity;
415
+ // Quote balance after the trade.
416
+ const totalFees = fill.takerTradeFee + fill.takerGasFee;
417
+ const quoteBalanceAfter = context.isBuy ?
418
+ context.quoteBalance - fill.tradeQuoteQuantity - totalFees
419
+ : context.quoteBalance + fill.tradeQuoteQuantity - totalFees;
420
+ // Account value (equity) after the trade, holding other markets constant.
421
+ const equityAfter = quoteBalanceAfter +
422
+ (0, _pipmath_1.multiplyPips)(newPositionQuantity, context.indexPrice) +
423
+ (context.otherPositionsNotionalAtIndex -
424
+ (0, _pipmath_1.multiplyPips)(context.currentPositionQuantity, context.indexPrice));
425
+ // Initial margin requirement after the trade.
426
+ const newPositionNotional = (0, _pipmath_1.multiplyPips)((0, _pipmath_1.absBigInt)(newPositionQuantity), context.indexPrice);
427
+ const newPositionInitialMarginRequirement = (0, _pipmath_1.multiplyPips)(newPositionNotional, (0, quantities_1.calculateInitialMarginFractionWithOverride)({
428
+ baseQuantity: newPositionQuantity,
429
+ initialMarginFractionOverride: context.initialMarginFractionOverride,
430
+ leverageParameters: context.leverageParameters,
431
+ }));
432
+ const initialMarginRequirementAfter = context.initialMarginRequirement -
433
+ context.currentInitialMarginRequirement +
434
+ newPositionInitialMarginRequirement;
435
+ // Maintenance margin requirement after the trade.
436
+ const currentMaintenanceMarginRequirement = (0, _pipmath_1.multiplyPips)((0, _pipmath_1.multiplyPips)((0, _pipmath_1.absBigInt)(context.currentPositionQuantity), context.indexPrice), context.maintenanceMarginFraction);
437
+ const newPositionMaintenanceMarginRequirement = (0, _pipmath_1.multiplyPips)(newPositionNotional, context.maintenanceMarginFraction);
438
+ const otherPositionsMaintenanceMarginRequirement = (0, _pipmath_1.maxBigInt)(context.totalMaintenanceMarginRequirement -
439
+ currentMaintenanceMarginRequirement, BigInt(0));
440
+ // Held collateral after the trade: only this market's orders are affected
441
+ // (by the position change, the new resting order, and self-trade reductions).
442
+ const heldBeforeForMarket = calculateHeldCollateralForMarket(context, context.marketStandingOrders, context.currentPositionQuantity);
443
+ const standingOrdersAfter = fill.standingOrdersAfterSelfTrade.slice();
444
+ if (makerBaseQuantity > BigInt(0)) {
445
+ standingOrdersAfter.push({
446
+ isBuy: context.isBuy,
447
+ price: context.limitPrice,
448
+ openQuantity: makerBaseQuantity,
449
+ });
450
+ }
451
+ const heldAfterForMarket = calculateHeldCollateralForMarket(context, standingOrdersAfter, newPositionQuantity);
452
+ const totalHeldCollateralAfter = context.totalHeldCollateral - heldBeforeForMarket + heldAfterForMarket;
453
+ // Collateral measures (unclamped values are used for feasibility flags).
454
+ const freeCollateralBefore = (0, _pipmath_1.maxBigInt)(context.equity - context.initialMarginRequirement, BigInt(0));
455
+ const availableCollateralBefore = (0, _pipmath_1.maxBigInt)(freeCollateralBefore - context.totalHeldCollateral, BigInt(0));
456
+ const freeCollateralAfterUnclamped = equityAfter - initialMarginRequirementAfter;
457
+ const freeCollateralAfter = (0, _pipmath_1.maxBigInt)(freeCollateralAfterUnclamped, BigInt(0));
458
+ // Available collateral after the order is intentionally NOT clamped at zero:
459
+ // `cost` is allowed to exceed the wallet's available collateral (e.g. an order
460
+ // whose margin or held-collateral requirement is greater than the wallet can
461
+ // cover), consistent with the feasibility flags, which also reflect unclamped
462
+ // values.
463
+ const availableCollateralAfter = freeCollateralAfterUnclamped - totalHeldCollateralAfter;
464
+ estimate.cost = availableCollateralBefore - availableCollateralAfter;
465
+ // Liquidation price (cross-margin, account-wide).
466
+ estimate.liquidationPrice = calculateLiquidationPrice({
467
+ newPositionQuantity,
468
+ maintenanceMarginFraction: context.maintenanceMarginFraction,
469
+ quoteBalanceAfter,
470
+ otherPositionsNotionalAtIndex: context.otherPositionsNotionalAtIndex -
471
+ (0, _pipmath_1.multiplyPips)(context.currentPositionQuantity, context.indexPrice),
472
+ otherPositionsMaintenanceMarginRequirement,
473
+ });
474
+ // Feasibility flags.
475
+ //
476
+ // `freeCollateralExceeded` covers the crossing (trade) portion: the initial
477
+ // margin requirement must be met after a trade that increases a position, and
478
+ // the maintenance margin requirement after a trade that reduces it.
479
+ if (crossesSpread && fill.tradeBaseQuantity > BigInt(0)) {
480
+ const positionReduced = wasPositionReduced(context.currentPositionQuantity, newPositionQuantity);
481
+ estimate.freeCollateralExceeded =
482
+ positionReduced ?
483
+ equityAfter -
484
+ (otherPositionsMaintenanceMarginRequirement +
485
+ newPositionMaintenanceMarginRequirement) <
486
+ BigInt(0)
487
+ : freeCollateralAfterUnclamped < BigInt(0);
488
+ }
489
+ // `availableCollateralExceeded` covers any resting (maker) portion: its held
490
+ // collateral may not exceed the wallet's available collateral. This applies
491
+ // both to non-crossing orders and to the unfilled remainder of an order that
492
+ // partially crossed the spread.
493
+ if (makerBaseQuantity > BigInt(0)) {
494
+ estimate.availableCollateralExceeded =
495
+ freeCollateralAfter - totalHeldCollateralAfter < BigInt(0);
496
+ }
497
+ // Maximum position size. Check the order's requested quantity against the
498
+ // room left by the current position (`maximumPositionSize` ∓ position).
499
+ if (!context.reduceOnly) {
500
+ const maxAdditionalLiquidity = context.isBuy ?
501
+ context.maximumPositionSize - context.currentPositionQuantity
502
+ : context.maximumPositionSize + context.currentPositionQuantity;
503
+ // For quote-denominated orders the requested base quantity is not fixed; the
504
+ // realized base (fills + resting + self-trade) is used as a proxy.
505
+ const orderBaseQuantity = 'baseQuantity' in quantity ?
506
+ quantity.baseQuantity
507
+ : fill.tradeBaseQuantity + fill.selfTradeBaseQuantity + makerBaseQuantity;
508
+ if (context.isMarketOrder || crossesSpread) {
509
+ // Market and crossing limit orders are checked against the order quantity
510
+ // alone; the wallet's other standing orders may be canceled after the
511
+ // incoming order is executed if the sum of all standing orders exceeds
512
+ // the maximum position size.
513
+ if (orderBaseQuantity > maxAdditionalLiquidity) {
514
+ estimate.maximumPositionSizeExceeded = true;
515
+ }
516
+ }
517
+ else {
518
+ // A non-crossing (resting) limit order must fit alongside the wallet's
519
+ // other same-side standing orders without collectively exceeding the
520
+ // maximum position.
521
+ let sameSideActiveQuantity = BigInt(0);
522
+ for (const order of context.marketStandingOrders) {
523
+ if (order.isBuy === context.isBuy) {
524
+ sameSideActiveQuantity += order.openQuantity;
525
+ }
526
+ }
527
+ if (sameSideActiveQuantity + orderBaseQuantity > maxAdditionalLiquidity) {
528
+ estimate.maximumPositionSizeExceeded = true;
529
+ }
530
+ }
531
+ }
532
+ return estimate;
533
+ }
534
+ /**
535
+ * @private
536
+ * Resolves the slider input (a fraction of available collateral to consume)
537
+ * into a base quantity, by searching for the largest base quantity whose
538
+ * estimated cost does not exceed the target.
539
+ */
540
+ function resolveBaseQuantityForCollateralRatio(context, ratio) {
541
+ const freeCollateralBefore = (0, _pipmath_1.maxBigInt)(context.equity - context.initialMarginRequirement, BigInt(0));
542
+ const availableCollateralBefore = (0, _pipmath_1.maxBigInt)(freeCollateralBefore - context.totalHeldCollateral, BigInt(0));
543
+ const targetCost = (0, _pipmath_1.multiplyPips)(availableCollateralBefore, ratio);
544
+ if (targetCost <= BigInt(0)) {
545
+ return BigInt(0);
546
+ }
547
+ // Upper bound: all matchable liquidity, plus resting capacity for limit
548
+ // orders that may add to the books. Liquidity beyond the execution price limit
549
+ // is included: the slider matches through the book (the breach is flagged on
550
+ // the result rather than capping the size).
551
+ let matchableBase = BigInt(0);
552
+ for (const level of context.makerLevels) {
553
+ if (!context.isMarketOrder) {
554
+ const crosses = context.isBuy ?
555
+ context.limitPrice >= level.price
556
+ : context.limitPrice <= level.price;
557
+ if (!crosses) {
558
+ break;
559
+ }
560
+ }
561
+ matchableBase += level.size;
562
+ }
563
+ const canRest = !context.isMarketOrder &&
564
+ (context.timeInForce === request_1.TimeInForce.gtc ||
565
+ context.timeInForce === request_1.TimeInForce.gtx);
566
+ const restingCapacity = canRest ?
567
+ (0, _pipmath_1.maxBigInt)(context.maximumPositionSize +
568
+ (0, _pipmath_1.absBigInt)(context.currentPositionQuantity), BigInt(0))
569
+ : BigInt(0);
570
+ let high = matchableBase + restingCapacity;
571
+ if (high <= BigInt(0)) {
572
+ return BigInt(0);
573
+ }
574
+ // The slider seeks the largest quantity that (a) does not exceed the target
575
+ // cost and (b) remains feasible (does not breach the wallet's collateral).
576
+ //
577
+ // The feasibility gate is essential, not merely a cost comparison: `cost` is
578
+ // clamped at the available collateral (available-after cannot go below zero),
579
+ // so at a 100% slider the target equals that ceiling and a cost-only check
580
+ // (`cost <= target`) is satisfied by *every* larger quantity on the clamped
581
+ // "plateau" — which made the search walk all the way to `high` (matching all
582
+ // liquidity, or all liquidity up to the limit price). A quantity past the
583
+ // boundary sets freeCollateral/availableCollateralExceeded, which excludes the
584
+ // plateau and yields the largest quantity that drives available collateral to
585
+ // ~zero while remaining acceptable.
586
+ const isAcceptable = (baseQuantity) => {
587
+ // Size against the full book (do not cap at the execution price limit), so a
588
+ // breach does not prevent the slider from consuming the target collateral.
589
+ const e = runEstimate(context, { baseQuantity }, undefined, false);
590
+ return (e.cost <= targetCost &&
591
+ !e.freeCollateralExceeded &&
592
+ !e.availableCollateralExceeded);
593
+ };
594
+ // If even the maximum quantity is acceptable (e.g. liquidity- or
595
+ // position-size-limited so the target cost is never reached), return it.
596
+ if (isAcceptable(high)) {
597
+ return high;
598
+ }
599
+ let low = BigInt(0);
600
+ for (let iteration = 0; iteration < 80; iteration += 1) {
601
+ if (high - low <= BigInt(1)) {
602
+ break;
603
+ }
604
+ const mid = (low + high) / BigInt(2);
605
+ if (isAcceptable(mid)) {
606
+ low = mid;
607
+ }
608
+ else {
609
+ high = mid;
610
+ }
611
+ }
612
+ return low;
613
+ }
614
+ function buildContext(args) {
615
+ const { market, wallet, order } = args;
616
+ const { side } = order;
617
+ const isBuy = side === request_1.OrderSide.buy;
618
+ const isMarketOrder = typeof order.limitPrice === 'undefined';
619
+ const limitPrice = order.limitPrice ?? BigInt(0);
620
+ const timeInForce = order.timeInForce ?? request_1.TimeInForce.gtc;
621
+ const reduceOnly = order.reduceOnly ?? false;
622
+ const indexPrice = (0, _pipmath_1.decimalToPip)(market.indexPrice);
623
+ const leverageParameters = (0, quantities_1.convertToLeverageParametersBigInt)(market);
624
+ const maximumPositionSize = (0, _pipmath_1.decimalToPip)(market.maximumPositionSize);
625
+ const maintenanceMarginFraction = (0, _pipmath_1.decimalToPip)(market.maintenanceMarginFraction);
626
+ const overrideDecimal = (args.walletInitialMarginFractionOverrides ?? []).find((imfo) => imfo.market === market.market)?.initialMarginFractionOverride;
627
+ const initialMarginFractionOverride = typeof overrideDecimal === 'string' ? (0, _pipmath_1.decimalToPip)(overrideDecimal) : null;
628
+ // The wallet's effective taker fee rate already reflects the lowest of the
629
+ // exchange, market, and wallet rates.
630
+ const takerFeeRate = (0, _pipmath_1.decimalToPip)(wallet.takerFeeRate);
631
+ const gasFeePerOrder = args.takerTradeGasFee ?? BigInt(0);
632
+ // Maker-side order book levels, best price first.
633
+ const makerLevels = (isBuy ? args.orderBook.asks : args.orderBook.bids)
634
+ .filter((level) => level.size > BigInt(0))
635
+ .slice()
636
+ .sort((a, b) => isBuy ? Number(a.price - b.price) : Number(b.price - a.price));
637
+ // Execution price limits. The best bid/ask are taken defensively (the input
638
+ // book is not assumed to be sorted), ignoring empty levels.
639
+ const positiveAsks = args.orderBook.asks.filter((l) => l.size > BigInt(0));
640
+ const positiveBids = args.orderBook.bids.filter((l) => l.size > BigInt(0));
641
+ const bestAsk = positiveAsks.length > 0 ?
642
+ positiveAsks.reduce((best, l) => (l.price < best ? l.price : best), positiveAsks[0].price)
643
+ : null;
644
+ const bestBid = positiveBids.length > 0 ?
645
+ positiveBids.reduce((best, l) => (l.price > best ? l.price : best), positiveBids[0].price)
646
+ : null;
647
+ const baselinePrice = bestAsk !== null && bestBid !== null ?
648
+ (bestAsk + bestBid) / BigInt(2)
649
+ : indexPrice;
650
+ const marketOrderExecutionPriceLimit = (0, _pipmath_1.decimalToPip)(market.marketOrderExecutionPriceLimit);
651
+ let minimumExecutionPrice = null;
652
+ let maximumExecutionPrice = null;
653
+ if (isMarketOrder &&
654
+ marketOrderExecutionPriceLimit > BigInt(0) &&
655
+ baselinePrice > BigInt(0)) {
656
+ minimumExecutionPrice = (0, _pipmath_1.multiplyPips)(baselinePrice, _pipmath_1.oneInPips - marketOrderExecutionPriceLimit);
657
+ maximumExecutionPrice = (0, _pipmath_1.dividePips)(baselinePrice, _pipmath_1.oneInPips - marketOrderExecutionPriceLimit);
658
+ }
659
+ const limitOrderExecutionPriceLimit = (0, _pipmath_1.decimalToPip)(market.limitOrderExecutionPriceLimit);
660
+ let limitOrderPriceExceedsLimit = false;
661
+ if (!isMarketOrder &&
662
+ limitOrderExecutionPriceLimit > BigInt(0) &&
663
+ baselinePrice > BigInt(0)) {
664
+ const minimumLimitPrice = (0, _pipmath_1.multiplyPips)(baselinePrice, _pipmath_1.oneInPips - limitOrderExecutionPriceLimit);
665
+ const maximumLimitPrice = (0, _pipmath_1.dividePips)(baselinePrice, _pipmath_1.oneInPips - limitOrderExecutionPriceLimit);
666
+ limitOrderPriceExceedsLimit =
667
+ limitPrice < minimumLimitPrice || limitPrice > maximumLimitPrice;
668
+ }
669
+ // The wallet's resting limit orders in the order's market. Untriggered stop
670
+ // orders have the `active` status; they are excluded because they cannot be
671
+ // matched or self-traded and do not require held collateral.
672
+ const restingOrderStatuses = ['open', 'partiallyFilled'];
673
+ const marketStandingOrders = (args.walletsStandingOrders ?? [])
674
+ .filter((standingOrder) => standingOrder.market === market.market &&
675
+ typeof standingOrder.price !== 'undefined' &&
676
+ restingOrderStatuses.includes(standingOrder.status))
677
+ .map((standingOrder) => ({
678
+ isBuy: standingOrder.side === request_1.OrderSide.buy,
679
+ // The filter above guarantees a defined price.
680
+ price: (0, _pipmath_1.decimalToPip)(standingOrder.price ?? '0'),
681
+ openQuantity: (0, _pipmath_1.decimalToPip)(standingOrder.originalQuantity) -
682
+ (0, _pipmath_1.decimalToPip)(standingOrder.executedQuantity),
683
+ }))
684
+ .filter((standingOrder) => standingOrder.openQuantity > BigInt(0));
685
+ // Position and account-level aggregates.
686
+ const positions = wallet.positions ?? [];
687
+ const equity = (0, _pipmath_1.decimalToPip)(wallet.equity);
688
+ const quoteBalance = (0, _pipmath_1.decimalToPip)(wallet.quoteBalance);
689
+ const totalHeldCollateral = (0, _pipmath_1.decimalToPip)(wallet.heldCollateral);
690
+ let initialMarginRequirement = BigInt(0);
691
+ let currentPositionQuantity = BigInt(0);
692
+ let currentInitialMarginRequirement = BigInt(0);
693
+ for (const position of positions) {
694
+ initialMarginRequirement += (0, _pipmath_1.decimalToPip)(position.marginRequirement);
695
+ if (position.market === market.market) {
696
+ currentPositionQuantity = (0, _pipmath_1.decimalToPip)(position.quantity);
697
+ currentInitialMarginRequirement = (0, _pipmath_1.decimalToPip)(position.marginRequirement);
698
+ }
699
+ }
700
+ const totalMaintenanceMarginRequirement = (0, _pipmath_1.multiplyPips)((0, _pipmath_1.decimalToPip)(wallet.marginRatio), equity);
701
+ // Σ (every position's notional at index price) == equity - quoteBalance
702
+ const otherPositionsNotionalAtIndex = equity - quoteBalance;
703
+ return {
704
+ side,
705
+ isBuy,
706
+ isMarketOrder,
707
+ limitPrice,
708
+ timeInForce,
709
+ reduceOnly,
710
+ indexPrice,
711
+ leverageParameters,
712
+ maximumPositionSize,
713
+ initialMarginFractionOverride,
714
+ maintenanceMarginFraction,
715
+ takerFeeRate,
716
+ gasFeePerOrder,
717
+ makerLevels,
718
+ minimumExecutionPrice,
719
+ maximumExecutionPrice,
720
+ limitOrderPriceExceedsLimit,
721
+ marketStandingOrders,
722
+ currentPositionQuantity,
723
+ equity,
724
+ quoteBalance,
725
+ initialMarginRequirement,
726
+ currentInitialMarginRequirement,
727
+ totalHeldCollateral,
728
+ totalMaintenanceMarginRequirement,
729
+ otherPositionsNotionalAtIndex,
730
+ };
731
+ }
732
+ /**
733
+ * Generates an estimate of how a taker order submitted via the buy/sell panel
734
+ * would be executed by the trading engine. The estimate takes into account
735
+ * trade and gas fees, the (incremental) initial margin fraction and its
736
+ * overrides, the maximum position size, collateral freed up by the reduction of
737
+ * positions, the effect of differences between the execution and index price on
738
+ * collateral, self-trades, held collateral (for standing orders), and changes
739
+ * in held collateral as a result of self-trades and position size changes.
740
+ *
741
+ * The minimum taker quantity is intentionally ignored; the estimate generates
742
+ * results for quantities that do not meet the minimum.
743
+ *
744
+ * The order's quantity may be expressed in base asset terms, in quote asset
745
+ * terms, or as a fraction of the wallet's available collateral to consume (the
746
+ * panel's slider). See {@link BuySellPanelEstimateQuantity}.
747
+ *
748
+ * All quantities, prices, and collateral values are expressed in pips
749
+ * (see {@link decimalToPip}).
750
+ */
751
+ function calculateBuySellPanelEstimate(args) {
752
+ const { order } = args;
753
+ const quantityInputCount = (typeof order.baseQuantity !== 'undefined' ? 1 : 0) +
754
+ (typeof order.quoteQuantity !== 'undefined' ? 1 : 0) +
755
+ (typeof order.availableCollateralRatio !== 'undefined' ? 1 : 0);
756
+ if (quantityInputCount !== 1) {
757
+ throw new Error('Provide exactly one of baseQuantity, quoteQuantity, or availableCollateralRatio');
758
+ }
759
+ const context = buildContext(args);
760
+ if (typeof order.quoteQuantity !== 'undefined') {
761
+ return runEstimate(context, { quoteQuantity: order.quoteQuantity });
762
+ }
763
+ if (typeof order.baseQuantity !== 'undefined') {
764
+ return runEstimate(context, { baseQuantity: order.baseQuantity });
765
+ }
766
+ const ratio = order.availableCollateralRatio;
767
+ const baseQuantity = resolveBaseQuantityForCollateralRatio(context, ratio);
768
+ // Base the zero-quantity determination on the slider input, not the resolved
769
+ // base quantity (which is zero when the order cannot execute or rest). The
770
+ // slider also matches through the book rather than capping at the execution
771
+ // price limit (the breach is still flagged).
772
+ return runEstimate(context, { baseQuantity }, ratio <= BigInt(0), false);
773
+ }
774
+ exports.calculateBuySellPanelEstimate = calculateBuySellPanelEstimate;