@katanaperps/katana-perps-sdk 2.1.0-beta.14 → 2.1.0-beta.16

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.
@@ -120,6 +120,8 @@ export interface BuySellPanelEstimate {
120
120
  maximumPositionSizeExceeded: boolean;
121
121
  /** `true` if a post-only ({@link TimeInForce.gtx gtx}) order would cross the spread (rejected) */
122
122
  postOnlyWouldCross: boolean;
123
+ /** `true` if an immediate-or-cancel ({@link TimeInForce.ioc ioc}) order matched no liquidity (nothing would execute) */
124
+ immediateOrCancelWouldNotExecute: boolean;
123
125
  /** `true` if a fill-or-kill ({@link TimeInForce.fok fok}) order could not be fully filled (rejected) */
124
126
  fillOrKillWouldNotExecute: boolean;
125
127
  /** `true` if a reduce-only order is on the same side as the open position (rejected) */
@@ -1 +1 @@
1
- {"version":3,"file":"buySellPanelEstimate.d.ts","sourceRoot":"","sources":["../../src/orderbook/buySellPanelEstimate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"buySellPanelEstimate.d.ts","sourceRoot":"","sources":["../../src/orderbook/buySellPanelEstimate.ts"],"names":[],"mappings":"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"}
@@ -24,6 +24,7 @@ function makeEmptyEstimate() {
24
24
  executionPriceLimitExceeded: false,
25
25
  maximumPositionSizeExceeded: false,
26
26
  postOnlyWouldCross: false,
27
+ immediateOrCancelWouldNotExecute: false,
27
28
  fillOrKillWouldNotExecute: false,
28
29
  reduceOnlyWouldNotReducePosition: false,
29
30
  reduceOnlyNoOpenPosition: false,
@@ -128,7 +129,11 @@ function calculateLiquidationPrice(args) {
128
129
  * (self-trades) from other liquidity, and applying execution price limits and
129
130
  * taker fees.
130
131
  */
131
- function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
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) {
132
137
  const isQuantityInQuote = 'quoteQuantity' in quantity;
133
138
  // Working copy of own maker-side resting liquidity, indexed by price.
134
139
  const standingOrdersCopy = context.marketStandingOrders.map((order) => ({
@@ -149,6 +154,7 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
149
154
  takerTradeFee: BigInt(0),
150
155
  takerGasFee: BigInt(0),
151
156
  selfTradeBaseQuantity: BigInt(0),
157
+ selfTradeQuoteQuantity: BigInt(0),
152
158
  selfTradeEncountered: false,
153
159
  executionPriceLimitExceeded: false,
154
160
  standingOrdersAfterSelfTrade: standingOrdersCopy,
@@ -159,6 +165,13 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
159
165
  // Tracks reduce-only fill capacity (real, position-reducing fills only)
160
166
  let remainingReduceOnly = reduceOnlyMaximumBaseQuantity;
161
167
  for (const level of context.makerLevels) {
168
+ // Stop once the taker order's quantity is fully consumed.
169
+ const exhausted = remainingBase !== null ?
170
+ remainingBase <= BigInt(0)
171
+ : remainingQuote <= BigInt(0);
172
+ if (exhausted) {
173
+ break;
174
+ }
162
175
  // doOrdersMatch
163
176
  if (!context.isMarketOrder) {
164
177
  const crosses = context.isBuy ?
@@ -168,20 +181,6 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
168
181
  break;
169
182
  }
170
183
  }
171
- // Execution price limit (market orders)
172
- if ((context.minimumExecutionPrice !== null &&
173
- level.price < context.minimumExecutionPrice) ||
174
- (context.maximumExecutionPrice !== null &&
175
- level.price > context.maximumExecutionPrice)) {
176
- result.executionPriceLimitExceeded = true;
177
- break;
178
- }
179
- const exhausted = remainingBase !== null ?
180
- remainingBase <= BigInt(0)
181
- : remainingQuote <= BigInt(0);
182
- if (exhausted) {
183
- break;
184
- }
185
184
  // Determine how much of this level the taker consumes.
186
185
  let consumedBase;
187
186
  if (remainingBase !== null) {
@@ -197,6 +196,24 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
197
196
  consumedBase = (level.size * remainingQuote) / levelQuoteIfFull;
198
197
  }
199
198
  }
199
+ // Nothing more can be filled at this (best remaining) price — e.g. a
200
+ // sub-pip quote remainder left by flooring. The order is complete, so worse
201
+ // levels it never reaches must not trip the execution price limit.
202
+ if (consumedBase <= BigInt(0)) {
203
+ break;
204
+ }
205
+ // A trade occurs at this level's price, so apply the execution price limit.
206
+ // When not enforcing (slider sizing), matching continues but the breach is
207
+ // still recorded on the result.
208
+ if ((context.minimumExecutionPrice !== null &&
209
+ level.price < context.minimumExecutionPrice) ||
210
+ (context.maximumExecutionPrice !== null &&
211
+ level.price > context.maximumExecutionPrice)) {
212
+ result.executionPriceLimitExceeded = true;
213
+ if (enforceExecutionPriceLimit) {
214
+ break;
215
+ }
216
+ }
200
217
  // Split into self-trade (own liquidity) and real fill (other liquidity).
201
218
  const ownOrders = ownMakerOrdersByPrice.get(level.price) ?? [];
202
219
  let ownSizeAtLevel = BigInt(0);
@@ -215,6 +232,7 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
215
232
  if (selfPart > BigInt(0)) {
216
233
  result.selfTradeEncountered = true;
217
234
  result.selfTradeBaseQuantity += selfPart;
235
+ result.selfTradeQuoteQuantity += (0, _pipmath_1.multiplyPips)(selfPart, level.price, context.isMarketOrder && context.isBuy);
218
236
  // Reduce the wallet's own resting orders at this price.
219
237
  let toReduce = selfPart;
220
238
  for (const order of ownOrders) {
@@ -272,7 +290,19 @@ function matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity) {
272
290
  * @private
273
291
  * Produces a complete estimate for a concrete base or quote quantity.
274
292
  */
275
- function runEstimate(context, quantity) {
293
+ function runEstimate(context, quantity,
294
+ // Whether the *input* quantity is zero. A zero-quantity order does nothing, so
295
+ // none of the time-in-force feasibility flags apply to it. This is based on
296
+ // the input (e.g. the slider ratio) rather than the resolved base quantity: a
297
+ // non-zero slider on an order that cannot execute or rest resolves to a base
298
+ // quantity of zero, yet its time-in-force flags should still be evaluated.
299
+ isZeroQuantity = ('baseQuantity' in quantity ?
300
+ quantity.baseQuantity
301
+ : quantity.quoteQuantity) <= BigInt(0),
302
+ // When `false`, matching continues past the execution price limit (still
303
+ // flagged) instead of stopping at it. Used for slider sizing so the order can
304
+ // be sized to consume the target collateral.
305
+ enforceExecutionPriceLimit = true) {
276
306
  const estimate = makeEmptyEstimate();
277
307
  // Reduce-only validity and fill capacity.
278
308
  let reduceOnlyMaximumBaseQuantity = null;
@@ -297,7 +327,9 @@ function runEstimate(context, quantity) {
297
327
  (context.isBuy ?
298
328
  context.limitPrice >= bestMakerPrice
299
329
  : context.limitPrice <= bestMakerPrice));
300
- if (context.timeInForce === request_1.TimeInForce.gtx && crossesSpread) {
330
+ if (context.timeInForce === request_1.TimeInForce.gtx &&
331
+ crossesSpread &&
332
+ !isZeroQuantity) {
301
333
  estimate.postOnlyWouldCross = true;
302
334
  return estimate;
303
335
  }
@@ -306,24 +338,51 @@ function runEstimate(context, quantity) {
306
338
  estimate.executionPriceLimitExceeded = true;
307
339
  return estimate;
308
340
  }
309
- const fill = matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity);
341
+ const fill = matchTakerOrder(context, quantity, reduceOnlyMaximumBaseQuantity, enforceExecutionPriceLimit);
310
342
  estimate.selfTradeEncountered = fill.selfTradeEncountered;
311
343
  estimate.executionPriceLimitExceeded = fill.executionPriceLimitExceeded;
344
+ // Immediate-or-cancel: the unfilled remainder is canceled rather than rested,
345
+ // so an order that matched no liquidity would not execute at all.
346
+ estimate.immediateOrCancelWouldNotExecute =
347
+ context.timeInForce === request_1.TimeInForce.ioc &&
348
+ !isZeroQuantity &&
349
+ fill.tradeBaseQuantity === BigInt(0);
312
350
  // Fill-or-kill: the order must be fully filled by crossing liquidity.
313
- if (context.timeInForce === request_1.TimeInForce.fok) {
314
- const requestedFilled = 'baseQuantity' in quantity ?
315
- fill.tradeBaseQuantity + fill.selfTradeBaseQuantity >=
316
- quantity.baseQuantity
317
- : fill.tradeQuoteQuantity +
318
- (0, _pipmath_1.multiplyPips)(fill.selfTradeBaseQuantity, context.indexPrice) >=
319
- quantity.quoteQuantity;
320
- if (!requestedFilled) {
351
+ if (context.timeInForce === request_1.TimeInForce.fok && !isZeroQuantity) {
352
+ let fullyFillable;
353
+ if ('baseQuantity' in quantity) {
354
+ // Base quantities are matched exactly (no rounding), so the bounded fill
355
+ // determines fillability directly.
356
+ fullyFillable =
357
+ fill.tradeBaseQuantity + fill.selfTradeBaseQuantity >=
358
+ quantity.baseQuantity;
359
+ }
360
+ else {
361
+ // Quote fills are floored to whole base pips, so the achievable quote is
362
+ // the largest value not exceeding the requested quote and rarely equals it
363
+ // exactly. Decide fillability from the *total* crossable quote liquidity
364
+ // instead: re-match with an unbounded budget (every crossable level fills
365
+ // in full, so no flooring occurs).
366
+ const unboundedQuote = context.makerLevels.reduce((sum, level) => sum + (0, _pipmath_1.multiplyPips)(level.size, level.price), BigInt(1));
367
+ const maxFill = matchTakerOrder(context, { quoteQuantity: unboundedQuote }, reduceOnlyMaximumBaseQuantity, enforceExecutionPriceLimit);
368
+ fullyFillable =
369
+ maxFill.tradeQuoteQuantity + maxFill.selfTradeQuoteQuantity >=
370
+ quantity.quoteQuantity;
371
+ }
372
+ if (!fullyFillable) {
321
373
  estimate.fillOrKillWouldNotExecute = true;
322
374
  return estimate;
323
375
  }
324
376
  }
325
- estimate.tradeBaseQuantity = fill.tradeBaseQuantity;
326
- estimate.tradeQuoteQuantity = fill.tradeQuoteQuantity;
377
+ // The crossing (trade) quantity includes any self-traded quantity: the taker
378
+ // order must be submitted with enough quantity to traverse the book and reach
379
+ // the wallet's own resting order, otherwise that self-trade would not occur.
380
+ // (Position, collateral and liquidation effects below use only the real
381
+ // fills, since a self-trade does not change the position or quote balance.)
382
+ estimate.tradeBaseQuantity =
383
+ fill.tradeBaseQuantity + fill.selfTradeBaseQuantity;
384
+ estimate.tradeQuoteQuantity =
385
+ fill.tradeQuoteQuantity + fill.selfTradeQuoteQuantity;
327
386
  // Determine the resting (maker) portion. Reduce-only limit orders may rest on
328
387
  // the books (their reducing portion); whether the resting quantity is valid is
329
388
  // checked below via `reduceOnlyOpenPositionSizeExceeded`.
@@ -336,10 +395,11 @@ function runEstimate(context, quantity) {
336
395
  makerBaseQuantity = fill.remainingBaseQuantity;
337
396
  }
338
397
  else {
339
- // Convert the remaining quote budget to base at the limit price.
398
+ // Convert the remaining quote budget to base at the limit price. The
399
+ // crossing portion (real fills + self-trades) has consumed the rest.
340
400
  const remainingQuote = (0, _pipmath_1.maxBigInt)(quantity.quoteQuantity -
341
401
  fill.tradeQuoteQuantity -
342
- (0, _pipmath_1.multiplyPips)(fill.selfTradeBaseQuantity, context.limitPrice), BigInt(0));
402
+ fill.selfTradeQuoteQuantity, BigInt(0));
343
403
  makerBaseQuantity =
344
404
  context.limitPrice > BigInt(0) ?
345
405
  (0, _pipmath_1.dividePips)(remainingQuote, context.limitPrice)
@@ -494,7 +554,9 @@ function resolveBaseQuantityForCollateralRatio(context, ratio) {
494
554
  return BigInt(0);
495
555
  }
496
556
  // Upper bound: all matchable liquidity, plus resting capacity for limit
497
- // orders that may add to the books.
557
+ // orders that may add to the books. Liquidity beyond the execution price limit
558
+ // is included: the slider matches through the book (the breach is flagged on
559
+ // the result rather than capping the size).
498
560
  let matchableBase = BigInt(0);
499
561
  for (const level of context.makerLevels) {
500
562
  if (!context.isMarketOrder) {
@@ -505,12 +567,6 @@ function resolveBaseQuantityForCollateralRatio(context, ratio) {
505
567
  break;
506
568
  }
507
569
  }
508
- if ((context.minimumExecutionPrice !== null &&
509
- level.price < context.minimumExecutionPrice) ||
510
- (context.maximumExecutionPrice !== null &&
511
- level.price > context.maximumExecutionPrice)) {
512
- break;
513
- }
514
570
  matchableBase += level.size;
515
571
  }
516
572
  const canRest = !context.isMarketOrder &&
@@ -537,7 +593,9 @@ function resolveBaseQuantityForCollateralRatio(context, ratio) {
537
593
  // plateau and yields the largest quantity that drives available collateral to
538
594
  // ~zero while remaining acceptable.
539
595
  const isAcceptable = (baseQuantity) => {
540
- const e = runEstimate(context, { baseQuantity });
596
+ // Size against the full book (do not cap at the execution price limit), so a
597
+ // breach does not prevent the slider from consuming the target collateral.
598
+ const e = runEstimate(context, { baseQuantity }, undefined, false);
541
599
  return (e.cost <= targetCost &&
542
600
  !e.freeCollateralExceeded &&
543
601
  !e.availableCollateralExceeded);
@@ -585,9 +643,16 @@ function buildContext(args) {
585
643
  .filter((level) => level.size > BigInt(0))
586
644
  .slice()
587
645
  .sort((a, b) => isBuy ? Number(a.price - b.price) : Number(b.price - a.price));
588
- // Execution price limits.
589
- const bestAsk = args.orderBook.asks.length > 0 ? args.orderBook.asks[0].price : null;
590
- const bestBid = args.orderBook.bids.length > 0 ? args.orderBook.bids[0].price : null;
646
+ // Execution price limits. The best bid/ask are taken defensively (the input
647
+ // book is not assumed to be sorted), ignoring empty levels.
648
+ const positiveAsks = args.orderBook.asks.filter((l) => l.size > BigInt(0));
649
+ const positiveBids = args.orderBook.bids.filter((l) => l.size > BigInt(0));
650
+ const bestAsk = positiveAsks.length > 0 ?
651
+ positiveAsks.reduce((best, l) => (l.price < best ? l.price : best), positiveAsks[0].price)
652
+ : null;
653
+ const bestBid = positiveBids.length > 0 ?
654
+ positiveBids.reduce((best, l) => (l.price > best ? l.price : best), positiveBids[0].price)
655
+ : null;
591
656
  const baselinePrice = bestAsk !== null && bestBid !== null ?
592
657
  (bestAsk + bestBid) / BigInt(2)
593
658
  : indexPrice;
@@ -707,7 +772,12 @@ function calculateBuySellPanelEstimate(args) {
707
772
  if (typeof order.baseQuantity !== 'undefined') {
708
773
  return runEstimate(context, { baseQuantity: order.baseQuantity });
709
774
  }
710
- const baseQuantity = resolveBaseQuantityForCollateralRatio(context, order.availableCollateralRatio);
711
- return runEstimate(context, { baseQuantity });
775
+ const ratio = order.availableCollateralRatio;
776
+ const baseQuantity = resolveBaseQuantityForCollateralRatio(context, ratio);
777
+ // Base the zero-quantity determination on the slider input, not the resolved
778
+ // base quantity (which is zero when the order cannot execute or rest). The
779
+ // slider also matches through the book rather than capping at the execution
780
+ // price limit (the breach is still flagged).
781
+ return runEstimate(context, { baseQuantity }, ratio <= BigInt(0), false);
712
782
  }
713
783
  exports.calculateBuySellPanelEstimate = calculateBuySellPanelEstimate;
@@ -174,6 +174,70 @@ describe('orderbook/buySellPanelEstimate', () => {
174
174
  });
175
175
  testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('4'));
176
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);
177
241
  });
178
242
  it('flags a post-only (gtx) order that would cross the spread', () => {
179
243
  const estimate = runEstimate({
@@ -215,6 +279,37 @@ describe('orderbook/buySellPanelEstimate', () => {
215
279
  expect(estimate.fillOrKillWouldNotExecute).to.equal(true);
216
280
  testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
217
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
+ });
218
313
  it('detects self-trades and frees the matched order’s held collateral', () => {
219
314
  // The book’s ask at 100 (size 10) includes the wallet’s own 3-unit sell.
220
315
  const estimate = runEstimate({
@@ -233,11 +328,13 @@ describe('orderbook/buySellPanelEstimate', () => {
233
328
  order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
234
329
  });
235
330
  expect(estimate.selfTradeEncountered).to.equal(true);
236
- // Only the 2 units not belonging to the wallet are actually traded
237
- testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
238
- testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('200'));
239
- // Freeing the 30 held by the canceled own order more than offsets the new
240
- // position’s margin (20) and fee (0.2): collateral increases.
331
+ // The order must cross all 5 (the 2 others + the 3 own units) to reach the
332
+ // wallet's own order, so the self-traded 3 count toward the trade quantity.
333
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
334
+ testHelpers.assertBigintsEqual(estimate.tradeQuoteQuantity, (0, _pipmath_1.decimalToPip)('500'));
335
+ // Cost reflects only the real 2-unit fill: freeing the 30 held by the
336
+ // canceled own order more than offsets the new position’s margin (20) and
337
+ // fee (0.2), so collateral increases.
241
338
  testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('-9.8'));
242
339
  });
243
340
  it('flags an order that exceeds free collateral', () => {
@@ -323,6 +420,66 @@ describe('orderbook/buySellPanelEstimate', () => {
323
420
  // Only the 10 available at 100 (within the limit) are filled
324
421
  testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('10'));
325
422
  });
423
+ it('does not flag the execution price limit when the order fills entirely within it', () => {
424
+ const estimate = runEstimate({
425
+ market: {
426
+ ...defaultMarket,
427
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%
428
+ },
429
+ orderBook: {
430
+ // best ask 100.1, plus worse levels beyond the 1% limit (max ~101.01)
431
+ asks: [level('100.1', '10'), level('102', '10'), level('103', '10')],
432
+ bids: [level('99.9', '10'), level('98', '10')],
433
+ },
434
+ // baseline 100; fully fills 5 @ 100.1 and never reaches the 102 level
435
+ order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
436
+ });
437
+ expect(estimate.executionPriceLimitExceeded).to.equal(false);
438
+ testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
439
+ });
440
+ it('does not flag the execution price limit for a quote order that fills within it', () => {
441
+ const estimate = runEstimate({
442
+ market: {
443
+ ...defaultMarket,
444
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%
445
+ },
446
+ orderBook: {
447
+ asks: [level('100.1', '10'), level('102', '10'), level('103', '10')],
448
+ bids: [level('99.9', '10')],
449
+ },
450
+ // 50 quote fills within the best ask (well under its 1,001 quote of
451
+ // depth); the worse 102 level is never reached.
452
+ order: { side: request_1.OrderSide.buy, quoteQuantity: (0, _pipmath_1.decimalToPip)('50') },
453
+ });
454
+ expect(estimate.executionPriceLimitExceeded).to.equal(false);
455
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('0')).to.equal(true);
456
+ });
457
+ it('matches a slider order through the book past the execution price limit and flags it', () => {
458
+ const estimate = runEstimate({
459
+ market: {
460
+ ...defaultMarket,
461
+ marketOrderExecutionPriceLimit: '0.01000000', // 1%, max ~101.01
462
+ },
463
+ wallet: {
464
+ ...defaultWallet,
465
+ equity: '100.00000000',
466
+ quoteBalance: '100.00000000',
467
+ },
468
+ orderBook: {
469
+ // Only 2 of liquidity within the limit; the rest is beyond it.
470
+ asks: [level('100.1', '2'), level('102', '10'), level('103', '10')],
471
+ bids: [level('99.9', '10')],
472
+ },
473
+ order: { side: request_1.OrderSide.buy, availableCollateralRatio: _pipmath_1.oneInPips }, // 100%
474
+ });
475
+ // The slider sizes the order to consume the available collateral by
476
+ // matching beyond the limit, and flags the breach rather than capping.
477
+ expect(estimate.executionPriceLimitExceeded).to.equal(true);
478
+ expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('2')).to.equal(true);
479
+ const target = (0, _pipmath_1.decimalToPip)('100');
480
+ expect(estimate.cost <= target).to.equal(true);
481
+ expect(target - estimate.cost <= (0, _pipmath_1.decimalToPip)('1')).to.equal(true);
482
+ });
326
483
  it('flags a limit order whose price is outside the allowed range', () => {
327
484
  const estimate = runEstimate({
328
485
  market: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@katanaperps/katana-perps-sdk",
3
- "version": "2.1.0-beta.14",
3
+ "version": "2.1.0-beta.16",
4
4
  "description": "Katana Perps SDK for Javascript in the browser and Node.js",
5
5
  "repository": "git@github.com:katanaperps/katana-perps-sdk-js.git",
6
6
  "license": "MIT",