perp-cli 0.14.1 → 0.14.3
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.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +22 -26
- package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
- package/dist/bot/strategies/funding-arb-utils.js +82 -19
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +48 -29
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- package/dist/commands/market.js +2 -2
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +1 -1
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +47 -28
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +24 -7
- package/dist/exchanges/aster.js +178 -93
- package/dist/exchanges/hyperliquid.d.ts +30 -4
- package/dist/exchanges/hyperliquid.js +159 -55
- package/dist/exchanges/interface.d.ts +3 -2
- package/dist/exchanges/interface.js +3 -2
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/dist/trade-validator.d.ts +1 -1
- package/dist/trade-validator.js +7 -2
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- package/skills/perp-cli/references/commands.md +5 -3
|
@@ -110,6 +110,23 @@ export function findRate(ratesByExchange, exchange, symbol) {
|
|
|
110
110
|
const upper = symbol.toUpperCase();
|
|
111
111
|
return map.get(upper) ?? map.get(upper + "-PERP") ?? map.get(upper.replace(/-PERP$/, ""));
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Leverage for an entry: the configured value, capped by every leg's published max.
|
|
115
|
+
* A leg whose max is unpublished (undefined) is not a cap — the venue enforces its own
|
|
116
|
+
* limit on setLeverage / the order — and `logUnknown` names it, so it is never read as 1x.
|
|
117
|
+
*/
|
|
118
|
+
export function cappedLeverage(configured, legs, logUnknown) {
|
|
119
|
+
let leverage = configured;
|
|
120
|
+
for (const leg of legs) {
|
|
121
|
+
if (leg.maxLeverage === undefined) {
|
|
122
|
+
logUnknown(` [LEV] ${leg.exchange} does not publish a max leverage — using ${configured}x; the venue enforces its cap`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
leverage = Math.min(leverage, leg.maxLeverage);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return leverage;
|
|
129
|
+
}
|
|
113
130
|
/** Match a symbol against a target, accounting for -PERP suffix variations. */
|
|
114
131
|
export function matchSymbol(s, target) {
|
|
115
132
|
const u = s.toUpperCase();
|
|
@@ -165,32 +182,33 @@ async function marketFundingHours(adapter, exchangeName, symbol) {
|
|
|
165
182
|
}
|
|
166
183
|
return getFundingHours(exchangeName);
|
|
167
184
|
}
|
|
168
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* Score a symbol's funding rate using history. null = not enough data (fewer than 2 history
|
|
187
|
+
* points, or the per-symbol funding basis is unknown). A failed read THROWS — callers must not
|
|
188
|
+
* mistake a 429 / timeout for "history too thin".
|
|
189
|
+
*/
|
|
169
190
|
export async function scoreFunding(adapter, symbol, exchangeName, periods, cache) {
|
|
170
191
|
const key = `${exchangeName}:${symbol}`;
|
|
171
192
|
const cached = cache?.get(key);
|
|
172
193
|
if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
|
|
173
194
|
return cached.score;
|
|
174
|
-
// Outside the try: an unknown venue is a configuration error, not "no score".
|
|
175
195
|
const venueHours = hasPerSymbolFundingHours(adapter) ? null : getFundingHours(exchangeName);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (history.length < 2)
|
|
182
|
-
return null;
|
|
183
|
-
const rates = history.map(h => parseFloat(h.rate));
|
|
184
|
-
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
185
|
-
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
186
|
-
const annualized = annualizeHourlyRate(avgRate / fH);
|
|
187
|
-
const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
|
|
188
|
-
cache?.set(key, { score, ts: Date.now() });
|
|
189
|
-
return score;
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
196
|
+
const fH = venueHours !== null ? venueHours : await marketFundingHours(adapter, exchangeName, symbol);
|
|
197
|
+
if (fH === null)
|
|
198
|
+
return null; // per-symbol basis unknown — the hourly rate would be a guess
|
|
199
|
+
const history = await adapter.getFundingHistory(symbol, periods);
|
|
200
|
+
if (history.length < 2)
|
|
192
201
|
return null;
|
|
202
|
+
const rates = history.map(h => parseFloat(h.rate));
|
|
203
|
+
if (rates.some(r => !Number.isFinite(r))) {
|
|
204
|
+
throw new PerpError("EXCHANGE_ERROR", `${exchangeName} ${symbol}: funding history has a non-numeric rate`, { exchange: exchangeName });
|
|
193
205
|
}
|
|
206
|
+
const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
|
|
207
|
+
const consistency = rates.filter(r => r > 0).length / rates.length;
|
|
208
|
+
const annualized = annualizeHourlyRate(avgRate / fH);
|
|
209
|
+
const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
|
|
210
|
+
cache?.set(key, { score, ts: Date.now() });
|
|
211
|
+
return score;
|
|
194
212
|
}
|
|
195
213
|
/**
|
|
196
214
|
* Fetch funding rates from an exchange adapter. Failures propagate — callers
|
|
@@ -207,6 +225,7 @@ export async function fetchRates(adapter, exchangeName) {
|
|
|
207
225
|
continue;
|
|
208
226
|
m.fundingHours = await marketFundingHours(adapter, exchangeName, m.symbol);
|
|
209
227
|
}
|
|
228
|
+
// maxLeverage undefined = not published by the venue (callers cap by published maxes only — see cappedLeverage).
|
|
210
229
|
const result = [];
|
|
211
230
|
for (const m of withRates) {
|
|
212
231
|
// Skip aster symbols with unknown funding period — the hourly rate would be a guess.
|
|
@@ -221,7 +240,7 @@ export async function fetchRates(adapter, exchangeName) {
|
|
|
221
240
|
rate: parseFloat(m.fundingRate),
|
|
222
241
|
price: parseFloat(m.markPrice),
|
|
223
242
|
sizeDecimals: m.sizeDecimals,
|
|
224
|
-
maxLeverage: m.maxLeverage,
|
|
243
|
+
maxLeverage: m.maxLeverage ?? undefined,
|
|
225
244
|
fundingHours: m.fundingHours,
|
|
226
245
|
});
|
|
227
246
|
}
|
|
@@ -249,3 +268,47 @@ export async function cancelBotSymbols(adapters, symbols, tag, log) {
|
|
|
249
268
|
}
|
|
250
269
|
log(` ${tag} Cancel requested for ${[...symbols].join(", ")} on ${[...adapters.keys()].join(", ")}`);
|
|
251
270
|
}
|
|
271
|
+
/** Venues whose funding history is CAPABILITY_UNSUPPORTED, already reported once (collectFundingSince). */
|
|
272
|
+
const _fundingUnsupportedLogged = new Set();
|
|
273
|
+
/**
|
|
274
|
+
* Books settled funding not yet booked (shared by the funding-arb and spot-perp bots).
|
|
275
|
+
* `since[venue]` is a per-venue HIGH-WATER MARK of the newest payment time already booked
|
|
276
|
+
* (initially `defaultStart`, the bot's start — recovery booked the 24 h before it): only payments
|
|
277
|
+
* strictly newer are summed, so consecutive reads never count a payment twice. A venue whose read
|
|
278
|
+
* fails keeps its mark and is re-read next time; the others are booked — one failure never drops
|
|
279
|
+
* or blocks the rest, and a failed read is never counted as 0. When a catching-up read returns a
|
|
280
|
+
* full page whose oldest row is still newer than the mark, older payments may be beyond the page
|
|
281
|
+
* and a warning is logged. `since` is mutated in place.
|
|
282
|
+
*/
|
|
283
|
+
export async function collectFundingSince(adapters, since, defaultStart, log) {
|
|
284
|
+
const PAGE = 50;
|
|
285
|
+
let total = 0;
|
|
286
|
+
const failed = [];
|
|
287
|
+
for (const [name, a] of adapters) {
|
|
288
|
+
try {
|
|
289
|
+
const payments = await a.getFundingPayments(PAGE);
|
|
290
|
+
const mark = since[name] ?? defaultStart;
|
|
291
|
+
const fresh = payments.filter(fp => fp.time > mark);
|
|
292
|
+
const sum = fresh.reduce((s, fp) => s + parseFloat(fp.payment), 0);
|
|
293
|
+
total += sum;
|
|
294
|
+
since[name] = fresh.reduce((m, fp) => Math.max(m, fp.time), mark);
|
|
295
|
+
if (payments.length >= PAGE && fresh.length === payments.length) {
|
|
296
|
+
log(` [FUND] ${name}: ${PAGE} unbooked payments in one read — older ones since the last booked payment may be missing from fundingIncome`);
|
|
297
|
+
}
|
|
298
|
+
if (Math.abs(sum) > 0.001)
|
|
299
|
+
log(` [FUND] ${name}: $${sum.toFixed(4)} funding`);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
failed.push(name);
|
|
303
|
+
// A venue that does not offer funding history at all (QFEX: CAPABILITY_UNSUPPORTED) is said
|
|
304
|
+
// once per process; a read failure is logged every time.
|
|
305
|
+
const unsupported = e instanceof PerpError && e.structured.code === "CAPABILITY_UNSUPPORTED";
|
|
306
|
+
if (!unsupported || !_fundingUnsupportedLogged.has(name)) {
|
|
307
|
+
log(` [FUND] ${name}: ${unsupported ? "funding history not supported — its funding is not counted" : "funding history unavailable (re-read next check)"}: ${e instanceof Error ? e.message : String(e)}`);
|
|
308
|
+
}
|
|
309
|
+
if (unsupported)
|
|
310
|
+
_fundingUnsupportedLogged.add(name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { total, failed };
|
|
314
|
+
}
|
|
@@ -54,16 +54,22 @@ class FundingAutoStrategy {
|
|
|
54
54
|
get defaultFeeRate() { return Number(this._config.defaultFeeRate ?? 0.0005); }
|
|
55
55
|
get defaultSlippage() { return Number(this._config.defaultSlippage ?? 0.001); }
|
|
56
56
|
get scanIntervalTicks() { return Number(this._config.scanIntervalTicks ?? 1); }
|
|
57
|
-
/**
|
|
58
|
-
_cachedEquity =
|
|
57
|
+
/** Total equity across all venues, read this tick; null = some venue's balance is unknown. */
|
|
58
|
+
_cachedEquity = null;
|
|
59
|
+
/**
|
|
60
|
+
* Position size: min of sizeUsd (if set) and maxCapitalPct% of total equity. null = cannot be
|
|
61
|
+
* sized (equity unknown, or no capital and no sizeUsd) — no entry, never a substituted size.
|
|
62
|
+
*/
|
|
59
63
|
getSizeUsd() {
|
|
64
|
+
if (this._cachedEquity === null)
|
|
65
|
+
return null;
|
|
60
66
|
const fixedSize = Number(this._config.sizeUsd ?? 0);
|
|
61
67
|
const capitalLimit = this._cachedEquity * (this.maxCapitalPct / 100);
|
|
62
68
|
if (fixedSize > 0 && capitalLimit > 0)
|
|
63
69
|
return Math.min(fixedSize, capitalLimit);
|
|
64
70
|
if (capitalLimit > 0)
|
|
65
71
|
return capitalLimit;
|
|
66
|
-
return
|
|
72
|
+
return null;
|
|
67
73
|
}
|
|
68
74
|
describe() {
|
|
69
75
|
return {
|
|
@@ -96,22 +102,27 @@ class FundingAutoStrategy {
|
|
|
96
102
|
ctx.log(`[funding-auto] Starting (perp-perp only; spot-perp mode is not supported by this strategy)`);
|
|
97
103
|
}
|
|
98
104
|
async onTick(ctx, _snapshot) {
|
|
99
|
-
// Cache total equity across ALL exchanges for capital-based sizing
|
|
100
|
-
try {
|
|
101
|
-
const adapters = this.getAdapters(ctx);
|
|
102
|
-
let totalEquity = 0;
|
|
103
|
-
for (const [, adapter] of adapters) {
|
|
104
|
-
try {
|
|
105
|
-
const bal = await adapter.getBalance();
|
|
106
|
-
totalEquity += Number(bal.equity) || 0;
|
|
107
|
-
}
|
|
108
|
-
catch { /* skip */ }
|
|
109
|
-
}
|
|
110
|
-
this._cachedEquity = totalEquity > 0 ? totalEquity : this._cachedEquity;
|
|
111
|
-
}
|
|
112
|
-
catch { /* keep previous cached value */ }
|
|
113
105
|
// Get all adapters
|
|
114
106
|
const adapters = this.getAdapters(ctx);
|
|
107
|
+
// Total equity across ALL exchanges for capital-based sizing, read fresh every tick.
|
|
108
|
+
// Any unknown balance → equity unknown (null): no new entry is sized this tick (never a
|
|
109
|
+
// stale value, a venue counted as $0, or a substituted size).
|
|
110
|
+
let totalEquity = 0;
|
|
111
|
+
for (const [name, adapter] of adapters) {
|
|
112
|
+
try {
|
|
113
|
+
const bal = await adapter.getBalance();
|
|
114
|
+
const eq = Number(bal.equity);
|
|
115
|
+
if (!Number.isFinite(eq))
|
|
116
|
+
throw new Error(`equity "${bal.equity}" is not a number`);
|
|
117
|
+
if (totalEquity !== null)
|
|
118
|
+
totalEquity += eq;
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
totalEquity = null;
|
|
122
|
+
ctx.log(`[funding-auto] ${name} balance unreadable (${e instanceof Error ? e.message : String(e)}) — equity unknown, no new entries this tick`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this._cachedEquity = totalEquity;
|
|
115
126
|
if (adapters.size < 2) {
|
|
116
127
|
ctx.log(`[funding-auto] Need 2+ exchanges, have ${adapters.size}. Skipping.`);
|
|
117
128
|
return [];
|
|
@@ -123,6 +134,8 @@ class FundingAutoStrategy {
|
|
|
123
134
|
// Fetch funding rates from all exchanges (price data cached for position sizing)
|
|
124
135
|
const ratesByExchange = new Map();
|
|
125
136
|
const priceCache = new Map(); // "exchange:symbol" -> price
|
|
137
|
+
// Venues whose markets could not be read this tick: no open / close decision touches them.
|
|
138
|
+
const ratesUnavailable = new Set();
|
|
126
139
|
for (const [name, adapter] of adapters) {
|
|
127
140
|
try {
|
|
128
141
|
const markets = await adapter.getMarkets();
|
|
@@ -144,17 +157,19 @@ class FundingAutoStrategy {
|
|
|
144
157
|
}
|
|
145
158
|
ratesByExchange.set(name, map);
|
|
146
159
|
}
|
|
147
|
-
catch {
|
|
148
|
-
|
|
160
|
+
catch (e) {
|
|
161
|
+
ratesUnavailable.add(name);
|
|
162
|
+
ctx.log(`[funding-auto] ${name} markets unreadable (${e instanceof Error ? e.message : String(e)}) — no open / close decision on ${name} this tick`);
|
|
149
163
|
}
|
|
150
164
|
}
|
|
151
165
|
// Store priceCache for use by openPosition/closePosition
|
|
152
166
|
ctx.state.set("priceCache", priceCache);
|
|
153
167
|
// Calculate notional for discovered positions (using current price)
|
|
168
|
+
// (unknown price → notional left unset, never valued at $0)
|
|
154
169
|
for (const pos of positions) {
|
|
155
170
|
const price = priceCache.get(`${pos.longExchange}:${pos.symbol}`)
|
|
156
|
-
|
|
157
|
-
if (price
|
|
171
|
+
?? priceCache.get(`${pos.shortExchange}:${pos.symbol}`);
|
|
172
|
+
if (price !== undefined) {
|
|
158
173
|
pos.notional = Math.max(pos.longSize, pos.shortSize) * price;
|
|
159
174
|
}
|
|
160
175
|
}
|
|
@@ -171,12 +186,15 @@ class FundingAutoStrategy {
|
|
|
171
186
|
const longRates = ratesByExchange.get(pos.longExchange);
|
|
172
187
|
const shortRates = ratesByExchange.get(pos.shortExchange);
|
|
173
188
|
if (!longRates || !shortRates) {
|
|
189
|
+
const down = [pos.longExchange, pos.shortExchange].filter(n => ratesUnavailable.has(n) || !ratesByExchange.has(n));
|
|
190
|
+
ctx.log(`[funding-auto] ${pos.symbol}: rates unavailable on ${down.join(", ")} — skipping the exit decision this tick`);
|
|
174
191
|
surviving.push(pos);
|
|
175
192
|
continue;
|
|
176
193
|
}
|
|
177
194
|
const longInfo = longRates.get(pos.symbol);
|
|
178
195
|
const shortInfo = shortRates.get(pos.symbol);
|
|
179
196
|
if (!longInfo || !shortInfo) {
|
|
197
|
+
ctx.log(`[funding-auto] ${pos.symbol}: market missing on ${!longInfo ? pos.longExchange : pos.shortExchange} — skipping the exit decision this tick`);
|
|
180
198
|
surviving.push(pos);
|
|
181
199
|
continue;
|
|
182
200
|
}
|
|
@@ -184,6 +202,11 @@ class FundingAutoStrategy {
|
|
|
184
202
|
// (each normalized by its own funding basis), annualized %. Positive = still
|
|
185
203
|
// earning; ≤ 0 = the pair has reversed and now pays.
|
|
186
204
|
const currentSpread = annualizeHourlyRate(hourlyOf(shortInfo, pos.shortExchange) - hourlyOf(longInfo, pos.longExchange));
|
|
205
|
+
if (!Number.isFinite(currentSpread)) {
|
|
206
|
+
ctx.log(`[funding-auto] ${pos.symbol}: funding rate unknown (${pos.longExchange}↔${pos.shortExchange}) — skipping the exit decision this tick`);
|
|
207
|
+
surviving.push(pos);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
187
210
|
// Calculate real P&L from exchange history. An unreadable history is never "0":
|
|
188
211
|
// no close decision for this pair this tick.
|
|
189
212
|
const [longPnl, shortPnl] = await Promise.all([
|
|
@@ -219,8 +242,14 @@ class FundingAutoStrategy {
|
|
|
219
242
|
return surviving.length > 0 ? [{ type: "noop" }] : [];
|
|
220
243
|
if (surviving.length >= this.maxPositions)
|
|
221
244
|
return [{ type: "noop" }];
|
|
245
|
+
// Unknown sizing (equity unknown, or no capital and no sizeUsd): no new entries this tick.
|
|
246
|
+
const sizeUsd = this.getSizeUsd();
|
|
247
|
+
if (sizeUsd === null) {
|
|
248
|
+
ctx.log(`[funding-auto] No new entries: position size unknown (equity ${this._cachedEquity === null ? "unknown" : `$${this._cachedEquity.toFixed(2)}`}, sizeUsd ${Number(this._config.sizeUsd ?? 0)})`);
|
|
249
|
+
return surviving.length > 0 ? [{ type: "noop" }] : [];
|
|
250
|
+
}
|
|
222
251
|
ctx.state.set("lastScan", Date.now());
|
|
223
|
-
ctx.log(`[funding-auto] Scanning ${ratesByExchange.size} exchanges, equity: $${this._cachedEquity.toFixed(2)}, size: $${
|
|
252
|
+
ctx.log(`[funding-auto] Scanning ${ratesByExchange.size} exchanges, equity: $${this._cachedEquity.toFixed(2)}, size: $${sizeUsd.toFixed(2)}/pos, active: ${surviving.length}`);
|
|
224
253
|
const activeSymbols = new Set(surviving.map(p => `${p.symbol}:${p.mode}`));
|
|
225
254
|
// Also block symbols we already tried to enter (prevents re-entry on failed/tiny orders)
|
|
226
255
|
const attempted = ctx.state.get("attemptedSymbols") ?? new Set();
|
|
@@ -263,7 +292,7 @@ class FundingAutoStrategy {
|
|
|
263
292
|
if (annualSpread < this.perpPerpMinSpread)
|
|
264
293
|
continue;
|
|
265
294
|
// Calculate cost and break-even
|
|
266
|
-
const notional =
|
|
295
|
+
const notional = sizeUsd;
|
|
267
296
|
const hourlyIncome = Math.abs(maxRate - minRate) * notional;
|
|
268
297
|
const { totalCost } = calculateCosts(notional, this.defaultFeeRate, this.defaultFeeRate, this.defaultSlippage, this.defaultSlippage);
|
|
269
298
|
const breakEvenHours = calculateBreakEven(totalCost, hourlyIncome);
|
|
@@ -314,7 +343,7 @@ class FundingAutoStrategy {
|
|
|
314
343
|
// Track attempted symbols to prevent re-entry on same tick cycle
|
|
315
344
|
attempted.add(`${opp.symbol}:${opp.mode}`);
|
|
316
345
|
ctx.state.set("attemptedSymbols", attempted);
|
|
317
|
-
const success = await this.openPosition(ctx, opp, adapters);
|
|
346
|
+
const success = await this.openPosition(ctx, opp, adapters, sizeUsd);
|
|
318
347
|
if (success) {
|
|
319
348
|
currentCount++;
|
|
320
349
|
perpPerpOpened++;
|
|
@@ -362,16 +391,10 @@ class FundingAutoStrategy {
|
|
|
362
391
|
}
|
|
363
392
|
return adapters;
|
|
364
393
|
}
|
|
365
|
-
/**
|
|
394
|
+
/** Cached (this tick's) price for a symbol on a given exchange; undefined = unknown. */
|
|
366
395
|
getCachedPrice(ctx, exchange, symbol) {
|
|
367
396
|
const cache = ctx.state.get("priceCache");
|
|
368
|
-
return cache?.get(`${exchange}:${symbol}`)
|
|
369
|
-
}
|
|
370
|
-
/** Format size with exchange-appropriate precision based on asset price */
|
|
371
|
-
formatSize(rawSize, _symbol) {
|
|
372
|
-
// Use price to determine precision
|
|
373
|
-
const price = rawSize > 0 ? this.getSizeUsd() / rawSize : 0;
|
|
374
|
-
return formatSizeForPrice(rawSize, price);
|
|
397
|
+
return cache?.get(`${exchange}:${symbol}`);
|
|
375
398
|
}
|
|
376
399
|
/** Discover existing arb pairs from live exchange positions; `unreadable` = venues whose positions failed to load. */
|
|
377
400
|
async discoverArbPairs(adapters, log) {
|
|
@@ -458,7 +481,7 @@ class FundingAutoStrategy {
|
|
|
458
481
|
* (reduce-only, verified). A definite short failure rolls the long back reduce-only.
|
|
459
482
|
* An unknown outcome or a failed rollback / trim quarantines the symbol.
|
|
460
483
|
*/
|
|
461
|
-
async openPosition(ctx, opp, adapters) {
|
|
484
|
+
async openPosition(ctx, opp, adapters, notionalUsd) {
|
|
462
485
|
// Engine: equity unknown → no new entries (closes still run).
|
|
463
486
|
if (ctx.state.get("equityUnknown") === true) {
|
|
464
487
|
ctx.log(`[funding-auto] Skip ${opp.symbol}: equity unknown — no new entries`);
|
|
@@ -469,11 +492,12 @@ class FundingAutoStrategy {
|
|
|
469
492
|
if (!longAdapter || !shortAdapter)
|
|
470
493
|
return false;
|
|
471
494
|
const price = this.getCachedPrice(ctx, opp.longExchange, opp.symbol)
|
|
472
|
-
|
|
473
|
-
if (price
|
|
495
|
+
?? this.getCachedPrice(ctx, opp.shortExchange, opp.symbol);
|
|
496
|
+
if (price === undefined) {
|
|
497
|
+
ctx.log(`[funding-auto] Skip ${opp.symbol}: price unknown on ${opp.longExchange} / ${opp.shortExchange}`);
|
|
474
498
|
return false;
|
|
475
|
-
|
|
476
|
-
const size =
|
|
499
|
+
}
|
|
500
|
+
const size = formatSizeForPrice(notionalUsd / price, price);
|
|
477
501
|
// Minimum notional check — skip if below exchange minimums
|
|
478
502
|
if (notionalUsd < MIN_NOTIONAL_USD) {
|
|
479
503
|
ctx.log(`[funding-auto] Skip ${opp.symbol}: notional $${notionalUsd.toFixed(0)} < min $${MIN_NOTIONAL_USD}`);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Strategy, StrategyContext, StrategyAction, EnrichedSnapshot } from "../strategy-types.js";
|
|
2
|
+
/** Consecutive failed open-order reads after which every tick logs a loud warning. */
|
|
3
|
+
export declare const GRID_READ_FAILURE_WARN = 5;
|
|
2
4
|
export declare class GridStrategy implements Strategy {
|
|
3
5
|
readonly name = "grid";
|
|
4
6
|
describe(): {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { registerStrategy } from "../strategy-registry.js";
|
|
2
|
+
/** Consecutive failed open-order reads after which every tick logs a loud warning. */
|
|
3
|
+
export const GRID_READ_FAILURE_WARN = 5;
|
|
2
4
|
export class GridStrategy {
|
|
3
5
|
name = "grid";
|
|
4
6
|
describe() {
|
|
@@ -95,12 +97,13 @@ export class GridStrategy {
|
|
|
95
97
|
}
|
|
96
98
|
// For each filled level, place the opposite (counter) order
|
|
97
99
|
const newLevels = [...matchedLevels];
|
|
100
|
+
const counterOrders = [];
|
|
98
101
|
for (const filled of unmatchedLevels) {
|
|
99
102
|
const wasBuy = filled.side === "buy";
|
|
100
103
|
const newSide = wasBuy ? "sell" : "buy";
|
|
101
104
|
const newPrice = wasBuy ? filled.price + step : filled.price - step;
|
|
102
105
|
if (newPrice >= gridLower && newPrice <= gridUpper) {
|
|
103
|
-
|
|
106
|
+
counterOrders.push({
|
|
104
107
|
type: "place_order",
|
|
105
108
|
side: newSide,
|
|
106
109
|
price: newPrice.toFixed(2),
|
|
@@ -111,8 +114,24 @@ export class GridStrategy {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
ctx.state.set("gridLevels", newLevels);
|
|
117
|
+
actions.push(...counterOrders);
|
|
118
|
+
const prevFailures = ctx.state.get("gridReadFailures") ?? 0;
|
|
119
|
+
if (prevFailures > 0)
|
|
120
|
+
ctx.log(` [GRID] Open orders readable again after ${prevFailures} failed read(s)`);
|
|
121
|
+
ctx.state.set("gridReadFailures", 0);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// No fill detection / counter orders on an unread book (never on stale data); retried next tick.
|
|
125
|
+
const failures = (ctx.state.get("gridReadFailures") ?? 0) + 1;
|
|
126
|
+
ctx.state.set("gridReadFailures", failures);
|
|
127
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
128
|
+
if (failures >= GRID_READ_FAILURE_WARN) {
|
|
129
|
+
ctx.log(` [GRID] WARNING: open orders unreadable for ${failures} consecutive ticks (${reason}) — the grid is NOT placing counter orders; check: perp -e ${ctx.adapter.name} account orders`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
ctx.log(` [GRID] Open orders read failed (${failures}/${GRID_READ_FAILURE_WARN}): ${reason} — fill detection skipped this tick`);
|
|
133
|
+
}
|
|
114
134
|
}
|
|
115
|
-
catch { /* retry next loop */ }
|
|
116
135
|
// Auto-rebalance if price exits range
|
|
117
136
|
if (params.rebalance) {
|
|
118
137
|
const lastRebalance = ctx.state.get("lastRebalance") ?? 0;
|
|
@@ -12,7 +12,7 @@ import { registerStrategy } from "../strategy-registry.js";
|
|
|
12
12
|
import { getFundingHours } from "../../funding.js";
|
|
13
13
|
import { annualizeHourlyRate } from "../../funding/normalize.js";
|
|
14
14
|
import { pruneExecutionLog } from "../../execution-log.js";
|
|
15
|
-
import { buildAdapterMap, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols } from "./funding-arb-utils.js";
|
|
15
|
+
import { buildAdapterMap, getPerpSymbol, findRate, cappedLeverage, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
|
|
16
16
|
import { SpotPerpExecutor } from "./spot-perp-executor.js";
|
|
17
17
|
import { findSpotQuoteBalance, getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotSharesPerpMargin, spotTokenBase } from "../../exchanges/spot-factory.js";
|
|
18
18
|
import { getVenue } from "../../exchanges/venue.js";
|
|
@@ -84,6 +84,9 @@ export class SpotPerpArbStrategy {
|
|
|
84
84
|
ctx.state.set("spaPositions", []);
|
|
85
85
|
ctx.state.set("arbPositions", 0);
|
|
86
86
|
ctx.state.set("fundingIncome", 0);
|
|
87
|
+
// Payments up to this instant are covered by the startup recovery (last 24 h); the periodic
|
|
88
|
+
// check books only payments newer than it (collectFundingSince high-water mark).
|
|
89
|
+
ctx.state.set("fundingStartTime", Date.now());
|
|
87
90
|
ctx.state.set("fundingLastCheck", Date.now());
|
|
88
91
|
ctx.log(` [SPA] Ready | rate >= ${p.min_rate}% | close < ${p.close_rate}% | size $${p.size_usd}`);
|
|
89
92
|
ctx.log(` [SPA] Consistency >= ${(p.min_consistency * 100).toFixed(0)}% | history ${p.history_periods} periods | rotation ${p.rotation_threshold}%`);
|
|
@@ -103,13 +106,16 @@ export class SpotPerpArbStrategy {
|
|
|
103
106
|
try {
|
|
104
107
|
const payments = await a.getFundingPayments(200);
|
|
105
108
|
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
106
|
-
const
|
|
109
|
+
const startedAt = ctx.state.get("fundingStartTime");
|
|
110
|
+
const sum = payments.filter(fp => fp.time > oneDayAgo && fp.time <= startedAt).reduce((s, fp) => s + parseFloat(fp.payment), 0);
|
|
107
111
|
if (Math.abs(sum) > 0.001) {
|
|
108
112
|
historicalFunding += sum;
|
|
109
113
|
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical`);
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
|
-
catch {
|
|
116
|
+
catch (e) {
|
|
117
|
+
ctx.log(` [FUND] ${name}: funding history unavailable — historical funding excludes it: ${e instanceof Error ? e.message : String(e)}`);
|
|
118
|
+
}
|
|
113
119
|
}
|
|
114
120
|
if (Math.abs(historicalFunding) > 0.001)
|
|
115
121
|
ctx.state.set("fundingIncome", historicalFunding);
|
|
@@ -181,22 +187,15 @@ export class SpotPerpArbStrategy {
|
|
|
181
187
|
const lastCheck = ctx.state.get("fundingLastCheck");
|
|
182
188
|
if (Date.now() - lastCheck > 5 * 60 * 1000) {
|
|
183
189
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const payments = await a.getFundingPayments(50);
|
|
188
|
-
const start = ctx.state.get("fundingLastCheck");
|
|
189
|
-
const sum = payments.filter(fp => fp.time > start - 5 * 60 * 1000).reduce((s, fp) => s + parseFloat(fp.payment), 0);
|
|
190
|
-
total += sum;
|
|
191
|
-
if (Math.abs(sum) > 0.001)
|
|
192
|
-
ctx.log(` [FUND] ${name}: $${sum.toFixed(4)}`);
|
|
193
|
-
}
|
|
194
|
-
catch { /* skip */ }
|
|
195
|
-
}
|
|
190
|
+
const since = ctx.state.get("fundingLastCheckByVenue") ?? {};
|
|
191
|
+
const { total } = await collectFundingSince(adapters, since, ctx.state.get("fundingStartTime"), ctx.log);
|
|
192
|
+
ctx.state.set("fundingLastCheckByVenue", since);
|
|
196
193
|
ctx.state.set("fundingIncome", ctx.state.get("fundingIncome") + total);
|
|
197
194
|
ctx.state.set("fundingLastCheck", Date.now());
|
|
198
195
|
}
|
|
199
|
-
catch {
|
|
196
|
+
catch (e) {
|
|
197
|
+
ctx.log(` [FUND] funding check failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
198
|
+
}
|
|
200
199
|
}
|
|
201
200
|
for (const pos of positions) {
|
|
202
201
|
if (Date.now() - pos.lastFundingCheck < 10 * 60 * 1000)
|
|
@@ -213,7 +212,10 @@ export class SpotPerpArbStrategy {
|
|
|
213
212
|
pos.fundingHistory = recent.map(fp => ({ time: fp.time, amount: parseFloat(fp.payment) }));
|
|
214
213
|
pos.lastFundingCheck = Date.now();
|
|
215
214
|
}
|
|
216
|
-
catch {
|
|
215
|
+
catch (e) {
|
|
216
|
+
// lastFundingCheck is not advanced: retried next tick.
|
|
217
|
+
ctx.log(` [FUND] ${pos.symbol} ${pos.perpExchange}: funding history unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
218
|
+
}
|
|
217
219
|
}
|
|
218
220
|
const display = positions.map(pos => {
|
|
219
221
|
const ann = this.calcAnnualized(pos, ratesByExchange);
|
|
@@ -311,7 +313,7 @@ export class SpotPerpArbStrategy {
|
|
|
311
313
|
const allowed = allowedByBase.get(pos.symbol.toUpperCase());
|
|
312
314
|
if (allowed === undefined)
|
|
313
315
|
continue;
|
|
314
|
-
const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange, allowed);
|
|
316
|
+
const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange, allowed, ctx.log);
|
|
315
317
|
const best = candidates[0];
|
|
316
318
|
if (!best || best.exchange === pos.perpExchange)
|
|
317
319
|
continue;
|
|
@@ -329,12 +331,12 @@ export class SpotPerpArbStrategy {
|
|
|
329
331
|
continue;
|
|
330
332
|
const perpSym = getPerpSymbol(pos.symbol, pos.perpExchange);
|
|
331
333
|
const newMaxLev = findRate(ratesByExchange, best.exchange, getPerpSymbol(pos.symbol, best.exchange))?.maxLeverage
|
|
332
|
-
?? findRate(ratesByExchange, best.exchange, pos.symbol)?.maxLeverage
|
|
334
|
+
?? findRate(ratesByExchange, best.exchange, pos.symbol)?.maxLeverage;
|
|
333
335
|
const ok = await this.executor.rotateShort({
|
|
334
336
|
symbol: pos.symbol, perpSymbol: perpSym,
|
|
335
337
|
currentExchange: pos.perpExchange, newExchange: best.exchange,
|
|
336
338
|
currentAdapter: oldAdapter, newAdapter,
|
|
337
|
-
size: pos.size, leverage:
|
|
339
|
+
size: pos.size, leverage: cappedLeverage(p.leverage, [{ exchange: best.exchange, maxLeverage: newMaxLev }], ctx.log), log: ctx.log,
|
|
338
340
|
});
|
|
339
341
|
if (ok.ok) {
|
|
340
342
|
// The spot leg is unchanged: the state entry follows the short to its new venue.
|
|
@@ -385,7 +387,7 @@ export class SpotPerpArbStrategy {
|
|
|
385
387
|
continue;
|
|
386
388
|
if (this.quarantined(base, ctx))
|
|
387
389
|
continue;
|
|
388
|
-
const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange, allowed);
|
|
390
|
+
const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange, allowed, ctx.log);
|
|
389
391
|
for (const cand of candidates) {
|
|
390
392
|
if (cand.score.annualized < p.min_rate || cand.score.consistency < p.min_consistency)
|
|
391
393
|
continue;
|
|
@@ -499,7 +501,7 @@ export class SpotPerpArbStrategy {
|
|
|
499
501
|
return byBase;
|
|
500
502
|
}
|
|
501
503
|
/** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
|
|
502
|
-
async findPerpCandidates(symbol, adapters, ratesByExchange, allowedPerpVenues) {
|
|
504
|
+
async findPerpCandidates(symbol, adapters, ratesByExchange, allowedPerpVenues, log) {
|
|
503
505
|
const candidates = [];
|
|
504
506
|
for (const [exName, adapter] of adapters) {
|
|
505
507
|
if (!allowedPerpVenues.has(exName))
|
|
@@ -508,8 +510,20 @@ export class SpotPerpArbStrategy {
|
|
|
508
510
|
const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
|
|
509
511
|
if (!ri || ri.rate <= 0)
|
|
510
512
|
continue;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
+
// A failed history read is not "too little history": say so, then try the next symbol form;
|
|
514
|
+
// a venue with no readable form is left out this cycle.
|
|
515
|
+
let score = null;
|
|
516
|
+
for (const sym of perpSym === symbol ? [perpSym] : [perpSym, symbol]) {
|
|
517
|
+
try {
|
|
518
|
+
score = await this.scoreFundingLocal(adapter, sym, exName);
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
log(` [SPA] ${sym} on ${exName}: funding history unreadable — not a candidate this cycle: ${e instanceof Error ? e.message : String(e)}`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (score)
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
513
527
|
if (!score)
|
|
514
528
|
continue;
|
|
515
529
|
candidates.push({ exchange: exName, rate: ri.rate, score });
|
|
@@ -567,8 +581,7 @@ export class SpotPerpArbStrategy {
|
|
|
567
581
|
const perpSymbol = getPerpSymbol(symbol, perpExchange);
|
|
568
582
|
const rateInfo = findRate(ratesByExchange, perpExchange, perpSymbol)
|
|
569
583
|
?? findRate(ratesByExchange, perpExchange, symbol);
|
|
570
|
-
const
|
|
571
|
-
const leverage = Math.min(p.leverage, perpMaxLev);
|
|
584
|
+
const leverage = cappedLeverage(p.leverage, [{ exchange: perpExchange, maxLeverage: rateInfo?.maxLeverage }], ctx.log);
|
|
572
585
|
// Pre-entry: verify fundingHours (aster lazy bootstrap may not have run yet)
|
|
573
586
|
if (perpExchange === "aster" && rateInfo && "getFundingHours" in perpAdapter) {
|
|
574
587
|
const actualFh = await perpAdapter.getFundingHours(symbol);
|
|
@@ -589,11 +602,17 @@ export class SpotPerpArbStrategy {
|
|
|
589
602
|
const maxTotalPositionUsd = riskConfig?.max_position_usd ?? Infinity;
|
|
590
603
|
const positions = ctx.state.get("spaPositions");
|
|
591
604
|
let currentTotalUsd = 0;
|
|
592
|
-
|
|
605
|
+
// No cap (Infinity): exposure does not gate the entry.
|
|
606
|
+
for (const pos of Number.isFinite(maxTotalPositionUsd) ? positions : []) {
|
|
593
607
|
const ri = findRate(ratesByExchange, pos.perpExchange, getPerpSymbol(pos.symbol, pos.perpExchange))
|
|
594
608
|
?? findRate(ratesByExchange, pos.perpExchange, pos.symbol);
|
|
595
|
-
|
|
596
|
-
|
|
609
|
+
// Unknown price of a held position → exposure unknown: no new entry this cycle (a $0
|
|
610
|
+
// valuation would understate exposure and let the max_position_usd cap be bypassed).
|
|
611
|
+
if (ri === undefined || !(ri.price > 0)) {
|
|
612
|
+
ctx.log(` [SPA] Skip ${symbol}: ${pos.symbol} ${pos.perpExchange} price unknown this cycle — total exposure unknown, no new entries`);
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
currentTotalUsd += ri.price * parseFloat(pos.size);
|
|
597
616
|
}
|
|
598
617
|
const remainingBudget = maxTotalPositionUsd - currentTotalUsd;
|
|
599
618
|
if (remainingBudget < 10) {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export interface ExchangeBalance {
|
|
2
2
|
exchange: string;
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** null = the balance read failed (`error` says why) — shown "unavailable", never $0. */
|
|
4
|
+
equity: string | null;
|
|
5
|
+
available: string | null;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
/** A venue whose display read (positions / spot balances) failed this tick. */
|
|
9
|
+
export interface VenueReadError {
|
|
10
|
+
exchange: string;
|
|
11
|
+
error: string;
|
|
5
12
|
}
|
|
6
13
|
export interface BotTuiState {
|
|
7
14
|
phase: string;
|
|
@@ -29,6 +36,10 @@ export interface BotTuiState {
|
|
|
29
36
|
strategyState: Record<string, unknown>;
|
|
30
37
|
tick: number;
|
|
31
38
|
exchangeBalances?: ExchangeBalance[];
|
|
39
|
+
/** Venues whose positions could not be read: their positions are unknown, not empty. */
|
|
40
|
+
positionErrors?: VenueReadError[];
|
|
41
|
+
/** Open orders read failure (null = read): the orders are unknown, not empty. */
|
|
42
|
+
openOrdersError?: string | null;
|
|
32
43
|
}
|
|
33
44
|
export interface Position {
|
|
34
45
|
symbol: string;
|