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
package/dist/core/utils.js
CHANGED
|
@@ -112,6 +112,33 @@ export function normalizeCoin(coin) {
|
|
|
112
112
|
export function sleep(ms) {
|
|
113
113
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
114
114
|
}
|
|
115
|
+
/** Hyperliquid rejects orders below this notional (waived for reduce-only). */
|
|
116
|
+
export const MIN_ORDER_NOTIONAL_USD = 10;
|
|
117
|
+
/**
|
|
118
|
+
* Parse a single entry of an order response's `statuses` array. Handles the
|
|
119
|
+
* plain-string success states ("waitingForFill" / "waitingForTrigger") that
|
|
120
|
+
* Hyperliquid returns for normalTpsl children armed behind an unfilled parent,
|
|
121
|
+
* alongside the usual resting/filled/error objects.
|
|
122
|
+
*/
|
|
123
|
+
export function parseOrderStatus(status) {
|
|
124
|
+
if (status === 'waitingForFill' || status === 'waitingForTrigger') {
|
|
125
|
+
return { kind: 'waiting', state: status };
|
|
126
|
+
}
|
|
127
|
+
if (status && typeof status === 'object') {
|
|
128
|
+
const data = status;
|
|
129
|
+
if (data.resting) {
|
|
130
|
+
return { kind: 'resting', oid: data.resting.oid };
|
|
131
|
+
}
|
|
132
|
+
if (data.filled) {
|
|
133
|
+
const f = data.filled;
|
|
134
|
+
return { kind: 'filled', totalSz: parseFloat(f.totalSz), avgPx: parseFloat(f.avgPx), oid: f.oid };
|
|
135
|
+
}
|
|
136
|
+
if (data.error) {
|
|
137
|
+
return { kind: 'error', error: String(data.error) };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { kind: 'unknown', raw: status };
|
|
141
|
+
}
|
|
115
142
|
/**
|
|
116
143
|
* Generate a random client order ID
|
|
117
144
|
*/
|
package/dist/lib.d.ts
CHANGED
|
@@ -2,12 +2,15 @@ export { HyperliquidClient, getClient, } from './core/client.js';
|
|
|
2
2
|
export { WebSocketManager, getWebSocket, resetWebSocket, } from './core/ws.js';
|
|
3
3
|
export type { WsEventMap, WsEventType, WsEventHandler } from './core/ws.js';
|
|
4
4
|
export { loadConfig, isConfigured, getNetwork, isMainnet, ensureConfigDir, getConfigPath, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, OPEN_BROKER_BUILDER_ADDRESS, } from './core/config.js';
|
|
5
|
-
export { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, parseArgs, getSlippagePrice, getTimestampMs, generateCloid, orderToWire, checkBuilderFeeApproval, } from './core/utils.js';
|
|
5
|
+
export { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, parseArgs, getSlippagePrice, getTimestampMs, generateCloid, orderToWire, checkBuilderFeeApproval, MIN_ORDER_NOTIONAL_USD, parseOrderStatus, } from './core/utils.js';
|
|
6
|
+
export type { ParsedOrderStatus } from './core/utils.js';
|
|
6
7
|
export type * from './core/types.js';
|
|
7
8
|
export { runBracket } from './operations/bracket.js';
|
|
8
9
|
export type { BracketOptions, BracketResult } from './operations/bracket.js';
|
|
9
10
|
export { runChase } from './operations/chase.js';
|
|
10
11
|
export type { ChaseOptions, ChaseResult } from './operations/chase.js';
|
|
12
|
+
export { runScale, calculateLevels } from './operations/scale.js';
|
|
13
|
+
export type { ScaleOptions, ScaleResult, OrderLevel } from './operations/scale.js';
|
|
11
14
|
export { startAutomation, getRunningAutomations, getAutomation, getRegisteredAutomations, } from './auto/runtime.js';
|
|
12
15
|
export type { RuntimeOptions } from './auto/runtime.js';
|
|
13
16
|
export { resolveScriptPath, resolveExamplePath, listAutomations, listExamples, loadExampleConfigs, ensureAutomationsDir, loadAutomation, } from './auto/loader.js';
|
package/dist/lib.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../scripts/lib.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,iBAAiB,EACjB,SAAS,GACV,MAAM,kBAAkB,CAAC;AAK1B,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE5E,OAAO,EACL,UAAU,EACV,YAAY,EACZ,UAAU,EACV,SAAS,EACT,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,2BAA2B,GAC5B,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EACV,SAAS,EACT,KAAK,EACL,aAAa,EACb,SAAS,EACT,aAAa,EACb,oBAAoB,EACpB,SAAS,EACT,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,WAAW,EACX,uBAAuB,
|
|
1
|
+
{"version":3,"file":"lib.d.ts","sourceRoot":"","sources":["../scripts/lib.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,iBAAiB,EACjB,SAAS,GACV,MAAM,kBAAkB,CAAC;AAK1B,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,cAAc,GACf,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE5E,OAAO,EACL,UAAU,EACV,YAAY,EACZ,UAAU,EACV,SAAS,EACT,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,2BAA2B,GAC5B,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EACV,SAAS,EACT,KAAK,EACL,aAAa,EACb,SAAS,EACT,aAAa,EACb,oBAAoB,EACpB,SAAS,EACT,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,sBAAsB,EACtB,gBAAgB,GACjB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,mBAAmB,iBAAiB,CAAC;AAIrC,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAE7E,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAClE,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAInF,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,aAAa,EACb,wBAAwB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,oBAAoB,EACpB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,4BAA4B,EAC5B,2BAA2B,EAC3B,uBAAuB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,aAAa,EACb,uBAAuB,EACvB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAE5B,mBAAmB,iBAAiB,CAAC"}
|
package/dist/lib.js
CHANGED
|
@@ -11,10 +11,11 @@ export { HyperliquidClient, getClient, } from './core/client.js';
|
|
|
11
11
|
// REST-polling. `userFill` / `orderUpdate` are emitted per own-order event; see WsEventMap.
|
|
12
12
|
export { WebSocketManager, getWebSocket, resetWebSocket, } from './core/ws.js';
|
|
13
13
|
export { loadConfig, isConfigured, getNetwork, isMainnet, ensureConfigDir, getConfigPath, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, OPEN_BROKER_BUILDER_ADDRESS, } from './core/config.js';
|
|
14
|
-
export { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, parseArgs, getSlippagePrice, getTimestampMs, generateCloid, orderToWire, checkBuilderFeeApproval, } from './core/utils.js';
|
|
14
|
+
export { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, parseArgs, getSlippagePrice, getTimestampMs, generateCloid, orderToWire, checkBuilderFeeApproval, MIN_ORDER_NOTIONAL_USD, parseOrderStatus, } from './core/utils.js';
|
|
15
15
|
// ── Operations (in-process callable) ────────────────────────────────
|
|
16
16
|
export { runBracket } from './operations/bracket.js';
|
|
17
17
|
export { runChase } from './operations/chase.js';
|
|
18
|
+
export { runScale, calculateLevels } from './operations/scale.js';
|
|
18
19
|
// ── Automation runtime ──────────────────────────────────────────────
|
|
19
20
|
export { startAutomation, getRunningAutomations, getAutomation, getRegisteredAutomations, } from './auto/runtime.js';
|
|
20
21
|
export { resolveScriptPath, resolveExamplePath, listAutomations, listExamples, loadExampleConfigs, ensureAutomationsDir, loadAutomation, } from './auto/loader.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"advanced-orders.test.d.ts","sourceRoot":"","sources":["../../scripts/operations/advanced-orders.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runBracket } from './bracket.js';
|
|
4
|
+
import { runChase } from './chase.js';
|
|
5
|
+
import { runScale } from './scale.js';
|
|
6
|
+
import { UserFillWatcher } from './execution.js';
|
|
7
|
+
const okOrder = (statuses) => ({
|
|
8
|
+
status: 'ok',
|
|
9
|
+
response: { type: 'order', data: { statuses } },
|
|
10
|
+
});
|
|
11
|
+
const okCancel = () => ({
|
|
12
|
+
status: 'ok',
|
|
13
|
+
response: { type: 'cancel', data: { statuses: ['success'] } },
|
|
14
|
+
});
|
|
15
|
+
class StaticFillWatcher {
|
|
16
|
+
fills;
|
|
17
|
+
constructor(fills) {
|
|
18
|
+
this.fills = fills;
|
|
19
|
+
}
|
|
20
|
+
async start() { }
|
|
21
|
+
async stop() { }
|
|
22
|
+
getFilled(oid) {
|
|
23
|
+
return this.fills.get(oid) ?? { size: 0, notional: 0 };
|
|
24
|
+
}
|
|
25
|
+
async waitForFill(oid) {
|
|
26
|
+
return this.getFilled(oid);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function bracketClientStub(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
verbose: false,
|
|
32
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
33
|
+
async getAllMids() {
|
|
34
|
+
return { ETH: '1000' };
|
|
35
|
+
},
|
|
36
|
+
async marketOrder() {
|
|
37
|
+
throw new Error('marketOrder should not be called');
|
|
38
|
+
},
|
|
39
|
+
async limitOrder() {
|
|
40
|
+
throw new Error('limitOrder should not be called');
|
|
41
|
+
},
|
|
42
|
+
async tpslOrders() {
|
|
43
|
+
throw new Error('tpslOrders should not be called');
|
|
44
|
+
},
|
|
45
|
+
async bracketOrder() {
|
|
46
|
+
throw new Error('bracketOrder should not be called');
|
|
47
|
+
},
|
|
48
|
+
async cancel() {
|
|
49
|
+
throw new Error('cancel should not be called');
|
|
50
|
+
},
|
|
51
|
+
async getUserFills() {
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
test('scale rejects invalid levels and rolls back resting orders on partial ladder placement', async () => {
|
|
58
|
+
const cancelled = [];
|
|
59
|
+
const client = {
|
|
60
|
+
verbose: false,
|
|
61
|
+
async getAllMids() {
|
|
62
|
+
return { ETH: '1000' };
|
|
63
|
+
},
|
|
64
|
+
async bulkOrder() {
|
|
65
|
+
return okOrder([
|
|
66
|
+
{ resting: { oid: 101 } },
|
|
67
|
+
{ error: 'insufficient margin' },
|
|
68
|
+
{ resting: { oid: 103 } },
|
|
69
|
+
]);
|
|
70
|
+
},
|
|
71
|
+
async bulkCancel(cancels) {
|
|
72
|
+
cancelled.push(...cancels);
|
|
73
|
+
return okCancel();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
await assert.rejects(() => runScale({
|
|
77
|
+
coin: 'ETH',
|
|
78
|
+
side: 'buy',
|
|
79
|
+
size: 1,
|
|
80
|
+
levels: 0,
|
|
81
|
+
rangePct: 2,
|
|
82
|
+
client,
|
|
83
|
+
output: () => { },
|
|
84
|
+
}), /levels must be a positive integer/);
|
|
85
|
+
const result = await runScale({
|
|
86
|
+
coin: 'ETH',
|
|
87
|
+
side: 'buy',
|
|
88
|
+
size: 1,
|
|
89
|
+
levels: 3,
|
|
90
|
+
rangePct: 2,
|
|
91
|
+
client,
|
|
92
|
+
output: () => { },
|
|
93
|
+
});
|
|
94
|
+
assert.equal(result.status, 'partial');
|
|
95
|
+
assert.equal(result.rolledBack, true);
|
|
96
|
+
assert.deepEqual(cancelled, [
|
|
97
|
+
{ coin: 'ETH', oid: 101 },
|
|
98
|
+
{ coin: 'ETH', oid: 103 },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
test('scale pre-checks the $10 exchange minimum on the thinnest level (waived for reduce-only)', async () => {
|
|
102
|
+
let placed = false;
|
|
103
|
+
const client = {
|
|
104
|
+
verbose: false,
|
|
105
|
+
async getAllMids() {
|
|
106
|
+
return { ETH: '1000' };
|
|
107
|
+
},
|
|
108
|
+
async bulkOrder(orders) {
|
|
109
|
+
placed = true;
|
|
110
|
+
return okOrder(orders.map((_, i) => ({ resting: { oid: 200 + i } })));
|
|
111
|
+
},
|
|
112
|
+
async bulkCancel() {
|
|
113
|
+
return okCancel();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
// Total ~$30 over 3 linear levels → thinnest level ~$5 < $10.
|
|
117
|
+
await assert.rejects(() => runScale({
|
|
118
|
+
coin: 'ETH',
|
|
119
|
+
side: 'buy',
|
|
120
|
+
size: 0.03,
|
|
121
|
+
levels: 3,
|
|
122
|
+
rangePct: 2,
|
|
123
|
+
client,
|
|
124
|
+
output: () => { },
|
|
125
|
+
}), /below the \$10 exchange minimum/);
|
|
126
|
+
assert.equal(placed, false);
|
|
127
|
+
// Same ladder reduce-only is allowed (exchange waives the minimum).
|
|
128
|
+
const result = await runScale({
|
|
129
|
+
coin: 'ETH',
|
|
130
|
+
side: 'buy',
|
|
131
|
+
size: 0.03,
|
|
132
|
+
levels: 3,
|
|
133
|
+
rangePct: 2,
|
|
134
|
+
reduceOnly: true,
|
|
135
|
+
client,
|
|
136
|
+
output: () => { },
|
|
137
|
+
});
|
|
138
|
+
assert.equal(result.status, 'complete');
|
|
139
|
+
});
|
|
140
|
+
test('bracket limit entry defaults to one atomic normalTpsl batch', async () => {
|
|
141
|
+
const calls = [];
|
|
142
|
+
const client = bracketClientStub({
|
|
143
|
+
async bracketOrder(coin, isBuy, size, entryPrice, opts) {
|
|
144
|
+
calls.push({ coin, isBuy, size, entryPrice, opts: opts ?? {} });
|
|
145
|
+
return okOrder([
|
|
146
|
+
{ resting: { oid: 500 } },
|
|
147
|
+
'waitingForFill',
|
|
148
|
+
'waitingForFill',
|
|
149
|
+
]);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const result = await runBracket({
|
|
153
|
+
coin: 'ETH',
|
|
154
|
+
side: 'buy',
|
|
155
|
+
size: 1,
|
|
156
|
+
tpPct: 5,
|
|
157
|
+
slPct: 2,
|
|
158
|
+
entryType: 'limit',
|
|
159
|
+
entryPrice: 990,
|
|
160
|
+
client,
|
|
161
|
+
output: () => { },
|
|
162
|
+
});
|
|
163
|
+
assert.equal(result.status, 'armed');
|
|
164
|
+
assert.equal(result.entryOid, 500);
|
|
165
|
+
assert.equal(calls.length, 1);
|
|
166
|
+
assert.equal(calls[0].isBuy, true);
|
|
167
|
+
assert.equal(calls[0].entryPrice, 990);
|
|
168
|
+
assert.equal(calls[0].opts.takeProfitPrice, 990 * 1.05);
|
|
169
|
+
assert.equal(calls[0].opts.stopLossPrice, 990 * 0.98);
|
|
170
|
+
assert.equal(calls[0].opts.stopLossIsMarket, true);
|
|
171
|
+
});
|
|
172
|
+
test('bracket atomic batch rolls back resting orders when a leg is rejected', async () => {
|
|
173
|
+
const cancelled = [];
|
|
174
|
+
const client = bracketClientStub({
|
|
175
|
+
async bracketOrder() {
|
|
176
|
+
return okOrder([
|
|
177
|
+
{ resting: { oid: 600 } },
|
|
178
|
+
'waitingForFill',
|
|
179
|
+
{ error: 'Invalid TP/SL price.' },
|
|
180
|
+
]);
|
|
181
|
+
},
|
|
182
|
+
async cancel(_coin, oid) {
|
|
183
|
+
cancelled.push(oid);
|
|
184
|
+
return okCancel();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const result = await runBracket({
|
|
188
|
+
coin: 'ETH',
|
|
189
|
+
side: 'buy',
|
|
190
|
+
size: 1,
|
|
191
|
+
tpPct: 5,
|
|
192
|
+
slPct: 2,
|
|
193
|
+
entryType: 'limit',
|
|
194
|
+
entryPrice: 990,
|
|
195
|
+
client,
|
|
196
|
+
output: () => { },
|
|
197
|
+
});
|
|
198
|
+
assert.equal(result.status, 'entry_failed');
|
|
199
|
+
assert.match(result.reason ?? '', /Invalid TP\/SL price/);
|
|
200
|
+
assert.deepEqual(cancelled, [600]);
|
|
201
|
+
});
|
|
202
|
+
test('bracket with --no-atomic waits for limit-entry fill and arms positionTpsl for confirmed size', async () => {
|
|
203
|
+
const pairCalls = [];
|
|
204
|
+
const client = bracketClientStub({
|
|
205
|
+
async limitOrder() {
|
|
206
|
+
return okOrder([{ resting: { oid: 777 } }]);
|
|
207
|
+
},
|
|
208
|
+
async tpslOrders(_coin, isBuy, size, opts) {
|
|
209
|
+
pairCalls.push({ size, isBuy, opts: opts ?? {} });
|
|
210
|
+
return okOrder([{ resting: { oid: 778 } }, { resting: { oid: 779 } }]);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const fillWatcher = new StaticFillWatcher(new Map([
|
|
214
|
+
[777, { size: 0.4, notional: 392, avgPrice: 980 }],
|
|
215
|
+
]));
|
|
216
|
+
const result = await runBracket({
|
|
217
|
+
coin: 'ETH',
|
|
218
|
+
side: 'buy',
|
|
219
|
+
size: 1,
|
|
220
|
+
tpPct: 5,
|
|
221
|
+
slPct: 2,
|
|
222
|
+
entryType: 'limit',
|
|
223
|
+
entryPrice: 990,
|
|
224
|
+
entryTimeoutSec: 5,
|
|
225
|
+
atomic: false,
|
|
226
|
+
client,
|
|
227
|
+
fillWatcher,
|
|
228
|
+
output: () => { },
|
|
229
|
+
});
|
|
230
|
+
assert.equal(result.status, 'complete');
|
|
231
|
+
assert.equal(result.protectedSize, 0.4);
|
|
232
|
+
assert.equal(result.entryPrice, 980);
|
|
233
|
+
assert.equal(pairCalls.length, 1);
|
|
234
|
+
assert.equal(pairCalls[0].size, 0.4);
|
|
235
|
+
assert.equal(pairCalls[0].isBuy, false);
|
|
236
|
+
assert.equal(pairCalls[0].opts.grouping, 'positionTpsl');
|
|
237
|
+
assert.equal(pairCalls[0].opts.takeProfitPrice, 1029);
|
|
238
|
+
assert.equal(pairCalls[0].opts.stopLossPrice, 960.4);
|
|
239
|
+
});
|
|
240
|
+
test('bracket market entry supports a one-sided TP sized to the actual fill', async () => {
|
|
241
|
+
const calls = [];
|
|
242
|
+
const client = bracketClientStub({
|
|
243
|
+
async marketOrder() {
|
|
244
|
+
return okOrder([{ filled: { totalSz: '0.8', avgPx: '1001', oid: 1 } }]);
|
|
245
|
+
},
|
|
246
|
+
async tpslOrders(_coin, isBuy, size, opts) {
|
|
247
|
+
calls.push({ isBuy, size, opts: opts ?? {} });
|
|
248
|
+
return okOrder([{ resting: { oid: 2 } }]);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const result = await runBracket({
|
|
252
|
+
coin: 'ETH',
|
|
253
|
+
side: 'buy',
|
|
254
|
+
size: 1,
|
|
255
|
+
tpPct: 5,
|
|
256
|
+
entryType: 'market',
|
|
257
|
+
client,
|
|
258
|
+
fillWatcher: new StaticFillWatcher(new Map()),
|
|
259
|
+
output: () => { },
|
|
260
|
+
});
|
|
261
|
+
assert.equal(result.status, 'complete');
|
|
262
|
+
assert.equal(result.protectedSize, 0.8);
|
|
263
|
+
assert.equal(calls.length, 1);
|
|
264
|
+
assert.equal(calls[0].size, 0.8);
|
|
265
|
+
assert.equal(calls[0].isBuy, false);
|
|
266
|
+
assert.equal(calls[0].opts.grouping, 'positionTpsl');
|
|
267
|
+
assert.equal(calls[0].opts.takeProfitPrice, 1001 * 1.05);
|
|
268
|
+
assert.equal(calls[0].opts.stopLossPrice, undefined);
|
|
269
|
+
});
|
|
270
|
+
test('bracket validates targets before any order goes out', async () => {
|
|
271
|
+
// SL% ≥ 100 resolves to a negative price.
|
|
272
|
+
await assert.rejects(() => runBracket({
|
|
273
|
+
coin: 'ETH',
|
|
274
|
+
side: 'buy',
|
|
275
|
+
size: 1,
|
|
276
|
+
tpPct: 5,
|
|
277
|
+
slPct: 150,
|
|
278
|
+
client: bracketClientStub(),
|
|
279
|
+
output: () => { },
|
|
280
|
+
}), /SL must resolve to a positive price/);
|
|
281
|
+
// Absolute TP on the wrong side of the entry.
|
|
282
|
+
await assert.rejects(() => runBracket({
|
|
283
|
+
coin: 'ETH',
|
|
284
|
+
side: 'buy',
|
|
285
|
+
size: 1,
|
|
286
|
+
tpPrice: 900,
|
|
287
|
+
entryType: 'limit',
|
|
288
|
+
entryPrice: 990,
|
|
289
|
+
client: bracketClientStub(),
|
|
290
|
+
output: () => { },
|
|
291
|
+
}), /TP price must be above entry/);
|
|
292
|
+
// No target at all.
|
|
293
|
+
await assert.rejects(() => runBracket({
|
|
294
|
+
coin: 'ETH',
|
|
295
|
+
side: 'buy',
|
|
296
|
+
size: 1,
|
|
297
|
+
client: bracketClientStub(),
|
|
298
|
+
output: () => { },
|
|
299
|
+
}), /provide a TP target, an SL target, or both/);
|
|
300
|
+
});
|
|
301
|
+
test('chase requotes only the remaining size after a partial fill', async () => {
|
|
302
|
+
const limitSizes = [];
|
|
303
|
+
const cancelled = [];
|
|
304
|
+
const fills = new Map();
|
|
305
|
+
let orderCount = 0;
|
|
306
|
+
let mid = 1000;
|
|
307
|
+
const client = {
|
|
308
|
+
verbose: false,
|
|
309
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
310
|
+
async getAllMids() {
|
|
311
|
+
mid += 2;
|
|
312
|
+
return { ETH: String(mid) };
|
|
313
|
+
},
|
|
314
|
+
async getOpenOrders() {
|
|
315
|
+
return [];
|
|
316
|
+
},
|
|
317
|
+
async getUserFills() {
|
|
318
|
+
return [];
|
|
319
|
+
},
|
|
320
|
+
async limitOrder(_coin, _isBuy, size) {
|
|
321
|
+
limitSizes.push(size);
|
|
322
|
+
orderCount += 1;
|
|
323
|
+
const oid = 900 + orderCount;
|
|
324
|
+
if (orderCount === 1) {
|
|
325
|
+
fills.set(oid, { size: 0.4, notional: 400, avgPrice: 1000 });
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
fills.set(oid, { size, notional: size * 1002, avgPrice: 1002 });
|
|
329
|
+
}
|
|
330
|
+
return okOrder([{ resting: { oid } }]);
|
|
331
|
+
},
|
|
332
|
+
async cancel(_coin, oid) {
|
|
333
|
+
cancelled.push(oid);
|
|
334
|
+
return okCancel();
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
const result = await runChase({
|
|
338
|
+
coin: 'ETH',
|
|
339
|
+
side: 'buy',
|
|
340
|
+
size: 1,
|
|
341
|
+
offsetBps: 5,
|
|
342
|
+
timeoutSec: 2,
|
|
343
|
+
intervalMs: 1,
|
|
344
|
+
maxChaseBps: 1_000,
|
|
345
|
+
client,
|
|
346
|
+
fillWatcher: new StaticFillWatcher(fills),
|
|
347
|
+
output: () => { },
|
|
348
|
+
});
|
|
349
|
+
assert.equal(result.status, 'filled');
|
|
350
|
+
assert.equal(limitSizes.length, 2);
|
|
351
|
+
assert.equal(limitSizes[0], 1);
|
|
352
|
+
assert(Math.abs(limitSizes[1] - 0.6) < 1e-9);
|
|
353
|
+
assert.deepEqual(cancelled, [901]);
|
|
354
|
+
});
|
|
355
|
+
test('chase reprices and continues after a post-only rejection', async () => {
|
|
356
|
+
const fills = new Map();
|
|
357
|
+
let orderCount = 0;
|
|
358
|
+
const client = {
|
|
359
|
+
verbose: false,
|
|
360
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
361
|
+
async getAllMids() {
|
|
362
|
+
return { ETH: '1000' };
|
|
363
|
+
},
|
|
364
|
+
async getOpenOrders() {
|
|
365
|
+
return [];
|
|
366
|
+
},
|
|
367
|
+
async getUserFills() {
|
|
368
|
+
return [];
|
|
369
|
+
},
|
|
370
|
+
async limitOrder(_coin, _isBuy, size) {
|
|
371
|
+
orderCount += 1;
|
|
372
|
+
if (orderCount === 1) {
|
|
373
|
+
return okOrder([{ error: 'Post only order would have immediately matched, bbo was 999.9@1000.1' }]);
|
|
374
|
+
}
|
|
375
|
+
const oid = 900 + orderCount;
|
|
376
|
+
fills.set(oid, { size, notional: size * 1000, avgPrice: 1000 });
|
|
377
|
+
return okOrder([{ resting: { oid } }]);
|
|
378
|
+
},
|
|
379
|
+
async cancel() {
|
|
380
|
+
return okCancel();
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
const result = await runChase({
|
|
384
|
+
coin: 'ETH',
|
|
385
|
+
side: 'buy',
|
|
386
|
+
size: 1,
|
|
387
|
+
offsetBps: 5,
|
|
388
|
+
timeoutSec: 2,
|
|
389
|
+
intervalMs: 1,
|
|
390
|
+
maxChaseBps: 1_000,
|
|
391
|
+
client,
|
|
392
|
+
fillWatcher: new StaticFillWatcher(fills),
|
|
393
|
+
output: () => { },
|
|
394
|
+
});
|
|
395
|
+
assert.equal(result.status, 'filled');
|
|
396
|
+
assert.equal(orderCount, 2);
|
|
397
|
+
});
|
|
398
|
+
test('chase cancels the resting order even when the loop throws', async () => {
|
|
399
|
+
const cancelled = [];
|
|
400
|
+
const client = {
|
|
401
|
+
verbose: false,
|
|
402
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
403
|
+
async getAllMids() {
|
|
404
|
+
return { ETH: '1000' };
|
|
405
|
+
},
|
|
406
|
+
async getOpenOrders() {
|
|
407
|
+
throw new Error('open-orders lookup exploded');
|
|
408
|
+
},
|
|
409
|
+
async getUserFills() {
|
|
410
|
+
return [];
|
|
411
|
+
},
|
|
412
|
+
async limitOrder() {
|
|
413
|
+
return okOrder([{ resting: { oid: 950 } }]);
|
|
414
|
+
},
|
|
415
|
+
async cancel(_coin, oid) {
|
|
416
|
+
cancelled.push(oid);
|
|
417
|
+
return okCancel();
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
await assert.rejects(() => runChase({
|
|
421
|
+
coin: 'ETH',
|
|
422
|
+
side: 'buy',
|
|
423
|
+
size: 1,
|
|
424
|
+
offsetBps: 5,
|
|
425
|
+
timeoutSec: 2,
|
|
426
|
+
intervalMs: 1,
|
|
427
|
+
maxChaseBps: 1_000,
|
|
428
|
+
client,
|
|
429
|
+
fillWatcher: new StaticFillWatcher(new Map()),
|
|
430
|
+
output: () => { },
|
|
431
|
+
}), /open-orders lookup exploded/);
|
|
432
|
+
assert.deepEqual(cancelled, [950]);
|
|
433
|
+
});
|
|
434
|
+
test('chase rejects a start size below the $10 exchange minimum', async () => {
|
|
435
|
+
const client = {
|
|
436
|
+
verbose: false,
|
|
437
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
438
|
+
async getAllMids() {
|
|
439
|
+
return { ETH: '1000' };
|
|
440
|
+
},
|
|
441
|
+
async getOpenOrders() {
|
|
442
|
+
return [];
|
|
443
|
+
},
|
|
444
|
+
async getUserFills() {
|
|
445
|
+
return [];
|
|
446
|
+
},
|
|
447
|
+
async limitOrder() {
|
|
448
|
+
throw new Error('limitOrder should not be called');
|
|
449
|
+
},
|
|
450
|
+
async cancel() {
|
|
451
|
+
return okCancel();
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
await assert.rejects(() => runChase({
|
|
455
|
+
coin: 'ETH',
|
|
456
|
+
side: 'buy',
|
|
457
|
+
size: 0.005,
|
|
458
|
+
client,
|
|
459
|
+
fillWatcher: new StaticFillWatcher(new Map()),
|
|
460
|
+
output: () => { },
|
|
461
|
+
}), /below the \$10 exchange minimum/);
|
|
462
|
+
});
|
|
463
|
+
test('user fill watcher de-duplicates repeated REST fallback fills', async () => {
|
|
464
|
+
const fillTime = Date.now();
|
|
465
|
+
const watcher = new UserFillWatcher({
|
|
466
|
+
verbose: false,
|
|
467
|
+
address: '0x0000000000000000000000000000000000000001',
|
|
468
|
+
async getUserFills() {
|
|
469
|
+
return [
|
|
470
|
+
{ coin: 'ETH', px: '1000', sz: '0.25', time: fillTime, oid: 123 },
|
|
471
|
+
];
|
|
472
|
+
},
|
|
473
|
+
}, { ws: null, sinceMs: fillTime - 1000 });
|
|
474
|
+
await watcher.start();
|
|
475
|
+
await watcher.waitForFill(123, 1, 1, { coin: 'ETH', pollMs: 1 });
|
|
476
|
+
await watcher.waitForFill(123, 1, 1, { coin: 'ETH', pollMs: 1 });
|
|
477
|
+
assert.equal(watcher.getFilled(123).size, 0.25);
|
|
478
|
+
});
|
|
@@ -1,27 +1,79 @@
|
|
|
1
1
|
#!/usr/bin/env npx tsx
|
|
2
|
+
import type { CancelResponse, OrderResponse } from '../core/types.js';
|
|
3
|
+
import { type FillWatcher } from './execution.js';
|
|
2
4
|
export interface BracketOptions {
|
|
3
5
|
coin: string;
|
|
4
6
|
side: 'buy' | 'sell';
|
|
5
7
|
size: number;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
/** Take profit distance in % from entry. Optional if tpPrice/sl* given. */
|
|
9
|
+
tpPct?: number;
|
|
10
|
+
/** Stop loss distance in % from entry. Optional if slPrice/tp* given. */
|
|
11
|
+
slPct?: number;
|
|
12
|
+
/** Absolute take profit price (takes precedence over tpPct). */
|
|
13
|
+
tpPrice?: number;
|
|
14
|
+
/** Absolute stop loss trigger price (takes precedence over slPct). */
|
|
15
|
+
slPrice?: number;
|
|
8
16
|
entryType?: 'market' | 'limit';
|
|
9
17
|
entryPrice?: number;
|
|
10
18
|
slippage?: number;
|
|
19
|
+
entryTimeoutSec?: number;
|
|
20
|
+
slSlippageBps?: number;
|
|
21
|
+
/** SL fires as a market trigger (default true); false = stop-limit. */
|
|
22
|
+
slMarket?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Limit entries only: submit entry + TP/SL as one atomic normalTpsl batch
|
|
25
|
+
* (default true) — the exchange arms the exits when the entry fills, so the
|
|
26
|
+
* bracket survives this process exiting. false = legacy fill-watch path.
|
|
27
|
+
*/
|
|
28
|
+
atomic?: boolean;
|
|
11
29
|
leverage?: number;
|
|
12
30
|
dryRun?: boolean;
|
|
13
31
|
verbose?: boolean;
|
|
32
|
+
client?: BracketClient;
|
|
33
|
+
fillWatcher?: FillWatcher;
|
|
14
34
|
/** Receives each output line. Defaults to console.log. */
|
|
15
35
|
output?: (line: string) => void;
|
|
16
36
|
}
|
|
37
|
+
export interface BracketClient {
|
|
38
|
+
verbose: boolean;
|
|
39
|
+
getAllMids(): Promise<Record<string, string>>;
|
|
40
|
+
marketOrder(coin: string, isBuy: boolean, size: number, slippageBps?: number, leverage?: number): Promise<OrderResponse>;
|
|
41
|
+
limitOrder(coin: string, isBuy: boolean, size: number, price: number, tif?: 'Gtc' | 'Ioc' | 'Alo', reduceOnly?: boolean, leverage?: number): Promise<OrderResponse>;
|
|
42
|
+
tpslOrders(coin: string, isBuy: boolean, size: number, opts?: {
|
|
43
|
+
takeProfitPrice?: number;
|
|
44
|
+
stopLossPrice?: number;
|
|
45
|
+
stopLossSlippageBps?: number;
|
|
46
|
+
stopLossIsMarket?: boolean;
|
|
47
|
+
grouping?: 'positionTpsl' | 'normalTpsl';
|
|
48
|
+
leverage?: number;
|
|
49
|
+
}): Promise<OrderResponse>;
|
|
50
|
+
bracketOrder(coin: string, isBuy: boolean, size: number, entryPrice: number, opts?: {
|
|
51
|
+
entryTif?: 'Gtc' | 'Alo';
|
|
52
|
+
takeProfitPrice?: number;
|
|
53
|
+
stopLossPrice?: number;
|
|
54
|
+
stopLossSlippageBps?: number;
|
|
55
|
+
stopLossIsMarket?: boolean;
|
|
56
|
+
leverage?: number;
|
|
57
|
+
}): Promise<OrderResponse>;
|
|
58
|
+
cancel(coin: string, oid: number): Promise<CancelResponse>;
|
|
59
|
+
address: string;
|
|
60
|
+
getUserFills(user?: string): Promise<Array<{
|
|
61
|
+
coin: string;
|
|
62
|
+
px: string;
|
|
63
|
+
sz: string;
|
|
64
|
+
time: number;
|
|
65
|
+
oid: number;
|
|
66
|
+
}>>;
|
|
67
|
+
}
|
|
17
68
|
export interface BracketResult {
|
|
18
|
-
status: 'dry' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
69
|
+
status: 'dry' | 'armed' | 'limit_resting' | 'complete' | 'entry_failed' | 'partial';
|
|
19
70
|
entryPrice?: number;
|
|
20
71
|
tpPrice?: number;
|
|
21
72
|
slPrice?: number;
|
|
22
73
|
tpOid?: number | null;
|
|
23
74
|
slOid?: number | null;
|
|
24
75
|
entryOid?: number | null;
|
|
76
|
+
protectedSize?: number;
|
|
25
77
|
reason?: string;
|
|
26
78
|
}
|
|
27
79
|
export declare function runBracket(opts: BracketOptions): Promise<BracketResult>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bracket.d.ts","sourceRoot":"","sources":["../../scripts/operations/bracket.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"bracket.d.ts","sourceRoot":"","sources":["../../scripts/operations/bracket.ts"],"names":[],"mappings":"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"}
|