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.
- package/CHANGELOG.md +19 -0
- package/SKILL.md +13 -4
- package/dist/core/client.d.ts +60 -6
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/client.js +190 -26
- 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 +2 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -1
- package/dist/operations/advanced-orders.test.js +302 -13
- package/dist/operations/bracket.d.ts +35 -5
- package/dist/operations/bracket.d.ts.map +1 -1
- package/dist/operations/bracket.js +238 -64
- package/dist/operations/chase.d.ts +1 -1
- package/dist/operations/chase.d.ts.map +1 -1
- package/dist/operations/chase.js +58 -23
- package/dist/operations/scale.d.ts.map +1 -1
- package/dist/operations/scale.js +10 -1
- 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 +1 -1
- package/scripts/core/client.ts +249 -30
- package/scripts/core/utils.ts +37 -0
- package/scripts/lib.ts +3 -0
- package/scripts/operations/advanced-orders.test.ts +347 -14
- package/scripts/operations/bracket.ts +252 -73
- package/scripts/operations/chase.ts +57 -24
- package/scripts/operations/scale.ts +10 -1
- package/scripts/operations/set-tpsl.ts +62 -57
- package/scripts/operations/trigger-order.ts +20 -6
- package/scripts/operations/twap.ts +14 -1
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { getClient } from '../core/client.js';
|
|
6
|
-
import type { OrderResponse } from '../core/types.js';
|
|
7
|
-
import { formatUsd, parseArgs, sleep } from '../core/utils.js';
|
|
6
|
+
import type { CancelResponse, OrderResponse } from '../core/types.js';
|
|
7
|
+
import { formatUsd, parseArgs, parseOrderStatus, sleep } from '../core/utils.js';
|
|
8
8
|
import { UserFillWatcher, type FillWatcher } from './execution.js';
|
|
9
9
|
|
|
10
10
|
function printUsage() {
|
|
@@ -26,12 +26,24 @@ Options:
|
|
|
26
26
|
--price Entry price (required if --entry limit)
|
|
27
27
|
--tp Take profit distance in % from entry
|
|
28
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)
|
|
29
31
|
--slippage Slippage for market entry in bps (default: 50)
|
|
30
|
-
--entry-timeout Seconds to wait for limit entry fill before returning
|
|
31
|
-
|
|
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.
|
|
32
41
|
--leverage Set leverage (e.g., 10 for 10x). Cross for main perps, isolated for HIP-3
|
|
33
42
|
--dry Dry run - show bracket plan without executing
|
|
34
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
|
+
|
|
35
47
|
Take Profit / Stop Loss:
|
|
36
48
|
For LONG (buy): TP is above entry, SL is below entry
|
|
37
49
|
For SHORT (sell): TP is below entry, SL is above entry
|
|
@@ -40,9 +52,12 @@ Examples:
|
|
|
40
52
|
# Long ETH with 3% take profit and 1.5% stop loss
|
|
41
53
|
npx tsx scripts/operations/bracket.ts --coin ETH --side buy --size 0.5 --tp 3 --sl 1.5
|
|
42
54
|
|
|
43
|
-
# 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)
|
|
44
56
|
npx tsx scripts/operations/bracket.ts --coin BTC --side sell --size 0.1 --entry limit --price 100000 --tp 5 --sl 2
|
|
45
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
|
+
|
|
46
61
|
# Preview bracket setup
|
|
47
62
|
npx tsx scripts/operations/bracket.ts --coin SOL --side buy --size 10 --tp 5 --sl 2 --dry
|
|
48
63
|
`);
|
|
@@ -52,13 +67,27 @@ export interface BracketOptions {
|
|
|
52
67
|
coin: string;
|
|
53
68
|
side: 'buy' | 'sell';
|
|
54
69
|
size: number;
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
|
57
78
|
entryType?: 'market' | 'limit';
|
|
58
79
|
entryPrice?: number;
|
|
59
80
|
slippage?: number;
|
|
60
81
|
entryTimeoutSec?: number;
|
|
61
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;
|
|
62
91
|
leverage?: number;
|
|
63
92
|
dryRun?: boolean;
|
|
64
93
|
verbose?: boolean;
|
|
@@ -73,13 +102,29 @@ export interface BracketClient {
|
|
|
73
102
|
getAllMids(): Promise<Record<string, string>>;
|
|
74
103
|
marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
|
|
75
104
|
limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
|
|
76
|
-
|
|
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>;
|
|
77
122
|
address: string;
|
|
78
123
|
getUserFills(user?: string): Promise<Array<{ coin: string; px: string; sz: string; time: number; oid: number }>>;
|
|
79
124
|
}
|
|
80
125
|
|
|
81
126
|
export interface BracketResult {
|
|
82
|
-
status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
127
|
+
status: 'dry' | 'armed' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
83
128
|
entryPrice?: number;
|
|
84
129
|
tpPrice?: number;
|
|
85
130
|
slPrice?: number;
|
|
@@ -90,17 +135,55 @@ export interface BracketResult {
|
|
|
90
135
|
reason?: string;
|
|
91
136
|
}
|
|
92
137
|
|
|
138
|
+
interface ResolvedTargets {
|
|
139
|
+
tpPrice: number | null;
|
|
140
|
+
slPrice: number | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
94
144
|
const out = opts.output ?? ((line: string) => console.log(line));
|
|
95
145
|
const entryType = opts.entryType ?? 'market';
|
|
146
|
+
const slMarket = opts.slMarket !== false;
|
|
147
|
+
const atomic = opts.atomic !== false;
|
|
96
148
|
const isLong = opts.side === 'buy';
|
|
97
149
|
|
|
150
|
+
const hasTp = (opts.tpPct ?? 0) > 0 || (opts.tpPrice ?? 0) > 0;
|
|
151
|
+
const hasSl = (opts.slPct ?? 0) > 0 || (opts.slPrice ?? 0) > 0;
|
|
152
|
+
|
|
98
153
|
if (opts.size <= 0 || isNaN(opts.size)) throw new Error('size must be positive');
|
|
99
|
-
if (
|
|
154
|
+
if (!hasTp && !hasSl) throw new Error('provide a TP target, an SL target, or both (tpPct/tpPrice/slPct/slPrice)');
|
|
100
155
|
if (entryType === 'limit' && opts.entryPrice === undefined) {
|
|
101
156
|
throw new Error('entryPrice is required for limit entry');
|
|
102
157
|
}
|
|
103
158
|
|
|
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
|
+
|
|
104
187
|
const client = opts.client ?? getClient();
|
|
105
188
|
if (opts.verbose) client.verbose = true;
|
|
106
189
|
|
|
@@ -113,14 +196,13 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
|
113
196
|
|
|
114
197
|
const entry = entryType === 'limit' ? opts.entryPrice! : midPrice;
|
|
115
198
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
let slPrice = isLong
|
|
120
|
-
? entry * (1 - opts.slPct / 100)
|
|
121
|
-
: 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);
|
|
122
202
|
|
|
123
|
-
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;
|
|
124
206
|
const notional = entry * opts.size;
|
|
125
207
|
|
|
126
208
|
out('Bracket Plan');
|
|
@@ -128,29 +210,111 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
|
128
210
|
out(`Coin: ${opts.coin}`);
|
|
129
211
|
out(`Position: ${isLong ? 'LONG' : 'SHORT'}`);
|
|
130
212
|
out(`Size: ${opts.size}`);
|
|
131
|
-
out(`Entry Type: ${entryType.toUpperCase()}`);
|
|
213
|
+
out(`Entry Type: ${entryType.toUpperCase()}${entryType === 'limit' ? (atomic ? ' (atomic TP/SL)' : ' (fill-watch TP/SL)') : ''}`);
|
|
132
214
|
out(`Current Mid: ${formatUsd(midPrice)}`);
|
|
133
215
|
out(`Entry Price: ${formatUsd(entry)}${entryType === 'market' ? ' (approx)' : ''}`);
|
|
134
|
-
out(`Take Profit: ${formatUsd(tpPrice)} (
|
|
135
|
-
out(`Stop Loss: ${formatUsd(slPrice)} (
|
|
136
|
-
|
|
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
|
+
}
|
|
137
221
|
out(`Est. Notional: ${formatUsd(notional)}`);
|
|
138
222
|
|
|
139
|
-
const potentialProfit = notional * (opts.tpPct / 100);
|
|
140
|
-
const potentialLoss = notional * (opts.slPct / 100);
|
|
141
223
|
out('\nRisk Analysis');
|
|
142
224
|
out('-------------');
|
|
143
|
-
out(`Potential Profit: ${formatUsd(
|
|
144
|
-
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)}`);
|
|
145
227
|
|
|
146
228
|
if (opts.dryRun) {
|
|
147
229
|
out('\n🔍 Dry run - bracket not executed');
|
|
148
|
-
return { status: 'dry', entryPrice: entry, tpPrice, slPrice };
|
|
230
|
+
return { status: 'dry', entryPrice: entry, tpPrice: targets.tpPrice ?? undefined, slPrice: targets.slPrice ?? undefined };
|
|
149
231
|
}
|
|
150
232
|
|
|
151
233
|
out('\nExecuting bracket...\n');
|
|
152
234
|
|
|
153
|
-
//
|
|
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
|
+
});
|
|
247
|
+
|
|
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
|
+
}
|
|
253
|
+
|
|
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 */ }
|
|
263
|
+
}
|
|
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).`);
|
|
267
|
+
return { status: 'entry_failed', reason };
|
|
268
|
+
}
|
|
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})`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
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
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Fill-first path: market entry, or limit entry with --no-atomic ────
|
|
154
318
|
out('Step 1: Entry order');
|
|
155
319
|
let actualEntry = entry;
|
|
156
320
|
let entryOid: number | null = null;
|
|
@@ -237,72 +401,80 @@ export async function runBracket(opts: BracketOptions): Promise<BracketResult> {
|
|
|
237
401
|
return { status: 'entry_failed', reason: 'No confirmed fill size' };
|
|
238
402
|
}
|
|
239
403
|
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
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 };
|
|
247
413
|
}
|
|
248
414
|
|
|
249
415
|
await sleep(500);
|
|
250
416
|
|
|
251
|
-
// Step 2:
|
|
252
|
-
|
|
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`);
|
|
253
420
|
const exitSide = !isLong;
|
|
254
|
-
const pairResponse = await client.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
opts.
|
|
261
|
-
|
|
262
|
-
);
|
|
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
|
+
});
|
|
263
429
|
|
|
264
430
|
let tpOid: number | null = null;
|
|
265
431
|
let slOid: number | null = null;
|
|
432
|
+
let exitErrors = 0;
|
|
266
433
|
if (pairResponse.status === 'ok' && pairResponse.response && typeof pairResponse.response === 'object') {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|
|
284
452
|
}
|
|
285
453
|
} else {
|
|
454
|
+
exitErrors++;
|
|
286
455
|
const reason = typeof pairResponse.response === 'string' ? pairResponse.response : 'Unknown error';
|
|
287
|
-
out(` ❌
|
|
456
|
+
out(` ❌ ${legsLabel} orders failed: ${reason}`);
|
|
288
457
|
}
|
|
289
458
|
|
|
290
459
|
out('\n========== Bracket Summary ==========');
|
|
291
460
|
out(`Position: ${isLong ? 'LONG' : 'SHORT'} ${filledSize} ${opts.coin}`);
|
|
292
461
|
out(`Entry: ${formatUsd(actualEntry)}`);
|
|
293
|
-
out(`Take Profit: ${formatUsd(tpPrice)}
|
|
294
|
-
out(`Stop Loss: ${formatUsd(slPrice)} (
|
|
295
|
-
if (
|
|
296
|
-
out(`\n✅ Bracket complete!
|
|
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.');
|
|
297
468
|
}
|
|
298
469
|
|
|
299
470
|
return {
|
|
300
|
-
status:
|
|
471
|
+
status: exitErrors === 0 ? 'complete' : 'partial',
|
|
301
472
|
entryPrice: actualEntry,
|
|
302
|
-
tpPrice,
|
|
303
|
-
slPrice,
|
|
473
|
+
tpPrice: targets.tpPrice ?? undefined,
|
|
474
|
+
slPrice: targets.slPrice ?? undefined,
|
|
304
475
|
tpOid,
|
|
305
476
|
slOid,
|
|
477
|
+
entryOid,
|
|
306
478
|
protectedSize: filledSize,
|
|
307
479
|
};
|
|
308
480
|
}
|
|
@@ -315,15 +487,18 @@ async function main() {
|
|
|
315
487
|
const size = parseFloat(args.size as string);
|
|
316
488
|
const entryType = (args.entry as string || 'market') as 'market' | 'limit';
|
|
317
489
|
const entryPrice = args.price ? parseFloat(args.price as string) : undefined;
|
|
318
|
-
const tpPct = parseFloat(args.tp as string);
|
|
319
|
-
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;
|
|
320
494
|
const slippage = args.slippage ? parseInt(args.slippage as string) : undefined;
|
|
321
495
|
const entryTimeoutSec = args['entry-timeout'] ? parseInt(args['entry-timeout'] as string) : undefined;
|
|
322
496
|
const slSlippageBps = args['sl-slippage'] ? parseInt(args['sl-slippage'] as string) : undefined;
|
|
323
497
|
const leverage = args.leverage ? parseInt(args.leverage as string) : undefined;
|
|
324
498
|
const dryRun = args.dry as boolean;
|
|
325
499
|
|
|
326
|
-
|
|
500
|
+
const hasTarget = [tpPct, slPct, tpPrice, slPrice].some((v) => v !== undefined && !isNaN(v));
|
|
501
|
+
if (!coin || !side || isNaN(size) || !hasTarget) {
|
|
327
502
|
printUsage();
|
|
328
503
|
process.exit(1);
|
|
329
504
|
}
|
|
@@ -339,11 +514,15 @@ async function main() {
|
|
|
339
514
|
size,
|
|
340
515
|
tpPct,
|
|
341
516
|
slPct,
|
|
517
|
+
tpPrice,
|
|
518
|
+
slPrice,
|
|
342
519
|
entryType,
|
|
343
520
|
entryPrice,
|
|
344
521
|
slippage,
|
|
345
522
|
entryTimeoutSec,
|
|
346
523
|
slSlippageBps,
|
|
524
|
+
slMarket: !(args['sl-limit'] as boolean),
|
|
525
|
+
atomic: !(args['no-atomic'] as boolean),
|
|
347
526
|
leverage,
|
|
348
527
|
dryRun,
|
|
349
528
|
verbose: args.verbose as boolean,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { getClient } from '../core/client.js';
|
|
6
6
|
import type { OrderResponse, CancelResponse, OpenOrder } from '../core/types.js';
|
|
7
|
-
import { formatUsd, parseArgs, sleep } from '../core/utils.js';
|
|
7
|
+
import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs, sleep } from '../core/utils.js';
|
|
8
8
|
import { UserFillWatcher, type FillWatcher } from './execution.js';
|
|
9
9
|
|
|
10
10
|
function printUsage() {
|
|
@@ -68,7 +68,7 @@ export interface ChaseClient {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export interface ChaseResult {
|
|
71
|
-
status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded';
|
|
71
|
+
status: 'dry' | 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional';
|
|
72
72
|
iterations: number;
|
|
73
73
|
durationSec: number;
|
|
74
74
|
startMid: number;
|
|
@@ -99,6 +99,10 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
99
99
|
const startMid = parseFloat(mids[opts.coin]);
|
|
100
100
|
if (!startMid) throw new Error(`No market data for ${opts.coin}`);
|
|
101
101
|
|
|
102
|
+
const startBelowMinimum = !opts.reduceOnly && opts.size * startMid < MIN_ORDER_NOTIONAL_USD;
|
|
103
|
+
const minNotionalMsg = `Chase size (~$${(opts.size * startMid).toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`;
|
|
104
|
+
if (startBelowMinimum && !opts.dryRun) throw new Error(minNotionalMsg);
|
|
105
|
+
|
|
102
106
|
const maxChasePrice = isBuy
|
|
103
107
|
? startMid * (1 + maxChaseBps / 10000)
|
|
104
108
|
: startMid * (1 - maxChaseBps / 10000);
|
|
@@ -116,6 +120,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
116
120
|
out(`Order Type: ALO (post-only)`);
|
|
117
121
|
|
|
118
122
|
if (opts.dryRun) {
|
|
123
|
+
if (startBelowMinimum) out(`\n⚠️ ${minNotionalMsg}`);
|
|
119
124
|
out('\n🔍 Dry run - chase not started');
|
|
120
125
|
return { status: 'dry', iterations: 0, durationSec: 0, startMid, endMid: startMid };
|
|
121
126
|
}
|
|
@@ -128,7 +133,7 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
128
133
|
let remainingSize = opts.size;
|
|
129
134
|
let iteration = 0;
|
|
130
135
|
let filled = false;
|
|
131
|
-
let exitReason: 'filled' | 'timeout' | 'max_chase_exceeded' = 'timeout';
|
|
136
|
+
let exitReason: 'filled' | 'timeout' | 'max_chase_exceeded' | 'min_notional' = 'timeout';
|
|
132
137
|
const accountedFills = new Map<number, number>();
|
|
133
138
|
const ownsFillWatcher = !opts.fillWatcher;
|
|
134
139
|
const fillWatcher = opts.fillWatcher ?? new UserFillWatcher(client, { sinceMs: startTime });
|
|
@@ -160,9 +165,20 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
167
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
// A transient /info failure mid-chase must not kill the execution (and
|
|
169
|
+
// strand the resting order) — wait a tick and retry instead.
|
|
170
|
+
let currentMid = NaN;
|
|
171
|
+
try {
|
|
172
|
+
const currentMids = await client.getAllMids();
|
|
173
|
+
currentMid = parseFloat(currentMids[opts.coin]);
|
|
174
|
+
} catch {
|
|
175
|
+
/* transient failure — retry next tick */
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(currentMid) || currentMid <= 0) {
|
|
178
|
+
out(`[${iteration}] ⏳ No live price — retrying...`);
|
|
179
|
+
await sleep(intervalMs);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
166
182
|
|
|
167
183
|
if (isBuy && currentMid > maxChasePrice) {
|
|
168
184
|
out(`\n⚠️ Price ${formatUsd(currentMid)} exceeded max chase ${formatUsd(maxChasePrice)}`);
|
|
@@ -206,6 +222,14 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
206
222
|
break;
|
|
207
223
|
}
|
|
208
224
|
|
|
225
|
+
// The dust remainder of a partial fill can drop below the exchange
|
|
226
|
+
// minimum — placing it would just error out on every tick.
|
|
227
|
+
if (!opts.reduceOnly && remainingSize * currentMid < MIN_ORDER_NOTIONAL_USD) {
|
|
228
|
+
out(`\n⚠️ Remaining size (~$${(remainingSize * currentMid).toFixed(2)}) fell below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum`);
|
|
229
|
+
exitReason = 'min_notional';
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
209
233
|
out(`[${iteration}] Mid: ${formatUsd(currentMid)} → Order: ${formatUsd(orderPrice)} x ${remainingSize.toFixed(6)}...`);
|
|
210
234
|
|
|
211
235
|
const response = await client.limitOrder(opts.coin, isBuy, remainingSize, orderPrice, 'Alo', opts.reduceOnly, opts.leverage);
|
|
@@ -226,10 +250,18 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
226
250
|
break;
|
|
227
251
|
}
|
|
228
252
|
} else if (status?.error) {
|
|
229
|
-
|
|
253
|
+
if (/post.?only|immediately match/i.test(status.error)) {
|
|
254
|
+
// Stale mid crossed the book — the fast-market condition chase
|
|
255
|
+
// exists for. Reprice on the next tick instead of dying.
|
|
256
|
+
out(`↩️ Quote would cross the book — repricing`);
|
|
257
|
+
lastPrice = null;
|
|
258
|
+
} else {
|
|
259
|
+
throw new Error(status.error);
|
|
260
|
+
}
|
|
230
261
|
}
|
|
231
262
|
} else {
|
|
232
|
-
|
|
263
|
+
const reason = typeof response.response === 'string' ? response.response : 'Order rejected';
|
|
264
|
+
throw new Error(reason);
|
|
233
265
|
}
|
|
234
266
|
} else {
|
|
235
267
|
if (currentOid !== null) {
|
|
@@ -261,23 +293,24 @@ export async function runChase(opts: ChaseOptions): Promise<ChaseResult> {
|
|
|
261
293
|
await sleep(intervalMs);
|
|
262
294
|
}
|
|
263
295
|
} finally {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
296
|
+
// Always pull the working order before returning — even when the loop
|
|
297
|
+
// throws — so an error never strands a live resting quote at a stale price.
|
|
298
|
+
if (currentOid !== null && !filled) {
|
|
299
|
+
applyFills(currentOid);
|
|
300
|
+
out(`\nCancelling unfilled order...`);
|
|
301
|
+
try {
|
|
302
|
+
await client.cancel(opts.coin, currentOid);
|
|
303
|
+
out(`✅ Cancelled`);
|
|
304
|
+
} catch {
|
|
305
|
+
out(`⚠️ Could not cancel (may have filled)`);
|
|
306
|
+
}
|
|
307
|
+
applyFills(currentOid);
|
|
308
|
+
if (remainingSize <= opts.size * 0.001) {
|
|
309
|
+
filled = true;
|
|
310
|
+
exitReason = 'filled';
|
|
311
|
+
}
|
|
280
312
|
}
|
|
313
|
+
if (ownsFillWatcher) await fillWatcher.stop();
|
|
281
314
|
}
|
|
282
315
|
|
|
283
316
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { getClient } from '../core/client.js';
|
|
6
6
|
import type { CancelResponse, OrderResponse } from '../core/types.js';
|
|
7
|
-
import { formatUsd, parseArgs } from '../core/utils.js';
|
|
7
|
+
import { MIN_ORDER_NOTIONAL_USD, formatUsd, parseArgs } from '../core/utils.js';
|
|
8
8
|
|
|
9
9
|
function printUsage() {
|
|
10
10
|
console.log(`
|
|
@@ -194,11 +194,20 @@ export async function runScale(opts: ScaleOptions): Promise<ScaleResult> {
|
|
|
194
194
|
);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// Every level must clear the exchange's minimum notional (waived for
|
|
198
|
+
// reduce-only) or it gets a silent per-level rejection.
|
|
199
|
+
const thinnest = levels.reduce((min, level) => Math.min(min, level.size * level.price), Infinity);
|
|
200
|
+
const belowMinimum = !reduceOnly && thinnest < MIN_ORDER_NOTIONAL_USD;
|
|
201
|
+
const minNotionalMsg = `smallest scale level (~$${thinnest.toFixed(2)}) is below the $${MIN_ORDER_NOTIONAL_USD} exchange minimum — use fewer levels or a larger size`;
|
|
202
|
+
|
|
197
203
|
if (opts.dryRun) {
|
|
204
|
+
if (belowMinimum) out(`\n⚠️ The ${minNotionalMsg}`);
|
|
198
205
|
out('\n🔍 Dry run - orders not placed');
|
|
199
206
|
return { status: 'dry', levels, restingOids: [], filledOids: [], errors: [], rolledBack: false };
|
|
200
207
|
}
|
|
201
208
|
|
|
209
|
+
if (belowMinimum) throw new Error(`the ${minNotionalMsg}`);
|
|
210
|
+
|
|
202
211
|
out('\nPlacing ladder as a bulk order...\n');
|
|
203
212
|
|
|
204
213
|
const response = await client.bulkOrder(
|