perp-cli 0.3.15 → 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 +50 -55
- package/skills/perp-cli/references/strategies.md +4 -8
|
@@ -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
|
@@ -5,7 +5,7 @@ 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.
|
|
8
|
+
version: "0.3.16"
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# perp-cli Agent Guide
|
|
@@ -15,10 +15,9 @@ Multi-DEX perpetual futures CLI — Pacifica (Solana), Hyperliquid (HyperEVM), L
|
|
|
15
15
|
## Rules
|
|
16
16
|
|
|
17
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.**
|
|
21
|
-
5. **Risk first.** Run `perp --json risk status` before and during any operation.
|
|
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.**
|
|
22
21
|
|
|
23
22
|
## Install
|
|
24
23
|
|
|
@@ -35,81 +34,77 @@ perp --json wallet set pac <SOLANA_KEY> # Pacifica
|
|
|
35
34
|
perp --json wallet set lt <EVM_KEY> # Lighter (API key auto-generated)
|
|
36
35
|
perp --json wallet show # verify
|
|
37
36
|
```
|
|
38
|
-
Same EVM key works for both HL and Lighter.
|
|
37
|
+
Same EVM key works for both HL and Lighter.
|
|
39
38
|
|
|
40
|
-
##
|
|
39
|
+
## Key Commands
|
|
41
40
|
|
|
42
41
|
```bash
|
|
43
|
-
#
|
|
44
|
-
perp --json
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
perp --json -e <EX> market book <SYM> # orderbook
|
|
52
|
-
|
|
53
|
-
# Trading
|
|
54
|
-
perp --json -e <EX> trade leverage <SYM> <N> --isolated # set BEFORE trading
|
|
42
|
+
# Status (ONE call = balances, positions, risk level/violations/canTrade)
|
|
43
|
+
perp --json portfolio
|
|
44
|
+
|
|
45
|
+
# Arb workflow
|
|
46
|
+
perp --json arb scan --min 5 # find opportunities
|
|
47
|
+
perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage <N> --isolated # execute
|
|
48
|
+
|
|
49
|
+
# Single exchange trading (when not doing arb)
|
|
55
50
|
perp --json -e <EX> trade market <SYM> buy <SIZE>
|
|
56
|
-
perp --json -e <EX> trade market <SYM> sell <SIZE>
|
|
57
51
|
perp --json -e <EX> trade close <SYM>
|
|
58
|
-
perp --json -e <EX> trade check <SYM> <SIDE> <SIZE> --leverage <L>
|
|
59
|
-
|
|
60
|
-
# Risk
|
|
61
|
-
perp --json risk status # risk level + violations
|
|
62
|
-
perp --json risk liquidation-distance # % from liq for all positions
|
|
63
|
-
perp --json risk check --notional <$> --leverage <L>
|
|
64
52
|
|
|
65
|
-
#
|
|
66
|
-
perp --json
|
|
53
|
+
# Risk (only if you need to set limits — portfolio already includes risk)
|
|
54
|
+
perp --json risk limits --max-leverage 5
|
|
67
55
|
```
|
|
68
56
|
|
|
69
|
-
Exchange aliases: `hl
|
|
57
|
+
Exchange aliases: `hl`, `pac`, `lt`. Symbols auto-resolve (`BTC`, `SOL`, `ICP`).
|
|
70
58
|
|
|
71
|
-
##
|
|
59
|
+
## Arb Workflow
|
|
72
60
|
|
|
73
61
|
```
|
|
74
|
-
1. perp --json
|
|
75
|
-
2. perp --json
|
|
76
|
-
3.
|
|
77
|
-
4. perp --json
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
81
72
|
```
|
|
82
73
|
|
|
83
74
|
## Position Sizing
|
|
84
75
|
|
|
85
|
-
- Single position
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
- Both arb legs MUST have exact same size
|
|
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
|
|
89
79
|
|
|
90
|
-
##
|
|
80
|
+
## Arb Direction (CRITICAL)
|
|
91
81
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
82
|
+
`arb scan` returns `longExch`, `shortExch`, `netSpread`.
|
|
83
|
+
ALWAYS follow exactly. NEVER reverse. NEVER enter if `netSpread <= 0`.
|
|
84
|
+
|
|
85
|
+
## Portfolio Response
|
|
86
|
+
|
|
87
|
+
`portfolio` includes `risk: { level, canTrade, violations[] }` — no need for separate `risk status`.
|
|
88
|
+
Check `canTrade` before any order. If `false`, do NOT trade.
|
|
97
89
|
|
|
98
90
|
## Monitoring (while positions open)
|
|
99
91
|
|
|
100
|
-
|
|
101
|
-
Every
|
|
102
|
-
|
|
92
|
+
```
|
|
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>
|
|
97
|
+
```
|
|
103
98
|
|
|
104
99
|
## Error Handling
|
|
105
100
|
|
|
106
|
-
|
|
101
|
+
Responses: `{ "ok": true, "data": {...} }` or `{ "ok": false, "error": { "code": "...", "retryable": true/false } }`
|
|
107
102
|
|
|
108
|
-
Retryable (wait 5s
|
|
109
|
-
|
|
103
|
+
Retryable (wait 5s): `RATE_LIMITED`, `EXCHANGE_UNREACHABLE`, `TIMEOUT`
|
|
104
|
+
**Lighter `invalid signature`**: `perp --json -e lighter manage setup-api-key`
|
|
110
105
|
|
|
111
106
|
## References
|
|
112
107
|
|
|
113
108
|
- `references/commands.md` — full command reference
|
|
114
|
-
- `references/agent-operations.md` — setup flows, deposit/withdraw
|
|
115
|
-
- `references/strategies.md` — risk framework, arb strategy
|
|
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-drawdown-pct 10 \
|
|
309
|
+
--max-margin 95 \
|
|
314
310
|
--max-position-pct 80 \
|
|
315
311
|
--max-drawdown 500 \
|
|
316
312
|
--max-position 50000 \
|
|
317
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
|