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.
- package/dist/__tests__/risk-assessment.test.js +5 -22
- package/dist/commands/arb-auto.js +222 -3
- package/dist/commands/portfolio.js +27 -0
- package/dist/commands/risk.js +12 -23
- package/dist/risk.d.ts +0 -2
- package/dist/risk.js +12 -25
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +60 -257
- package/skills/perp-cli/references/strategies.md +9 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { assessRisk, preTradeCheck, calcLiquidationDistance, getLiquidationDistances, effectiveLimit
|
|
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% →
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
});
|
package/dist/commands/risk.js
CHANGED
|
@@ -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
|
|
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 <
|
|
61
|
-
? chalk.
|
|
62
|
-
:
|
|
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>",
|
|
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:
|
|
115
|
-
dailyLossLimitUsd:
|
|
116
|
-
minLiquidationDistance:
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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)
|
|
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:
|
|
5
|
-
maxPositionUsd:
|
|
6
|
-
maxTotalExposureUsd:
|
|
7
|
-
dailyLossLimitUsd:
|
|
8
|
-
maxPositions:
|
|
9
|
-
maxLeverage:
|
|
10
|
-
maxMarginUtilization:
|
|
11
|
-
minLiquidationDistance:
|
|
12
|
-
maxDrawdownPct:
|
|
13
|
-
maxPositionPct:
|
|
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 <
|
|
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 <
|
|
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
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
##
|
|
15
|
+
## Rules
|
|
17
16
|
|
|
18
|
-
1. **
|
|
19
|
-
2. **NEVER use
|
|
20
|
-
3. **
|
|
21
|
-
4. **NEVER
|
|
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
|
-
##
|
|
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
|
-
|
|
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 <
|
|
54
|
-
perp --json wallet
|
|
55
|
-
perp --json
|
|
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
|
-
|
|
37
|
+
Same EVM key works for both HL and Lighter.
|
|
58
38
|
|
|
59
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
→
|
|
150
|
-
→
|
|
151
|
-
→
|
|
152
|
-
→
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
```
|
|
190
|
-
For funding arb, BOTH legs must have the EXACT SAME SIZE. Size mismatch = directional exposure.
|
|
85
|
+
## Portfolio Response
|
|
191
86
|
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
313
|
-
--max-
|
|
314
|
-
--max-position-pct 25 \
|
|
309
|
+
--max-margin 95 \
|
|
310
|
+
--max-position-pct 80 \
|
|
315
311
|
--max-drawdown 500 \
|
|
316
|
-
--max-position
|
|
317
|
-
--max-exposure
|
|
312
|
+
--max-position 50000 \
|
|
313
|
+
--max-exposure 100000 \
|
|
318
314
|
--daily-loss 200 \
|
|
319
|
-
--min-liq-distance
|
|
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:
|
|
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 <
|
|
384
|
-
- **Total margin used <
|
|
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
|