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
@@ -250,12 +250,12 @@ export function lighterTakerFilledBase(eventInfo, sentBaseAmount) {
250
250
  * A required open-order row field as a string (Rule #2): missing / empty / non-finite numeric →
251
251
  * EXCHANGE_ERROR, never listed as price / size "0" or status "open".
252
252
  */
253
- export function lighterRowField(o, key, venue) {
253
+ export function lighterRowField(o, key, venue, what = "open order") {
254
254
  const v = o[key];
255
255
  if ((typeof v === "string" && v.trim() !== "") || (typeof v === "number" && Number.isFinite(v)))
256
256
  return String(v);
257
- throw new PerpError("EXCHANGE_ERROR", `${venue} open order ${lighterOrderIdOf(o) || "(no id)"}: row has no ${key} (got ${JSON.stringify(v ?? null)})`, {
258
- exchange: venue, remediation: `Retry: perp -e ${venue} account orders`,
257
+ throw new PerpError("EXCHANGE_ERROR", `${venue} ${what} ${lighterOrderIdOf(o) || "(no id)"}: row has no ${key} (got ${JSON.stringify(v ?? null)})`, {
258
+ exchange: venue, remediation: `Retry: perp -e ${venue} ${what === "open order" ? "account orders" : "account history"}`,
259
259
  });
260
260
  }
261
261
  /** `iocOutcomeOf` parser shared by LighterAdapter and LighterSpotAdapter. */
@@ -1438,7 +1438,7 @@ export class LighterAdapter {
1438
1438
  size: lighterRowField(o, "initial_base_amount", this.name),
1439
1439
  filled: lighterRowField(o, "filled_base_amount", this.name),
1440
1440
  status: lighterRowField(o, "status", this.name),
1441
- type: String(o.type ?? "limit"),
1441
+ type: lighterRowField(o, "type", this.name),
1442
1442
  triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
1443
1443
  };
1444
1444
  });
@@ -2097,15 +2097,17 @@ export class LighterAdapter {
2097
2097
  if (!this._address)
2098
2098
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
2099
2099
  const orders = await this.historyRows("/accountInactiveOrders", "orders", { market_type: "perp" }, limit, true);
2100
+ // Every field is required: a missing price / size / status / type is an unreadable row, never "0" / "done" / "limit".
2101
+ const req = (o, k) => lighterRowField(o, k, this.name, "order history");
2100
2102
  return orders.map((o) => ({
2101
2103
  orderId: lighterOrderIdOf(o),
2102
2104
  symbol: this.historySymbol(o.market_index, "order history", o),
2103
2105
  side: this.sideOfIsAsk(o.is_ask, "order history", o),
2104
- price: String(o.price ?? "0"),
2105
- size: String(o.initial_base_amount ?? o.base_amount ?? ""),
2106
- filled: String(o.filled_base_amount ?? "0"),
2107
- status: String(o.status ?? "done"),
2108
- type: String(o.type ?? "limit"),
2106
+ price: req(o, "price"),
2107
+ size: req(o, "initial_base_amount"),
2108
+ filled: req(o, "filled_base_amount"),
2109
+ status: req(o, "status"),
2110
+ type: req(o, "type"),
2109
2111
  triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
2110
2112
  }));
2111
2113
  }
@@ -21,7 +21,6 @@ import { type OndoDepositChain } from "../venues/ondo.js";
21
21
  export interface OndoFundsClient {
22
22
  get<T>(path: string, query: null): Promise<T>;
23
23
  post<T>(path: string, body: unknown): Promise<T>;
24
- postNoResult(path: string, body: unknown): Promise<void>;
25
24
  }
26
25
  export declare const ONDO_DEPOSIT_CREDIT_TIMEOUT_MS = 600000;
27
26
  export declare const ONDO_DEPOSIT_CREDIT_POLL_MS = 10000;
@@ -381,6 +381,13 @@ export declare class PacificaAdapter implements ExchangeAdapter {
381
381
  * readable field → EXCHANGE_ERROR, never an empty list or a placeholder value.
382
382
  */
383
383
  getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
384
+ /**
385
+ * Recent fills, newest first (GET /trades/history; the client unwraps `{success, data}`).
386
+ * `side` is the position effect — open_long / close_short buy, open_short / close_long sell
387
+ * (live 2026-10-13: `"side":"close_short"` on a buy-to-close fill); bid / ask are accepted for
388
+ * rows that carry an order side. Anything else, a non-list or an unreadable field → EXCHANGE_ERROR.
389
+ */
384
390
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
391
+ /** Funding paid / received per position (GET /funding/history, `payout` signed). Strict like getTradeHistory. */
385
392
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
386
393
  }
@@ -332,6 +332,23 @@ function spotInfoSymbols(rows) {
332
332
  export function isPacificaStopType(type) {
333
333
  return /^(stop|take_profit|stop_loss)_/.test(type);
334
334
  }
335
+ /** Fill sides of GET /trades/history (position effect) plus the order sides bid / ask. */
336
+ const PACIFICA_FILL_BUY_SIDES = new Set(["open_long", "close_short", "bid"]);
337
+ const PACIFICA_FILL_SELL_SIDES = new Set(["open_short", "close_long", "ask"]);
338
+ /** `created_at` (ms) of a Pacifica history row; non-numeric → EXCHANGE_ERROR (never NaN). */
339
+ function pacificaTime(row, endpoint) {
340
+ const t = Number(pacificaField(row, "created_at", endpoint));
341
+ if (!Number.isFinite(t))
342
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row with a non-numeric created_at`, { exchange: "pacifica" });
343
+ return t;
344
+ }
345
+ /** A required field of a Pacifica history row as a string; missing / empty → EXCHANGE_ERROR. */
346
+ function pacificaField(row, key, endpoint) {
347
+ const v = row[key];
348
+ if ((typeof v === "string" && v !== "") || (typeof v === "number" && Number.isFinite(v)))
349
+ return String(v);
350
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row without a readable ${key}: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "pacifica" });
351
+ }
335
352
  export class PacificaAdapter {
336
353
  name = "pacifica";
337
354
  chain = "solana";
@@ -1352,12 +1369,7 @@ export class PacificaAdapter {
1352
1369
  if (!Array.isArray(rows)) {
1353
1370
  throw new PerpError("EXCHANGE_ERROR", `Pacifica /orders/history returned no order list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1354
1371
  }
1355
- const field = (o, k) => {
1356
- const v = o[k];
1357
- if ((typeof v === "string" && v !== "") || typeof v === "number")
1358
- return String(v);
1359
- throw new PerpError("EXCHANGE_ERROR", `Pacifica order-history row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "pacifica" });
1360
- };
1372
+ const field = (o, k) => pacificaField(o, k, "/orders/history");
1361
1373
  return rows.slice(0, limit).map((o) => {
1362
1374
  const side = field(o, "side");
1363
1375
  if (side !== "bid" && side !== "ask") {
@@ -1376,33 +1388,47 @@ export class PacificaAdapter {
1376
1388
  };
1377
1389
  });
1378
1390
  }
1391
+ /**
1392
+ * Recent fills, newest first (GET /trades/history; the client unwraps `{success, data}`).
1393
+ * `side` is the position effect — open_long / close_short buy, open_short / close_long sell
1394
+ * (live 2026-10-13: `"side":"close_short"` on a buy-to-close fill); bid / ask are accepted for
1395
+ * rows that carry an order side. Anything else, a non-list or an unreadable field → EXCHANGE_ERROR.
1396
+ */
1379
1397
  async getTradeHistory(limit = 30) {
1380
1398
  if (!this._hasRealKey)
1381
1399
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1382
- const raw = await this.client.getTradeHistory(this.account);
1383
- const trades = (raw.data ?? raw);
1384
- if (!Array.isArray(trades))
1385
- return [];
1386
- return trades.slice(0, limit).map((t) => ({
1387
- time: Number(t.created_at ?? 0),
1388
- symbol: String(t.symbol ?? ""),
1389
- side: String(t.side) === "bid" ? "buy" : "sell",
1390
- price: String(t.price ?? "0"),
1391
- size: String(t.amount ?? ""),
1392
- fee: String(t.fee ?? "0"),
1393
- }));
1400
+ const rows = await this.client.getTradeHistory(this.account, { limit });
1401
+ if (!Array.isArray(rows)) {
1402
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1403
+ }
1404
+ return rows.slice(0, limit).map((t) => {
1405
+ const side = pacificaField(t, "side", "/trades/history");
1406
+ const buy = PACIFICA_FILL_BUY_SIDES.has(side);
1407
+ if (!buy && !PACIFICA_FILL_SELL_SIDES.has(side)) {
1408
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history row with unknown side "${side}"`, { exchange: "pacifica" });
1409
+ }
1410
+ return {
1411
+ time: pacificaTime(t, "/trades/history"),
1412
+ symbol: pacificaField(t, "symbol", "/trades/history"),
1413
+ side: buy ? "buy" : "sell",
1414
+ price: pacificaField(t, "price", "/trades/history"),
1415
+ size: pacificaField(t, "amount", "/trades/history"),
1416
+ fee: pacificaField(t, "fee", "/trades/history"),
1417
+ };
1418
+ });
1394
1419
  }
1420
+ /** Funding paid / received per position (GET /funding/history, `payout` signed). Strict like getTradeHistory. */
1395
1421
  async getFundingPayments(limit = 200) {
1396
1422
  if (!this._hasRealKey)
1397
1423
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1398
- const raw = await this.client.getFundingAccountHistory(this.account);
1399
- const history = (raw.data ?? raw);
1400
- if (!Array.isArray(history))
1401
- return [];
1402
- return history.slice(0, limit).map((h) => ({
1403
- time: Number(h.created_at ?? 0),
1404
- symbol: String(h.symbol ?? ""),
1405
- payment: String(h.payout ?? "0"),
1424
+ const rows = await this.client.getFundingAccountHistory(this.account, { limit });
1425
+ if (!Array.isArray(rows)) {
1426
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /funding/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1427
+ }
1428
+ return rows.slice(0, limit).map((h) => ({
1429
+ time: pacificaTime(h, "/funding/history"),
1430
+ symbol: pacificaField(h, "symbol", "/funding/history"),
1431
+ payment: pacificaField(h, "payout", "/funding/history"),
1406
1432
  }));
1407
1433
  }
1408
1434
  }
@@ -28,10 +28,29 @@ export interface RebalanceMove {
28
28
  amount: number;
29
29
  reason: string;
30
30
  }
31
+ /** A venue whose balance could not be read (left out of the snapshots). */
32
+ export interface BalanceReadFailure {
33
+ exchange: string;
34
+ error: string;
35
+ }
31
36
  /**
32
- * Fetch balances from all exchanges in parallel.
37
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
38
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
33
39
  */
34
40
  export declare function fetchAllBalances(adapters: Map<string, ExchangeAdapter>): Promise<ExchangeBalanceSnapshot[]>;
41
+ /**
42
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
43
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
44
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
45
+ * is not read and is returned in `notConfigured` — not a failure.
46
+ */
47
+ export declare function fetchBalancesWithFailures(adapters: Map<string, ExchangeAdapter>): Promise<{
48
+ snapshots: ExchangeBalanceSnapshot[];
49
+ failed: BalanceReadFailure[];
50
+ notConfigured: string[];
51
+ }>;
52
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
53
+ export declare function isReadOnlyAdapter(a: ExchangeAdapter): boolean;
35
54
  /**
36
55
  * Compute a rebalancing plan to equalize available balance across exchanges.
37
56
  *
package/dist/rebalance.js CHANGED
@@ -20,10 +20,26 @@ import { EXCHANGE_TO_CHAIN } from "./bridge-engine.js";
20
20
  // Re-export for backward compat
21
21
  const EXCHANGE_CHAINS = Object.fromEntries(Object.entries(EXCHANGE_TO_CHAIN).map(([ex, chain]) => [ex, { chain }]));
22
22
  /**
23
- * Fetch balances from all exchanges in parallel.
23
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
24
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
24
25
  */
25
26
  export async function fetchAllBalances(adapters) {
26
- const entries = [...adapters.entries()];
27
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
28
+ for (const f of failed)
29
+ process.stderr.write(`[warn] ${f.exchange} balance unavailable (left out): ${f.error}\n`);
30
+ for (const n of notConfigured)
31
+ process.stderr.write(`[info] ${n} not configured (no credentials) — left out\n`);
32
+ return snapshots;
33
+ }
34
+ /**
35
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
36
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
37
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
38
+ * is not read and is returned in `notConfigured` — not a failure.
39
+ */
40
+ export async function fetchBalancesWithFailures(adapters) {
41
+ const notConfigured = [...adapters.entries()].filter(([, a]) => isReadOnlyAdapter(a)).map(([n]) => n);
42
+ const entries = [...adapters.entries()].filter(([, a]) => !isReadOnlyAdapter(a));
27
43
  const results = await Promise.allSettled(entries.map(async ([name, adapter]) => {
28
44
  const bal = await adapter.getBalance();
29
45
  // Strict policy (qa/2026-05-16): empty string is corruption, not
@@ -57,9 +73,19 @@ export async function fetchAllBalances(adapters) {
57
73
  unrealizedPnl,
58
74
  };
59
75
  }));
60
- return results
61
- .filter((r) => r.status === "fulfilled")
62
- .map((r) => r.value);
76
+ const snapshots = [];
77
+ const failed = [];
78
+ results.forEach((r, i) => {
79
+ if (r.status === "fulfilled")
80
+ snapshots.push(r.value);
81
+ else
82
+ failed.push({ exchange: entries[i][0], error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
83
+ });
84
+ return { snapshots, failed, notConfigured };
85
+ }
86
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
87
+ export function isReadOnlyAdapter(a) {
88
+ return a.isReadOnly === true;
63
89
  }
64
90
  /**
65
91
  * Compute a rebalancing plan to equalize available balance across exchanges.
@@ -4,7 +4,7 @@ import { symbolMatch, logSettledRejections } from "../utils.js";
4
4
  import { logExecution } from "../execution-log.js";
5
5
  import { updateJobState } from "../jobs.js";
6
6
  import { invalidateCache } from "../cache.js";
7
- import { fetchAllBalances, computeRebalancePlan, hasEnoughBalance, marketLeverageOf, requiredArbMargin } from "../rebalance.js";
7
+ import { fetchBalancesWithFailures, computeRebalancePlan, hasEnoughBalance, marketLeverageOf, requiredArbMargin } from "../rebalance.js";
8
8
  import { checkArbLiquidity } from "../liquidity.js";
9
9
  import { computeAnnualSpread } from "../funding.js";
10
10
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../shared-api.js";
@@ -15,6 +15,11 @@ import { isQuarantined, quarantineSymbol } from "../bot/quarantine.js";
15
15
  import { isPositionGone, matchSymbol } from "../bot/strategies/funding-arb-utils.js";
16
16
  /** Quarantine source for `perp strategy funding-arb` (src/bot/quarantine.ts). */
17
17
  const SOURCE = "funding-arb-cli";
18
+ /** The market's published size decimals on `adapter` (null = not published / market not listed). */
19
+ async function sizeDecimalsOf(adapter, symbol) {
20
+ const m = (await adapter.getMarkets()).find((x) => symbolMatch(x.symbol, symbol));
21
+ return m === undefined ? null : m.sizeDecimals;
22
+ }
18
23
  async function fetchAllRates() {
19
24
  // SSOT rule #2: each DEX is independent. allSettled keeps the comparison
20
25
  // alive when one DEX errors out, while rejection reasons get logged to
@@ -147,9 +152,17 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
147
152
  // ── Phase 2: Max drawdown check ──
148
153
  if (params.maxDrawdown && params.autoExecute && positions.length > 0) {
149
154
  try {
150
- const snapshots = await fetchAllBalances(adapters);
155
+ const { snapshots, failed } = await fetchBalancesWithFailures(adapters);
151
156
  const totalPnl = snapshots.reduce((s, e) => s + e.unrealizedPnl, 0);
152
- if (totalPnl < -params.maxDrawdown) {
157
+ // Only a failed venue that holds one of the bot's legs makes the uPnL sum unknown; a venue
158
+ // without credentials (notConfigured) cannot hold one.
159
+ const legVenues = new Set(positions.flatMap(p => [p.longExchange, p.shortExchange]));
160
+ const failedLegs = failed.filter(f => legVenues.has(f.exchange));
161
+ if (failedLegs.length > 0) {
162
+ // A partial uPnL sum is not the bot's drawdown: never close everything on it.
163
+ log(`[ARB] Drawdown check skipped — balance unavailable on ${failedLegs.map(f => `${f.exchange} (${f.error})`).join(", ")}`);
164
+ }
165
+ else if (totalPnl < -params.maxDrawdown) {
153
166
  log(`[ARB] MAX DRAWDOWN hit: uPnL $${totalPnl.toFixed(2)} < -$${params.maxDrawdown}`);
154
167
  log(`[ARB] Closing all ${positions.length} positions...`);
155
168
  const stillOpen = await closeAllPositions(positions, adapters, log);
@@ -166,7 +179,11 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
166
179
  const shouldCheckBalance = params.autoRebalance && (Date.now() - lastRebalanceCheck > 300_000); // every 5 min
167
180
  if (shouldCheckBalance || (params.autoExecute && positions.length < maxPos)) {
168
181
  try {
169
- balanceSnapshots = await fetchAllBalances(adapters);
182
+ const read = await fetchBalancesWithFailures(adapters);
183
+ balanceSnapshots = read.snapshots;
184
+ // A venue missing from the snapshots has no balance for entry checks (no entry there).
185
+ for (const f of read.failed)
186
+ log(`[ARB] ${f.exchange} balance unavailable — no entries / rebalance on it this cycle: ${f.error}`);
170
187
  lastRebalanceCheck = Date.now();
171
188
  if (params.autoRebalance) {
172
189
  const threshold = params.rebalanceThreshold ?? 100;
@@ -229,8 +246,22 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
229
246
  const liq = await checkArbLiquidity(longAdapter, shortAdapter, symbol, requestedUsd, 0.5, log);
230
247
  if (!liq.viable)
231
248
  continue;
232
- // Compute matched size (same for both legs)
233
- let matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx);
249
+ // Compute matched size (same for both legs) at each venue's published size precision —
250
+ // the per-venue defaults (HL 2 decimals) would reject small BTC legs or over-round.
251
+ let longDec;
252
+ let shortDec;
253
+ try {
254
+ [longDec, shortDec] = await Promise.all([sizeDecimalsOf(longAdapter, symbol), sizeDecimalsOf(shortAdapter, symbol)]);
255
+ }
256
+ catch (e) {
257
+ log(`[ARB] Skip ${symbol}: could not read the market size precision (${e instanceof Error ? e.message : String(e)})`);
258
+ continue;
259
+ }
260
+ if (longDec === null || shortDec === null) {
261
+ log(`[ARB] Skip ${symbol}: size precision not published on ${longDec === null ? longEx : shortEx}`);
262
+ continue;
263
+ }
264
+ let matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx, { longSizeDecimals: longDec, shortSizeDecimals: shortDec });
234
265
  if (!matched) {
235
266
  log(`[ARB] Skip ${symbol}: can't compute matched size (min notional or precision issue)`);
236
267
  continue;
@@ -266,7 +297,7 @@ export async function runFundingArb(adapters, params, jobId, log = console.log)
266
297
  // The LONG was definitely rejected for lot size (nothing placed): recompute once.
267
298
  const lotSize = res.lotSize;
268
299
  log(`[ARB] Lot size ${lotSize} on ${longEx} — recalculating (nothing was placed)`);
269
- matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx, { lotSize });
300
+ matched = computeMatchedSize(liq.adjustedSizeUsd, highest.markPrice, longEx, shortEx, { lotSize, longSizeDecimals: longDec, shortSizeDecimals: shortDec });
270
301
  if (!matched) {
271
302
  log(`[ARB] Skip ${symbol}: can't meet lot size ${lotSize}`);
272
303
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster, Lighter RH, Ondo Perps, Arcus",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -6,7 +6,7 @@ allowed-tools: "Bash(perp:*), Bash(npx -y perp-cli:*), Bash(bash scripts/preflig
6
6
  compatibility: "Requires Node.js 20 or newer and the perp-cli npm package (global install or npx). Network access required for exchange APIs and bridge providers. Mutating commands need a wallet key imported into the OWS vault via 'perp wallet set EXCHANGE' (key on stdin or hidden prompt — never argv). Skill is portable across Claude Code, Claude.ai, Cursor, Codex, and Gemini CLI — no platform-specific features."
7
7
  metadata:
8
8
  author: hypurrquant
9
- version: "0.14.1"
9
+ version: "0.14.2"
10
10
  ---
11
11
 
12
12
  # perp-cli
@@ -46,7 +46,7 @@ Also bundled under `scripts/`: `preflight.sh`, `arb-monitor.sh`, `funding-analys
46
46
  ### Step 1: Verify install + version
47
47
 
48
48
  ```bash
49
- perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.1
49
+ perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.2
50
50
  ```
51
51
 
52
52
  If it is missing or older, ask the user before installing/upgrading (`npm install -g perp-cli@latest`) — never install on your own. If `perp` is not on PATH, prefix every command with `npx -y perp-cli@latest`.
@@ -37,6 +37,7 @@ perp --json -e <EX> account margin <SYMBOL> # position margin info
37
37
  perp --json -e <EX> account pnl # realized + unrealized + funding
38
38
  perp --json -e <EX> account twap-orders # active TWAP orders
39
39
  perp --json portfolio # cross-exchange unified view
40
+ # funding24h is null (with fundingError) when a venue's funding history is unreadable; totals.fundingIncomplete = true then
40
41
  perp --json portfolio --arb # full dashboard with arb top 5
41
42
  ```
42
43
 
@@ -255,7 +256,7 @@ perp --json arb scan --gaps # cross-exchange pri
255
256
  perp --json arb scan --hip3 # HIP-3 cross-dex spreads
256
257
  perp --json arb scan --history [SYMBOL] # historical funding
257
258
  perp --json arb scan --compare <SYMBOL> # compare a symbol across exchanges
258
- perp --json arb scan --positions # position funding impact
259
+ perp --json arb scan --positions # position funding impact; actual24h null + fundingHistoryErrors when a venue's history is unreadable
259
260
  perp --json arb scan --live --interval 60 # continuous monitoring
260
261
  perp --json arb scan --top 10 --hold-days 7 --bridge-cost 0 --size 100 # cost-aware filtering
261
262
  perp --json arb scan --rates --include-unverified # also show QFEX (unverified funding; display only, never ranked)
@@ -281,6 +282,7 @@ perp --json arb close <SYM> --state-only --yes # drop a stale state
281
282
 
282
283
  # Status / history / config / rebalance
283
284
  perp --json arb status # open arb positions + PnL + funding; check unconfirmedPairs, staleStateEntries
285
+ # spot-perp entries: fundingSource actual | partial | estimate | recorded (+ fundingError) — only "actual" is complete settled funding
284
286
  perp --json arb status --period 7 --symbol BTC # filter funding lookback / symbol
285
287
  perp --json arb history # alias: arb log
286
288
  perp --json arb history --period 30 # past arb trade performance
@@ -424,9 +426,9 @@ perp --json history list # execution audit trail
424
426
  perp --json history positions # position lifetime tracking
425
427
  perp --json history prune # purge old entries
426
428
  perp --json history summary # trading performance summary (replaces former 'stats')
427
- perp --json history pnl # P&L breakdown by exchange
429
+ perp --json history pnl # P&L breakdown by exchange; errors + partial name venues whose reads failed (left out, not 0)
428
430
  perp --json history funding # funding payment aggregation
429
- perp --json history report # full performance report (summary + pnl + funding)
431
+ perp --json history report # full performance report (summary + pnl + funding); errors + partial + venuesRead
430
432
  perp --json history compare # compare strategies / accounts
431
433
  perp --json history snapshot # equity snapshot
432
434
  perp --json history track # PnL tracking helper