openbroker 1.9.6 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  // Set Take Profit and/or Stop Loss on an existing position
3
3
 
4
4
  import { getClient } from '../core/client.js';
5
- import { formatUsd, parseArgs, sleep } from '../core/utils.js';
5
+ import { formatUsd, parseArgs, parseOrderStatus } from '../core/utils.js';
6
6
 
7
7
  function printUsage() {
8
8
  console.log(`
@@ -10,7 +10,8 @@ Open Broker - Set TP/SL
10
10
  =======================
11
11
 
12
12
  Add take profit and/or stop loss orders to an existing position.
13
- Uses trigger orders that execute when price reaches the target.
13
+ Placed as one batch with Hyperliquid's positionTpsl grouping: the triggers
14
+ track the open position and OCO-cancel each other when one fires.
14
15
 
15
16
  Usage:
16
17
  npx tsx scripts/operations/set-tpsl.ts --coin <COIN> [--tp <PRICE>] [--sl <PRICE>]
@@ -20,7 +21,10 @@ Options:
20
21
  --tp Take profit trigger price
21
22
  --sl Stop loss trigger price
22
23
  --size Size to protect (default: full position size)
23
- --sl-slippage Stop loss slippage in bps (default: 100 = 1%)
24
+ --sl-slippage Stop loss fill cap past the trigger in bps (default: 100 = 1%)
25
+ --sl-limit Place the SL as a stop-limit instead of a market trigger.
26
+ Warning: a gap move past the limit band can skip the stop
27
+ entirely and leave the position unprotected.
24
28
  --dry Dry run - show orders without placing
25
29
  --verbose Show debug output
26
30
 
@@ -46,9 +50,11 @@ Examples:
46
50
  How Trigger Orders Work:
47
51
  - TP/SL are trigger orders, NOT regular limit orders
48
52
  - They sit dormant until price reaches the trigger level
49
- - Once triggered, they execute as limit orders
53
+ - TP executes as a limit order at the target; SL executes as a market
54
+ order capped by the slippage band (use --sl-limit for a stop-limit)
50
55
  - These are reduce-only orders (close position, don't reverse)
51
- - SL has slippage buffer to ensure fill in fast markets
56
+ - positionTpsl grouping ties them to the position: when one fires and
57
+ closes the position, the venue cancels the other automatically
52
58
  `);
53
59
  }
54
60
 
@@ -95,6 +101,7 @@ async function main() {
95
101
  const slInput = args.sl as string | undefined;
96
102
  const sizeOverride = args.size ? parseFloat(args.size as string) : undefined;
97
103
  const slSlippage = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : 100;
104
+ const slMarket = !(args['sl-limit'] as boolean);
98
105
  const dryRun = args.dry as boolean;
99
106
 
100
107
  if (!coin) {
@@ -159,26 +166,29 @@ async function main() {
159
166
  process.exit(1);
160
167
  }
161
168
 
162
- // Validate TP/SL make sense for position direction
169
+ // Triggers must sit on the correct side of the LIVE price, or the
170
+ // exchange fires them immediately on placement.
171
+ const directionErrors: string[] = [];
163
172
  if (isLong) {
164
173
  if (tpPrice && tpPrice <= currentPrice) {
165
- console.warn(`⚠️ Warning: TP (${formatUsd(tpPrice)}) is at or below current price (${formatUsd(currentPrice)})`);
166
- console.warn(' For LONG positions, TP should be above current price');
174
+ directionErrors.push(`TP (${formatUsd(tpPrice)}) must be above the current price (${formatUsd(currentPrice)}) for a LONG`);
167
175
  }
168
176
  if (slPrice && slPrice >= currentPrice) {
169
- console.warn(`⚠️ Warning: SL (${formatUsd(slPrice)}) is at or above current price (${formatUsd(currentPrice)})`);
170
- console.warn(' For LONG positions, SL should be below current price');
177
+ directionErrors.push(`SL (${formatUsd(slPrice)}) must be below the current price (${formatUsd(currentPrice)}) for a LONG`);
171
178
  }
172
179
  } else {
173
180
  if (tpPrice && tpPrice >= currentPrice) {
174
- console.warn(`⚠️ Warning: TP (${formatUsd(tpPrice)}) is at or above current price (${formatUsd(currentPrice)})`);
175
- console.warn(' For SHORT positions, TP should be below current price');
181
+ directionErrors.push(`TP (${formatUsd(tpPrice)}) must be below the current price (${formatUsd(currentPrice)}) for a SHORT`);
176
182
  }
177
183
  if (slPrice && slPrice <= currentPrice) {
178
- console.warn(`⚠️ Warning: SL (${formatUsd(slPrice)}) is at or below current price (${formatUsd(currentPrice)})`);
179
- console.warn(' For SHORT positions, SL should be above current price');
184
+ directionErrors.push(`SL (${formatUsd(slPrice)}) must be above the current price (${formatUsd(currentPrice)}) for a SHORT`);
180
185
  }
181
186
  }
187
+ if (directionErrors.length > 0) {
188
+ for (const err of directionErrors) console.error(`Error: ${err}`);
189
+ console.error('A trigger on the wrong side of the live price fires immediately on placement.');
190
+ process.exit(1);
191
+ }
182
192
 
183
193
  // Calculate risk/reward
184
194
  let tpDistance = 0, slDistance = 0, riskReward = 0;
@@ -216,7 +226,7 @@ async function main() {
216
226
  const slLimitPrice = isLong
217
227
  ? slPrice * (1 - slSlippage / 10000)
218
228
  : slPrice * (1 + slSlippage / 10000);
219
- console.log(`Stop Loss: ${slSide} ${size} @ ${formatUsd(slPrice)} trigger, ${formatUsd(slLimitPrice)} limit (-${slDistance.toFixed(2)}%)`);
229
+ console.log(`Stop Loss: ${slSide} ${size} @ ${formatUsd(slPrice)} trigger, ${slMarket ? `market (fill capped at ${formatUsd(slLimitPrice)})` : `${formatUsd(slLimitPrice)} limit`} (-${slDistance.toFixed(2)}%)`);
220
230
  }
221
231
  if (riskReward > 0) {
222
232
  console.log(`Risk/Reward: 1:${riskReward.toFixed(2)}`);
@@ -235,50 +245,45 @@ async function main() {
235
245
  return;
236
246
  }
237
247
 
238
- console.log('\nPlacing trigger orders...\n');
239
-
240
- // Place Take Profit
241
- let tpOid: number | null = null;
242
- if (tpPrice) {
243
- const tpSide = !isLong; // Opposite of position direction
244
- const response = await client.takeProfit(coin, tpSide, size, tpPrice);
245
-
246
- if (response.status === 'ok' && response.response && typeof response.response === 'object') {
247
- const status = response.response.data.statuses[0];
248
- if (status?.resting) {
249
- tpOid = status.resting.oid;
250
- console.log(`✅ Take Profit placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
251
- } else if (status?.error) {
252
- console.log(`❌ TP failed: ${status.error}`);
253
- } else {
254
- console.log(`⚠️ TP status:`, JSON.stringify(status));
255
- }
256
- } else {
257
- console.log(`❌ TP failed: ${typeof response.response === 'string' ? response.response : 'Unknown error'}`);
258
- }
248
+ console.log('\nPlacing trigger orders (positionTpsl batch)...\n');
259
249
 
260
- await sleep(200);
261
- }
250
+ // One batch with positionTpsl grouping: the venue ties the triggers to
251
+ // the open position and OCO-cancels the survivor when one fires.
252
+ const exitSide = !isLong; // Opposite of position direction
253
+ const response = await client.tpslOrders(coin, exitSide, size, {
254
+ takeProfitPrice: tpPrice ?? undefined,
255
+ stopLossPrice: slPrice ?? undefined,
256
+ stopLossSlippageBps: slSlippage,
257
+ stopLossIsMarket: slMarket,
258
+ grouping: 'positionTpsl',
259
+ });
262
260
 
263
- // Place Stop Loss
261
+ let tpOid: number | null = null;
264
262
  let slOid: number | null = null;
265
- if (slPrice) {
266
- const slSide = !isLong; // Opposite of position direction
267
- const response = await client.stopLoss(coin, slSide, size, slPrice, slSlippage);
268
-
269
- if (response.status === 'ok' && response.response && typeof response.response === 'object') {
270
- const status = response.response.data.statuses[0];
271
- if (status?.resting) {
272
- slOid = status.resting.oid;
273
- console.log(`✅ Stop Loss placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
274
- } else if (status?.error) {
275
- console.log(`❌ SL failed: ${status.error}`);
263
+ let placementErrors = 0;
264
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
265
+ const statuses = response.response.data.statuses.map(parseOrderStatus);
266
+ let idx = 0;
267
+ for (const leg of [tpPrice ? 'Take Profit' : null, slPrice ? 'Stop Loss' : null]) {
268
+ if (!leg) continue;
269
+ const status = statuses[idx++];
270
+ const price = leg === 'Take Profit' ? tpPrice! : slPrice!;
271
+ if (status?.kind === 'resting') {
272
+ if (leg === 'Take Profit') tpOid = status.oid; else slOid = status.oid;
273
+ console.log(`✅ ${leg} placed @ ${formatUsd(price)} (OID: ${status.oid})`);
274
+ } else if (status?.kind === 'waiting') {
275
+ console.log(`✅ ${leg} armed @ ${formatUsd(price)} (${status.state})`);
276
+ } else if (status?.kind === 'error') {
277
+ placementErrors++;
278
+ console.log(`❌ ${leg} failed: ${status.error}`);
276
279
  } else {
277
- console.log(`⚠️ SL status:`, JSON.stringify(status));
280
+ placementErrors++;
281
+ console.log(`⚠️ ${leg} status:`, JSON.stringify(status));
278
282
  }
279
- } else {
280
- console.log(`❌ SL failed: ${typeof response.response === 'string' ? response.response : 'Unknown error'}`);
281
283
  }
284
+ } else {
285
+ placementErrors++;
286
+ console.log(`❌ TP/SL failed: ${typeof response.response === 'string' ? response.response : 'Unknown error'}`);
282
287
  }
283
288
 
284
289
  // Summary
@@ -286,12 +291,12 @@ async function main() {
286
291
  console.log(`Position: ${isLong ? 'LONG' : 'SHORT'} ${absSize} ${coin}`);
287
292
  console.log(`Entry: ${formatUsd(entryPrice)}`);
288
293
  if (tpOid) console.log(`Take Profit: ${formatUsd(tpPrice!)} (OID: ${tpOid})`);
289
- if (slOid) console.log(`Stop Loss: ${formatUsd(slPrice!)} (OID: ${slOid})`);
294
+ if (slOid) console.log(`Stop Loss: ${formatUsd(slPrice!)} (OID: ${slOid}, ${slMarket ? 'market trigger' : 'stop-limit'})`);
290
295
 
291
- if (tpOid && slOid) {
292
- console.log(`\n💡 Tip: When one order fills, cancel the other manually:`);
293
- console.log(` npx tsx scripts/operations/cancel.ts --coin ${coin} --oid <OID>`);
296
+ if (placementErrors === 0 && tpPrice && slPrice) {
297
+ console.log(`\n💡 The triggers track the position: when one fires, the venue cancels the other.`);
294
298
  }
299
+ if (placementErrors > 0) process.exit(1);
295
300
 
296
301
  } catch (error) {
297
302
  console.error('Error:', error);
@@ -23,15 +23,18 @@ Options:
23
23
  --type Order type: tp (take profit) or sl (stop loss)
24
24
  --limit Limit price when triggered (default: trigger price for TP, with slippage for SL)
25
25
  --slippage Slippage for SL in bps (default: 100 = 1%)
26
+ --exec Execution when triggered: market or limit
27
+ (default: market for SL, limit for TP)
26
28
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
27
29
  --reduce Reduce-only order (default: true for TP/SL)
28
30
  --dry Dry run - show order without placing
29
31
 
30
32
  Trigger Order Behavior:
31
33
  - Order is dormant until price reaches trigger level
32
- - Once triggered, becomes a limit order at the limit price
33
- - TP: Limit price = trigger price (favorable)
34
- - SL: Limit price = trigger ± slippage (ensures fill)
34
+ - TP: rests as a limit order at the limit price (favorable)
35
+ - SL: fires as a market order capped by the limit price (trigger ± slippage);
36
+ pass --exec limit for a stop-limit, but note a gap move past the band can
37
+ skip the stop entirely and leave the position unprotected
35
38
 
36
39
  Examples:
37
40
  # Take profit: sell 0.5 HYPE when price rises to $40
@@ -63,6 +66,11 @@ async function main() {
63
66
  const orderType = args.type as string;
64
67
  const limitPriceOverride = args.limit ? parseFloat(args.limit as string) : undefined;
65
68
  const slippageBps = args.slippage ? parseInt(args.slippage as string) : 100;
69
+ const execOverride = args.exec as string | undefined;
70
+ if (execOverride && execOverride !== 'market' && execOverride !== 'limit') {
71
+ console.error('Error: --exec must be "market" or "limit"');
72
+ process.exit(1);
73
+ }
66
74
  const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
67
75
  const reduceOnly = args.reduce !== 'false'; // Default true
68
76
  const dryRun = args.dry as boolean;
@@ -116,13 +124,18 @@ async function main() {
116
124
  // TP: use trigger price as limit (favorable)
117
125
  limitPrice = triggerPrice;
118
126
  } else {
119
- // SL: add slippage to ensure fill
127
+ // SL: slippage band past the trigger — caps the market fill, or is the
128
+ // resting price for a stop-limit
120
129
  const slippageMult = slippageBps / 10000;
121
130
  limitPrice = isBuy
122
131
  ? triggerPrice * (1 + slippageMult)
123
132
  : triggerPrice * (1 - slippageMult);
124
133
  }
125
134
 
135
+ // SL fires as a market trigger by default (a stop-limit can be gapped
136
+ // over and never fill); TP rests as a limit at the target.
137
+ const isMarket = execOverride ? execOverride === 'market' : tpsl === 'sl';
138
+
126
139
  const distanceFromCurrent = ((triggerPrice - currentPrice) / currentPrice) * 100;
127
140
  const notional = triggerPrice * size;
128
141
 
@@ -134,7 +147,7 @@ async function main() {
134
147
  console.log(`Size: ${size}`);
135
148
  console.log(`Current Price: ${formatUsd(currentPrice)}`);
136
149
  console.log(`Trigger Price: ${formatUsd(triggerPrice)} (${distanceFromCurrent >= 0 ? '+' : ''}${distanceFromCurrent.toFixed(2)}%)`);
137
- console.log(`Limit Price: ${formatUsd(limitPrice)}`);
150
+ console.log(`Execution: ${isMarket ? `market (fill capped at ${formatUsd(limitPrice)})` : `limit @ ${formatUsd(limitPrice)}`}`);
138
151
  console.log(`Reduce Only: ${reduceOnly ? 'Yes' : 'No'}`);
139
152
  console.log(`Est. Notional: ${formatUsd(notional)}`);
140
153
 
@@ -170,7 +183,8 @@ async function main() {
170
183
  limitPrice,
171
184
  tpsl,
172
185
  reduceOnly,
173
- leverage
186
+ leverage,
187
+ isMarket
174
188
  );
175
189
 
176
190
  console.log('\nResult');
@@ -2,7 +2,7 @@
2
2
  // TWAP (Time-Weighted Average Price) execution using Hyperliquid's native TWAP orders
3
3
 
4
4
  import { getClient } from '../core/client.js';
5
- import { formatUsd, parseArgs } from '../core/utils.js';
5
+ import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs } from '../core/utils.js';
6
6
 
7
7
  function printUsage() {
8
8
  console.log(`
@@ -90,6 +90,10 @@ async function main() {
90
90
  }
91
91
 
92
92
  const notional = midPrice * totalSize;
93
+ // Native TWAP fires a sub-order every 30s; each must clear the exchange minimum.
94
+ const sliceCount = durationMinutes * 2;
95
+ const perSliceNotional = notional / sliceCount;
96
+ const sliceBelowMinimum = !reduceOnly && perSliceNotional < MIN_ORDER_NOTIONAL_USD;
93
97
 
94
98
  console.log('Order Details');
95
99
  console.log('-------------');
@@ -99,6 +103,7 @@ async function main() {
99
103
  console.log(`Current Price: ${formatUsd(midPrice)}`);
100
104
  console.log(`Est. Notional: ${formatUsd(notional)}`);
101
105
  console.log(`Duration: ${formatDuration(durationMinutes * 60)}`);
106
+ console.log(`Slices: ~${sliceCount} (every 30s, ~${formatUsd(perSliceNotional)} each)`);
102
107
  console.log(`Randomize: ${randomize ? 'yes' : 'no'}`);
103
108
  console.log(`Reduce Only: ${reduceOnly ? 'yes' : 'no'}`);
104
109
  if (leverage) {
@@ -106,11 +111,19 @@ async function main() {
106
111
  }
107
112
 
108
113
  if (dryRun) {
114
+ if (sliceBelowMinimum) {
115
+ console.log(`\n⚠️ TWAP slices of ~${formatUsd(perSliceNotional)} fall below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — shorten the duration or increase the size.`);
116
+ }
109
117
  console.log('\nDry run - no order placed.');
110
118
  console.log('The exchange will handle order slicing and timing automatically.');
111
119
  return;
112
120
  }
113
121
 
122
+ if (sliceBelowMinimum) {
123
+ console.error(`Error: TWAP slices of ~${formatUsd(perSliceNotional)} fall below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — shorten the duration or increase the size.`);
124
+ process.exit(1);
125
+ }
126
+
114
127
  console.log('\nPlacing native TWAP order...\n');
115
128
 
116
129
  const response = await client.twapOrder(