perp-cli 0.14.1 → 0.14.2

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 (61) 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 +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  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 +28 -20
  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/portfolio.d.ts +5 -0
  30. package/dist/commands/portfolio.js +58 -20
  31. package/dist/commands/rebalance.js +46 -10
  32. package/dist/commands/settings.js +2 -1
  33. package/dist/commands/trade.js +8 -4
  34. package/dist/commands/wallet.js +1 -1
  35. package/dist/cross-chain-margin.d.ts +29 -2
  36. package/dist/cross-chain-margin.js +30 -4
  37. package/dist/dashboard/server.d.ts +45 -8
  38. package/dist/dashboard/server.js +113 -42
  39. package/dist/dashboard/ui.js +46 -27
  40. package/dist/dashboard/ws-feeds.d.ts +25 -3
  41. package/dist/dashboard/ws-feeds.js +219 -82
  42. package/dist/dex-asset-map.d.ts +23 -4
  43. package/dist/dex-asset-map.js +39 -13
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.d.ts +6 -0
  46. package/dist/exchanges/arcus/public.js +12 -4
  47. package/dist/exchanges/aster.d.ts +19 -7
  48. package/dist/exchanges/aster.js +79 -46
  49. package/dist/exchanges/hyperliquid.d.ts +30 -0
  50. package/dist/exchanges/hyperliquid.js +159 -34
  51. package/dist/exchanges/lighter.d.ts +1 -1
  52. package/dist/exchanges/lighter.js +11 -9
  53. package/dist/exchanges/ondo/funds.d.ts +0 -1
  54. package/dist/exchanges/pacifica.d.ts +7 -0
  55. package/dist/exchanges/pacifica.js +52 -26
  56. package/dist/rebalance.d.ts +20 -1
  57. package/dist/rebalance.js +31 -5
  58. package/dist/strategies/funding-arb.js +38 -7
  59. package/package.json +1 -1
  60. package/skills/perp-cli/SKILL.md +2 -2
  61. package/skills/perp-cli/references/commands.md +5 -3
@@ -10,10 +10,11 @@ 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
- // If lotSize is provided, round to nearest multiple of lotSize (floor)
13
+ // If lotSize is provided, round to nearest multiple of lotSize (floor). The 1e-9 guard keeps a
14
+ // float quotient like 0.3 / 0.1 = 2.9999999999999996 from dropping a whole lot (as fillSizeString does).
14
15
  let roundedSize;
15
16
  if (opts?.lotSize && opts.lotSize > 0) {
16
- roundedSize = Math.floor(rawSize / opts.lotSize) * opts.lotSize;
17
+ roundedSize = Math.floor(rawSize / opts.lotSize + 1e-9) * opts.lotSize;
17
18
  }
18
19
  else {
19
20
  // Round DOWN to the least precise exchange's decimals
@@ -31,7 +32,7 @@ export function computeMatchedSize(sizeUsd, price, longExchange, shortExchange,
31
32
  // Try rounding UP instead
32
33
  let roundedUp;
33
34
  if (opts?.lotSize && opts.lotSize > 0) {
34
- roundedUp = Math.ceil(rawSize / opts.lotSize) * opts.lotSize;
35
+ roundedUp = Math.ceil(rawSize / opts.lotSize - 1e-9) * opts.lotSize;
35
36
  }
36
37
  else {
37
38
  const factor = Math.pow(10, szDecimals);
@@ -43,17 +44,38 @@ export function computeMatchedSize(sizeUsd, price, longExchange, shortExchange,
43
44
  // (b), so a ceil that still fell short of minNotional was returned and then
44
45
  // rejected by the venue — SSOT Rule #2: fail honestly with null instead.
45
46
  if (notionalUp >= minNotional && notionalUp <= sizeUsd * 1.2) {
46
- return { size: formatSize(roundedUp, szDecimals, opts?.lotSize), notional: notionalUp };
47
+ return sized(roundedUp, price, szDecimals, opts?.lotSize);
47
48
  }
48
49
  return null; // Can't meet minimum within tolerance
49
50
  }
50
- return { size: formatSize(roundedSize, szDecimals, opts?.lotSize), notional };
51
+ return sized(roundedSize, price, szDecimals, opts?.lotSize);
52
+ }
53
+ /**
54
+ * The size string sent to both venues and the notional OF THAT STRING (never of the unformatted
55
+ * float). With a lot, the lot's own decimals are used — formatting a 0.0001-lot size with the
56
+ * venue default (e.g. 2) used to round 0.0157 UP to "0.02" or a 0.004 size to "0.00". A size that
57
+ * formats to zero is not viable (null), never a zero-size order.
58
+ */
59
+ function sized(size, price, szDecimals, lotSize) {
60
+ const str = formatSize(size, szDecimals, lotSize);
61
+ const n = Number(str);
62
+ if (!(n > 0))
63
+ return null;
64
+ return { size: str, notional: n * price };
51
65
  }
52
66
  function formatSize(size, szDecimals, lotSize) {
53
- if (lotSize && lotSize >= 1)
54
- return String(Math.round(size));
67
+ if (lotSize && lotSize > 0)
68
+ return size.toFixed(decimalsOfLot(lotSize)); // whole lots (≥1) → 0 decimals; 2.5 → 1
55
69
  return size.toFixed(szDecimals);
56
70
  }
71
+ /** Decimal places of a lot size (0.0001 → 4, 0.5 → 1, 1e-7 → 7). */
72
+ function decimalsOfLot(lot) {
73
+ const s = lot.toString();
74
+ const exp = /e-(\d+)$/.exec(s);
75
+ if (exp)
76
+ return Number(exp[1]) + (s.split("e")[0].split(".")[1]?.length ?? 0);
77
+ return s.includes(".") ? s.split(".")[1].length : 0;
78
+ }
57
79
  /** Size decimal precision by exchange (conservative fallbacks — prefer explicit decimals via opts) */
58
80
  function getSizeDecimals(exchange) {
59
81
  switch (exchange.toLowerCase()) {
@@ -17,7 +17,11 @@ export interface MarketSnapshot {
17
17
  * @returns RSI value between 0 and 100, or NaN if insufficient data.
18
18
  */
19
19
  export declare function calculateRSI(closes: number[], period?: number): number;
20
- export declare function getMarketSnapshot(adapter: ExchangeAdapter, symbol: string): Promise<MarketSnapshot>;
20
+ /**
21
+ * `log` receives a line for each failed klines / orderbook read; the affected fields are NaN
22
+ * (every condition comparison on them is false), never a fabricated 0.
23
+ */
24
+ export declare function getMarketSnapshot(adapter: ExchangeAdapter, symbol: string, log?: (msg: string) => void): Promise<MarketSnapshot>;
21
25
  export declare function evaluateCondition(condition: Condition, snapshot: MarketSnapshot, context: {
22
26
  equity: number | null;
23
27
  startTime: number;
@@ -38,7 +38,11 @@ export function calculateRSI(closes, period = 14) {
38
38
  const rs = avgGain / avgLoss;
39
39
  return 100 - 100 / (1 + rs);
40
40
  }
41
- export async function getMarketSnapshot(adapter, symbol) {
41
+ /**
42
+ * `log` receives a line for each failed klines / orderbook read; the affected fields are NaN
43
+ * (every condition comparison on them is false), never a fabricated 0.
44
+ */
45
+ export async function getMarketSnapshot(adapter, symbol, log) {
42
46
  const markets = await adapter.getMarkets();
43
47
  const m = markets.find(mk => symbolMatch(mk.symbol, symbol));
44
48
  if (!m) {
@@ -50,9 +54,10 @@ export async function getMarketSnapshot(adapter, symbol) {
50
54
  const fundingRate = m.fundingRate === null ? NaN : parseFloat(m.fundingRate);
51
55
  // Unknown volume → NaN, never 0: a 0 would read as "no liquidity" to volume conditions.
52
56
  const volume24h = m.volume24h === null ? NaN : parseFloat(m.volume24h);
53
- // Try to get kline data for 24h high/low + RSI
54
- let high24h = price;
55
- let low24h = price;
57
+ // 24h high/low + RSI from klines. Unknown (failed read / no candles) → NaN: a high=low=price
58
+ // fallback would read as 0% volatility and satisfy "volatility_below" on a number nobody measured.
59
+ let high24h = NaN;
60
+ let low24h = NaN;
56
61
  let rsi = NaN;
57
62
  try {
58
63
  const now = Date.now();
@@ -72,11 +77,11 @@ export async function getMarketSnapshot(adapter, symbol) {
72
77
  rsi = calculateRSI(closes, 14);
73
78
  }
74
79
  }
75
- catch {
76
- // non-critical
80
+ catch (e) {
81
+ log?.(` WARNING: ${adapter.name} ${symbol} klines read failed — volatility / RSI unknown this tick: ${e instanceof Error ? e.message : String(e)}`);
77
82
  }
78
- // Calculate bid-ask spread from orderbook
79
- let spreadPct = 0;
83
+ // Bid-ask spread from the orderbook. Unknown (failed read / empty side) → NaN, never 0.
84
+ let spreadPct = NaN;
80
85
  try {
81
86
  const ob = await adapter.getOrderbook(symbol);
82
87
  if (ob.bids.length > 0 && ob.asks.length > 0) {
@@ -88,10 +93,10 @@ export async function getMarketSnapshot(adapter, symbol) {
88
93
  }
89
94
  }
90
95
  }
91
- catch {
92
- // non-critical
96
+ catch (e) {
97
+ log?.(` WARNING: ${adapter.name} ${symbol} orderbook read failed — spread unknown this tick: ${e instanceof Error ? e.message : String(e)}`);
93
98
  }
94
- const volatility24h = price > 0 ? ((high24h - low24h) / price) * 100 : 0;
99
+ const volatility24h = price > 0 ? ((high24h - low24h) / price) * 100 : NaN;
95
100
  return { price, high24h, low24h, volume24h, fundingRate, volatility24h, rsi, spreadPct };
96
101
  }
97
102
  export function evaluateCondition(condition, snapshot, context) {
@@ -87,3 +87,15 @@ export declare function executeActions(adapter: ExchangeAdapter, symbol: string,
87
87
  filled: number;
88
88
  }>;
89
89
  export { filledOf };
90
+ /** TUI positions of one venue; a failed read → `error` set (shown "unavailable"), never an empty list. */
91
+ export declare function fetchPositions(adapter: ExchangeAdapter, symbol: string, exchangeName?: string): Promise<{
92
+ positions: import("./tui/index.js").Position[];
93
+ error: string | null;
94
+ }>;
95
+ /** A venue whose balance read failed is listed with `error` set and null amounts, never left out. */
96
+ export declare function fetchExchangeBalances(primaryAdapter: ExchangeAdapter, extraAdapters?: Map<string, ExchangeAdapter>): Promise<import("./tui/index.js").ExchangeBalance[]>;
97
+ /** TUI open orders; a failed read → `error` set (shown "unavailable"), never an empty list. */
98
+ export declare function fetchOpenOrders(adapter: ExchangeAdapter, symbol: string): Promise<{
99
+ orders: import("./tui/index.js").OpenOrder[];
100
+ error: string | null;
101
+ }>;
@@ -336,7 +336,7 @@ isDryRun) {
336
336
  const loopStart = Date.now();
337
337
  try {
338
338
  // Get market data (enriched for strategy use)
339
- const { snapshot, unknown: dataUnknown } = await getEnrichedSnapshot(adapter, config.symbol);
339
+ const { snapshot, unknown: dataUnknown } = await getEnrichedSnapshot(adapter, config.symbol, (m) => effectiveLog(m));
340
340
  if (snapshot.price > 0)
341
341
  lastPrice = snapshot.price;
342
342
  // A strategy that reads the orderbook / klines never runs on fake (empty) data.
@@ -505,8 +505,9 @@ isDryRun) {
505
505
  }
506
506
  }
507
507
  // ── Fetch balances + emit state update for TUI / JSON ──
508
- const [positions, openOrders, exchangeBalances] = await Promise.all([
509
- extraAdapters ? fetchAllPositions(adapter, config.symbol, extraAdapters) : fetchPositions(adapter, config.symbol, adapter.name),
508
+ // Display data: a failed venue read is carried as an error (rendered "unavailable"), never as empty.
509
+ const [{ positions, errors: positionErrors }, { orders: openOrders, error: openOrdersError }, exchangeBalances] = await Promise.all([
510
+ extraAdapters ? fetchAllPositions(adapter, config.symbol, extraAdapters) : fetchPositions(adapter, config.symbol, adapter.name).then(r => ({ positions: r.positions, errors: r.error === null ? [] : [{ exchange: adapter.name, error: r.error }] })),
510
511
  fetchOpenOrders(adapter, config.symbol),
511
512
  fetchExchangeBalances(adapter, extraAdapters),
512
513
  ]);
@@ -551,7 +552,7 @@ isDryRun) {
551
552
  }
552
553
  }
553
554
  if (mode === "tui" || mode === "json") {
554
- const tuiState = buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances);
555
+ const tuiState = buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances, { positionErrors, openOrdersError });
555
556
  if (mode === "tui") {
556
557
  tuiEmitter.emitState(tuiState);
557
558
  }
@@ -882,7 +883,7 @@ export { filledOf };
882
883
  * read): the empty arrays in `snapshot` then are NOT data — the engine skips the tick of a
883
884
  * strategy that reads them (MARKET_DATA_STRATEGIES).
884
885
  */
885
- async function getEnrichedSnapshot(adapter, symbol) {
886
+ async function getEnrichedSnapshot(adapter, symbol, log) {
886
887
  // Multi-symbol strategies use "ALL" — skip market-specific data
887
888
  if (symbol.toUpperCase() === "ALL") {
888
889
  return {
@@ -894,7 +895,7 @@ async function getEnrichedSnapshot(adapter, symbol) {
894
895
  unknown: null,
895
896
  };
896
897
  }
897
- const base = await getMarketSnapshot(adapter, symbol);
898
+ const base = await getMarketSnapshot(adapter, symbol, log);
898
899
  const failed = [];
899
900
  const why = (what) => (e) => { failed.push(`${what} read failed: ${e instanceof Error ? e.message : String(e)}`); return null; };
900
901
  const [orderbook, klines] = await Promise.all([
@@ -909,11 +910,11 @@ async function getEnrichedSnapshot(adapter, symbol) {
909
910
  // ── Utils ──
910
911
  function logStatus(log, state, snapshot, phase) {
911
912
  const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
912
- const vol = snapshot.volatility24h.toFixed(1);
913
+ const vol = Number.isFinite(snapshot.volatility24h) ? `${snapshot.volatility24h.toFixed(1)}%` : "unknown";
913
914
  const fr = (snapshot.fundingRate * 100).toFixed(4);
914
915
  const eq = usdOrUnknown(state.equity);
915
916
  const dd = state.equity !== null && state.peakEquity !== null ? `$${(state.peakEquity - state.equity).toFixed(2)}` : "unknown";
916
- log(chalk.gray(` [${phase}] $${snapshot.price.toFixed(2)} | vol: ${vol}% | fr: ${fr}% | ` +
917
+ log(chalk.gray(` [${phase}] $${snapshot.price.toFixed(2)} | vol: ${vol} | fr: ${fr}% | ` +
917
918
  `eq: ${eq} | dd: ${dd} | fills: ${state.fills} | ${runtime}`));
918
919
  }
919
920
  /** "$12.34", or "unknown" for a null (unknown) amount. */
@@ -937,27 +938,29 @@ function sleep(ms) {
937
938
  return new Promise(r => setTimeout(r, ms));
938
939
  }
939
940
  // ── TUI helpers ──
940
- async function fetchPositions(adapter, symbol, exchangeName) {
941
+ /** TUI positions of one venue; a failed read → `error` set (shown "unavailable"), never an empty list. */
942
+ export async function fetchPositions(adapter, symbol, exchangeName) {
941
943
  try {
942
944
  const positions = await adapter.getPositions();
943
945
  const isAll = symbol.toUpperCase() === "ALL";
944
- return positions
945
- .filter(p => (isAll || p.symbol.toUpperCase() === symbol.toUpperCase() || p.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP") && parseFloat(p.size) !== 0)
946
- .map(p => ({
947
- symbol: p.symbol,
948
- side: p.side,
949
- size: p.size,
950
- entryPrice: p.entryPrice,
951
- markPrice: p.markPrice,
952
- unrealizedPnl: p.unrealizedPnl,
953
- exchange: exchangeName ?? adapter.name,
954
- }));
946
+ return { positions: positions
947
+ .filter(p => (isAll || p.symbol.toUpperCase() === symbol.toUpperCase() || p.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP") && parseFloat(p.size) !== 0)
948
+ .map(p => ({
949
+ symbol: p.symbol,
950
+ side: p.side,
951
+ size: p.size,
952
+ entryPrice: p.entryPrice,
953
+ markPrice: p.markPrice,
954
+ unrealizedPnl: p.unrealizedPnl,
955
+ exchange: exchangeName ?? adapter.name,
956
+ })), error: null };
955
957
  }
956
- catch {
957
- return [];
958
+ catch (e) {
959
+ return { positions: [], error: `positions read failed: ${e instanceof Error ? e.message : String(e)}` };
958
960
  }
959
961
  }
960
- async function fetchExchangeBalances(primaryAdapter, extraAdapters) {
962
+ /** A venue whose balance read failed is listed with `error` set and null amounts, never left out. */
963
+ export async function fetchExchangeBalances(primaryAdapter, extraAdapters) {
961
964
  const adapters = [[primaryAdapter.name, primaryAdapter]];
962
965
  if (extraAdapters) {
963
966
  for (const [name, a] of extraAdapters) {
@@ -971,7 +974,9 @@ async function fetchExchangeBalances(primaryAdapter, extraAdapters) {
971
974
  const bal = await a.getBalance();
972
975
  results.push({ exchange: name, equity: bal.equity, available: bal.available });
973
976
  }
974
- catch { /* skip */ }
977
+ catch (e) {
978
+ results.push({ exchange: name, equity: null, available: null, error: e instanceof Error ? e.message : String(e) });
979
+ }
975
980
  }));
976
981
  return results;
977
982
  }
@@ -986,15 +991,14 @@ async function fetchSpotPositions(adapter) {
986
991
  try {
987
992
  const spot = await getBotSpotAdapter(adapter.name, adapter);
988
993
  if (spot === null)
989
- return [];
994
+ return { positions: [], error: null };
990
995
  net = spotNetworkOf(spot);
991
996
  raw = await spot.getSpotBalances();
992
997
  markets = await spotMarketsForValuation(getVenue(adapter.name), spot, raw, net);
993
998
  }
994
999
  catch (e) {
995
- // Display-only path: surface the failure instead of hiding it, keep the TUI alive.
996
- process.stderr.write(`[warn] ${adapter.name} spot positions unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
997
- return [];
1000
+ // Display-only path: surfaced as "unavailable" in the TUI / JSON state, the TUI stays alive.
1001
+ return { positions: [], error: `spot positions read failed: ${e instanceof Error ? e.message : String(e)}` };
998
1002
  }
999
1003
  const def = getVenue(adapter.name);
1000
1004
  const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), m.markPrice]));
@@ -1025,7 +1029,7 @@ async function fetchSpotPositions(adapter) {
1025
1029
  exchange: adapter.name,
1026
1030
  });
1027
1031
  }
1028
- return out;
1032
+ return { positions: out, error: null };
1029
1033
  }
1030
1034
  async function fetchAllPositions(primaryAdapter, symbol, extraAdapters) {
1031
1035
  const adapters = [[primaryAdapter.name, primaryAdapter]];
@@ -1036,33 +1040,38 @@ async function fetchAllPositions(primaryAdapter, symbol, extraAdapters) {
1036
1040
  }
1037
1041
  }
1038
1042
  const all = [];
1043
+ const errors = [];
1039
1044
  await Promise.all(adapters.map(async ([name, a]) => {
1040
1045
  const [perpPos, spotPos] = await Promise.all([
1041
1046
  fetchPositions(a, symbol, name),
1042
1047
  fetchSpotPositions(a),
1043
1048
  ]);
1044
- all.push([...perpPos, ...spotPos]);
1049
+ all.push([...perpPos.positions, ...spotPos.positions]);
1050
+ for (const err of [perpPos.error, spotPos.error])
1051
+ if (err !== null)
1052
+ errors.push({ exchange: name, error: err });
1045
1053
  }));
1046
- return all.flat();
1054
+ return { positions: all.flat(), errors };
1047
1055
  }
1048
- async function fetchOpenOrders(adapter, symbol) {
1056
+ /** TUI open orders; a failed read → `error` set (shown "unavailable"), never an empty list. */
1057
+ export async function fetchOpenOrders(adapter, symbol) {
1049
1058
  try {
1050
1059
  const orders = await adapter.getOpenOrders();
1051
- return orders
1052
- .filter(o => o.symbol.toUpperCase() === symbol.toUpperCase() || o.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP")
1053
- .map(o => ({
1054
- orderId: o.orderId,
1055
- side: o.side,
1056
- price: o.price,
1057
- size: o.size,
1058
- type: o.type,
1059
- }));
1060
+ return { orders: orders
1061
+ .filter(o => o.symbol.toUpperCase() === symbol.toUpperCase() || o.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP")
1062
+ .map(o => ({
1063
+ orderId: o.orderId,
1064
+ side: o.side,
1065
+ price: o.price,
1066
+ size: o.size,
1067
+ type: o.type,
1068
+ })), error: null };
1060
1069
  }
1061
- catch {
1062
- return [];
1070
+ catch (e) {
1071
+ return { orders: [], error: `open orders read failed: ${e instanceof Error ? e.message : String(e)}` };
1063
1072
  }
1064
1073
  }
1065
- function buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances) {
1074
+ function buildTuiState(state, config, snapshot, positions, openOrders, strategyCtx, exchangeBalances, readErrors) {
1066
1075
  // Extract serializable strategy state from the Map
1067
1076
  const strategyState = {};
1068
1077
  for (const [k, v] of strategyCtx.state) {
@@ -1099,5 +1108,7 @@ function buildTuiState(state, config, snapshot, positions, openOrders, strategyC
1099
1108
  strategyState,
1100
1109
  tick: strategyCtx.tick,
1101
1110
  exchangeBalances,
1111
+ positionErrors: readErrors?.positionErrors ?? [],
1112
+ openOrdersError: readErrors?.openOrdersError ?? null,
1102
1113
  };
1103
1114
  }
@@ -76,8 +76,8 @@ function buildMarketMessage(snapshot, symbol) {
76
76
  `Symbol: ${symbol}`,
77
77
  `Price: ${snapshot.price.toFixed(4)}`,
78
78
  `Funding Rate: ${(snapshot.fundingRate * 100).toFixed(4)}%`,
79
- `24h Volatility: ${snapshot.volatility24h.toFixed(2)}%`,
80
- `24h Volume: ${snapshot.volume24h.toFixed(2)}`,
79
+ `24h Volatility: ${Number.isFinite(snapshot.volatility24h) ? `${snapshot.volatility24h.toFixed(2)}%` : "unknown"}`,
80
+ `24h Volume: ${Number.isFinite(snapshot.volume24h) ? snapshot.volume24h.toFixed(2) : "unknown"}`,
81
81
  `Open Interest: ${snapshot.openInterest}`,
82
82
  ];
83
83
  const bestBid = snapshot.orderbook.bids[0];
@@ -7,7 +7,7 @@ import { logExecution, pruneExecutionLog } from "../../execution-log.js";
7
7
  import { annualizeHourlyRate } from "../../funding/normalize.js";
8
8
  import { getBotSpotAdapter, nonQuoteSpotBalances, readSpotQuoteAvailableFresh, spotNetworkOf, spotTokenBase, sweepSpotQuoteToPerp } from "../../exchanges/spot-factory.js";
9
9
  import { getVenue } from "../../exchanges/venue.js";
10
- import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, getPriceEstimate, fetchRates, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols } from "./funding-arb-utils.js";
10
+ import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, getPriceEstimate, fetchRates, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
11
11
  import { fillSizeString, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../../exchanges/spot-result.js";
12
12
  import { floorToStep, lotStepOf } from "../../exchanges/precision.js";
13
13
  import { isQuarantined, quarantineKey, quarantineSymbol } from "../quarantine.js";
@@ -70,6 +70,9 @@ export class FundingArbStrategy {
70
70
  ctx.state.set("arbOpenPositions", []);
71
71
  ctx.state.set("arbPositions", 0);
72
72
  ctx.state.set("fundingIncome", 0);
73
+ // Payments up to this instant are covered by the startup recovery (last 24 h); the periodic
74
+ // check books only payments newer than it (collectFundingSince high-water mark).
75
+ ctx.state.set("fundingStartTime", Date.now());
73
76
  ctx.state.set("fundingLastCheck", Date.now());
74
77
  ctx.state.set("fundingHistory", []);
75
78
  ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | close < ${params.close_spread}% | size $${params.size_usd}`);
@@ -99,14 +102,17 @@ export class FundingArbStrategy {
99
102
  try {
100
103
  const payments = await a.getFundingPayments(200);
101
104
  const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
102
- const recent = payments.filter(p => p.time > oneDayAgo);
105
+ const startedAt = ctx.state.get("fundingStartTime");
106
+ const recent = payments.filter(p => p.time > oneDayAgo && p.time <= startedAt);
103
107
  const sum = recent.reduce((s, p) => s + parseFloat(p.payment), 0);
104
108
  if (Math.abs(sum) > 0.001) {
105
109
  historicalFunding += sum;
106
110
  ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} historical funding`);
107
111
  }
108
112
  }
109
- catch { /* not all adapters support this */ }
113
+ catch (e) {
114
+ ctx.log(` [FUND] ${name}: funding history unavailable — historical funding excludes it: ${e instanceof Error ? e.message : String(e)}`);
115
+ }
110
116
  }
111
117
  if (Math.abs(historicalFunding) > 0.001) {
112
118
  ctx.state.set("fundingIncome", historicalFunding);
@@ -385,25 +391,15 @@ export class FundingArbStrategy {
385
391
  const lastCheck = ctx.state.get("fundingLastCheck");
386
392
  if (Date.now() - lastCheck > 5 * 60 * 1000) {
387
393
  try {
388
- let totalFunding = 0;
389
- for (const [name, a] of adapters) {
390
- try {
391
- const payments = await a.getFundingPayments(50);
392
- const startTime = ctx.state.get("fundingLastCheck");
393
- const recent = payments.filter(p => p.time > startTime - 5 * 60 * 1000);
394
- const sum = recent.reduce((s, p) => s + parseFloat(p.payment), 0);
395
- totalFunding += sum;
396
- if (Math.abs(sum) > 0.001) {
397
- ctx.log(` [FUND] ${name}: $${sum.toFixed(4)} funding received`);
398
- }
399
- }
400
- catch { /* not all adapters support this */ }
401
- }
402
- const prev = ctx.state.get("fundingIncome");
403
- ctx.state.set("fundingIncome", prev + totalFunding);
394
+ const since = ctx.state.get("fundingLastCheckByVenue") ?? {};
395
+ const { total } = await collectFundingSince(adapters, since, ctx.state.get("fundingStartTime"), ctx.log);
396
+ ctx.state.set("fundingLastCheckByVenue", since);
397
+ ctx.state.set("fundingIncome", ctx.state.get("fundingIncome") + total);
404
398
  ctx.state.set("fundingLastCheck", Date.now());
405
399
  }
406
- catch { /* non-critical */ }
400
+ catch (e) {
401
+ ctx.log(` [FUND] funding check failed: ${e instanceof Error ? e.message : String(e)}`);
402
+ }
407
403
  }
408
404
  // ── Update strategy state for TUI display ──
409
405
  const arbPosForDisplay = openPositions.map(p => {
@@ -91,3 +91,22 @@ export declare function fetchRates(adapter: ExchangeAdapter, exchangeName: strin
91
91
  * cancelled when the bot traded no symbol.
92
92
  */
93
93
  export declare function cancelBotSymbols(adapters: Map<string, ExchangeAdapter>, symbols: ReadonlySet<string>, tag: string, log: (msg: string) => void): Promise<void>;
94
+ /**
95
+ * Books settled funding not yet booked (shared by the funding-arb and spot-perp bots).
96
+ * `since[venue]` is a per-venue HIGH-WATER MARK of the newest payment time already booked
97
+ * (initially `defaultStart`, the bot's start — recovery booked the 24 h before it): only payments
98
+ * strictly newer are summed, so consecutive reads never count a payment twice. A venue whose read
99
+ * fails keeps its mark and is re-read next time; the others are booked — one failure never drops
100
+ * or blocks the rest, and a failed read is never counted as 0. When a catching-up read returns a
101
+ * full page whose oldest row is still newer than the mark, older payments may be beyond the page
102
+ * and a warning is logged. `since` is mutated in place.
103
+ */
104
+ export declare function collectFundingSince(adapters: ReadonlyMap<string, {
105
+ getFundingPayments(limit?: number): Promise<Array<{
106
+ time: number;
107
+ payment: string;
108
+ }>>;
109
+ }>, since: Record<string, number>, defaultStart: number, log: (msg: string) => void): Promise<{
110
+ total: number;
111
+ failed: string[];
112
+ }>;
@@ -249,3 +249,47 @@ export async function cancelBotSymbols(adapters, symbols, tag, log) {
249
249
  }
250
250
  log(` ${tag} Cancel requested for ${[...symbols].join(", ")} on ${[...adapters.keys()].join(", ")}`);
251
251
  }
252
+ /** Venues whose funding history is CAPABILITY_UNSUPPORTED, already reported once (collectFundingSince). */
253
+ const _fundingUnsupportedLogged = new Set();
254
+ /**
255
+ * Books settled funding not yet booked (shared by the funding-arb and spot-perp bots).
256
+ * `since[venue]` is a per-venue HIGH-WATER MARK of the newest payment time already booked
257
+ * (initially `defaultStart`, the bot's start — recovery booked the 24 h before it): only payments
258
+ * strictly newer are summed, so consecutive reads never count a payment twice. A venue whose read
259
+ * fails keeps its mark and is re-read next time; the others are booked — one failure never drops
260
+ * or blocks the rest, and a failed read is never counted as 0. When a catching-up read returns a
261
+ * full page whose oldest row is still newer than the mark, older payments may be beyond the page
262
+ * and a warning is logged. `since` is mutated in place.
263
+ */
264
+ export async function collectFundingSince(adapters, since, defaultStart, log) {
265
+ const PAGE = 50;
266
+ let total = 0;
267
+ const failed = [];
268
+ for (const [name, a] of adapters) {
269
+ try {
270
+ const payments = await a.getFundingPayments(PAGE);
271
+ const mark = since[name] ?? defaultStart;
272
+ const fresh = payments.filter(fp => fp.time > mark);
273
+ const sum = fresh.reduce((s, fp) => s + parseFloat(fp.payment), 0);
274
+ total += sum;
275
+ since[name] = fresh.reduce((m, fp) => Math.max(m, fp.time), mark);
276
+ if (payments.length >= PAGE && fresh.length === payments.length) {
277
+ log(` [FUND] ${name}: ${PAGE} unbooked payments in one read — older ones since the last booked payment may be missing from fundingIncome`);
278
+ }
279
+ if (Math.abs(sum) > 0.001)
280
+ log(` [FUND] ${name}: $${sum.toFixed(4)} funding`);
281
+ }
282
+ catch (e) {
283
+ failed.push(name);
284
+ // A venue that does not offer funding history at all (QFEX: CAPABILITY_UNSUPPORTED) is said
285
+ // once per process; a read failure is logged every time.
286
+ const unsupported = e instanceof PerpError && e.structured.code === "CAPABILITY_UNSUPPORTED";
287
+ if (!unsupported || !_fundingUnsupportedLogged.has(name)) {
288
+ 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)}`);
289
+ }
290
+ if (unsupported)
291
+ _fundingUnsupportedLogged.add(name);
292
+ }
293
+ }
294
+ return { total, failed };
295
+ }