@katanaperps/katana-perps-sdk 2.1.0-beta.13 → 2.1.0-beta.14
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.
- package/dist/orderbook/buySellPanelEstimate.d.ts +155 -0
- package/dist/orderbook/buySellPanelEstimate.d.ts.map +1 -0
- package/dist/orderbook/buySellPanelEstimate.js +713 -0
- package/dist/orderbook/index.d.ts +1 -0
- package/dist/orderbook/index.d.ts.map +1 -1
- package/dist/orderbook/index.js +1 -0
- package/dist/tests/orderbook/buySellPanelEstimate.test.d.ts +2 -0
- package/dist/tests/orderbook/buySellPanelEstimate.test.d.ts.map +1 -0
- package/dist/tests/orderbook/buySellPanelEstimate.test.js +618 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/orderbook/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/orderbook/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,uBAAuB,CAAC;AACtC,cAAc,kBAAkB,CAAC"}
|
package/dist/orderbook/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
|
+
tslib_1.__exportStar(require("#orderbook/buySellPanelEstimate"), exports);
|
|
4
5
|
tslib_1.__exportStar(require("#orderbook/quantities"), exports);
|
|
5
6
|
tslib_1.__exportStar(require("#orderbook/utils"), exports);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buySellPanelEstimate.test.d.ts","sourceRoot":"","sources":["../../../src/tests/orderbook/buySellPanelEstimate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,618 @@
|
|
|
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
|
+
});
|
|
178
|
+
it('flags a post-only (gtx) order that would cross the spread', () => {
|
|
179
|
+
const estimate = runEstimate({
|
|
180
|
+
orderBook: { asks: [level('100', '10')], bids: [] },
|
|
181
|
+
order: {
|
|
182
|
+
side: request_1.OrderSide.buy,
|
|
183
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
184
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('100'),
|
|
185
|
+
timeInForce: request_1.TimeInForce.gtx,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(estimate.postOnlyWouldCross).to.equal(true);
|
|
189
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
190
|
+
});
|
|
191
|
+
it('rests a non-crossing post-only (gtx) order in full', () => {
|
|
192
|
+
const estimate = runEstimate({
|
|
193
|
+
orderBook: { asks: [level('100', '10')], bids: [] },
|
|
194
|
+
order: {
|
|
195
|
+
side: request_1.OrderSide.buy,
|
|
196
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
197
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('90'),
|
|
198
|
+
timeInForce: request_1.TimeInForce.gtx,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
expect(estimate.postOnlyWouldCross).to.equal(false);
|
|
202
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
|
|
203
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
204
|
+
});
|
|
205
|
+
it('flags a fill-or-kill order that cannot be fully filled', () => {
|
|
206
|
+
const estimate = runEstimate({
|
|
207
|
+
orderBook: { asks: [level('100', '4')], bids: [] },
|
|
208
|
+
order: {
|
|
209
|
+
side: request_1.OrderSide.buy,
|
|
210
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
|
|
211
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('100'),
|
|
212
|
+
timeInForce: request_1.TimeInForce.fok,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
expect(estimate.fillOrKillWouldNotExecute).to.equal(true);
|
|
216
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
217
|
+
});
|
|
218
|
+
it('detects self-trades and frees the matched order’s held collateral', () => {
|
|
219
|
+
// The book’s ask at 100 (size 10) includes the wallet’s own 3-unit sell.
|
|
220
|
+
const estimate = runEstimate({
|
|
221
|
+
wallet: { ...defaultWallet, heldCollateral: '30.00000000' },
|
|
222
|
+
orderBook: { asks: [level('100', '10')], bids: [] },
|
|
223
|
+
walletsStandingOrders: [
|
|
224
|
+
{
|
|
225
|
+
market: 'ETH-USD',
|
|
226
|
+
side: request_1.OrderSide.sell,
|
|
227
|
+
price: '100.00000000',
|
|
228
|
+
originalQuantity: '3.00000000',
|
|
229
|
+
executedQuantity: '0.00000000',
|
|
230
|
+
status: 'open',
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
|
|
234
|
+
});
|
|
235
|
+
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.
|
|
241
|
+
testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('-9.8'));
|
|
242
|
+
});
|
|
243
|
+
it('flags an order that exceeds free collateral', () => {
|
|
244
|
+
const estimate = runEstimate({
|
|
245
|
+
wallet: {
|
|
246
|
+
...defaultWallet,
|
|
247
|
+
equity: '60.00000000',
|
|
248
|
+
quoteBalance: '60.00000000',
|
|
249
|
+
},
|
|
250
|
+
orderBook: { asks: [level('100', '1000')], bids: [] },
|
|
251
|
+
// Notional 50,000 => margin 5,000, far above 60 of equity
|
|
252
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('500') },
|
|
253
|
+
});
|
|
254
|
+
expect(estimate.freeCollateralExceeded).to.equal(true);
|
|
255
|
+
});
|
|
256
|
+
it('flags a resting order that exceeds available collateral', () => {
|
|
257
|
+
const estimate = runEstimate({
|
|
258
|
+
wallet: {
|
|
259
|
+
...defaultWallet,
|
|
260
|
+
equity: '100.00000000',
|
|
261
|
+
quoteBalance: '100.00000000',
|
|
262
|
+
},
|
|
263
|
+
orderBook: { asks: [level('100', '10')], bids: [] },
|
|
264
|
+
order: {
|
|
265
|
+
side: request_1.OrderSide.buy,
|
|
266
|
+
// 30 @ 50 => 1,500 quote, margin 150 > 100 available
|
|
267
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('30'),
|
|
268
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('50'),
|
|
269
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
expect(estimate.availableCollateralExceeded).to.equal(true);
|
|
273
|
+
expect(estimate.freeCollateralExceeded).to.equal(false);
|
|
274
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('30'));
|
|
275
|
+
// 30 @ 50 => 1,500 notional, held 150; available was 100, so the cost is
|
|
276
|
+
// allowed to exceed available collateral (150, not clamped to 100).
|
|
277
|
+
testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('150'));
|
|
278
|
+
});
|
|
279
|
+
it('flags available collateral when a partially-crossing limit order rests an unaffordable remainder', () => {
|
|
280
|
+
const estimate = runEstimate({
|
|
281
|
+
wallet: {
|
|
282
|
+
...defaultWallet,
|
|
283
|
+
equity: '100.00000000',
|
|
284
|
+
quoteBalance: '100.00000000',
|
|
285
|
+
},
|
|
286
|
+
// Only 2 of liquidity at the limit price; the rest rests on the books.
|
|
287
|
+
orderBook: { asks: [level('100', '2')], bids: [] },
|
|
288
|
+
order: {
|
|
289
|
+
side: request_1.OrderSide.buy,
|
|
290
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('100'),
|
|
291
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('100'),
|
|
292
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
// 2 fills (affordable), 98 rests => held 98 * 100 * 0.1 = 980 > ~80 free
|
|
296
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
|
|
297
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('98'));
|
|
298
|
+
expect(estimate.freeCollateralExceeded).to.equal(false);
|
|
299
|
+
expect(estimate.availableCollateralExceeded).to.equal(true);
|
|
300
|
+
});
|
|
301
|
+
it('flags an order that exceeds the maximum position size', () => {
|
|
302
|
+
const estimate = runEstimate({
|
|
303
|
+
market: { ...defaultMarket, maximumPositionSize: '4.00000000' },
|
|
304
|
+
orderBook: { asks: [level('100', '100')], bids: [] },
|
|
305
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
|
|
306
|
+
});
|
|
307
|
+
expect(estimate.maximumPositionSizeExceeded).to.equal(true);
|
|
308
|
+
});
|
|
309
|
+
it('flags a market order that exceeds the execution price limit', () => {
|
|
310
|
+
const estimate = runEstimate({
|
|
311
|
+
market: {
|
|
312
|
+
...defaultMarket,
|
|
313
|
+
marketOrderExecutionPriceLimit: '0.00500000', // 0.5%
|
|
314
|
+
},
|
|
315
|
+
orderBook: {
|
|
316
|
+
asks: [level('100', '10'), level('101', '10')],
|
|
317
|
+
bids: [level('99', '10')],
|
|
318
|
+
},
|
|
319
|
+
// baseline = (100 + 99) / 2 = 99.5; max execution price = 100.0
|
|
320
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('15') },
|
|
321
|
+
});
|
|
322
|
+
expect(estimate.executionPriceLimitExceeded).to.equal(true);
|
|
323
|
+
// Only the 10 available at 100 (within the limit) are filled
|
|
324
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('10'));
|
|
325
|
+
});
|
|
326
|
+
it('flags a limit order whose price is outside the allowed range', () => {
|
|
327
|
+
const estimate = runEstimate({
|
|
328
|
+
market: {
|
|
329
|
+
...defaultMarket,
|
|
330
|
+
limitOrderExecutionPriceLimit: '0.05000000', // 5%
|
|
331
|
+
},
|
|
332
|
+
orderBook: {
|
|
333
|
+
asks: [level('100', '10')],
|
|
334
|
+
bids: [level('100', '10')],
|
|
335
|
+
},
|
|
336
|
+
order: {
|
|
337
|
+
side: request_1.OrderSide.buy,
|
|
338
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
339
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('200'), // far above baseline 100
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
expect(estimate.executionPriceLimitExceeded).to.equal(true);
|
|
343
|
+
});
|
|
344
|
+
it('computes the account-wide liquidation price for a new long', () => {
|
|
345
|
+
const estimate = runEstimate({
|
|
346
|
+
wallet: {
|
|
347
|
+
...defaultWallet,
|
|
348
|
+
equity: '60.00000000',
|
|
349
|
+
quoteBalance: '60.00000000',
|
|
350
|
+
},
|
|
351
|
+
orderBook: { asks: [level('100', '100')], bids: [] },
|
|
352
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
|
|
353
|
+
});
|
|
354
|
+
// quoteBalanceAfter = 60 - 500 - 0.5 = -440.5; long 5 @ MMF 0.05:
|
|
355
|
+
// -440.5 + 5P = 0.25P => P = 440.5 / 4.75 = 92.73684210
|
|
356
|
+
testHelpers.assertBigintsEqual(estimate.liquidationPrice ?? BigInt(-1), (0, _pipmath_1.decimalToPip)('92.73684210'));
|
|
357
|
+
});
|
|
358
|
+
it('returns a null liquidation price when the position is fully closed', () => {
|
|
359
|
+
const estimate = runEstimate({
|
|
360
|
+
wallet: {
|
|
361
|
+
...defaultWallet,
|
|
362
|
+
quoteBalance: '-400.00000000',
|
|
363
|
+
equity: '100.00000000',
|
|
364
|
+
marginRatio: '0.25000000',
|
|
365
|
+
positions: [longEthPosition],
|
|
366
|
+
},
|
|
367
|
+
orderBook: { bids: [level('100', '10')], asks: [] },
|
|
368
|
+
order: {
|
|
369
|
+
side: request_1.OrderSide.sell,
|
|
370
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
371
|
+
reduceOnly: true,
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
expect(estimate.liquidationPrice).to.equal(null);
|
|
375
|
+
// Closing the long frees its 50 of margin (less the 0.5 fee)
|
|
376
|
+
testHelpers.assertBigintsEqual(estimate.cost, (0, _pipmath_1.decimalToPip)('-49.5'));
|
|
377
|
+
});
|
|
378
|
+
it('clamps a reduce-only order to the position size', () => {
|
|
379
|
+
const estimate = runEstimate({
|
|
380
|
+
wallet: {
|
|
381
|
+
...defaultWallet,
|
|
382
|
+
quoteBalance: '-400.00000000',
|
|
383
|
+
equity: '100.00000000',
|
|
384
|
+
marginRatio: '0.25000000',
|
|
385
|
+
positions: [longEthPosition],
|
|
386
|
+
},
|
|
387
|
+
orderBook: { bids: [level('100', '100')], asks: [] },
|
|
388
|
+
order: {
|
|
389
|
+
side: request_1.OrderSide.sell,
|
|
390
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
|
|
391
|
+
reduceOnly: true,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
// Only 5 (the position size) can be reduced
|
|
395
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
|
|
396
|
+
});
|
|
397
|
+
it('flags a reduce-only order that would not reduce the position', () => {
|
|
398
|
+
const estimate = runEstimate({
|
|
399
|
+
wallet: {
|
|
400
|
+
...defaultWallet,
|
|
401
|
+
quoteBalance: '-400.00000000',
|
|
402
|
+
equity: '100.00000000',
|
|
403
|
+
marginRatio: '0.25000000',
|
|
404
|
+
positions: [longEthPosition],
|
|
405
|
+
},
|
|
406
|
+
orderBook: { asks: [level('100', '100')], bids: [] },
|
|
407
|
+
// A buy does not reduce an existing long
|
|
408
|
+
order: {
|
|
409
|
+
side: request_1.OrderSide.buy,
|
|
410
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
411
|
+
reduceOnly: true,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
expect(estimate.reduceOnlyWouldNotReducePosition).to.equal(true);
|
|
415
|
+
});
|
|
416
|
+
it('flags a reduce-only order placed with no open position', () => {
|
|
417
|
+
const estimate = runEstimate({
|
|
418
|
+
orderBook: { bids: [level('100', '10')], asks: [] },
|
|
419
|
+
order: {
|
|
420
|
+
side: request_1.OrderSide.sell,
|
|
421
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
422
|
+
reduceOnly: true,
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
expect(estimate.reduceOnlyNoOpenPosition).to.equal(true);
|
|
426
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
427
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
428
|
+
});
|
|
429
|
+
it('rests a reduce-only limit order up to the open position size', () => {
|
|
430
|
+
const estimate = runEstimate({
|
|
431
|
+
wallet: longEthWallet,
|
|
432
|
+
orderBook: { asks: [], bids: [] },
|
|
433
|
+
order: {
|
|
434
|
+
side: request_1.OrderSide.sell,
|
|
435
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('3'),
|
|
436
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('110'), // above the market: does not cross
|
|
437
|
+
reduceOnly: true,
|
|
438
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('0'));
|
|
442
|
+
// The reducing portion (<= position size of 5) rests on the books
|
|
443
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
|
|
444
|
+
expect(estimate.reduceOnlyOpenPositionSizeExceeded).to.equal(false);
|
|
445
|
+
});
|
|
446
|
+
it('flags a reduce-only limit order whose resting quantity exceeds the open position size', () => {
|
|
447
|
+
const estimate = runEstimate({
|
|
448
|
+
wallet: longEthWallet,
|
|
449
|
+
orderBook: { asks: [], bids: [] },
|
|
450
|
+
order: {
|
|
451
|
+
side: request_1.OrderSide.sell,
|
|
452
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('8'), // exceeds the position of 5
|
|
453
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('110'),
|
|
454
|
+
reduceOnly: true,
|
|
455
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
// The order is still reported as resting, but flagged as not fully reducing
|
|
459
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('8'));
|
|
460
|
+
expect(estimate.reduceOnlyOpenPositionSizeExceeded).to.equal(true);
|
|
461
|
+
});
|
|
462
|
+
it('fills a reduce-only market order up to the full position size regardless of same-side standing orders', () => {
|
|
463
|
+
const estimate = runEstimate({
|
|
464
|
+
wallet: longEthWallet,
|
|
465
|
+
orderBook: { bids: [level('100', '100')], asks: [] },
|
|
466
|
+
walletsStandingOrders: [
|
|
467
|
+
{
|
|
468
|
+
market: 'ETH-USD',
|
|
469
|
+
side: request_1.OrderSide.sell, // same side as the reduce-only sell
|
|
470
|
+
price: '100.00000000',
|
|
471
|
+
originalQuantity: '3.00000000',
|
|
472
|
+
executedQuantity: '0.00000000',
|
|
473
|
+
status: 'open',
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
order: {
|
|
477
|
+
side: request_1.OrderSide.sell,
|
|
478
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('10'),
|
|
479
|
+
reduceOnly: true,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
// The standing sell does not cap the market reduce-only fill: the full
|
|
483
|
+
// position of 5 is reduced
|
|
484
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
|
|
485
|
+
});
|
|
486
|
+
it('does not flag maximum position size for a crossing limit order on account of standing orders', () => {
|
|
487
|
+
const estimate = runEstimate({
|
|
488
|
+
market: { ...defaultMarket, maximumPositionSize: '10.00000000' },
|
|
489
|
+
wallet: {
|
|
490
|
+
...defaultWallet,
|
|
491
|
+
equity: '100000.00000000',
|
|
492
|
+
quoteBalance: '100000.00000000',
|
|
493
|
+
},
|
|
494
|
+
orderBook: { asks: [level('100', '3')], bids: [] },
|
|
495
|
+
walletsStandingOrders: [
|
|
496
|
+
{
|
|
497
|
+
market: 'ETH-USD',
|
|
498
|
+
side: request_1.OrderSide.buy,
|
|
499
|
+
price: '90.00000000',
|
|
500
|
+
originalQuantity: '8.00000000',
|
|
501
|
+
executedQuantity: '0.00000000',
|
|
502
|
+
status: 'open',
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
order: {
|
|
506
|
+
side: request_1.OrderSide.buy,
|
|
507
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('6'), // <= MPS of 10
|
|
508
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('100'),
|
|
509
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
// Order qty 6 <= MPS 10; the 8 standing buy is excluded from the check
|
|
513
|
+
// for a crossing order (it would get canceled after the incoming order
|
|
514
|
+
// is executed)
|
|
515
|
+
expect(estimate.maximumPositionSizeExceeded).to.equal(false);
|
|
516
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
|
|
517
|
+
testHelpers.assertBigintsEqual(estimate.makerBaseQuantity, (0, _pipmath_1.decimalToPip)('3'));
|
|
518
|
+
});
|
|
519
|
+
it('flags maximum position size for a market order whose requested quantity exceeds it despite thin liquidity', () => {
|
|
520
|
+
const estimate = runEstimate({
|
|
521
|
+
market: { ...defaultMarket, maximumPositionSize: '4.00000000' },
|
|
522
|
+
orderBook: { asks: [level('100', '2')], bids: [] },
|
|
523
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
|
|
524
|
+
});
|
|
525
|
+
expect(estimate.maximumPositionSizeExceeded).to.equal(true);
|
|
526
|
+
// Only the 2 of available liquidity is fillable
|
|
527
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('2'));
|
|
528
|
+
});
|
|
529
|
+
it('excludes untriggered stop orders (status active) from self-trade detection', () => {
|
|
530
|
+
const estimate = runEstimate({
|
|
531
|
+
orderBook: { asks: [level('100', '10')], bids: [] },
|
|
532
|
+
walletsStandingOrders: [
|
|
533
|
+
{
|
|
534
|
+
market: 'ETH-USD',
|
|
535
|
+
side: request_1.OrderSide.sell,
|
|
536
|
+
price: '100.00000000',
|
|
537
|
+
originalQuantity: '3.00000000',
|
|
538
|
+
executedQuantity: '0.00000000',
|
|
539
|
+
status: 'active', // untriggered stop order
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
order: { side: request_1.OrderSide.buy, baseQuantity: (0, _pipmath_1.decimalToPip)('5') },
|
|
543
|
+
});
|
|
544
|
+
// The untriggered stop order is ignored: no self-trade, full 5 is traded
|
|
545
|
+
expect(estimate.selfTradeEncountered).to.equal(false);
|
|
546
|
+
testHelpers.assertBigintsEqual(estimate.tradeBaseQuantity, (0, _pipmath_1.decimalToPip)('5'));
|
|
547
|
+
});
|
|
548
|
+
it('resolves the available-collateral slider so that a ratio of 1 consumes all of it', () => {
|
|
549
|
+
const ratio = _pipmath_1.oneInPips; // 100%
|
|
550
|
+
const estimate = runEstimate({
|
|
551
|
+
orderBook: { asks: [level('100', '1000')], bids: [] },
|
|
552
|
+
order: {
|
|
553
|
+
side: request_1.OrderSide.buy,
|
|
554
|
+
availableCollateralRatio: ratio,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
// available collateral = 1,000; cost should approach but not exceed it
|
|
558
|
+
const target = (0, _pipmath_1.multiplyPips)((0, _pipmath_1.decimalToPip)('1000'), ratio);
|
|
559
|
+
expect(estimate.cost <= target).to.equal(true);
|
|
560
|
+
expect(target - estimate.cost <= (0, _pipmath_1.decimalToPip)('1')).to.equal(true);
|
|
561
|
+
// The quantity is bound by collateral (margin 10/unit + fee), ~99 base —
|
|
562
|
+
// NOT the full 1,000 of available book liquidity, and it stays feasible.
|
|
563
|
+
expect(estimate.tradeBaseQuantity < (0, _pipmath_1.decimalToPip)('150')).to.equal(true);
|
|
564
|
+
expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('90')).to.equal(true);
|
|
565
|
+
expect(estimate.freeCollateralExceeded).to.equal(false);
|
|
566
|
+
});
|
|
567
|
+
it('does not gulp all crossable liquidity for a 100% slider on a limit order', () => {
|
|
568
|
+
const estimate = runEstimate({
|
|
569
|
+
orderBook: { asks: [level('100', '1000')], bids: [] },
|
|
570
|
+
order: {
|
|
571
|
+
side: request_1.OrderSide.buy,
|
|
572
|
+
limitPrice: (0, _pipmath_1.decimalToPip)('100'),
|
|
573
|
+
timeInForce: request_1.TimeInForce.gtc,
|
|
574
|
+
availableCollateralRatio: _pipmath_1.oneInPips,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
// Collateral-bound (~99), not the 1,000 of liquidity up to the limit price.
|
|
578
|
+
expect(estimate.tradeBaseQuantity < (0, _pipmath_1.decimalToPip)('150')).to.equal(true);
|
|
579
|
+
expect(estimate.tradeBaseQuantity > (0, _pipmath_1.decimalToPip)('90')).to.equal(true);
|
|
580
|
+
expect(estimate.freeCollateralExceeded).to.equal(false);
|
|
581
|
+
});
|
|
582
|
+
it('applies an initial margin fraction override (higher margin, higher cost)', () => {
|
|
583
|
+
const baseArgs = {
|
|
584
|
+
orderBook: { asks: [level('100', '100')], bids: [] },
|
|
585
|
+
order: {
|
|
586
|
+
side: request_1.OrderSide.buy,
|
|
587
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('5'),
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
const withoutOverride = runEstimate(baseArgs);
|
|
591
|
+
const withOverride = runEstimate({
|
|
592
|
+
...baseArgs,
|
|
593
|
+
walletInitialMarginFractionOverrides: [
|
|
594
|
+
{
|
|
595
|
+
wallet: '0xwallet',
|
|
596
|
+
market: 'ETH-USD',
|
|
597
|
+
initialMarginFractionOverride: '0.20000000', // 20% vs 10%
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
});
|
|
601
|
+
// Margin doubles from 50 to 100, raising the cost by ~50
|
|
602
|
+
testHelpers.assertBigintsEqual(withOverride.cost - withoutOverride.cost, (0, _pipmath_1.decimalToPip)('50'));
|
|
603
|
+
});
|
|
604
|
+
it('requires exactly one quantity input', () => {
|
|
605
|
+
expect(() => orderbook.calculateBuySellPanelEstimate({
|
|
606
|
+
market: defaultMarket,
|
|
607
|
+
wallet: defaultWallet,
|
|
608
|
+
orderBook: { asks: [], bids: [] },
|
|
609
|
+
// @ts-expect-error intentionally providing two quantity inputs
|
|
610
|
+
order: {
|
|
611
|
+
side: request_1.OrderSide.buy,
|
|
612
|
+
baseQuantity: (0, _pipmath_1.decimalToPip)('1'),
|
|
613
|
+
quoteQuantity: (0, _pipmath_1.decimalToPip)('1'),
|
|
614
|
+
},
|
|
615
|
+
})).to.throw();
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@katanaperps/katana-perps-sdk",
|
|
3
|
-
"version": "2.1.0-beta.
|
|
3
|
+
"version": "2.1.0-beta.14",
|
|
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",
|