openbroker 1.9.5 → 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.
- package/README.md +6 -4
- package/SKILL.md +2 -2
- package/dist/core/client.d.ts +6 -0
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +71 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/operations/advanced-orders.test.d.ts +2 -0
- package/dist/operations/advanced-orders.test.d.ts.map +1 -0
- package/dist/operations/advanced-orders.test.js +189 -0
- package/dist/operations/bracket.d.ts +22 -0
- package/dist/operations/bracket.d.ts.map +1 -1
- package/dist/operations/bracket.js +106 -73
- package/dist/operations/chase.d.ts +19 -0
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +125 -58
- package/dist/operations/execution.d.ts +53 -0
- package/dist/operations/execution.d.ts.map +1 -0
- package/dist/operations/execution.js +106 -0
- package/dist/operations/scale.d.ts +50 -1
- package/dist/operations/scale.d.ts.map +1 -1
- package/dist/operations/scale.js +143 -105
- package/package.json +3 -2
- package/scripts/core/client.ts +91 -0
- package/scripts/lib.ts +3 -0
- package/scripts/operations/advanced-orders.test.ts +209 -0
- package/scripts/operations/bracket.ts +128 -72
- package/scripts/operations/chase.ts +138 -61
- package/scripts/operations/execution.ts +138 -0
- package/scripts/operations/scale.ts +186 -131
|
@@ -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
|
-
|
|
140
|
-
const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
|
|
161
|
+
await fillWatcher.start();
|
|
141
162
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
186
|
+
return { status: 'entry_failed', reason };
|
|
151
187
|
}
|
|
152
188
|
} else {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
194
|
-
out('\nStep 2:
|
|
195
|
-
const
|
|
196
|
-
const
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 (
|
|
205
|
-
out(` ❌ TP failed: ${
|
|
271
|
+
} else if (tpStatus?.error) {
|
|
272
|
+
out(` ❌ TP failed: ${tpStatus.error}`);
|
|
206
273
|
} else {
|
|
207
|
-
out(` ⚠️ TP status: ${JSON.stringify(
|
|
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
|
-
|
|
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 (
|
|
228
|
-
out(` ❌ SL failed: ${
|
|
280
|
+
} else if (slStatus?.error) {
|
|
281
|
+
out(` ❌ SL failed: ${slStatus.error}`);
|
|
229
282
|
} else {
|
|
230
|
-
out(` ⚠️ SL status: ${JSON.stringify(
|
|
283
|
+
out(` ⚠️ SL status: ${JSON.stringify(slStatus)}`);
|
|
231
284
|
}
|
|
232
285
|
} else {
|
|
233
|
-
const reason = typeof
|
|
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'} ${
|
|
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,
|