perp-cli 0.7.50 → 0.9.1
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/bot/engine.d.ts +1 -0
- package/dist/bot/engine.js +1 -0
- package/dist/bot/strategies/funding-arb-strategy.js +2 -2
- package/dist/bot/strategies/funding-arb-utils.d.ts +49 -0
- package/dist/bot/strategies/funding-arb-utils.js +239 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.d.ts +42 -0
- package/dist/bot/strategies/funding-arb-v2-strategy.js +873 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +43 -0
- package/dist/bot/strategies/spot-perp-arb-strategy.js +547 -0
- package/dist/bot/strategies/spot-perp-executor.d.ts +81 -0
- package/dist/bot/strategies/spot-perp-executor.js +271 -0
- package/dist/commands/bot.js +80 -1
- package/dist/commands/trade.js +131 -0
- package/dist/exchanges/aster.d.ts +1 -1
- package/dist/exchanges/aster.js +5 -5
- package/dist/exchanges/hyperliquid.d.ts +7 -0
- package/dist/exchanges/hyperliquid.js +23 -0
- package/dist/exchanges/lighter-spot.js +20 -0
- package/dist/exchanges/lighter.js +45 -1
- package/dist/exchanges/pacifica.js +42 -1
- package/dist/exchanges/spot-interface.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/bot.js
CHANGED
|
@@ -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", "lighter,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"]) {
|
package/dist/commands/trade.js
CHANGED
|
@@ -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][];
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -64,8 +64,8 @@ export class AsterAdapter {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
catch { /* non-critical */ }
|
|
67
|
-
this
|
|
68
|
-
return
|
|
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
|
|
102
|
-
//
|
|
103
|
-
const fundingHours = this._fundingHoursCache.get(this._fromApi(sym))
|
|
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) {
|