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.
Files changed (66) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +22 -26
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
  9. package/dist/bot/strategies/funding-arb-utils.js +82 -19
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +48 -29
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/commands/account.js +2 -2
  19. package/dist/commands/arb/index.d.ts +20 -0
  20. package/dist/commands/arb/index.js +121 -36
  21. package/dist/commands/arb-auto.d.ts +36 -0
  22. package/dist/commands/arb-auto.js +202 -76
  23. package/dist/commands/arb.js +51 -35
  24. package/dist/commands/bot.d.ts +6 -0
  25. package/dist/commands/bot.js +55 -45
  26. package/dist/commands/funds-onchain.js +6 -5
  27. package/dist/commands/funds.js +18 -5
  28. package/dist/commands/history.js +135 -53
  29. package/dist/commands/market.js +2 -2
  30. package/dist/commands/portfolio.d.ts +5 -0
  31. package/dist/commands/portfolio.js +58 -20
  32. package/dist/commands/rebalance.js +46 -10
  33. package/dist/commands/settings.js +2 -1
  34. package/dist/commands/trade.js +8 -4
  35. package/dist/commands/wallet.js +1 -1
  36. package/dist/cross-chain-margin.d.ts +29 -2
  37. package/dist/cross-chain-margin.js +30 -4
  38. package/dist/dashboard/server.d.ts +45 -8
  39. package/dist/dashboard/server.js +113 -42
  40. package/dist/dashboard/ui.js +47 -28
  41. package/dist/dashboard/ws-feeds.d.ts +25 -3
  42. package/dist/dashboard/ws-feeds.js +219 -82
  43. package/dist/dex-asset-map.d.ts +23 -4
  44. package/dist/dex-asset-map.js +39 -13
  45. package/dist/exchanges/arcus/adapter.js +33 -6
  46. package/dist/exchanges/arcus/public.d.ts +6 -0
  47. package/dist/exchanges/arcus/public.js +12 -4
  48. package/dist/exchanges/aster.d.ts +24 -7
  49. package/dist/exchanges/aster.js +178 -93
  50. package/dist/exchanges/hyperliquid.d.ts +30 -4
  51. package/dist/exchanges/hyperliquid.js +159 -55
  52. package/dist/exchanges/interface.d.ts +3 -2
  53. package/dist/exchanges/interface.js +3 -2
  54. package/dist/exchanges/lighter.d.ts +1 -1
  55. package/dist/exchanges/lighter.js +11 -9
  56. package/dist/exchanges/ondo/funds.d.ts +0 -1
  57. package/dist/exchanges/pacifica.d.ts +7 -0
  58. package/dist/exchanges/pacifica.js +52 -26
  59. package/dist/rebalance.d.ts +20 -1
  60. package/dist/rebalance.js +31 -5
  61. package/dist/strategies/funding-arb.js +38 -7
  62. package/dist/trade-validator.d.ts +1 -1
  63. package/dist/trade-validator.js +7 -2
  64. package/package.json +1 -1
  65. package/skills/perp-cli/SKILL.md +2 -2
  66. 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
- /** Score a symbol's funding rate using history. Returns null if insufficient data. */
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
- try {
177
- const fH = venueHours !== null ? venueHours : await marketFundingHours(adapter, exchangeName, symbol);
178
- if (fH === null)
179
- return null; // per-symbol basis unknown — the hourly rate would be a guess
180
- const history = await adapter.getFundingHistory(symbol, periods);
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
- /** Position size: min of sizeUsd (if set) and maxCapitalPct% of total equity */
58
- _cachedEquity = 0;
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 fixedSize > 0 ? fixedSize : 100; // fallback
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
- // Skip unavailable exchange
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
- || priceCache.get(`${pos.shortExchange}:${pos.symbol}`) || 0;
157
- if (price > 0) {
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: $${this.getSizeUsd().toFixed(2)}/pos, active: ${surviving.length}`);
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 = this.getSizeUsd();
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
- /** Resolve cached price for a symbol on a given exchange */
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}`) ?? 0;
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
- || this.getCachedPrice(ctx, opp.shortExchange, opp.symbol);
473
- if (price <= 0)
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
- const notionalUsd = this.getSizeUsd();
476
- const size = this.formatSize(notionalUsd / price, opp.symbol);
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
- actions.push({
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 sum = payments.filter(fp => fp.time > oneDayAgo).reduce((s, fp) => s + parseFloat(fp.payment), 0);
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 { /* not supported */ }
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
- let total = 0;
185
- for (const [name, a] of adapters) {
186
- try {
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 { /* non-critical */ }
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 { /* non-critical */ }
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 ?? 1;
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: Math.min(p.leverage, newMaxLev), log: ctx.log,
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
- const score = await this.scoreFundingLocal(adapter, perpSym, exName)
512
- ?? await this.scoreFundingLocal(adapter, symbol, exName);
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 perpMaxLev = rateInfo?.maxLeverage ?? 1;
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
- for (const pos of positions) {
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
- const price = ri?.price ?? 0;
596
- currentTotalUsd += price * parseFloat(pos.size);
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
- equity: string;
4
- available: string;
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;