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.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +1 -1
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +46 -27
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +19 -7
- package/dist/exchanges/aster.js +79 -46
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- 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}
|
|
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:
|
|
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:
|
|
2105
|
-
size:
|
|
2106
|
-
filled:
|
|
2107
|
-
status:
|
|
2108
|
-
type:
|
|
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
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
return
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
return
|
|
1403
|
-
time:
|
|
1404
|
-
symbol:
|
|
1405
|
-
payment:
|
|
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
|
}
|
package/dist/rebalance.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 {
|
|
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
|
|
155
|
+
const { snapshots, failed } = await fetchBalancesWithFailures(adapters);
|
|
151
156
|
const totalPnl = snapshots.reduce((s, e) => s + e.unrealizedPnl, 0);
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|