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.
@@ -3,8 +3,8 @@
3
3
 
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
- import type { OrderResponse } from '../core/types.js';
7
- import { formatUsd, parseArgs, sleep } from '../core/utils.js';
6
+ import type { CancelResponse, OrderResponse } from '../core/types.js';
7
+ import { formatUsd, parseArgs, parseOrderStatus, sleep } from '../core/utils.js';
8
8
  import { UserFillWatcher, type FillWatcher } from './execution.js';
9
9
 
10
10
  function printUsage() {
@@ -26,12 +26,24 @@ Options:
26
26
  --price Entry price (required if --entry limit)
27
27
  --tp Take profit distance in % from entry
28
28
  --sl Stop loss distance in % from entry
29
+ --tp-price Take profit at an absolute price (instead of --tp)
30
+ --sl-price Stop loss at an absolute price (instead of --sl)
29
31
  --slippage Slippage for market entry in bps (default: 50)
30
- --entry-timeout Seconds to wait for limit entry fill before returning (default: 300)
31
- --sl-slippage Stop-loss trigger limit slippage in bps (default: 100)
32
+ --entry-timeout Seconds to wait for limit entry fill before returning
33
+ (default: 300; only used with --no-atomic)
34
+ --sl-slippage Stop-loss fill cap past the trigger in bps (default: 100)
35
+ --sl-limit Place the SL as a stop-limit instead of a market trigger.
36
+ Warning: a gap move past the limit band can skip the stop
37
+ entirely and leave the position unprotected.
38
+ --no-atomic For limit entries: place the entry alone and arm TP/SL only
39
+ after a confirmed fill, instead of the default atomic batch
40
+ where the exchange arms TP/SL on fill server-side.
32
41
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
33
42
  --dry Dry run - show bracket plan without executing
34
43
 
44
+ At least one of --tp/--tp-price/--sl/--sl-price is required; one-sided
45
+ brackets (TP-only or SL-only) are supported.
46
+
35
47
  Take Profit / Stop Loss:
36
48
  For LONG (buy): TP is above entry, SL is below entry
37
49
  For SHORT (sell): TP is below entry, SL is above entry
@@ -40,9 +52,12 @@ Examples:
40
52
  # Long ETH with 3% take profit and 1.5% stop loss
41
53
  npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
42
54
 
43
- # Short BTC with limit entry at $100k, 5% TP, 2% SL
55
+ # Short BTC with limit entry at $100k, 5% TP, 2% SL (armed atomically on fill)
44
56
  npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
45
57
 
58
+ # Long with absolute targets, SL only
59
+ npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --sl-price 120
60
+
46
61
  # Preview bracket setup
47
62
  npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
48
63
  `);
@@ -52,13 +67,27 @@ export interface BracketOptions {
52
67
  coin: string;
53
68
  side: 'buy' | 'sell';
54
69
  size: number;
55
- tpPct: number;
56
- slPct: number;
70
+ /** Take profit distance in % from entry. Optional if tpPrice/sl* given. */
71
+ tpPct?: number;
72
+ /** Stop loss distance in % from entry. Optional if slPrice/tp* given. */
73
+ slPct?: number;
74
+ /** Absolute take profit price (takes precedence over tpPct). */
75
+ tpPrice?: number;
76
+ /** Absolute stop loss trigger price (takes precedence over slPct). */
77
+ slPrice?: number;
57
78
  entryType?: 'market' | 'limit';
58
79
  entryPrice?: number;
59
80
  slippage?: number;
60
81
  entryTimeoutSec?: number;
61
82
  slSlippageBps?: number;
83
+ /** SL fires as a market trigger (default true); false = stop-limit. */
84
+ slMarket?: boolean;
85
+ /**
86
+ * Limit entries only: submit entry + TP/SL as one atomic normalTpsl batch
87
+ * (default true) — the exchange arms the exits when the entry fills, so the
88
+ * bracket survives this process exiting. false = legacy fill-watch path.
89
+ */
90
+ atomic?: boolean;
62
91
  leverage?: number;
63
92
  dryRun?: boolean;
64
93
  verbose?: boolean;
@@ -73,13 +102,29 @@ export interface BracketClient {
73
102
  getAllMids(): Promise<Record<string, string>>;
74
103
  marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
75
104
  limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
76
- tpslPair(coin: string, isBuy: boolean, size: number, takeProfitPrice: number, stopLossPrice: number, stopLossSlippageBps?: number, leverage?: number): Promise<OrderResponse>;
105
+ tpslOrders(coin: string, isBuy: boolean, size: number, opts?: {
106
+ takeProfitPrice?: number;
107
+ stopLossPrice?: number;
108
+ stopLossSlippageBps?: number;
109
+ stopLossIsMarket?: boolean;
110
+ grouping?: 'positionTpsl' | 'normalTpsl';
111
+ leverage?: number;
112
+ }): Promise<OrderResponse>;
113
+ bracketOrder(coin: string, isBuy: boolean, size: number, entryPrice: number, opts?: {
114
+ entryTif?: 'Gtc' | 'Alo';
115
+ takeProfitPrice?: number;
116
+ stopLossPrice?: number;
117
+ stopLossSlippageBps?: number;
118
+ stopLossIsMarket?: boolean;
119
+ leverage?: number;
120
+ }): Promise<OrderResponse>;
121
+ cancel(coin: string, oid: number): Promise<CancelResponse>;
77
122
  address: string;
78
123
  getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
79
124
  }
80
125
 
81
126
  export interface BracketResult {
82
- status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
127
+ status: 'dry' | 'armed' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
83
128
  entryPrice?: number;
84
129
  tpPrice?: number;
85
130
  slPrice?: number;
@@ -90,17 +135,55 @@ export interface BracketResult {
90
135
  reason?: string;
91
136
  }
92
137
 
138
+ interface ResolvedTargets {
139
+ tpPrice: number | null;
140
+ slPrice: number | null;
141
+ }
142
+
93
143
  export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
94
144
  const out = opts.output ?? ((line: string) => console.log(line));
95
145
  const entryType = opts.entryType ?? 'market';
146
+ const slMarket = opts.slMarket !== false;
147
+ const atomic = opts.atomic !== false;
96
148
  const isLong = opts.side === 'buy';
97
149
 
150
+ const hasTp = (opts.tpPct ?? 0) > 0 || (opts.tpPrice ?? 0) > 0;
151
+ const hasSl = (opts.slPct ?? 0) > 0 || (opts.slPrice ?? 0) > 0;
152
+
98
153
  if (opts.size <= 0 || isNaN(opts.size)) throw new Error('size must be positive');
99
- if (opts.tpPct <= 0 || opts.slPct <= 0) throw new Error('tp and sl must be positive percentages');
154
+ if (!hasTp && !hasSl) throw new Error('provide a TP target, an SL target, or both (tpPct/tpPrice/slPct/slPrice)');
100
155
  if (entryType === 'limit' && opts.entryPrice === undefined) {
101
156
  throw new Error('entryPrice is required for limit entry');
102
157
  }
103
158
 
159
+ // Resolve percent targets off the reference entry and validate every provided leg.
160
+ const resolveTargets = (entryPrice: number): ResolvedTargets => {
161
+ const tpPrice = !hasTp
162
+ ? null
163
+ : (opts.tpPrice ?? 0) > 0
164
+ ? opts.tpPrice!
165
+ : isLong
166
+ ? entryPrice * (1 + opts.tpPct! / 100)
167
+ : entryPrice * (1 - opts.tpPct! / 100);
168
+ const slPrice = !hasSl
169
+ ? null
170
+ : (opts.slPrice ?? 0) > 0
171
+ ? opts.slPrice!
172
+ : isLong
173
+ ? entryPrice * (1 - opts.slPct! / 100)
174
+ : entryPrice * (1 + opts.slPct! / 100);
175
+ if (tpPrice !== null && tpPrice <= 0) throw new Error('TP must resolve to a positive price (keep TP% under 100 on shorts)');
176
+ if (slPrice !== null && slPrice <= 0) throw new Error('SL must resolve to a positive price (keep SL% under 100)');
177
+ if (isLong) {
178
+ if (tpPrice !== null && tpPrice <= entryPrice) throw new Error('for a long, TP price must be above entry');
179
+ if (slPrice !== null && slPrice >= entryPrice) throw new Error('for a long, SL price must be below entry');
180
+ } else {
181
+ if (tpPrice !== null && tpPrice >= entryPrice) throw new Error('for a short, TP price must be below entry');
182
+ if (slPrice !== null && slPrice <= entryPrice) throw new Error('for a short, SL price must be above entry');
183
+ }
184
+ return { tpPrice, slPrice };
185
+ };
186
+
104
187
  const client = opts.client ?? getClient();
105
188
  if (opts.verbose) client.verbose = true;
106
189
 
@@ -113,14 +196,13 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
113
196
 
114
197
  const entry = entryType === 'limit' ? opts.entryPrice! : midPrice;
115
198
 
116
- let tpPrice = isLong
117
- ? entry * (1 + opts.tpPct / 100)
118
- : entry * (1 - opts.tpPct / 100);
119
- let slPrice = isLong
120
- ? entry * (1 - opts.slPct / 100)
121
- : entry * (1 + opts.slPct / 100);
199
+ // Validate targets against the pre-trade reference BEFORE the entry goes out,
200
+ // so a bad TP/SL never leaves an unprotected position behind.
201
+ let targets = resolveTargets(entry);
122
202
 
123
- const riskReward = opts.tpPct / opts.slPct;
203
+ const legsLabel = hasTp && hasSl ? 'TP/SL' : hasTp ? 'TP' : 'SL';
204
+ const tpDistPct = targets.tpPrice !== null ? Math.abs(targets.tpPrice - entry) / entry * 100 : null;
205
+ const slDistPct = targets.slPrice !== null ? Math.abs(targets.slPrice - entry) / entry * 100 : null;
124
206
  const notional = entry * opts.size;
125
207
 
126
208
  out('Bracket Plan');
@@ -128,29 +210,111 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
128
210
  out(`Coin: ${opts.coin}`);
129
211
  out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
130
212
  out(`Size: ${opts.size}`);
131
- out(`Entry Type: ${entryType.toUpperCase()}`);
213
+ out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
132
214
  out(`Current Mid: ${formatUsd(midPrice)}`);
133
215
  out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
134
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%)`);
135
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%)`);
136
- out(`Risk/Reward: 1:${riskReward.toFixed(2)}`);
216
+ if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)} (${tpDistPct!.toFixed(2)}% from entry)`);
217
+ if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slDistPct!.toFixed(2)}% from entry, ${slMarket ? 'market trigger' : 'stop-limit'})`);
218
+ if (tpDistPct !== null && slDistPct !== null && slDistPct > 0) {
219
+ out(`Risk/Reward: 1:${(tpDistPct / slDistPct).toFixed(2)}`);
220
+ }
137
221
  out(`Est. Notional: ${formatUsd(notional)}`);
138
222
 
139
- const potentialProfit = notional * (opts.tpPct / 100);
140
- const potentialLoss = notional * (opts.slPct / 100);
141
223
  out('\nRisk Analysis');
142
224
  out('-------------');
143
- out(`Potential Profit: ${formatUsd(potentialProfit)}`);
144
- out(`Potential Loss: ${formatUsd(potentialLoss)}`);
225
+ if (targets.tpPrice !== null) out(`Potential Profit: ${formatUsd(Math.abs(targets.tpPrice - entry) * opts.size)}`);
226
+ if (targets.slPrice !== null) out(`Potential Loss: ${formatUsd(Math.abs(entry - targets.slPrice) * opts.size)}`);
145
227
 
146
228
  if (opts.dryRun) {
147
229
  out('\n🔍 Dry run - bracket not executed');
148
- return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
230
+ return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
149
231
  }
150
232
 
151
233
  out('\nExecuting bracket...\n');
152
234
 
153
- // Step 1: Entry
235
+ // ── Atomic path: limit entry + TP/SL in one normalTpsl batch ──────────
236
+ // The exchange arms the exits when the entry fills (children come back as
237
+ // "waitingForFill"), so the bracket survives this process exiting.
238
+ if (entryType === 'limit' && atomic) {
239
+ out(`Step 1: Limit entry + ${legsLabel} (atomic normalTpsl batch)`);
240
+ const response = await client.bracketOrder(opts.coin, isLong, opts.size, entry, {
241
+ takeProfitPrice: targets.tpPrice ?? undefined,
242
+ stopLossPrice: targets.slPrice ?? undefined,
243
+ stopLossSlippageBps: opts.slSlippageBps,
244
+ stopLossIsMarket: slMarket,
245
+ leverage: opts.leverage,
246
+ });
247
+
248
+ if (response.status !== 'ok' || !response.response || typeof response.response !== 'object') {
249
+ const reason = typeof response.response === 'string' ? response.response : 'Unknown error';
250
+ out(` ❌ Bracket failed: ${reason}`);
251
+ return { status: 'entry_failed', reason };
252
+ }
253
+
254
+ const statuses = response.response.data.statuses.map(parseOrderStatus);
255
+ const entryStatus = statuses[0];
256
+ const childStatuses = statuses.slice(1);
257
+ const failed = statuses.find((s) => s.kind === 'error' || s.kind === 'unknown');
258
+ if (failed) {
259
+ // Roll back anything that landed so no half-armed bracket is left behind.
260
+ const restingOids = statuses.flatMap((s) => (s.kind === 'resting' ? [s.oid] : []));
261
+ for (const oid of restingOids) {
262
+ try { await client.cancel(opts.coin, oid); } catch { /* may have filled */ }
263
+ }
264
+ const reason = failed.kind === 'error' ? failed.error : `Unexpected order status: ${JSON.stringify(failed)}`;
265
+ out(` ❌ Bracket rejected: ${reason}`);
266
+ if (restingOids.length) out(` Cancelled ${restingOids.length} resting order(s).`);
267
+ return { status: 'entry_failed', reason };
268
+ }
269
+
270
+ let entryOid: number | null = null;
271
+ let filledEntry: { size: number; avgPx: number } | null = null;
272
+ if (entryStatus.kind === 'resting') {
273
+ entryOid = entryStatus.oid;
274
+ out(` ✅ Entry resting @ ${formatUsd(entry)} (OID: ${entryOid})`);
275
+ } else if (entryStatus.kind === 'filled') {
276
+ entryOid = entryStatus.oid;
277
+ filledEntry = { size: entryStatus.totalSz, avgPx: entryStatus.avgPx };
278
+ out(` ✅ Entry filled immediately: ${entryStatus.totalSz} @ ${formatUsd(entryStatus.avgPx)}`);
279
+ }
280
+
281
+ let tpOid: number | null = null;
282
+ let slOid: number | null = null;
283
+ let childIdx = 0;
284
+ for (const label of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
285
+ if (!label) continue;
286
+ const child = childStatuses[childIdx++];
287
+ if (!child) continue;
288
+ if (child.kind === 'waiting') {
289
+ out(` ✅ ${label} armed — activates when the entry fills (${child.state})`);
290
+ } else if (child.kind === 'resting') {
291
+ if (label === 'TP') tpOid = child.oid; else slOid = child.oid;
292
+ out(` ✅ ${label} trigger live (OID: ${child.oid})`);
293
+ }
294
+ }
295
+
296
+ out('\n========== Bracket Summary ==========');
297
+ out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
298
+ out(`Entry: ${formatUsd(filledEntry?.avgPx ?? entry)}${filledEntry ? '' : ' (resting)'}`);
299
+ if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)}`);
300
+ if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'})`);
301
+ out(filledEntry
302
+ ? `\n✅ Bracket complete! ${legsLabel} triggers are live.`
303
+ : `\n✅ Bracket armed! ${legsLabel} activates server-side when the entry fills.`);
304
+
305
+ return {
306
+ status: filledEntry ? 'complete' : 'armed',
307
+ entryPrice: filledEntry?.avgPx ?? entry,
308
+ tpPrice: targets.tpPrice ?? undefined,
309
+ slPrice: targets.slPrice ?? undefined,
310
+ tpOid,
311
+ slOid,
312
+ entryOid,
313
+ protectedSize: filledEntry?.size ?? opts.size,
314
+ };
315
+ }
316
+
317
+ // ── Fill-first path: market entry, or limit entry with --no-atomic ────
154
318
  out('Step 1: Entry order');
155
319
  let actualEntry = entry;
156
320
  let entryOid: number | null = null;
@@ -237,72 +401,80 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
237
401
  return { status: 'entry_failed', reason: 'No confirmed fill size' };
238
402
  }
239
403
 
240
- // Recalculate TP/SL based on actual entry
241
- if (isLong) {
242
- tpPrice = actualEntry * (1 + opts.tpPct / 100);
243
- slPrice = actualEntry * (1 - opts.slPct / 100);
244
- } else {
245
- tpPrice = actualEntry * (1 - opts.tpPct / 100);
246
- slPrice = actualEntry * (1 + opts.slPct / 100);
404
+ // Re-resolve off the actual fill price; if the fill drifted past a fixed
405
+ // target, report it instead of leaving a half-armed bracket silently.
406
+ try {
407
+ targets = resolveTargets(actualEntry);
408
+ } catch (error) {
409
+ const reason = error instanceof Error ? error.message : String(error);
410
+ out(`\n❌ Entry filled @ ${formatUsd(actualEntry)}, but ${legsLabel} could not be armed: ${reason}`);
411
+ out('⚠️ Position is OPEN and UNPROTECTED - set TP/SL manually (openbroker set-tpsl).');
412
+ return { status: 'partial', entryPrice: actualEntry, protectedSize: 0, reason };
247
413
  }
248
414
 
249
415
  await sleep(500);
250
416
 
251
- // Step 2: Paired TP/SL trigger orders
252
- out('\nStep 2: Paired TP/SL trigger orders');
417
+ // Step 2: TP/SL triggers tied to the now-open position (positionTpsl) —
418
+ // OCO between themselves and cancelled by the venue if the position closes.
419
+ out(`\nStep 2: Position ${legsLabel} trigger orders`);
253
420
  const exitSide = !isLong;
254
- const pairResponse = await client.tpslPair(
255
- opts.coin,
256
- exitSide,
257
- filledSize,
258
- tpPrice,
259
- slPrice,
260
- opts.slSlippageBps,
261
- opts.leverage,
262
- );
421
+ const pairResponse = await client.tpslOrders(opts.coin, exitSide, filledSize, {
422
+ takeProfitPrice: targets.tpPrice ?? undefined,
423
+ stopLossPrice: targets.slPrice ?? undefined,
424
+ stopLossSlippageBps: opts.slSlippageBps,
425
+ stopLossIsMarket: slMarket,
426
+ grouping: 'positionTpsl',
427
+ leverage: opts.leverage,
428
+ });
263
429
 
264
430
  let tpOid: number | null = null;
265
431
  let slOid: number | null = null;
432
+ let exitErrors = 0;
266
433
  if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
267
- const [tpStatus, slStatus] = pairResponse.response.data.statuses;
268
- if (tpStatus?.resting) {
269
- tpOid = tpStatus.resting.oid;
270
- out(` ✅ TP trigger placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
271
- } else if (tpStatus?.error) {
272
- out(` ❌ TP failed: ${tpStatus.error}`);
273
- } else {
274
- out(` ⚠️ TP status: ${JSON.stringify(tpStatus)}`);
275
- }
276
-
277
- if (slStatus?.resting) {
278
- slOid = slStatus.resting.oid;
279
- out(` ✅ SL trigger placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
280
- } else if (slStatus?.error) {
281
- out(` ❌ SL failed: ${slStatus.error}`);
282
- } else {
283
- out(` ⚠️ SL status: ${JSON.stringify(slStatus)}`);
434
+ const statuses = pairResponse.response.data.statuses.map(parseOrderStatus);
435
+ let idx = 0;
436
+ for (const leg of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
437
+ if (!leg) continue;
438
+ const status = statuses[idx++];
439
+ const price = leg === 'TP' ? targets.tpPrice! : targets.slPrice!;
440
+ if (status?.kind === 'resting') {
441
+ if (leg === 'TP') tpOid = status.oid; else slOid = status.oid;
442
+ out(` ✅ ${leg} trigger placed @ ${formatUsd(price)} (OID: ${status.oid})`);
443
+ } else if (status?.kind === 'waiting') {
444
+ out(` ✅ ${leg} trigger armed @ ${formatUsd(price)} (${status.state})`);
445
+ } else if (status?.kind === 'error') {
446
+ exitErrors++;
447
+ out(` ❌ ${leg} failed: ${status.error}`);
448
+ } else {
449
+ exitErrors++;
450
+ out(` ⚠️ ${leg} status: ${JSON.stringify(status)}`);
451
+ }
284
452
  }
285
453
  } else {
454
+ exitErrors++;
286
455
  const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
287
- out(` ❌ TP/SL pair failed: ${reason}`);
456
+ out(` ❌ ${legsLabel} orders failed: ${reason}`);
288
457
  }
289
458
 
290
459
  out('\n========== Bracket Summary ==========');
291
460
  out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
292
461
  out(`Entry: ${formatUsd(actualEntry)}`);
293
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%) - Trigger order`);
294
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%) - Trigger order`);
295
- if (tpOid && slOid) {
296
- out(`\n✅ Bracket complete! TP and SL are linked trigger orders.`);
462
+ if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)} - Trigger order`);
463
+ if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'}) - Trigger order`);
464
+ if (exitErrors === 0) {
465
+ out(`\n✅ Bracket complete! ${legsLabel} triggers track the position (positionTpsl).`);
466
+ } else {
467
+ out('\n⚠️ Position is open but not fully protected - set the missing trigger manually.');
297
468
  }
298
469
 
299
470
  return {
300
- status: tpOid && slOid ? 'complete' : 'partial',
471
+ status: exitErrors === 0 ? 'complete' : 'partial',
301
472
  entryPrice: actualEntry,
302
- tpPrice,
303
- slPrice,
473
+ tpPrice: targets.tpPrice ?? undefined,
474
+ slPrice: targets.slPrice ?? undefined,
304
475
  tpOid,
305
476
  slOid,
477
+ entryOid,
306
478
  protectedSize: filledSize,
307
479
  };
308
480
  }
@@ -315,15 +487,18 @@ async function main() {
315
487
  const size = parseFloat(args.size as string);
316
488
  const entryType = (args.entry as string || 'market') as 'market' | 'limit';
317
489
  const entryPrice = args.price ? parseFloat(args.price as string) : undefined;
318
- const tpPct = parseFloat(args.tp as string);
319
- const slPct = parseFloat(args.sl as string);
490
+ const tpPct = args.tp ? parseFloat(args.tp as string) : undefined;
491
+ const slPct = args.sl ? parseFloat(args.sl as string) : undefined;
492
+ const tpPrice = args['tp-price'] ? parseFloat(args['tp-price'] as string) : undefined;
493
+ const slPrice = args['sl-price'] ? parseFloat(args['sl-price'] as string) : undefined;
320
494
  const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
321
495
  const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout'] as string) : undefined;
322
496
  const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : undefined;
323
497
  const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
324
498
  const dryRun = args.dry as boolean;
325
499
 
326
- if (!coin || !side || isNaN(size) || isNaN(tpPct) || isNaN(slPct)) {
500
+ const hasTarget = [tpPct, slPct, tpPrice, slPrice].some((v) => v !== undefined && !isNaN(v));
501
+ if (!coin || !side || isNaN(size) || !hasTarget) {
327
502
  printUsage();
328
503
  process.exit(1);
329
504
  }
@@ -339,11 +514,15 @@ async function main() {
339
514
  size,
340
515
  tpPct,
341
516
  slPct,
517
+ tpPrice,
518
+ slPrice,
342
519
  entryType,
343
520
  entryPrice,
344
521
  slippage,
345
522
  entryTimeoutSec,
346
523
  slSlippageBps,
524
+ slMarket: !(args['sl-limit'] as boolean),
525
+ atomic: !(args['no-atomic'] as boolean),
347
526
  leverage,
348
527
  dryRun,
349
528
  verbose: args.verbose as boolean,
@@ -4,7 +4,7 @@
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
6
  import type { OrderResponse, CancelResponse, OpenOrder } from '../core/types.js';
7
- import { formatUsd, parseArgs, sleep } from '../core/utils.js';
7
+ import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs, sleep } from '../core/utils.js';
8
8
  import { UserFillWatcher, type FillWatcher } from './execution.js';
9
9
 
10
10
  function printUsage() {
@@ -68,7 +68,7 @@ export interface ChaseClient {
68
68
  }
69
69
 
70
70
  export interface ChaseResult {
71
- status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded';
71
+ status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional';
72
72
  iterations: number;
73
73
  durationSec: number;
74
74
  startMid: number;
@@ -99,6 +99,10 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
99
99
  const startMid = parseFloat(mids[opts.coin]);
100
100
  if (!startMid) throw new Error(`No market data for ${opts.coin}`);
101
101
 
102
+ const startBelowMinimum = !opts.reduceOnly && opts.size * startMid < MIN_ORDER_NOTIONAL_USD;
103
+ const minNotionalMsg = `Chase size (~$${(opts.size * startMid).toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`;
104
+ if (startBelowMinimum && !opts.dryRun) throw new Error(minNotionalMsg);
105
+
102
106
  const maxChasePrice = isBuy
103
107
  ? startMid * (1 + maxChaseBps / 10000)
104
108
  : startMid * (1 - maxChaseBps / 10000);
@@ -116,6 +120,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
116
120
  out(`Order Type: ALO (post-only)`);
117
121
 
118
122
  if (opts.dryRun) {
123
+ if (startBelowMinimum) out(`\n⚠️ ${minNotionalMsg}`);
119
124
  out('\n🔍 Dry run - chase not started');
120
125
  return { status: 'dry', iterations: 0, durationSec: 0, startMid, endMid: startMid };
121
126
  }
@@ -128,7 +133,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
128
133
  let remainingSize = opts.size;
129
134
  let iteration = 0;
130
135
  let filled = false;
131
- let exitReason: 'filled' | 'timeout' | 'max_chase_exceeded' = 'timeout';
136
+ let exitReason: 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional' = 'timeout';
132
137
  const accountedFills = new Map<number, number>();
133
138
  const ownsFillWatcher = !opts.fillWatcher;
134
139
  const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: startTime });
@@ -160,9 +165,20 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
160
165
  }
161
166
  }
162
167
 
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}`);
168
+ // A transient /info failure mid-chase must not kill the execution (and
169
+ // strand the resting order) — wait a tick and retry instead.
170
+ let currentMid = NaN;
171
+ try {
172
+ const currentMids = await client.getAllMids();
173
+ currentMid = parseFloat(currentMids[opts.coin]);
174
+ } catch {
175
+ /* transient failure — retry next tick */
176
+ }
177
+ if (!Number.isFinite(currentMid) || currentMid <= 0) {
178
+ out(`[${iteration}] ⏳ No live price — retrying...`);
179
+ await sleep(intervalMs);
180
+ continue;
181
+ }
166
182
 
167
183
  if (isBuy && currentMid > maxChasePrice) {
168
184
  out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
@@ -206,6 +222,14 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
206
222
  break;
207
223
  }
208
224
 
225
+ // The dust remainder of a partial fill can drop below the exchange
226
+ // minimum — placing it would just error out on every tick.
227
+ if (!opts.reduceOnly && remainingSize * currentMid < MIN_ORDER_NOTIONAL_USD) {
228
+ out(`\n⚠️ Remaining size (~$${(remainingSize * currentMid).toFixed(2)}) fell below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`);
229
+ exitReason = 'min_notional';
230
+ break;
231
+ }
232
+
209
233
  out(`[${iteration}] Mid: ${formatUsd(currentMid)} → Order: ${formatUsd(orderPrice)} x ${remainingSize.toFixed(6)}...`);
210
234
 
211
235
  const response = await client.limitOrder(opts.coin, isBuy, remainingSize, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
@@ -226,10 +250,18 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
226
250
  break;
227
251
  }
228
252
  } else if (status?.error) {
229
- out(`❌ ${status.error}`);
253
+ if (/post.?only|immediately match/i.test(status.error)) {
254
+ // Stale mid crossed the book — the fast-market condition chase
255
+ // exists for. Reprice on the next tick instead of dying.
256
+ out(`↩️ Quote would cross the book — repricing`);
257
+ lastPrice = null;
258
+ } else {
259
+ throw new Error(status.error);
260
+ }
230
261
  }
231
262
  } else {
232
- out(`❌ Failed`);
263
+ const reason = typeof response.response === 'string' ? response.response : 'Order rejected';
264
+ throw new Error(reason);
233
265
  }
234
266
  } else {
235
267
  if (currentOid !== null) {
@@ -261,23 +293,24 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
261
293
  await sleep(intervalMs);
262
294
  }
263
295
  } finally {
264
- if (ownsFillWatcher) await fillWatcher.stop();
265
- }
266
-
267
- if (currentOid !== null && !filled) {
268
- applyFills(currentOid);
269
- out(`\nCancelling unfilled order...`);
270
- try {
271
- await client.cancel(opts.coin, currentOid);
272
- out(`✅ Cancelled`);
273
- } catch {
274
- out(`⚠️ Could not cancel (may have filled)`);
275
- }
276
- applyFills(currentOid);
277
- if (remainingSize <= opts.size * 0.001) {
278
- filled = true;
279
- exitReason = 'filled';
296
+ // Always pull the working order before returning — even when the loop
297
+ // throws — so an error never strands a live resting quote at a stale price.
298
+ if (currentOid !== null && !filled) {
299
+ applyFills(currentOid);
300
+ out(`\nCancelling unfilled order...`);
301
+ try {
302
+ await client.cancel(opts.coin, currentOid);
303
+ out(`✅ Cancelled`);
304
+ } catch {
305
+ out(`⚠️ Could not cancel (may have filled)`);
306
+ }
307
+ applyFills(currentOid);
308
+ if (remainingSize <= opts.size * 0.001) {
309
+ filled = true;
310
+ exitReason = 'filled';
311
+ }
280
312
  }
313
+ if (ownsFillWatcher) await fillWatcher.stop();
281
314
  }
282
315
 
283
316
  const elapsed = (Date.now() - startTime) / 1000;
@@ -4,7 +4,7 @@
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
6
  import type { CancelResponse, OrderResponse } from '../core/types.js';
7
- import { formatUsd, parseArgs } from '../core/utils.js';
7
+ import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs } from '../core/utils.js';
8
8
 
9
9
  function printUsage() {
10
10
  console.log(`
@@ -194,11 +194,20 @@ export async function runScale(opts: ScaleOptions): Promise<ScaleResult> {
194
194
  );
195
195
  }
196
196
 
197
+ // Every level must clear the exchange's minimum notional (waived for
198
+ // reduce-only) or it gets a silent per-level rejection.
199
+ const thinnest = levels.reduce((min, level) => Math.min(min, level.size * level.price), Infinity);
200
+ const belowMinimum = !reduceOnly && thinnest < MIN_ORDER_NOTIONAL_USD;
201
+ const minNotionalMsg = `smallest scale level (~$${thinnest.toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — use fewer levels or a larger size`;
202
+
197
203
  if (opts.dryRun) {
204
+ if (belowMinimum) out(`\n⚠️ The ${minNotionalMsg}`);
198
205
  out('\n🔍 Dry run - orders not placed');
199
206
  return { status: 'dry', levels, restingOids: [], filledOids: [], errors: [], rolledBack: false };
200
207
  }
201
208
 
209
+ if (belowMinimum) throw new Error(`the ${minNotionalMsg}`);
210
+
202
211
  out('\nPlacing ladder as a bulk order...\n');
203
212
 
204
213
  const response = await client.bulkOrder(