openbroker 1.9.5 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +6 -4
  3. package/SKILL.md +14 -5
  4. package/dist/core/client.d.ts +63 -3
  5. package/dist/core/client.d.ts.map +1 -1
  6. package/dist/core/client.js +246 -11
  7. package/dist/core/utils.d.ts +29 -0
  8. package/dist/core/utils.d.ts.map +1 -1
  9. package/dist/core/utils.js +27 -0
  10. package/dist/lib.d.ts +4 -1
  11. package/dist/lib.d.ts.map +1 -1
  12. package/dist/lib.js +2 -1
  13. package/dist/operations/advanced-orders.test.d.ts +2 -0
  14. package/dist/operations/advanced-orders.test.d.ts.map +1 -0
  15. package/dist/operations/advanced-orders.test.js +478 -0
  16. package/dist/operations/bracket.d.ts +55 -3
  17. package/dist/operations/bracket.d.ts.map +1 -1
  18. package/dist/operations/bracket.js +324 -117
  19. package/dist/operations/chase.d.ts +20 -1
  20. package/dist/operations/chase.d.ts.map +1 -1
  21. package/dist/operations/chase.js +169 -67
  22. package/dist/operations/execution.d.ts +53 -0
  23. package/dist/operations/execution.d.ts.map +1 -0
  24. package/dist/operations/execution.js +106 -0
  25. package/dist/operations/scale.d.ts +50 -1
  26. package/dist/operations/scale.d.ts.map +1 -1
  27. package/dist/operations/scale.js +152 -105
  28. package/dist/operations/set-tpsl.js +69 -57
  29. package/dist/operations/trigger-order.js +18 -6
  30. package/dist/operations/twap.js +13 -1
  31. package/package.json +3 -2
  32. package/scripts/core/client.ts +320 -10
  33. package/scripts/core/utils.ts +37 -0
  34. package/scripts/lib.ts +6 -0
  35. package/scripts/operations/advanced-orders.test.ts +542 -0
  36. package/scripts/operations/bracket.ts +350 -115
  37. package/scripts/operations/chase.ts +183 -73
  38. package/scripts/operations/execution.ts +138 -0
  39. package/scripts/operations/scale.ts +195 -131
  40. package/scripts/operations/set-tpsl.ts +62 -57
  41. package/scripts/operations/trigger-order.ts +20 -6
  42. package/scripts/operations/twap.ts +14 -1
@@ -3,7 +3,9 @@
3
3
 
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
- 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
+ import { UserFillWatcher, type FillWatcher } from './execution.js';
7
9
 
8
10
  function printUsage() {
9
11
  console.log(`
@@ -24,10 +26,24 @@ Options:
24
26
  --price Entry price (required if --entry limit)
25
27
  --tp Take profit distance in % from entry
26
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)
27
31
  --slippage Slippage for market entry in bps (default: 50)
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.
28
41
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
29
42
  --dry Dry run - show bracket plan without executing
30
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
+
31
47
  Take Profit / Stop Loss:
32
48
  For LONG (buy): TP is above entry, SL is below entry
33
49
  For SHORT (sell): TP is below entry, SL is above entry
@@ -36,9 +52,12 @@ Examples:
36
52
  # Long ETH with 3% take profit and 1.5% stop loss
37
53
  npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
38
54
 
39
- # 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)
40
56
  npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
41
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
+
42
61
  # Preview bracket setup
43
62
  npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
44
63
  `);
@@ -48,41 +67,124 @@ export interface BracketOptions {
48
67
  coin: string;
49
68
  side: 'buy' | 'sell';
50
69
  size: number;
51
- tpPct: number;
52
- 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;
53
78
  entryType?: 'market' | 'limit';
54
79
  entryPrice?: number;
55
80
  slippage?: number;
81
+ entryTimeoutSec?: number;
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;
56
91
  leverage?: number;
57
92
  dryRun?: boolean;
58
93
  verbose?: boolean;
94
+ client?: BracketClient;
95
+ fillWatcher?: FillWatcher;
59
96
  /** Receives each output line. Defaults to console.log. */
60
97
  output?: (line: string) => void;
61
98
  }
62
99
 
100
+ export interface BracketClient {
101
+ verbose: boolean;
102
+ getAllMids(): Promise<Record<string, string>>;
103
+ marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
104
+ limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, 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>;
122
+ address: string;
123
+ getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
124
+ }
125
+
63
126
  export interface BracketResult {
64
- status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
127
+ status: 'dry' | 'armed' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
65
128
  entryPrice?: number;
66
129
  tpPrice?: number;
67
130
  slPrice?: number;
68
131
  tpOid?: number | null;
69
132
  slOid?: number | null;
70
133
  entryOid?: number | null;
134
+ protectedSize?: number;
71
135
  reason?: string;
72
136
  }
73
137
 
138
+ interface ResolvedTargets {
139
+ tpPrice: number | null;
140
+ slPrice: number | null;
141
+ }
142
+
74
143
  export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
75
144
  const out = opts.output ?? ((line: string) => console.log(line));
76
145
  const entryType = opts.entryType ?? 'market';
146
+ const slMarket = opts.slMarket !== false;
147
+ const atomic = opts.atomic !== false;
77
148
  const isLong = opts.side === 'buy';
78
149
 
150
+ const hasTp = (opts.tpPct ?? 0) > 0 || (opts.tpPrice ?? 0) > 0;
151
+ const hasSl = (opts.slPct ?? 0) > 0 || (opts.slPrice ?? 0) > 0;
152
+
79
153
  if (opts.size <= 0 || isNaN(opts.size)) throw new Error('size must be positive');
80
- 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)');
81
155
  if (entryType === 'limit' && opts.entryPrice === undefined) {
82
156
  throw new Error('entryPrice is required for limit entry');
83
157
  }
84
158
 
85
- const client = getClient();
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
+
187
+ const client = opts.client ?? getClient();
86
188
  if (opts.verbose) client.verbose = true;
87
189
 
88
190
  out('Open Broker - Bracket Order');
@@ -94,14 +196,13 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
94
196
 
95
197
  const entry = entryType === 'limit' ? opts.entryPrice! : midPrice;
96
198
 
97
- let tpPrice = isLong
98
- ? entry * (1 + opts.tpPct / 100)
99
- : entry * (1 - opts.tpPct / 100);
100
- let slPrice = isLong
101
- ? entry * (1 - opts.slPct / 100)
102
- : 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);
103
202
 
104
- 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;
105
206
  const notional = entry * opts.size;
106
207
 
107
208
  out('Bracket Plan');
@@ -109,149 +210,272 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
109
210
  out(`Coin: ${opts.coin}`);
110
211
  out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
111
212
  out(`Size: ${opts.size}`);
112
- out(`Entry Type: ${entryType.toUpperCase()}`);
213
+ out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
113
214
  out(`Current Mid: ${formatUsd(midPrice)}`);
114
215
  out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
115
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%)`);
116
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%)`);
117
- 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
+ }
118
221
  out(`Est. Notional: ${formatUsd(notional)}`);
119
222
 
120
- const potentialProfit = notional * (opts.tpPct / 100);
121
- const potentialLoss = notional * (opts.slPct / 100);
122
223
  out('\nRisk Analysis');
123
224
  out('-------------');
124
- out(`Potential Profit: ${formatUsd(potentialProfit)}`);
125
- 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)}`);
126
227
 
127
228
  if (opts.dryRun) {
128
229
  out('\n🔍 Dry run - bracket not executed');
129
- return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
230
+ return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
130
231
  }
131
232
 
132
233
  out('\nExecuting bracket...\n');
133
234
 
134
- // Step 1: Entry
135
- out('Step 1: Entry order');
136
- let actualEntry = entry;
137
- let entryOid: number | null = null;
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
+ });
138
247
 
139
- if (entryType === 'market') {
140
- const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
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
+ }
141
253
 
142
- if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
143
- const status = entryResponse.response.data.statuses[0];
144
- if (status?.filled) {
145
- actualEntry = parseFloat(status.filled.avgPx);
146
- out(` ✅ Filled @ ${formatUsd(actualEntry)}`);
147
- } else if (status?.error) {
148
- out(` ❌ Entry failed: ${status.error}`);
149
- out('\n⚠️ Bracket aborted - no position opened');
150
- return { status: 'entry_failed', reason: status.error };
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 */ }
151
263
  }
152
- } else {
153
- const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
154
- out(` Entry failed: ${reason}`);
155
- out('\n⚠️ Bracket aborted - no position opened');
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).`);
156
267
  return { status: 'entry_failed', reason };
157
268
  }
158
- } else {
159
- const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
160
-
161
- if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
162
- const status = entryResponse.response.data.statuses[0];
163
- if (status?.resting) {
164
- entryOid = status.resting.oid;
165
- out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
166
- out(` ⏳ Waiting for fill before placing TP/SL...`);
167
- out('\n⚠️ Note: TP/SL will be placed after entry fills. Monitor manually or use a strategy script.');
168
- return { status: 'limit_resting', entryOid, entryPrice: entry };
169
- } else if (status?.filled) {
170
- actualEntry = parseFloat(status.filled.avgPx);
171
- out(` ✅ Filled immediately @ ${formatUsd(actualEntry)}`);
172
- } else if (status?.error) {
173
- out(` ❌ Entry failed: ${status.error}`);
174
- return { status: 'entry_failed', reason: status.error };
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})`);
175
293
  }
176
- } else {
177
- out(` ❌ Entry failed`);
178
- return { status: 'entry_failed', reason: 'Unknown error' };
179
294
  }
180
- }
181
295
 
182
- // Recalculate TP/SL based on actual entry
183
- if (isLong) {
184
- tpPrice = actualEntry * (1 + opts.tpPct / 100);
185
- slPrice = actualEntry * (1 - opts.slPct / 100);
186
- } else {
187
- tpPrice = actualEntry * (1 - opts.tpPct / 100);
188
- slPrice = actualEntry * (1 + opts.slPct / 100);
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
+ };
189
315
  }
190
316
 
191
- await sleep(500);
317
+ // ── Fill-first path: market entry, or limit entry with --no-atomic ────
318
+ out('Step 1: Entry order');
319
+ let actualEntry = entry;
320
+ let entryOid: number | null = null;
321
+ let filledSize = 0;
322
+ const ownsFillWatcher = !opts.fillWatcher;
323
+ const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: Date.now() });
192
324
 
193
- // Step 2: Take Profit (trigger order)
194
- out('\nStep 2: Take Profit order (trigger)');
195
- const tpSide = !isLong;
196
- const tpResponse = await client.takeProfit(opts.coin, tpSide, opts.size, tpPrice);
325
+ await fillWatcher.start();
197
326
 
198
- let tpOid: number | null = null;
199
- if (tpResponse.status === 'ok' && tpResponse.response && typeof tpResponse.response === 'object') {
200
- const status = tpResponse.response.data.statuses[0];
201
- if (status?.resting) {
202
- tpOid = status.resting.oid;
203
- out(` ✅ TP trigger placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
204
- } else if (status?.error) {
205
- out(` ❌ TP failed: ${status.error}`);
327
+ try {
328
+ if (entryType === 'market') {
329
+ const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
330
+
331
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
332
+ const status = entryResponse.response.data.statuses[0];
333
+ if (status?.filled) {
334
+ actualEntry = parseFloat(status.filled.avgPx);
335
+ filledSize = parseFloat(status.filled.totalSz);
336
+ out(` ✅ Filled ${filledSize} @ ${formatUsd(actualEntry)}`);
337
+ } else if (status?.error) {
338
+ out(` ❌ Entry failed: ${status.error}`);
339
+ out('\n⚠️ Bracket aborted - no position opened');
340
+ return { status: 'entry_failed', reason: status.error };
341
+ } else {
342
+ out(` ❌ Entry failed: unexpected response`);
343
+ out('\n⚠️ Bracket aborted - no confirmed position opened');
344
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
345
+ }
346
+ } else {
347
+ const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
348
+ out(` ❌ Entry failed: ${reason}`);
349
+ out('\n⚠️ Bracket aborted - no position opened');
350
+ return { status: 'entry_failed', reason };
351
+ }
206
352
  } else {
207
- out(` ⚠️ TP status: ${JSON.stringify(status)}`);
353
+ const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
354
+
355
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
356
+ const status = entryResponse.response.data.statuses[0];
357
+ if (status?.resting) {
358
+ entryOid = status.resting.oid;
359
+ const entryTimeoutSec = opts.entryTimeoutSec ?? 300;
360
+ out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
361
+
362
+ if (entryTimeoutSec <= 0) {
363
+ out(` ⏳ Entry resting; TP/SL not armed until a fill is confirmed.`);
364
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
365
+ }
366
+
367
+ out(` ⏳ Waiting up to ${entryTimeoutSec}s for fill confirmation...`);
368
+ const fill = await fillWatcher.waitForFill(entryOid, opts.size, entryTimeoutSec * 1000, { coin: opts.coin });
369
+ if (fill.size <= 0) {
370
+ out(` ⚠️ Entry still resting after ${entryTimeoutSec}s; TP/SL not armed.`);
371
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
372
+ }
373
+ filledSize = Math.min(fill.size, opts.size);
374
+ actualEntry = fill.avgPrice ?? entry;
375
+ out(` ✅ Fill confirmed: ${filledSize} @ ${formatUsd(actualEntry)}`);
376
+ if (filledSize < opts.size * 0.999) {
377
+ out(` ⚠️ Partial entry fill; arming TP/SL for filled size only.`);
378
+ }
379
+ } else if (status?.filled) {
380
+ actualEntry = parseFloat(status.filled.avgPx);
381
+ filledSize = parseFloat(status.filled.totalSz);
382
+ out(` ✅ Filled immediately ${filledSize} @ ${formatUsd(actualEntry)}`);
383
+ } else if (status?.error) {
384
+ out(` ❌ Entry failed: ${status.error}`);
385
+ return { status: 'entry_failed', reason: status.error };
386
+ } else {
387
+ out(` ❌ Entry failed: unexpected response`);
388
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
389
+ }
390
+ } else {
391
+ out(` ❌ Entry failed`);
392
+ return { status: 'entry_failed', reason: 'Unknown error' };
393
+ }
208
394
  }
209
- } else {
210
- const reason = typeof tpResponse.response === 'string' ? tpResponse.response : 'Unknown error';
211
- out(` ❌ TP failed: ${reason}`);
395
+ } finally {
396
+ if (ownsFillWatcher) await fillWatcher.stop();
397
+ }
398
+
399
+ if (!Number.isFinite(filledSize) || filledSize <= 0) {
400
+ out('\n⚠️ Bracket aborted - no confirmed fill size');
401
+ return { status: 'entry_failed', reason: 'No confirmed fill size' };
402
+ }
403
+
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 };
212
413
  }
213
414
 
214
415
  await sleep(500);
215
416
 
216
- // Step 3: Stop Loss (trigger order)
217
- out('\nStep 3: Stop Loss order (trigger)');
218
- const slSide = !isLong;
219
- const slResponse = await client.stopLoss(opts.coin, slSide, opts.size, slPrice);
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`);
420
+ const exitSide = !isLong;
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
+ });
220
429
 
430
+ let tpOid: number | null = null;
221
431
  let slOid: number | null = null;
222
- if (slResponse.status === 'ok' && slResponse.response && typeof slResponse.response === 'object') {
223
- const status = slResponse.response.data.statuses[0];
224
- if (status?.resting) {
225
- slOid = status.resting.oid;
226
- out(` ✅ SL trigger placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
227
- } else if (status?.error) {
228
- out(` ❌ SL failed: ${status.error}`);
229
- } else {
230
- out(` ⚠️ SL status: ${JSON.stringify(status)}`);
432
+ let exitErrors = 0;
433
+ if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
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
+ }
231
452
  }
232
453
  } else {
233
- const reason = typeof slResponse.response === 'string' ? slResponse.response : 'Unknown error';
234
- out(` ❌ SL failed: ${reason}`);
454
+ exitErrors++;
455
+ const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
456
+ out(` ❌ ${legsLabel} orders failed: ${reason}`);
235
457
  }
236
458
 
237
459
  out('\n========== Bracket Summary ==========');
238
- out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
460
+ out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
239
461
  out(`Entry: ${formatUsd(actualEntry)}`);
240
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%) - Trigger order`);
241
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%) - Trigger order`);
242
- if (tpOid && slOid) {
243
- out(`\n✅ Bracket complete! TP and SL are trigger orders.`);
244
- out(` They will only execute when price reaches trigger level.`);
245
- out(` When one fills, cancel the other manually.`);
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.');
246
468
  }
247
469
 
248
470
  return {
249
- status: tpOid && slOid ? 'complete' : 'partial',
471
+ status: exitErrors === 0 ? 'complete' : 'partial',
250
472
  entryPrice: actualEntry,
251
- tpPrice,
252
- slPrice,
473
+ tpPrice: targets.tpPrice ?? undefined,
474
+ slPrice: targets.slPrice ?? undefined,
253
475
  tpOid,
254
476
  slOid,
477
+ entryOid,
478
+ protectedSize: filledSize,
255
479
  };
256
480
  }
257
481
 
@@ -263,13 +487,18 @@ async function main() {
263
487
  const size = parseFloat(args.size as string);
264
488
  const entryType = (args.entry as string || 'market') as 'market' | 'limit';
265
489
  const entryPrice = args.price ? parseFloat(args.price as string) : undefined;
266
- const tpPct = parseFloat(args.tp as string);
267
- 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;
268
494
  const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
495
+ const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout'] as string) : undefined;
496
+ const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : undefined;
269
497
  const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
270
498
  const dryRun = args.dry as boolean;
271
499
 
272
- 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) {
273
502
  printUsage();
274
503
  process.exit(1);
275
504
  }
@@ -285,9 +514,15 @@ async function main() {
285
514
  size,
286
515
  tpPct,
287
516
  slPct,
517
+ tpPrice,
518
+ slPrice,
288
519
  entryType,
289
520
  entryPrice,
290
521
  slippage,
522
+ entryTimeoutSec,
523
+ slSlippageBps,
524
+ slMarket: !(args['sl-limit'] as boolean),
525
+ atomic: !(args['no-atomic'] as boolean),
291
526
  leverage,
292
527
  dryRun,
293
528
  verbose: args.verbose as boolean,