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.
- package/README.md +6 -4
- package/SKILL.md +2 -2
- package/dist/core/client.d.ts +6 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +71 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/operations/advanced-orders.test.d.ts +2 -0
- package/dist/operations/advanced-orders.test.d.ts.map +1 -0
- package/dist/operations/advanced-orders.test.js +189 -0
- package/dist/operations/bracket.d.ts +22 -0
- package/dist/operations/bracket.d.ts.map +1 -1
- package/dist/operations/bracket.js +106 -73
- package/dist/operations/chase.d.ts +19 -0
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +125 -58
- package/dist/operations/execution.d.ts +53 -0
- package/dist/operations/execution.d.ts.map +1 -0
- package/dist/operations/execution.js +106 -0
- package/dist/operations/scale.d.ts +50 -1
- package/dist/operations/scale.d.ts.map +1 -1
- package/dist/operations/scale.js +143 -105
- package/package.json +3 -2
- package/scripts/core/client.ts +91 -0
- package/scripts/lib.ts +3 -0
- package/scripts/operations/advanced-orders.test.ts +209 -0
- package/scripts/operations/bracket.ts +128 -72
- package/scripts/operations/chase.ts +138 -61
- package/scripts/operations/execution.ts +138 -0
- package/scripts/operations/scale.ts +186 -131
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
? currentMid * (1 - offsetBps / 10000)
|
|
133
|
-
: currentMid * (1 + offsetBps / 10000);
|
|
147
|
+
await fillWatcher.start();
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
try {
|
|
150
|
+
while (Date.now() - startTime < timeoutSec * 1000) {
|
|
151
|
+
iteration++;
|
|
136
152
|
|
|
137
|
-
if (priceChanged) {
|
|
138
153
|
if (currentOid !== null) {
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
+
}
|