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.
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env npx tsx
2
2
  // Scale In/Out - Place a grid of limit orders
3
3
 
4
+ import { fileURLToPath } from 'url';
4
5
  import { getClient } from '../core/client.js';
5
- import { formatUsd, parseArgs, sleep } from '../core/utils.js';
6
+ import type { CancelResponse, OrderResponse } from '../core/types.js';
7
+ import { formatUsd, parseArgs } from '../core/utils.js';
6
8
 
7
9
  function printUsage() {
8
10
  console.log(`
@@ -42,14 +44,49 @@ Examples:
42
44
  `);
43
45
  }
44
46
 
45
- interface OrderLevel {
47
+ export interface OrderLevel {
46
48
  level: number;
47
49
  price: number;
48
50
  size: number;
49
51
  distanceFromMid: number;
50
52
  }
51
53
 
52
- function calculateLevels(
54
+ export interface ScaleClient {
55
+ verbose: boolean;
56
+ getAllMids(): Promise<Record<string, string>>;
57
+ bulkOrder(
58
+ orders: Array<{ coin: string; isBuy: boolean; size: number; price: number; tif?: 'Gtc' | 'Alo'; reduceOnly?: boolean; leverage?: number }>
59
+ ): Promise<OrderResponse>;
60
+ bulkCancel(cancels: Array<{ coin: string; oid: number }>): Promise<CancelResponse>;
61
+ }
62
+
63
+ export interface ScaleOptions {
64
+ coin: string;
65
+ side: 'buy' | 'sell';
66
+ size: number;
67
+ levels: number;
68
+ rangePct: number;
69
+ distribution?: 'linear' | 'exponential' | 'flat';
70
+ leverage?: number;
71
+ reduceOnly?: boolean;
72
+ tif?: 'Gtc' | 'Alo';
73
+ dryRun?: boolean;
74
+ verbose?: boolean;
75
+ rollbackOnPartial?: boolean;
76
+ client?: ScaleClient;
77
+ output?: (line: string) => void;
78
+ }
79
+
80
+ export interface ScaleResult {
81
+ status: 'dry' | 'complete' | 'partial' | 'failed';
82
+ levels: OrderLevel[];
83
+ restingOids: number[];
84
+ filledOids: number[];
85
+ errors: string[];
86
+ rolledBack: boolean;
87
+ }
88
+
89
+ export function calculateLevels(
53
90
  midPrice: number,
54
91
  isBuy: boolean,
55
92
  totalSize: number,
@@ -100,6 +137,133 @@ function calculateLevels(
100
137
  return levels;
101
138
  }
102
139
 
140
+ export async function runScale(opts: ScaleOptions): Promise<ScaleResult> {
141
+ const out = opts.output ?? ((line: string) => console.log(line));
142
+ const distribution = opts.distribution ?? 'linear';
143
+ const reduceOnly = opts.reduceOnly ?? false;
144
+ const tif = opts.tif ?? 'Gtc';
145
+ const rollbackOnPartial = opts.rollbackOnPartial ?? true;
146
+ const isBuy = opts.side === 'buy';
147
+
148
+ if (!opts.coin) throw new Error('coin is required');
149
+ if (opts.side !== 'buy' && opts.side !== 'sell') throw new Error('side must be buy or sell');
150
+ if (!Number.isFinite(opts.size) || opts.size <= 0) throw new Error('size must be positive');
151
+ if (!Number.isInteger(opts.levels) || opts.levels <= 0) throw new Error('levels must be a positive integer');
152
+ if (!Number.isFinite(opts.rangePct) || opts.rangePct <= 0) throw new Error('rangePct must be positive');
153
+ if (!['linear', 'exponential', 'flat'].includes(distribution)) throw new Error('distribution must be linear, exponential, or flat');
154
+
155
+ const client = opts.client ?? getClient();
156
+ if (opts.verbose) client.verbose = true;
157
+
158
+ out('Open Broker - Scale In/Out');
159
+ out('==========================\n');
160
+
161
+ const mids = await client.getAllMids();
162
+ const midPrice = parseFloat(mids[opts.coin]);
163
+ if (!midPrice) throw new Error(`No market data for ${opts.coin}`);
164
+
165
+ const levels = calculateLevels(midPrice, isBuy, opts.size, opts.levels, opts.rangePct, distribution);
166
+ const notional = levels.reduce((sum, l) => sum + l.price * l.size, 0);
167
+ const avgPrice = notional / opts.size;
168
+
169
+ out('Order Plan');
170
+ out('----------');
171
+ out(`Coin: ${opts.coin}`);
172
+ out(`Side: ${isBuy ? 'BUY' : 'SELL'}`);
173
+ out(`Total Size: ${opts.size}`);
174
+ out(`Current Mid: ${formatUsd(midPrice)}`);
175
+ out(`Levels: ${opts.levels}`);
176
+ out(`Range: ${opts.rangePct}% ${isBuy ? 'below' : 'above'} mid`);
177
+ out(`Distribution: ${distribution}`);
178
+ out(`Time in Force: ${tif}`);
179
+ out(`Reduce Only: ${reduceOnly ? 'Yes' : 'No'}`);
180
+ out(`Est. Notional: ${formatUsd(notional)}`);
181
+ out(`Avg Price: ${formatUsd(avgPrice)}`);
182
+
183
+ out('\nOrder Grid');
184
+ out('----------');
185
+ out('Level | Price | Size | Distance');
186
+ out('------|--------------|------------|----------');
187
+
188
+ for (const level of levels) {
189
+ out(
190
+ ` ${level.level.toString().padStart(2)} | ` +
191
+ `${formatUsd(level.price).padStart(12)} | ` +
192
+ `${level.size.toFixed(6).padStart(10)} | ` +
193
+ `${level.distanceFromMid.toFixed(2)}%`
194
+ );
195
+ }
196
+
197
+ if (opts.dryRun) {
198
+ out('\nšŸ” Dry run - orders not placed');
199
+ return { status: 'dry', levels, restingOids: [], filledOids: [], errors: [], rolledBack: false };
200
+ }
201
+
202
+ out('\nPlacing ladder as a bulk order...\n');
203
+
204
+ const response = await client.bulkOrder(
205
+ levels.map((level) => ({
206
+ coin: opts.coin,
207
+ isBuy,
208
+ size: level.size,
209
+ price: level.price,
210
+ tif,
211
+ reduceOnly,
212
+ leverage: opts.leverage,
213
+ }))
214
+ );
215
+
216
+ const restingOids: number[] = [];
217
+ const filledOids: number[] = [];
218
+ const errors: string[] = [];
219
+
220
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
221
+ response.response.data.statuses.forEach((status, index) => {
222
+ const level = levels[index];
223
+ if (status?.resting) {
224
+ restingOids.push(status.resting.oid);
225
+ out(`Level ${level.level}: āœ… OID ${status.resting.oid}`);
226
+ } else if (status?.filled) {
227
+ filledOids.push(status.filled.oid);
228
+ out(`Level ${level.level}: āœ… Filled ${status.filled.totalSz} @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
229
+ } else if (status?.error) {
230
+ errors.push(`Level ${level.level}: ${status.error}`);
231
+ out(`Level ${level.level}: āŒ ${status.error}`);
232
+ } else {
233
+ errors.push(`Level ${level.level}: Unknown status`);
234
+ out(`Level ${level.level}: āš ļø Unknown status`);
235
+ }
236
+ });
237
+ } else {
238
+ const reason = typeof response.response === 'string' ? response.response : 'Bulk order failed';
239
+ errors.push(reason);
240
+ out(`āŒ ${reason}`);
241
+ }
242
+
243
+ let rolledBack = false;
244
+ if (errors.length > 0 && rollbackOnPartial && restingOids.length > 0) {
245
+ out('\nPartial ladder placement detected; cancelling resting ladder orders...');
246
+ await client.bulkCancel(restingOids.map((oid) => ({ coin: opts.coin, oid })));
247
+ rolledBack = true;
248
+ out(`Cancelled ${restingOids.length} resting order(s).`);
249
+ }
250
+
251
+ out('\n========== Summary ==========');
252
+ out(`Orders Placed: ${restingOids.length + filledOids.length}/${opts.levels}`);
253
+ if (errors.length > 0) out(`Failed: ${errors.length}`);
254
+ if (restingOids.length > 0) out(`Resting OIDs: ${restingOids.join(', ')}`);
255
+ if (filledOids.length > 0) out(`Filled OIDs: ${filledOids.join(', ')}`);
256
+ if (rolledBack) out('Rollback: Resting orders cancelled');
257
+
258
+ const status = errors.length === 0
259
+ ? 'complete'
260
+ : restingOids.length + filledOids.length > 0
261
+ ? 'partial'
262
+ : 'failed';
263
+
264
+ return { status, levels, restingOids, filledOids, errors, rolledBack };
265
+ }
266
+
103
267
  async function main() {
104
268
  const args = parseArgs(process.argv.slice(2));
105
269
 
@@ -119,17 +283,6 @@ async function main() {
119
283
  process.exit(1);
120
284
  }
121
285
 
122
- if (side !== 'buy' && side !== 'sell') {
123
- console.error('Error: --side must be "buy" or "sell"');
124
- process.exit(1);
125
- }
126
-
127
- if (!['linear', 'exponential', 'flat'].includes(distribution)) {
128
- console.error('Error: --distribution must be linear, exponential, or flat');
129
- process.exit(1);
130
- }
131
-
132
- // Map uppercase CLI input to Pascal case for SDK
133
286
  const tifMap: Record<string, 'Gtc' | 'Alo'> = {
134
287
  'GTC': 'Gtc',
135
288
  'ALO': 'Alo'
@@ -141,126 +294,28 @@ async function main() {
141
294
  process.exit(1);
142
295
  }
143
296
 
144
- const isBuy = side === 'buy';
145
- const client = getClient();
146
-
147
- if (args.verbose) {
148
- client.verbose = true;
149
- }
150
-
151
- console.log('Open Broker - Scale In/Out');
152
- console.log('==========================\n');
153
-
154
297
  try {
155
- const mids = await client.getAllMids();
156
- const midPrice = parseFloat(mids[coin]);
157
- if (!midPrice) {
158
- console.error(`Error: No market data for ${coin}`);
159
- process.exit(1);
160
- }
161
-
162
- const levels = calculateLevels(midPrice, isBuy, totalSize, numLevels, rangePct, distribution);
163
- const notional = levels.reduce((sum, l) => sum + l.price * l.size, 0);
164
- const avgPrice = notional / totalSize;
165
-
166
- console.log('Order Plan');
167
- console.log('----------');
168
- console.log(`Coin: ${coin}`);
169
- console.log(`Side: ${isBuy ? 'BUY' : 'SELL'}`);
170
- console.log(`Total Size: ${totalSize}`);
171
- console.log(`Current Mid: ${formatUsd(midPrice)}`);
172
- console.log(`Levels: ${numLevels}`);
173
- console.log(`Range: ${rangePct}% ${isBuy ? 'below' : 'above'} mid`);
174
- console.log(`Distribution: ${distribution}`);
175
- console.log(`Time in Force: ${tif}`);
176
- console.log(`Reduce Only: ${reduceOnly ? 'Yes' : 'No'}`);
177
- console.log(`Est. Notional: ${formatUsd(notional)}`);
178
- console.log(`Avg Price: ${formatUsd(avgPrice)}`);
179
-
180
- console.log('\nOrder Grid');
181
- console.log('----------');
182
- console.log('Level | Price | Size | Distance');
183
- console.log('------|--------------|------------|----------');
184
-
185
- for (const level of levels) {
186
- console.log(
187
- ` ${level.level.toString().padStart(2)} | ` +
188
- `${formatUsd(level.price).padStart(12)} | ` +
189
- `${level.size.toFixed(6).padStart(10)} | ` +
190
- `${level.distanceFromMid.toFixed(2)}%`
191
- );
192
- }
193
-
194
- if (dryRun) {
195
- console.log('\nšŸ” Dry run - orders not placed');
196
- return;
197
- }
198
-
199
- console.log('\nPlacing orders...\n');
200
-
201
- const results: { level: number; oid?: number; error?: string }[] = [];
202
-
203
- for (const level of levels) {
204
- process.stdout.write(`Level ${level.level}: ${formatUsd(level.price)} x ${level.size.toFixed(6)}... `);
205
-
206
- try {
207
- const response = await client.limitOrder(
208
- coin,
209
- isBuy,
210
- level.size,
211
- level.price,
212
- tif,
213
- reduceOnly,
214
- leverage
215
- );
216
-
217
- if (response.status === 'ok' && response.response && typeof response.response === 'object') {
218
- const status = response.response.data.statuses[0];
219
- if (status?.resting) {
220
- console.log(`āœ… OID: ${status.resting.oid}`);
221
- results.push({ level: level.level, oid: status.resting.oid });
222
- } else if (status?.filled) {
223
- console.log(`āœ… Filled immediately @ ${formatUsd(parseFloat(status.filled.avgPx))}`);
224
- results.push({ level: level.level, oid: status.filled.oid });
225
- } else if (status?.error) {
226
- console.log(`āŒ ${status.error}`);
227
- results.push({ level: level.level, error: status.error });
228
- } else {
229
- console.log(`āš ļø Unknown status`);
230
- results.push({ level: level.level, error: 'Unknown status' });
231
- }
232
- } else {
233
- const error = typeof response.response === 'string' ? response.response : 'Failed';
234
- console.log(`āŒ ${error}`);
235
- results.push({ level: level.level, error });
236
- }
237
- } catch (err) {
238
- const error = err instanceof Error ? err.message : String(err);
239
- console.log(`āŒ ${error}`);
240
- results.push({ level: level.level, error });
241
- }
242
-
243
- // Small delay between orders
244
- await sleep(100);
245
- }
246
-
247
- // Summary
248
- const successful = results.filter(r => r.oid).length;
249
- const failed = results.filter(r => r.error).length;
250
-
251
- console.log('\n========== Summary ==========');
252
- console.log(`Orders Placed: ${successful}/${numLevels}`);
253
- if (failed > 0) {
254
- console.log(`Failed: ${failed}`);
255
- }
256
- if (successful > 0) {
257
- console.log(`Order IDs: ${results.filter(r => r.oid).map(r => r.oid).join(', ')}`);
258
- }
259
-
298
+ const result = await runScale({
299
+ coin,
300
+ side: side as 'buy' | 'sell',
301
+ size: totalSize,
302
+ levels: numLevels,
303
+ rangePct,
304
+ distribution,
305
+ leverage,
306
+ reduceOnly,
307
+ tif,
308
+ dryRun,
309
+ verbose: args.verbose as boolean,
310
+ });
311
+ if (result.status === 'failed') process.exit(1);
260
312
  } catch (error) {
261
- console.error('Error:', error);
313
+ console.error('Error:', error instanceof Error ? error.message : error);
262
314
  process.exit(1);
263
315
  }
264
316
  }
265
317
 
266
- main();
318
+ // Only run when invoked as a script — not when imported as a module.
319
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
320
+ main();
321
+ }