|
| 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