Skip to content

Commit 457de60

Browse files
Hiksangclaude
andcommitted
feat: funding-arb-v2 — history-based decisions, cross-exchange spot-perp, rotation
New strategy "funding-arb-v2" with fundamental redesign: Entry: funding history N periods checked (consistency >= 70%), not snapshot rate Close: avg net funding over last N payments, not instantaneous spread Spot-perp: buy spot (HL/Lighter) + short perp on BEST of 4 exchanges Rotation: move perp short to better exchange without touching spot Perp-perp: also history-based with same consistency check New files: - funding-arb-v2-strategy.ts (764 lines) - funding-arb-utils.ts (268 lines) — shared utilities Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 80ac0fb commit 457de60

3 files changed

Lines changed: 1033 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "perp-cli",
3-
"version": "0.7.51",
3+
"version": "0.8.0",
44
"description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
55
"bin": {
66
"perp": "dist/index.js",
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/**
2+
* Shared utilities for funding arbitrage strategies (v1 + v2).
3+
* Extracted to keep individual strategy files under size limits.
4+
*/
5+
6+
import type { StrategyContext } from "../strategy-types.js";
7+
import type { ExchangeAdapter } from "../../exchanges/index.js";
8+
import type { SpotAdapter } from "../../exchanges/spot-interface.js";
9+
import { getFundingHours } from "../../funding.js";
10+
11+
// ── Type aliases ──
12+
13+
export type RateEntry = {
14+
rate: number;
15+
price: number;
16+
sizeDecimals?: number;
17+
maxLeverage?: number;
18+
fundingHours?: number;
19+
};
20+
21+
export type RateMap = Map<string, Map<string, RateEntry>>;
22+
23+
// ── Adapter helpers ──
24+
25+
/** Build adapter map from primary adapter + extraAdapters in context state. */
26+
export function buildAdapterMap(ctx: StrategyContext): Map<string, ExchangeAdapter> {
27+
const extraAdapters = ctx.state.get("extraAdapters") as Map<string, ExchangeAdapter> | undefined;
28+
const adapters = new Map<string, ExchangeAdapter>();
29+
adapters.set(ctx.adapter.name.toLowerCase(), ctx.adapter);
30+
if (extraAdapters) {
31+
for (const [name, a] of extraAdapters) adapters.set(name, a);
32+
}
33+
return adapters;
34+
}
35+
36+
const spotAdapterCache = new Map<string, SpotAdapter>();
37+
38+
/** Get or create a spot adapter for a given exchange name + perp adapter instance. */
39+
export async function getSpotAdapter(name: string, adapter: ExchangeAdapter): Promise<SpotAdapter | null> {
40+
const cached = spotAdapterCache.get(name);
41+
if (cached) return cached;
42+
try {
43+
let spot: SpotAdapter | null = null;
44+
if (name === "hyperliquid") {
45+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
46+
const { HyperliquidAdapter } = await import("../../exchanges/hyperliquid.js");
47+
if (adapter instanceof HyperliquidAdapter) {
48+
const instance = new HyperliquidSpotAdapter(adapter);
49+
await instance.init();
50+
spot = instance;
51+
}
52+
} else if (name === "lighter") {
53+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
54+
const { LighterAdapter } = await import("../../exchanges/lighter.js");
55+
if (adapter instanceof LighterAdapter) {
56+
const instance = new LighterSpotAdapter(adapter);
57+
await instance.init();
58+
spot = instance;
59+
}
60+
}
61+
if (spot) spotAdapterCache.set(name, spot);
62+
return spot;
63+
} catch { /* not supported */ }
64+
return null;
65+
}
66+
67+
// ── Transfer helpers ──
68+
69+
/** Transfer USDC from perp to spot account (exchange-specific). */
70+
export async function transferUsdcToSpot(spotAdapter: SpotAdapter, exchangeName: string, amount: number): Promise<void> {
71+
if (exchangeName === "hyperliquid") {
72+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
73+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
74+
await spotAdapter.transferUsdcToSpot(amount);
75+
return;
76+
}
77+
} else if (exchangeName === "lighter") {
78+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
79+
if (spotAdapter instanceof LighterSpotAdapter) {
80+
await spotAdapter.transferUsdcToSpot(amount);
81+
return;
82+
}
83+
}
84+
}
85+
86+
/** Transfer USDC from spot to perp account (exchange-specific). */
87+
export async function transferUsdcToPerp(spotAdapter: SpotAdapter, exchangeName: string, amount: number): Promise<void> {
88+
if (exchangeName === "hyperliquid") {
89+
const { HyperliquidSpotAdapter } = await import("../../exchanges/hyperliquid-spot.js");
90+
if (spotAdapter instanceof HyperliquidSpotAdapter) {
91+
await spotAdapter.transferUsdcToPerp(amount);
92+
return;
93+
}
94+
} else if (exchangeName === "lighter") {
95+
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
96+
if (spotAdapter instanceof LighterSpotAdapter) {
97+
await spotAdapter.transferUsdcToPerp(amount);
98+
return;
99+
}
100+
}
101+
}
102+
103+
// ── Rate / symbol helpers ──
104+
105+
/** Resolve perp symbol for a given base symbol on an exchange. */
106+
export function getPerpSymbol(baseSymbol: string, _exchangeName: string): string {
107+
return baseSymbol.replace(/-PERP$/, "").toUpperCase();
108+
}
109+
110+
/** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP. */
111+
export function findRate(ratesByExchange: RateMap, exchange: string, symbol: string): RateEntry | undefined {
112+
const map = ratesByExchange.get(exchange);
113+
if (!map) return undefined;
114+
const upper = symbol.toUpperCase();
115+
return map.get(upper) ?? map.get(upper + "-PERP") ?? map.get(upper.replace(/-PERP$/, ""));
116+
}
117+
118+
/** Match a symbol against a target, accounting for -PERP suffix variations. */
119+
export function matchSymbol(s: string, target: string): boolean {
120+
const u = s.toUpperCase();
121+
const t = target.toUpperCase();
122+
return u === t || u === t + "-PERP" || u.replace(/-PERP$/, "") === t;
123+
}
124+
125+
/** Get a price estimate for a symbol from the perp adapter. */
126+
export async function getPriceEstimate(perpAdapter: ExchangeAdapter, perpSymbol: string, fallbackSymbol: string): Promise<number> {
127+
try {
128+
const markets = await perpAdapter.getMarkets();
129+
const market = markets.find(m =>
130+
m.symbol.toUpperCase() === perpSymbol.toUpperCase() ||
131+
m.symbol.toUpperCase() === fallbackSymbol.toUpperCase() ||
132+
m.symbol.toUpperCase() === `${fallbackSymbol.toUpperCase()}-PERP`,
133+
);
134+
return market ? parseFloat(market.markPrice) : 0;
135+
} catch {
136+
return 0;
137+
}
138+
}
139+
140+
// ── Position recovery ──
141+
142+
export interface RecoveredPosition {
143+
symbol: string;
144+
mode: "spot-perp" | "perp-perp";
145+
longExchange: string;
146+
shortExchange: string;
147+
size: string;
148+
}
149+
150+
/** Recover arb positions from exchange state (perp-perp + spot-perp, same + cross exchange). */
151+
export async function recoverArbPositions(
152+
adapters: Map<string, ExchangeAdapter>,
153+
log: (msg: string) => void,
154+
): Promise<RecoveredPosition[]> {
155+
const positionsByExchange = new Map<string, { symbol: string; side: string; size: string }[]>();
156+
for (const [name, a] of adapters) {
157+
try {
158+
const positions = await a.getPositions();
159+
positionsByExchange.set(name, positions.map(p => ({ symbol: p.symbol.toUpperCase(), side: p.side, size: p.size })));
160+
} catch { /* skip */ }
161+
}
162+
163+
const recovered: RecoveredPosition[] = [];
164+
const used = new Set<string>();
165+
166+
// Cross-exchange perp-perp
167+
for (const [exA, posA] of positionsByExchange) {
168+
for (const pA of posA) {
169+
const keyA = `${exA}:${pA.symbol}`;
170+
if (used.has(keyA)) continue;
171+
for (const [exB, posB] of positionsByExchange) {
172+
if (exA === exB) continue;
173+
for (const pB of posB) {
174+
const keyB = `${exB}:${pB.symbol}`;
175+
if (used.has(keyB) || pA.symbol !== pB.symbol || pA.side === pB.side) continue;
176+
const longEx = pA.side === "long" ? exA : exB;
177+
const shortEx = pA.side === "short" ? exA : exB;
178+
recovered.push({ symbol: pA.symbol, mode: "perp-perp", longExchange: longEx, shortExchange: shortEx, size: pA.side === "long" ? pA.size : pB.size });
179+
used.add(keyA); used.add(keyB); break;
180+
}
181+
if (used.has(keyA)) break;
182+
}
183+
}
184+
}
185+
186+
// Same-exchange spot-perp hedges
187+
for (const [name, a] of adapters) {
188+
try {
189+
const spotAdapter = await getSpotAdapter(name, a);
190+
if (!spotAdapter) continue;
191+
const bals = await spotAdapter.getSpotBalances();
192+
const nonUsdc = bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
193+
if (nonUsdc.length === 0) continue;
194+
for (const perp of positionsByExchange.get(name) ?? []) {
195+
const perpKey = `${name}:${perp.symbol}`;
196+
if (used.has(perpKey)) continue;
197+
const base = perp.symbol.replace(/-PERP$/, "").toUpperCase();
198+
const spotBal = nonUsdc.find(b => b.token.toUpperCase().replace(/-SPOT$/, "") === base);
199+
if (spotBal && perp.side === "short") {
200+
recovered.push({ symbol: base, mode: "spot-perp", longExchange: `${name}-spot`, shortExchange: name, size: perp.size });
201+
used.add(perpKey);
202+
log(` Recovered spot-perp: ${base} ${name}-spot<>${name}`);
203+
}
204+
}
205+
} catch { /* no spot */ }
206+
}
207+
208+
// Cross-exchange spot-perp
209+
for (const [spotExName, spotExAdapter] of adapters) {
210+
try {
211+
const spotAdapter = await getSpotAdapter(spotExName, spotExAdapter);
212+
if (!spotAdapter) continue;
213+
const bals = await spotAdapter.getSpotBalances();
214+
const nonUsdc = bals.filter(b => Number(b.total) > 0 && !b.token.toUpperCase().startsWith("USDC"));
215+
for (const bal of nonUsdc) {
216+
const base = bal.token.toUpperCase().replace(/-SPOT$/, "");
217+
for (const [perpExName, perpPositions] of positionsByExchange) {
218+
if (perpExName === spotExName) continue;
219+
for (const perp of perpPositions) {
220+
const perpKey = `${perpExName}:${perp.symbol}`;
221+
if (used.has(perpKey)) continue;
222+
if (perp.symbol.replace(/-PERP$/, "").toUpperCase() === base && perp.side === "short") {
223+
recovered.push({ symbol: base, mode: "spot-perp", longExchange: `${spotExName}-spot`, shortExchange: perpExName, size: perp.size });
224+
used.add(perpKey);
225+
log(` Recovered cross spot-perp: ${base} ${spotExName}-spot<>${perpExName}`);
226+
}
227+
}
228+
}
229+
}
230+
} catch { /* no spot */ }
231+
}
232+
233+
return recovered;
234+
}
235+
236+
/** Fetch funding rates from an exchange adapter. */
237+
export async function fetchRates(
238+
adapter: ExchangeAdapter,
239+
exchangeName: string,
240+
): Promise<{ symbol: string; rate: number; price: number; sizeDecimals?: number; maxLeverage?: number; fundingHours?: number }[]> {
241+
try {
242+
const markets = await adapter.getMarkets();
243+
const withRates = markets.filter(m => m.fundingRate != null);
244+
245+
// Bootstrap aster funding hours lazily
246+
if (exchangeName === "aster" && "getFundingHours" in adapter) {
247+
const aster = adapter as unknown as { getFundingHours(sym: string): Promise<number> };
248+
const uncached = withRates.filter(m => {
249+
const c = (adapter as any)?._fundingHoursCache?.get?.(m.symbol);
250+
return c === undefined;
251+
});
252+
for (const m of uncached.slice(0, 20)) {
253+
m.fundingHours = await aster.getFundingHours(m.symbol);
254+
}
255+
}
256+
257+
return withRates.map(m => ({
258+
symbol: m.symbol,
259+
rate: parseFloat(m.fundingRate!),
260+
price: parseFloat(m.markPrice),
261+
sizeDecimals: m.sizeDecimals,
262+
maxLeverage: m.maxLeverage,
263+
fundingHours: m.fundingHours,
264+
}));
265+
} catch {
266+
return [];
267+
}
268+
}

0 commit comments

Comments
 (0)