openbroker 1.9.4 → 1.9.6

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 (43) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +21 -4
  3. package/SKILL.md +3 -3
  4. package/bin/cli.ts +4 -2
  5. package/dist/core/client.d.ts +6 -0
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +71 -0
  8. package/dist/lib.d.ts +2 -0
  9. package/dist/lib.d.ts.map +1 -1
  10. package/dist/lib.js +1 -0
  11. package/dist/operations/advanced-orders.test.d.ts +2 -0
  12. package/dist/operations/advanced-orders.test.d.ts.map +1 -0
  13. package/dist/operations/advanced-orders.test.js +189 -0
  14. package/dist/operations/bracket.d.ts +22 -0
  15. package/dist/operations/bracket.d.ts.map +1 -1
  16. package/dist/operations/bracket.js +106 -73
  17. package/dist/operations/chase.d.ts +19 -0
  18. package/dist/operations/chase.d.ts.map +1 -1
  19. package/dist/operations/chase.js +125 -58
  20. package/dist/operations/execution.d.ts +53 -0
  21. package/dist/operations/execution.d.ts.map +1 -0
  22. package/dist/operations/execution.js +106 -0
  23. package/dist/operations/scale.d.ts +50 -1
  24. package/dist/operations/scale.d.ts.map +1 -1
  25. package/dist/operations/scale.js +143 -105
  26. package/dist/setup/install.js +100 -3
  27. package/dist/setup/package-catalog.d.ts +12 -0
  28. package/dist/setup/package-catalog.d.ts.map +1 -0
  29. package/dist/setup/package-catalog.js +36 -0
  30. package/dist/setup/package-catalog.test.d.ts +2 -0
  31. package/dist/setup/package-catalog.test.d.ts.map +1 -0
  32. package/dist/setup/package-catalog.test.js +31 -0
  33. package/package.json +5 -3
  34. package/scripts/core/client.ts +91 -0
  35. package/scripts/lib.ts +3 -0
  36. package/scripts/operations/advanced-orders.test.ts +209 -0
  37. package/scripts/operations/bracket.ts +128 -72
  38. package/scripts/operations/chase.ts +138 -61
  39. package/scripts/operations/execution.ts +138 -0
  40. package/scripts/operations/scale.ts +186 -131
  41. package/scripts/setup/install.ts +110 -3
  42. package/scripts/setup/package-catalog.test.ts +50 -0
  43. package/scripts/setup/package-catalog.ts +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbroker",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,10 +51,12 @@
51
51
  "funding-scan": "tsx scripts/info/funding-scan.ts",
52
52
  "outcomes": "tsx scripts/info/outcomes.ts",
53
53
  "build": "tsc",
54
- "prepublishOnly": "npm run build && npm run test:guardrails && npm run test:realtime && npm run test:cli",
54
+ "prepublishOnly": "npm run build && npm run test:guardrails && npm run test:realtime && npm run test:advanced && npm run test:install && npm run test:cli",
55
55
  "test:cli": "node --import tsx bin/cli.ts --help",
56
56
  "test:guardrails": "node --import tsx --test scripts/auto/guardrails.test.ts",
57
- "test:realtime": "node --import tsx --test scripts/auto/realtime.test.ts"
57
+ "test:realtime": "node --import tsx --test scripts/auto/realtime.test.ts",
58
+ "test:advanced": "node --import tsx --test scripts/operations/advanced-orders.test.ts",
59
+ "test:install": "node --import tsx --test scripts/setup/package-catalog.test.ts"
58
60
  },
59
61
  "dependencies": {
60
62
  "@nktkas/hyperliquid": "^0.30.3",
@@ -2703,6 +2703,97 @@ export class HyperliquidClient {
2703
2703
  return this.triggerOrder(coin, isBuy, size, triggerPrice, triggerPrice, 'tp', true);
2704
2704
  }
2705
2705
 
2706
+ /**
2707
+ * Place a paired TP/SL trigger set using Hyperliquid's normalTpsl grouping.
2708
+ * The two orders are submitted together so the venue treats them as a linked
2709
+ * bracket pair instead of two unrelated reduce-only triggers.
2710
+ */
2711
+ async tpslPair(
2712
+ coin: string,
2713
+ isBuy: boolean,
2714
+ size: number,
2715
+ takeProfitPrice: number,
2716
+ stopLossPrice: number,
2717
+ stopLossSlippageBps: number = 100,
2718
+ leverage?: number
2719
+ ): Promise<OrderResponse> {
2720
+ await this.requireTrading();
2721
+ await this.getMetaAndAssetCtxs();
2722
+
2723
+ if (leverage && !this.isHip3(coin)) {
2724
+ this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
2725
+ await this.updateLeverage(coin, leverage, true);
2726
+ }
2727
+
2728
+ const slippageMult = stopLossSlippageBps / 10000;
2729
+ const stopLossLimitPrice = isBuy
2730
+ ? stopLossPrice * (1 + slippageMult)
2731
+ : stopLossPrice * (1 - slippageMult);
2732
+
2733
+ await this.ensureHip3Ready(coin, size * Math.max(takeProfitPrice, stopLossLimitPrice), leverage);
2734
+
2735
+ const assetIndex = this.getAssetIndex(coin);
2736
+ const szDecimals = this.getSzDecimals(coin);
2737
+ const roundedSize = roundSize(size, szDecimals);
2738
+
2739
+ const orderWires = [
2740
+ {
2741
+ a: assetIndex,
2742
+ b: isBuy,
2743
+ p: roundPrice(takeProfitPrice, szDecimals),
2744
+ s: roundedSize,
2745
+ r: true,
2746
+ t: {
2747
+ trigger: {
2748
+ triggerPx: roundPrice(takeProfitPrice, szDecimals),
2749
+ isMarket: false,
2750
+ tpsl: 'tp' as const,
2751
+ },
2752
+ },
2753
+ },
2754
+ {
2755
+ a: assetIndex,
2756
+ b: isBuy,
2757
+ p: roundPrice(stopLossLimitPrice, szDecimals),
2758
+ s: roundedSize,
2759
+ r: true,
2760
+ t: {
2761
+ trigger: {
2762
+ triggerPx: roundPrice(stopLossPrice, szDecimals),
2763
+ isMarket: false,
2764
+ tpsl: 'sl' as const,
2765
+ },
2766
+ },
2767
+ },
2768
+ ];
2769
+
2770
+ const orderRequest: {
2771
+ orders: typeof orderWires;
2772
+ grouping: 'normalTpsl';
2773
+ builder?: BuilderInfo;
2774
+ } = {
2775
+ orders: orderWires,
2776
+ grouping: 'normalTpsl',
2777
+ };
2778
+
2779
+ if (!this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
2780
+ orderRequest.builder = this.builderInfo;
2781
+ this.log('Including builder fee:', this.builderInfo);
2782
+ }
2783
+
2784
+ try {
2785
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
2786
+ this.log('TP/SL pair response:', JSON.stringify(response, null, 2));
2787
+ return response as unknown as OrderResponse;
2788
+ } catch (error) {
2789
+ this.log('TP/SL pair error:', error);
2790
+ return {
2791
+ status: 'err',
2792
+ response: error instanceof Error ? error.message : String(error),
2793
+ };
2794
+ }
2795
+ }
2796
+
2706
2797
  async cancel(coin: string, oid: number): Promise<CancelResponse> {
2707
2798
  await this.requireTrading();
2708
2799
  await this.getMetaAndAssetCtxs();
package/scripts/lib.ts CHANGED
@@ -59,6 +59,9 @@ export type { BracketOptions, BracketResult } from './operations/bracket.js';
59
59
  export { runChase } from './operations/chase.js';
60
60
  export type { ChaseOptions, ChaseResult } from './operations/chase.js';
61
61
 
62
+ export { runScale, calculateLevels } from './operations/scale.js';
63
+ export type { ScaleOptions, ScaleResult, OrderLevel } from './operations/scale.js';
64
+
62
65
  // ── Automation runtime ──────────────────────────────────────────────
63
66
 
64
67
  export {
@@ -0,0 +1,209 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runBracket, type BracketClient } from './bracket.js';
4
+ import { runChase, type ChaseClient } from './chase.js';
5
+ import { runScale, type ScaleClient } from './scale.js';
6
+ import { UserFillWatcher, type FillSummary, type FillWatcher } from './execution.js';
7
+ import type { CancelResponse, OpenOrder, OrderResponse } from '../core/types.js';
8
+
9
+ const okOrder = (statuses: OrderResponse['response'] extends infer R
10
+ ? R extends { data: { statuses: infer S } } ? S : never
11
+ : never): OrderResponse => ({
12
+ status: 'ok',
13
+ response: { type: 'order', data: { statuses } },
14
+ });
15
+
16
+ const okCancel = (): CancelResponse => ({
17
+ status: 'ok',
18
+ response: { type: 'cancel', data: { statuses: ['success'] } },
19
+ });
20
+
21
+ class StaticFillWatcher implements FillWatcher {
22
+ constructor(private readonly fills: Map<number, FillSummary>) {}
23
+ async start(): Promise<void> {}
24
+ async stop(): Promise<void> {}
25
+ getFilled(oid: number): FillSummary {
26
+ return this.fills.get(oid) ?? { size: 0, notional: 0 };
27
+ }
28
+ async waitForFill(oid: number): Promise<FillSummary> {
29
+ return this.getFilled(oid);
30
+ }
31
+ }
32
+
33
+ test('scale rejects invalid levels and rolls back resting orders on partial ladder placement', async () => {
34
+ const cancelled: Array<{ coin: string; oid: number }> = [];
35
+ const client: ScaleClient = {
36
+ verbose: false,
37
+ async getAllMids() {
38
+ return { ETH: '1000' };
39
+ },
40
+ async bulkOrder() {
41
+ return okOrder([
42
+ { resting: { oid: 101 } },
43
+ { error: 'insufficient margin' },
44
+ { resting: { oid: 103 } },
45
+ ]);
46
+ },
47
+ async bulkCancel(cancels) {
48
+ cancelled.push(...cancels);
49
+ return okCancel();
50
+ },
51
+ };
52
+
53
+ await assert.rejects(
54
+ () => runScale({
55
+ coin: 'ETH',
56
+ side: 'buy',
57
+ size: 1,
58
+ levels: 0,
59
+ rangePct: 2,
60
+ client,
61
+ output: () => {},
62
+ }),
63
+ /levels must be a positive integer/,
64
+ );
65
+
66
+ const result = await runScale({
67
+ coin: 'ETH',
68
+ side: 'buy',
69
+ size: 1,
70
+ levels: 3,
71
+ rangePct: 2,
72
+ client,
73
+ output: () => {},
74
+ });
75
+
76
+ assert.equal(result.status, 'partial');
77
+ assert.equal(result.rolledBack, true);
78
+ assert.deepEqual(cancelled, [
79
+ { coin: 'ETH', oid: 101 },
80
+ { coin: 'ETH', oid: 103 },
81
+ ]);
82
+ });
83
+
84
+ test('bracket waits for limit-entry fill and arms linked TP/SL for confirmed size', async () => {
85
+ const pairCalls: Array<{ size: number; tp: number; sl: number; isBuy: boolean }> = [];
86
+ const client: BracketClient = {
87
+ verbose: false,
88
+ address: '0x0000000000000000000000000000000000000001',
89
+ async getAllMids() {
90
+ return { ETH: '1000' };
91
+ },
92
+ async marketOrder() {
93
+ throw new Error('marketOrder should not be called');
94
+ },
95
+ async limitOrder() {
96
+ return okOrder([{ resting: { oid: 777 } }]);
97
+ },
98
+ async tpslPair(_coin, isBuy, size, tp, sl) {
99
+ pairCalls.push({ size, tp, sl, isBuy });
100
+ return okOrder([{ resting: { oid: 778 } }, { resting: { oid: 779 } }]);
101
+ },
102
+ async getUserFills() {
103
+ return [];
104
+ },
105
+ };
106
+
107
+ const fillWatcher = new StaticFillWatcher(new Map([
108
+ [777, { size: 0.4, notional: 392, avgPrice: 980 }],
109
+ ]));
110
+
111
+ const result = await runBracket({
112
+ coin: 'ETH',
113
+ side: 'buy',
114
+ size: 1,
115
+ tpPct: 5,
116
+ slPct: 2,
117
+ entryType: 'limit',
118
+ entryPrice: 990,
119
+ entryTimeoutSec: 5,
120
+ client,
121
+ fillWatcher,
122
+ output: () => {},
123
+ });
124
+
125
+ assert.equal(result.status, 'complete');
126
+ assert.equal(result.protectedSize, 0.4);
127
+ assert.equal(result.entryPrice, 980);
128
+ assert.equal(pairCalls.length, 1);
129
+ assert.equal(pairCalls[0].size, 0.4);
130
+ assert.equal(pairCalls[0].isBuy, false);
131
+ assert.equal(pairCalls[0].tp, 1029);
132
+ assert.equal(pairCalls[0].sl, 960.4);
133
+ });
134
+
135
+ test('chase requotes only the remaining size after a partial fill', async () => {
136
+ const limitSizes: number[] = [];
137
+ const cancelled: number[] = [];
138
+ const fills = new Map<number, FillSummary>();
139
+ let orderCount = 0;
140
+ let mid = 1000;
141
+
142
+ const client: ChaseClient = {
143
+ verbose: false,
144
+ address: '0x0000000000000000000000000000000000000001',
145
+ async getAllMids() {
146
+ mid += 2;
147
+ return { ETH: String(mid) };
148
+ },
149
+ async getOpenOrders(): Promise<OpenOrder[]> {
150
+ return [];
151
+ },
152
+ async getUserFills() {
153
+ return [];
154
+ },
155
+ async limitOrder(_coin, _isBuy, size) {
156
+ limitSizes.push(size);
157
+ orderCount += 1;
158
+ const oid = 900 + orderCount;
159
+ if (orderCount === 1) {
160
+ fills.set(oid, { size: 0.4, notional: 400, avgPrice: 1000 });
161
+ } else {
162
+ fills.set(oid, { size, notional: size * 1002, avgPrice: 1002 });
163
+ }
164
+ return okOrder([{ resting: { oid } }]);
165
+ },
166
+ async cancel(_coin, oid) {
167
+ cancelled.push(oid);
168
+ return okCancel();
169
+ },
170
+ };
171
+
172
+ const result = await runChase({
173
+ coin: 'ETH',
174
+ side: 'buy',
175
+ size: 1,
176
+ offsetBps: 5,
177
+ timeoutSec: 2,
178
+ intervalMs: 1,
179
+ maxChaseBps: 1_000,
180
+ client,
181
+ fillWatcher: new StaticFillWatcher(fills),
182
+ output: () => {},
183
+ });
184
+
185
+ assert.equal(result.status, 'filled');
186
+ assert.equal(limitSizes.length, 2);
187
+ assert.equal(limitSizes[0], 1);
188
+ assert(Math.abs(limitSizes[1] - 0.6) < 1e-9);
189
+ assert.deepEqual(cancelled, [901]);
190
+ });
191
+
192
+ test('user fill watcher de-duplicates repeated REST fallback fills', async () => {
193
+ const fillTime = Date.now();
194
+ const watcher = new UserFillWatcher({
195
+ verbose: false,
196
+ address: '0x0000000000000000000000000000000000000001',
197
+ async getUserFills() {
198
+ return [
199
+ { coin: 'ETH', px: '1000', sz: '0.25', time: fillTime, oid: 123 },
200
+ ];
201
+ },
202
+ }, { ws: null, sinceMs: fillTime - 1000 });
203
+
204
+ await watcher.start();
205
+ await watcher.waitForFill(123, 1, 1, { coin: 'ETH', pollMs: 1 });
206
+ await watcher.waitForFill(123, 1, 1, { coin: 'ETH', pollMs: 1 });
207
+
208
+ assert.equal(watcher.getFilled(123).size, 0.25);
209
+ });
@@ -3,7 +3,9 @@
3
3
 
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getClient } from '../core/client.js';
6
+ import type { OrderResponse } from '../core/types.js';
6
7
  import { formatUsd, parseArgs, sleep } from '../core/utils.js';
8
+ import { UserFillWatcher, type FillWatcher } from './execution.js';
7
9
 
8
10
  function printUsage() {
9
11
  console.log(`
@@ -25,6 +27,8 @@ Options:
25
27
  --tp Take profit distance in % from entry
26
28
  --sl Stop loss distance in % from entry
27
29
  --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)
28
32
  --leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
29
33
  --dry Dry run - show bracket plan without executing
30
34
 
@@ -53,13 +57,27 @@ export interface BracketOptions {
53
57
  entryType?: 'market' | 'limit';
54
58
  entryPrice?: number;
55
59
  slippage?: number;
60
+ entryTimeoutSec?: number;
61
+ slSlippageBps?: number;
56
62
  leverage?: number;
57
63
  dryRun?: boolean;
58
64
  verbose?: boolean;
65
+ client?: BracketClient;
66
+ fillWatcher?: FillWatcher;
59
67
  /** Receives each output line. Defaults to console.log. */
60
68
  output?: (line: string) => void;
61
69
  }
62
70
 
71
+ export interface BracketClient {
72
+ verbose: boolean;
73
+ getAllMids(): Promise<Record<string, string>>;
74
+ marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
75
+ 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>;
77
+ address: string;
78
+ getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
79
+ }
80
+
63
81
  export interface BracketResult {
64
82
  status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
65
83
  entryPrice?: number;
@@ -68,6 +86,7 @@ export interface BracketResult {
68
86
  tpOid?: number | null;
69
87
  slOid?: number | null;
70
88
  entryOid?: number | null;
89
+ protectedSize?: number;
71
90
  reason?: string;
72
91
  }
73
92
 
@@ -82,7 +101,7 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
82
101
  throw new Error('entryPrice is required for limit entry');
83
102
  }
84
103
 
85
- const client = getClient();
104
+ const client = opts.client ?? getClient();
86
105
  if (opts.verbose) client.verbose = true;
87
106
 
88
107
  out('Open Broker - Bracket Order');
@@ -135,48 +154,87 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
135
154
  out('Step 1: Entry order');
136
155
  let actualEntry = entry;
137
156
  let entryOid: number | null = null;
157
+ let filledSize = 0;
158
+ const ownsFillWatcher = !opts.fillWatcher;
159
+ const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: Date.now() });
138
160
 
139
- if (entryType === 'market') {
140
- const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
161
+ await fillWatcher.start();
141
162
 
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}`);
163
+ try {
164
+ if (entryType === 'market') {
165
+ const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
166
+
167
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
168
+ const status = entryResponse.response.data.statuses[0];
169
+ if (status?.filled) {
170
+ actualEntry = parseFloat(status.filled.avgPx);
171
+ filledSize = parseFloat(status.filled.totalSz);
172
+ out(` ✅ Filled ${filledSize} @ ${formatUsd(actualEntry)}`);
173
+ } else if (status?.error) {
174
+ out(` ❌ Entry failed: ${status.error}`);
175
+ out('\n⚠️ Bracket aborted - no position opened');
176
+ return { status: 'entry_failed', reason: status.error };
177
+ } else {
178
+ out(` ❌ Entry failed: unexpected response`);
179
+ out('\n⚠️ Bracket aborted - no confirmed position opened');
180
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
181
+ }
182
+ } else {
183
+ const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
184
+ out(` ❌ Entry failed: ${reason}`);
149
185
  out('\n⚠️ Bracket aborted - no position opened');
150
- return { status: 'entry_failed', reason: status.error };
186
+ return { status: 'entry_failed', reason };
151
187
  }
152
188
  } 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');
156
- return { status: 'entry_failed', reason };
157
- }
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 };
189
+ const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
190
+
191
+ if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
192
+ const status = entryResponse.response.data.statuses[0];
193
+ if (status?.resting) {
194
+ entryOid = status.resting.oid;
195
+ const entryTimeoutSec = opts.entryTimeoutSec ?? 300;
196
+ out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
197
+
198
+ if (entryTimeoutSec <= 0) {
199
+ out(` ⏳ Entry resting; TP/SL not armed until a fill is confirmed.`);
200
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
201
+ }
202
+
203
+ out(` ⏳ Waiting up to ${entryTimeoutSec}s for fill confirmation...`);
204
+ const fill = await fillWatcher.waitForFill(entryOid, opts.size, entryTimeoutSec * 1000, { coin: opts.coin });
205
+ if (fill.size <= 0) {
206
+ out(` ⚠️ Entry still resting after ${entryTimeoutSec}s; TP/SL not armed.`);
207
+ return { status: 'limit_resting', entryOid, entryPrice: entry };
208
+ }
209
+ filledSize = Math.min(fill.size, opts.size);
210
+ actualEntry = fill.avgPrice ?? entry;
211
+ out(` ✅ Fill confirmed: ${filledSize} @ ${formatUsd(actualEntry)}`);
212
+ if (filledSize < opts.size * 0.999) {
213
+ out(` ⚠️ Partial entry fill; arming TP/SL for filled size only.`);
214
+ }
215
+ } else if (status?.filled) {
216
+ actualEntry = parseFloat(status.filled.avgPx);
217
+ filledSize = parseFloat(status.filled.totalSz);
218
+ out(` ✅ Filled immediately ${filledSize} @ ${formatUsd(actualEntry)}`);
219
+ } else if (status?.error) {
220
+ out(` ❌ Entry failed: ${status.error}`);
221
+ return { status: 'entry_failed', reason: status.error };
222
+ } else {
223
+ out(` ❌ Entry failed: unexpected response`);
224
+ return { status: 'entry_failed', reason: 'Unexpected entry response' };
225
+ }
226
+ } else {
227
+ out(` ❌ Entry failed`);
228
+ return { status: 'entry_failed', reason: 'Unknown error' };
175
229
  }
176
- } else {
177
- out(` ❌ Entry failed`);
178
- return { status: 'entry_failed', reason: 'Unknown error' };
179
230
  }
231
+ } finally {
232
+ if (ownsFillWatcher) await fillWatcher.stop();
233
+ }
234
+
235
+ if (!Number.isFinite(filledSize) || filledSize <= 0) {
236
+ out('\n⚠️ Bracket aborted - no confirmed fill size');
237
+ return { status: 'entry_failed', reason: 'No confirmed fill size' };
180
238
  }
181
239
 
182
240
  // Recalculate TP/SL based on actual entry
@@ -190,59 +248,52 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
190
248
 
191
249
  await sleep(500);
192
250
 
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);
251
+ // Step 2: Paired TP/SL trigger orders
252
+ out('\nStep 2: Paired TP/SL trigger orders');
253
+ 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
+ );
197
263
 
198
264
  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;
265
+ let slOid: number | null = null;
266
+ 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;
203
270
  out(` ✅ TP trigger placed @ ${formatUsd(tpPrice)} (OID: ${tpOid})`);
204
- } else if (status?.error) {
205
- out(` ❌ TP failed: ${status.error}`);
271
+ } else if (tpStatus?.error) {
272
+ out(` ❌ TP failed: ${tpStatus.error}`);
206
273
  } else {
207
- out(` ⚠️ TP status: ${JSON.stringify(status)}`);
274
+ out(` ⚠️ TP status: ${JSON.stringify(tpStatus)}`);
208
275
  }
209
- } else {
210
- const reason = typeof tpResponse.response === 'string' ? tpResponse.response : 'Unknown error';
211
- out(` ❌ TP failed: ${reason}`);
212
- }
213
276
 
214
- await sleep(500);
215
-
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);
220
-
221
- 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;
277
+ if (slStatus?.resting) {
278
+ slOid = slStatus.resting.oid;
226
279
  out(` ✅ SL trigger placed @ ${formatUsd(slPrice)} (OID: ${slOid})`);
227
- } else if (status?.error) {
228
- out(` ❌ SL failed: ${status.error}`);
280
+ } else if (slStatus?.error) {
281
+ out(` ❌ SL failed: ${slStatus.error}`);
229
282
  } else {
230
- out(` ⚠️ SL status: ${JSON.stringify(status)}`);
283
+ out(` ⚠️ SL status: ${JSON.stringify(slStatus)}`);
231
284
  }
232
285
  } else {
233
- const reason = typeof slResponse.response === 'string' ? slResponse.response : 'Unknown error';
234
- out(` ❌ SL failed: ${reason}`);
286
+ const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
287
+ out(` ❌ TP/SL pair failed: ${reason}`);
235
288
  }
236
289
 
237
290
  out('\n========== Bracket Summary ==========');
238
- out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
291
+ out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
239
292
  out(`Entry: ${formatUsd(actualEntry)}`);
240
293
  out(`Take Profit: ${formatUsd(tpPrice)} (+${opts.tpPct}%) - Trigger order`);
241
294
  out(`Stop Loss: ${formatUsd(slPrice)} (-${opts.slPct}%) - Trigger order`);
242
295
  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.`);
296
+ out(`\n✅ Bracket complete! TP and SL are linked trigger orders.`);
246
297
  }
247
298
 
248
299
  return {
@@ -252,6 +303,7 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
252
303
  slPrice,
253
304
  tpOid,
254
305
  slOid,
306
+ protectedSize: filledSize,
255
307
  };
256
308
  }
257
309
 
@@ -266,6 +318,8 @@ async function main() {
266
318
  const tpPct = parseFloat(args.tp as string);
267
319
  const slPct = parseFloat(args.sl as string);
268
320
  const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
321
+ const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout'] as string) : undefined;
322
+ const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : undefined;
269
323
  const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
270
324
  const dryRun = args.dry as boolean;
271
325
 
@@ -288,6 +342,8 @@ async function main() {
288
342
  entryType,
289
343
  entryPrice,
290
344
  slippage,
345
+ entryTimeoutSec,
346
+ slSlippageBps,
291
347
  leverage,
292
348
  dryRun,
293
349
  verbose: args.verbose as boolean,