openbroker 1.9.6 → 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.
@@ -2,7 +2,7 @@
2
2
  // Bracket Order - Entry with Take Profit and Stop Loss
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 { formatUsd, parseArgs, parseOrderStatus, sleep } from '../core/utils.js';
6
6
  import { UserFillWatcher } from './execution.js';
7
7
  function printUsage() {
8
8
  console.log(`
@@ -23,12 +23,24 @@ Options:
23
23
  --price Entry price (required if --entry limit)
24
24
  --tp Take profit distance in % from entry
25
25
  --sl Stop loss distance in % from entry
26
+ --tp-price Take profit at an absolute price (instead of --tp)
27
+ --sl-price Stop loss at an absolute price (instead of --sl)
26
28
  --slippage Slippage for market entry in bps (default: 50)
27
- --entry-timeout Seconds to wait for limit entry fill before returning (default: 300)
28
- --sl-slippage Stop-loss trigger limit slippage in bps (default: 100)
29
+ --entry-timeout Seconds to wait for limit entry fill before returning
30
+ (default: 300; only used with --no-atomic)
31
+ --sl-slippage Stop-loss fill cap past the trigger in bps (default: 100)
32
+ --sl-limit Place the SL as a stop-limit instead of a market trigger.
33
+ Warning: a gap move past the limit band can skip the stop
34
+ entirely and leave the position unprotected.
35
+ --no-atomic For limit entries: place the entry alone and arm TP/SL only
36
+ after a confirmed fill, instead of the default atomic batch
37
+ where the exchange arms TP/SL on fill server-side.
29
38
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
30
39
  --dry Dry run - show bracket plan without executing
31
40
 
41
+ At least one of --tp/--tp-price/--sl/--sl-price is required; one-sided
42
+ brackets (TP-only or SL-only) are supported.
43
+
32
44
  Take Profit / Stop Loss:
33
45
  For LONG (buy): TP is above entry, SL is below entry
34
46
  For SHORT (sell): TP is below entry, SL is above entry
@@ -37,9 +49,12 @@ Examples:
37
49
  # Long ETH with 3% take profit and 1.5% stop loss
38
50
  npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
39
51
 
40
- # Short BTC with limit entry at $100k, 5% TP, 2% SL
52
+ # Short BTC with limit entry at $100k, 5% TP, 2% SL (armed atomically on fill)
41
53
  npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
42
54
 
55
+ # Long with absolute targets, SL only
56
+ npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --sl-price 120
57
+
43
58
  # Preview bracket setup
44
59
  npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
45
60
  `);
@@ -47,14 +62,52 @@ Examples:
47
62
  export async function runBracket(opts) {
48
63
  const out = opts.output ?? ((line) => console.log(line));
49
64
  const entryType = opts.entryType ?? 'market';
65
+ const slMarket = opts.slMarket !== false;
66
+ const atomic = opts.atomic !== false;
50
67
  const isLong = opts.side === 'buy';
68
+ const hasTp = (opts.tpPct ?? 0) > 0 || (opts.tpPrice ?? 0) > 0;
69
+ const hasSl = (opts.slPct ?? 0) > 0 || (opts.slPrice ?? 0) > 0;
51
70
  if (opts.size <= 0 || isNaN(opts.size))
52
71
  throw new Error('size must be positive');
53
- if (opts.tpPct <= 0 || opts.slPct <= 0)
54
- throw new Error('tp and sl must be positive percentages');
72
+ if (!hasTp && !hasSl)
73
+ throw new Error('provide a TP target, an SL target, or both (tpPct/tpPrice/slPct/slPrice)');
55
74
  if (entryType === 'limit' && opts.entryPrice === undefined) {
56
75
  throw new Error('entryPrice is required for limit entry');
57
76
  }
77
+ // Resolve percent targets off the reference entry and validate every provided leg.
78
+ const resolveTargets = (entryPrice) => {
79
+ const tpPrice = !hasTp
80
+ ? null
81
+ : (opts.tpPrice ?? 0) > 0
82
+ ? opts.tpPrice
83
+ : isLong
84
+ ? entryPrice * (1 + opts.tpPct / 100)
85
+ : entryPrice * (1 - opts.tpPct / 100);
86
+ const slPrice = !hasSl
87
+ ? null
88
+ : (opts.slPrice ?? 0) > 0
89
+ ? opts.slPrice
90
+ : isLong
91
+ ? entryPrice * (1 - opts.slPct / 100)
92
+ : entryPrice * (1 + opts.slPct / 100);
93
+ if (tpPrice !== null && tpPrice <= 0)
94
+ throw new Error('TP must resolve to a positive price (keep TP% under 100 on shorts)');
95
+ if (slPrice !== null && slPrice <= 0)
96
+ throw new Error('SL must resolve to a positive price (keep SL% under 100)');
97
+ if (isLong) {
98
+ if (tpPrice !== null && tpPrice <= entryPrice)
99
+ throw new Error('for a long, TP price must be above entry');
100
+ if (slPrice !== null && slPrice >= entryPrice)
101
+ throw new Error('for a long, SL price must be below entry');
102
+ }
103
+ else {
104
+ if (tpPrice !== null && tpPrice >= entryPrice)
105
+ throw new Error('for a short, TP price must be below entry');
106
+ if (slPrice !== null && slPrice <= entryPrice)
107
+ throw new Error('for a short, SL price must be above entry');
108
+ }
109
+ return { tpPrice, slPrice };
110
+ };
58
111
  const client = opts.client ?? getClient();
59
112
  if (opts.verbose)
60
113
  client.verbose = true;
@@ -65,38 +118,129 @@ export async function runBracket(opts) {
65
118
  if (!midPrice)
66
119
  throw new Error(`No market data for ${opts.coin}`);
67
120
  const entry = entryType === 'limit' ? opts.entryPrice : midPrice;
68
- let tpPrice = isLong
69
- ? entry * (1 + opts.tpPct / 100)
70
- : entry * (1 - opts.tpPct / 100);
71
- let slPrice = isLong
72
- ? entry * (1 - opts.slPct / 100)
73
- : entry * (1 + opts.slPct / 100);
74
- const riskReward = opts.tpPct / opts.slPct;
121
+ // Validate targets against the pre-trade reference BEFORE the entry goes out,
122
+ // so a bad TP/SL never leaves an unprotected position behind.
123
+ let targets = resolveTargets(entry);
124
+ const legsLabel = hasTp && hasSl ? 'TP/SL' : hasTp ? 'TP' : 'SL';
125
+ const tpDistPct = targets.tpPrice !== null ? Math.abs(targets.tpPrice - entry) / entry * 100 : null;
126
+ const slDistPct = targets.slPrice !== null ? Math.abs(targets.slPrice - entry) / entry * 100 : null;
75
127
  const notional = entry * opts.size;
76
128
  out('Bracket Plan');
77
129
  out('------------');
78
130
  out(`Coin: ${opts.coin}`);
79
131
  out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
80
132
  out(`Size: ${opts.size}`);
81
- out(`Entry Type: ${entryType.toUpperCase()}`);
133
+ out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
82
134
  out(`Current Mid: ${formatUsd(midPrice)}`);
83
135
  out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
84
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%)`);
85
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%)`);
86
- out(`Risk/Reward: 1:${riskReward.toFixed(2)}`);
136
+ if (targets.tpPrice !== null)
137
+ out(`Take Profit: ${formatUsd(targets.tpPrice)} (${tpDistPct.toFixed(2)}% from entry)`);
138
+ if (targets.slPrice !== null)
139
+ out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slDistPct.toFixed(2)}% from entry, ${slMarket ? 'market trigger' : 'stop-limit'})`);
140
+ if (tpDistPct !== null && slDistPct !== null && slDistPct > 0) {
141
+ out(`Risk/Reward: 1:${(tpDistPct / slDistPct).toFixed(2)}`);
142
+ }
87
143
  out(`Est. Notional: ${formatUsd(notional)}`);
88
- const potentialProfit = notional * (opts.tpPct / 100);
89
- const potentialLoss = notional * (opts.slPct / 100);
90
144
  out('\nRisk Analysis');
91
145
  out('-------------');
92
- out(`Potential Profit: ${formatUsd(potentialProfit)}`);
93
- out(`Potential Loss: ${formatUsd(potentialLoss)}`);
146
+ if (targets.tpPrice !== null)
147
+ out(`Potential Profit: ${formatUsd(Math.abs(targets.tpPrice - entry) * opts.size)}`);
148
+ if (targets.slPrice !== null)
149
+ out(`Potential Loss: ${formatUsd(Math.abs(entry - targets.slPrice) * opts.size)}`);
94
150
  if (opts.dryRun) {
95
151
  out('\n🔍 Dry run - bracket not executed');
96
- return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
152
+ return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
97
153
  }
98
154
  out('\nExecuting bracket...\n');
99
- // Step 1: Entry
155
+ // ── Atomic path: limit entry + TP/SL in one normalTpsl batch ──────────
156
+ // The exchange arms the exits when the entry fills (children come back as
157
+ // "waitingForFill"), so the bracket survives this process exiting.
158
+ if (entryType === 'limit' && atomic) {
159
+ out(`Step 1: Limit entry + ${legsLabel} (atomic normalTpsl batch)`);
160
+ const response = await client.bracketOrder(opts.coin, isLong, opts.size, entry, {
161
+ takeProfitPrice: targets.tpPrice ?? undefined,
162
+ stopLossPrice: targets.slPrice ?? undefined,
163
+ stopLossSlippageBps: opts.slSlippageBps,
164
+ stopLossIsMarket: slMarket,
165
+ leverage: opts.leverage,
166
+ });
167
+ if (response.status !== 'ok' || !response.response || typeof response.response !== 'object') {
168
+ const reason = typeof response.response === 'string' ? response.response : 'Unknown error';
169
+ out(` ❌ Bracket failed: ${reason}`);
170
+ return { status: 'entry_failed', reason };
171
+ }
172
+ const statuses = response.response.data.statuses.map(parseOrderStatus);
173
+ const entryStatus = statuses[0];
174
+ const childStatuses = statuses.slice(1);
175
+ const failed = statuses.find((s) => s.kind === 'error' || s.kind === 'unknown');
176
+ if (failed) {
177
+ // Roll back anything that landed so no half-armed bracket is left behind.
178
+ const restingOids = statuses.flatMap((s) => (s.kind === 'resting' ? [s.oid] : []));
179
+ for (const oid of restingOids) {
180
+ try {
181
+ await client.cancel(opts.coin, oid);
182
+ }
183
+ catch { /* may have filled */ }
184
+ }
185
+ const reason = failed.kind === 'error' ? failed.error : `Unexpected order status: ${JSON.stringify(failed)}`;
186
+ out(` ❌ Bracket rejected: ${reason}`);
187
+ if (restingOids.length)
188
+ out(` Cancelled ${restingOids.length} resting order(s).`);
189
+ return { status: 'entry_failed', reason };
190
+ }
191
+ let entryOid = null;
192
+ let filledEntry = null;
193
+ if (entryStatus.kind === 'resting') {
194
+ entryOid = entryStatus.oid;
195
+ out(` ✅ Entry resting @ ${formatUsd(entry)} (OID: ${entryOid})`);
196
+ }
197
+ else if (entryStatus.kind === 'filled') {
198
+ entryOid = entryStatus.oid;
199
+ filledEntry = { size: entryStatus.totalSz, avgPx: entryStatus.avgPx };
200
+ out(` ✅ Entry filled immediately: ${entryStatus.totalSz} @ ${formatUsd(entryStatus.avgPx)}`);
201
+ }
202
+ let tpOid = null;
203
+ let slOid = null;
204
+ let childIdx = 0;
205
+ for (const label of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
206
+ if (!label)
207
+ continue;
208
+ const child = childStatuses[childIdx++];
209
+ if (!child)
210
+ continue;
211
+ if (child.kind === 'waiting') {
212
+ out(` ✅ ${label} armed — activates when the entry fills (${child.state})`);
213
+ }
214
+ else if (child.kind === 'resting') {
215
+ if (label === 'TP')
216
+ tpOid = child.oid;
217
+ else
218
+ slOid = child.oid;
219
+ out(` ✅ ${label} trigger live (OID: ${child.oid})`);
220
+ }
221
+ }
222
+ out('\n========== Bracket Summary ==========');
223
+ out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
224
+ out(`Entry: ${formatUsd(filledEntry?.avgPx ?? entry)}${filledEntry ? '' : ' (resting)'}`);
225
+ if (targets.tpPrice !== null)
226
+ out(`Take Profit: ${formatUsd(targets.tpPrice)}`);
227
+ if (targets.slPrice !== null)
228
+ out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'})`);
229
+ out(filledEntry
230
+ ? `\n✅ Bracket complete! ${legsLabel} triggers are live.`
231
+ : `\n✅ Bracket armed! ${legsLabel} activates server-side when the entry fills.`);
232
+ return {
233
+ status: filledEntry ? 'complete' : 'armed',
234
+ entryPrice: filledEntry?.avgPx ?? entry,
235
+ tpPrice: targets.tpPrice ?? undefined,
236
+ slPrice: targets.slPrice ?? undefined,
237
+ tpOid,
238
+ slOid,
239
+ entryOid,
240
+ protectedSize: filledEntry?.size ?? opts.size,
241
+ };
242
+ }
243
+ // ── Fill-first path: market entry, or limit entry with --no-atomic ────
100
244
  out('Step 1: Entry order');
101
245
  let actualEntry = entry;
102
246
  let entryOid = null;
@@ -185,64 +329,87 @@ export async function runBracket(opts) {
185
329
  out('\n⚠️ Bracket aborted - no confirmed fill size');
186
330
  return { status: 'entry_failed', reason: 'No confirmed fill size' };
187
331
  }
188
- // Recalculate TP/SL based on actual entry
189
- if (isLong) {
190
- tpPrice = actualEntry * (1 + opts.tpPct / 100);
191
- slPrice = actualEntry * (1 - opts.slPct / 100);
332
+ // Re-resolve off the actual fill price; if the fill drifted past a fixed
333
+ // target, report it instead of leaving a half-armed bracket silently.
334
+ try {
335
+ targets = resolveTargets(actualEntry);
192
336
  }
193
- else {
194
- tpPrice = actualEntry * (1 - opts.tpPct / 100);
195
- slPrice = actualEntry * (1 + opts.slPct / 100);
337
+ catch (error) {
338
+ const reason = error instanceof Error ? error.message : String(error);
339
+ out(`\n❌ Entry filled @ ${formatUsd(actualEntry)}, but ${legsLabel} could not be armed: ${reason}`);
340
+ out('⚠️ Position is OPEN and UNPROTECTED - set TP/SL manually (openbroker set-tpsl).');
341
+ return { status: 'partial', entryPrice: actualEntry, protectedSize: 0, reason };
196
342
  }
197
343
  await sleep(500);
198
- // Step 2: Paired TP/SL trigger orders
199
- out('\nStep 2: Paired TP/SL trigger orders');
344
+ // Step 2: TP/SL triggers tied to the now-open position (positionTpsl) —
345
+ // OCO between themselves and cancelled by the venue if the position closes.
346
+ out(`\nStep 2: Position ${legsLabel} trigger orders`);
200
347
  const exitSide = !isLong;
201
- const pairResponse = await client.tpslPair(opts.coin, exitSide, filledSize, tpPrice, slPrice, opts.slSlippageBps, opts.leverage);
348
+ const pairResponse = await client.tpslOrders(opts.coin, exitSide, filledSize, {
349
+ takeProfitPrice: targets.tpPrice ?? undefined,
350
+ stopLossPrice: targets.slPrice ?? undefined,
351
+ stopLossSlippageBps: opts.slSlippageBps,
352
+ stopLossIsMarket: slMarket,
353
+ grouping: 'positionTpsl',
354
+ leverage: opts.leverage,
355
+ });
202
356
  let tpOid = null;
203
357
  let slOid = null;
358
+ let exitErrors = 0;
204
359
  if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
205
- const [tpStatus, slStatus] = pairResponse.response.data.statuses;
206
- if (tpStatus?.resting) {
207
- tpOid = tpStatus.resting.oid;
208
- out(` ✅ TP trigger placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
209
- }
210
- else if (tpStatus?.error) {
211
- out(` ❌ TP failed: ${tpStatus.error}`);
212
- }
213
- else {
214
- out(` ⚠️ TP status: ${JSON.stringify(tpStatus)}`);
215
- }
216
- if (slStatus?.resting) {
217
- slOid = slStatus.resting.oid;
218
- out(` ✅ SL trigger placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
219
- }
220
- else if (slStatus?.error) {
221
- out(` ❌ SL failed: ${slStatus.error}`);
222
- }
223
- else {
224
- out(` ⚠️ SL status: ${JSON.stringify(slStatus)}`);
360
+ const statuses = pairResponse.response.data.statuses.map(parseOrderStatus);
361
+ let idx = 0;
362
+ for (const leg of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
363
+ if (!leg)
364
+ continue;
365
+ const status = statuses[idx++];
366
+ const price = leg === 'TP' ? targets.tpPrice : targets.slPrice;
367
+ if (status?.kind === 'resting') {
368
+ if (leg === 'TP')
369
+ tpOid = status.oid;
370
+ else
371
+ slOid = status.oid;
372
+ out(` ✅ ${leg} trigger placed @ ${formatUsd(price)} (OID: ${status.oid})`);
373
+ }
374
+ else if (status?.kind === 'waiting') {
375
+ out(` ✅ ${leg} trigger armed @ ${formatUsd(price)} (${status.state})`);
376
+ }
377
+ else if (status?.kind === 'error') {
378
+ exitErrors++;
379
+ out(` ${leg} failed: ${status.error}`);
380
+ }
381
+ else {
382
+ exitErrors++;
383
+ out(` ⚠️ ${leg} status: ${JSON.stringify(status)}`);
384
+ }
225
385
  }
226
386
  }
227
387
  else {
388
+ exitErrors++;
228
389
  const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
229
- out(` ❌ TP/SL pair failed: ${reason}`);
390
+ out(` ❌ ${legsLabel} orders failed: ${reason}`);
230
391
  }
231
392
  out('\n========== Bracket Summary ==========');
232
393
  out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
233
394
  out(`Entry: ${formatUsd(actualEntry)}`);
234
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%) - Trigger order`);
235
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%) - Trigger order`);
236
- if (tpOid && slOid) {
237
- out(`\n✅ Bracket complete! TP and SL are linked trigger orders.`);
395
+ if (targets.tpPrice !== null)
396
+ out(`Take Profit: ${formatUsd(targets.tpPrice)} - Trigger order`);
397
+ if (targets.slPrice !== null)
398
+ out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'}) - Trigger order`);
399
+ if (exitErrors === 0) {
400
+ out(`\n✅ Bracket complete! ${legsLabel} triggers track the position (positionTpsl).`);
401
+ }
402
+ else {
403
+ out('\n⚠️ Position is open but not fully protected - set the missing trigger manually.');
238
404
  }
239
405
  return {
240
- status: tpOid && slOid ? 'complete' : 'partial',
406
+ status: exitErrors === 0 ? 'complete' : 'partial',
241
407
  entryPrice: actualEntry,
242
- tpPrice,
243
- slPrice,
408
+ tpPrice: targets.tpPrice ?? undefined,
409
+ slPrice: targets.slPrice ?? undefined,
244
410
  tpOid,
245
411
  slOid,
412
+ entryOid,
246
413
  protectedSize: filledSize,
247
414
  };
248
415
  }
@@ -253,14 +420,17 @@ async function main() {
253
420
  const size = parseFloat(args.size);
254
421
  const entryType = (args.entry || 'market');
255
422
  const entryPrice = args.price ? parseFloat(args.price) : undefined;
256
- const tpPct = parseFloat(args.tp);
257
- const slPct = parseFloat(args.sl);
423
+ const tpPct = args.tp ? parseFloat(args.tp) : undefined;
424
+ const slPct = args.sl ? parseFloat(args.sl) : undefined;
425
+ const tpPrice = args['tp-price'] ? parseFloat(args['tp-price']) : undefined;
426
+ const slPrice = args['sl-price'] ? parseFloat(args['sl-price']) : undefined;
258
427
  const slippage = args.slippage ? parseInt(args.slippage) : undefined;
259
428
  const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout']) : undefined;
260
429
  const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage']) : undefined;
261
430
  const leverage = args.leverage ? parseInt(args.leverage) : undefined;
262
431
  const dryRun = args.dry;
263
- if (!coin || !side || isNaN(size) || isNaN(tpPct) || isNaN(slPct)) {
432
+ const hasTarget = [tpPct, slPct, tpPrice, slPrice].some((v) => v !== undefined && !isNaN(v));
433
+ if (!coin || !side || isNaN(size) || !hasTarget) {
264
434
  printUsage();
265
435
  process.exit(1);
266
436
  }
@@ -275,11 +445,15 @@ async function main() {
275
445
  size,
276
446
  tpPct,
277
447
  slPct,
448
+ tpPrice,
449
+ slPrice,
278
450
  entryType,
279
451
  entryPrice,
280
452
  slippage,
281
453
  entryTimeoutSec,
282
454
  slSlippageBps,
455
+ slMarket: !args['sl-limit'],
456
+ atomic: !args['no-atomic'],
283
457
  leverage,
284
458
  dryRun,
285
459
  verbose: args.verbose,
@@ -34,7 +34,7 @@ export interface ChaseClient {
34
34
  cancel(coin: string, oid: number): Promise<CancelResponse>;
35
35
  }
36
36
  export interface ChaseResult {
37
- status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded';
37
+ status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional';
38
38
  iterations: number;
39
39
  durationSec: number;
40
40
  startMid: number;
@@ -1 +1 @@
1
- {"version":3,"file":"chase.d.ts","sourceRoot":"","sources":["../../scripts/operations/chase.ts"],"names":[],"mappings":";AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEjF,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAkCnE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,aAAa,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IACtC,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACjH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,EAAE,UAAU,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACpK,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,SAAS,GAAG,oBAAoB,CAAC;IAC5D,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CA0NvE"}
1
+ {"version":3,"file":"chase.d.ts","sourceRoot":"","sources":["../../scripts/operations/chase.ts"],"names":[],"mappings":";AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEjF,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAkCnE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,aAAa,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IACtC,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IACjH,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,EAAE,UAAU,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACpK,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,SAAS,GAAG,oBAAoB,GAAG,cAAc,CAAC;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CA2PvE"}
@@ -2,7 +2,7 @@
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
6
  import { UserFillWatcher } from './execution.js';
7
7
  function printUsage() {
8
8
  console.log(`
@@ -61,6 +61,10 @@ export async function runChase(opts) {
61
61
  const startMid = parseFloat(mids[opts.coin]);
62
62
  if (!startMid)
63
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);
64
68
  const maxChasePrice = isBuy
65
69
  ? startMid * (1 + maxChaseBps / 10000)
66
70
  : startMid * (1 - maxChaseBps / 10000);
@@ -76,6 +80,8 @@ export async function runChase(opts) {
76
80
  out(`Update Rate: ${intervalMs}ms`);
77
81
  out(`Order Type: ALO (post-only)`);
78
82
  if (opts.dryRun) {
83
+ if (startBelowMinimum)
84
+ out(`\n⚠️ ${minNotionalMsg}`);
79
85
  out('\n🔍 Dry run - chase not started');
80
86
  return { status: 'dry', iterations: 0, durationSec: 0, startMid, endMid: startMid };
81
87
  }
@@ -113,10 +119,21 @@ export async function runChase(opts) {
113
119
  break;
114
120
  }
115
121
  }
116
- const currentMids = await client.getAllMids();
117
- const currentMid = parseFloat(currentMids[opts.coin]);
118
- if (!currentMid)
119
- throw new Error(`No market data for ${opts.coin}`);
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
+ }
120
137
  if (isBuy && currentMid > maxChasePrice) {
121
138
  out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
122
139
  exitReason = 'max_chase_exceeded';
@@ -155,6 +172,13 @@ export async function runChase(opts) {
155
172
  out(`\n✅ Order filled!`);
156
173
  break;
157
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';
180
+ break;
181
+ }
158
182
  out(`[${iteration}] Mid: ${formatUsd(currentMid)} → Order: ${formatUsd(orderPrice)} x ${remainingSize.toFixed(6)}...`);
159
183
  const response = await client.limitOrder(opts.coin, isBuy, remainingSize, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
160
184
  if (response.status === 'ok' && response.response && typeof response.response === 'object') {
@@ -175,11 +199,20 @@ export async function runChase(opts) {
175
199
  }
176
200
  }
177
201
  else if (status?.error) {
178
- out(`❌ ${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
+ }
179
211
  }
180
212
  }
181
213
  else {
182
- out(`❌ Failed`);
214
+ const reason = typeof response.response === 'string' ? response.response : 'Order rejected';
215
+ throw new Error(reason);
183
216
  }
184
217
  }
185
218
  else {
@@ -211,25 +244,27 @@ export async function runChase(opts) {
211
244
  }
212
245
  }
213
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
+ }
264
+ }
214
265
  if (ownsFillWatcher)
215
266
  await fillWatcher.stop();
216
267
  }
217
- if (currentOid !== null && !filled) {
218
- applyFills(currentOid);
219
- out(`\nCancelling unfilled order...`);
220
- try {
221
- await client.cancel(opts.coin, currentOid);
222
- out(`✅ Cancelled`);
223
- }
224
- catch {
225
- out(`⚠️ Could not cancel (may have filled)`);
226
- }
227
- applyFills(currentOid);
228
- if (remainingSize <= opts.size * 0.001) {
229
- filled = true;
230
- exitReason = 'filled';
231
- }
232
- }
233
268
  const elapsed = (Date.now() - startTime) / 1000;
234
269
  const endMid = parseFloat((await client.getAllMids())[opts.coin]);
235
270
  const priceMove = ((endMid - startMid) / startMid) * 10000;
@@ -1 +1 @@
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,CA6HvE"}
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"}
@@ -2,7 +2,7 @@
2
2
  // Scale In/Out - Place a grid of limit orders
3
3
  import { fileURLToPath } from 'url';
4
4
  import { getClient } from '../core/client.js';
5
- import { formatUsd, parseArgs } from '../core/utils.js';
5
+ import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs } from '../core/utils.js';
6
6
  function printUsage() {
7
7
  console.log(`
8
8
  Open Broker - Scale In/Out
@@ -130,10 +130,19 @@ export async function runScale(opts) {
130
130
  `${level.size.toFixed(6).padStart(10)} | ` +
131
131
  `${level.distanceFromMid.toFixed(2)}%`);
132
132
  }
133
+ // Every level must clear the exchange's minimum notional (waived for
134
+ // reduce-only) or it gets a silent per-level rejection.
135
+ const thinnest = levels.reduce((min, level) => Math.min(min, level.size * level.price), Infinity);
136
+ const belowMinimum = !reduceOnly && thinnest < MIN_ORDER_NOTIONAL_USD;
137
+ const minNotionalMsg = `smallest scale level (~$${thinnest.toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — use fewer levels or a larger size`;
133
138
  if (opts.dryRun) {
139
+ if (belowMinimum)
140
+ out(`\n⚠️ The ${minNotionalMsg}`);
134
141
  out('\n🔍 Dry run - orders not placed');
135
142
  return { status: 'dry', levels, restingOids: [], filledOids: [], errors: [], rolledBack: false };
136
143
  }
144
+ if (belowMinimum)
145
+ throw new Error(`the ${minNotionalMsg}`);
137
146
  out('\nPlacing ladder as a bulk order...\n');
138
147
  const response = await client.bulkOrder(levels.map((level) => ({
139
148
  coin: opts.coin,