perp-cli 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/arb/sizing.d.ts +1 -0
  2. package/dist/arb/sizing.js +25 -6
  3. package/dist/arb/utils.js +1 -0
  4. package/dist/bot/conditions.js +1 -2
  5. package/dist/bot/config.d.ts +2 -1
  6. package/dist/bot/config.js +3 -1
  7. package/dist/bot/engine.js +23 -13
  8. package/dist/bot/presets.js +1 -0
  9. package/dist/bot/strategies/dca-strategy.js +8 -4
  10. package/dist/bot/strategies/funding-arb-strategy.d.ts +1 -1
  11. package/dist/bot/strategies/funding-arb-strategy.js +126 -14
  12. package/dist/bot/strategies/grid-strategy.js +56 -51
  13. package/dist/cli-spec.js +1 -1
  14. package/dist/commands/account.js +1 -1
  15. package/dist/commands/arb/index.js +12 -4
  16. package/dist/commands/arb-auto.d.ts +2 -0
  17. package/dist/commands/arb-auto.js +37 -16
  18. package/dist/commands/arb.js +25 -6
  19. package/dist/commands/bot.js +5 -6
  20. package/dist/commands/dashboard.js +1 -1
  21. package/dist/commands/history.js +1 -1
  22. package/dist/commands/init.d.ts +2 -2
  23. package/dist/commands/init.js +5 -0
  24. package/dist/commands/market.js +1 -1
  25. package/dist/commands/rebalance.js +3 -3
  26. package/dist/commands/risk.js +1 -1
  27. package/dist/commands/settings.js +1 -1
  28. package/dist/commands/wallet.js +2 -2
  29. package/dist/config.d.ts +1 -1
  30. package/dist/config.js +1 -0
  31. package/dist/constants.js +1 -0
  32. package/dist/exchanges/index.d.ts +1 -0
  33. package/dist/exchanges/index.js +1 -0
  34. package/dist/exchanges/registry.js +5 -0
  35. package/dist/funding/normalize.js +1 -0
  36. package/dist/funding/rates.d.ts +1 -1
  37. package/dist/funding/rates.js +36 -3
  38. package/dist/index.js +19 -6
  39. package/dist/strategies/funding-arb.js +28 -5
  40. package/dist/utils.js +6 -1
  41. package/package.json +1 -1
@@ -8,6 +8,7 @@ import type { ExchangeAdapter } from "../exchanges/index.js";
8
8
  export declare function computeMatchedSize(sizeUsd: number, price: number, longExchange: string, shortExchange: string, opts?: {
9
9
  longSizeDecimals?: number;
10
10
  shortSizeDecimals?: number;
11
+ lotSize?: number;
11
12
  }): {
12
13
  size: string;
13
14
  notional: number;
@@ -10,9 +10,16 @@ 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
- // Round DOWN to the least precise exchange's decimals
14
- const factor = Math.pow(10, szDecimals);
15
- const roundedSize = Math.floor(rawSize * factor) / factor;
13
+ // If lotSize is provided, round to nearest multiple of lotSize (floor)
14
+ let roundedSize;
15
+ if (opts?.lotSize && opts.lotSize > 0) {
16
+ roundedSize = Math.floor(rawSize / opts.lotSize) * opts.lotSize;
17
+ }
18
+ else {
19
+ // Round DOWN to the least precise exchange's decimals
20
+ const factor = Math.pow(10, szDecimals);
21
+ roundedSize = Math.floor(rawSize * factor) / factor;
22
+ }
16
23
  if (roundedSize <= 0)
17
24
  return null;
18
25
  // Verify notional meets minimum for both exchanges
@@ -20,15 +27,27 @@ export function computeMatchedSize(sizeUsd, price, longExchange, shortExchange,
20
27
  const minNotional = Math.max(getMinNotional(longExchange), getMinNotional(shortExchange));
21
28
  if (notional < minNotional) {
22
29
  // Try rounding UP instead
23
- const roundedUp = Math.ceil(rawSize * factor) / factor;
30
+ let roundedUp;
31
+ if (opts?.lotSize && opts.lotSize > 0) {
32
+ roundedUp = Math.ceil(rawSize / opts.lotSize) * opts.lotSize;
33
+ }
34
+ else {
35
+ const factor = Math.pow(10, szDecimals);
36
+ roundedUp = Math.ceil(rawSize * factor) / factor;
37
+ }
24
38
  const notionalUp = roundedUp * price;
25
39
  // Only round up if the increase is small (< 20% over requested)
26
40
  if (notionalUp <= sizeUsd * 1.2) {
27
- return { size: roundedUp.toFixed(szDecimals), notional: notionalUp };
41
+ return { size: formatSize(roundedUp, szDecimals, opts?.lotSize), notional: notionalUp };
28
42
  }
29
43
  return null; // Can't meet minimum
30
44
  }
31
- return { size: roundedSize.toFixed(szDecimals), notional };
45
+ return { size: formatSize(roundedSize, szDecimals, opts?.lotSize), notional };
46
+ }
47
+ function formatSize(size, szDecimals, lotSize) {
48
+ if (lotSize && lotSize >= 1)
49
+ return String(Math.round(size));
50
+ return size.toFixed(szDecimals);
32
51
  }
33
52
  /** Size decimal precision by exchange (conservative fallbacks — prefer explicit decimals via opts) */
34
53
  function getSizeDecimals(exchange) {
package/dist/arb/utils.js CHANGED
@@ -7,6 +7,7 @@ export const SETTLEMENT_SCHEDULES = {
7
7
  hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
8
8
  pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
9
9
  lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
10
+ aster: [0, 8, 16], // every 8 hours (Binance convention)
10
11
  };
11
12
  /**
12
13
  * Get the most recent settlement time for an exchange.
@@ -41,7 +41,7 @@ export async function getMarketSnapshot(adapter, symbol) {
41
41
  const markets = await adapter.getMarkets();
42
42
  const m = markets.find(mk => mk.symbol.toUpperCase() === symbol.toUpperCase());
43
43
  if (!m) {
44
- return { price: 0, high24h: 0, low24h: 0, volume24h: 0, fundingRate: 0, volatility24h: 0, rsi: NaN, spreadPct: 0 };
44
+ throw new Error(`Market "${symbol}" not found on ${adapter.name}`);
45
45
  }
46
46
  const price = parseFloat(m.markPrice);
47
47
  const fundingRate = parseFloat(m.fundingRate);
@@ -124,7 +124,6 @@ export function evaluateCondition(condition, snapshot, context) {
124
124
  const elapsed = (Date.now() - context.startTime) / 1000;
125
125
  return elapsed > val;
126
126
  }
127
- // Custom exit conditions (not standard ConditionType but used in exit logic)
128
127
  case "max_drawdown":
129
128
  return (context.peakEquity - context.equity) > val;
130
129
  default:
@@ -1,4 +1,4 @@
1
- export type ConditionType = "price_above" | "price_below" | "volatility_above" | "volatility_below" | "funding_rate_above" | "funding_rate_below" | "spread_above" | "rsi_above" | "rsi_below" | "balance_above" | "balance_below" | "time_after" | "always";
1
+ export type ConditionType = "price_above" | "price_below" | "volatility_above" | "volatility_below" | "funding_rate_above" | "funding_rate_below" | "spread_above" | "rsi_above" | "rsi_below" | "balance_above" | "balance_below" | "time_after" | "max_drawdown" | "always";
2
2
  export interface Condition {
3
3
  type: ConditionType;
4
4
  value: number | string;
@@ -31,6 +31,7 @@ export interface DCAStrategyParams {
31
31
  interval_sec: number;
32
32
  total_orders: number;
33
33
  price_limit?: number;
34
+ side?: "buy" | "sell";
34
35
  }
35
36
  export interface FundingArbStrategyParams {
36
37
  type: "funding-arb";
@@ -65,7 +65,7 @@ export function parseStrategy(type, raw) {
65
65
  range_pct: raw.range_pct !== undefined ? Number(raw.range_pct) : 3,
66
66
  upper: raw.upper !== undefined ? Number(raw.upper) : undefined,
67
67
  lower: raw.lower !== undefined ? Number(raw.lower) : undefined,
68
- rebalance: raw.rebalance !== false,
68
+ rebalance: raw.rebalance !== false && raw.rebalance !== "false",
69
69
  rebalance_cooldown: Number(raw.rebalance_cooldown ?? 60),
70
70
  leverage: raw.leverage !== undefined ? Number(raw.leverage) : undefined,
71
71
  };
@@ -76,6 +76,7 @@ export function parseStrategy(type, raw) {
76
76
  interval_sec: Number(raw.interval_sec ?? raw.interval ?? 60),
77
77
  total_orders: Number(raw.total_orders ?? raw.orders ?? 0),
78
78
  price_limit: raw.price_limit !== undefined ? Number(raw.price_limit) : undefined,
79
+ side: raw.side !== undefined ? String(raw.side) : undefined,
79
80
  };
80
81
  case "funding-arb":
81
82
  return {
@@ -149,6 +150,7 @@ export function quickDCAConfig(opts) {
149
150
  interval_sec: opts.intervalSec,
150
151
  total_orders: opts.orders,
151
152
  price_limit: opts.priceLimit,
153
+ side: opts.side ?? "buy",
152
154
  },
153
155
  entry_conditions: entryConds,
154
156
  exit_conditions: [
@@ -56,9 +56,11 @@ mode = "headless") {
56
56
  dailyStartDate: new Date().toISOString().slice(0, 10),
57
57
  fills: 0,
58
58
  totalPnl: 0,
59
+ initialEquity: 0,
59
60
  rebalanceCount: 0,
60
61
  lastRebalance: 0,
61
62
  strategyActive: false,
63
+ riskBreached: false,
62
64
  };
63
65
  // Resolve strategy from registry
64
66
  const strategyFactory = getStrategy(config.strategy.type);
@@ -84,7 +86,7 @@ mode = "headless") {
84
86
  let tuiUnmount;
85
87
  let paused = false;
86
88
  // Wrap log for TUI/JSON modes
87
- const effectiveLog = mode === "json"
89
+ let effectiveLog = mode === "json"
88
90
  ? () => { } // JSON mode suppresses console log; NDJSON emitted per tick
89
91
  : mode === "tui"
90
92
  ? (msg) => tuiEmitter.emitLog(msg)
@@ -105,7 +107,7 @@ mode = "headless") {
105
107
  }
106
108
  catch {
107
109
  // Fallback to headless if TUI fails (e.g., non-TTY)
108
- // mode stays as-is but we just use the original log
110
+ effectiveLog = log;
109
111
  }
110
112
  }
111
113
  const shutdown = () => { running = false; state.phase = "stopped"; };
@@ -130,6 +132,7 @@ mode = "headless") {
130
132
  state.equity = parseFloat(bal.equity);
131
133
  state.peakEquity = state.equity;
132
134
  state.dailyStartEquity = state.equity;
135
+ state.initialEquity = state.equity;
133
136
  effectiveLog(` Starting equity: $${state.equity.toFixed(2)}`);
134
137
  }
135
138
  catch {
@@ -162,6 +165,7 @@ mode = "headless") {
162
165
  state.dailyStartDate = today;
163
166
  }
164
167
  state.dailyPnl = state.equity - state.dailyStartEquity;
168
+ state.totalPnl = state.equity - state.initialEquity;
165
169
  }
166
170
  catch { /* non-critical */ }
167
171
  const context = {
@@ -217,6 +221,7 @@ mode = "headless") {
217
221
  ? `drawdown $${drawdown.toFixed(2)} > limit $${config.risk.max_drawdown}`
218
222
  : "exit condition met";
219
223
  effectiveLog(` Exiting: ${reason}`);
224
+ state.riskBreached = riskBreached;
220
225
  state.phase = "exiting";
221
226
  }
222
227
  else {
@@ -237,14 +242,21 @@ mode = "headless") {
237
242
  effectiveLog(` Pausing ${config.risk.pause_after_loss_sec}s after loss`);
238
243
  state.phase = "paused";
239
244
  await sleep(config.risk.pause_after_loss_sec * 1000);
245
+ // Data will be refreshed on the next loop iteration (re-fetches snapshot + equity)
240
246
  state.phase = "monitoring";
241
247
  effectiveLog(` Resuming monitoring...`);
242
248
  }
243
- else {
244
- // If exit due to risk breach, stop completely
249
+ else if (state.riskBreached) {
250
+ // Risk breach (drawdown or daily loss exceeded) — stop completely
245
251
  effectiveLog(` Bot stopped.`);
246
252
  break;
247
253
  }
254
+ else {
255
+ // Normal exit condition (not risk breach) — return to monitoring
256
+ state.phase = "monitoring";
257
+ state.riskBreached = false;
258
+ effectiveLog(` Exit condition met, returning to monitoring...`);
259
+ }
248
260
  }
249
261
  // ── Emit state update for TUI / JSON ──
250
262
  const [positions, openOrders, exchangeBalances] = await Promise.all([
@@ -354,18 +366,16 @@ async function executeActions(adapter, symbol, actions, log, dryRun = false) {
354
366
  // ── Enriched snapshot ──
355
367
  async function getEnrichedSnapshot(adapter, symbol) {
356
368
  const base = await getMarketSnapshot(adapter, symbol);
357
- const [orderbook, klines, markets] = await Promise.all([
369
+ const [orderbook, klines] = await Promise.all([
358
370
  adapter.getOrderbook(symbol).catch(() => ({ bids: [], asks: [] })),
359
371
  adapter.getKlines(symbol, "1h", Date.now() - 24 * 60 * 60 * 1000, Date.now()).catch(() => []),
360
- adapter.getMarkets().catch(() => []),
361
372
  ]);
362
- const market = markets.find(m => m.symbol.toUpperCase().includes(symbol.toUpperCase()));
363
- return { ...base, klines, orderbook, openInterest: market?.openInterest ?? "0" };
373
+ return { ...base, klines, orderbook, openInterest: "0" };
364
374
  }
365
375
  // ── Utils ──
366
- /** Count fill-producing actions (market orders and limit orders) */
376
+ /** Count fill-producing actions (all order placements) */
367
377
  function countFills(actions) {
368
- return actions.filter(a => a.type === "place_order" && a.orderType === "market").length;
378
+ return actions.filter(a => a.type === "place_order").length;
369
379
  }
370
380
  function logStatus(log, state, snapshot, phase) {
371
381
  const runtime = formatDuration(Math.floor((Date.now() - state.startTime) / 1000));
@@ -398,7 +408,7 @@ async function fetchPositions(adapter, symbol, exchangeName) {
398
408
  const positions = await adapter.getPositions();
399
409
  const isAll = symbol.toUpperCase() === "ALL";
400
410
  return positions
401
- .filter(p => (isAll || p.symbol.toUpperCase().includes(symbol.toUpperCase())) && parseFloat(p.size) !== 0)
411
+ .filter(p => (isAll || p.symbol.toUpperCase() === symbol.toUpperCase() || p.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP") && parseFloat(p.size) !== 0)
402
412
  .map(p => ({
403
413
  symbol: p.symbol,
404
414
  side: p.side,
@@ -446,7 +456,7 @@ async function fetchOpenOrders(adapter, symbol) {
446
456
  try {
447
457
  const orders = await adapter.getOpenOrders();
448
458
  return orders
449
- .filter(o => o.symbol.toUpperCase().includes(symbol.toUpperCase()))
459
+ .filter(o => o.symbol.toUpperCase() === symbol.toUpperCase() || o.symbol.toUpperCase() === symbol.toUpperCase() + "-PERP")
450
460
  .map(o => ({
451
461
  orderId: o.orderId,
452
462
  side: o.side,
@@ -487,7 +497,7 @@ function buildTuiState(state, config, snapshot, positions, openOrders, strategyC
487
497
  exchange: config.exchange,
488
498
  price: snapshot.price,
489
499
  volume24h: snapshot.volume24h ?? 0,
490
- openInterest: "0",
500
+ openInterest: snapshot.openInterest ?? "0",
491
501
  fundingRate: snapshot.fundingRate,
492
502
  volatility24h: snapshot.volatility24h,
493
503
  positions,
@@ -194,6 +194,7 @@ const dcaSeller = {
194
194
  amount: 0.01,
195
195
  interval_sec: 3600,
196
196
  total_orders: 24,
197
+ side: "sell",
197
198
  },
198
199
  entry_conditions: [{ type: "always", value: 0 }],
199
200
  exit_conditions: [],
@@ -34,19 +34,23 @@ export class DCAStrategy {
34
34
  // Check order limit
35
35
  if (params.total_orders > 0 && dcaOrdersPlaced >= params.total_orders)
36
36
  return [];
37
- // Check price limit
37
+ // Check price limit (buy: skip if price too high, sell: skip if price too low)
38
38
  if (params.price_limit) {
39
- if (snapshot.price > params.price_limit)
39
+ const side = params.side ?? "buy";
40
+ if (side === "buy" && snapshot.price > params.price_limit)
41
+ return [];
42
+ if (side === "sell" && snapshot.price < params.price_limit)
40
43
  return [];
41
44
  }
42
45
  // Place market buy order
43
46
  ctx.state.set("dcaOrdersPlaced", dcaOrdersPlaced + 1);
44
47
  ctx.state.set("dcaLastOrder", Date.now());
48
+ const side = params.side ?? "buy";
45
49
  const progress = params.total_orders > 0 ? ` (${dcaOrdersPlaced + 1}/${params.total_orders})` : "";
46
- ctx.log(` [DCA] Order #${dcaOrdersPlaced + 1}${progress}: buy ${params.amount} ${ctx.symbol} @ $${snapshot.price.toFixed(2)}`);
50
+ ctx.log(` [DCA] Order #${dcaOrdersPlaced + 1}${progress}: ${side} ${params.amount} ${ctx.symbol} @ $${snapshot.price.toFixed(2)}`);
47
51
  return [{
48
52
  type: "place_order",
49
- side: "buy",
53
+ side,
50
54
  price: "0",
51
55
  size: String(params.amount),
52
56
  orderType: "market",
@@ -27,6 +27,6 @@ export declare class FundingArbStrategy implements Strategy {
27
27
  private _config;
28
28
  init(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<void>;
29
29
  onTick(ctx: StrategyContext, _snapshot: EnrichedSnapshot): Promise<StrategyAction[]>;
30
- onStop(_ctx: StrategyContext): Promise<StrategyAction[]>;
30
+ onStop(ctx: StrategyContext): Promise<StrategyAction[]>;
31
31
  private fetchRates;
32
32
  }
@@ -1,6 +1,7 @@
1
1
  import { registerStrategy } from "../strategy-registry.js";
2
2
  import { checkArbLiquidity } from "../../liquidity.js";
3
3
  import { computeMatchedSize, reconcileArbFills } from "../../arb-sizing.js";
4
+ import { computeAnnualSpread } from "../../funding.js";
4
5
  export class FundingArbStrategy {
5
6
  name = "funding-arb";
6
7
  describe() {
@@ -23,13 +24,14 @@ export class FundingArbStrategy {
23
24
  this._config = ctx.config;
24
25
  const params = this.params;
25
26
  ctx.state.set("arbRunning", true);
27
+ ctx.state.set("arbOpenPositions", []);
26
28
  ctx.state.set("arbPositions", 0);
27
- ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | size $${params.size_usd}`);
29
+ ctx.log(` [ARB] Funding arb ready | spread >= ${params.min_spread}% | close < ${params.close_spread}% | size $${params.size_usd}`);
28
30
  ctx.log(` [ARB] Exchanges: ${params.exchanges.join(", ")}`);
29
31
  }
30
32
  async onTick(ctx, _snapshot) {
31
33
  const params = this.params;
32
- const arbPositions = ctx.state.get("arbPositions");
34
+ const openPositions = ctx.state.get("arbOpenPositions");
33
35
  // Get extra adapters from context state (set by engine)
34
36
  const extraAdapters = ctx.state.get("extraAdapters");
35
37
  // Build adapter map from primary + extras
@@ -52,7 +54,42 @@ export class FundingArbStrategy {
52
54
  map.set(r.symbol.toUpperCase(), { rate: r.rate, price: r.price });
53
55
  ratesByExchange.set(name, map);
54
56
  }
55
- // Find opportunities
57
+ // ── Check existing positions for close_spread ──
58
+ const toClose = [];
59
+ for (const pos of openPositions) {
60
+ const longRate = ratesByExchange.get(pos.longExchange)?.get(pos.symbol)?.rate;
61
+ const shortRate = ratesByExchange.get(pos.shortExchange)?.get(pos.symbol)?.rate;
62
+ if (longRate === undefined || shortRate === undefined)
63
+ continue;
64
+ const currentSpread = computeAnnualSpread(shortRate, pos.shortExchange, longRate, pos.longExchange);
65
+ if (currentSpread < params.close_spread) {
66
+ ctx.log(` [ARB] Closing ${pos.symbol}: spread ${currentSpread.toFixed(1)}% < close_spread ${params.close_spread}% (entry: ${pos.entrySpread.toFixed(1)}%)`);
67
+ const longAdapter = adapters.get(pos.longExchange);
68
+ const shortAdapter = adapters.get(pos.shortExchange);
69
+ if (longAdapter && shortAdapter) {
70
+ try {
71
+ await Promise.all([
72
+ longAdapter.marketOrder(pos.symbol, "sell", pos.size),
73
+ shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
74
+ ]);
75
+ toClose.push(pos);
76
+ ctx.log(` [ARB] Closed ${pos.symbol} position`);
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ ctx.log(` [ARB] Close failed for ${pos.symbol}: ${msg}`);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ // Remove closed positions
86
+ if (toClose.length > 0) {
87
+ const remaining = openPositions.filter(p => !toClose.includes(p));
88
+ ctx.state.set("arbOpenPositions", remaining);
89
+ ctx.state.set("arbPositions", remaining.length);
90
+ }
91
+ // ── Find new opportunities ──
92
+ const arbPositions = ctx.state.get("arbOpenPositions").length;
56
93
  const exchangeNames = [...ratesByExchange.keys()];
57
94
  const opportunities = [];
58
95
  const allSymbols = new Set();
@@ -77,8 +114,7 @@ export class FundingArbStrategy {
77
114
  }
78
115
  }
79
116
  if (minExchange && maxExchange && minExchange !== maxExchange) {
80
- const { computeAnnualSpread: cas } = await import("../../funding.js");
81
- const spread = cas(maxRate, maxExchange, minRate, minExchange);
117
+ const spread = computeAnnualSpread(maxRate, maxExchange, minRate, minExchange);
82
118
  if (spread >= params.min_spread) {
83
119
  opportunities.push({
84
120
  symbol: sym,
@@ -110,17 +146,41 @@ export class FundingArbStrategy {
110
146
  if (!liq.viable)
111
147
  return [];
112
148
  // Compute matched size
113
- const matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
149
+ let matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange);
114
150
  if (!matched) {
115
151
  ctx.log(` [ARB] Skip ${best.symbol}: can't compute matched size (min notional or precision issue)`);
116
152
  return [];
117
153
  }
118
154
  try {
119
155
  ctx.log(` [ARB] Opening: ${matched.size} ${best.symbol} on both legs ($${matched.notional.toFixed(0)}/leg, slippage ~${liq.longSlippage.toFixed(2)}%/${liq.shortSlippage.toFixed(2)}%)`);
120
- await Promise.all([
121
- longAdapter.marketOrder(best.symbol, "buy", matched.size),
122
- shortAdapter.marketOrder(best.symbol, "sell", matched.size),
123
- ]);
156
+ try {
157
+ await Promise.all([
158
+ longAdapter.marketOrder(best.symbol, "buy", matched.size),
159
+ shortAdapter.marketOrder(best.symbol, "sell", matched.size),
160
+ ]);
161
+ }
162
+ catch (orderErr) {
163
+ // Parse lot size from error and retry with corrected size
164
+ const errMsg = orderErr instanceof Error ? orderErr.message : String(orderErr);
165
+ const lotMatch = errMsg.match(/not a multiple of lot size (\d+(?:\.\d+)?)/);
166
+ if (lotMatch) {
167
+ const lotSize = parseFloat(lotMatch[1]);
168
+ ctx.log(` [ARB] Lot size ${lotSize} detected, recalculating...`);
169
+ matched = computeMatchedSize(liq.adjustedSizeUsd, price, best.longExchange, best.shortExchange, { lotSize });
170
+ if (!matched) {
171
+ ctx.log(` [ARB] Skip ${best.symbol}: can't meet lot size ${lotSize}`);
172
+ return [];
173
+ }
174
+ ctx.log(` [ARB] Retry: ${matched.size} ${best.symbol} ($${matched.notional.toFixed(0)}/leg)`);
175
+ await Promise.all([
176
+ longAdapter.marketOrder(best.symbol, "buy", matched.size),
177
+ shortAdapter.marketOrder(best.symbol, "sell", matched.size),
178
+ ]);
179
+ }
180
+ else {
181
+ throw orderErr;
182
+ }
183
+ }
124
184
  // Verify fills match, correct if needed
125
185
  try {
126
186
  const recon = await reconcileArbFills(longAdapter, shortAdapter, best.symbol, (msg) => ctx.log(` ${msg}`));
@@ -128,9 +188,22 @@ export class FundingArbStrategy {
128
188
  ctx.log(` [ARB] WARNING: fills not matched after correction attempt`);
129
189
  }
130
190
  }
131
- catch { /* non-critical */ }
132
- ctx.state.set("arbPositions", arbPositions + 1);
133
- ctx.log(` [ARB] Position opened! (${arbPositions + 1}/${params.max_positions})`);
191
+ catch (reconErr) {
192
+ const reconMsg = reconErr instanceof Error ? reconErr.message : String(reconErr);
193
+ ctx.log(` [ARB] WARNING: Fill reconciliation failed: ${reconMsg}`);
194
+ }
195
+ // Track position in array
196
+ const currentPositions = ctx.state.get("arbOpenPositions");
197
+ currentPositions.push({
198
+ symbol: best.symbol,
199
+ longExchange: best.longExchange,
200
+ shortExchange: best.shortExchange,
201
+ entrySpread: best.spread,
202
+ size: matched.size,
203
+ });
204
+ ctx.state.set("arbOpenPositions", currentPositions);
205
+ ctx.state.set("arbPositions", currentPositions.length);
206
+ ctx.log(` [ARB] Position opened! (${currentPositions.length}/${params.max_positions})`);
134
207
  // Return noop -- we already executed directly via adapters
135
208
  // (arb requires multi-adapter coordination, can't be expressed as single-adapter actions)
136
209
  return [{ type: "noop" }];
@@ -148,7 +221,46 @@ export class FundingArbStrategy {
148
221
  }
149
222
  return [];
150
223
  }
151
- async onStop(_ctx) {
224
+ async onStop(ctx) {
225
+ const openPositions = ctx.state.get("arbOpenPositions");
226
+ const extraAdapters = ctx.state.get("extraAdapters");
227
+ // Close all open arb positions
228
+ if (openPositions && openPositions.length > 0) {
229
+ const adapters = new Map();
230
+ adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
231
+ if (extraAdapters) {
232
+ for (const [name, a] of extraAdapters)
233
+ adapters.set(name, a);
234
+ }
235
+ for (const pos of openPositions) {
236
+ const longAdapter = adapters.get(pos.longExchange);
237
+ const shortAdapter = adapters.get(pos.shortExchange);
238
+ if (longAdapter && shortAdapter) {
239
+ try {
240
+ ctx.log(` [ARB] Closing ${pos.symbol} on stop (${pos.size})`);
241
+ await Promise.all([
242
+ longAdapter.marketOrder(pos.symbol, "sell", pos.size),
243
+ shortAdapter.marketOrder(pos.symbol, "buy", pos.size),
244
+ ]);
245
+ }
246
+ catch (err) {
247
+ const msg = err instanceof Error ? err.message : String(err);
248
+ ctx.log(` [ARB] Close on stop failed for ${pos.symbol}: ${msg}`);
249
+ }
250
+ }
251
+ }
252
+ ctx.state.set("arbOpenPositions", []);
253
+ ctx.state.set("arbPositions", 0);
254
+ }
255
+ // Cancel on extra adapters directly (M2: was only cancelling on primary adapter)
256
+ if (extraAdapters) {
257
+ for (const [, a] of extraAdapters) {
258
+ try {
259
+ await a.cancelAllOrders();
260
+ }
261
+ catch { /* best effort */ }
262
+ }
263
+ }
152
264
  return [{ type: "cancel_all" }];
153
265
  }
154
266
  async fetchRates(adapter, _exchangeName) {