perp-cli 0.3.14 → 0.3.16

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
2
+ import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit } from "../risk.js";
3
3
  const defaultLimits = {
4
4
  maxDrawdownUsd: 500,
5
5
  maxPositionUsd: 5000,
@@ -186,13 +186,13 @@ describe("getLiquidationDistances", () => {
186
186
  it("should assign correct status based on limits", () => {
187
187
  const limits = { ...defaultLimits, minLiquidationDistance: 30 };
188
188
  const positions = [
189
- { exchange: "a", position: makePosition("A", "long", 1, 100, 2, 0, "85") }, // 15% → critical (< 20% hard cap)
189
+ { exchange: "a", position: makePosition("A", "long", 1, 100, 2, 0, "85") }, // 15% → danger (< 30% user limit)
190
190
  { exchange: "b", position: makePosition("B", "long", 1, 100, 2, 0, "78") }, // 22% → danger (< 30% user limit)
191
191
  { exchange: "c", position: makePosition("C", "long", 1, 100, 2, 0, "65") }, // 35% → warning (< 30% * 1.5 = 45%)
192
192
  { exchange: "d", position: makePosition("D", "long", 1, 100, 2, 0, "40") }, // 60% → safe
193
193
  ];
194
194
  const distances = getLiquidationDistances(positions, limits);
195
- expect(distances.find(d => d.symbol === "A").status).toBe("critical");
195
+ expect(distances.find(d => d.symbol === "A").status).toBe("danger");
196
196
  expect(distances.find(d => d.symbol === "B").status).toBe("danger");
197
197
  expect(distances.find(d => d.symbol === "C").status).toBe("warning");
198
198
  expect(distances.find(d => d.symbol === "D").status).toBe("safe");
@@ -209,30 +209,18 @@ describe("getLiquidationDistances", () => {
209
209
  });
210
210
  });
211
211
  describe("Liquidation Distance in assessRisk", () => {
212
- it("should add critical violation when position is below hard cap (20%)", () => {
212
+ it("should add violation when position is below user limit", () => {
213
213
  const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
214
- // BTC long at $100000, liq at $90000 → 10% distance (below 20% hard cap)
214
+ // BTC long at $100000, liq at $90000 → 10% distance (below 30% user limit)
215
215
  const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 10, 0, "90000") }];
216
216
  const result = assessRisk(balances, positions, defaultLimits);
217
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(true);
218
- expect(result.level).toBe("critical");
219
- expect(result.canTrade).toBe(false);
220
- });
221
- it("should add high violation when below user limit but above hard cap", () => {
222
- const limits = { ...defaultLimits, minLiquidationDistance: 30 };
223
- const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
224
- // BTC long at $100000, liq at $75000 → 25% distance (above 20% hard cap, below 30% user limit)
225
- const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 4, 0, "75000") }];
226
- const result = assessRisk(balances, positions, limits);
227
217
  expect(result.violations.some(v => v.rule === "min_liquidation_distance")).toBe(true);
228
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(false);
229
218
  });
230
219
  it("should not add violation when distance is above user limit", () => {
231
220
  const balances = [{ exchange: "test", balance: makeBalance(10000, 8000, 2000, 0) }];
232
221
  // BTC long at $100000, liq at $50000 → 50% distance (safe)
233
222
  const positions = [{ exchange: "test", position: makePosition("BTC", "long", 0.01, 100000, 2, 0, "50000") }];
234
223
  const result = assessRisk(balances, positions, defaultLimits);
235
- expect(result.violations.some(v => v.rule === "liquidation_distance_hard_cap")).toBe(false);
236
224
  expect(result.violations.some(v => v.rule === "min_liquidation_distance")).toBe(false);
237
225
  });
238
226
  it("should report minLiquidationDistancePct in metrics", () => {
@@ -259,11 +247,6 @@ describe("Liquidation Distance in assessRisk", () => {
259
247
  expect(result.liquidationDistances[0].status).toBe("safe");
260
248
  });
261
249
  });
262
- describe("LIQUIDATION_DISTANCE_HARD_CAP", () => {
263
- it("should be 20", () => {
264
- expect(LIQUIDATION_DISTANCE_HARD_CAP).toBe(20);
265
- });
266
- });
267
250
  describe("Percentage-based Risk Limits", () => {
268
251
  it("effectiveLimit should return min of USD and pct-of-equity", () => {
269
252
  expect(effectiveLimit(500, 10, 1000)).toBe(100); // 10% of $1000 = $100 < $500
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { formatUsd, printJson, jsonOk } from "../utils.js";
3
+ import { computeExecutableSize } from "../liquidity.js";
3
4
  import { computeAnnualSpread, toHourlyRate } from "../funding.js";
4
5
  import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
5
6
  import { scanDexArb } from "../dex-asset-map.js";
@@ -729,6 +730,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
729
730
  .command("scan")
730
731
  .description("Scan current funding rate spreads")
731
732
  .option("--min <pct>", "Min annual spread to show", "10")
733
+ .option("--top <n>", "Return only top N results (for JSON output)")
732
734
  .option("--hold-days <days>", "Expected hold period for cost calc", "7")
733
735
  .option("--bridge-cost <usd>", "One-way bridge cost in USD", "0.5")
734
736
  .option("--size <usd>", "Position size per leg ($) for cost calc", "100")
@@ -742,12 +744,14 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
742
744
  const spreads = await fetchFundingSpreads();
743
745
  const filtered = spreads.filter(s => Math.abs(s.spread) >= minSpread);
744
746
  if (isJson()) {
745
- const enriched = filtered.map(s => {
747
+ let enriched = filtered.map(s => {
746
748
  const grossSpread = Math.abs(s.spread);
747
749
  const rtCost = computeRoundTripCostPct(s.longExch, s.shortExch);
748
750
  const net = computeNetSpread(grossSpread, holdDays, rtCost, bridgeCostUsd, sizeUsd);
749
- return { ...s, grossSpread, netSpread: net, estFeesPct: rtCost };
750
- });
751
+ return { symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost };
752
+ }).sort((a, b) => b.netSpread - a.netSpread);
753
+ if (opts.top)
754
+ enriched = enriched.slice(0, parseInt(opts.top));
751
755
  return printJson(jsonOk(enriched));
752
756
  }
753
757
  if (filtered.length === 0) {
@@ -783,6 +787,221 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
783
787
  console.log(chalk.gray(` * Spreads are estimates based on current rates — actual may vary`));
784
788
  console.log(chalk.gray(` Use 'perp arb auto --min-spread ${minSpread}' to auto-trade\n`));
785
789
  });
790
+ // ── arb exec ── (validate orderbook + simultaneous dual-leg entry)
791
+ arb
792
+ .command("exec")
793
+ .description("Execute arb: validate orderbook depth on both exchanges, then enter both legs simultaneously")
794
+ .argument("<symbol>", "Symbol (e.g. BTC, ETH, ICP)")
795
+ .argument("<longExch>", "Exchange to go LONG on (hl, pac, lighter)")
796
+ .argument("<shortExch>", "Exchange to go SHORT on (hl, pac, lighter)")
797
+ .argument("<sizeUsd>", "Position size per leg in USD")
798
+ .option("--max-slippage <pct>", "Max slippage % per leg", "0.5")
799
+ .option("--leverage <n>", "Set leverage before entry (both exchanges)")
800
+ .option("--isolated", "Use isolated margin mode")
801
+ .action(async (symbol, longExch, shortExch, sizeUsdStr, opts) => {
802
+ const sym = symbol.toUpperCase();
803
+ const sizeUsd = parseFloat(sizeUsdStr);
804
+ const maxSlippage = parseFloat(opts.maxSlippage);
805
+ // Resolve exchange aliases
806
+ const aliasMap = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
807
+ longExch = aliasMap[longExch.toLowerCase()] || longExch.toLowerCase();
808
+ shortExch = aliasMap[shortExch.toLowerCase()] || shortExch.toLowerCase();
809
+ if (longExch === shortExch) {
810
+ if (isJson())
811
+ return printJson(jsonOk({ error: "longExch and shortExch must be different" }));
812
+ console.log(chalk.red(" Long and short exchange must be different."));
813
+ return;
814
+ }
815
+ const longAdapter = await getAdapterForExchange(longExch);
816
+ const shortAdapter = await getAdapterForExchange(shortExch);
817
+ // 1. Set leverage if requested
818
+ if (opts.leverage) {
819
+ const lev = parseInt(opts.leverage);
820
+ const mode = opts.isolated ? "isolated" : "cross";
821
+ if (!isJson())
822
+ console.log(chalk.gray(` Setting leverage ${lev}x ${mode} on both exchanges...`));
823
+ const [longLev, shortLev] = await Promise.allSettled([
824
+ longAdapter.setLeverage(sym, lev, mode),
825
+ shortAdapter.setLeverage(sym, lev, mode),
826
+ ]);
827
+ if (longLev.status === "rejected") {
828
+ const msg = `Failed to set leverage on ${longExch}: ${longLev.reason instanceof Error ? longLev.reason.message : longLev.reason}`;
829
+ if (isJson())
830
+ return printJson(jsonOk({ error: msg }));
831
+ console.log(chalk.red(` ${msg}`));
832
+ return;
833
+ }
834
+ if (shortLev.status === "rejected") {
835
+ const msg = `Failed to set leverage on ${shortExch}: ${shortLev.reason instanceof Error ? shortLev.reason.message : shortLev.reason}`;
836
+ if (isJson())
837
+ return printJson(jsonOk({ error: msg }));
838
+ console.log(chalk.red(` ${msg}`));
839
+ return;
840
+ }
841
+ }
842
+ // 2. Fetch orderbooks simultaneously
843
+ if (!isJson())
844
+ console.log(chalk.gray(` Checking orderbook depth on both exchanges...`));
845
+ const [longBook, shortBook] = await Promise.all([
846
+ longAdapter.getOrderbook(sym),
847
+ shortAdapter.getOrderbook(sym),
848
+ ]);
849
+ // 3. Validate: long side buys from asks, short side sells into bids
850
+ const longCheck = computeExecutableSize(longBook.asks, sizeUsd, maxSlippage);
851
+ const shortCheck = computeExecutableSize(shortBook.bids, sizeUsd, maxSlippage);
852
+ const validation = {
853
+ long: { exchange: longExch, side: "buy", depthUsd: longCheck.depthUsd, canFill: longCheck.canFillFull, slippagePct: longCheck.slippagePct, maxSizeBase: longCheck.maxSize },
854
+ short: { exchange: shortExch, side: "sell", depthUsd: shortCheck.depthUsd, canFill: shortCheck.canFillFull, slippagePct: shortCheck.slippagePct, maxSizeBase: shortCheck.maxSize },
855
+ };
856
+ if (!longCheck.canFillFull || !shortCheck.canFillFull) {
857
+ // Use the smaller fillable amount
858
+ const fillableUsd = Math.min(longCheck.maxSize * longCheck.avgFillPrice, shortCheck.maxSize * shortCheck.avgFillPrice);
859
+ const result = {
860
+ error: "Insufficient orderbook depth",
861
+ requestedUsd: sizeUsd,
862
+ fillableUsd: Math.round(fillableUsd * 100) / 100,
863
+ validation,
864
+ };
865
+ if (isJson())
866
+ return printJson(jsonOk(result));
867
+ console.log(chalk.red(`\n Insufficient depth for $${sizeUsd}:`));
868
+ console.log(chalk.gray(` ${longExch} asks: $${longCheck.depthUsd.toFixed(0)} depth, ${longCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
869
+ console.log(chalk.gray(` ${shortExch} bids: $${shortCheck.depthUsd.toFixed(0)} depth, ${shortCheck.canFillFull ? "OK" : "NOT ENOUGH"}`));
870
+ console.log(chalk.yellow(` Max fillable: $${fillableUsd.toFixed(2)}\n`));
871
+ return;
872
+ }
873
+ // 4. Compute matched size in base asset (use the smaller side)
874
+ // Round down to each exchange's lot size (inferred from orderbook)
875
+ const inferDecimals = (levels) => {
876
+ for (const [, s] of levels) {
877
+ const dot = s.indexOf(".");
878
+ if (dot >= 0)
879
+ return s.length - dot - 1;
880
+ }
881
+ return 0;
882
+ };
883
+ const longDecimals = inferDecimals(longBook.asks);
884
+ const shortDecimals = inferDecimals(shortBook.bids);
885
+ const decimals = Math.min(longDecimals, shortDecimals);
886
+ const matchedBase = Math.min(longCheck.recommendedSize, shortCheck.recommendedSize);
887
+ const matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
888
+ const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
889
+ const matchedUsd = matchedBase * avgPrice;
890
+ if (!isJson()) {
891
+ console.log(chalk.cyan(`\n Arb Exec: ${sym}`));
892
+ console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
893
+ console.log(chalk.gray(` SHORT ${shortExch}: sell ${matchedSize} (~$${(matchedBase * shortCheck.avgFillPrice).toFixed(2)}, slippage ${shortCheck.slippagePct.toFixed(3)}%)`));
894
+ console.log(chalk.gray(` Executing both legs simultaneously...\n`));
895
+ }
896
+ // 5. Execute both legs simultaneously
897
+ const [longResult, shortResult] = await Promise.allSettled([
898
+ longAdapter.marketOrder(sym, "buy", matchedSize),
899
+ shortAdapter.marketOrder(sym, "sell", matchedSize),
900
+ ]);
901
+ const longOk = longResult.status === "fulfilled";
902
+ const shortOk = shortResult.status === "fulfilled";
903
+ if (longOk && shortOk) {
904
+ // 6. Verify both positions actually exist (some exchanges return success but silently reject)
905
+ const [longPositions, shortPositions] = await Promise.all([
906
+ longAdapter.getPositions().catch(() => []),
907
+ shortAdapter.getPositions().catch(() => []),
908
+ ]);
909
+ const longPos = longPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
910
+ const shortPos = shortPositions.find(p => p.symbol.toUpperCase().startsWith(sym));
911
+ if (!longPos && !shortPos) {
912
+ // Both silently rejected
913
+ const result = { status: "both_rejected", symbol: sym, size: matchedSize, message: "Both exchanges accepted order but no positions opened. Check min order size." };
914
+ if (isJson())
915
+ return printJson(jsonOk(result));
916
+ console.log(chalk.red(` Both orders accepted but no positions opened. Min order size not met?\n`));
917
+ return;
918
+ }
919
+ if (!longPos || !shortPos) {
920
+ // One side silently rejected — close the other
921
+ const openSide = longPos ? "long" : "short";
922
+ const openAdapter = longPos ? longAdapter : shortAdapter;
923
+ if (!isJson())
924
+ console.log(chalk.yellow(` ${openSide} opened but other side silently rejected — closing ${openSide}...`));
925
+ try {
926
+ await openAdapter.marketOrder(sym, longPos ? "sell" : "buy", matchedSize);
927
+ }
928
+ catch { /* best effort */ }
929
+ const result = { status: "silent_reject", symbol: sym, openedSide: openSide, closedSide: longPos ? "short" : "long", message: "One leg silently rejected (likely min order size). Rolled back." };
930
+ if (isJson())
931
+ return printJson(jsonOk(result));
932
+ console.log(chalk.yellow(` Rolled back ${openSide} leg. Check exchange min order sizes.\n`));
933
+ return;
934
+ }
935
+ logExecution({
936
+ type: "arb_entry", exchange: `${longExch}+${shortExch}`,
937
+ symbol: sym, side: "entry", size: matchedSize,
938
+ status: "success", dryRun: false,
939
+ meta: { longExch, shortExch, matchedUsd, longSlippage: longCheck.slippagePct, shortSlippage: shortCheck.slippagePct },
940
+ });
941
+ const result = {
942
+ status: "filled", symbol: sym, size: matchedSize, notionalUsd: Math.round(matchedUsd * 100) / 100,
943
+ long: { exchange: longExch, side: "buy", size: longPos.size, slippagePct: longCheck.slippagePct },
944
+ short: { exchange: shortExch, side: "sell", size: shortPos.size, slippagePct: shortCheck.slippagePct },
945
+ };
946
+ if (isJson())
947
+ return printJson(jsonOk(result));
948
+ console.log(chalk.green(` FILLED: ${sym} ${matchedSize} units (~$${matchedUsd.toFixed(2)})`));
949
+ console.log(chalk.green(` LONG ${longExch} ✓ (${longPos.size})`));
950
+ console.log(chalk.green(` SHORT ${shortExch} ✓ (${shortPos.size})\n`));
951
+ }
952
+ else if (longOk !== shortOk) {
953
+ // One leg failed — rollback
954
+ const filledSide = longOk ? "long" : "short";
955
+ const failedSide = longOk ? "short" : "long";
956
+ const filledAdapter = longOk ? longAdapter : shortAdapter;
957
+ const rollbackAction = longOk ? "sell" : "buy";
958
+ const failedErr = longOk
959
+ ? shortResult.reason
960
+ : longResult.reason;
961
+ if (!isJson())
962
+ console.log(chalk.yellow(` PARTIAL: ${filledSide} OK, ${failedSide} FAILED — rolling back...`));
963
+ let rollbackOk = false;
964
+ for (let attempt = 1; attempt <= 2; attempt++) {
965
+ try {
966
+ await filledAdapter.marketOrder(sym, rollbackAction, matchedSize);
967
+ rollbackOk = true;
968
+ break;
969
+ }
970
+ catch { /* retry */ }
971
+ }
972
+ logExecution({
973
+ type: "arb_entry", exchange: `${longExch}+${shortExch}`,
974
+ symbol: sym, side: "entry", size: matchedSize,
975
+ status: "failed", dryRun: false,
976
+ error: `Partial: ${failedSide} failed (${failedErr instanceof Error ? failedErr.message : String(failedErr)}). Rollback: ${rollbackOk ? "ok" : "FAILED"}`,
977
+ });
978
+ const result = {
979
+ status: "partial_fill", symbol: sym, filledSide, failedSide,
980
+ failedError: failedErr instanceof Error ? failedErr.message : String(failedErr),
981
+ rollback: rollbackOk ? "success" : "FAILED — MANUAL CLOSE REQUIRED",
982
+ };
983
+ if (isJson())
984
+ return printJson(jsonOk(result));
985
+ console.log(rollbackOk
986
+ ? chalk.yellow(` Rollback OK — no open exposure.\n`)
987
+ : chalk.red.bold(` ROLLBACK FAILED — ${filledSide} leg still open. Close manually!\n`));
988
+ }
989
+ else {
990
+ // Both failed
991
+ const longErr = longResult.reason;
992
+ const shortErr = shortResult.reason;
993
+ const result = {
994
+ status: "both_failed", symbol: sym,
995
+ longError: longErr instanceof Error ? longErr.message : String(longErr),
996
+ shortError: shortErr instanceof Error ? shortErr.message : String(shortErr),
997
+ };
998
+ if (isJson())
999
+ return printJson(jsonOk(result));
1000
+ console.log(chalk.red(` Both legs failed:`));
1001
+ console.log(chalk.red(` LONG: ${result.longError}`));
1002
+ console.log(chalk.red(` SHORT: ${result.shortError}\n`));
1003
+ }
1004
+ });
786
1005
  // ── arb pnl ── (check arb position PnL)
787
1006
  arb
788
1007
  .command("pnl")
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, makeTable, formatUsd, formatPnl, withJsonErrors } from "../utils.js";
3
+ import { assessRisk } from "../risk.js";
3
4
  const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
4
5
  async function fetchExchangeSnapshot(name, getAdapter) {
5
6
  try {
@@ -65,6 +66,12 @@ function buildSummary(snapshots) {
65
66
  pct: totalEquity > 0 ? (Number(s.balance.equity) / totalEquity) * 100 : 0,
66
67
  }))
67
68
  .sort((a, b) => b.pct - a.pct);
69
+ // Full risk assessment (reuses same data, no extra API calls)
70
+ const riskBalances = snapshots
71
+ .filter(s => s.balance)
72
+ .map(s => ({ exchange: s.exchange, balance: s.balance }));
73
+ const riskPositions = allPositions.map(p => ({ exchange: p.exchange, position: p }));
74
+ const assessment = assessRisk(riskBalances, riskPositions);
68
75
  return {
69
76
  totalEquity,
70
77
  totalAvailable,
@@ -79,6 +86,11 @@ function buildSummary(snapshots) {
79
86
  largestPosition,
80
87
  exchangeConcentration,
81
88
  },
89
+ risk: {
90
+ level: assessment.level,
91
+ canTrade: assessment.canTrade,
92
+ violations: assessment.violations,
93
+ },
82
94
  };
83
95
  }
84
96
  export function registerPortfolioCommands(program, getAdapterForExchange, isJson) {
@@ -149,6 +161,14 @@ export function registerPortfolioCommands(program, getAdapterForExchange, isJson
149
161
  }
150
162
  // ── Risk Metrics ──
151
163
  console.log(chalk.white.bold("\n Risk Metrics"));
164
+ const levelColor = {
165
+ low: chalk.green,
166
+ medium: chalk.yellow,
167
+ high: chalk.red,
168
+ critical: chalk.bgRed.white,
169
+ }[summary.risk.level];
170
+ console.log(` Risk Level: ${levelColor(summary.risk.level.toUpperCase())}`);
171
+ console.log(` Can Trade: ${summary.risk.canTrade ? chalk.green("YES") : chalk.red("NO")}`);
152
172
  const mu = summary.riskMetrics.marginUtilization;
153
173
  const muColor = mu < 30 ? chalk.green : mu < 60 ? chalk.yellow : chalk.red;
154
174
  console.log(` Margin Utilization: ${muColor(`${mu.toFixed(1)}%`)}`);
@@ -163,6 +183,13 @@ export function registerPortfolioCommands(program, getAdapterForExchange, isJson
163
183
  console.log(` ${ec.exchange.padEnd(12)} ${bar} ${ec.pct.toFixed(1)}%`);
164
184
  }
165
185
  }
186
+ if (summary.risk.violations.length > 0) {
187
+ console.log(chalk.red.bold("\n Risk Violations"));
188
+ for (const v of summary.risk.violations) {
189
+ const sevColor = { low: chalk.green, medium: chalk.yellow, high: chalk.red, critical: chalk.bgRed.white }[v.severity];
190
+ console.log(` ${sevColor(v.severity.toUpperCase().padEnd(8))} ${v.message}`);
191
+ }
192
+ }
166
193
  console.log();
167
194
  });
168
195
  });
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { printJson, jsonOk, makeTable, formatUsd, withJsonErrors } from "../utils.js";
3
- import { loadRiskLimits, saveRiskLimits, assessRisk, getLiquidationDistances, LIQUIDATION_DISTANCE_HARD_CAP } from "../risk.js";
3
+ import { loadRiskLimits, saveRiskLimits, assessRisk, getLiquidationDistances } from "../risk.js";
4
4
  const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
5
5
  export function registerRiskCommands(program, getAdapterForExchange, isJson) {
6
6
  const risk = program.command("risk").description("Risk management and guardrails");
@@ -57,11 +57,9 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
57
57
  console.log(` Largest Position: $${formatUsd(assessment.metrics.largestPositionUsd)}`);
58
58
  console.log(` Max Leverage Used: ${assessment.metrics.maxLeverageUsed}x`);
59
59
  if (assessment.metrics.minLiquidationDistancePct >= 0) {
60
- const ldColor = assessment.metrics.minLiquidationDistancePct < LIQUIDATION_DISTANCE_HARD_CAP
61
- ? chalk.bgRed.white
62
- : assessment.metrics.minLiquidationDistancePct < assessment.limits.minLiquidationDistance
63
- ? chalk.red
64
- : chalk.green;
60
+ const ldColor = assessment.metrics.minLiquidationDistancePct < assessment.limits.minLiquidationDistance
61
+ ? chalk.red
62
+ : chalk.green;
65
63
  console.log(` Min Liq Distance: ${ldColor(`${assessment.metrics.minLiquidationDistancePct.toFixed(1)}%`)}`);
66
64
  }
67
65
  if (assessment.violations.length > 0) {
@@ -101,7 +99,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
101
99
  .option("--max-positions <n>", "Max number of simultaneous positions")
102
100
  .option("--max-leverage <n>", "Max leverage per position")
103
101
  .option("--max-margin <pct>", "Max margin utilization %")
104
- .option("--min-liq-distance <pct>", `Min liquidation distance % (hard cap: >=${LIQUIDATION_DISTANCE_HARD_CAP}%)`)
102
+ .option("--min-liq-distance <pct>", "Min liquidation distance %")
105
103
  .option("--reset", "Reset all limits to defaults")
106
104
  .action(async (opts) => {
107
105
  let limits = loadRiskLimits();
@@ -111,9 +109,9 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
111
109
  opts.minLiqDistance || opts.reset;
112
110
  if (opts.reset) {
113
111
  limits = {
114
- maxDrawdownUsd: 500, maxPositionUsd: 5000, maxTotalExposureUsd: 20000,
115
- dailyLossLimitUsd: 200, maxPositions: 10, maxLeverage: 20, maxMarginUtilization: 80,
116
- minLiquidationDistance: 30, maxDrawdownPct: 10, maxPositionPct: 25,
112
+ maxDrawdownUsd: 100000, maxPositionUsd: 100000, maxTotalExposureUsd: 500000,
113
+ dailyLossLimitUsd: 50000, maxPositions: 50, maxLeverage: 50, maxMarginUtilization: 95,
114
+ minLiquidationDistance: 5,
117
115
  };
118
116
  }
119
117
  if (opts.maxDrawdown)
@@ -139,15 +137,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
139
137
  if (opts.maxMargin)
140
138
  limits.maxMarginUtilization = parseFloat(opts.maxMargin);
141
139
  if (opts.minLiqDistance) {
142
- const val = parseFloat(opts.minLiqDistance);
143
- if (val < LIQUIDATION_DISTANCE_HARD_CAP) {
144
- const msg = `Cannot set min liquidation distance below ${LIQUIDATION_DISTANCE_HARD_CAP}% (hard cap). Got: ${val}%`;
145
- if (isJson())
146
- return printJson(jsonOk({ error: msg, hardCap: LIQUIDATION_DISTANCE_HARD_CAP }));
147
- console.log(chalk.red(`\n ${msg}\n`));
148
- return;
149
- }
150
- limits.minLiquidationDistance = val;
140
+ limits.minLiquidationDistance = parseFloat(opts.minLiqDistance);
151
141
  }
152
142
  if (hasUpdate)
153
143
  saveRiskLimits(limits);
@@ -167,7 +157,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
167
157
  console.log(` Max Positions: ${limits.maxPositions}`);
168
158
  console.log(` Max Leverage: ${limits.maxLeverage}x`);
169
159
  console.log(` Max Margin Util: ${limits.maxMarginUtilization}%`);
170
- console.log(` Min Liq Distance: ${limits.minLiquidationDistance}% ${chalk.gray(`(hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`)}`);
160
+ console.log(` Min Liq Distance: ${limits.minLiquidationDistance}%`);
171
161
  console.log(chalk.gray(`\n When both USD and % are set, the stricter limit applies.\n`));
172
162
  console.log(chalk.gray(` Config file: ~/.perp/risk.json\n`));
173
163
  });
@@ -248,12 +238,11 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
248
238
  positions: distances,
249
239
  limits: {
250
240
  minLiquidationDistance: limits.minLiquidationDistance,
251
- hardCap: LIQUIDATION_DISTANCE_HARD_CAP,
252
241
  },
253
242
  }));
254
243
  }
255
244
  console.log(chalk.cyan.bold("\n Liquidation Distance Report\n"));
256
- console.log(chalk.gray(` Your limit: ${limits.minLiquidationDistance}% | Hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%\n`));
245
+ console.log(chalk.gray(` Your limit: ${limits.minLiquidationDistance}%\n`));
257
246
  const rows = distances.map(d => {
258
247
  const statusColor = {
259
248
  safe: chalk.green,
@@ -276,7 +265,7 @@ export function registerRiskCommands(program, getAdapterForExchange, isJson) {
276
265
  const critical = distances.filter(d => d.status === "critical");
277
266
  const danger = distances.filter(d => d.status === "danger");
278
267
  if (critical.length > 0) {
279
- console.log(chalk.bgRed.white.bold(` ⚠ ${critical.length} position(s) BELOW HARD CAP (${LIQUIDATION_DISTANCE_HARD_CAP}%) — REDUCE IMMEDIATELY`));
268
+ console.log(chalk.bgRed.white.bold(` ⚠ ${critical.length} position(s) critically close to liquidation — REDUCE IMMEDIATELY`));
280
269
  }
281
270
  if (danger.length > 0) {
282
271
  console.log(chalk.red.bold(` ⚠ ${danger.length} position(s) below your limit (${limits.minLiquidationDistance}%) — action recommended`));
package/dist/risk.d.ts CHANGED
@@ -15,8 +15,6 @@ export interface RiskLimits {
15
15
  }
16
16
  /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
17
17
  export declare function effectiveLimit(usdLimit: number, pctLimit: number | undefined, totalEquity: number): number;
18
- /** Hard cap: liquidation distance can NEVER be set below this % */
19
- export declare const LIQUIDATION_DISTANCE_HARD_CAP = 20;
20
18
  export declare function loadRiskLimits(): RiskLimits;
21
19
  export declare function saveRiskLimits(limits: RiskLimits): void;
22
20
  export type RiskLevel = "low" | "medium" | "high" | "critical";
package/dist/risk.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  const DEFAULT_LIMITS = {
4
- maxDrawdownUsd: 500,
5
- maxPositionUsd: 5000,
6
- maxTotalExposureUsd: 20000,
7
- dailyLossLimitUsd: 200,
8
- maxPositions: 10,
9
- maxLeverage: 20,
10
- maxMarginUtilization: 80,
11
- minLiquidationDistance: 30,
12
- maxDrawdownPct: 10,
13
- maxPositionPct: 25,
4
+ maxDrawdownUsd: 100000,
5
+ maxPositionUsd: 100000,
6
+ maxTotalExposureUsd: 500000,
7
+ dailyLossLimitUsd: 50000,
8
+ maxPositions: 50,
9
+ maxLeverage: 50,
10
+ maxMarginUtilization: 95,
11
+ minLiquidationDistance: 5,
12
+ maxDrawdownPct: undefined,
13
+ maxPositionPct: undefined,
14
14
  };
15
15
  /** Resolve effective USD limit: min of fixed USD and pct-of-equity (if both set) */
16
16
  export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
@@ -19,8 +19,6 @@ export function effectiveLimit(usdLimit, pctLimit, totalEquity) {
19
19
  const pctInUsd = (pctLimit / 100) * totalEquity;
20
20
  return Math.min(usdLimit, pctInUsd);
21
21
  }
22
- /** Hard cap: liquidation distance can NEVER be set below this % */
23
- export const LIQUIDATION_DISTANCE_HARD_CAP = 20;
24
22
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
25
23
  const RISK_FILE = resolve(PERP_DIR, "risk.json");
26
24
  export function loadRiskLimits() {
@@ -61,9 +59,7 @@ export function getLiquidationDistances(positions, limits) {
61
59
  const liquidationPrice = Number(p.liquidationPrice);
62
60
  const distancePct = calcLiquidationDistance(markPrice, liquidationPrice, p.side);
63
61
  let status = "safe";
64
- if (distancePct < LIQUIDATION_DISTANCE_HARD_CAP)
65
- status = "critical";
66
- else if (distancePct < lim.minLiquidationDistance)
62
+ if (distancePct < lim.minLiquidationDistance)
67
63
  status = "danger";
68
64
  else if (distancePct < lim.minLiquidationDistance * 1.5)
69
65
  status = "warning";
@@ -162,16 +158,7 @@ export function assessRisk(balances, positions, limits) {
162
158
  if (ld.distancePct < minLiquidationDistancePct) {
163
159
  minLiquidationDistancePct = ld.distancePct;
164
160
  }
165
- if (ld.distancePct < LIQUIDATION_DISTANCE_HARD_CAP) {
166
- violations.push({
167
- rule: "liquidation_distance_hard_cap",
168
- severity: "critical",
169
- message: `${ld.exchange}:${ld.symbol} is ${ld.distancePct.toFixed(1)}% from liquidation (hard cap: ${LIQUIDATION_DISTANCE_HARD_CAP}%)`,
170
- current: ld.distancePct,
171
- limit: LIQUIDATION_DISTANCE_HARD_CAP,
172
- });
173
- }
174
- else if (ld.distancePct < lim.minLiquidationDistance) {
161
+ if (ld.distancePct < lim.minLiquidationDistance) {
175
162
  violations.push({
176
163
  rule: "min_liquidation_distance",
177
164
  severity: "high",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -1,307 +1,110 @@
1
1
  ---
2
2
  name: perp-cli
3
- description: "Multi-DEX perpetual futures trading CLI for Pacifica (Solana), Hyperliquid (EVM), and Lighter (Ethereum). Use when user asks to trade perps, check funding rates, bridge USDC, manage positions, scan arbitrage opportunities, or mentions perp-cli, hypurrquant, Pacifica, Hyperliquid, or Lighter exchanges. Also use when user says 'set up perp trading', 'check my positions', 'buy BTC perps', 'funding rate arb', 'bridge USDC', or 'deposit to exchange'."
3
+ description: "Multi-DEX perpetual futures trading CLI for Pacifica (Solana), Hyperliquid (EVM), and Lighter (Ethereum). Use when user asks to trade perps, check funding rates, bridge USDC, manage positions, scan arbitrage opportunities, or mentions perp-cli, hypurrquant, Pacifica, Hyperliquid, or Lighter exchanges."
4
4
  allowed-tools: "Bash(perp:*), Bash(npx perp-cli:*), Bash(npx -y perp-cli:*)"
5
5
  license: MIT
6
6
  metadata:
7
7
  author: hypurrquant
8
- version: "0.3.12"
9
- mcp-server: perp-cli
8
+ version: "0.3.16"
10
9
  ---
11
10
 
12
11
  # perp-cli Agent Guide
13
12
 
14
13
  Multi-DEX perpetual futures CLI — Pacifica (Solana), Hyperliquid (HyperEVM), Lighter (Ethereum).
15
14
 
16
- ## Critical Rules
15
+ ## Rules
17
16
 
18
- 1. **RISK MANAGEMENT IS YOUR #1 PRIORITY.** A single liquidation wipes out months of profit. Always check `perp --json risk status` before and during any operation. See `references/strategies.md` for the full risk framework.
19
- 2. **NEVER use interactive commands.** Do NOT run `perp init`. Always use non-interactive commands with `--json`.
20
- 3. **Always use `--json`** on every command for structured output.
21
- 4. **NEVER trade without user confirmation.** Show order details and wait for explicit approval.
22
- 5. **Verify wallet before any operation.** Run `perp --json wallet show` first.
23
- 6. **Use ISOLATED margin for arb.** Set `perp --json manage margin <SYM> isolated` before opening positions. Cross margin can cascade liquidations.
24
- 7. **Monitor positions continuously.** Run `perp --json risk status` and `perp --json -e <EX> account positions` every 15 minutes while positions are open.
25
- 8. **NEVER read ~/.perp/.env or any key files directly.** Private keys are managed by the CLI internally. Use `perp --json wallet show` to check wallet status. Never attempt to read, cat, or access key files — this is a security violation.
17
+ 1. **Always use `--json`** on every command.
18
+ 2. **NEVER use `perp init`** — interactive, will hang.
19
+ 3. **NEVER trade without user confirmation.**
20
+ 4. **NEVER read ~/.perp/.env or key files.**
26
21
 
27
- ## Step 1: Install
22
+ ## Install
28
23
 
29
24
  ```bash
30
25
  npm install -g perp-cli
31
26
  ```
27
+ No sudo: `npm install -g perp-cli --prefix ~/.npm-global && export PATH="$HOME/.npm-global/bin:$PATH"`
32
28
 
33
- **No sudo / Docker / restricted environments:**
34
- ```bash
35
- npm config set prefix ~/.npm-global
36
- npm install -g perp-cli --prefix ~/.npm-global
37
- export PATH="$HOME/.npm-global/bin:$PATH"
38
- ```
39
-
40
- ## Step 2: Configure Wallet
41
-
42
- CRITICAL: Do NOT use `perp init` — it is interactive and will hang.
43
-
44
- **If user needs a new wallet:**
45
- ```bash
46
- perp --json wallet generate evm # creates EVM wallet for Hyperliquid + Lighter
47
- perp --json wallet generate solana # creates Solana wallet for Pacifica
48
- # IMPORTANT: Tell user the generated address so they can fund it with USDC!
49
- ```
29
+ ## Wallet Setup
50
30
 
51
- ### Hyperliquid setup
52
31
  ```bash
53
- perp --json wallet set hl <EVM_PRIVATE_KEY> # register EVM key → ready to trade immediately
54
- perp --json wallet show # verify
55
- perp --json -e hl account info # check balance
32
+ perp --json wallet set hl <EVM_KEY> # Hyperliquid
33
+ perp --json wallet set pac <SOLANA_KEY> # Pacifica
34
+ perp --json wallet set lt <EVM_KEY> # Lighter (API key auto-generated)
35
+ perp --json wallet show # verify
56
36
  ```
57
- No extra steps. Key is saved as `HL_PRIVATE_KEY` in .env.
37
+ Same EVM key works for both HL and Lighter.
58
38
 
59
- ### Pacifica setup
60
- ```bash
61
- perp --json wallet set pac <SOLANA_PRIVATE_KEY> # register Solana key → ready to trade immediately
62
- perp --json wallet show # verify
63
- perp --json -e pac account info # check balance
64
- ```
65
- No extra steps. Key is saved as `PACIFICA_PRIVATE_KEY` in .env.
66
-
67
- ### Lighter setup (API key auto-generated on registration)
68
- ```bash
69
- perp --json wallet set lt <EVM_PRIVATE_KEY> # register EVM key
70
- # → AUTOMATICALLY generates Lighter API key via on-chain tx
71
- # → Saves to .env: LIGHTER_PRIVATE_KEY, LIGHTER_API_KEY, LIGHTER_ACCOUNT_INDEX, LIGHTER_API_KEY_INDEX
72
- # → No manual API key creation needed. Do NOT ask the user to visit the Lighter website.
73
- perp --json wallet show # verify
74
- perp --json -e lighter account info # check balance
75
- ```
76
- Same EVM key can be used for both Hyperliquid and Lighter:
77
- ```bash
78
- perp --json wallet set hl <KEY> # same key
79
- perp --json wallet set lt <KEY> # same key, different exchange binding
80
- ```
81
- If API key auto-setup fails (rare, e.g. no ETH for gas on Lighter chain):
82
- ```bash
83
- perp --json -e lighter manage setup-api-key # manual retry
84
- ```
39
+ ## Key Commands
85
40
 
86
- **Verify setup (ALWAYS do this after any wallet command):**
87
41
  ```bash
88
- perp --json wallet show
89
- # Success: { "ok": true, "data": { "exchanges": [{ "exchange": "hyperliquid", "address": "0x..." }] } }
90
- # Empty: { "ok": true, "data": { "exchanges": [] } } ← wallet not configured yet
91
- ```
42
+ # Status (ONE call = balances, positions, risk level/violations/canTrade)
43
+ perp --json portfolio
92
44
 
93
- ## Step 3: Use
94
-
95
- ### Exchange selection
96
- ```bash
97
- perp --json -e hyperliquid ... # Hyperliquid (EVM)
98
- perp --json -e pacifica ... # Pacifica (Solana)
99
- perp --json -e lighter ... # Lighter (Ethereum)
100
- ```
101
- If a default exchange is set, `-e` can be omitted.
45
+ # Arb workflow
46
+ perp --json arb scan --min 5 # find opportunities
47
+ perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage <N> --isolated # execute
102
48
 
103
- ### Symbol naming
104
- Symbols are auto-resolved across exchanges. Use bare symbols (e.g., `BTC`, `SOL`, `ICP`) everywhere — the CLI handles exchange-specific naming:
105
- - **Hyperliquid**: `ICP` → `ICP-PERP` (auto-resolved, `-PERP` suffix added)
106
- - **Pacifica / Lighter**: bare symbols as-is
107
- - `arb scan` returns bare symbols — pass them directly to any exchange command.
49
+ # Single exchange trading (when not doing arb)
50
+ perp --json -e <EX> trade market <SYM> buy <SIZE>
51
+ perp --json -e <EX> trade close <SYM>
108
52
 
109
- ### Common operations
110
- ```bash
111
- perp --json wallet show # check configured wallets
112
- perp --json portfolio # unified multi-exchange view
113
- perp --json arb scan --min 5 # find funding arb opportunities (>5bps spread)
53
+ # Risk (only if you need to set limits — portfolio already includes risk)
54
+ perp --json risk limits --max-leverage 5
114
55
  ```
115
56
 
116
- ### Per-exchange commands (ALL 3 exchanges use the SAME commands)
117
- Every command below works on ALL exchanges. Just change `-e`:
118
- ```bash
119
- # Account
120
- perp --json -e hl account info # Hyperliquid balance & margin
121
- perp --json -e pac account info # Pacifica balance & margin
122
- perp --json -e lighter account info # Lighter balance & margin
123
- perp --json -e <EX> account positions # open positions
124
-
125
- # Market data
126
- perp --json -e <EX> market list # available markets
127
- perp --json -e <EX> market mid <SYM> # mid price
128
- perp --json -e <EX> market book <SYM> # orderbook depth
57
+ Exchange aliases: `hl`, `pac`, `lt`. Symbols auto-resolve (`BTC`, `SOL`, `ICP`).
129
58
 
130
- # Trading (same syntax on ALL exchanges)
131
- perp --json -e <EX> trade leverage <SYM> <N> --isolated # set leverage
132
- perp --json -e <EX> trade market <SYM> buy <SIZE> # market buy
133
- perp --json -e <EX> trade market <SYM> sell <SIZE> # market sell
134
- perp --json -e <EX> trade close <SYM> # close position
135
- perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L> # pre-flight
59
+ ## Arb Workflow
136
60
 
137
- # Deposit / Withdraw
138
- perp --json deposit hyperliquid <AMOUNT> # deposit to HL
139
- perp --json deposit pacifica <AMOUNT> # deposit to Pacifica
140
- perp --json deposit lighter info # show Lighter deposit routes
141
- perp --json deposit lighter cctp arb <AMOUNT> # deposit to Lighter via CCTP
142
- perp --json withdraw <EX> <AMOUNT> # withdraw from exchange
143
61
  ```
144
- **All 3 exchanges are fully operational.** Do NOT say any exchange "requires additional setup" or "is not available" — if `wallet show` shows it configured, it's ready to trade.
145
-
146
- ### Funding arb direction (CRITICAL — do NOT reverse)
147
- ```
148
- arb scan returns: longExch, shortExch, netSpread
149
- → ALWAYS follow longExch/shortExch exactly. NEVER reverse the direction.
150
- → NEVER enter if netSpread ≤ 0 (= loss after fees)
151
- → Positive funding = longs pay shorts → be SHORT to receive
152
- → Negative funding = shorts pay longs → be LONG to receive
62
+ 1. perp --json portfolio → check balances across all exchanges
63
+ 2. perp --json arb scan --min 5 → find opportunity (longExch, shortExch, netSpread)
64
+ 3. [Show opportunity to user, get confirmation]
65
+ 4. perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated
66
+ → validates orderbook depth on both sides
67
+ → rounds size to each exchange's lot size
68
+ → executes BOTH legs simultaneously
69
+ → verifies positions exist after execution
70
+ → auto-rollback if one leg fails
71
+ 5. perp --json portfolio → verify positions
153
72
  ```
154
73
 
155
- ### Trade execution (MANDATORY checklist)
156
- ```
157
- BEFORE ANY TRADE:
158
- 0. perp --json portfolio → check TOTAL equity + per-exchange balances
159
- - Single position notional < 25% of TOTAL equity
160
- - Each exchange MUST have sufficient balance for its leg
161
- - Notional ≠ margin required. Check available balance on EACH exchange.
162
- - If balance is insufficient, bridge first or reduce size.
74
+ ## Position Sizing
163
75
 
164
- 1. perp --json risk status → check risk level (STOP if critical)
165
- 2. perp --json -e <EX> account info → verify EXCHANGE-SPECIFIC balance
166
- 3. perp --json -e <EX> market mid <SYM> → current price
167
- 4. perp --json -e <EX> trade leverage <SYM> <N> --isolated → set leverage + isolated margin FIRST
168
- 5. perp --json risk check --notional <$> --leverage <L> → risk pre-check
169
- 6. perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L> → trade validation
170
- ⚠ trade check does NOT read exchange-set leverage. ALWAYS pass --leverage explicitly.
171
- 7. [Show order details + risk assessment to user, get explicit confirmation]
172
- 8. perp --json -e <EX> trade market <SYM> <SIDE> <SIZE> → execute
173
- 9. perp --json -e <EX> account positions → verify result + check liquidation price
174
- ```
76
+ - Single position < **80%** of that exchange's available balance
77
+ - Leverage 1-3x for arb, max 5x
78
+ - `arb exec` auto-matches both legs to the same size
175
79
 
176
- ### Exchange-specific constraints
177
- ```
178
- Minimum order values (notional, enforced by exchange):
179
- - Hyperliquid: $10 minimum per order
180
- - Pacifica: varies by symbol (usually ~$1)
181
- - Lighter: varies by symbol
80
+ ## Arb Direction (CRITICAL)
182
81
 
183
- If your calculated size falls below the minimum, increase to meet it or skip the opportunity.
184
- trade check returns valid: true/false but is ADVISORY — it does NOT block execution.
185
- The exchange itself will reject orders below its minimum.
186
- ```
82
+ `arb scan` returns `longExch`, `shortExch`, `netSpread`.
83
+ ALWAYS follow exactly. NEVER reverse. NEVER enter if `netSpread <= 0`.
187
84
 
188
- ### Arb order sizing (CRITICAL — both legs MUST match)
189
- ```
190
- For funding arb, BOTH legs must have the EXACT SAME SIZE. Size mismatch = directional exposure.
85
+ ## Portfolio Response
191
86
 
192
- 1. Check orderbook depth on BOTH exchanges:
193
- perp --json -e <LONG_EX> market book <SYM> → asks (you're buying)
194
- perp --json -e <SHORT_EX> market book <SYM> → bids (you're selling)
87
+ `portfolio` includes `risk: { level, canTrade, violations[] }` — no need for separate `risk status`.
88
+ Check `canTrade` before any order. If `false`, do NOT trade.
195
89
 
196
- 2. Compute ORDER_SIZE:
197
- - fillable_long = sum of ask sizes at best 2-3 levels
198
- - fillable_short = sum of bid sizes at best 2-3 levels
199
- - ORDER_SIZE = min(fillable_long, fillable_short, desired_size)
200
- - Must be ≥ BOTH exchanges' minimum order value (e.g. HL requires ≥$10 notional)
90
+ ## Monitoring (while positions open)
201
91
 
202
- 3. Execute BOTH legs with the SAME ORDER_SIZE:
203
- perp --json -e <LONG_EX> trade market <SYM> buy <ORDER_SIZE>
204
- → verify fill via account positions
205
- perp --json -e <SHORT_EX> trade market <SYM> sell <ORDER_SIZE>
206
- → verify fill via account positions
207
-
208
- 4. Confirm matched: both positions must show identical size.
209
- If mismatch (partial fill), adjust the larger to match the smaller.
210
92
  ```
211
- See `references/strategies.md` for detailed execution strategy (chunked orders, limit orders, failure handling).
212
-
213
- ### Post-entry monitoring (MANDATORY while positions are open)
214
- ```
215
- Every 15 minutes:
216
- perp --json risk status → overall risk level + violations
217
- perp --json risk liquidation-distance → % from liq price for ALL positions
218
- perp --json -e <EX> account positions → check each position P&L
219
-
220
- Every 1 hour (at funding settlement):
221
- perp --json arb scan --min 5 → is spread still profitable?
222
- perp --json portfolio → total equity across exchanges
223
- Compare both legs' unrealized P&L — they should roughly offset
224
-
225
- Exit triggers:
226
- - Spread below breakeven (including fees) → show exit plan, get user approval
227
- - risk status level = "critical" or canTrade = false → reduce immediately
228
- - One leg closed unexpectedly → close the other leg IMMEDIATELY
229
- - Target hold duration reached → re-evaluate or exit
230
- ```
231
-
232
- ### Knowing your capital (CHECK BEFORE ANY DECISION)
233
- ```
234
- perp --json wallet show → configured wallets + addresses
235
- perp --json wallet balance → on-chain USDC (in wallet, NOT on exchange)
236
- perp --json -e <EX> account info → exchange balance (available for trading)
237
- perp --json portfolio → unified view: equity, margin, P&L per exchange
238
- ```
239
- **On-chain balance ≠ exchange balance.** Always check both. Capital must be deposited to exchange before trading.
240
-
241
- For full command reference, see `references/commands.md`.
242
- For agent-specific operations (setup flows, deposit/withdraw, order types, idempotency), see `references/agent-operations.md`.
243
- For autonomous strategies (funding rate arb, risk management, opportunity cost), see `references/strategies.md`.
244
-
245
- ## Response Format
246
-
247
- All JSON responses follow this envelope:
248
- ```json
249
- { "ok": true, "data": { ... }, "meta": { "timestamp": "..." } }
250
- { "ok": false, "error": { "code": "ERROR_CODE", "message": "...", "retryable": true } }
93
+ Every 15 min: perp --json portfolio
94
+ Every 1 hour: perp --json arb scan --min 5
95
+ Exit if: spread < breakeven or one leg closed unexpectedly.
96
+ Close both: perp --json -e <EX1> trade close <SYM> & perp --json -e <EX2> trade close <SYM>
251
97
  ```
252
98
 
253
99
  ## Error Handling
254
100
 
255
- | Code | Retryable | Action |
256
- |------|-----------|--------|
257
- | INSUFFICIENT_BALANCE | No | Report, suggest deposit |
258
- | MARGIN_INSUFFICIENT | No | Suggest lower leverage or smaller size |
259
- | SYMBOL_NOT_FOUND | No | Run `market list` for valid symbols |
260
- | SIGNATURE_FAILED | No | Run `wallet show` to check key configuration |
261
- | RATE_LIMITED | Yes | Wait 5s, retry once |
262
- | EXCHANGE_UNREACHABLE | Yes | Wait 5s, retry up to 3 times |
263
- | TIMEOUT | Yes | Retry, check network |
264
-
265
- ## Safety Guardrails
266
-
267
- - NEVER execute trades without user confirmation
268
- - Warn if single trade exceeds 50% of available balance
269
- - Warn if leverage exceeds 10x
270
- - Double-confirm bridge transfers over $1000
271
-
272
- ## Troubleshooting
101
+ Responses: `{ "ok": true, "data": {...} }` or `{ "ok": false, "error": { "code": "...", "retryable": true/false } }`
273
102
 
274
- ### "No private key configured for <exchange>"
275
- The wallet is not set up. Fix:
276
- ```bash
277
- perp --json wallet set <exchange> <key> # if user has a key
278
- perp --json wallet generate evm # if user needs a new EVM wallet
279
- perp --json wallet generate solana # if user needs a new Solana wallet
280
- perp --json wallet show # verify it worked
281
- ```
282
-
283
- ### Command hangs or waits for input
284
- You used an interactive command. NEVER use `perp init` or any command without `--json`. Cancel and retry with `--json`.
103
+ Retryable (wait 5s): `RATE_LIMITED`, `EXCHANGE_UNREACHABLE`, `TIMEOUT`
104
+ **Lighter `invalid signature`**: `perp --json -e lighter manage setup-api-key`
285
105
 
286
- ### Generated wallet has zero balance
287
- New wallets start empty. Show the address to the user and ask them to fund it with USDC before trading.
288
-
289
- ## MCP Server (Advisor Mode)
290
-
291
- For read-only access without CLI execution:
292
- ```json
293
- {
294
- "mcpServers": {
295
- "perp-cli": {
296
- "command": "npx",
297
- "args": ["-y", "perp-cli", "mcp"],
298
- "env": {
299
- "HL_PRIVATE_KEY": "<evm-hex>",
300
- "PACIFICA_PRIVATE_KEY": "<solana-base58>"
301
- }
302
- }
303
- }
304
- }
305
- ```
106
+ ## References
306
107
 
307
- **MCP Tools:** get_markets, get_orderbook, get_funding_rates, get_prices, get_balance, get_positions, get_open_orders, portfolio, arb_scan, health_check, suggest_command, explain_command
108
+ - `references/commands.md` — full command reference
109
+ - `references/agent-operations.md` — setup flows, deposit/withdraw
110
+ - `references/strategies.md` — risk framework, arb strategy
@@ -257,17 +257,14 @@ perp --json -e <EX> account margin <SYM> # detailed: liquidationPrice, margi
257
257
 
258
258
  **Liquidation distance is configurable by the user.** You MUST ask the user what risk tolerance they want:
259
259
  ```bash
260
- # Ask user: "What minimum liquidation distance do you want? (default: 30%, hard minimum: 20%)"
260
+ # Ask user: "What minimum liquidation distance do you want? (default: 5%)"
261
261
  perp --json risk limits --min-liq-distance <USER_CHOICE>
262
262
  ```
263
263
 
264
- **Hard cap: 20%.** No matter what the user says, the system will NEVER allow a position to get within 20% of liquidation. This is non-negotiable and enforced at the system level. If a user tries to set it below 20%, the command will reject it.
265
-
266
264
  **Action rules based on `risk liquidation-distance` output:**
267
265
  - `status: "safe"` → no action needed
268
266
  - `status: "warning"` → monitor more frequently (every 5 minutes)
269
267
  - `status: "danger"` → alert user, recommend reducing position size
270
- - `status: "critical"` (below 20% hard cap) → REDUCE IMMEDIATELY, `canTrade` becomes `false`
271
268
 
272
269
  ### Leverage and Margin Mode
273
270
 
@@ -309,14 +306,13 @@ Limits can be set in **USD or % of equity** (when both are set, the stricter one
309
306
  ```bash
310
307
  perp --json risk limits \
311
308
  --max-leverage 5 \
312
- --max-margin 60 \
313
- --max-drawdown-pct 10 \
314
- --max-position-pct 25 \
309
+ --max-margin 95 \
310
+ --max-position-pct 80 \
315
311
  --max-drawdown 500 \
316
- --max-position 5000 \
317
- --max-exposure 20000 \
312
+ --max-position 50000 \
313
+ --max-exposure 100000 \
318
314
  --daily-loss 200 \
319
- --min-liq-distance 30
315
+ --min-liq-distance 5
320
316
  ```
321
317
 
322
318
  **Use % limits for small portfolios.** A $65 portfolio with `--max-drawdown 500` is meaningless.
@@ -325,7 +321,7 @@ With `--max-drawdown-pct 10`, the effective limit auto-adjusts to $6.50.
325
321
  **IMPORTANT: Ask the user about their risk tolerance BEFORE setting limits.** Key questions:
326
322
  - "How much leverage are you comfortable with?" (default: 5x for arb)
327
323
  - "What's your maximum acceptable loss?" (default: $500)
328
- - "How close to liquidation are you willing to get?" (default: 30%, minimum: 20%)
324
+ - "How close to liquidation are you willing to get?" (default: 5%)
329
325
 
330
326
  These limits are enforced by `perp risk check`. Always run it before trades:
331
327
  ```bash
@@ -380,8 +376,8 @@ perp --json portfolio # totalEquity, marginUtilization, c
380
376
  ```
381
377
 
382
378
  Rules of thumb:
383
- - **Single position notional < 25% of total equity** across all exchanges
384
- - **Total margin used < 60% of total equity** — leave buffer for adverse moves
379
+ - **Single position notional < 80% of that exchange's available balance**
380
+ - **Total margin used < 80% of total equity** — leave buffer for adverse moves
385
381
  - **Capital in transit (bridging) counts as "at risk"** — it's not available for margin
386
382
 
387
383
  ### Per-Exchange Balance Constraints