perp-cli 0.9.7 → 0.11.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/dist/bot/engine.js +23 -4
- package/dist/bot/presets.js +3 -3
- package/dist/bot/strategies/funding-arb-strategy.d.ts +0 -23
- package/dist/bot/strategies/funding-arb-strategy.js +41 -176
- package/dist/bot/strategies/funding-arb-utils.js +9 -1
- package/dist/bot/strategies/funding-arb-v2-strategy.js +20 -11
- package/dist/bot/strategies/funding-auto.js +2 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +2 -1
- package/dist/bot/strategies/spot-perp-arb-strategy.js +26 -18
- package/dist/bot/strategies/spot-perp-executor.js +4 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/ows.d.ts +2 -0
- package/dist/commands/ows.js +887 -0
- package/dist/commands/wallet.js +378 -160
- package/dist/config.d.ts +9 -0
- package/dist/config.js +55 -9
- package/dist/exchanges/aster.js +2 -1
- package/dist/exchanges/hyperliquid.d.ts +1 -0
- package/dist/exchanges/hyperliquid.js +5 -0
- package/dist/exchanges/interface.d.ts +2 -0
- package/dist/exchanges/lighter-spot.js +5 -2
- package/dist/exchanges/lighter.js +14 -4
- package/dist/guardrail/contracts.d.ts +14 -0
- package/dist/guardrail/contracts.js +72 -0
- package/dist/guardrail/perp-guardrail.d.ts +21 -0
- package/dist/guardrail/perp-guardrail.js +137 -0
- package/dist/index.js +54 -4
- package/dist/mcp-server.js +11 -9
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +2 -0
- package/dist/signer/index.d.ts +2 -0
- package/dist/signer/index.js +2 -0
- package/dist/signer/ows-evm.d.ts +18 -0
- package/dist/signer/ows-evm.js +67 -0
- package/dist/signer/ows-loader.d.ts +2 -0
- package/dist/signer/ows-loader.js +6 -0
- package/dist/signer/ows-solana.d.ts +25 -0
- package/dist/signer/ows-solana.js +56 -0
- package/package.json +4 -2
package/dist/bot/engine.js
CHANGED
|
@@ -2,6 +2,9 @@ import { getMarketSnapshot, evaluateAllConditions } from "./conditions.js";
|
|
|
2
2
|
import { updateJobState } from "../jobs.js";
|
|
3
3
|
import { getStrategy } from "./strategy-registry.js";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
5
8
|
// Auto-register built-in strategies
|
|
6
9
|
import "./strategies/grid-strategy.js";
|
|
7
10
|
import "./strategies/dca-strategy.js";
|
|
@@ -87,14 +90,30 @@ mode = "headless") {
|
|
|
87
90
|
const tuiEmitter = new TuiEmitter();
|
|
88
91
|
let tuiUnmount;
|
|
89
92
|
let paused = false;
|
|
90
|
-
//
|
|
93
|
+
// Log file: always write to ~/.perp/logs/<bot-name>.log
|
|
94
|
+
const logDir = join(homedir(), ".perp", "logs");
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(logDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
catch { /* ignore */ }
|
|
99
|
+
const logFile = join(logDir, `${config.name}.log`);
|
|
100
|
+
const writeLog = (msg) => {
|
|
101
|
+
const ts = new Date().toISOString();
|
|
102
|
+
const clean = msg.replace(/\x1b\[[0-9;]*m/g, ""); // strip ANSI colors
|
|
103
|
+
try {
|
|
104
|
+
appendFileSync(logFile, `${ts} ${clean}\n`);
|
|
105
|
+
}
|
|
106
|
+
catch { /* ignore */ }
|
|
107
|
+
};
|
|
108
|
+
// Wrap log for TUI/JSON modes + always write to file
|
|
91
109
|
let effectiveLog = mode === "json"
|
|
92
|
-
? () => { } // JSON mode suppresses console
|
|
110
|
+
? (msg) => { writeLog(msg); } // JSON mode suppresses console; still log to file
|
|
93
111
|
: mode === "tui"
|
|
94
|
-
? (msg) => tuiEmitter.emitLog(msg)
|
|
95
|
-
: log;
|
|
112
|
+
? (msg) => { tuiEmitter.emitLog(msg); writeLog(msg); }
|
|
113
|
+
: (msg) => { log(msg); writeLog(msg); };
|
|
96
114
|
// Update strategy context to use effective log
|
|
97
115
|
strategyCtx.log = effectiveLog;
|
|
116
|
+
effectiveLog(` Log file: ${logFile}`);
|
|
98
117
|
if (mode === "tui") {
|
|
99
118
|
try {
|
|
100
119
|
const { startDashboard } = await import("./tui/index.js");
|
package/dist/bot/presets.js
CHANGED
|
@@ -211,7 +211,7 @@ const arbConservative = {
|
|
|
211
211
|
buildConfig: (exchange, _symbol) => ({
|
|
212
212
|
name: `arb-conservative-${Date.now().toString(36)}`,
|
|
213
213
|
exchange,
|
|
214
|
-
symbol: "
|
|
214
|
+
symbol: "ALL",
|
|
215
215
|
strategy: {
|
|
216
216
|
type: "funding-arb",
|
|
217
217
|
min_spread: 30,
|
|
@@ -234,7 +234,7 @@ const arbStandard = {
|
|
|
234
234
|
buildConfig: (exchange, _symbol) => ({
|
|
235
235
|
name: `arb-standard-${Date.now().toString(36)}`,
|
|
236
236
|
exchange,
|
|
237
|
-
symbol: "
|
|
237
|
+
symbol: "ALL",
|
|
238
238
|
strategy: {
|
|
239
239
|
type: "funding-arb",
|
|
240
240
|
min_spread: 20,
|
|
@@ -257,7 +257,7 @@ const arbAggressive = {
|
|
|
257
257
|
buildConfig: (exchange, _symbol) => ({
|
|
258
258
|
name: `arb-aggressive-${Date.now().toString(36)}`,
|
|
259
259
|
exchange,
|
|
260
|
-
symbol: "
|
|
260
|
+
symbol: "ALL",
|
|
261
261
|
strategy: {
|
|
262
262
|
type: "funding-arb",
|
|
263
263
|
min_spread: 10,
|
|
@@ -26,7 +26,6 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
26
26
|
private get params();
|
|
27
27
|
private _config;
|
|
28
28
|
private _failCooldown;
|
|
29
|
-
private _spotAdapterCache;
|
|
30
29
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
31
30
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
32
31
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
@@ -36,26 +35,4 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
36
35
|
private closeSpotPerp;
|
|
37
36
|
/** Open a spot-perp position: transfer USDC to spot, buy spot, short perp */
|
|
38
37
|
private openSpotPerp;
|
|
39
|
-
/** Transfer USDC from perp to spot account (exchange-specific) */
|
|
40
|
-
private transferUsdcToSpot;
|
|
41
|
-
/** Transfer USDC from spot to perp account (exchange-specific) */
|
|
42
|
-
private transferUsdcToPerp;
|
|
43
|
-
/**
|
|
44
|
-
* Get spot adapter for a given exchange name + perp adapter instance.
|
|
45
|
-
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
46
|
-
*/
|
|
47
|
-
private getSpotAdapter;
|
|
48
|
-
/**
|
|
49
|
-
* Resolve the perp symbol for a given base symbol on an exchange.
|
|
50
|
-
* HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
|
|
51
|
-
*/
|
|
52
|
-
private getPerpSymbol;
|
|
53
|
-
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP */
|
|
54
|
-
private findRate;
|
|
55
|
-
/**
|
|
56
|
-
* Get a price estimate for a symbol from the perp adapter.
|
|
57
|
-
* Tries the base symbol first, then with -PERP suffix.
|
|
58
|
-
*/
|
|
59
|
-
private getPriceEstimate;
|
|
60
|
-
private fetchRates;
|
|
61
38
|
}
|
|
@@ -3,6 +3,8 @@ import { checkArbLiquidity, checkSpotPerpLiquidity } from "../../liquidity.js";
|
|
|
3
3
|
import { computeMatchedSize, computeSpotPerpMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
|
|
4
4
|
import { getFundingHours } from "../../funding.js";
|
|
5
5
|
import { logExecution, pruneExecutionLog } from "../../execution-log.js";
|
|
6
|
+
import { annualizeHourlyRate } from "../../funding/normalize.js";
|
|
7
|
+
import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getSpotAdapter, getPerpSymbol, findRate, getPriceEstimate, fetchRates, } from "./funding-arb-utils.js";
|
|
6
8
|
export class FundingArbStrategy {
|
|
7
9
|
name = "funding-arb";
|
|
8
10
|
describe() {
|
|
@@ -25,7 +27,6 @@ export class FundingArbStrategy {
|
|
|
25
27
|
}
|
|
26
28
|
_config = {};
|
|
27
29
|
_failCooldown = new Map(); // "symbol:action" → timestamp of next retry
|
|
28
|
-
_spotAdapterCache = new Map();
|
|
29
30
|
async init(ctx, _snapshot) {
|
|
30
31
|
// Prune old execution logs (keep 30 days)
|
|
31
32
|
try {
|
|
@@ -241,7 +242,7 @@ export class FundingArbStrategy {
|
|
|
241
242
|
// Fetch rates — all exchanges if room for new positions, only position exchanges if full
|
|
242
243
|
const ratesByExchange = new Map();
|
|
243
244
|
for (const [name, a] of exchangesToScan) {
|
|
244
|
-
const rates = await
|
|
245
|
+
const rates = await fetchRates(a, name);
|
|
245
246
|
const map = new Map();
|
|
246
247
|
for (const r of rates)
|
|
247
248
|
map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price, sizeDecimals: r.sizeDecimals, maxLeverage: r.maxLeverage, fundingHours: r.fundingHours });
|
|
@@ -277,20 +278,20 @@ export class FundingArbStrategy {
|
|
|
277
278
|
if (p.mode === "spot-perp") {
|
|
278
279
|
// For spot-perp: long side is spot (rate=0), short side is perp
|
|
279
280
|
const perpExchange = p.shortExchange;
|
|
280
|
-
const perpSymbol =
|
|
281
|
-
const rateInfo =
|
|
282
|
-
??
|
|
281
|
+
const perpSymbol = getPerpSymbol(p.symbol, perpExchange);
|
|
282
|
+
const rateInfo = findRate(ratesByExchange, perpExchange, perpSymbol)
|
|
283
|
+
?? findRate(ratesByExchange, perpExchange, p.symbol);
|
|
283
284
|
const shortRate = rateInfo?.rate;
|
|
284
285
|
const fundingH = rateInfo?.fundingHours ?? getFundingHours(perpExchange);
|
|
285
|
-
signedSpread = shortRate !== undefined ? (shortRate / fundingH)
|
|
286
|
+
signedSpread = shortRate !== undefined ? annualizeHourlyRate(shortRate / fundingH) : 0;
|
|
286
287
|
}
|
|
287
288
|
else {
|
|
288
|
-
const longInfo =
|
|
289
|
-
const shortInfo =
|
|
289
|
+
const longInfo = findRate(ratesByExchange, p.longExchange, p.symbol);
|
|
290
|
+
const shortInfo = findRate(ratesByExchange, p.shortExchange, p.symbol);
|
|
290
291
|
const longHourly = longInfo ? longInfo.rate / (longInfo.fundingHours ?? getFundingHours(p.longExchange)) : undefined;
|
|
291
292
|
const shortHourly = shortInfo ? shortInfo.rate / (shortInfo.fundingHours ?? getFundingHours(p.shortExchange)) : undefined;
|
|
292
293
|
signedSpread = (longHourly !== undefined && shortHourly !== undefined)
|
|
293
|
-
? (shortHourly - longHourly)
|
|
294
|
+
? annualizeHourlyRate(shortHourly - longHourly)
|
|
294
295
|
: 0;
|
|
295
296
|
}
|
|
296
297
|
const sign = signedSpread >= 0 ? "+" : "";
|
|
@@ -304,22 +305,22 @@ export class FundingArbStrategy {
|
|
|
304
305
|
let signedSpread;
|
|
305
306
|
if (pos.mode === "spot-perp") {
|
|
306
307
|
const perpExchange = pos.shortExchange;
|
|
307
|
-
const perpSymbol =
|
|
308
|
-
const rateInfo =
|
|
309
|
-
??
|
|
308
|
+
const perpSymbol = getPerpSymbol(pos.symbol, perpExchange);
|
|
309
|
+
const rateInfo = findRate(ratesByExchange, perpExchange, perpSymbol)
|
|
310
|
+
?? findRate(ratesByExchange, perpExchange, pos.symbol);
|
|
310
311
|
if (rateInfo === undefined)
|
|
311
312
|
continue;
|
|
312
313
|
const fundingH = rateInfo.fundingHours ?? getFundingHours(perpExchange);
|
|
313
|
-
signedSpread = (rateInfo.rate / fundingH)
|
|
314
|
+
signedSpread = annualizeHourlyRate(rateInfo.rate / fundingH);
|
|
314
315
|
}
|
|
315
316
|
else {
|
|
316
|
-
const longInfo =
|
|
317
|
-
const shortInfo =
|
|
317
|
+
const longInfo = findRate(ratesByExchange, pos.longExchange, pos.symbol);
|
|
318
|
+
const shortInfo = findRate(ratesByExchange, pos.shortExchange, pos.symbol);
|
|
318
319
|
if (longInfo === undefined || shortInfo === undefined)
|
|
319
320
|
continue;
|
|
320
321
|
const longHourly = longInfo.rate / (longInfo.fundingHours ?? getFundingHours(pos.longExchange));
|
|
321
322
|
const shortHourly = shortInfo.rate / (shortInfo.fundingHours ?? getFundingHours(pos.shortExchange));
|
|
322
|
-
signedSpread = (shortHourly - longHourly)
|
|
323
|
+
signedSpread = annualizeHourlyRate(shortHourly - longHourly);
|
|
323
324
|
}
|
|
324
325
|
// Close if funding flipped (losing money) or spread below close threshold
|
|
325
326
|
const minHoldMs = (params.min_hold_hours ?? 2) * 60 * 60 * 1000;
|
|
@@ -343,7 +344,6 @@ export class FundingArbStrategy {
|
|
|
343
344
|
const longAdapter = adapters.get(pos.longExchange);
|
|
344
345
|
const shortAdapter = adapters.get(pos.shortExchange);
|
|
345
346
|
if (longAdapter && shortAdapter) {
|
|
346
|
-
const isPositionGone = (msg) => msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
|
|
347
347
|
let closeLongOk = false;
|
|
348
348
|
let longPositionGone = false;
|
|
349
349
|
try {
|
|
@@ -439,7 +439,7 @@ export class FundingArbStrategy {
|
|
|
439
439
|
let minExchange = "", maxExchange = "";
|
|
440
440
|
let minRate = 0, maxRate = 0;
|
|
441
441
|
for (const exName of scanExchangeNames) {
|
|
442
|
-
const rateInfo =
|
|
442
|
+
const rateInfo = findRate(ratesByExchange, exName, sym);
|
|
443
443
|
if (!rateInfo)
|
|
444
444
|
continue;
|
|
445
445
|
const hourlyRate = rateInfo.rate / (rateInfo.fundingHours ?? getFundingHours(exName));
|
|
@@ -455,7 +455,7 @@ export class FundingArbStrategy {
|
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
457
|
if (minExchange && maxExchange && minExchange !== maxExchange) {
|
|
458
|
-
const spread = (maxHourly - minHourly)
|
|
458
|
+
const spread = annualizeHourlyRate(maxHourly - minHourly);
|
|
459
459
|
if (spread >= params.min_spread) {
|
|
460
460
|
opportunities.push({
|
|
461
461
|
symbol: sym,
|
|
@@ -473,7 +473,7 @@ export class FundingArbStrategy {
|
|
|
473
473
|
const spotPerpMinSpread = params.spot_perp_min_spread ?? params.min_spread;
|
|
474
474
|
for (const [name, a] of adapters) {
|
|
475
475
|
try {
|
|
476
|
-
const spotAdapter = await
|
|
476
|
+
const spotAdapter = await getSpotAdapter(name, a);
|
|
477
477
|
if (!spotAdapter)
|
|
478
478
|
continue;
|
|
479
479
|
const spotMarkets = await spotAdapter.getSpotMarkets();
|
|
@@ -490,7 +490,7 @@ export class FundingArbStrategy {
|
|
|
490
490
|
if (!spotSymbols.has(base))
|
|
491
491
|
continue;
|
|
492
492
|
const hourlyRate = rate / (rateEntry.fundingHours ?? getFundingHours(name));
|
|
493
|
-
const annualSpread = hourlyRate
|
|
493
|
+
const annualSpread = annualizeHourlyRate(hourlyRate);
|
|
494
494
|
if (annualSpread >= spotPerpMinSpread) {
|
|
495
495
|
opportunities.push({
|
|
496
496
|
symbol: base,
|
|
@@ -545,13 +545,13 @@ export class FundingArbStrategy {
|
|
|
545
545
|
const shortAdapter = adapters.get(best.shortExchange);
|
|
546
546
|
if (!longAdapter || !shortAdapter)
|
|
547
547
|
continue;
|
|
548
|
-
const price =
|
|
548
|
+
const price = findRate(ratesByExchange, best.longExchange, best.symbol)?.price ?? 0;
|
|
549
549
|
if (price <= 0)
|
|
550
550
|
continue;
|
|
551
551
|
// Determine leverage for this symbol
|
|
552
552
|
const configLeverage = params.leverage ?? 3;
|
|
553
|
-
const longMaxLev =
|
|
554
|
-
const shortMaxLev =
|
|
553
|
+
const longMaxLev = findRate(ratesByExchange, best.longExchange, best.symbol)?.maxLeverage ?? 1;
|
|
554
|
+
const shortMaxLev = findRate(ratesByExchange, best.shortExchange, best.symbol)?.maxLeverage ?? 1;
|
|
555
555
|
const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
|
|
556
556
|
// Set leverage on both exchanges before ordering
|
|
557
557
|
try {
|
|
@@ -595,8 +595,8 @@ export class FundingArbStrategy {
|
|
|
595
595
|
if (!liq.viable)
|
|
596
596
|
continue;
|
|
597
597
|
// Compute matched size (use actual precision from exchange market data)
|
|
598
|
-
const longDec =
|
|
599
|
-
const shortDec =
|
|
598
|
+
const longDec = findRate(ratesByExchange, best.longExchange, best.symbol)?.sizeDecimals;
|
|
599
|
+
const shortDec = findRate(ratesByExchange, best.shortExchange, best.symbol)?.sizeDecimals;
|
|
600
600
|
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { longSizeDecimals: longDec, shortSizeDecimals: shortDec });
|
|
601
601
|
if (!matched) {
|
|
602
602
|
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
@@ -818,7 +818,7 @@ export class FundingArbStrategy {
|
|
|
818
818
|
return false;
|
|
819
819
|
}
|
|
820
820
|
try {
|
|
821
|
-
const spotAdapter = await
|
|
821
|
+
const spotAdapter = await getSpotAdapter(exchangeName, perpAdapter);
|
|
822
822
|
if (!spotAdapter) {
|
|
823
823
|
ctx.log(` [ARB] Close spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
824
824
|
return false;
|
|
@@ -838,11 +838,11 @@ export class FundingArbStrategy {
|
|
|
838
838
|
}
|
|
839
839
|
// Step 2: Transfer USDC back to perp (use 95% of notional as estimate)
|
|
840
840
|
try {
|
|
841
|
-
const perpSymbol =
|
|
842
|
-
const priceEstimate = await
|
|
841
|
+
const perpSymbol = getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
842
|
+
const priceEstimate = await getPriceEstimate(perpAdapter, perpSymbol, pos.symbol);
|
|
843
843
|
const proceeds = priceEstimate * parseFloat(pos.size) * 0.95;
|
|
844
844
|
if (proceeds > 1) {
|
|
845
|
-
await
|
|
845
|
+
await transferUsdcToPerp(spotAdapter, exchangeName, Math.floor(proceeds));
|
|
846
846
|
ctx.log(` [ARB] Transferred ~$${Math.floor(proceeds)} USDC back to perp`);
|
|
847
847
|
}
|
|
848
848
|
}
|
|
@@ -852,7 +852,7 @@ export class FundingArbStrategy {
|
|
|
852
852
|
// Non-fatal: continue to close perp short
|
|
853
853
|
}
|
|
854
854
|
// Step 3: Close perp short
|
|
855
|
-
const perpSymbol =
|
|
855
|
+
const perpSymbol = getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
856
856
|
try {
|
|
857
857
|
await perpAdapter.marketOrder(perpSymbol, "buy", pos.size, { reduceOnly: true });
|
|
858
858
|
ctx.log(` [ARB] Closed perp short ${pos.size} ${perpSymbol}`);
|
|
@@ -891,21 +891,21 @@ export class FundingArbStrategy {
|
|
|
891
891
|
return false;
|
|
892
892
|
}
|
|
893
893
|
try {
|
|
894
|
-
const spotAdapter = await
|
|
894
|
+
const spotAdapter = await getSpotAdapter(exchangeName, perpAdapter);
|
|
895
895
|
if (!spotAdapter) {
|
|
896
896
|
ctx.log(` [ARB] Open spot-perp failed: spot adapter not available for ${exchangeName}`);
|
|
897
897
|
return false;
|
|
898
898
|
}
|
|
899
899
|
// Determine leverage for perp leg
|
|
900
900
|
const configLeverage = params.leverage ?? 3;
|
|
901
|
-
const perpSymbolUpper =
|
|
902
|
-
const perpMaxLev =
|
|
903
|
-
??
|
|
901
|
+
const perpSymbolUpper = getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
|
|
902
|
+
const perpMaxLev = findRate(ratesByExchange, opp.shortExchange, perpSymbolUpper)?.maxLeverage
|
|
903
|
+
?? findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage
|
|
904
904
|
?? 1;
|
|
905
905
|
const leverage = Math.min(configLeverage, perpMaxLev);
|
|
906
906
|
// Set leverage on perp before ordering
|
|
907
907
|
try {
|
|
908
|
-
await perpAdapter.setLeverage(
|
|
908
|
+
await perpAdapter.setLeverage(getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
909
909
|
}
|
|
910
910
|
catch (err) {
|
|
911
911
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -943,8 +943,8 @@ export class FundingArbStrategy {
|
|
|
943
943
|
ctx.log(` [ARB] Balance check failed for spot-perp ${opp.symbol}: ${msg}`);
|
|
944
944
|
return false;
|
|
945
945
|
}
|
|
946
|
-
const perpSymbol =
|
|
947
|
-
const priceEstimate = await
|
|
946
|
+
const perpSymbol = getPerpSymbol(opp.symbol, opp.shortExchange);
|
|
947
|
+
const priceEstimate = await getPriceEstimate(perpAdapter, perpSymbol, opp.symbol);
|
|
948
948
|
if (priceEstimate <= 0) {
|
|
949
949
|
ctx.log(` [ARB] Skip spot-perp ${opp.symbol}: can't determine price`);
|
|
950
950
|
return false;
|
|
@@ -965,7 +965,7 @@ export class FundingArbStrategy {
|
|
|
965
965
|
ctx.log(` [ARB] Opening spot-perp: ${matched.size} ${opp.symbol} ($${matched.notional.toFixed(0)}, slippage ~${liq.spotSlippage.toFixed(2)}%/${liq.perpSlippage.toFixed(2)}%)`);
|
|
966
966
|
// Step 1: Transfer USDC to spot
|
|
967
967
|
const transferAmt = Math.ceil(matched.notional * 1.02); // slight buffer for price movement
|
|
968
|
-
await
|
|
968
|
+
await transferUsdcToSpot(spotAdapter, exchangeName, transferAmt);
|
|
969
969
|
ctx.log(` [ARB] Transferred $${transferAmt} USDC to spot`);
|
|
970
970
|
// Step 2: Buy spot
|
|
971
971
|
let spotResult;
|
|
@@ -979,7 +979,7 @@ export class FundingArbStrategy {
|
|
|
979
979
|
ctx.log(` [ARB] Open spot-perp failed (spot buy) for ${opp.symbol}: ${msg}`);
|
|
980
980
|
logExecution({ type: "arb_entry", exchange: opp.longExchange, symbol: opp.symbol, side: "buy", size: matched.size, status: "failed", error: msg, dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
|
|
981
981
|
try {
|
|
982
|
-
await
|
|
982
|
+
await transferUsdcToPerp(spotAdapter, exchangeName, transferAmt);
|
|
983
983
|
ctx.log(` [ARB] Transferred $${transferAmt} USDC back to perp after spot buy failure`);
|
|
984
984
|
}
|
|
985
985
|
catch { /* best effort */ }
|
|
@@ -1002,7 +1002,7 @@ export class FundingArbStrategy {
|
|
|
1002
1002
|
logExecution({ type: "multi_leg_rollback", exchange: opp.longExchange, symbol: opp.symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
|
|
1003
1003
|
// Transfer USDC back to perp account
|
|
1004
1004
|
try {
|
|
1005
|
-
await
|
|
1005
|
+
await transferUsdcToPerp(spotAdapter, opp.longExchange.replace("-spot", ""), Math.ceil(matched.notional * 1.02));
|
|
1006
1006
|
}
|
|
1007
1007
|
catch { /* best effort */ }
|
|
1008
1008
|
}
|
|
@@ -1036,140 +1036,5 @@ export class FundingArbStrategy {
|
|
|
1036
1036
|
return false;
|
|
1037
1037
|
}
|
|
1038
1038
|
}
|
|
1039
|
-
/** Transfer USDC from perp to spot account (exchange-specific) */
|
|
1040
|
-
async transferUsdcToSpot(spotAdapter, exchangeName, amount) {
|
|
1041
|
-
if (exchangeName === "hyperliquid") {
|
|
1042
|
-
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
1043
|
-
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
1044
|
-
await spotAdapter.transferUsdcToSpot(amount);
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
else if (exchangeName === "lighter") {
|
|
1049
|
-
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
1050
|
-
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
1051
|
-
await spotAdapter.transferUsdcToSpot(amount);
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
/** Transfer USDC from spot to perp account (exchange-specific) */
|
|
1057
|
-
async transferUsdcToPerp(spotAdapter, exchangeName, amount) {
|
|
1058
|
-
if (exchangeName === "hyperliquid") {
|
|
1059
|
-
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
1060
|
-
if (spotAdapter instanceof HyperliquidSpotAdapter) {
|
|
1061
|
-
await spotAdapter.transferUsdcToPerp(amount);
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
else if (exchangeName === "lighter") {
|
|
1066
|
-
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
1067
|
-
if (spotAdapter instanceof LighterSpotAdapter) {
|
|
1068
|
-
await spotAdapter.transferUsdcToPerp(amount);
|
|
1069
|
-
return;
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Get spot adapter for a given exchange name + perp adapter instance.
|
|
1075
|
-
* Returns null if the exchange doesn't support spot or instantiation fails.
|
|
1076
|
-
*/
|
|
1077
|
-
async getSpotAdapter(name, adapter) {
|
|
1078
|
-
const cached = this._spotAdapterCache.get(name);
|
|
1079
|
-
if (cached)
|
|
1080
|
-
return cached;
|
|
1081
|
-
try {
|
|
1082
|
-
let spot = null;
|
|
1083
|
-
if (name === "hyperliquid") {
|
|
1084
|
-
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
|
|
1085
|
-
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
|
|
1086
|
-
if (adapter instanceof HyperliquidAdapter) {
|
|
1087
|
-
const instance = new HyperliquidSpotAdapter(adapter);
|
|
1088
|
-
await instance.init();
|
|
1089
|
-
spot = instance;
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
else if (name === "lighter") {
|
|
1093
|
-
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
1094
|
-
const { LighterAdapter } = await import("../../exchanges/lighter.js");
|
|
1095
|
-
if (adapter instanceof LighterAdapter) {
|
|
1096
|
-
const instance = new LighterSpotAdapter(adapter);
|
|
1097
|
-
await instance.init();
|
|
1098
|
-
spot = instance;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
if (spot)
|
|
1102
|
-
this._spotAdapterCache.set(name, spot);
|
|
1103
|
-
return spot;
|
|
1104
|
-
}
|
|
1105
|
-
catch { /* not supported */ }
|
|
1106
|
-
return null;
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Resolve the perp symbol for a given base symbol on an exchange.
|
|
1110
|
-
* HL getPositions() returns symbols like "PURR" or "PURR-PERP" — we normalize here.
|
|
1111
|
-
*/
|
|
1112
|
-
getPerpSymbol(baseSymbol, exchangeName) {
|
|
1113
|
-
const base = baseSymbol.replace(/-PERP$/, "").toUpperCase();
|
|
1114
|
-
void exchangeName;
|
|
1115
|
-
return base;
|
|
1116
|
-
}
|
|
1117
|
-
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP */
|
|
1118
|
-
findRate(ratesByExchange, exchange, symbol) {
|
|
1119
|
-
const map = ratesByExchange.get(exchange);
|
|
1120
|
-
if (!map)
|
|
1121
|
-
return undefined;
|
|
1122
|
-
const upper = symbol.toUpperCase();
|
|
1123
|
-
return map.get(upper)
|
|
1124
|
-
?? map.get(upper + "-PERP")
|
|
1125
|
-
?? map.get(upper.replace(/-PERP$/, ""));
|
|
1126
|
-
}
|
|
1127
|
-
/**
|
|
1128
|
-
* Get a price estimate for a symbol from the perp adapter.
|
|
1129
|
-
* Tries the base symbol first, then with -PERP suffix.
|
|
1130
|
-
*/
|
|
1131
|
-
async getPriceEstimate(perpAdapter, perpSymbol, fallbackSymbol) {
|
|
1132
|
-
try {
|
|
1133
|
-
const markets = await perpAdapter.getMarkets();
|
|
1134
|
-
const market = markets.find(m => m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
|
|
1135
|
-
m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
|
|
1136
|
-
m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`);
|
|
1137
|
-
return market ? parseFloat(market.markPrice) : 0;
|
|
1138
|
-
}
|
|
1139
|
-
catch {
|
|
1140
|
-
return 0;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
async fetchRates(adapter, exchangeName) {
|
|
1144
|
-
try {
|
|
1145
|
-
const markets = await adapter.getMarkets();
|
|
1146
|
-
const withRates = markets.filter(m => m.fundingRate != null);
|
|
1147
|
-
// Bootstrap aster funding hours lazily (1 API call per unknown symbol, cached permanently)
|
|
1148
|
-
if (exchangeName === "aster" && "getFundingHours" in adapter) {
|
|
1149
|
-
const aster = adapter;
|
|
1150
|
-
// Only bootstrap symbols not yet cached (up to 20 per tick to complete faster)
|
|
1151
|
-
const uncached = withRates.filter(m => {
|
|
1152
|
-
const cached = adapter?._fundingHoursCache?.get?.(m.symbol);
|
|
1153
|
-
return cached === undefined; // only truly uncached, not symbols confirmed as 1h
|
|
1154
|
-
});
|
|
1155
|
-
const toBootstrap = uncached.slice(0, 20);
|
|
1156
|
-
for (const m of toBootstrap) {
|
|
1157
|
-
const fh = await aster.getFundingHours(m.symbol);
|
|
1158
|
-
m.fundingHours = fh;
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return withRates.map(m => ({
|
|
1162
|
-
symbol: m.symbol,
|
|
1163
|
-
rate: parseFloat(m.fundingRate),
|
|
1164
|
-
price: parseFloat(m.markPrice),
|
|
1165
|
-
sizeDecimals: m.sizeDecimals,
|
|
1166
|
-
maxLeverage: m.maxLeverage,
|
|
1167
|
-
fundingHours: m.fundingHours,
|
|
1168
|
-
}));
|
|
1169
|
-
}
|
|
1170
|
-
catch {
|
|
1171
|
-
return [];
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
1039
|
}
|
|
1175
1040
|
registerStrategy("funding-arb", (_config) => new FundingArbStrategy());
|
|
@@ -262,8 +262,16 @@ export async function fetchRates(adapter, exchangeName) {
|
|
|
262
262
|
m.fundingHours = await aster.getFundingHours(m.symbol);
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
|
+
// For non-aster exchanges, set fundingHours from static map if not already set
|
|
266
|
+
if (exchangeName !== "aster") {
|
|
267
|
+
const defaultFH = getFundingHours(exchangeName);
|
|
268
|
+
for (const m of withRates) {
|
|
269
|
+
if (m.fundingHours == null)
|
|
270
|
+
m.fundingHours = defaultFH;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
265
273
|
return withRates
|
|
266
|
-
.filter(m => m.fundingHours != null) // skip symbols with unknown funding period
|
|
274
|
+
.filter(m => m.fundingHours != null) // skip aster symbols with unknown funding period
|
|
267
275
|
.map(m => ({
|
|
268
276
|
symbol: m.symbol,
|
|
269
277
|
rate: parseFloat(m.fundingRate),
|
|
@@ -529,7 +529,18 @@ export class FundingArbV2Strategy {
|
|
|
529
529
|
const pBal = exchangeBalances.get(opp.shortExchange);
|
|
530
530
|
if (!sBal || !pBal)
|
|
531
531
|
return false;
|
|
532
|
-
|
|
532
|
+
let spotAvail = sBal.available;
|
|
533
|
+
const spotExAdapter = adapters.get(spotExName);
|
|
534
|
+
if (spotExAdapter && !spotExAdapter.isUnifiedAccount) {
|
|
535
|
+
try {
|
|
536
|
+
const sb = await spotAdapter.getSpotBalances();
|
|
537
|
+
const su = sb.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
538
|
+
if (su)
|
|
539
|
+
spotAvail += parseFloat(su.available);
|
|
540
|
+
}
|
|
541
|
+
catch { /* non-critical */ }
|
|
542
|
+
}
|
|
543
|
+
const maxNtl = Math.min(spotAvail, pBal.available * leverage) * 0.8;
|
|
533
544
|
if (maxNtl < targetSizeUsd) {
|
|
534
545
|
if (maxNtl < 10)
|
|
535
546
|
return false;
|
|
@@ -629,17 +640,15 @@ export class FundingArbV2Strategy {
|
|
|
629
640
|
this._failCooldown.set(`${pos.symbol}:close`, Date.now() + 5 * 60 * 1000);
|
|
630
641
|
return false;
|
|
631
642
|
}
|
|
632
|
-
// Transfer USDC back (same-exchange)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(proceeds));
|
|
640
|
-
}
|
|
641
|
-
catch { /* non-fatal */ }
|
|
643
|
+
// Transfer USDC back to perp (always — not just same-exchange)
|
|
644
|
+
try {
|
|
645
|
+
const spotBals = await spotAdapter.getSpotBalances();
|
|
646
|
+
const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
|
|
647
|
+
const usdcAmt = usdcBal ? parseFloat(usdcBal.total) : 0;
|
|
648
|
+
if (usdcAmt > 1)
|
|
649
|
+
await transferUsdcToPerp(spotAdapter, spotExName, Math.floor(usdcAmt));
|
|
642
650
|
}
|
|
651
|
+
catch { /* non-fatal */ }
|
|
643
652
|
// Close perp short
|
|
644
653
|
const perpSym = getPerpSymbol(pos.symbol, pos.shortExchange);
|
|
645
654
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerStrategy } from "../strategy-registry.js";
|
|
2
|
-
import { toHourlyRate, computeAnnualSpread } from "../../funding/normalize.js";
|
|
2
|
+
import { toHourlyRate, computeAnnualSpread, annualizeHourlyRate } from "../../funding/normalize.js";
|
|
3
3
|
/**
|
|
4
4
|
* Minimum notional per exchange (USD):
|
|
5
5
|
* - HL: ~$2 (0.001 ETH)
|
|
@@ -203,7 +203,7 @@ class FundingAutoStrategy {
|
|
|
203
203
|
continue;
|
|
204
204
|
}
|
|
205
205
|
const hourlyRate = toHourlyRate(shortInfo.rate, pos.shortExchange);
|
|
206
|
-
const annualized = Math.abs(hourlyRate)
|
|
206
|
+
const annualized = annualizeHourlyRate(Math.abs(hourlyRate));
|
|
207
207
|
const pnl = await this.calculateRealPnl(adapters.get(pos.shortExchange), pos.symbol);
|
|
208
208
|
pos.accumulatedFunding = pnl.funding;
|
|
209
209
|
pos.totalCost = pnl.fees;
|
|
@@ -35,7 +35,8 @@ export declare class SpotPerpArbStrategy implements Strategy {
|
|
|
35
35
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
36
36
|
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
37
37
|
private scoreFundingLocal;
|
|
38
|
-
|
|
38
|
+
/** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
|
|
39
|
+
private findPerpCandidates;
|
|
39
40
|
private calcAnnualized;
|
|
40
41
|
private shouldClose;
|
|
41
42
|
private tryOpenPosition;
|