openbroker 1.9.5 → 1.10.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +6 -4
  3. package/SKILL.md +14 -5
  4. package/dist/core/client.d.ts +63 -3
  5. package/dist/core/client.d.ts.map +1 -1
  6. package/dist/core/client.js +246 -11
  7. package/dist/core/utils.d.ts +29 -0
  8. package/dist/core/utils.d.ts.map +1 -1
  9. package/dist/core/utils.js +27 -0
  10. package/dist/lib.d.ts +4 -1
  11. package/dist/lib.d.ts.map +1 -1
  12. package/dist/lib.js +2 -1
  13. package/dist/operations/advanced-orders.test.d.ts +2 -0
  14. package/dist/operations/advanced-orders.test.d.ts.map +1 -0
  15. package/dist/operations/advanced-orders.test.js +478 -0
  16. package/dist/operations/bracket.d.ts +55 -3
  17. package/dist/operations/bracket.d.ts.map +1 -1
  18. package/dist/operations/bracket.js +324 -117
  19. package/dist/operations/chase.d.ts +20 -1
  20. package/dist/operations/chase.d.ts.map +1 -1
  21. package/dist/operations/chase.js +169 -67
  22. package/dist/operations/execution.d.ts +53 -0
  23. package/dist/operations/execution.d.ts.map +1 -0
  24. package/dist/operations/execution.js +106 -0
  25. package/dist/operations/scale.d.ts +50 -1
  26. package/dist/operations/scale.d.ts.map +1 -1
  27. package/dist/operations/scale.js +152 -105
  28. package/dist/operations/set-tpsl.js +69 -57
  29. package/dist/operations/trigger-order.js +18 -6
  30. package/dist/operations/twap.js +13 -1
  31. package/package.json +3 -2
  32. package/scripts/core/client.ts +320 -10
  33. package/scripts/core/utils.ts +37 -0
  34. package/scripts/lib.ts +6 -0
  35. package/scripts/operations/advanced-orders.test.ts +542 -0
  36. package/scripts/operations/bracket.ts +350 -115
  37. package/scripts/operations/chase.ts +183 -73
  38. package/scripts/operations/execution.ts +138 -0
  39. package/scripts/operations/scale.ts +195 -131
  40. package/scripts/operations/set-tpsl.ts +62 -57
  41. package/scripts/operations/trigger-order.ts +20 -6
  42. package/scripts/operations/twap.ts +14 -1
@@ -2,7 +2,8 @@
2
2
  // Chase Order - Follow price with limit orders until filled
3
3
  import { fileURLToPath } from 'url';
4
4
  import { getClient } from '../core/client.js';
5
- import { formatUsd, parseArgs, sleep } from '../core/utils.js';
5
+ import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs, sleep } from '../core/utils.js';
6
+ import { UserFillWatcher } from './execution.js';
6
7
  function printUsage() {
7
8
  console.log(`
8
9
  Open Broker - Chase Order
@@ -43,7 +44,15 @@ export async function runChase(opts) {
43
44
  const isBuy = opts.side === 'buy';
44
45
  if (opts.size <= 0 || isNaN(opts.size))
45
46
  throw new Error('size must be positive');
46
- const client = getClient();
47
+ if (offsetBps < 0 || !Number.isFinite(offsetBps))
48
+ throw new Error('offsetBps must be non-negative');
49
+ if (timeoutSec <= 0 || !Number.isFinite(timeoutSec))
50
+ throw new Error('timeoutSec must be positive');
51
+ if (intervalMs <= 0 || !Number.isFinite(intervalMs))
52
+ throw new Error('intervalMs must be positive');
53
+ if (maxChaseBps <= 0 || !Number.isFinite(maxChaseBps))
54
+ throw new Error('maxChaseBps must be positive');
55
+ const client = opts.client ?? getClient();
47
56
  if (opts.verbose)
48
57
  client.verbose = true;
49
58
  out('Open Broker - Chase Order');
@@ -52,6 +61,10 @@ export async function runChase(opts) {
52
61
  const startMid = parseFloat(mids[opts.coin]);
53
62
  if (!startMid)
54
63
  throw new Error(`No market data for ${opts.coin}`);
64
+ const startBelowMinimum = !opts.reduceOnly && opts.size * startMid < MIN_ORDER_NOTIONAL_USD;
65
+ const minNotionalMsg = `Chase size (~$${(opts.size * startMid).toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`;
66
+ if (startBelowMinimum && !opts.dryRun)
67
+ throw new Error(minNotionalMsg);
55
68
  const maxChasePrice = isBuy
56
69
  ? startMid * (1 + maxChaseBps / 10000)
57
70
  : startMid * (1 - maxChaseBps / 10000);
@@ -67,6 +80,8 @@ export async function runChase(opts) {
67
80
  out(`Update Rate: ${intervalMs}ms`);
68
81
  out(`Order Type: ALO (post-only)`);
69
82
  if (opts.dryRun) {
83
+ if (startBelowMinimum)
84
+ out(`\n⚠️ ${minNotionalMsg}`);
70
85
  out('\n🔍 Dry run - chase not started');
71
86
  return { status: 'dry', iterations: 0, durationSec: 0, startMid, endMid: startMid };
72
87
  }
@@ -74,95 +89,181 @@ export async function runChase(opts) {
74
89
  const startTime = Date.now();
75
90
  let currentOid = null;
76
91
  let lastPrice = null;
92
+ let remainingSize = opts.size;
77
93
  let iteration = 0;
78
94
  let filled = false;
79
95
  let exitReason = 'timeout';
80
- while (Date.now() - startTime < timeoutSec * 1000) {
81
- iteration++;
82
- const currentMids = await client.getAllMids();
83
- const currentMid = parseFloat(currentMids[opts.coin]);
84
- if (isBuy && currentMid > maxChasePrice) {
85
- out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
86
- exitReason = 'max_chase_exceeded';
87
- break;
96
+ const accountedFills = new Map();
97
+ const ownsFillWatcher = !opts.fillWatcher;
98
+ const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: startTime });
99
+ const applyFills = (oid) => {
100
+ const totalFilled = fillWatcher.getFilled(oid).size;
101
+ const alreadyAccounted = accountedFills.get(oid) ?? 0;
102
+ const delta = Math.max(0, totalFilled - alreadyAccounted);
103
+ if (delta > 0) {
104
+ remainingSize = Math.max(0, remainingSize - delta);
105
+ accountedFills.set(oid, totalFilled);
88
106
  }
89
- if (!isBuy && currentMid < maxChasePrice) {
90
- out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
91
- exitReason = 'max_chase_exceeded';
92
- break;
93
- }
94
- const orderPrice = isBuy
95
- ? currentMid * (1 - offsetBps / 10000)
96
- : currentMid * (1 + offsetBps / 10000);
97
- const priceChanged = !lastPrice || Math.abs(orderPrice - lastPrice) / lastPrice > 0.0001;
98
- if (priceChanged) {
107
+ return delta;
108
+ };
109
+ await fillWatcher.start();
110
+ try {
111
+ while (Date.now() - startTime < timeoutSec * 1000) {
112
+ iteration++;
99
113
  if (currentOid !== null) {
100
- try {
101
- await client.cancel(opts.coin, currentOid);
102
- }
103
- catch {
104
- // Order might have filled
105
- }
106
- currentOid = null;
107
- }
108
- const orders = await client.getOpenOrders();
109
- const ourOrder = orders.find(o => o.coin === opts.coin && o.oid === currentOid);
110
- if (!ourOrder && currentOid !== null) {
111
- const state = await client.getUserState();
112
- const pos = state.assetPositions.find(p => p.position.coin === opts.coin);
113
- if (pos && Math.abs(parseFloat(pos.position.szi)) >= opts.size * 0.99) {
114
+ applyFills(currentOid);
115
+ if (remainingSize <= opts.size * 0.001) {
114
116
  filled = true;
115
117
  exitReason = 'filled';
116
118
  out(`\n✅ Order filled!`);
117
119
  break;
118
120
  }
119
121
  }
120
- out(`[${iteration}] Mid: ${formatUsd(currentMid)} Order: ${formatUsd(orderPrice)}...`);
121
- const response = await client.limitOrder(opts.coin, isBuy, opts.size, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
122
- if (response.status === 'ok' && response.response && typeof response.response === 'object') {
123
- const status = response.response.data.statuses[0];
124
- if (status?.resting) {
125
- currentOid = status.resting.oid;
126
- lastPrice = orderPrice;
127
- out(`OID: ${currentOid}`);
122
+ // A transient /info failure mid-chase must not kill the execution (and
123
+ // strand the resting order) wait a tick and retry instead.
124
+ let currentMid = NaN;
125
+ try {
126
+ const currentMids = await client.getAllMids();
127
+ currentMid = parseFloat(currentMids[opts.coin]);
128
+ }
129
+ catch {
130
+ /* transient failure — retry next tick */
131
+ }
132
+ if (!Number.isFinite(currentMid) || currentMid <= 0) {
133
+ out(`[${iteration}] ⏳ No live price — retrying...`);
134
+ await sleep(intervalMs);
135
+ continue;
136
+ }
137
+ if (isBuy && currentMid > maxChasePrice) {
138
+ out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
139
+ exitReason = 'max_chase_exceeded';
140
+ break;
141
+ }
142
+ if (!isBuy && currentMid < maxChasePrice) {
143
+ out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
144
+ exitReason = 'max_chase_exceeded';
145
+ break;
146
+ }
147
+ const orderPrice = isBuy
148
+ ? currentMid * (1 - offsetBps / 10000)
149
+ : currentMid * (1 + offsetBps / 10000);
150
+ const priceChanged = !lastPrice || Math.abs(orderPrice - lastPrice) / lastPrice > 0.0001;
151
+ if (priceChanged) {
152
+ if (currentOid !== null) {
153
+ applyFills(currentOid);
154
+ if (remainingSize <= opts.size * 0.001) {
155
+ filled = true;
156
+ exitReason = 'filled';
157
+ out(`\n✅ Order filled!`);
158
+ break;
159
+ }
160
+ try {
161
+ await client.cancel(opts.coin, currentOid);
162
+ }
163
+ catch {
164
+ // Order might have filled between the fill check and cancel.
165
+ }
166
+ applyFills(currentOid);
167
+ currentOid = null;
128
168
  }
129
- else if (status?.filled) {
169
+ if (remainingSize <= opts.size * 0.001) {
130
170
  filled = true;
131
171
  exitReason = 'filled';
132
- out(`✅ Filled @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
172
+ out(`\n✅ Order filled!`);
173
+ break;
174
+ }
175
+ // The dust remainder of a partial fill can drop below the exchange
176
+ // minimum — placing it would just error out on every tick.
177
+ if (!opts.reduceOnly && remainingSize * currentMid < MIN_ORDER_NOTIONAL_USD) {
178
+ out(`\n⚠️ Remaining size (~$${(remainingSize * currentMid).toFixed(2)}) fell below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`);
179
+ exitReason = 'min_notional';
133
180
  break;
134
181
  }
135
- else if (status?.error) {
136
- out(`❌ ${status.error}`);
182
+ out(`[${iteration}] Mid: ${formatUsd(currentMid)} → Order: ${formatUsd(orderPrice)} x ${remainingSize.toFixed(6)}...`);
183
+ const response = await client.limitOrder(opts.coin, isBuy, remainingSize, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
184
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
185
+ const status = response.response.data.statuses[0];
186
+ if (status?.resting) {
187
+ currentOid = status.resting.oid;
188
+ lastPrice = orderPrice;
189
+ out(`OID: ${currentOid}`);
190
+ }
191
+ else if (status?.filled) {
192
+ const totalSz = parseFloat(status.filled.totalSz);
193
+ remainingSize = Math.max(0, remainingSize - totalSz);
194
+ out(`✅ Filled ${totalSz} @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
195
+ if (remainingSize <= opts.size * 0.001) {
196
+ filled = true;
197
+ exitReason = 'filled';
198
+ break;
199
+ }
200
+ }
201
+ else if (status?.error) {
202
+ if (/post.?only|immediately match/i.test(status.error)) {
203
+ // Stale mid crossed the book — the fast-market condition chase
204
+ // exists for. Reprice on the next tick instead of dying.
205
+ out(`↩️ Quote would cross the book — repricing`);
206
+ lastPrice = null;
207
+ }
208
+ else {
209
+ throw new Error(status.error);
210
+ }
211
+ }
212
+ }
213
+ else {
214
+ const reason = typeof response.response === 'string' ? response.response : 'Order rejected';
215
+ throw new Error(reason);
137
216
  }
138
217
  }
139
218
  else {
140
- out(`❌ Failed`);
141
- }
142
- }
143
- else {
144
- if (currentOid !== null) {
145
- const orders = await client.getOpenOrders();
146
- const ourOrder = orders.find(o => o.oid === currentOid);
147
- if (!ourOrder) {
148
- filled = true;
149
- exitReason = 'filled';
150
- out(`\n✅ Order filled!`);
151
- break;
219
+ if (currentOid !== null) {
220
+ await fillWatcher.waitForFill(currentOid, remainingSize, intervalMs, { coin: opts.coin, pollMs: intervalMs });
221
+ applyFills(currentOid);
222
+ if (remainingSize <= opts.size * 0.001) {
223
+ filled = true;
224
+ exitReason = 'filled';
225
+ out(`\n✅ Order filled!`);
226
+ break;
227
+ }
228
+ const orders = await client.getOpenOrders();
229
+ const ourOrder = orders.find(o => o.oid === currentOid);
230
+ if (!ourOrder) {
231
+ applyFills(currentOid);
232
+ if (remainingSize <= opts.size * 0.001) {
233
+ filled = true;
234
+ exitReason = 'filled';
235
+ out(`\n✅ Order filled!`);
236
+ break;
237
+ }
238
+ currentOid = null;
239
+ lastPrice = null;
240
+ }
152
241
  }
153
242
  }
243
+ await sleep(intervalMs);
154
244
  }
155
- await sleep(intervalMs);
156
245
  }
157
- if (currentOid !== null && !filled) {
158
- out(`\nCancelling unfilled order...`);
159
- try {
160
- await client.cancel(opts.coin, currentOid);
161
- out(`✅ Cancelled`);
162
- }
163
- catch {
164
- out(`⚠️ Could not cancel (may have filled)`);
246
+ finally {
247
+ // Always pull the working order before returning — even when the loop
248
+ // throws — so an error never strands a live resting quote at a stale price.
249
+ if (currentOid !== null && !filled) {
250
+ applyFills(currentOid);
251
+ out(`\nCancelling unfilled order...`);
252
+ try {
253
+ await client.cancel(opts.coin, currentOid);
254
+ out(`✅ Cancelled`);
255
+ }
256
+ catch {
257
+ out(`⚠️ Could not cancel (may have filled)`);
258
+ }
259
+ applyFills(currentOid);
260
+ if (remainingSize <= opts.size * 0.001) {
261
+ filled = true;
262
+ exitReason = 'filled';
263
+ }
165
264
  }
265
+ if (ownsFillWatcher)
266
+ await fillWatcher.stop();
166
267
  }
167
268
  const elapsed = (Date.now() - startTime) / 1000;
168
269
  const endMid = parseFloat((await client.getAllMids())[opts.coin]);
@@ -172,6 +273,7 @@ export async function runChase(opts) {
172
273
  out(`Iterations: ${iteration}`);
173
274
  out(`Start Mid: ${formatUsd(startMid)}`);
174
275
  out(`End Mid: ${formatUsd(endMid)} (${priceMove >= 0 ? '+' : ''}${priceMove.toFixed(1)} bps)`);
276
+ out(`Filled Size: ${(opts.size - remainingSize).toFixed(6)} / ${opts.size}`);
175
277
  out(`Result: ${filled ? '✅ Filled' : '❌ Not filled'}`);
176
278
  return { status: exitReason, iterations: iteration, durationSec: elapsed, startMid, endMid };
177
279
  }
@@ -0,0 +1,53 @@
1
+ import type { HyperliquidClient } from '../core/client.js';
2
+ import { WebSocketManager } from '../core/ws.js';
3
+ export interface FillSummary {
4
+ size: number;
5
+ notional: number;
6
+ avgPrice?: number;
7
+ }
8
+ export interface FillWatcher {
9
+ start(): Promise<void>;
10
+ stop(): Promise<void>;
11
+ getFilled(oid: number): FillSummary;
12
+ waitForFill(oid: number, targetSize: number, timeoutMs: number, options?: {
13
+ coin?: string;
14
+ pollMs?: number;
15
+ }): Promise<FillSummary>;
16
+ }
17
+ type RestFill = {
18
+ coin: string;
19
+ px: string;
20
+ sz: string;
21
+ time: number;
22
+ oid: number;
23
+ };
24
+ type FillClient = Pick<HyperliquidClient, 'address' | 'verbose'> & {
25
+ getUserFills(user?: string): Promise<RestFill[]>;
26
+ };
27
+ export declare class UserFillWatcher implements FillWatcher {
28
+ private readonly client;
29
+ private fills;
30
+ private seenFills;
31
+ private ws;
32
+ private ownsWs;
33
+ private started;
34
+ private readonly sinceMs;
35
+ private readonly user;
36
+ private readonly onFill;
37
+ constructor(client: FillClient, options?: {
38
+ ws?: WebSocketManager | null;
39
+ sinceMs?: number;
40
+ user?: string;
41
+ });
42
+ start(): Promise<void>;
43
+ stop(): Promise<void>;
44
+ getFilled(oid: number): FillSummary;
45
+ waitForFill(oid: number, targetSize: number, timeoutMs: number, options?: {
46
+ coin?: string;
47
+ pollMs?: number;
48
+ }): Promise<FillSummary>;
49
+ private refreshRestFills;
50
+ private recordFill;
51
+ }
52
+ export {};
53
+ //# sourceMappingURL=execution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"execution.d.ts","sourceRoot":"","sources":["../../scripts/operations/execution.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC3D,OAAO,EAAE,gBAAgB,EAAmB,MAAM,eAAe,CAAC;AAGlE,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAAC;IACpC,WAAW,CACT,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAC3C,OAAO,CAAC,WAAW,CAAC,CAAC;CACzB;AAED,KAAK,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpF,KAAK,UAAU,GAAG,IAAI,CAAC,iBAAiB,EAAE,SAAS,GAAG,SAAS,CAAC,GAAG;IACjE,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;CAClD,CAAC;AAEF,qBAAa,eAAgB,YAAW,WAAW;IAmB/C,OAAO,CAAC,QAAQ,CAAC,MAAM;IAlBzB,OAAO,CAAC,KAAK,CAAkC;IAC/C,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,MAAM,CAAU;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAgB;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAQrB;gBAGiB,MAAM,EAAE,UAAU,EACnC,OAAO,GAAE;QAAE,EAAE,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAO;IAQ3E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAetB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ3B,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW;IAI7B,WAAW,CACf,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAO,GAC/C,OAAO,CAAC,WAAW,CAAC;YAeT,gBAAgB;IAW9B,OAAO,CAAC,UAAU;CAuBnB"}
@@ -0,0 +1,106 @@
1
+ import { WebSocketManager } from '../core/ws.js';
2
+ import { sleep } from '../core/utils.js';
3
+ export class UserFillWatcher {
4
+ client;
5
+ fills = new Map();
6
+ seenFills = new Set();
7
+ ws;
8
+ ownsWs;
9
+ started = false;
10
+ sinceMs;
11
+ user;
12
+ onFill = (fill) => {
13
+ this.recordFill({
14
+ oid: fill.oid,
15
+ coin: fill.coin,
16
+ px: fill.px,
17
+ sz: fill.sz,
18
+ time: fill.time,
19
+ });
20
+ };
21
+ constructor(client, options = {}) {
22
+ this.client = client;
23
+ this.ws = options.ws === undefined ? new WebSocketManager(client.verbose) : options.ws;
24
+ this.ownsWs = options.ws === undefined;
25
+ this.sinceMs = options.sinceMs ?? Date.now();
26
+ this.user = (options.user ?? client.address);
27
+ }
28
+ async start() {
29
+ if (this.started)
30
+ return;
31
+ this.started = true;
32
+ if (!this.ws)
33
+ return;
34
+ try {
35
+ await this.ws.connect();
36
+ this.ws.on('userFill', this.onFill);
37
+ await this.ws.subscribeUserFills(this.user);
38
+ }
39
+ catch {
40
+ this.ws.off('userFill', this.onFill);
41
+ if (this.ownsWs)
42
+ await this.ws.close().catch(() => { });
43
+ this.ws = null;
44
+ }
45
+ }
46
+ async stop() {
47
+ if (!this.started)
48
+ return;
49
+ this.started = false;
50
+ if (!this.ws)
51
+ return;
52
+ this.ws.off('userFill', this.onFill);
53
+ if (this.ownsWs)
54
+ await this.ws.close().catch(() => { });
55
+ }
56
+ getFilled(oid) {
57
+ return this.fills.get(oid) ?? { size: 0, notional: 0 };
58
+ }
59
+ async waitForFill(oid, targetSize, timeoutMs, options = {}) {
60
+ const deadline = Date.now() + Math.max(0, timeoutMs);
61
+ const pollMs = Math.max(250, options.pollMs ?? 1000);
62
+ while (Date.now() <= deadline) {
63
+ await this.refreshRestFills(options.coin);
64
+ const filled = this.getFilled(oid);
65
+ if (filled.size >= targetSize * 0.999)
66
+ return filled;
67
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
68
+ }
69
+ await this.refreshRestFills(options.coin);
70
+ return this.getFilled(oid);
71
+ }
72
+ async refreshRestFills(coin) {
73
+ try {
74
+ const fills = await this.client.getUserFills(this.user);
75
+ for (const fill of fills) {
76
+ this.recordFill(fill, coin);
77
+ }
78
+ }
79
+ catch {
80
+ // WebSocket is the primary path; REST polling is best-effort fallback.
81
+ }
82
+ }
83
+ recordFill(fill, expectedCoin) {
84
+ if (expectedCoin && fill.coin !== expectedCoin)
85
+ return;
86
+ if (fill.time < this.sinceMs)
87
+ return;
88
+ const key = `${fill.oid}:${fill.time}:${fill.px}:${fill.sz}`;
89
+ if (this.seenFills.has(key))
90
+ return;
91
+ const size = parseFloat(fill.sz);
92
+ const price = parseFloat(fill.px);
93
+ if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price <= 0)
94
+ return;
95
+ this.seenFills.add(key);
96
+ const prev = this.fills.get(fill.oid) ?? { size: 0, notional: 0 };
97
+ const next = {
98
+ size: prev.size + size,
99
+ notional: prev.notional + size * price,
100
+ };
101
+ this.fills.set(fill.oid, {
102
+ ...next,
103
+ avgPrice: next.notional / next.size,
104
+ });
105
+ }
106
+ }
@@ -1,3 +1,52 @@
1
1
  #!/usr/bin/env npx tsx
2
- export {};
2
+ import type { CancelResponse, OrderResponse } from '../core/types.js';
3
+ export interface OrderLevel {
4
+ level: number;
5
+ price: number;
6
+ size: number;
7
+ distanceFromMid: number;
8
+ }
9
+ export interface ScaleClient {
10
+ verbose: boolean;
11
+ getAllMids(): Promise<Record<string, string>>;
12
+ bulkOrder(orders: Array<{
13
+ coin: string;
14
+ isBuy: boolean;
15
+ size: number;
16
+ price: number;
17
+ tif?: 'Gtc' | 'Alo';
18
+ reduceOnly?: boolean;
19
+ leverage?: number;
20
+ }>): Promise<OrderResponse>;
21
+ bulkCancel(cancels: Array<{
22
+ coin: string;
23
+ oid: number;
24
+ }>): Promise<CancelResponse>;
25
+ }
26
+ export interface ScaleOptions {
27
+ coin: string;
28
+ side: 'buy' | 'sell';
29
+ size: number;
30
+ levels: number;
31
+ rangePct: number;
32
+ distribution?: 'linear' | 'exponential' | 'flat';
33
+ leverage?: number;
34
+ reduceOnly?: boolean;
35
+ tif?: 'Gtc' | 'Alo';
36
+ dryRun?: boolean;
37
+ verbose?: boolean;
38
+ rollbackOnPartial?: boolean;
39
+ client?: ScaleClient;
40
+ output?: (line: string) => void;
41
+ }
42
+ export interface ScaleResult {
43
+ status: 'dry' | 'complete' | 'partial' | 'failed';
44
+ levels: OrderLevel[];
45
+ restingOids: number[];
46
+ filledOids: number[];
47
+ errors: string[];
48
+ rolledBack: boolean;
49
+ }
50
+ export declare function calculateLevels(midPrice: number, isBuy: boolean, totalSize: number, numLevels: number, rangePct: number, distribution: 'linear' | 'exponential' | 'flat'): OrderLevel[];
51
+ export declare function runScale(opts: ScaleOptions): Promise<ScaleResult>;
3
52
  //# sourceMappingURL=scale.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scale.d.ts","sourceRoot":"","sources":["../../scripts/operations/scale.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"scale.d.ts","sourceRoot":"","sources":["../../scripts/operations/scale.ts"],"names":[],"mappings":";AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAyCtE,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,SAAS,CACP,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,GACzI,OAAO,CAAC,aAAa,CAAC,CAAC;IAC1B,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACpF;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,QAAQ,GAAG,aAAa,GAAG,MAAM,CAAC;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,KAAK,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAClD,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,EACd,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,QAAQ,GAAG,aAAa,GAAG,MAAM,GAC9C,UAAU,EAAE,CA0Cd;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAsIvE"}