openbroker 1.0.33

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.
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env npx tsx
2
+ // Simple Market Making - Place bid/ask quotes around mid price with inventory management
3
+
4
+ import { getClient } from '../core/client.js';
5
+ import { formatUsd, parseArgs, sleep } from '../core/utils.js';
6
+
7
+ function printUsage() {
8
+ console.log(`
9
+ Open Broker - Market Making Spread
10
+ ==================================
11
+
12
+ Place bid and ask orders around the mid price, earning the spread when both sides fill.
13
+ Includes inventory management to stay neutral and avoid directional drift.
14
+
15
+ Usage:
16
+ npx tsx scripts/strategies/mm-spread.ts --coin <COIN> --size <SIZE> --spread <BPS>
17
+
18
+ Options:
19
+ --coin Asset to market make (e.g., ETH, BTC)
20
+ --size Order size on each side (in base asset)
21
+ --spread Spread in bps from mid (e.g., 10 = 0.1% each side)
22
+ --skew-factor How aggressively to skew for inventory (default: 2.0)
23
+ --refresh Refresh interval in milliseconds (default: 2000)
24
+ --max-position Maximum net position before stopping that side (default: 3x size)
25
+ --cooldown Cooldown after fill before same-side quote (ms, default: 5000)
26
+ --duration How long to run in minutes (default: runs until stopped)
27
+ --dry Dry run - show strategy parameters without trading
28
+
29
+ Inventory Management:
30
+ The strategy automatically skews quotes based on inventory to stay neutral:
31
+ - If LONG: Bid is wider (less aggressive), Ask is tighter (more aggressive)
32
+ - If SHORT: Bid is tighter (more aggressive), Ask is wider (less aggressive)
33
+ - At max position: Stops quoting that side entirely
34
+
35
+ Examples:
36
+ # Market make ETH with 0.1 size, 10bps spread
37
+ npx tsx scripts/strategies/mm-spread.ts --coin ETH --size 0.1 --spread 10
38
+
39
+ # Tighter position limit and faster cooldown
40
+ npx tsx scripts/strategies/mm-spread.ts --coin BTC --size 0.01 --spread 5 --max-position 0.03 --cooldown 3000
41
+
42
+ # Preview setup
43
+ npx tsx scripts/strategies/mm-spread.ts --coin SOL --size 10 --spread 15 --dry
44
+
45
+ Risks:
46
+ - Adverse selection: getting picked off by informed traders
47
+ - Inventory risk: accumulating position during directional moves
48
+ - Use smaller --max-position for volatile assets
49
+ `);
50
+ }
51
+
52
+ interface Quote {
53
+ side: 'bid' | 'ask';
54
+ price: number;
55
+ size: number;
56
+ oid?: number;
57
+ placedAt: number;
58
+ }
59
+
60
+ interface MmState {
61
+ bid: Quote | null;
62
+ ask: Quote | null;
63
+ lastBidFill: number;
64
+ lastAskFill: number;
65
+ totalBought: number;
66
+ totalSold: number;
67
+ totalBuyCost: number;
68
+ totalSellRevenue: number;
69
+ roundTrips: number;
70
+ }
71
+
72
+ async function main() {
73
+ const args = parseArgs(process.argv.slice(2));
74
+
75
+ const coin = args.coin as string;
76
+ const size = parseFloat(args.size as string);
77
+ const spreadBps = parseFloat(args.spread as string);
78
+ const skewFactor = args['skew-factor'] ? parseFloat(args['skew-factor'] as string) : 2.0;
79
+ const refreshMs = args.refresh ? parseInt(args.refresh as string) : 2000;
80
+ const maxPosition = args['max-position'] ? parseFloat(args['max-position'] as string) : size * 3;
81
+ const cooldownMs = args.cooldown ? parseInt(args.cooldown as string) : 5000;
82
+ const durationMins = args.duration ? parseFloat(args.duration as string) : Infinity;
83
+ const dryRun = args.dry as boolean;
84
+
85
+ if (!coin || isNaN(size) || isNaN(spreadBps)) {
86
+ printUsage();
87
+ process.exit(1);
88
+ }
89
+
90
+ if (spreadBps < 1) {
91
+ console.error('Error: --spread must be at least 1 bps');
92
+ process.exit(1);
93
+ }
94
+
95
+ const client = getClient();
96
+
97
+ if (args.verbose) {
98
+ client.verbose = true;
99
+ }
100
+
101
+ console.log('Open Broker - Market Making');
102
+ console.log('===========================\n');
103
+
104
+ try {
105
+ // Get fresh market data
106
+ const mids = await client.getAllMids();
107
+ const midPrice = parseFloat(mids[coin]);
108
+ if (!midPrice) {
109
+ console.error(`Error: No market data for ${coin}`);
110
+ process.exit(1);
111
+ }
112
+
113
+ const baseSpread = spreadBps / 10000;
114
+ const halfSpread = baseSpread / 2;
115
+ const bidPrice = midPrice * (1 - halfSpread);
116
+ const askPrice = midPrice * (1 + halfSpread);
117
+ const notionalPerSide = size * midPrice;
118
+ const profitPerRoundTrip = (askPrice - bidPrice) * size;
119
+
120
+ console.log('Strategy Configuration');
121
+ console.log('----------------------');
122
+ console.log(`Coin: ${coin}`);
123
+ console.log(`Current Mid: ${formatUsd(midPrice)}`);
124
+ console.log(`Order Size: ${size} ${coin}`);
125
+ console.log(`Notional/Side: ${formatUsd(notionalPerSide)}`);
126
+ console.log(`Spread: ${spreadBps} bps (${(spreadBps / 100).toFixed(2)}%)`);
127
+ console.log(`Max Position: ±${maxPosition} ${coin}`);
128
+ console.log(`Skew Factor: ${skewFactor}x`);
129
+ console.log(`Cooldown: ${cooldownMs}ms`);
130
+ console.log(`Refresh: ${refreshMs}ms`);
131
+ console.log(`\nQuote Prices (at neutral):`);
132
+ console.log(` Bid: ${formatUsd(bidPrice)} (-${(halfSpread * 100).toFixed(3)}%)`);
133
+ console.log(` Ask: ${formatUsd(askPrice)} (+${(halfSpread * 100).toFixed(3)}%)`);
134
+ console.log(`\nProfit/Round Trip: ${formatUsd(profitPerRoundTrip)}`);
135
+
136
+ console.log(`\nInventory Skewing:`);
137
+ console.log(` When LONG: Bid wider, Ask tighter (encourage selling)`);
138
+ console.log(` When SHORT: Bid tighter, Ask wider (encourage buying)`);
139
+
140
+ if (dryRun) {
141
+ console.log('\n--- Dry run complete ---');
142
+ return;
143
+ }
144
+
145
+ console.log('\nStarting market making...\n');
146
+
147
+ const state: MmState = {
148
+ bid: null,
149
+ ask: null,
150
+ lastBidFill: 0,
151
+ lastAskFill: 0,
152
+ totalBought: 0,
153
+ totalSold: 0,
154
+ totalBuyCost: 0,
155
+ totalSellRevenue: 0,
156
+ roundTrips: 0,
157
+ };
158
+
159
+ const endTime = durationMins === Infinity ? Infinity : Date.now() + durationMins * 60 * 1000;
160
+
161
+ // Handle graceful shutdown
162
+ let shuttingDown = false;
163
+ process.on('SIGINT', async () => {
164
+ if (shuttingDown) return;
165
+ shuttingDown = true;
166
+ console.log('\n\nShutting down - cancelling quotes...');
167
+ await cancelQuotes(client, coin, state);
168
+ await printSummary(client, coin, state);
169
+ process.exit(0);
170
+ });
171
+
172
+ let lastLogTime = 0;
173
+ let iteration = 0;
174
+
175
+ while (Date.now() < endTime && !shuttingDown) {
176
+ iteration++;
177
+ const now = Date.now();
178
+
179
+ // Always get fresh mid price
180
+ const freshMids = await client.getAllMids();
181
+ const currentMid = parseFloat(freshMids[coin]);
182
+
183
+ // Get actual position from exchange for ground truth
184
+ const userState = await client.getUserState();
185
+ const position = userState.assetPositions.find(p => p.position.coin === coin);
186
+ const actualPosition = position ? parseFloat(position.position.szi) : 0;
187
+
188
+ // Get our open orders
189
+ const openOrders = await client.getOpenOrders();
190
+ const ourOrders = openOrders.filter(o => o.coin === coin);
191
+ const openOids = new Set(ourOrders.map(o => o.oid));
192
+
193
+ // Check for bid fill
194
+ if (state.bid && state.bid.oid && !openOids.has(state.bid.oid)) {
195
+ const fillPrice = state.bid.price;
196
+ state.totalBought += state.bid.size;
197
+ state.totalBuyCost += fillPrice * state.bid.size;
198
+ state.lastBidFill = now;
199
+ console.log(`[${new Date().toLocaleTimeString()}] BID FILLED @ ${formatUsd(fillPrice)} | Position: ${actualPosition.toFixed(4)}`);
200
+ state.bid = null;
201
+ }
202
+
203
+ // Check for ask fill
204
+ if (state.ask && state.ask.oid && !openOids.has(state.ask.oid)) {
205
+ const fillPrice = state.ask.price;
206
+ state.totalSold += state.ask.size;
207
+ state.totalSellRevenue += fillPrice * state.ask.size;
208
+ state.lastAskFill = now;
209
+ state.roundTrips += 0.5;
210
+ console.log(`[${new Date().toLocaleTimeString()}] ASK FILLED @ ${formatUsd(fillPrice)} | Position: ${actualPosition.toFixed(4)}`);
211
+ state.ask = null;
212
+ }
213
+
214
+ // Calculate inventory-based skew
215
+ // Skew ranges from -1 (max short) to +1 (max long)
216
+ const inventoryRatio = Math.max(-1, Math.min(1, actualPosition / maxPosition));
217
+
218
+ // Skew the spread: positive inventory = wider bid, tighter ask
219
+ const bidSkew = halfSpread * (1 + inventoryRatio * skewFactor);
220
+ const askSkew = halfSpread * (1 - inventoryRatio * skewFactor);
221
+
222
+ // Calculate skewed prices
223
+ const skewedBidPrice = currentMid * (1 - Math.max(0.0001, bidSkew));
224
+ const skewedAskPrice = currentMid * (1 + Math.max(0.0001, askSkew));
225
+
226
+ // Determine if we should quote each side
227
+ const bidCooldownOk = (now - state.lastBidFill) > cooldownMs;
228
+ const askCooldownOk = (now - state.lastAskFill) > cooldownMs;
229
+ const shouldBid = actualPosition < maxPosition && bidCooldownOk;
230
+ const shouldAsk = actualPosition > -maxPosition && askCooldownOk;
231
+
232
+ // Cancel stale quotes that have drifted too far from target
233
+ if (state.bid && state.bid.oid) {
234
+ const bidDrift = Math.abs(state.bid.price - skewedBidPrice) / currentMid;
235
+ if (bidDrift > 0.001 || !shouldBid) { // 0.1% drift threshold
236
+ try {
237
+ await client.cancel(coin, state.bid.oid);
238
+ state.bid = null;
239
+ } catch {
240
+ // Order may have been filled
241
+ state.bid = null;
242
+ }
243
+ }
244
+ }
245
+
246
+ if (state.ask && state.ask.oid) {
247
+ const askDrift = Math.abs(state.ask.price - skewedAskPrice) / currentMid;
248
+ if (askDrift > 0.001 || !shouldAsk) { // 0.1% drift threshold
249
+ try {
250
+ await client.cancel(coin, state.ask.oid);
251
+ state.ask = null;
252
+ } catch {
253
+ // Order may have been filled
254
+ state.ask = null;
255
+ }
256
+ }
257
+ }
258
+
259
+ // Place new bid if needed
260
+ if (shouldBid && !state.bid) {
261
+ const response = await client.limitOrder(coin, true, size, skewedBidPrice, 'Gtc', false);
262
+
263
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
264
+ const status = response.response.data.statuses[0];
265
+ if (status?.resting) {
266
+ state.bid = {
267
+ side: 'bid',
268
+ price: skewedBidPrice,
269
+ size,
270
+ oid: status.resting.oid,
271
+ placedAt: now,
272
+ };
273
+ } else if (status?.filled) {
274
+ // Filled immediately - unusual for a bid below mid
275
+ const fillPrice = parseFloat(status.filled.avgPx);
276
+ state.totalBought += size;
277
+ state.totalBuyCost += fillPrice * size;
278
+ state.lastBidFill = now;
279
+ console.log(`[${new Date().toLocaleTimeString()}] BID FILLED IMMEDIATELY @ ${formatUsd(fillPrice)}`);
280
+ }
281
+ }
282
+ }
283
+
284
+ // Place new ask if needed
285
+ if (shouldAsk && !state.ask) {
286
+ const response = await client.limitOrder(coin, false, size, skewedAskPrice, 'Gtc', false);
287
+
288
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
289
+ const status = response.response.data.statuses[0];
290
+ if (status?.resting) {
291
+ state.ask = {
292
+ side: 'ask',
293
+ price: skewedAskPrice,
294
+ size,
295
+ oid: status.resting.oid,
296
+ placedAt: now,
297
+ };
298
+ } else if (status?.filled) {
299
+ // Filled immediately - unusual for an ask above mid
300
+ const fillPrice = parseFloat(status.filled.avgPx);
301
+ state.totalSold += size;
302
+ state.totalSellRevenue += fillPrice * size;
303
+ state.lastAskFill = now;
304
+ state.roundTrips += 0.5;
305
+ console.log(`[${new Date().toLocaleTimeString()}] ASK FILLED IMMEDIATELY @ ${formatUsd(fillPrice)}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ // Periodic status log
311
+ if (now - lastLogTime > 30000) {
312
+ const bidStatus = state.bid ? formatUsd(state.bid.price) : (shouldBid ? 'placing...' : 'paused');
313
+ const askStatus = state.ask ? formatUsd(state.ask.price) : (shouldAsk ? 'placing...' : 'paused');
314
+ const realizedPnl = state.totalSellRevenue - state.totalBuyCost;
315
+ const skewPct = (inventoryRatio * 100).toFixed(1);
316
+
317
+ console.log(`[${new Date().toLocaleTimeString()}] Mid: ${formatUsd(currentMid)} | Bid: ${bidStatus} | Ask: ${askStatus} | Pos: ${actualPosition.toFixed(4)} (skew: ${skewPct}%) | PnL: ${formatUsd(realizedPnl)}`);
318
+ lastLogTime = now;
319
+ }
320
+
321
+ await sleep(refreshMs);
322
+ }
323
+
324
+ // End of duration
325
+ if (!shuttingDown) {
326
+ console.log('\nDuration ended. Cancelling quotes...');
327
+ await cancelQuotes(client, coin, state);
328
+ await printSummary(client, coin, state);
329
+ }
330
+
331
+ } catch (error) {
332
+ console.error('Error:', error);
333
+ process.exit(1);
334
+ }
335
+ }
336
+
337
+ async function cancelQuotes(
338
+ client: ReturnType<typeof getClient>,
339
+ coin: string,
340
+ state: MmState
341
+ ): Promise<void> {
342
+ if (state.bid && state.bid.oid) {
343
+ try {
344
+ await client.cancel(coin, state.bid.oid);
345
+ console.log(` Cancelled bid @ ${formatUsd(state.bid.price)}`);
346
+ } catch {
347
+ // Ignore
348
+ }
349
+ }
350
+ if (state.ask && state.ask.oid) {
351
+ try {
352
+ await client.cancel(coin, state.ask.oid);
353
+ console.log(` Cancelled ask @ ${formatUsd(state.ask.price)}`);
354
+ } catch {
355
+ // Ignore
356
+ }
357
+ }
358
+ }
359
+
360
+ async function printSummary(
361
+ client: ReturnType<typeof getClient>,
362
+ coin: string,
363
+ state: MmState
364
+ ): Promise<void> {
365
+ // Get final position and price
366
+ const userState = await client.getUserState();
367
+ const position = userState.assetPositions.find(p => p.position.coin === coin);
368
+ const finalPosition = position ? parseFloat(position.position.szi) : 0;
369
+
370
+ const mids = await client.getAllMids();
371
+ const currentMid = parseFloat(mids[coin]);
372
+
373
+ const inventoryValue = finalPosition * currentMid;
374
+ const realizedPnl = state.totalSellRevenue - state.totalBuyCost;
375
+
376
+ // Calculate inventory PnL
377
+ let inventoryPnl = 0;
378
+ if (finalPosition > 0 && state.totalBought > 0) {
379
+ const avgBuyPrice = state.totalBuyCost / state.totalBought;
380
+ inventoryPnl = (currentMid - avgBuyPrice) * finalPosition;
381
+ } else if (finalPosition < 0 && state.totalSold > 0) {
382
+ const avgSellPrice = state.totalSellRevenue / state.totalSold;
383
+ inventoryPnl = (avgSellPrice - currentMid) * Math.abs(finalPosition);
384
+ }
385
+
386
+ console.log('\n========== MM Summary ==========');
387
+ console.log(`Total Bought: ${state.totalBought.toFixed(6)} ${coin}`);
388
+ console.log(`Total Sold: ${state.totalSold.toFixed(6)} ${coin}`);
389
+ console.log(`Final Position: ${finalPosition.toFixed(6)} ${coin}`);
390
+ console.log(`Inventory Value: ${formatUsd(inventoryValue)}`);
391
+ console.log(`Round Trips: ${state.roundTrips.toFixed(1)}`);
392
+ console.log(`Realized PnL: ${formatUsd(realizedPnl)}`);
393
+ console.log(`Inventory PnL: ${formatUsd(inventoryPnl)}`);
394
+ console.log(`Total PnL: ${formatUsd(realizedPnl + inventoryPnl)}`);
395
+
396
+ if (Math.abs(finalPosition) > 0.0001) {
397
+ console.log(`\n⚠️ You have an open position of ${finalPosition.toFixed(6)} ${coin}`);
398
+ console.log(` Close it manually if desired.`);
399
+ }
400
+ }
401
+
402
+ main();