openbroker 1.9.5 → 1.9.6

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.
@@ -3,7 +3,9 @@
3
3
 
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
+ import type { OrderResponse, CancelResponse, OpenOrder } from '../core/types.js';
6
7
  import { formatUsd, parseArgs, sleep } from '../core/utils.js';
8
+ import { UserFillWatcher, type FillWatcher } from './execution.js';
7
9
 
8
10
  function printUsage() {
9
11
  console.log(`
@@ -49,10 +51,22 @@ export interface ChaseOptions {
49
51
  reduceOnly?: boolean;
50
52
  dryRun?: boolean;
51
53
  verbose?: boolean;
54
+ client?: ChaseClient;
55
+ fillWatcher?: FillWatcher;
52
56
  /** Receives each output line. Defaults to console.log. */
53
57
  output?: (line: string) => void;
54
58
  }
55
59
 
60
+ export interface ChaseClient {
61
+ verbose: boolean;
62
+ address: string;
63
+ getAllMids(): Promise<Record<string, string>>;
64
+ getOpenOrders(): Promise<OpenOrder[]>;
65
+ getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
66
+ limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
67
+ cancel(coin: string, oid: number): Promise<CancelResponse>;
68
+ }
69
+
56
70
  export interface ChaseResult {
57
71
  status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded';
58
72
  iterations: number;
@@ -70,8 +84,12 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
70
84
  const isBuy = opts.side === 'buy';
71
85
 
72
86
  if (opts.size <= 0 || isNaN(opts.size)) throw new Error('size must be positive');
87
+ if (offsetBps < 0 || !Number.isFinite(offsetBps)) throw new Error('offsetBps must be non-negative');
88
+ if (timeoutSec <= 0 || !Number.isFinite(timeoutSec)) throw new Error('timeoutSec must be positive');
89
+ if (intervalMs <= 0 || !Number.isFinite(intervalMs)) throw new Error('intervalMs must be positive');
90
+ if (maxChaseBps <= 0 || !Number.isFinite(maxChaseBps)) throw new Error('maxChaseBps must be positive');
73
91
 
74
- const client = getClient();
92
+ const client = opts.client ?? getClient();
75
93
  if (opts.verbose) client.verbose = true;
76
94
 
77
95
  out('Open Broker - Chase Order');
@@ -107,49 +125,34 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
107
125
  const startTime = Date.now();
108
126
  let currentOid: number | null = null;
109
127
  let lastPrice: number | null = null;
128
+ let remainingSize = opts.size;
110
129
  let iteration = 0;
111
130
  let filled = false;
112
131
  let exitReason: 'filled' | 'timeout' | 'max_chase_exceeded' = 'timeout';
113
-
114
- while (Date.now() - startTime < timeoutSec * 1000) {
115
- iteration++;
116
-
117
- const currentMids = await client.getAllMids();
118
- const currentMid = parseFloat(currentMids[opts.coin]);
119
-
120
- if (isBuy && currentMid > maxChasePrice) {
121
- out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
122
- exitReason = 'max_chase_exceeded';
123
- break;
124
- }
125
- if (!isBuy && currentMid < maxChasePrice) {
126
- out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
127
- exitReason = 'max_chase_exceeded';
128
- break;
132
+ const accountedFills = new Map<number, number>();
133
+ const ownsFillWatcher = !opts.fillWatcher;
134
+ const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: startTime });
135
+
136
+ const applyFills = (oid: number): number => {
137
+ const totalFilled = fillWatcher.getFilled(oid).size;
138
+ const alreadyAccounted = accountedFills.get(oid) ?? 0;
139
+ const delta = Math.max(0, totalFilled - alreadyAccounted);
140
+ if (delta > 0) {
141
+ remainingSize = Math.max(0, remainingSize - delta);
142
+ accountedFills.set(oid, totalFilled);
129
143
  }
144
+ return delta;
145
+ };
130
146
 
131
- const orderPrice = isBuy
132
- ? currentMid * (1 - offsetBps / 10000)
133
- : currentMid * (1 + offsetBps / 10000);
147
+ await fillWatcher.start();
134
148
 
135
- const priceChanged = !lastPrice || Math.abs(orderPrice - lastPrice) / lastPrice > 0.0001;
149
+ try {
150
+ while (Date.now() - startTime < timeoutSec * 1000) {
151
+ iteration++;
136
152
 
137
- if (priceChanged) {
138
153
  if (currentOid !== null) {
139
- try {
140
- await client.cancel(opts.coin, currentOid);
141
- } catch {
142
- // Order might have filled
143
- }
144
- currentOid = null;
145
- }
146
-
147
- const orders = await client.getOpenOrders();
148
- const ourOrder = orders.find(o => o.coin === opts.coin && o.oid === currentOid);
149
- if (!ourOrder && currentOid !== null) {
150
- const state = await client.getUserState();
151
- const pos = state.assetPositions.find(p => p.position.coin === opts.coin);
152
- if (pos && Math.abs(parseFloat(pos.position.szi)) >= opts.size * 0.99) {
154
+ applyFills(currentOid);
155
+ if (remainingSize <= opts.size * 0.001) {
153
156
  filled = true;
154
157
  exitReason = 'filled';
155
158
  out(`\n✅ Order filled!`);
@@ -157,44 +160,112 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
157
160
  }
158
161
  }
159
162
 
160
- out(`[${iteration}] Mid: ${formatUsd(currentMid)} Order: ${formatUsd(orderPrice)}...`);
163
+ const currentMids = await client.getAllMids();
164
+ const currentMid = parseFloat(currentMids[opts.coin]);
165
+ if (!currentMid) throw new Error(`No market data for ${opts.coin}`);
161
166
 
162
- const response = await client.limitOrder(opts.coin, isBuy, opts.size, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
167
+ if (isBuy && currentMid > maxChasePrice) {
168
+ out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
169
+ exitReason = 'max_chase_exceeded';
170
+ break;
171
+ }
172
+ if (!isBuy && currentMid < maxChasePrice) {
173
+ out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
174
+ exitReason = 'max_chase_exceeded';
175
+ break;
176
+ }
163
177
 
164
- if (response.status === 'ok' && response.response && typeof response.response === 'object') {
165
- const status = response.response.data.statuses[0];
166
- if (status?.resting) {
167
- currentOid = status.resting.oid;
168
- lastPrice = orderPrice;
169
- out(`OID: ${currentOid}`);
170
- } else if (status?.filled) {
171
- filled = true;
172
- exitReason = 'filled';
173
- out(`✅ Filled @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
174
- break;
175
- } else if (status?.error) {
176
- out(`❌ ${status.error}`);
178
+ const orderPrice = isBuy
179
+ ? currentMid * (1 - offsetBps / 10000)
180
+ : currentMid * (1 + offsetBps / 10000);
181
+
182
+ const priceChanged = !lastPrice || Math.abs(orderPrice - lastPrice) / lastPrice > 0.0001;
183
+
184
+ if (priceChanged) {
185
+ if (currentOid !== null) {
186
+ applyFills(currentOid);
187
+ if (remainingSize <= opts.size * 0.001) {
188
+ filled = true;
189
+ exitReason = 'filled';
190
+ out(`\n✅ Order filled!`);
191
+ break;
192
+ }
193
+ try {
194
+ await client.cancel(opts.coin, currentOid);
195
+ } catch {
196
+ // Order might have filled between the fill check and cancel.
197
+ }
198
+ applyFills(currentOid);
199
+ currentOid = null;
177
200
  }
178
- } else {
179
- out(`❌ Failed`);
180
- }
181
- } else {
182
- if (currentOid !== null) {
183
- const orders = await client.getOpenOrders();
184
- const ourOrder = orders.find(o => o.oid === currentOid);
185
- if (!ourOrder) {
201
+
202
+ if (remainingSize <= opts.size * 0.001) {
186
203
  filled = true;
187
204
  exitReason = 'filled';
188
205
  out(`\n✅ Order filled!`);
189
206
  break;
190
207
  }
208
+
209
+ out(`[${iteration}] Mid: ${formatUsd(currentMid)} → Order: ${formatUsd(orderPrice)} x ${remainingSize.toFixed(6)}...`);
210
+
211
+ const response = await client.limitOrder(opts.coin, isBuy, remainingSize, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
212
+
213
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
214
+ const status = response.response.data.statuses[0];
215
+ if (status?.resting) {
216
+ currentOid = status.resting.oid;
217
+ lastPrice = orderPrice;
218
+ out(`OID: ${currentOid}`);
219
+ } else if (status?.filled) {
220
+ const totalSz = parseFloat(status.filled.totalSz);
221
+ remainingSize = Math.max(0, remainingSize - totalSz);
222
+ out(`✅ Filled ${totalSz} @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
223
+ if (remainingSize <= opts.size * 0.001) {
224
+ filled = true;
225
+ exitReason = 'filled';
226
+ break;
227
+ }
228
+ } else if (status?.error) {
229
+ out(`❌ ${status.error}`);
230
+ }
231
+ } else {
232
+ out(`❌ Failed`);
233
+ }
234
+ } else {
235
+ if (currentOid !== null) {
236
+ await fillWatcher.waitForFill(currentOid, remainingSize, intervalMs, { coin: opts.coin, pollMs: intervalMs });
237
+ applyFills(currentOid);
238
+ if (remainingSize <= opts.size * 0.001) {
239
+ filled = true;
240
+ exitReason = 'filled';
241
+ out(`\n✅ Order filled!`);
242
+ break;
243
+ }
244
+
245
+ const orders = await client.getOpenOrders();
246
+ const ourOrder = orders.find(o => o.oid === currentOid);
247
+ if (!ourOrder) {
248
+ applyFills(currentOid);
249
+ if (remainingSize <= opts.size * 0.001) {
250
+ filled = true;
251
+ exitReason = 'filled';
252
+ out(`\n✅ Order filled!`);
253
+ break;
254
+ }
255
+ currentOid = null;
256
+ lastPrice = null;
257
+ }
258
+ }
191
259
  }
192
- }
193
260
 
194
- await sleep(intervalMs);
261
+ await sleep(intervalMs);
262
+ }
263
+ } finally {
264
+ if (ownsFillWatcher) await fillWatcher.stop();
195
265
  }
196
266
 
197
267
  if (currentOid !== null && !filled) {
268
+ applyFills(currentOid);
198
269
  out(`\nCancelling unfilled order...`);
199
270
  try {
200
271
  await client.cancel(opts.coin, currentOid);
@@ -202,6 +273,11 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
202
273
  } catch {
203
274
  out(`⚠️ Could not cancel (may have filled)`);
204
275
  }
276
+ applyFills(currentOid);
277
+ if (remainingSize <= opts.size * 0.001) {
278
+ filled = true;
279
+ exitReason = 'filled';
280
+ }
205
281
  }
206
282
 
207
283
  const elapsed = (Date.now() - startTime) / 1000;
@@ -213,6 +289,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
213
289
  out(`Iterations: ${iteration}`);
214
290
  out(`Start Mid: ${formatUsd(startMid)}`);
215
291
  out(`End Mid: ${formatUsd(endMid)} (${priceMove >= 0 ? '+' : ''}${priceMove.toFixed(1)} bps)`);
292
+ out(`Filled Size: ${(opts.size - remainingSize).toFixed(6)} / ${opts.size}`);
216
293
  out(`Result: ${filled ? '✅ Filled' : '❌ Not filled'}`);
217
294
 
218
295
  return { status: exitReason, iterations: iteration, durationSec: elapsed, startMid, endMid };
@@ -0,0 +1,138 @@
1
+ import type { HyperliquidClient } from '../core/client.js';
2
+ import { WebSocketManager, type WsEventMap } from '../core/ws.js';
3
+ import { sleep } from '../core/utils.js';
4
+
5
+ export interface FillSummary {
6
+ size: number;
7
+ notional: number;
8
+ avgPrice?: number;
9
+ }
10
+
11
+ export interface FillWatcher {
12
+ start(): Promise<void>;
13
+ stop(): Promise<void>;
14
+ getFilled(oid: number): FillSummary;
15
+ waitForFill(
16
+ oid: number,
17
+ targetSize: number,
18
+ timeoutMs: number,
19
+ options?: { coin?: string; pollMs?: number }
20
+ ): Promise<FillSummary>;
21
+ }
22
+
23
+ type RestFill = { coin: string; px: string; sz: string; time: number; oid: number };
24
+
25
+ type FillClient = Pick<HyperliquidClient, 'address' | 'verbose'> & {
26
+ getUserFills(user?: string): Promise<RestFill[]>;
27
+ };
28
+
29
+ export class UserFillWatcher implements FillWatcher {
30
+ private fills = new Map<number, FillSummary>();
31
+ private seenFills = new Set<string>();
32
+ private ws: WebSocketManager | null;
33
+ private ownsWs: boolean;
34
+ private started = false;
35
+ private readonly sinceMs: number;
36
+ private readonly user: `0x${string}`;
37
+ private readonly onFill = (fill: WsEventMap['userFill']) => {
38
+ this.recordFill({
39
+ oid: fill.oid,
40
+ coin: fill.coin,
41
+ px: fill.px,
42
+ sz: fill.sz,
43
+ time: fill.time,
44
+ });
45
+ };
46
+
47
+ constructor(
48
+ private readonly client: FillClient,
49
+ options: { ws?: WebSocketManager | null; sinceMs?: number; user?: string } = {},
50
+ ) {
51
+ this.ws = options.ws === undefined ? new WebSocketManager(client.verbose) : options.ws;
52
+ this.ownsWs = options.ws === undefined;
53
+ this.sinceMs = options.sinceMs ?? Date.now();
54
+ this.user = (options.user ?? client.address) as `0x${string}`;
55
+ }
56
+
57
+ async start(): Promise<void> {
58
+ if (this.started) return;
59
+ this.started = true;
60
+ if (!this.ws) return;
61
+ try {
62
+ await this.ws.connect();
63
+ this.ws.on('userFill', this.onFill);
64
+ await this.ws.subscribeUserFills(this.user);
65
+ } catch {
66
+ this.ws.off('userFill', this.onFill);
67
+ if (this.ownsWs) await this.ws.close().catch(() => {});
68
+ this.ws = null;
69
+ }
70
+ }
71
+
72
+ async stop(): Promise<void> {
73
+ if (!this.started) return;
74
+ this.started = false;
75
+ if (!this.ws) return;
76
+ this.ws.off('userFill', this.onFill);
77
+ if (this.ownsWs) await this.ws.close().catch(() => {});
78
+ }
79
+
80
+ getFilled(oid: number): FillSummary {
81
+ return this.fills.get(oid) ?? { size: 0, notional: 0 };
82
+ }
83
+
84
+ async waitForFill(
85
+ oid: number,
86
+ targetSize: number,
87
+ timeoutMs: number,
88
+ options: { coin?: string; pollMs?: number } = {},
89
+ ): Promise<FillSummary> {
90
+ const deadline = Date.now() + Math.max(0, timeoutMs);
91
+ const pollMs = Math.max(250, options.pollMs ?? 1000);
92
+
93
+ while (Date.now() <= deadline) {
94
+ await this.refreshRestFills(options.coin);
95
+ const filled = this.getFilled(oid);
96
+ if (filled.size >= targetSize * 0.999) return filled;
97
+ await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
98
+ }
99
+
100
+ await this.refreshRestFills(options.coin);
101
+ return this.getFilled(oid);
102
+ }
103
+
104
+ private async refreshRestFills(coin?: string): Promise<void> {
105
+ try {
106
+ const fills = await this.client.getUserFills(this.user);
107
+ for (const fill of fills) {
108
+ this.recordFill(fill, coin);
109
+ }
110
+ } catch {
111
+ // WebSocket is the primary path; REST polling is best-effort fallback.
112
+ }
113
+ }
114
+
115
+ private recordFill(
116
+ fill: { oid: number; coin: string; px: string; sz: string; time: number },
117
+ expectedCoin?: string,
118
+ ): void {
119
+ if (expectedCoin && fill.coin !== expectedCoin) return;
120
+ if (fill.time < this.sinceMs) return;
121
+ const key = `${fill.oid}:${fill.time}:${fill.px}:${fill.sz}`;
122
+ if (this.seenFills.has(key)) return;
123
+ const size = parseFloat(fill.sz);
124
+ const price = parseFloat(fill.px);
125
+ if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price <= 0) return;
126
+ this.seenFills.add(key);
127
+
128
+ const prev = this.fills.get(fill.oid) ?? { size: 0, notional: 0 };
129
+ const next = {
130
+ size: prev.size + size,
131
+ notional: prev.notional + size * price,
132
+ };
133
+ this.fills.set(fill.oid, {
134
+ ...next,
135
+ avgPrice: next.notional / next.size,
136
+ });
137
+ }
138
+ }