perp-cli 0.7.2 → 0.7.4
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/arb/sizing.d.ts +1 -0
- package/dist/arb/sizing.js +25 -6
- package/dist/arb/utils.js +1 -0
- package/dist/bot/conditions.js +1 -2
- package/dist/bot/config.d.ts +2 -1
- package/dist/bot/config.js +3 -1
- package/dist/bot/engine.js +23 -13
- package/dist/bot/presets.js +1 -0
- package/dist/bot/strategies/dca-strategy.js +8 -4
- package/dist/bot/strategies/funding-arb-strategy.d.ts +1 -1
- package/dist/bot/strategies/funding-arb-strategy.js +126 -14
- package/dist/bot/strategies/grid-strategy.js +56 -51
- package/dist/cli-spec.js +1 -1
- package/dist/commands/account.js +1 -1
- package/dist/commands/arb/index.js +12 -4
- package/dist/commands/arb-auto.d.ts +2 -0
- package/dist/commands/arb-auto.js +37 -16
- package/dist/commands/arb.js +25 -6
- package/dist/commands/bot.js +5 -6
- package/dist/commands/dashboard.js +1 -1
- package/dist/commands/history.js +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +5 -0
- package/dist/commands/market.js +1 -1
- package/dist/commands/rebalance.js +3 -3
- package/dist/commands/risk.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/commands/wallet.js +2 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -0
- package/dist/constants.js +1 -0
- package/dist/exchanges/index.d.ts +1 -0
- package/dist/exchanges/index.js +1 -0
- package/dist/exchanges/registry.js +5 -0
- package/dist/funding/normalize.js +1 -0
- package/dist/funding/rates.d.ts +1 -1
- package/dist/funding/rates.js +36 -3
- package/dist/index.js +19 -6
- package/dist/strategies/funding-arb.js +28 -5
- package/dist/utils.js +6 -1
- package/package.json +1 -1
package/dist/arb/sizing.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ExchangeAdapter } from "../exchanges/index.js";
|
|
|
8
8
|
export declare function computeMatchedSize(sizeUsd: number, price: number, longExchange: string, shortExchange: string, opts?: {
|
|
9
9
|
longSizeDecimals?: number;
|
|
10
10
|
shortSizeDecimals?: number;
|
|
11
|
+
lotSize?: number;
|
|
11
12
|
}): {
|
|
12
13
|
size: string;
|
|
13
14
|
notional: number;
|
package/dist/arb/sizing.js
CHANGED
|
@@ -10,9 +10,16 @@ export function computeMatchedSize(sizeUsd, price, longExchange, shortExchange,
|
|
|
10
10
|
const rawSize = sizeUsd / price;
|
|
11
11
|
// Use explicitly provided decimals if available, else conservative defaults
|
|
12
12
|
const szDecimals = Math.min(opts?.longSizeDecimals ?? getSizeDecimals(longExchange), opts?.shortSizeDecimals ?? getSizeDecimals(shortExchange));
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// If lotSize is provided, round to nearest multiple of lotSize (floor)
|
|
14
|
+
let roundedSize;
|
|
15
|
+
if (opts?.lotSize && opts.lotSize > 0) {
|
|
16
|
+
roundedSize = Math.floor(rawSize / opts.lotSize) * opts.lotSize;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
// Round DOWN to the least precise exchange's decimals
|
|
20
|
+
const factor = Math.pow(10, szDecimals);
|
|
21
|
+
roundedSize = Math.floor(rawSize * factor) / factor;
|
|
22
|
+
}
|
|
16
23
|
if (roundedSize <= 0)
|
|
17
24
|
return null;
|
|
18
25
|
// Verify notional meets minimum for both exchanges
|
|
@@ -20,15 +27,27 @@ export function computeMatchedSize(sizeUsd, price, longExchange, shortExchange,
|
|
|
20
27
|
const minNotional = Math.max(getMinNotional(longExchange), getMinNotional(shortExchange));
|
|
21
28
|
if (notional < minNotional) {
|
|
22
29
|
// Try rounding UP instead
|
|
23
|
-
|
|
30
|
+
let roundedUp;
|
|
31
|
+
if (opts?.lotSize && opts.lotSize > 0) {
|
|
32
|
+
roundedUp = Math.ceil(rawSize / opts.lotSize) * opts.lotSize;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const factor = Math.pow(10, szDecimals);
|
|
36
|
+
roundedUp = Math.ceil(rawSize * factor) / factor;
|
|
37
|
+
}
|
|
24
38
|
const notionalUp = roundedUp * price;
|
|
25
39
|
// Only round up if the increase is small (< 20% over requested)
|
|
26
40
|
if (notionalUp <= sizeUsd * 1.2) {
|
|
27
|
-
return { size: roundedUp
|
|
41
|
+
return { size: formatSize(roundedUp, szDecimals, opts?.lotSize), notional: notionalUp };
|
|
28
42
|
}
|
|
29
43
|
return null; // Can't meet minimum
|
|
30
44
|
}
|
|
31
|
-
return { size: roundedSize
|
|
45
|
+
return { size: formatSize(roundedSize, szDecimals, opts?.lotSize), notional };
|
|
46
|
+
}
|
|
47
|
+
function formatSize(size, szDecimals, lotSize) {
|
|
48
|
+
if (lotSize && lotSize >= 1)
|
|
49
|
+
return String(Math.round(size));
|
|
50
|
+
return size.toFixed(szDecimals);
|
|
32
51
|
}
|
|
33
52
|
/** Size decimal precision by exchange (conservative fallbacks — prefer explicit decimals via opts) */
|
|
34
53
|
function getSizeDecimals(exchange) {
|
package/dist/arb/utils.js
CHANGED
|
@@ -7,6 +7,7 @@ export const SETTLEMENT_SCHEDULES = {
|
|
|
7
7
|
hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
8
8
|
pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
9
9
|
lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
10
|
+
aster: [0, 8, 16], // every 8 hours (Binance convention)
|
|
10
11
|
};
|
|
11
12
|
/**
|
|
12
13
|
* Get the most recent settlement time for an exchange.
|
package/dist/bot/conditions.js
CHANGED
|
@@ -41,7 +41,7 @@ export async function getMarketSnapshot(adapter, symbol) {
|
|
|
41
41
|
const markets = await adapter.getMarkets();
|
|
42
42
|
const m = markets.find(mk => mk.symbol.toUpperCase() === symbol.toUpperCase());
|
|
43
43
|
if (!m) {
|
|
44
|
-
|
|
44
|
+
throw new Error(`Market "${symbol}" not found on ${adapter.name}`);
|
|
45
45
|
}
|
|
46
46
|
const price = parseFloat(m.markPrice);
|
|
47
47
|
const fundingRate = parseFloat(m.fundingRate);
|
|
@@ -124,7 +124,6 @@ export function evaluateCondition(condition, snapshot, context) {
|
|
|
124
124
|
const elapsed = (Date.now() - context.startTime) / 1000;
|
|
125
125
|
return elapsed > val;
|
|
126
126
|
}
|
|
127
|
-
// Custom exit conditions (not standard ConditionType but used in exit logic)
|
|
128
127
|
case "max_drawdown":
|
|
129
128
|
return (context.peakEquity - context.equity) > val;
|
|
130
129
|
default:
|
package/dist/bot/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ConditionType = "price_above" | "price_below" | "volatility_above" | "volatility_below" | "funding_rate_above" | "funding_rate_below" | "spread_above" | "rsi_above" | "rsi_below" | "balance_above" | "balance_below" | "time_after" | "always";
|
|
1
|
+
export type ConditionType = "price_above" | "price_below" | "volatility_above" | "volatility_below" | "funding_rate_above" | "funding_rate_below" | "spread_above" | "rsi_above" | "rsi_below" | "balance_above" | "balance_below" | "time_after" | "max_drawdown" | "always";
|
|
2
2
|
export interface Condition {
|
|
3
3
|
type: ConditionType;
|
|
4
4
|
value: number | string;
|
|
@@ -31,6 +31,7 @@ export interface DCAStrategyParams {
|
|
|
31
31
|
interval_sec: number;
|
|
32
32
|
total_orders: number;
|
|
33
33
|
price_limit?: number;
|
|
34
|
+
side?: "buy" | "sell";
|
|
34
35
|
}
|
|
35
36
|
export interface FundingArbStrategyParams {
|
|
36
37
|
type: "funding-arb";
|
package/dist/bot/config.js
CHANGED
|
@@ -65,7 +65,7 @@ export function parseStrategy(type, raw) {
|
|
|
65
65
|
range_pct: raw.range_pct !== undefined ? Number(raw.range_pct) : 3,
|
|
66
66
|
upper: raw.upper !== undefined ? Number(raw.upper) : undefined,
|
|
67
67
|
lower: raw.lower !== undefined ? Number(raw.lower) : undefined,
|
|
68
|
-
rebalance: raw.rebalance !== false,
|
|
68
|
+
rebalance: raw.rebalance !== false && raw.rebalance !== "false",
|
|
69
69
|
rebalance_cooldown: Number(raw.rebalance_cooldown ?? 60),
|
|
70
70
|
leverage: raw.leverage !== undefined ? Number(raw.leverage) : undefined,
|
|
71
71
|
};
|
|
@@ -76,6 +76,7 @@ export function parseStrategy(type, raw) {
|
|
|
76
76
|
interval_sec: Number(raw.interval_sec ?? raw.interval ?? 60),
|
|
77
77
|
total_orders: Number(raw.total_orders ?? raw.orders ?? 0),
|
|
78
78
|
price_limit: raw.price_limit !== undefined ? Number(raw.price_limit) : undefined,
|
|
79
|
+
side: raw.side !== undefined ? String(raw.side) : undefined,
|
|
79
80
|
};
|
|
80
81
|
case "funding-arb":
|
|
81
82
|
return {
|
|
@@ -149,6 +150,7 @@ export function quickDCAConfig(opts) {
|
|
|
149
150
|
interval_sec: opts.intervalSec,
|
|
150
151
|
total_orders: opts.orders,
|
|
151
152
|
price_limit: opts.priceLimit,
|
|
153
|
+
side: opts.side ?? "buy",
|
|
152
154
|
},
|
|
153
155
|
entry_conditions: entryConds,
|
|
154
156
|
exit_conditions: [
|
package/dist/bot/engine.js
CHANGED
|
@@ -56,9 +56,11 @@ mode = "headless") {
|
|
|
56
56
|
dailyStartDate: new Date().toISOString().slice(0, 10),
|
|
57
57
|
fills: 0,
|
|
58
58
|
totalPnl: 0,
|
|
59
|
+
initialEquity: 0,
|
|
59
60
|
rebalanceCount: 0,
|
|
60
61
|
lastRebalance: 0,
|
|
61
62
|
strategyActive: false,
|
|
63
|
+
riskBreached: false,
|
|
62
64
|
};
|
|
63
65
|
// Resolve strategy from registry
|
|
64
66
|
const strategyFactory = getStrategy(config.strategy.type);
|
|
@@ -84,7 +86,7 @@ mode = "headless") {
|
|
|
84
86
|
let tuiUnmount;
|
|
85
87
|
let paused = false;
|
|
86
88
|
// Wrap log for TUI/JSON modes
|
|
87
|
-
|
|
89
|
+
let effectiveLog = mode === "json"
|
|
88
90
|
? () => { } // JSON mode suppresses console log; NDJSON emitted per tick
|
|
89
91
|
: mode === "tui"
|
|
90
92
|
? (msg) => tuiEmitter.emitLog(msg)
|
|
@@ -105,7 +107,7 @@ mode = "headless") {
|
|
|
105
107
|
}
|
|
106
108
|
catch {
|
|
107
109
|
// Fallback to headless if TUI fails (e.g., non-TTY)
|
|
108
|
-
|
|
110
|
+
effectiveLog = log;
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
const shutdown = () => { running = false; state.phase = "stopped"; };
|
|
@@ -130,6 +132,7 @@ mode = "headless") {
|
|
|
130
132
|
state.equity = parseFloat(bal.equity);
|
|
131
133
|
state.peakEquity = state.equity;
|
|
132
134
|
state.dailyStartEquity = state.equity;
|
|
135
|
+
state.initialEquity = state.equity;
|
|
133
136
|
effectiveLog(` Starting equity: $${state.equity.toFixed(2)}`);
|
|
134
137
|
}
|
|
135
138
|
catch {
|
|
@@ -162,6 +165,7 @@ mode = "headless") {
|
|
|
162
165
|
state.dailyStartDate = today;
|
|
163
166
|
}
|
|
164
167
|
state.dailyPnl = state.equity - state.dailyStartEquity;
|
|
168
|
+
state.totalPnl = state.equity - state.initialEquity;
|
|
165
169
|
}
|
|
166
170
|
catch { /* non-critical */ }
|
|
167
171
|
const context = {
|
|
@@ -217,6 +221,7 @@ mode = "headless") {
|
|
|
217
221
|
? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
|
|
218
222
|
: "exit condition met";
|
|
219
223
|
effectiveLog(` Exiting: ${reason}`);
|
|
224
|
+
state.riskBreached = riskBreached;
|
|
220
225
|
state.phase = "exiting";
|
|
221
226
|
}
|
|
222
227
|
else {
|
|
@@ -237,14 +242,21 @@ mode = "headless") {
|
|
|
237
242
|
effectiveLog(` Pausing ${config.risk.pause_after_loss_sec}s after loss`);
|
|
238
243
|
state.phase = "paused";
|
|
239
244
|
await sleep(config.risk.pause_after_loss_sec * 1000);
|
|
245
|
+
// Data will be refreshed on the next loop iteration (re-fetches snapshot + equity)
|
|
240
246
|
state.phase = "monitoring";
|
|
241
247
|
effectiveLog(` Resuming monitoring...`);
|
|
242
248
|
}
|
|
243
|
-
else {
|
|
244
|
-
//
|
|
249
|
+
else if (state.riskBreached) {
|
|
250
|
+
// Risk breach (drawdown or daily loss exceeded) — stop completely
|
|
245
251
|
effectiveLog(` Bot stopped.`);
|
|
246
252
|
break;
|
|
247
253
|
}
|
|
254
|
+
else {
|
|
255
|
+
// Normal exit condition (not risk breach) — return to monitoring
|
|
256
|
+
state.phase = "monitoring";
|
|
257
|
+
state.riskBreached = false;
|
|
258
|
+
effectiveLog(` Exit condition met, returning to monitoring...`);
|
|
259
|
+
}
|
|
248
260
|
}
|
|
249
261
|
// ── Emit state update for TUI / JSON ──
|
|
250
262
|
const [positions, openOrders, exchangeBalances] = await Promise.all([
|
|
@@ -354,18 +366,16 @@ async function executeActions(adapter, symbol, actions, log, dryRun = false) {
|
|
|
354
366
|
// ── Enriched snapshot ──
|
|
355
367
|
async function getEnrichedSnapshot(adapter, symbol) {
|
|
356
368
|
const base = await getMarketSnapshot(adapter, symbol);
|
|
357
|
-
const [orderbook, klines
|
|
369
|
+
const [orderbook, klines] = await Promise.all([
|
|
358
370
|
adapter.getOrderbook(symbol).catch(() => ({ bids: [], asks: [] })),
|
|
359
371
|
adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(() => []),
|
|
360
|
-
adapter.getMarkets().catch(() => []),
|
|
361
372
|
]);
|
|
362
|
-
|
|
363
|
-
return { ...base, klines, orderbook, openInterest: market?.openInterest ?? "0" };
|
|
373
|
+
return { ...base, klines, orderbook, openInterest: "0" };
|
|
364
374
|
}
|
|
365
375
|
// ── Utils ──
|
|
366
|
-
/** Count fill-producing actions (
|
|
376
|
+
/** Count fill-producing actions (all order placements) */
|
|
367
377
|
function countFills(actions) {
|
|
368
|
-
return actions.filter(a => a.type === "place_order"
|
|
378
|
+
return actions.filter(a => a.type === "place_order").length;
|
|
369
379
|
}
|
|
370
380
|
function logStatus(log, state, snapshot, phase) {
|
|
371
381
|
const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
|
|
@@ -398,7 +408,7 @@ async function fetchPositions(adapter, symbol, exchangeName) {
|
|
|
398
408
|
const positions = await adapter.getPositions();
|
|
399
409
|
const isAll = symbol.toUpperCase() === "ALL";
|
|
400
410
|
return positions
|
|
401
|
-
.filter(p => (isAll || p.symbol.toUpperCase().
|
|
411
|
+
.filter(p => (isAll || p.symbol.toUpperCase() === symbol.toUpperCase() || p.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP") && parseFloat(p.size) !== 0)
|
|
402
412
|
.map(p => ({
|
|
403
413
|
symbol: p.symbol,
|
|
404
414
|
side: p.side,
|
|
@@ -446,7 +456,7 @@ async function fetchOpenOrders(adapter, symbol) {
|
|
|
446
456
|
try {
|
|
447
457
|
const orders = await adapter.getOpenOrders();
|
|
448
458
|
return orders
|
|
449
|
-
.filter(o => o.symbol.toUpperCase().
|
|
459
|
+
.filter(o => o.symbol.toUpperCase() === symbol.toUpperCase() || o.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP")
|
|
450
460
|
.map(o => ({
|
|
451
461
|
orderId: o.orderId,
|
|
452
462
|
side: o.side,
|
|
@@ -487,7 +497,7 @@ function buildTuiState(state, config, snapshot, positions, openOrders, strategyC
|
|
|
487
497
|
exchange: config.exchange,
|
|
488
498
|
price: snapshot.price,
|
|
489
499
|
volume24h: snapshot.volume24h ?? 0,
|
|
490
|
-
openInterest: "0",
|
|
500
|
+
openInterest: snapshot.openInterest ?? "0",
|
|
491
501
|
fundingRate: snapshot.fundingRate,
|
|
492
502
|
volatility24h: snapshot.volatility24h,
|
|
493
503
|
positions,
|
package/dist/bot/presets.js
CHANGED
|
@@ -34,19 +34,23 @@ export class DCAStrategy {
|
|
|
34
34
|
// Check order limit
|
|
35
35
|
if (params.total_orders > 0 && dcaOrdersPlaced >= params.total_orders)
|
|
36
36
|
return [];
|
|
37
|
-
// Check price limit
|
|
37
|
+
// Check price limit (buy: skip if price too high, sell: skip if price too low)
|
|
38
38
|
if (params.price_limit) {
|
|
39
|
-
|
|
39
|
+
const side = params.side ?? "buy";
|
|
40
|
+
if (side === "buy" && snapshot.price > params.price_limit)
|
|
41
|
+
return [];
|
|
42
|
+
if (side === "sell" && snapshot.price < params.price_limit)
|
|
40
43
|
return [];
|
|
41
44
|
}
|
|
42
45
|
// Place market buy order
|
|
43
46
|
ctx.state.set("dcaOrdersPlaced", dcaOrdersPlaced + 1);
|
|
44
47
|
ctx.state.set("dcaLastOrder", Date.now());
|
|
48
|
+
const side = params.side ?? "buy";
|
|
45
49
|
const progress = params.total_orders > 0 ? ` (${dcaOrdersPlaced + 1}/${params.total_orders})` : "";
|
|
46
|
-
ctx.log(` [DCA] Order #${dcaOrdersPlaced + 1}${progress}:
|
|
50
|
+
ctx.log(` [DCA] Order #${dcaOrdersPlaced + 1}${progress}: ${side} ${params.amount} ${ctx.symbol} @ $${snapshot.price.toFixed(2)}`);
|
|
47
51
|
return [{
|
|
48
52
|
type: "place_order",
|
|
49
|
-
side
|
|
53
|
+
side,
|
|
50
54
|
price: "0",
|
|
51
55
|
size: String(params.amount),
|
|
52
56
|
orderType: "market",
|
|
@@ -27,6 +27,6 @@ export declare class FundingArbStrategy implements Strategy {
|
|
|
27
27
|
private _config;
|
|
28
28
|
init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
|
|
29
29
|
onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
|
|
30
|
-
onStop(
|
|
30
|
+
onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
|
|
31
31
|
private fetchRates;
|
|
32
32
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerStrategy } from "../strategy-registry.js";
|
|
2
2
|
import { checkArbLiquidity } from "../../liquidity.js";
|
|
3
3
|
import { computeMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
|
|
4
|
+
import { computeAnnualSpread } from "../../funding.js";
|
|
4
5
|
export class FundingArbStrategy {
|
|
5
6
|
name = "funding-arb";
|
|
6
7
|
describe() {
|
|
@@ -23,13 +24,14 @@ export class FundingArbStrategy {
|
|
|
23
24
|
this._config = ctx.config;
|
|
24
25
|
const params = this.params;
|
|
25
26
|
ctx.state.set("arbRunning", true);
|
|
27
|
+
ctx.state.set("arbOpenPositions", []);
|
|
26
28
|
ctx.state.set("arbPositions", 0);
|
|
27
|
-
ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | size $${params.size_usd}`);
|
|
29
|
+
ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | close < ${params.close_spread}% | size $${params.size_usd}`);
|
|
28
30
|
ctx.log(` [ARB] Exchanges: ${params.exchanges.join(", ")}`);
|
|
29
31
|
}
|
|
30
32
|
async onTick(ctx, _snapshot) {
|
|
31
33
|
const params = this.params;
|
|
32
|
-
const
|
|
34
|
+
const openPositions = ctx.state.get("arbOpenPositions");
|
|
33
35
|
// Get extra adapters from context state (set by engine)
|
|
34
36
|
const extraAdapters = ctx.state.get("extraAdapters");
|
|
35
37
|
// Build adapter map from primary + extras
|
|
@@ -52,7 +54,42 @@ export class FundingArbStrategy {
|
|
|
52
54
|
map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price });
|
|
53
55
|
ratesByExchange.set(name, map);
|
|
54
56
|
}
|
|
55
|
-
//
|
|
57
|
+
// ── Check existing positions for close_spread ──
|
|
58
|
+
const toClose = [];
|
|
59
|
+
for (const pos of openPositions) {
|
|
60
|
+
const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
|
|
61
|
+
const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
|
|
62
|
+
if (longRate === undefined || shortRate === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
const currentSpread = computeAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
|
|
65
|
+
if (currentSpread < params.close_spread) {
|
|
66
|
+
ctx.log(` [ARB] Closing ${pos.symbol}: spread ${currentSpread.toFixed(1)}% < close_spread ${params.close_spread}% (entry: ${pos.entrySpread.toFixed(1)}%)`);
|
|
67
|
+
const longAdapter = adapters.get(pos.longExchange);
|
|
68
|
+
const shortAdapter = adapters.get(pos.shortExchange);
|
|
69
|
+
if (longAdapter && shortAdapter) {
|
|
70
|
+
try {
|
|
71
|
+
await Promise.all([
|
|
72
|
+
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
73
|
+
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
74
|
+
]);
|
|
75
|
+
toClose.push(pos);
|
|
76
|
+
ctx.log(` [ARB] Closed ${pos.symbol} position`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Remove closed positions
|
|
86
|
+
if (toClose.length > 0) {
|
|
87
|
+
const remaining = openPositions.filter(p => !toClose.includes(p));
|
|
88
|
+
ctx.state.set("arbOpenPositions", remaining);
|
|
89
|
+
ctx.state.set("arbPositions", remaining.length);
|
|
90
|
+
}
|
|
91
|
+
// ── Find new opportunities ──
|
|
92
|
+
const arbPositions = ctx.state.get("arbOpenPositions").length;
|
|
56
93
|
const exchangeNames = [...ratesByExchange.keys()];
|
|
57
94
|
const opportunities = [];
|
|
58
95
|
const allSymbols = new Set();
|
|
@@ -77,8 +114,7 @@ export class FundingArbStrategy {
|
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
if (minExchange && maxExchange && minExchange !== maxExchange) {
|
|
80
|
-
const
|
|
81
|
-
const spread = cas(maxRate, maxExchange, minRate, minExchange);
|
|
117
|
+
const spread = computeAnnualSpread(maxRate, maxExchange, minRate, minExchange);
|
|
82
118
|
if (spread >= params.min_spread) {
|
|
83
119
|
opportunities.push({
|
|
84
120
|
symbol: sym,
|
|
@@ -110,17 +146,41 @@ export class FundingArbStrategy {
|
|
|
110
146
|
if (!liq.viable)
|
|
111
147
|
return [];
|
|
112
148
|
// Compute matched size
|
|
113
|
-
|
|
149
|
+
let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
|
|
114
150
|
if (!matched) {
|
|
115
151
|
ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
|
|
116
152
|
return [];
|
|
117
153
|
}
|
|
118
154
|
try {
|
|
119
155
|
ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
try {
|
|
157
|
+
await Promise.all([
|
|
158
|
+
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
159
|
+
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
catch (orderErr) {
|
|
163
|
+
// Parse lot size from error and retry with corrected size
|
|
164
|
+
const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
|
|
165
|
+
const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
|
|
166
|
+
if (lotMatch) {
|
|
167
|
+
const lotSize = parseFloat(lotMatch[1]);
|
|
168
|
+
ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
|
|
169
|
+
matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
|
|
170
|
+
if (!matched) {
|
|
171
|
+
ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
|
|
175
|
+
await Promise.all([
|
|
176
|
+
longAdapter.marketOrder(best.symbol, "buy", matched.size),
|
|
177
|
+
shortAdapter.marketOrder(best.symbol, "sell", matched.size),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
throw orderErr;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
124
184
|
// Verify fills match, correct if needed
|
|
125
185
|
try {
|
|
126
186
|
const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
|
|
@@ -128,9 +188,22 @@ export class FundingArbStrategy {
|
|
|
128
188
|
ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
|
|
129
189
|
}
|
|
130
190
|
}
|
|
131
|
-
catch {
|
|
132
|
-
|
|
133
|
-
|
|
191
|
+
catch (reconErr) {
|
|
192
|
+
const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
|
|
193
|
+
ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
|
|
194
|
+
}
|
|
195
|
+
// Track position in array
|
|
196
|
+
const currentPositions = ctx.state.get("arbOpenPositions");
|
|
197
|
+
currentPositions.push({
|
|
198
|
+
symbol: best.symbol,
|
|
199
|
+
longExchange: best.longExchange,
|
|
200
|
+
shortExchange: best.shortExchange,
|
|
201
|
+
entrySpread: best.spread,
|
|
202
|
+
size: matched.size,
|
|
203
|
+
});
|
|
204
|
+
ctx.state.set("arbOpenPositions", currentPositions);
|
|
205
|
+
ctx.state.set("arbPositions", currentPositions.length);
|
|
206
|
+
ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
|
|
134
207
|
// Return noop -- we already executed directly via adapters
|
|
135
208
|
// (arb requires multi-adapter coordination, can't be expressed as single-adapter actions)
|
|
136
209
|
return [{ type: "noop" }];
|
|
@@ -148,7 +221,46 @@ export class FundingArbStrategy {
|
|
|
148
221
|
}
|
|
149
222
|
return [];
|
|
150
223
|
}
|
|
151
|
-
async onStop(
|
|
224
|
+
async onStop(ctx) {
|
|
225
|
+
const openPositions = ctx.state.get("arbOpenPositions");
|
|
226
|
+
const extraAdapters = ctx.state.get("extraAdapters");
|
|
227
|
+
// Close all open arb positions
|
|
228
|
+
if (openPositions && openPositions.length > 0) {
|
|
229
|
+
const adapters = new Map();
|
|
230
|
+
adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
|
|
231
|
+
if (extraAdapters) {
|
|
232
|
+
for (const [name, a] of extraAdapters)
|
|
233
|
+
adapters.set(name, a);
|
|
234
|
+
}
|
|
235
|
+
for (const pos of openPositions) {
|
|
236
|
+
const longAdapter = adapters.get(pos.longExchange);
|
|
237
|
+
const shortAdapter = adapters.get(pos.shortExchange);
|
|
238
|
+
if (longAdapter && shortAdapter) {
|
|
239
|
+
try {
|
|
240
|
+
ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
|
|
241
|
+
await Promise.all([
|
|
242
|
+
longAdapter.marketOrder(pos.symbol, "sell", pos.size),
|
|
243
|
+
shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
248
|
+
ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
ctx.state.set("arbOpenPositions", []);
|
|
253
|
+
ctx.state.set("arbPositions", 0);
|
|
254
|
+
}
|
|
255
|
+
// Cancel on extra adapters directly (M2: was only cancelling on primary adapter)
|
|
256
|
+
if (extraAdapters) {
|
|
257
|
+
for (const [, a] of extraAdapters) {
|
|
258
|
+
try {
|
|
259
|
+
await a.cancelAllOrders();
|
|
260
|
+
}
|
|
261
|
+
catch { /* best effort */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
152
264
|
return [{ type: "cancel_all" }];
|
|
153
265
|
}
|
|
154
266
|
async fetchRates(adapter, _exchangeName) {
|