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.
- package/CHANGELOG.md +19 -0
- package/README.md +6 -4
- package/SKILL.md +14 -5
- package/dist/core/client.d.ts +63 -3
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +246 -11
- package/dist/core/utils.d.ts +29 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +27 -0
- package/dist/lib.d.ts +4 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +2 -1
- 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 +478 -0
- package/dist/operations/bracket.d.ts +55 -3
- package/dist/operations/bracket.d.ts.map +1 -1
- package/dist/operations/bracket.js +324 -117
- package/dist/operations/chase.d.ts +20 -1
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +169 -67
- 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 +152 -105
- package/dist/operations/set-tpsl.js +69 -57
- package/dist/operations/trigger-order.js +18 -6
- package/dist/operations/twap.js +13 -1
- package/package.json +3 -2
- package/scripts/core/client.ts +320 -10
- package/scripts/core/utils.ts +37 -0
- package/scripts/lib.ts +6 -0
- package/scripts/operations/advanced-orders.test.ts +542 -0
- package/scripts/operations/bracket.ts +350 -115
- package/scripts/operations/chase.ts +183 -73
- package/scripts/operations/execution.ts +138 -0
- package/scripts/operations/scale.ts +195 -131
- package/scripts/operations/set-tpsl.ts +62 -57
- package/scripts/operations/trigger-order.ts +20 -6
- package/scripts/operations/twap.ts +14 -1
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { getClient } from '../core/client.js';
|
|
6
|
-
import {
|
|
6
|
+
import type { CancelResponse, OrderResponse } from '../core/types.js';
|
|
7
|
+
import { formatUsd, parseArgs, parseOrderStatus, sleep } from '../core/utils.js';
|
|
8
|
+
import { UserFillWatcher, type FillWatcher } from './execution.js';
|
|
7
9
|
|
|
8
10
|
function printUsage() {
|
|
9
11
|
console.log(`
|
|
@@ -24,10 +26,24 @@ Options:
|
|
|
24
26
|
--price Entry price (required if --entry limit)
|
|
25
27
|
--tp Take profit distance in % from entry
|
|
26
28
|
--sl Stop loss distance in % from entry
|
|
29
|
+
--tp-price Take profit at an absolute price (instead of --tp)
|
|
30
|
+
--sl-price Stop loss at an absolute price (instead of --sl)
|
|
27
31
|
--slippage Slippage for market entry in bps (default: 50)
|
|
32
|
+
--entry-timeout Seconds to wait for limit entry fill before returning
|
|
33
|
+
(default: 300; only used with --no-atomic)
|
|
34
|
+
--sl-slippage Stop-loss fill cap past the trigger in bps (default: 100)
|
|
35
|
+
--sl-limit Place the SL as a stop-limit instead of a market trigger.
|
|
36
|
+
Warning: a gap move past the limit band can skip the stop
|
|
37
|
+
entirely and leave the position unprotected.
|
|
38
|
+
--no-atomic For limit entries: place the entry alone and arm TP/SL only
|
|
39
|
+
after a confirmed fill, instead of the default atomic batch
|
|
40
|
+
where the exchange arms TP/SL on fill server-side.
|
|
28
41
|
--leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
|
|
29
42
|
--dry Dry run - show bracket plan without executing
|
|
30
43
|
|
|
44
|
+
At least one of --tp/--tp-price/--sl/--sl-price is required; one-sided
|
|
45
|
+
brackets (TP-only or SL-only) are supported.
|
|
46
|
+
|
|
31
47
|
Take Profit / Stop Loss:
|
|
32
48
|
For LONG (buy): TP is above entry, SL is below entry
|
|
33
49
|
For SHORT (sell): TP is below entry, SL is above entry
|
|
@@ -36,9 +52,12 @@ Examples:
|
|
|
36
52
|
# Long ETH with 3% take profit and 1.5% stop loss
|
|
37
53
|
npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
|
|
38
54
|
|
|
39
|
-
# Short BTC with limit entry at $100k, 5% TP, 2% SL
|
|
55
|
+
# Short BTC with limit entry at $100k, 5% TP, 2% SL (armed atomically on fill)
|
|
40
56
|
npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
|
|
41
57
|
|
|
58
|
+
# Long with absolute targets, SL only
|
|
59
|
+
npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --sl-price 120
|
|
60
|
+
|
|
42
61
|
# Preview bracket setup
|
|
43
62
|
npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
|
|
44
63
|
`);
|
|
@@ -48,41 +67,124 @@ export interface BracketOptions {
|
|
|
48
67
|
coin: string;
|
|
49
68
|
side: 'buy' | 'sell';
|
|
50
69
|
size: number;
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
/** Take profit distance in % from entry. Optional if tpPrice/sl* given. */
|
|
71
|
+
tpPct?: number;
|
|
72
|
+
/** Stop loss distance in % from entry. Optional if slPrice/tp* given. */
|
|
73
|
+
slPct?: number;
|
|
74
|
+
/** Absolute take profit price (takes precedence over tpPct). */
|
|
75
|
+
tpPrice?: number;
|
|
76
|
+
/** Absolute stop loss trigger price (takes precedence over slPct). */
|
|
77
|
+
slPrice?: number;
|
|
53
78
|
entryType?: 'market' | 'limit';
|
|
54
79
|
entryPrice?: number;
|
|
55
80
|
slippage?: number;
|
|
81
|
+
entryTimeoutSec?: number;
|
|
82
|
+
slSlippageBps?: number;
|
|
83
|
+
/** SL fires as a market trigger (default true); false = stop-limit. */
|
|
84
|
+
slMarket?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Limit entries only: submit entry + TP/SL as one atomic normalTpsl batch
|
|
87
|
+
* (default true) — the exchange arms the exits when the entry fills, so the
|
|
88
|
+
* bracket survives this process exiting. false = legacy fill-watch path.
|
|
89
|
+
*/
|
|
90
|
+
atomic?: boolean;
|
|
56
91
|
leverage?: number;
|
|
57
92
|
dryRun?: boolean;
|
|
58
93
|
verbose?: boolean;
|
|
94
|
+
client?: BracketClient;
|
|
95
|
+
fillWatcher?: FillWatcher;
|
|
59
96
|
/** Receives each output line. Defaults to console.log. */
|
|
60
97
|
output?: (line: string) => void;
|
|
61
98
|
}
|
|
62
99
|
|
|
100
|
+
export interface BracketClient {
|
|
101
|
+
verbose: boolean;
|
|
102
|
+
getAllMids(): Promise<Record<string, string>>;
|
|
103
|
+
marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
|
|
104
|
+
limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
|
|
105
|
+
tpslOrders(coin: string, isBuy: boolean, size: number, opts?: {
|
|
106
|
+
takeProfitPrice?: number;
|
|
107
|
+
stopLossPrice?: number;
|
|
108
|
+
stopLossSlippageBps?: number;
|
|
109
|
+
stopLossIsMarket?: boolean;
|
|
110
|
+
grouping?: 'positionTpsl' | 'normalTpsl';
|
|
111
|
+
leverage?: number;
|
|
112
|
+
}): Promise<OrderResponse>;
|
|
113
|
+
bracketOrder(coin: string, isBuy: boolean, size: number, entryPrice: number, opts?: {
|
|
114
|
+
entryTif?: 'Gtc' | 'Alo';
|
|
115
|
+
takeProfitPrice?: number;
|
|
116
|
+
stopLossPrice?: number;
|
|
117
|
+
stopLossSlippageBps?: number;
|
|
118
|
+
stopLossIsMarket?: boolean;
|
|
119
|
+
leverage?: number;
|
|
120
|
+
}): Promise<OrderResponse>;
|
|
121
|
+
cancel(coin: string, oid: number): Promise<CancelResponse>;
|
|
122
|
+
address: string;
|
|
123
|
+
getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
|
|
124
|
+
}
|
|
125
|
+
|
|
63
126
|
export interface BracketResult {
|
|
64
|
-
status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
127
|
+
status: 'dry' | 'armed' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
65
128
|
entryPrice?: number;
|
|
66
129
|
tpPrice?: number;
|
|
67
130
|
slPrice?: number;
|
|
68
131
|
tpOid?: number | null;
|
|
69
132
|
slOid?: number | null;
|
|
70
133
|
entryOid?: number | null;
|
|
134
|
+
protectedSize?: number;
|
|
71
135
|
reason?: string;
|
|
72
136
|
}
|
|
73
137
|
|
|
138
|
+
interface ResolvedTargets {
|
|
139
|
+
tpPrice: number | null;
|
|
140
|
+
slPrice: number | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
74
143
|
export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
75
144
|
const out = opts.output ?? ((line: string) => console.log(line));
|
|
76
145
|
const entryType = opts.entryType ?? 'market';
|
|
146
|
+
const slMarket = opts.slMarket !== false;
|
|
147
|
+
const atomic = opts.atomic !== false;
|
|
77
148
|
const isLong = opts.side === 'buy';
|
|
78
149
|
|
|
150
|
+
const hasTp = (opts.tpPct ?? 0) > 0 || (opts.tpPrice ?? 0) > 0;
|
|
151
|
+
const hasSl = (opts.slPct ?? 0) > 0 || (opts.slPrice ?? 0) > 0;
|
|
152
|
+
|
|
79
153
|
if (opts.size <= 0 || isNaN(opts.size)) throw new Error('size must be positive');
|
|
80
|
-
if (
|
|
154
|
+
if (!hasTp && !hasSl) throw new Error('provide a TP target, an SL target, or both (tpPct/tpPrice/slPct/slPrice)');
|
|
81
155
|
if (entryType === 'limit' && opts.entryPrice === undefined) {
|
|
82
156
|
throw new Error('entryPrice is required for limit entry');
|
|
83
157
|
}
|
|
84
158
|
|
|
85
|
-
|
|
159
|
+
// Resolve percent targets off the reference entry and validate every provided leg.
|
|
160
|
+
const resolveTargets = (entryPrice: number): ResolvedTargets => {
|
|
161
|
+
const tpPrice = !hasTp
|
|
162
|
+
? null
|
|
163
|
+
: (opts.tpPrice ?? 0) > 0
|
|
164
|
+
? opts.tpPrice!
|
|
165
|
+
: isLong
|
|
166
|
+
? entryPrice * (1 + opts.tpPct! / 100)
|
|
167
|
+
: entryPrice * (1 - opts.tpPct! / 100);
|
|
168
|
+
const slPrice = !hasSl
|
|
169
|
+
? null
|
|
170
|
+
: (opts.slPrice ?? 0) > 0
|
|
171
|
+
? opts.slPrice!
|
|
172
|
+
: isLong
|
|
173
|
+
? entryPrice * (1 - opts.slPct! / 100)
|
|
174
|
+
: entryPrice * (1 + opts.slPct! / 100);
|
|
175
|
+
if (tpPrice !== null && tpPrice <= 0) throw new Error('TP must resolve to a positive price (keep TP% under 100 on shorts)');
|
|
176
|
+
if (slPrice !== null && slPrice <= 0) throw new Error('SL must resolve to a positive price (keep SL% under 100)');
|
|
177
|
+
if (isLong) {
|
|
178
|
+
if (tpPrice !== null && tpPrice <= entryPrice) throw new Error('for a long, TP price must be above entry');
|
|
179
|
+
if (slPrice !== null && slPrice >= entryPrice) throw new Error('for a long, SL price must be below entry');
|
|
180
|
+
} else {
|
|
181
|
+
if (tpPrice !== null && tpPrice >= entryPrice) throw new Error('for a short, TP price must be below entry');
|
|
182
|
+
if (slPrice !== null && slPrice <= entryPrice) throw new Error('for a short, SL price must be above entry');
|
|
183
|
+
}
|
|
184
|
+
return { tpPrice, slPrice };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const client = opts.client ?? getClient();
|
|
86
188
|
if (opts.verbose) client.verbose = true;
|
|
87
189
|
|
|
88
190
|
out('Open Broker - Bracket Order');
|
|
@@ -94,14 +196,13 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
|
94
196
|
|
|
95
197
|
const entry = entryType === 'limit' ? opts.entryPrice! : midPrice;
|
|
96
198
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
let slPrice = isLong
|
|
101
|
-
? entry * (1 - opts.slPct / 100)
|
|
102
|
-
: entry * (1 + opts.slPct / 100);
|
|
199
|
+
// Validate targets against the pre-trade reference BEFORE the entry goes out,
|
|
200
|
+
// so a bad TP/SL never leaves an unprotected position behind.
|
|
201
|
+
let targets = resolveTargets(entry);
|
|
103
202
|
|
|
104
|
-
const
|
|
203
|
+
const legsLabel = hasTp && hasSl ? 'TP/SL' : hasTp ? 'TP' : 'SL';
|
|
204
|
+
const tpDistPct = targets.tpPrice !== null ? Math.abs(targets.tpPrice - entry) / entry * 100 : null;
|
|
205
|
+
const slDistPct = targets.slPrice !== null ? Math.abs(targets.slPrice - entry) / entry * 100 : null;
|
|
105
206
|
const notional = entry * opts.size;
|
|
106
207
|
|
|
107
208
|
out('Bracket Plan');
|
|
@@ -109,149 +210,272 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
|
109
210
|
out(`Coin: ${opts.coin}`);
|
|
110
211
|
out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
|
|
111
212
|
out(`Size: ${opts.size}`);
|
|
112
|
-
out(`Entry Type: ${entryType.toUpperCase()}`);
|
|
213
|
+
out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
|
|
113
214
|
out(`Current Mid: ${formatUsd(midPrice)}`);
|
|
114
215
|
out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
|
|
115
|
-
out(`Take Profit: ${formatUsd(tpPrice)} (
|
|
116
|
-
out(`Stop Loss: ${formatUsd(slPrice)} (
|
|
117
|
-
|
|
216
|
+
if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)} (${tpDistPct!.toFixed(2)}% from entry)`);
|
|
217
|
+
if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slDistPct!.toFixed(2)}% from entry, ${slMarket ? 'market trigger' : 'stop-limit'})`);
|
|
218
|
+
if (tpDistPct !== null && slDistPct !== null && slDistPct > 0) {
|
|
219
|
+
out(`Risk/Reward: 1:${(tpDistPct / slDistPct).toFixed(2)}`);
|
|
220
|
+
}
|
|
118
221
|
out(`Est. Notional: ${formatUsd(notional)}`);
|
|
119
222
|
|
|
120
|
-
const potentialProfit = notional * (opts.tpPct / 100);
|
|
121
|
-
const potentialLoss = notional * (opts.slPct / 100);
|
|
122
223
|
out('\nRisk Analysis');
|
|
123
224
|
out('-------------');
|
|
124
|
-
out(`Potential Profit: ${formatUsd(
|
|
125
|
-
out(`Potential Loss: ${formatUsd(
|
|
225
|
+
if (targets.tpPrice !== null) out(`Potential Profit: ${formatUsd(Math.abs(targets.tpPrice - entry) * opts.size)}`);
|
|
226
|
+
if (targets.slPrice !== null) out(`Potential Loss: ${formatUsd(Math.abs(entry - targets.slPrice) * opts.size)}`);
|
|
126
227
|
|
|
127
228
|
if (opts.dryRun) {
|
|
128
229
|
out('\n🔍 Dry run - bracket not executed');
|
|
129
|
-
return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
|
|
230
|
+
return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
|
|
130
231
|
}
|
|
131
232
|
|
|
132
233
|
out('\nExecuting bracket...\n');
|
|
133
234
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
235
|
+
// ── Atomic path: limit entry + TP/SL in one normalTpsl batch ──────────
|
|
236
|
+
// The exchange arms the exits when the entry fills (children come back as
|
|
237
|
+
// "waitingForFill"), so the bracket survives this process exiting.
|
|
238
|
+
if (entryType === 'limit' && atomic) {
|
|
239
|
+
out(`Step 1: Limit entry + ${legsLabel} (atomic normalTpsl batch)`);
|
|
240
|
+
const response = await client.bracketOrder(opts.coin, isLong, opts.size, entry, {
|
|
241
|
+
takeProfitPrice: targets.tpPrice ?? undefined,
|
|
242
|
+
stopLossPrice: targets.slPrice ?? undefined,
|
|
243
|
+
stopLossSlippageBps: opts.slSlippageBps,
|
|
244
|
+
stopLossIsMarket: slMarket,
|
|
245
|
+
leverage: opts.leverage,
|
|
246
|
+
});
|
|
138
247
|
|
|
139
|
-
|
|
140
|
-
|
|
248
|
+
if (response.status !== 'ok' || !response.response || typeof response.response !== 'object') {
|
|
249
|
+
const reason = typeof response.response === 'string' ? response.response : 'Unknown error';
|
|
250
|
+
out(` ❌ Bracket failed: ${reason}`);
|
|
251
|
+
return { status: 'entry_failed', reason };
|
|
252
|
+
}
|
|
141
253
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
254
|
+
const statuses = response.response.data.statuses.map(parseOrderStatus);
|
|
255
|
+
const entryStatus = statuses[0];
|
|
256
|
+
const childStatuses = statuses.slice(1);
|
|
257
|
+
const failed = statuses.find((s) => s.kind === 'error' || s.kind === 'unknown');
|
|
258
|
+
if (failed) {
|
|
259
|
+
// Roll back anything that landed so no half-armed bracket is left behind.
|
|
260
|
+
const restingOids = statuses.flatMap((s) => (s.kind === 'resting' ? [s.oid] : []));
|
|
261
|
+
for (const oid of restingOids) {
|
|
262
|
+
try { await client.cancel(opts.coin, oid); } catch { /* may have filled */ }
|
|
151
263
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
out(`
|
|
155
|
-
out('\n⚠️ Bracket aborted - no position opened');
|
|
264
|
+
const reason = failed.kind === 'error' ? failed.error : `Unexpected order status: ${JSON.stringify(failed)}`;
|
|
265
|
+
out(` ❌ Bracket rejected: ${reason}`);
|
|
266
|
+
if (restingOids.length) out(` Cancelled ${restingOids.length} resting order(s).`);
|
|
156
267
|
return { status: 'entry_failed', reason };
|
|
157
268
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
269
|
+
|
|
270
|
+
let entryOid: number | null = null;
|
|
271
|
+
let filledEntry: { size: number; avgPx: number } | null = null;
|
|
272
|
+
if (entryStatus.kind === 'resting') {
|
|
273
|
+
entryOid = entryStatus.oid;
|
|
274
|
+
out(` ✅ Entry resting @ ${formatUsd(entry)} (OID: ${entryOid})`);
|
|
275
|
+
} else if (entryStatus.kind === 'filled') {
|
|
276
|
+
entryOid = entryStatus.oid;
|
|
277
|
+
filledEntry = { size: entryStatus.totalSz, avgPx: entryStatus.avgPx };
|
|
278
|
+
out(` ✅ Entry filled immediately: ${entryStatus.totalSz} @ ${formatUsd(entryStatus.avgPx)}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let tpOid: number | null = null;
|
|
282
|
+
let slOid: number | null = null;
|
|
283
|
+
let childIdx = 0;
|
|
284
|
+
for (const label of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
|
|
285
|
+
if (!label) continue;
|
|
286
|
+
const child = childStatuses[childIdx++];
|
|
287
|
+
if (!child) continue;
|
|
288
|
+
if (child.kind === 'waiting') {
|
|
289
|
+
out(` ✅ ${label} armed — activates when the entry fills (${child.state})`);
|
|
290
|
+
} else if (child.kind === 'resting') {
|
|
291
|
+
if (label === 'TP') tpOid = child.oid; else slOid = child.oid;
|
|
292
|
+
out(` ✅ ${label} trigger live (OID: ${child.oid})`);
|
|
175
293
|
}
|
|
176
|
-
} else {
|
|
177
|
-
out(` ❌ Entry failed`);
|
|
178
|
-
return { status: 'entry_failed', reason: 'Unknown error' };
|
|
179
294
|
}
|
|
180
|
-
}
|
|
181
295
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
296
|
+
out('\n========== Bracket Summary ==========');
|
|
297
|
+
out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${opts.size} ${opts.coin}`);
|
|
298
|
+
out(`Entry: ${formatUsd(filledEntry?.avgPx ?? entry)}${filledEntry ? '' : ' (resting)'}`);
|
|
299
|
+
if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)}`);
|
|
300
|
+
if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'})`);
|
|
301
|
+
out(filledEntry
|
|
302
|
+
? `\n✅ Bracket complete! ${legsLabel} triggers are live.`
|
|
303
|
+
: `\n✅ Bracket armed! ${legsLabel} activates server-side when the entry fills.`);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
status: filledEntry ? 'complete' : 'armed',
|
|
307
|
+
entryPrice: filledEntry?.avgPx ?? entry,
|
|
308
|
+
tpPrice: targets.tpPrice ?? undefined,
|
|
309
|
+
slPrice: targets.slPrice ?? undefined,
|
|
310
|
+
tpOid,
|
|
311
|
+
slOid,
|
|
312
|
+
entryOid,
|
|
313
|
+
protectedSize: filledEntry?.size ?? opts.size,
|
|
314
|
+
};
|
|
189
315
|
}
|
|
190
316
|
|
|
191
|
-
|
|
317
|
+
// ── Fill-first path: market entry, or limit entry with --no-atomic ────
|
|
318
|
+
out('Step 1: Entry order');
|
|
319
|
+
let actualEntry = entry;
|
|
320
|
+
let entryOid: number | null = null;
|
|
321
|
+
let filledSize = 0;
|
|
322
|
+
const ownsFillWatcher = !opts.fillWatcher;
|
|
323
|
+
const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: Date.now() });
|
|
192
324
|
|
|
193
|
-
|
|
194
|
-
out('\nStep 2: Take Profit order (trigger)');
|
|
195
|
-
const tpSide = !isLong;
|
|
196
|
-
const tpResponse = await client.takeProfit(opts.coin, tpSide, opts.size, tpPrice);
|
|
325
|
+
await fillWatcher.start();
|
|
197
326
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
327
|
+
try {
|
|
328
|
+
if (entryType === 'market') {
|
|
329
|
+
const entryResponse = await client.marketOrder(opts.coin, isLong, opts.size, opts.slippage, opts.leverage);
|
|
330
|
+
|
|
331
|
+
if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
|
|
332
|
+
const status = entryResponse.response.data.statuses[0];
|
|
333
|
+
if (status?.filled) {
|
|
334
|
+
actualEntry = parseFloat(status.filled.avgPx);
|
|
335
|
+
filledSize = parseFloat(status.filled.totalSz);
|
|
336
|
+
out(` ✅ Filled ${filledSize} @ ${formatUsd(actualEntry)}`);
|
|
337
|
+
} else if (status?.error) {
|
|
338
|
+
out(` ❌ Entry failed: ${status.error}`);
|
|
339
|
+
out('\n⚠️ Bracket aborted - no position opened');
|
|
340
|
+
return { status: 'entry_failed', reason: status.error };
|
|
341
|
+
} else {
|
|
342
|
+
out(` ❌ Entry failed: unexpected response`);
|
|
343
|
+
out('\n⚠️ Bracket aborted - no confirmed position opened');
|
|
344
|
+
return { status: 'entry_failed', reason: 'Unexpected entry response' };
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const reason = typeof entryResponse.response === 'string' ? entryResponse.response : 'Unknown error';
|
|
348
|
+
out(` ❌ Entry failed: ${reason}`);
|
|
349
|
+
out('\n⚠️ Bracket aborted - no position opened');
|
|
350
|
+
return { status: 'entry_failed', reason };
|
|
351
|
+
}
|
|
206
352
|
} else {
|
|
207
|
-
|
|
353
|
+
const entryResponse = await client.limitOrder(opts.coin, isLong, opts.size, entry, 'Gtc', false, opts.leverage);
|
|
354
|
+
|
|
355
|
+
if (entryResponse.status === 'ok' && entryResponse.response && typeof entryResponse.response === 'object') {
|
|
356
|
+
const status = entryResponse.response.data.statuses[0];
|
|
357
|
+
if (status?.resting) {
|
|
358
|
+
entryOid = status.resting.oid;
|
|
359
|
+
const entryTimeoutSec = opts.entryTimeoutSec ?? 300;
|
|
360
|
+
out(` ✅ Limit order placed @ ${formatUsd(entry)} (OID: ${entryOid})`);
|
|
361
|
+
|
|
362
|
+
if (entryTimeoutSec <= 0) {
|
|
363
|
+
out(` ⏳ Entry resting; TP/SL not armed until a fill is confirmed.`);
|
|
364
|
+
return { status: 'limit_resting', entryOid, entryPrice: entry };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
out(` ⏳ Waiting up to ${entryTimeoutSec}s for fill confirmation...`);
|
|
368
|
+
const fill = await fillWatcher.waitForFill(entryOid, opts.size, entryTimeoutSec * 1000, { coin: opts.coin });
|
|
369
|
+
if (fill.size <= 0) {
|
|
370
|
+
out(` ⚠️ Entry still resting after ${entryTimeoutSec}s; TP/SL not armed.`);
|
|
371
|
+
return { status: 'limit_resting', entryOid, entryPrice: entry };
|
|
372
|
+
}
|
|
373
|
+
filledSize = Math.min(fill.size, opts.size);
|
|
374
|
+
actualEntry = fill.avgPrice ?? entry;
|
|
375
|
+
out(` ✅ Fill confirmed: ${filledSize} @ ${formatUsd(actualEntry)}`);
|
|
376
|
+
if (filledSize < opts.size * 0.999) {
|
|
377
|
+
out(` ⚠️ Partial entry fill; arming TP/SL for filled size only.`);
|
|
378
|
+
}
|
|
379
|
+
} else if (status?.filled) {
|
|
380
|
+
actualEntry = parseFloat(status.filled.avgPx);
|
|
381
|
+
filledSize = parseFloat(status.filled.totalSz);
|
|
382
|
+
out(` ✅ Filled immediately ${filledSize} @ ${formatUsd(actualEntry)}`);
|
|
383
|
+
} else if (status?.error) {
|
|
384
|
+
out(` ❌ Entry failed: ${status.error}`);
|
|
385
|
+
return { status: 'entry_failed', reason: status.error };
|
|
386
|
+
} else {
|
|
387
|
+
out(` ❌ Entry failed: unexpected response`);
|
|
388
|
+
return { status: 'entry_failed', reason: 'Unexpected entry response' };
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
out(` ❌ Entry failed`);
|
|
392
|
+
return { status: 'entry_failed', reason: 'Unknown error' };
|
|
393
|
+
}
|
|
208
394
|
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
395
|
+
} finally {
|
|
396
|
+
if (ownsFillWatcher) await fillWatcher.stop();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!Number.isFinite(filledSize) || filledSize <= 0) {
|
|
400
|
+
out('\n⚠️ Bracket aborted - no confirmed fill size');
|
|
401
|
+
return { status: 'entry_failed', reason: 'No confirmed fill size' };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Re-resolve off the actual fill price; if the fill drifted past a fixed
|
|
405
|
+
// target, report it instead of leaving a half-armed bracket silently.
|
|
406
|
+
try {
|
|
407
|
+
targets = resolveTargets(actualEntry);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
410
|
+
out(`\n❌ Entry filled @ ${formatUsd(actualEntry)}, but ${legsLabel} could not be armed: ${reason}`);
|
|
411
|
+
out('⚠️ Position is OPEN and UNPROTECTED - set TP/SL manually (openbroker set-tpsl).');
|
|
412
|
+
return { status: 'partial', entryPrice: actualEntry, protectedSize: 0, reason };
|
|
212
413
|
}
|
|
213
414
|
|
|
214
415
|
await sleep(500);
|
|
215
416
|
|
|
216
|
-
// Step
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
417
|
+
// Step 2: TP/SL triggers tied to the now-open position (positionTpsl) —
|
|
418
|
+
// OCO between themselves and cancelled by the venue if the position closes.
|
|
419
|
+
out(`\nStep 2: Position ${legsLabel} trigger orders`);
|
|
420
|
+
const exitSide = !isLong;
|
|
421
|
+
const pairResponse = await client.tpslOrders(opts.coin, exitSide, filledSize, {
|
|
422
|
+
takeProfitPrice: targets.tpPrice ?? undefined,
|
|
423
|
+
stopLossPrice: targets.slPrice ?? undefined,
|
|
424
|
+
stopLossSlippageBps: opts.slSlippageBps,
|
|
425
|
+
stopLossIsMarket: slMarket,
|
|
426
|
+
grouping: 'positionTpsl',
|
|
427
|
+
leverage: opts.leverage,
|
|
428
|
+
});
|
|
220
429
|
|
|
430
|
+
let tpOid: number | null = null;
|
|
221
431
|
let slOid: number | null = null;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
432
|
+
let exitErrors = 0;
|
|
433
|
+
if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
|
|
434
|
+
const statuses = pairResponse.response.data.statuses.map(parseOrderStatus);
|
|
435
|
+
let idx = 0;
|
|
436
|
+
for (const leg of [targets.tpPrice !== null ? 'TP' : null, targets.slPrice !== null ? 'SL' : null]) {
|
|
437
|
+
if (!leg) continue;
|
|
438
|
+
const status = statuses[idx++];
|
|
439
|
+
const price = leg === 'TP' ? targets.tpPrice! : targets.slPrice!;
|
|
440
|
+
if (status?.kind === 'resting') {
|
|
441
|
+
if (leg === 'TP') tpOid = status.oid; else slOid = status.oid;
|
|
442
|
+
out(` ✅ ${leg} trigger placed @ ${formatUsd(price)} (OID: ${status.oid})`);
|
|
443
|
+
} else if (status?.kind === 'waiting') {
|
|
444
|
+
out(` ✅ ${leg} trigger armed @ ${formatUsd(price)} (${status.state})`);
|
|
445
|
+
} else if (status?.kind === 'error') {
|
|
446
|
+
exitErrors++;
|
|
447
|
+
out(` ❌ ${leg} failed: ${status.error}`);
|
|
448
|
+
} else {
|
|
449
|
+
exitErrors++;
|
|
450
|
+
out(` ⚠️ ${leg} status: ${JSON.stringify(status)}`);
|
|
451
|
+
}
|
|
231
452
|
}
|
|
232
453
|
} else {
|
|
233
|
-
|
|
234
|
-
|
|
454
|
+
exitErrors++;
|
|
455
|
+
const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
|
|
456
|
+
out(` ❌ ${legsLabel} orders failed: ${reason}`);
|
|
235
457
|
}
|
|
236
458
|
|
|
237
459
|
out('\n========== Bracket Summary ==========');
|
|
238
|
-
out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${
|
|
460
|
+
out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
|
|
239
461
|
out(`Entry: ${formatUsd(actualEntry)}`);
|
|
240
|
-
out(`Take Profit: ${formatUsd(tpPrice)}
|
|
241
|
-
out(`Stop Loss: ${formatUsd(slPrice)} (
|
|
242
|
-
if (
|
|
243
|
-
out(`\n✅ Bracket complete!
|
|
244
|
-
|
|
245
|
-
out(
|
|
462
|
+
if (targets.tpPrice !== null) out(`Take Profit: ${formatUsd(targets.tpPrice)} - Trigger order`);
|
|
463
|
+
if (targets.slPrice !== null) out(`Stop Loss: ${formatUsd(targets.slPrice)} (${slMarket ? 'market trigger' : 'stop-limit'}) - Trigger order`);
|
|
464
|
+
if (exitErrors === 0) {
|
|
465
|
+
out(`\n✅ Bracket complete! ${legsLabel} triggers track the position (positionTpsl).`);
|
|
466
|
+
} else {
|
|
467
|
+
out('\n⚠️ Position is open but not fully protected - set the missing trigger manually.');
|
|
246
468
|
}
|
|
247
469
|
|
|
248
470
|
return {
|
|
249
|
-
status:
|
|
471
|
+
status: exitErrors === 0 ? 'complete' : 'partial',
|
|
250
472
|
entryPrice: actualEntry,
|
|
251
|
-
tpPrice,
|
|
252
|
-
slPrice,
|
|
473
|
+
tpPrice: targets.tpPrice ?? undefined,
|
|
474
|
+
slPrice: targets.slPrice ?? undefined,
|
|
253
475
|
tpOid,
|
|
254
476
|
slOid,
|
|
477
|
+
entryOid,
|
|
478
|
+
protectedSize: filledSize,
|
|
255
479
|
};
|
|
256
480
|
}
|
|
257
481
|
|
|
@@ -263,13 +487,18 @@ async function main() {
|
|
|
263
487
|
const size = parseFloat(args.size as string);
|
|
264
488
|
const entryType = (args.entry as string || 'market') as 'market' | 'limit';
|
|
265
489
|
const entryPrice = args.price ? parseFloat(args.price as string) : undefined;
|
|
266
|
-
const tpPct = parseFloat(args.tp as string);
|
|
267
|
-
const slPct = parseFloat(args.sl as string);
|
|
490
|
+
const tpPct = args.tp ? parseFloat(args.tp as string) : undefined;
|
|
491
|
+
const slPct = args.sl ? parseFloat(args.sl as string) : undefined;
|
|
492
|
+
const tpPrice = args['tp-price'] ? parseFloat(args['tp-price'] as string) : undefined;
|
|
493
|
+
const slPrice = args['sl-price'] ? parseFloat(args['sl-price'] as string) : undefined;
|
|
268
494
|
const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
|
|
495
|
+
const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout'] as string) : undefined;
|
|
496
|
+
const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : undefined;
|
|
269
497
|
const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
|
|
270
498
|
const dryRun = args.dry as boolean;
|
|
271
499
|
|
|
272
|
-
|
|
500
|
+
const hasTarget = [tpPct, slPct, tpPrice, slPrice].some((v) => v !== undefined && !isNaN(v));
|
|
501
|
+
if (!coin || !side || isNaN(size) || !hasTarget) {
|
|
273
502
|
printUsage();
|
|
274
503
|
process.exit(1);
|
|
275
504
|
}
|
|
@@ -285,9 +514,15 @@ async function main() {
|
|
|
285
514
|
size,
|
|
286
515
|
tpPct,
|
|
287
516
|
slPct,
|
|
517
|
+
tpPrice,
|
|
518
|
+
slPrice,
|
|
288
519
|
entryType,
|
|
289
520
|
entryPrice,
|
|
290
521
|
slippage,
|
|
522
|
+
entryTimeoutSec,
|
|
523
|
+
slSlippageBps,
|
|
524
|
+
slMarket: !(args['sl-limit'] as boolean),
|
|
525
|
+
atomic: !(args['no-atomic'] as boolean),
|
|
291
526
|
leverage,
|
|
292
527
|
dryRun,
|
|
293
528
|
verbose: args.verbose as boolean,
|