perp-cli 0.7.50 → 0.9.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Spot-Perp Executor — handles ALL exchange interactions for spot-perp arbitrage.
3
+ *
4
+ * Stateless except for spot adapter cache. Never makes trading decisions.
5
+ * Strategy calls executor methods; executor handles error recovery / rollback.
6
+ */
7
+ import { logExecution } from "../../execution-log.js";
8
+ import { invalidateCache } from "../../cache.js";
9
+ import { checkSpotPerpLiquidity } from "../../liquidity.js";
10
+ import { computeSpotPerpMatchedSize } from "../../arb-sizing.js";
11
+ import { getSpotAdapter as getSpotAdapterUtil, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, matchSymbol, getPriceEstimate, } from "./funding-arb-utils.js";
12
+ // ── Executor ──
13
+ export class SpotPerpExecutor {
14
+ _spotAdapterCache = new Map();
15
+ /** Get or create cached spot adapter. */
16
+ async getSpotAdapter(name, adapter) {
17
+ const cached = this._spotAdapterCache.get(name);
18
+ if (cached)
19
+ return cached;
20
+ const spot = await getSpotAdapterUtil(name, adapter);
21
+ if (spot)
22
+ this._spotAdapterCache.set(name, spot);
23
+ return spot;
24
+ }
25
+ /** Transfer USDC: perp -> spot account. */
26
+ async transferToSpot(spotAdapter, exchange, amount) {
27
+ await transferUsdcToSpot(spotAdapter, exchange, amount);
28
+ }
29
+ /** Transfer USDC: spot -> perp account. */
30
+ async transferToPerp(spotAdapter, exchange, amount) {
31
+ await transferUsdcToPerp(spotAdapter, exchange, amount);
32
+ }
33
+ /** Returns true if a reduceOnly rejection indicates the position is already gone. */
34
+ isPositionGone(msg) {
35
+ return msg.includes("ReduceOnly") || msg.includes("reduceOnly") || msg.includes("-2022");
36
+ }
37
+ /**
38
+ * Full spot-perp entry flow:
39
+ * 1. Check liquidity
40
+ * 2. Compute matched size
41
+ * 3. Set leverage on perp
42
+ * 4. Transfer USDC to spot (only the shortfall, track actual transferred)
43
+ * 5. Buy spot
44
+ * 6. Short perp
45
+ * 7. Verify positions
46
+ * On failure: rollback all completed steps.
47
+ */
48
+ async openPosition(params) {
49
+ const { symbol, perpSymbol, spotExchange, perpExchange, spotAdapter, perpAdapter, sizeUsd, leverage, log } = params;
50
+ try {
51
+ // Set leverage
52
+ try {
53
+ await perpAdapter.setLeverage(perpSymbol, leverage, "cross");
54
+ }
55
+ catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ log(` [SPA] setLeverage failed: ${msg}`);
58
+ return { success: false, error: `setLeverage: ${msg}` };
59
+ }
60
+ // Price estimate
61
+ const price = await getPriceEstimate(perpAdapter, perpSymbol, symbol);
62
+ if (price <= 0)
63
+ return { success: false, error: "price <= 0" };
64
+ // Liquidity check
65
+ const liq = await checkSpotPerpLiquidity(spotAdapter, perpAdapter, symbol, perpSymbol, sizeUsd, 0.5, (msg) => log(` ${msg}`));
66
+ if (!liq.viable)
67
+ return { success: false, error: "liquidity not viable" };
68
+ // Compute matched size
69
+ const spotMarkets = await spotAdapter.getSpotMarkets();
70
+ const spotDec = spotMarkets.find(m => m.baseToken.toUpperCase() === symbol.toUpperCase())?.sizeDecimals;
71
+ const matched = computeSpotPerpMatchedSize(liq.adjustedSizeUsd, price, spotExchange, perpExchange, spotDec);
72
+ if (!matched) {
73
+ log(` [SPA] Skip ${symbol}: matched size fail`);
74
+ return { success: false, error: "matched size fail" };
75
+ }
76
+ log(` [SPA] Opening: ${matched.size} ${symbol} ($${matched.notional.toFixed(0)}) ${spotExchange}-spot<>${perpExchange}`);
77
+ // Transfer USDC to spot account — only transfer the shortfall, track actual amount
78
+ const tAmt = Math.ceil(matched.notional * 1.02);
79
+ let spotUsdcAvailable = 0;
80
+ try {
81
+ const spotBals = await spotAdapter.getSpotBalances();
82
+ const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
83
+ spotUsdcAvailable = usdcBal ? parseFloat(usdcBal.available) : 0;
84
+ }
85
+ catch { /* ignore */ }
86
+ let actualTransferred = 0;
87
+ if (spotUsdcAvailable >= tAmt) {
88
+ log(` [SPA] Spot already has $${spotUsdcAvailable.toFixed(2)} USDC, skipping transfer`);
89
+ }
90
+ else {
91
+ actualTransferred = Math.ceil(tAmt - spotUsdcAvailable);
92
+ try {
93
+ await transferUsdcToSpot(spotAdapter, spotExchange, actualTransferred);
94
+ log(` [SPA] Transferred $${actualTransferred} USDC to ${spotExchange} spot`);
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ log(` [SPA] USDC transfer to spot failed: ${msg}`);
99
+ return { success: false, error: `transfer: ${msg}` };
100
+ }
101
+ }
102
+ // Step 1: Buy spot
103
+ try {
104
+ await spotAdapter.spotMarketOrder(symbol, "buy", matched.size);
105
+ logExecution({ type: "arb_entry", exchange: `${spotExchange}-spot`, symbol, side: "buy", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "spot" } });
106
+ }
107
+ catch (err) {
108
+ const msg = err instanceof Error ? err.message : String(err);
109
+ log(` [SPA] Spot buy failed: ${msg}`);
110
+ if (actualTransferred > 0) {
111
+ try {
112
+ await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
113
+ log(` [SPA] Recovered $${actualTransferred} USDC back to perp`);
114
+ }
115
+ catch { /* best effort */ }
116
+ }
117
+ return { success: false, error: `spot buy: ${msg}` };
118
+ }
119
+ // Step 2: Short perp
120
+ try {
121
+ await perpAdapter.marketOrder(perpSymbol, "sell", matched.size);
122
+ logExecution({ type: "arb_entry", exchange: perpExchange, symbol, side: "sell", size: matched.size, notional: matched.notional, status: "success", dryRun: false, meta: { mode: "spot-perp", leg: "perp" } });
123
+ }
124
+ catch (err) {
125
+ const msg = err instanceof Error ? err.message : String(err);
126
+ log(` [SPA] Perp short failed: ${msg} -- ROLLING BACK spot`);
127
+ try {
128
+ await spotAdapter.spotMarketOrder(symbol, "sell", matched.size);
129
+ logExecution({ type: "multi_leg_rollback", exchange: `${spotExchange}-spot`, symbol, side: "sell", size: matched.size, status: "success", dryRun: false });
130
+ if (actualTransferred > 0) {
131
+ try {
132
+ await transferUsdcToPerp(spotAdapter, spotExchange, actualTransferred);
133
+ }
134
+ catch { /* best effort */ }
135
+ }
136
+ }
137
+ catch (rbErr) {
138
+ log(` [SPA] CRITICAL: Spot rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
139
+ }
140
+ return { success: false, error: `perp short: ${msg}` };
141
+ }
142
+ // Position verification
143
+ invalidateCache("acct");
144
+ try {
145
+ const perpPositions = await perpAdapter.getPositions();
146
+ const hasPerp = perpPositions.some(x => matchSymbol(x.symbol, perpSymbol) && parseFloat(x.size) > 0);
147
+ if (!hasPerp) {
148
+ log(` [SPA] WARNING: Perp position not visible (cache delay?)`);
149
+ }
150
+ }
151
+ catch (err) {
152
+ log(` [SPA] Verify error: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ return { success: true, size: matched.size, notional: matched.notional };
155
+ }
156
+ catch (err) {
157
+ const msg = err instanceof Error ? err.message : String(err);
158
+ log(` [SPA] Open failed: ${msg}`);
159
+ return { success: false, error: msg };
160
+ }
161
+ }
162
+ /**
163
+ * Full spot-perp close flow:
164
+ * 1. Close perp short FIRST (reduceOnly) — hedge stays intact if this fails
165
+ * 2. Sell spot
166
+ * 3. Transfer USDC back to perp (always, not just same-exchange)
167
+ */
168
+ async closePosition(params) {
169
+ const { symbol, spotExchange, perpExchange, spotAdapter, perpAdapter, size, log } = params;
170
+ const perpSym = getPerpSymbol(symbol, perpExchange);
171
+ try {
172
+ // Step 1: Close perp short FIRST (reduceOnly) — if this fails, hedge is still intact
173
+ try {
174
+ await perpAdapter.marketOrder(perpSym, "buy", size, { reduceOnly: true });
175
+ logExecution({ type: "arb_close", exchange: perpExchange, symbol, side: "buy", size, status: "success", dryRun: false, meta: { mode: "spot-perp" } });
176
+ }
177
+ catch (err) {
178
+ const msg = err instanceof Error ? err.message : String(err);
179
+ // ReduceOnly rejected = position already gone, proceed to sell spot
180
+ if (this.isPositionGone(msg)) {
181
+ log(` [SPA] Perp already closed (ReduceOnly rejected)`);
182
+ }
183
+ else {
184
+ log(` [SPA] Perp close failed, hedge intact: ${msg}`);
185
+ return false;
186
+ }
187
+ }
188
+ // Step 2: Sell spot
189
+ try {
190
+ await spotAdapter.spotMarketOrder(symbol, "sell", size);
191
+ }
192
+ catch (err) {
193
+ const msg = err instanceof Error ? err.message : String(err);
194
+ log(` [SPA] Spot sell failed: ${msg} -- re-opening perp hedge`);
195
+ try {
196
+ await perpAdapter.marketOrder(perpSym, "sell", size);
197
+ }
198
+ catch (rbErr) {
199
+ log(` [SPA] CRITICAL: Perp re-hedge failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
200
+ }
201
+ return false;
202
+ }
203
+ // Step 3: Transfer USDC back to perp (always — lighter/HL both need this)
204
+ try {
205
+ const spotBals = await spotAdapter.getSpotBalances();
206
+ const usdcBal = spotBals.find(b => b.token.toUpperCase().startsWith("USDC"));
207
+ const usdcAmount = usdcBal ? parseFloat(usdcBal.total) : 0;
208
+ if (usdcAmount > 1) {
209
+ await transferUsdcToPerp(spotAdapter, spotExchange, Math.floor(usdcAmount));
210
+ log(` [SPA] Transferred $${Math.floor(usdcAmount)} USDC back to perp`);
211
+ }
212
+ }
213
+ catch { /* non-fatal */ }
214
+ return true;
215
+ }
216
+ catch (err) {
217
+ log(` [SPA] Close failed: ${err instanceof Error ? err.message : String(err)}`);
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Rotate perp short to better exchange:
223
+ * 1. Set leverage on new exchange
224
+ * 2. Close current perp short (reduceOnly)
225
+ * 3. Open new perp short on new exchange
226
+ * 4. Rollback if new short fails
227
+ * Spot stays unchanged.
228
+ */
229
+ async rotateShort(params) {
230
+ const { symbol, currentExchange, newExchange, currentAdapter, newAdapter, size, leverage, log } = params;
231
+ const oldSym = getPerpSymbol(symbol, currentExchange);
232
+ const newSym = getPerpSymbol(symbol, newExchange);
233
+ // Set leverage on new exchange
234
+ try {
235
+ await newAdapter.setLeverage(newSym, leverage, "cross");
236
+ }
237
+ catch (err) {
238
+ log(` [SPA] Rotation setLeverage failed: ${err instanceof Error ? err.message : String(err)}`);
239
+ return false;
240
+ }
241
+ // Close old short
242
+ try {
243
+ await currentAdapter.marketOrder(oldSym, "buy", size, { reduceOnly: true });
244
+ logExecution({ type: "arb_close", exchange: currentExchange, symbol, side: "buy", size, status: "success", dryRun: false, meta: { rotation: true } });
245
+ }
246
+ catch (err) {
247
+ log(` [SPA] Rotation close failed: ${err instanceof Error ? err.message : String(err)}`);
248
+ return false;
249
+ }
250
+ // Open new short
251
+ try {
252
+ await newAdapter.marketOrder(newSym, "sell", size);
253
+ logExecution({ type: "arb_entry", exchange: newExchange, symbol, side: "sell", size, status: "success", dryRun: false, meta: { rotation: true } });
254
+ }
255
+ catch (err) {
256
+ const msg = err instanceof Error ? err.message : String(err);
257
+ log(` [SPA] Rotation new short failed: ${msg} -- rolling back`);
258
+ try {
259
+ await currentAdapter.marketOrder(oldSym, "sell", size);
260
+ log(` [SPA] Rotation rollback OK`);
261
+ logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size, status: "success", dryRun: false });
262
+ }
263
+ catch (rbErr) {
264
+ log(` [SPA] CRITICAL: Rotation rollback failed: ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`);
265
+ logExecution({ type: "multi_leg_rollback", exchange: currentExchange, symbol, side: "sell", size, status: "failed", error: rbErr instanceof Error ? rbErr.message : String(rbErr), dryRun: false });
266
+ }
267
+ return false;
268
+ }
269
+ return true;
270
+ }
271
+ }
@@ -209,6 +209,85 @@ export function registerBotCommands(program, getAdapter, getAdapterFor, isJson)
209
209
  const log = makeLog();
210
210
  await runBot(primaryAdapter, config, opts.jobId, log, adapters, mode);
211
211
  });
212
+ // ── bot delta-neutral ──
213
+ bot
214
+ .command("delta-neutral")
215
+ .description("Spot-long + perp-short delta-neutral funding rate farming")
216
+ .option("--exchanges <list>", "Comma-separated perp exchange names", "pacifica,lighter,hyperliquid,aster")
217
+ .option("--min-rate <pct>", "Min annualized rate to enter (%)", "10")
218
+ .option("--close-rate <pct>", "Close when avg rate drops below (%)", "2")
219
+ .option("--size <usd>", "Position size per leg ($)", "50")
220
+ .option("--max-positions <n>", "Max simultaneous positions", "5")
221
+ .option("--leverage <n>", "Perp side leverage", "3")
222
+ .option("--interval <sec>", "Check interval in seconds", "60")
223
+ .option("--max-drawdown <usd>", "Stop if drawdown exceeds ($)", "200")
224
+ .option("--background", "Run in background (tmux)")
225
+ .option("--headless", "Run without TUI dashboard")
226
+ .option("--job-id <id>", "Job ID")
227
+ .action(async (opts) => {
228
+ const exchangeNames = opts.exchanges.split(",").map(e => e.trim());
229
+ const adapters = new Map();
230
+ for (const name of exchangeNames) {
231
+ try {
232
+ adapters.set(name, await getAdapterFor(name));
233
+ }
234
+ catch {
235
+ // skip unavailable
236
+ }
237
+ }
238
+ if (adapters.size < 1) {
239
+ console.error(chalk.red("\n Need at least 1 exchange for delta-neutral.\n"));
240
+ return;
241
+ }
242
+ const primaryAdapter = adapters.values().next().value;
243
+ const config = {
244
+ name: `delta-neutral-${Date.now().toString(36)}`,
245
+ exchange: primaryAdapter.name,
246
+ symbol: "ETH",
247
+ strategy: {
248
+ type: "spot-perp-arb",
249
+ exchanges: exchangeNames,
250
+ min_rate: parseFloat(opts.minRate),
251
+ close_rate: parseFloat(opts.closeRate),
252
+ size_usd: parseFloat(opts.size),
253
+ max_positions: parseInt(opts.maxPositions),
254
+ leverage: parseInt(opts.leverage),
255
+ },
256
+ entry_conditions: [{ type: "always", value: 0 }],
257
+ exit_conditions: [],
258
+ risk: {
259
+ max_position_usd: parseFloat(opts.size) * parseInt(opts.maxPositions) * 2,
260
+ max_daily_loss: parseFloat(opts.maxDrawdown) / 2,
261
+ max_drawdown: parseFloat(opts.maxDrawdown),
262
+ pause_after_loss_sec: 300,
263
+ max_open_bots: 1,
264
+ },
265
+ monitor_interval_sec: parseInt(opts.interval),
266
+ };
267
+ if (opts.background) {
268
+ const { startJob } = await import("../jobs.js");
269
+ const cliArgs = [
270
+ `delta-neutral`,
271
+ `--min-rate`, opts.minRate, `--close-rate`, opts.closeRate,
272
+ `--size`, opts.size, `--max-positions`, opts.maxPositions,
273
+ `--leverage`, opts.leverage, `--exchanges`, opts.exchanges,
274
+ `--interval`, opts.interval, `--max-drawdown`, opts.maxDrawdown,
275
+ ];
276
+ const job = startJob({
277
+ strategy: `bot:delta-neutral`,
278
+ exchange: "multi",
279
+ params: { ...opts },
280
+ cliArgs,
281
+ });
282
+ if (isJson())
283
+ return printJson(jsonOk(job));
284
+ printBotJobStarted(config.name, job.id);
285
+ return;
286
+ }
287
+ const mode = resolveOutputMode(isJson, opts.headless);
288
+ const log = makeLog();
289
+ await runBot(primaryAdapter, config, opts.jobId, log, adapters, mode);
290
+ });
212
291
  // ── bot preset list ──
213
292
  bot
214
293
  .command("preset-list")
@@ -550,7 +629,7 @@ export function registerBotCommands(program, getAdapter, getAdapterFor, isJson)
550
629
  const log = makeLog();
551
630
  // Multi-exchange strategies need all adapters
552
631
  let extraAdapters;
553
- const multiExchangeStrategies = ["funding-auto", "funding-arb", "funding-arb-v2", "basis-arb", "hedge-agent"];
632
+ const multiExchangeStrategies = ["funding-auto", "funding-arb", "funding-arb-v2", "spot-perp-arb", "basis-arb", "hedge-agent"];
554
633
  if (multiExchangeStrategies.includes(strategyName) && getAdapterFor) {
555
634
  extraAdapters = new Map();
556
635
  for (const ex of ["pacifica", "hyperliquid", "lighter", "aster"]) {
@@ -1185,6 +1185,137 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
1185
1185
  }
1186
1186
  console.log(chalk.yellow(`\n PnL tracker stopped.\n`));
1187
1187
  });
1188
+ // === Spot Trading ===
1189
+ trade
1190
+ .command("spot-buy <symbol> <size>")
1191
+ .description("Buy spot token (HL or Lighter spot market)")
1192
+ .action(async (symbol, size) => {
1193
+ await withJsonErrors(isJson(), async () => {
1194
+ const adapter = await getAdapter();
1195
+ const exchangeName = adapter.name.toLowerCase();
1196
+ let spotAdapter;
1197
+ if (exchangeName === "hyperliquid") {
1198
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1199
+ const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1200
+ if (adapter instanceof HyperliquidAdapter) {
1201
+ spotAdapter = new HyperliquidSpotAdapter(adapter);
1202
+ }
1203
+ }
1204
+ else if (exchangeName === "lighter") {
1205
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1206
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1207
+ if (adapter instanceof LighterAdapter) {
1208
+ spotAdapter = new LighterSpotAdapter(adapter);
1209
+ }
1210
+ }
1211
+ if (!spotAdapter) {
1212
+ if (isJson())
1213
+ return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1214
+ errorAndExit(`${exchangeName} does not support spot trading`);
1215
+ return;
1216
+ }
1217
+ await spotAdapter.init();
1218
+ if (dryRunGuard("spot buy", { exchange: exchangeName, symbol: symbol.toUpperCase(), side: "buy", size }))
1219
+ return;
1220
+ const result = await spotAdapter.spotMarketOrder(symbol.toUpperCase(), "buy", size);
1221
+ logExecution({ type: "market_order", exchange: exchangeName + "-spot", symbol: symbol.toUpperCase(), side: "buy", size, status: "success", dryRun: false, meta: { spot: true, result } });
1222
+ if (isJson()) {
1223
+ printJson(jsonOk({ order: result, symbol: symbol.toUpperCase(), side: "buy", size }));
1224
+ }
1225
+ else {
1226
+ console.log(chalk.green(`\n ✓ Spot bought ${size} ${symbol.toUpperCase()} on ${exchangeName}\n`));
1227
+ }
1228
+ });
1229
+ });
1230
+ trade
1231
+ .command("spot-sell <symbol> <size>")
1232
+ .description("Sell spot token (HL or Lighter spot market)")
1233
+ .action(async (symbol, size) => {
1234
+ await withJsonErrors(isJson(), async () => {
1235
+ const adapter = await getAdapter();
1236
+ const exchangeName = adapter.name.toLowerCase();
1237
+ let spotAdapter;
1238
+ if (exchangeName === "hyperliquid") {
1239
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1240
+ const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1241
+ if (adapter instanceof HyperliquidAdapter) {
1242
+ spotAdapter = new HyperliquidSpotAdapter(adapter);
1243
+ }
1244
+ }
1245
+ else if (exchangeName === "lighter") {
1246
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1247
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1248
+ if (adapter instanceof LighterAdapter) {
1249
+ spotAdapter = new LighterSpotAdapter(adapter);
1250
+ }
1251
+ }
1252
+ if (!spotAdapter) {
1253
+ if (isJson())
1254
+ return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1255
+ errorAndExit(`${exchangeName} does not support spot trading`);
1256
+ return;
1257
+ }
1258
+ await spotAdapter.init();
1259
+ if (dryRunGuard("spot sell", { exchange: exchangeName, symbol: symbol.toUpperCase(), side: "sell", size }))
1260
+ return;
1261
+ const result = await spotAdapter.spotMarketOrder(symbol.toUpperCase(), "sell", size);
1262
+ logExecution({ type: "market_order", exchange: exchangeName + "-spot", symbol: symbol.toUpperCase(), side: "sell", size, status: "success", dryRun: false, meta: { spot: true, result } });
1263
+ if (isJson()) {
1264
+ printJson(jsonOk({ order: result, symbol: symbol.toUpperCase(), side: "sell", size }));
1265
+ }
1266
+ else {
1267
+ console.log(chalk.green(`\n ✓ Spot sold ${size} ${symbol.toUpperCase()} on ${exchangeName}\n`));
1268
+ }
1269
+ });
1270
+ });
1271
+ trade
1272
+ .command("spot-balance")
1273
+ .description("Show spot token balances (HL or Lighter)")
1274
+ .action(async () => {
1275
+ await withJsonErrors(isJson(), async () => {
1276
+ const adapter = await getAdapter();
1277
+ const exchangeName = adapter.name.toLowerCase();
1278
+ let spotAdapter;
1279
+ if (exchangeName === "hyperliquid") {
1280
+ const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
1281
+ const { HyperliquidAdapter } = await import("../exchanges/hyperliquid.js");
1282
+ if (adapter instanceof HyperliquidAdapter) {
1283
+ spotAdapter = new HyperliquidSpotAdapter(adapter);
1284
+ }
1285
+ }
1286
+ else if (exchangeName === "lighter") {
1287
+ const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
1288
+ const { LighterAdapter } = await import("../exchanges/lighter.js");
1289
+ if (adapter instanceof LighterAdapter) {
1290
+ spotAdapter = new LighterSpotAdapter(adapter);
1291
+ }
1292
+ }
1293
+ if (!spotAdapter) {
1294
+ if (isJson())
1295
+ return printJson(jsonError("UNSUPPORTED", `${exchangeName} does not support spot trading`));
1296
+ errorAndExit(`${exchangeName} does not support spot trading`);
1297
+ return;
1298
+ }
1299
+ await spotAdapter.init();
1300
+ const balances = await spotAdapter.getSpotBalances();
1301
+ const nonZero = balances.filter(b => Number(b.total) > 0);
1302
+ if (isJson()) {
1303
+ printJson(jsonOk({ exchange: exchangeName, balances: nonZero }));
1304
+ }
1305
+ else {
1306
+ console.log(chalk.cyan(`\n Spot Balances (${exchangeName}):\n`));
1307
+ if (nonZero.length === 0) {
1308
+ console.log(chalk.gray(" No spot tokens\n"));
1309
+ }
1310
+ else {
1311
+ for (const b of nonZero) {
1312
+ console.log(` ${b.token.padEnd(10)} ${b.total.padEnd(15)} available: ${b.available}`);
1313
+ }
1314
+ console.log();
1315
+ }
1316
+ }
1317
+ });
1318
+ });
1188
1319
  // ── Multi-leg orders ──────────────────────────────────────────────────
1189
1320
  if (getAdapterForExchange) {
1190
1321
  const tradeCmd = program.commands.find(c => c.name() === "trade");
@@ -28,7 +28,7 @@ export declare class AsterAdapter implements ExchangeAdapter {
28
28
  private _fromApi;
29
29
  init(): Promise<void>;
30
30
  /** Get funding interval for a symbol (lazy bootstrap — queries on first access, cached permanently). */
31
- getFundingHours(symbol: string): Promise<number>;
31
+ getFundingHours(symbol: string): Promise<number | undefined>;
32
32
  getMarkets(): Promise<ExchangeMarketInfo[]>;
33
33
  getOrderbook(symbol: string): Promise<{
34
34
  bids: [string, string][];
@@ -64,8 +64,8 @@ export class AsterAdapter {
64
64
  }
65
65
  }
66
66
  catch { /* non-critical */ }
67
- this._fundingHoursCache.set(key, 1); // default
68
- return 1;
67
+ // Don't cache a default — let strategy skip this symbol until real data is available
68
+ return undefined;
69
69
  }
70
70
  async getMarkets() {
71
71
  // Return cached result if fresh
@@ -98,9 +98,9 @@ export class AsterAdapter {
98
98
  const maxLev = Number(s.maxLeverage ?? 50);
99
99
  const lotFilter = s.filters
100
100
  ?.find(f => f.filterType === "LOT_SIZE");
101
- // Use cached funding interval if available (populated by loadFundingIntervals()),
102
- // otherwise default to 1h.
103
- const fundingHours = this._fundingHoursCache.get(this._fromApi(sym)) ?? 1;
101
+ // Use cached funding interval if available (populated by getFundingHours()).
102
+ // If not yet bootstrapped, fundingHours = undefined → strategy must skip this symbol.
103
+ const fundingHours = this._fundingHoursCache.get(this._fromApi(sym));
104
104
  return {
105
105
  symbol: this._fromApi(sym),
106
106
  markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
@@ -55,6 +55,13 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
55
55
  getPositions(): Promise<ExchangePosition[]>;
56
56
  getOpenOrders(): Promise<ExchangeOrder[]>;
57
57
  private _invalidateAccountCache;
58
+ /**
59
+ * Validate that an HL order actually filled.
60
+ * HL API returns status "ok" even for 0-fill IoC orders.
61
+ * Response shape: { status: "ok", response: { type: "order", data: { statuses: [...] } } }
62
+ * Each status is one of: { filled: { totalSz, avgPx, oid } }, { resting: { oid } }, { error: "msg" }
63
+ */
64
+ private _validateOrderFill;
58
65
  marketOrder(symbol: string, side: "buy" | "sell", size: string, opts?: {
59
66
  reduceOnly?: boolean;
60
67
  }): Promise<unknown>;
@@ -393,10 +393,32 @@ export class HyperliquidAdapter {
393
393
  }
394
394
  catch { /* ignore */ }
395
395
  }
396
+ /**
397
+ * Validate that an HL order actually filled.
398
+ * HL API returns status "ok" even for 0-fill IoC orders.
399
+ * Response shape: { status: "ok", response: { type: "order", data: { statuses: [...] } } }
400
+ * Each status is one of: { filled: { totalSz, avgPx, oid } }, { resting: { oid } }, { error: "msg" }
401
+ */
402
+ _validateOrderFill(result, symbol, side, requestedSize) {
403
+ const r = result;
404
+ const statuses = r?.response?.data?.statuses;
405
+ if (!statuses || statuses.length === 0) {
406
+ throw new Error(`Market ${side} ${symbol}: no order status in response`);
407
+ }
408
+ const st = statuses[0];
409
+ if (st.error) {
410
+ throw new Error(`Market ${side} ${symbol}: ${st.error}`);
411
+ }
412
+ const filled = st.filled;
413
+ if (!filled || Number(filled.totalSz ?? 0) === 0) {
414
+ throw new Error(`Market ${side} ${symbol}: order not filled (0 of ${requestedSize}). Check price/liquidity.`);
415
+ }
416
+ }
396
417
  async marketOrder(symbol, side, size, opts) {
397
418
  this.ensureSigner();
398
419
  if (this._dex) {
399
420
  const r = await this._dexMarketOrder(symbol, side, size);
421
+ this._validateOrderFill(r, symbol, side, size);
400
422
  await this._invalidateAccountCache();
401
423
  return r;
402
424
  }
@@ -405,6 +427,7 @@ export class HyperliquidAdapter {
405
427
  console.log = () => { };
406
428
  try {
407
429
  const result = await this.sdk.custom.marketOpen(symbol.toUpperCase(), side === "buy", parseFloat(size));
430
+ this._validateOrderFill(result, symbol, side, size);
408
431
  await this._invalidateAccountCache();
409
432
  return result;
410
433
  }
@@ -231,6 +231,26 @@ export class LighterSpotAdapter {
231
231
  orderType: 1, // MARKET
232
232
  timeInForce: 0, // IOC
233
233
  });
234
+ // Verify fill via tx endpoint
235
+ const txResult = result;
236
+ if (txResult.tx_hash) {
237
+ for (let attempt = 0; attempt < 5; attempt++) {
238
+ await new Promise(r => setTimeout(r, 500));
239
+ try {
240
+ const tx = await this._restGet("/tx", { by: "hash", value: txResult.tx_hash });
241
+ const status = Number(tx.status ?? 0);
242
+ if (status === 3)
243
+ return result; // executed
244
+ if (status > 3)
245
+ throw new Error(`Spot ${side} ${symbol}: tx rejected (status=${status})`);
246
+ }
247
+ catch (e) {
248
+ if (e instanceof Error && e.message.startsWith("Spot "))
249
+ throw e;
250
+ }
251
+ }
252
+ throw new Error(`Spot ${side} ${symbol}: tx not confirmed after 2.5s (hash=${txResult.tx_hash})`);
253
+ }
234
254
  return result;
235
255
  }
236
256
  async spotLimitOrder(symbol, side, price, size, opts) {