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