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,319 @@
1
+ #!/usr/bin/env npx tsx
2
+ // Funding Arbitrage Strategy - Collect funding by being on the paying side
3
+
4
+ import { getClient } from '../core/client.js';
5
+ import { formatUsd, formatPercent, parseArgs, sleep, annualizeFundingRate } from '../core/utils.js';
6
+
7
+ function printUsage() {
8
+ console.log(`
9
+ Open Broker - Funding Arbitrage
10
+ ===============================
11
+
12
+ Collect funding payments by taking positions opposite to the majority.
13
+ When funding is positive (longs pay shorts), go short to collect.
14
+ When funding is negative (shorts pay longs), go long to collect.
15
+
16
+ Usage:
17
+ npx tsx scripts/strategies/funding-arb.ts --coin <COIN> --size <SIZE> [options]
18
+
19
+ Options:
20
+ --coin Asset to trade (e.g., ETH, BTC)
21
+ --size Position size in USD notional
22
+ --min-funding Minimum annualized funding rate to enter (default: 20%)
23
+ --max-funding Maximum funding rate - avoid extreme rates (default: 200%)
24
+ --duration How long to run in hours (default: runs until stopped)
25
+ --check Interval to check funding in minutes (default: 60)
26
+ --close-at Close position when funding drops below X% (default: 5%)
27
+ --dry Dry run - show opportunities without trading
28
+
29
+ Modes:
30
+ --mode perp Perp only - directional exposure to funding (default)
31
+ --mode hedge Open opposite spot position for delta neutral (requires spot)
32
+
33
+ Examples:
34
+ # Monitor ETH funding, enter $5000 short if funding > 25% annualized
35
+ npx tsx scripts/strategies/funding-arb.ts --coin ETH --size 5000 --min-funding 25
36
+
37
+ # Run for 24 hours, check every 30 minutes
38
+ npx tsx scripts/strategies/funding-arb.ts --coin BTC --size 10000 --duration 24 --check 30
39
+
40
+ # Preview current opportunities
41
+ npx tsx scripts/strategies/funding-arb.ts --coin ETH --size 5000 --dry
42
+
43
+ Funding Info:
44
+ - Hyperliquid funding is paid HOURLY (not 8h like CEXs)
45
+ - Positive rate: longs pay shorts (go short to collect)
46
+ - Negative rate: shorts pay longs (go long to collect)
47
+ - Annualized = hourly rate × 8760
48
+ `);
49
+ }
50
+
51
+ interface FundingPosition {
52
+ coin: string;
53
+ side: 'long' | 'short';
54
+ size: number;
55
+ entryPrice: number;
56
+ entryFunding: number;
57
+ entryTime: Date;
58
+ }
59
+
60
+ async function main() {
61
+ const args = parseArgs(process.argv.slice(2));
62
+
63
+ const coin = args.coin as string;
64
+ const notionalSize = parseFloat(args.size as string);
65
+ const minFunding = args['min-funding'] ? parseFloat(args['min-funding'] as string) : 20;
66
+ const maxFunding = args['max-funding'] ? parseFloat(args['max-funding'] as string) : 200;
67
+ const durationHours = args.duration ? parseFloat(args.duration as string) : Infinity;
68
+ const checkIntervalMins = args.check ? parseFloat(args.check as string) : 60;
69
+ const closeAt = args['close-at'] ? parseFloat(args['close-at'] as string) : 5;
70
+ const mode = (args.mode as string) || 'perp';
71
+ const dryRun = args.dry as boolean;
72
+
73
+ if (!coin || isNaN(notionalSize)) {
74
+ printUsage();
75
+ process.exit(1);
76
+ }
77
+
78
+ if (mode !== 'perp' && mode !== 'hedge') {
79
+ console.error('Error: --mode must be "perp" or "hedge"');
80
+ process.exit(1);
81
+ }
82
+
83
+ if (mode === 'hedge') {
84
+ console.error('Error: Hedge mode (perp + spot) not yet implemented');
85
+ process.exit(1);
86
+ }
87
+
88
+ const client = getClient();
89
+
90
+ if (args.verbose) {
91
+ client.verbose = true;
92
+ }
93
+
94
+ console.log('Open Broker - Funding Arbitrage');
95
+ console.log('===============================\n');
96
+
97
+ try {
98
+ // Initial funding check
99
+ await client.getMetaAndAssetCtxs();
100
+ const meta = await client.getMetaAndAssetCtxs();
101
+ const assetIndex = client.getAssetIndex(coin);
102
+ const assetCtx = meta.assetCtxs[assetIndex];
103
+
104
+ const mids = await client.getAllMids();
105
+ const midPrice = parseFloat(mids[coin]);
106
+ if (!midPrice) {
107
+ console.error(`Error: No market data for ${coin}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ const hourlyFunding = parseFloat(assetCtx.funding);
112
+ const annualizedFunding = annualizeFundingRate(hourlyFunding) * 100; // as percentage
113
+ const openInterest = parseFloat(assetCtx.openInterest);
114
+ const positionSize = notionalSize / midPrice;
115
+
116
+ console.log('Current Market State');
117
+ console.log('--------------------');
118
+ console.log(`Coin: ${coin}`);
119
+ console.log(`Mid Price: ${formatUsd(midPrice)}`);
120
+ console.log(`Hourly Funding: ${(hourlyFunding * 100).toFixed(4)}%`);
121
+ console.log(`Annualized: ${annualizedFunding.toFixed(2)}%`);
122
+ console.log(`Open Interest: ${formatUsd(openInterest)}`);
123
+ console.log(`\nStrategy Config`);
124
+ console.log('---------------');
125
+ console.log(`Target Notional: ${formatUsd(notionalSize)}`);
126
+ console.log(`Position Size: ${positionSize.toFixed(6)} ${coin}`);
127
+ console.log(`Min Funding: ${minFunding}% annualized`);
128
+ console.log(`Max Funding: ${maxFunding}% annualized`);
129
+ console.log(`Close At: ${closeAt}% annualized`);
130
+ console.log(`Check Interval: ${checkIntervalMins} minutes`);
131
+ console.log(`Mode: ${mode.toUpperCase()}`);
132
+
133
+ // Determine action based on funding
134
+ const absAnnualized = Math.abs(annualizedFunding);
135
+ const shouldGoShort = annualizedFunding > 0; // Positive funding = longs pay shorts
136
+ const suggestedSide = shouldGoShort ? 'SHORT' : 'LONG';
137
+
138
+ console.log(`\nFunding Analysis`);
139
+ console.log('----------------');
140
+ if (absAnnualized >= minFunding && absAnnualized <= maxFunding) {
141
+ console.log(`Status: OPPORTUNITY FOUND`);
142
+ console.log(`Suggested: ${suggestedSide} to collect ${absAnnualized.toFixed(2)}% APR`);
143
+
144
+ const hourlyPayment = Math.abs(hourlyFunding) * notionalSize;
145
+ const dailyPayment = hourlyPayment * 24;
146
+ const monthlyPayment = dailyPayment * 30;
147
+ console.log(`\nEstimated Funding Income:`);
148
+ console.log(` Hourly: ${formatUsd(hourlyPayment)}`);
149
+ console.log(` Daily: ${formatUsd(dailyPayment)}`);
150
+ console.log(` Monthly: ${formatUsd(monthlyPayment)}`);
151
+ } else if (absAnnualized < minFunding) {
152
+ console.log(`Status: FUNDING TOO LOW (${absAnnualized.toFixed(2)}% < ${minFunding}%)`);
153
+ console.log(`Action: Wait for higher funding rates`);
154
+ } else {
155
+ console.log(`Status: FUNDING TOO HIGH (${absAnnualized.toFixed(2)}% > ${maxFunding}%)`);
156
+ console.log(`Action: Risk of squeeze, skip this opportunity`);
157
+ }
158
+
159
+ if (dryRun) {
160
+ console.log('\n--- Dry run complete ---');
161
+ return;
162
+ }
163
+
164
+ // Check if we should enter
165
+ if (absAnnualized < minFunding || absAnnualized > maxFunding) {
166
+ console.log('\nNo action taken - funding outside target range.');
167
+
168
+ if (durationHours === Infinity) {
169
+ console.log('Exiting. Use --duration to run continuous monitoring.');
170
+ return;
171
+ }
172
+ }
173
+
174
+ // Execute entry if conditions met
175
+ let position: FundingPosition | null = null;
176
+
177
+ if (absAnnualized >= minFunding && absAnnualized <= maxFunding) {
178
+ console.log(`\nOpening ${suggestedSide} position...`);
179
+
180
+ const isBuy = !shouldGoShort;
181
+ const response = await client.marketOrder(coin, isBuy, positionSize);
182
+
183
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
184
+ const status = response.response.data.statuses[0];
185
+ if (status?.filled) {
186
+ const avgPrice = parseFloat(status.filled.avgPx);
187
+ const filledSize = parseFloat(status.filled.totalSz);
188
+ console.log(` Entry filled: ${filledSize} ${coin} @ ${formatUsd(avgPrice)}`);
189
+
190
+ position = {
191
+ coin,
192
+ side: shouldGoShort ? 'short' : 'long',
193
+ size: filledSize,
194
+ entryPrice: avgPrice,
195
+ entryFunding: annualizedFunding,
196
+ entryTime: new Date(),
197
+ };
198
+ } else if (status?.error) {
199
+ console.log(` Entry failed: ${status.error}`);
200
+ }
201
+ } else {
202
+ console.log(` Entry failed: ${typeof response.response === 'string' ? response.response : 'Unknown error'}`);
203
+ }
204
+ }
205
+
206
+ // Monitoring loop
207
+ if (durationHours !== Infinity || position) {
208
+ const endTime = durationHours === Infinity ? Infinity : Date.now() + durationHours * 3600 * 1000;
209
+ const checkInterval = checkIntervalMins * 60 * 1000;
210
+
211
+ console.log(`\nMonitoring funding rates...`);
212
+ console.log(`(Press Ctrl+C to exit)\n`);
213
+
214
+ let totalFundingCollected = 0;
215
+ let lastCheck = Date.now();
216
+
217
+ while (Date.now() < endTime) {
218
+ await sleep(checkInterval);
219
+
220
+ // Get updated funding
221
+ const newMeta = await client.getMetaAndAssetCtxs();
222
+ const newAssetCtx = newMeta.assetCtxs[assetIndex];
223
+ const newHourlyFunding = parseFloat(newAssetCtx.funding);
224
+ const newAnnualized = annualizeFundingRate(newHourlyFunding) * 100;
225
+ const newMids = await client.getAllMids();
226
+ const newPrice = parseFloat(newMids[coin]);
227
+
228
+ const timeElapsed = (Date.now() - lastCheck) / 3600000; // hours
229
+ lastCheck = Date.now();
230
+
231
+ console.log(`[${new Date().toLocaleTimeString()}] ${coin}: ${newAnnualized.toFixed(2)}% APR, Price: ${formatUsd(newPrice)}`);
232
+
233
+ if (position) {
234
+ // Calculate funding collected
235
+ const fundingCollected = Math.abs(newHourlyFunding) * position.size * newPrice * timeElapsed;
236
+ totalFundingCollected += fundingCollected;
237
+
238
+ // Calculate PnL
239
+ const pricePnl = position.side === 'long'
240
+ ? (newPrice - position.entryPrice) * position.size
241
+ : (position.entryPrice - newPrice) * position.size;
242
+ const totalPnl = pricePnl + totalFundingCollected;
243
+
244
+ console.log(` Position: ${position.side.toUpperCase()} ${position.size.toFixed(6)} @ ${formatUsd(position.entryPrice)}`);
245
+ console.log(` Price PnL: ${formatUsd(pricePnl)}, Funding: ${formatUsd(totalFundingCollected)}, Total: ${formatUsd(totalPnl)}`);
246
+
247
+ // Check if we should close
248
+ const shouldClose =
249
+ (position.side === 'short' && newAnnualized < closeAt) ||
250
+ (position.side === 'long' && newAnnualized > -closeAt);
251
+
252
+ if (shouldClose) {
253
+ console.log(`\n Funding dropped below ${closeAt}%, closing position...`);
254
+
255
+ const closeIsBuy = position.side === 'short';
256
+ const closeResponse = await client.marketOrder(coin, closeIsBuy, position.size);
257
+
258
+ if (closeResponse.status === 'ok' && closeResponse.response && typeof closeResponse.response === 'object') {
259
+ const closeStatus = closeResponse.response.data.statuses[0];
260
+ if (closeStatus?.filled) {
261
+ const exitPrice = parseFloat(closeStatus.filled.avgPx);
262
+ console.log(` Closed @ ${formatUsd(exitPrice)}`);
263
+ console.log(`\n === Position Summary ===`);
264
+ console.log(` Entry: ${formatUsd(position.entryPrice)}`);
265
+ console.log(` Exit: ${formatUsd(exitPrice)}`);
266
+ console.log(` Price PnL: ${formatUsd(pricePnl)}`);
267
+ console.log(` Funding: ${formatUsd(totalFundingCollected)}`);
268
+ console.log(` Total PnL: ${formatUsd(totalPnl)}`);
269
+ position = null;
270
+ }
271
+ }
272
+ }
273
+ } else {
274
+ // No position - check if we should enter
275
+ const absNewAnnualized = Math.abs(newAnnualized);
276
+ if (absNewAnnualized >= minFunding && absNewAnnualized <= maxFunding) {
277
+ const enterShort = newAnnualized > 0;
278
+ const enterSide = enterShort ? 'SHORT' : 'LONG';
279
+ console.log(` Funding opportunity: ${enterSide} at ${absNewAnnualized.toFixed(2)}% APR`);
280
+
281
+ const enterIsBuy = !enterShort;
282
+ const currentSize = notionalSize / newPrice;
283
+ const response = await client.marketOrder(coin, enterIsBuy, currentSize);
284
+
285
+ if (response.status === 'ok' && response.response && typeof response.response === 'object') {
286
+ const status = response.response.data.statuses[0];
287
+ if (status?.filled) {
288
+ position = {
289
+ coin,
290
+ side: enterShort ? 'short' : 'long',
291
+ size: parseFloat(status.filled.totalSz),
292
+ entryPrice: parseFloat(status.filled.avgPx),
293
+ entryFunding: newAnnualized,
294
+ entryTime: new Date(),
295
+ };
296
+ console.log(` Entered ${enterSide} ${position.size.toFixed(6)} @ ${formatUsd(position.entryPrice)}`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ console.log('');
303
+ }
304
+
305
+ // Close any remaining position at end of duration
306
+ if (position) {
307
+ console.log(`\nDuration ended. Closing position...`);
308
+ const closeIsBuy = position.side === 'short';
309
+ await client.marketOrder(coin, closeIsBuy, position.size);
310
+ }
311
+ }
312
+
313
+ } catch (error) {
314
+ console.error('Error:', error);
315
+ process.exit(1);
316
+ }
317
+ }
318
+
319
+ main();