perp-cli 0.14.1 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +22 -26
- package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
- package/dist/bot/strategies/funding-arb-utils.js +82 -19
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +48 -29
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- package/dist/commands/market.js +2 -2
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +1 -1
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +47 -28
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +24 -7
- package/dist/exchanges/aster.js +178 -93
- package/dist/exchanges/hyperliquid.d.ts +30 -4
- package/dist/exchanges/hyperliquid.js +159 -55
- package/dist/exchanges/interface.d.ts +3 -2
- package/dist/exchanges/interface.js +3 -2
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/dist/trade-validator.d.ts +1 -1
- package/dist/trade-validator.js +7 -2
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- package/skills/perp-cli/references/commands.md +5 -3
package/dist/arb/sizing.js
CHANGED
|
@@ -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
|
|
47
|
+
return sized(roundedUp, price, szDecimals, opts?.lotSize);
|
|
47
48
|
}
|
|
48
49
|
return null; // Can't meet minimum within tolerance
|
|
49
50
|
}
|
|
50
|
-
return
|
|
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
|
|
54
|
-
return
|
|
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()) {
|
package/dist/bot/conditions.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/bot/conditions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
let
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
let spreadPct =
|
|
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
|
-
|
|
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 :
|
|
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) {
|
package/dist/bot/engine.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/bot/engine.js
CHANGED
|
@@ -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
|
-
|
|
509
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
996
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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, cappedLeverage, 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
|
|
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 {
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 {
|
|
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 => {
|
|
@@ -641,9 +637,10 @@ export class FundingArbStrategy {
|
|
|
641
637
|
continue;
|
|
642
638
|
// Determine leverage for this symbol
|
|
643
639
|
const configLeverage = params.leverage ?? 3;
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
640
|
+
const leverage = cappedLeverage(configLeverage, [
|
|
641
|
+
{ exchange: best.longExchange, maxLeverage: findRate(ratesByExchange, best.longExchange, best.symbol)?.maxLeverage },
|
|
642
|
+
{ exchange: best.shortExchange, maxLeverage: findRate(ratesByExchange, best.shortExchange, best.symbol)?.maxLeverage },
|
|
643
|
+
], ctx.log);
|
|
647
644
|
// Set leverage on both exchanges before ordering
|
|
648
645
|
try {
|
|
649
646
|
await longAdapter.setLeverage(best.symbol, leverage, "cross");
|
|
@@ -1024,9 +1021,8 @@ export class FundingArbStrategy {
|
|
|
1024
1021
|
const configLeverage = params.leverage ?? 3;
|
|
1025
1022
|
const perpSymbolUpper = getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
|
|
1026
1023
|
const perpMaxLev = findRate(ratesByExchange, opp.shortExchange, perpSymbolUpper)?.maxLeverage
|
|
1027
|
-
?? findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage
|
|
1028
|
-
|
|
1029
|
-
const leverage = Math.min(configLeverage, perpMaxLev);
|
|
1024
|
+
?? findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage;
|
|
1025
|
+
const leverage = cappedLeverage(configLeverage, [{ exchange: opp.shortExchange, maxLeverage: perpMaxLev }], ctx.log);
|
|
1030
1026
|
// Set leverage on perp before ordering
|
|
1031
1027
|
try {
|
|
1032
1028
|
await perpAdapter.setLeverage(getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
|
|
@@ -47,6 +47,15 @@ export declare function botSpotPerpMatches(spotVenue: string, spotMarkets: reado
|
|
|
47
47
|
export declare function getPerpSymbol(baseSymbol: string, _exchangeName: string): string;
|
|
48
48
|
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP. */
|
|
49
49
|
export declare function findRate(ratesByExchange: RateMap, exchange: string, symbol: string): RateEntry | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Leverage for an entry: the configured value, capped by every leg's published max.
|
|
52
|
+
* A leg whose max is unpublished (undefined) is not a cap — the venue enforces its own
|
|
53
|
+
* limit on setLeverage / the order — and `logUnknown` names it, so it is never read as 1x.
|
|
54
|
+
*/
|
|
55
|
+
export declare function cappedLeverage(configured: number, legs: {
|
|
56
|
+
exchange: string;
|
|
57
|
+
maxLeverage: number | undefined;
|
|
58
|
+
}[], logUnknown: (msg: string) => void): number;
|
|
50
59
|
/** Match a symbol against a target, accounting for -PERP suffix variations. */
|
|
51
60
|
export declare function matchSymbol(s: string, target: string): boolean;
|
|
52
61
|
/** Get a price estimate for a symbol from the perp adapter. */
|
|
@@ -68,7 +77,11 @@ export interface FundingScore {
|
|
|
68
77
|
* ORDER_PENDING outcome is never "gone").
|
|
69
78
|
*/
|
|
70
79
|
export declare function isPositionGone(e: unknown): boolean;
|
|
71
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Score a symbol's funding rate using history. null = not enough data (fewer than 2 history
|
|
82
|
+
* points, or the per-symbol funding basis is unknown). A failed read THROWS — callers must not
|
|
83
|
+
* mistake a 429 / timeout for "history too thin".
|
|
84
|
+
*/
|
|
72
85
|
export declare function scoreFunding(adapter: ExchangeAdapter, symbol: string, exchangeName: string, periods: number, cache?: Map<string, {
|
|
73
86
|
score: FundingScore;
|
|
74
87
|
ts: number;
|
|
@@ -91,3 +104,22 @@ export declare function fetchRates(adapter: ExchangeAdapter, exchangeName: strin
|
|
|
91
104
|
* cancelled when the bot traded no symbol.
|
|
92
105
|
*/
|
|
93
106
|
export declare function cancelBotSymbols(adapters: Map<string, ExchangeAdapter>, symbols: ReadonlySet<string>, tag: string, log: (msg: string) => void): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Books settled funding not yet booked (shared by the funding-arb and spot-perp bots).
|
|
109
|
+
* `since[venue]` is a per-venue HIGH-WATER MARK of the newest payment time already booked
|
|
110
|
+
* (initially `defaultStart`, the bot's start — recovery booked the 24 h before it): only payments
|
|
111
|
+
* strictly newer are summed, so consecutive reads never count a payment twice. A venue whose read
|
|
112
|
+
* fails keeps its mark and is re-read next time; the others are booked — one failure never drops
|
|
113
|
+
* or blocks the rest, and a failed read is never counted as 0. When a catching-up read returns a
|
|
114
|
+
* full page whose oldest row is still newer than the mark, older payments may be beyond the page
|
|
115
|
+
* and a warning is logged. `since` is mutated in place.
|
|
116
|
+
*/
|
|
117
|
+
export declare function collectFundingSince(adapters: ReadonlyMap<string, {
|
|
118
|
+
getFundingPayments(limit?: number): Promise<Array<{
|
|
119
|
+
time: number;
|
|
120
|
+
payment: string;
|
|
121
|
+
}>>;
|
|
122
|
+
}>, since: Record<string, number>, defaultStart: number, log: (msg: string) => void): Promise<{
|
|
123
|
+
total: number;
|
|
124
|
+
failed: string[];
|
|
125
|
+
}>;
|