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
@@ -2,7 +2,8 @@
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
+ import { UserFillWatcher } from './execution.js';
6
7
  function printUsage() {
7
8
  console.log(`
8
9
  Open Broker - Bracket Order
@@ -22,10 +23,24 @@ Options:
22
23
  --price Entry price (required if --entry limit)
23
24
  --tp Take profit distance in % from entry
24
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)
25
28
  --slippage Slippage for market entry in bps (default: 50)
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.
26
38
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
27
39
  --dry Dry run - show bracket plan without executing
28
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
+
29
44
  Take Profit / Stop Loss:
30
45
  For LONG (buy): TP is above entry, SL is below entry
31
46
  For SHORT (sell): TP is below entry, SL is above entry
@@ -34,9 +49,12 @@ Examples:
34
49
  # Long ETH with 3% take profit and 1.5% stop loss
35
50
  npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
36
51
 
37
- # 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)
38
53
  npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
39
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
+
40
58
  # Preview bracket setup
41
59
  npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
42
60
  `);
@@ -44,15 +62,53 @@ Examples:
44
62
  export async function runBracket(opts) {
45
63
  const out = opts.output ?? ((line) => console.log(line));
46
64
  const entryType = opts.entryType ?? 'market';
65
+ const slMarket = opts.slMarket !== false;
66
+ const atomic = opts.atomic !== false;
47
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;
48
70
  if (opts.size <= 0 || isNaN(opts.size))
49
71
  throw new Error('size must be positive');
50
- if (opts.tpPct <= 0 || opts.slPct <= 0)
51
- 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)');
52
74
  if (entryType === 'limit' && opts.entryPrice === undefined) {
53
75
  throw new Error('entryPrice is required for limit entry');
54
76
  }
55
- const client = getClient();
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
+ };
111
+ const client = opts.client ?? getClient();
56
112
  if (opts.verbose)
57
113
  client.verbose = true;
58
114
  out('Open Broker - Bracket Order');
@@ -62,159 +118,299 @@ export async function runBracket(opts) {
62
118
  if (!midPrice)
63
119
  throw new Error(`No market data for ${opts.coin}`);
64
120
  const entry = entryType === 'limit' ? opts.entryPrice : midPrice;
65
- let tpPrice = isLong
66
- ? entry * (1 + opts.tpPct / 100)
67
- : entry * (1 - opts.tpPct / 100);
68
- let slPrice = isLong
69
- ? entry * (1 - opts.slPct / 100)
70
- : entry * (1 + opts.slPct / 100);
71
- 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;
72
127
  const notional = entry * opts.size;
73
128
  out('Bracket Plan');
74
129
  out('------------');
75
130
  out(`Coin: ${opts.coin}`);
76
131
  out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
77
132
  out(`Size: ${opts.size}`);
78
- out(`Entry Type: ${entryType.toUpperCase()}`);
133
+ out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
79
134
  out(`Current Mid: ${formatUsd(midPrice)}`);
80
135
  out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
81
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%)`);
82
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%)`);
83
- 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
+ }
84
143
  out(`Est. Notional: ${formatUsd(notional)}`);
85
- const potentialProfit = notional * (opts.tpPct / 100);
86
- const potentialLoss = notional * (opts.slPct / 100);
87
144
  out('\nRisk Analysis');
88
145
  out('-------------');
89
- out(`Potential Profit: ${formatUsd(potentialProfit)}`);
90
- 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)}`);
91
150
  if (opts.dryRun) {
92
151
  out('\n🔍 Dry run - bracket not executed');
93
- return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
152
+ return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
94
153
  }
95
154
  out('\nExecuting bracket...\n');
96
- // 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 ────
97
244
  out('Step 1: Entry order');
98
245
  let actualEntry = entry;
99
246
  let entryOid = null;
100
- if (entryType === 'market') {
101
- const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
102
- if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
103
- const status = entryResponse.response.data.statuses[0];
104
- if (status?.filled) {
105
- actualEntry = parseFloat(status.filled.avgPx);
106
- out(` ✅ Filled @ ${formatUsd(actualEntry)}`);
247
+ let filledSize = 0;
248
+ const ownsFillWatcher = !opts.fillWatcher;
249
+ const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: Date.now() });
250
+ await fillWatcher.start();
251
+ try {
252
+ if (entryType === 'market') {
253
+ const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
254
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
255
+ const status = entryResponse.response.data.statuses[0];
256
+ if (status?.filled) {
257
+ actualEntry = parseFloat(status.filled.avgPx);
258
+ filledSize = parseFloat(status.filled.totalSz);
259
+ out(` ✅ Filled ${filledSize} @ ${formatUsd(actualEntry)}`);
260
+ }
261
+ else if (status?.error) {
262
+ out(` ❌ Entry failed: ${status.error}`);
263
+ out('\n⚠️ Bracket aborted - no position opened');
264
+ return { status: 'entry_failed', reason: status.error };
265
+ }
266
+ else {
267
+ out(` ❌ Entry failed: unexpected response`);
268
+ out('\n⚠️ Bracket aborted - no confirmed position opened');
269
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
270
+ }
107
271
  }
108
- else if (status?.error) {
109
- out(` ❌ Entry failed: ${status.error}`);
272
+ else {
273
+ const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
274
+ out(` ❌ Entry failed: ${reason}`);
110
275
  out('\n⚠️ Bracket aborted - no position opened');
111
- return { status: 'entry_failed', reason: status.error };
276
+ return { status: 'entry_failed', reason };
112
277
  }
113
278
  }
114
279
  else {
115
- const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
116
- out(` ❌ Entry failed: ${reason}`);
117
- out('\n⚠️ Bracket aborted - no position opened');
118
- return { status: 'entry_failed', reason };
119
- }
120
- }
121
- else {
122
- const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
123
- if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
124
- const status = entryResponse.response.data.statuses[0];
125
- if (status?.resting) {
126
- entryOid = status.resting.oid;
127
- out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
128
- out(` ⏳ Waiting for fill before placing TP/SL...`);
129
- out('\n⚠️ Note: TP/SL will be placed after entry fills. Monitor manually or use a strategy script.');
130
- return { status: 'limit_resting', entryOid, entryPrice: entry };
280
+ const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
281
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
282
+ const status = entryResponse.response.data.statuses[0];
283
+ if (status?.resting) {
284
+ entryOid = status.resting.oid;
285
+ const entryTimeoutSec = opts.entryTimeoutSec ?? 300;
286
+ out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
287
+ if (entryTimeoutSec <= 0) {
288
+ out(` ⏳ Entry resting; TP/SL not armed until a fill is confirmed.`);
289
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
290
+ }
291
+ out(` ⏳ Waiting up to ${entryTimeoutSec}s for fill confirmation...`);
292
+ const fill = await fillWatcher.waitForFill(entryOid, opts.size, entryTimeoutSec * 1000, { coin: opts.coin });
293
+ if (fill.size <= 0) {
294
+ out(` ⚠️ Entry still resting after ${entryTimeoutSec}s; TP/SL not armed.`);
295
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
296
+ }
297
+ filledSize = Math.min(fill.size, opts.size);
298
+ actualEntry = fill.avgPrice ?? entry;
299
+ out(` ✅ Fill confirmed: ${filledSize} @ ${formatUsd(actualEntry)}`);
300
+ if (filledSize < opts.size * 0.999) {
301
+ out(` ⚠️ Partial entry fill; arming TP/SL for filled size only.`);
302
+ }
303
+ }
304
+ else if (status?.filled) {
305
+ actualEntry = parseFloat(status.filled.avgPx);
306
+ filledSize = parseFloat(status.filled.totalSz);
307
+ out(` ✅ Filled immediately ${filledSize} @ ${formatUsd(actualEntry)}`);
308
+ }
309
+ else if (status?.error) {
310
+ out(` ❌ Entry failed: ${status.error}`);
311
+ return { status: 'entry_failed', reason: status.error };
312
+ }
313
+ else {
314
+ out(` ❌ Entry failed: unexpected response`);
315
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
316
+ }
131
317
  }
132
- else if (status?.filled) {
133
- actualEntry = parseFloat(status.filled.avgPx);
134
- out(` ✅ Filled immediately @ ${formatUsd(actualEntry)}`);
318
+ else {
319
+ out(` ❌ Entry failed`);
320
+ return { status: 'entry_failed', reason: 'Unknown error' };
135
321
  }
136
- else if (status?.error) {
137
- out(` ❌ Entry failed: ${status.error}`);
138
- return { status: 'entry_failed', reason: status.error };
139
- }
140
- }
141
- else {
142
- out(` ❌ Entry failed`);
143
- return { status: 'entry_failed', reason: 'Unknown error' };
144
322
  }
145
323
  }
146
- // Recalculate TP/SL based on actual entry
147
- if (isLong) {
148
- tpPrice = actualEntry * (1 + opts.tpPct / 100);
149
- slPrice = actualEntry * (1 - opts.slPct / 100);
324
+ finally {
325
+ if (ownsFillWatcher)
326
+ await fillWatcher.stop();
150
327
  }
151
- else {
152
- tpPrice = actualEntry * (1 - opts.tpPct / 100);
153
- slPrice = actualEntry * (1 + opts.slPct / 100);
328
+ if (!Number.isFinite(filledSize) || filledSize <= 0) {
329
+ out('\n⚠️ Bracket aborted - no confirmed fill size');
330
+ return { status: 'entry_failed', reason: 'No confirmed fill size' };
154
331
  }
155
- await sleep(500);
156
- // Step 2: Take Profit (trigger order)
157
- out('\nStep 2: Take Profit order (trigger)');
158
- const tpSide = !isLong;
159
- const tpResponse = await client.takeProfit(opts.coin, tpSide, opts.size, tpPrice);
160
- let tpOid = null;
161
- if (tpResponse.status === 'ok' && tpResponse.response && typeof tpResponse.response === 'object') {
162
- const status = tpResponse.response.data.statuses[0];
163
- if (status?.resting) {
164
- tpOid = status.resting.oid;
165
- out(` ✅ TP trigger placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
166
- }
167
- else if (status?.error) {
168
- out(` ❌ TP failed: ${status.error}`);
169
- }
170
- else {
171
- out(` ⚠️ TP status: ${JSON.stringify(status)}`);
172
- }
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);
173
336
  }
174
- else {
175
- const reason = typeof tpResponse.response === 'string' ? tpResponse.response : 'Unknown error';
176
- out(` TP failed: ${reason}`);
337
+ catch (error) {
338
+ const reason = error instanceof Error ? error.message : String(error);
339
+ out(`\nEntry 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 };
177
342
  }
178
343
  await sleep(500);
179
- // Step 3: Stop Loss (trigger order)
180
- out('\nStep 3: Stop Loss order (trigger)');
181
- const slSide = !isLong;
182
- const slResponse = await client.stopLoss(opts.coin, slSide, opts.size, slPrice);
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`);
347
+ const exitSide = !isLong;
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
+ });
356
+ let tpOid = null;
183
357
  let slOid = null;
184
- if (slResponse.status === 'ok' && slResponse.response && typeof slResponse.response === 'object') {
185
- const status = slResponse.response.data.statuses[0];
186
- if (status?.resting) {
187
- slOid = status.resting.oid;
188
- out(` ✅ SL trigger placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
189
- }
190
- else if (status?.error) {
191
- out(` ❌ SL failed: ${status.error}`);
192
- }
193
- else {
194
- out(` ⚠️ SL status: ${JSON.stringify(status)}`);
358
+ let exitErrors = 0;
359
+ if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
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
+ }
195
385
  }
196
386
  }
197
387
  else {
198
- const reason = typeof slResponse.response === 'string' ? slResponse.response : 'Unknown error';
199
- out(` ❌ SL failed: ${reason}`);
388
+ exitErrors++;
389
+ const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
390
+ out(` ❌ ${legsLabel} orders failed: ${reason}`);
200
391
  }
201
392
  out('\n========== Bracket Summary ==========');
202
- out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
393
+ out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
203
394
  out(`Entry: ${formatUsd(actualEntry)}`);
204
- out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%) - Trigger order`);
205
- out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%) - Trigger order`);
206
- if (tpOid && slOid) {
207
- out(`\n✅ Bracket complete! TP and SL are trigger orders.`);
208
- out(` They will only execute when price reaches trigger level.`);
209
- out(` When one fills, cancel the other manually.`);
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.');
210
404
  }
211
405
  return {
212
- status: tpOid && slOid ? 'complete' : 'partial',
406
+ status: exitErrors === 0 ? 'complete' : 'partial',
213
407
  entryPrice: actualEntry,
214
- tpPrice,
215
- slPrice,
408
+ tpPrice: targets.tpPrice ?? undefined,
409
+ slPrice: targets.slPrice ?? undefined,
216
410
  tpOid,
217
411
  slOid,
412
+ entryOid,
413
+ protectedSize: filledSize,
218
414
  };
219
415
  }
220
416
  async function main() {
@@ -224,12 +420,17 @@ async function main() {
224
420
  const size = parseFloat(args.size);
225
421
  const entryType = (args.entry || 'market');
226
422
  const entryPrice = args.price ? parseFloat(args.price) : undefined;
227
- const tpPct = parseFloat(args.tp);
228
- 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;
229
427
  const slippage = args.slippage ? parseInt(args.slippage) : undefined;
428
+ const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout']) : undefined;
429
+ const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage']) : undefined;
230
430
  const leverage = args.leverage ? parseInt(args.leverage) : undefined;
231
431
  const dryRun = args.dry;
232
- 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) {
233
434
  printUsage();
234
435
  process.exit(1);
235
436
  }
@@ -244,9 +445,15 @@ async function main() {
244
445
  size,
245
446
  tpPct,
246
447
  slPct,
448
+ tpPrice,
449
+ slPrice,
247
450
  entryType,
248
451
  entryPrice,
249
452
  slippage,
453
+ entryTimeoutSec,
454
+ slSlippageBps,
455
+ slMarket: !args['sl-limit'],
456
+ atomic: !args['no-atomic'],
250
457
  leverage,
251
458
  dryRun,
252
459
  verbose: args.verbose,
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env npx tsx
2
+ import type { OrderResponse, CancelResponse, OpenOrder } from '../core/types.js';
3
+ import { type FillWatcher } from './execution.js';
2
4
  export interface ChaseOptions {
3
5
  coin: string;
4
6
  side: 'buy' | 'sell';
@@ -11,11 +13,28 @@ export interface ChaseOptions {
11
13
  reduceOnly?: boolean;
12
14
  dryRun?: boolean;
13
15
  verbose?: boolean;
16
+ client?: ChaseClient;
17
+ fillWatcher?: FillWatcher;
14
18
  /** Receives each output line. Defaults to console.log. */
15
19
  output?: (line: string) => void;
16
20
  }
21
+ export interface ChaseClient {
22
+ verbose: boolean;
23
+ address: string;
24
+ getAllMids(): Promise<Record<string, string>>;
25
+ getOpenOrders(): Promise<OpenOrder[]>;
26
+ getUserFills(user?: string): Promise<Array<{
27
+ coin: string;
28
+ px: string;
29
+ sz: string;
30
+ time: number;
31
+ oid: number;
32
+ }>>;
33
+ limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
34
+ cancel(coin: string, oid: number): Promise<CancelResponse>;
35
+ }
17
36
  export interface ChaseResult {
18
- status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded';
37
+ status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional';
19
38
  iterations: number;
20
39
  durationSec: number;
21
40
  startMid: number;
@@ -1 +1 @@
1
- {"version":3,"file":"chase.d.ts","sourceRoot":"","sources":["../../scripts/operations/chase.ts"],"names":[],"mappings":";AAuCA,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,0DAA0D;IAC1D,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACjC;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,CA2JvE"}
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"}