OpenPond
1Branch0Tags
GL
glucryptoUpdate OPENTOOL_PUBLIC config + budget caps
0190d56a month ago9Commits
typescript
import { store } from "opentool/store"; import { wallet } from "opentool/wallet"; import type { ToolProfile } from "opentool"; import { fetchHyperliquidClearinghouseState, placeHyperliquidOrder, type HyperliquidOrderResponse, } from "opentool/adapters/hyperliquid"; import type { WalletFullContext } from "opentool/wallet"; import { DEFAULT_SYMBOLS, MOMENTUM_LOOKBACK_DAYS, MOMENTUM_TILT_STRENGTH, applyMomentumTilt, fetchMarkPrice, fetchMomentumReturn, fetchSizeDecimals, formatMarketablePrice, formatOrderSize, normalizeCryptoSymbol, normalizeSymbolInputs, readConfig, resolveChainConfig, } from "../src/crypto-movers"; const DEFAULT_ALLOCATION_MODE: "percent" | "fixed" = "percent"; const DEFAULT_PERCENT_OF_EQUITY = 2; const DEFAULT_MAX_PERCENT_OF_EQUITY = 10; const DEFAULT_MAX_PER_RUN_USD = 500; const DEFAULT_AMOUNT_USD = 400; const DEFAULT_ENVIRONMENT: "mainnet" | "testnet" = "mainnet"; const DEFAULT_SLIPPAGE_BPS = 50; const STRATEGY_ID = "crypto-movers"; const CONFIG_ENV_NAME = "OPENTOOL_PUBLIC_HL_CRYPTO_MOVERS_CONFIG"; export const profile: ToolProfile = { description: "Weekly DCA into top crypto movers on Hyperliquid.", category: "strategy", schedule: { cron: "0 12 * * 1", enabled: false }, chains: ["hyperliquid"], }; function toNumber(value: unknown): number | null { if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (typeof value === "string") { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function resolveNumber(value: unknown, fallback: number): number { const parsed = toNumber(value); return parsed === null ? fallback : parsed; } function resolveBudget(params: { allocationMode: "percent" | "fixed"; percentOfEquity: number; maxPercentOfEquity: number; maxPerRunUsd: number; amountUsd: number; accountValue: number; }) { const percent = Number.isFinite(params.percentOfEquity) ? Math.max(params.percentOfEquity, 0) : 0; const maxPercent = Number.isFinite(params.maxPercentOfEquity) ? Math.max(params.maxPercentOfEquity, 0) : 0; const maxPerRun = Number.isFinite(params.maxPerRunUsd) ? Math.max(params.maxPerRunUsd, 0) : 0; const amountUsd = Number.isFinite(params.amountUsd) ? Math.max(params.amountUsd, 0) : 0; const accountValue = Number.isFinite(params.accountValue) ? Math.max(params.accountValue, 0) : 0; if (params.allocationMode === "fixed") { return maxPerRun > 0 ? Math.min(amountUsd, maxPerRun) : amountUsd; } let budget = accountValue > 0 && percent > 0 ? (accountValue * percent) / 100 : 0; if (accountValue > 0 && maxPercent > 0) { budget = Math.min(budget, (accountValue * maxPercent) / 100); } if (maxPerRun > 0) { budget = Math.min(budget, maxPerRun); } return budget; } function resolvePositionSize(assetPositions: any[], symbol: string): number { const normalizedSymbol = symbol.toUpperCase(); const base = normalizedSymbol.split("-")[0]; for (const pos of assetPositions) { const coin = typeof pos.coin === "string" ? pos.coin : typeof pos?.position?.coin === "string" ? pos.position.coin : ""; if (!coin) continue; const coinNorm = coin.toUpperCase(); if (coinNorm === normalizedSymbol || coinNorm === base || coinNorm.startsWith(base)) { const rawSize = pos.szi ?? pos.position?.szi ?? 0; const parsed = Number.parseFloat(String(rawSize)); if (Number.isFinite(parsed)) return parsed; } } return 0; } function extractOrderIds(result: HyperliquidOrderResponse | unknown): { cloids: string[]; oids: string[]; } { const cloids = new Set<string>(); const oids = new Set<string>(); const push = (value: unknown, target: Set<string>) => { if (value === null || value === undefined) return; const str = String(value); if (str.length) target.add(str); }; const statuses = (result as any)?.response?.data?.statuses as | Array<{ resting?: { cloid?: string | number; oid?: string | number }; filled?: { cloid?: string | number; oid?: string | number }; }> | undefined; if (!Array.isArray(statuses)) { return { cloids: [], oids: [] }; } for (const status of statuses) { push(status.resting?.cloid, cloids); push(status.resting?.oid, oids); push(status.filled?.cloid, cloids); push(status.filled?.oid, oids); } return { cloids: Array.from(cloids), oids: Array.from(oids), }; } export async function GET(_req: Request) { const configRaw = process.env[CONFIG_ENV_NAME] ?? null; const config = readConfig(); try { const environment = config.environment ?? DEFAULT_ENVIRONMENT; const allocationMode = config.allocationMode ?? DEFAULT_ALLOCATION_MODE; const percentOfEquity = resolveNumber( config.percentOfEquity, DEFAULT_PERCENT_OF_EQUITY ); const maxPercentOfEquity = resolveNumber( config.maxPercentOfEquity, DEFAULT_MAX_PERCENT_OF_EQUITY ); const maxPerRunUsd = resolveNumber(config.maxPerRunUsd, DEFAULT_MAX_PER_RUN_USD); const amountUsd = resolveNumber(config.amountUsd, DEFAULT_AMOUNT_USD); const slippageBps = config.slippageBps ?? DEFAULT_SLIPPAGE_BPS; const momentumTilt = config.momentumTilt ?? config.rebalance ?? false; const schedule = config.schedule ?? "weekly"; const chainConfig = resolveChainConfig(environment); const ctx = await wallet({ chain: chainConfig.chain }); const rawSymbols = (config.symbols?.length ? config.symbols : DEFAULT_SYMBOLS) as Parameters< typeof normalizeSymbolInputs >[0]; const normalized = normalizeSymbolInputs(rawSymbols).map((entry) => ({ ...entry, normalizedSymbol: normalizeCryptoSymbol(entry.symbol), })); if (normalized.length === 0) { throw new Error("No symbols configured."); } const prices = await Promise.all( normalized.map((entry) => fetchMarkPrice(entry.normalizedSymbol)) ); const priceBySymbol = new Map<string, number>(); normalized.forEach((entry, index) => { priceBySymbol.set(entry.normalizedSymbol, prices[index]); }); const sizeDecimals = await Promise.all( normalized.map((entry) => fetchSizeDecimals(entry.normalizedSymbol, environment)) ); const sizeDecimalsBySymbol = new Map<string, number>(); normalized.forEach((entry, index) => { sizeDecimalsBySymbol.set(entry.normalizedSymbol, sizeDecimals[index] ?? 0); }); const baseWeights = normalized.map((entry) => entry.weight); let momentumReturns: number[] | null = null; let targetWeights = baseWeights; if (momentumTilt) { momentumReturns = await Promise.all( normalized.map((entry) => fetchMomentumReturn( entry.normalizedSymbol, environment, MOMENTUM_LOOKBACK_DAYS ) ) ); targetWeights = applyMomentumTilt( baseWeights, momentumReturns, MOMENTUM_TILT_STRENGTH ); } let totalCurrent = 0; const currentNotional = new Map<string, number>(); const clearing = await fetchHyperliquidClearinghouseState({ environment, walletAddress: ctx.address as `0x${string}`, }); const clearingData = (clearing as any)?.data ?? clearing; const assetPositions = (clearingData as any)?.assetPositions; if (!Array.isArray(assetPositions)) { throw new Error("Hyperliquid clearinghouseState did not return assetPositions."); } const marginSummary = (clearingData as any)?.marginSummary ?? (clearingData as any)?.crossMarginSummary ?? null; const accountValue = toNumber(marginSummary?.accountValue) ?? toNumber((clearingData as any)?.crossMarginSummary?.accountValue) ?? 0; const withdrawable = toNumber((clearingData as any)?.withdrawable) ?? toNumber(marginSummary?.withdrawable) ?? toNumber((clearingData as any)?.crossMarginSummary?.withdrawable) ?? 0; const budgetUsd = resolveBudget({ allocationMode, percentOfEquity, maxPercentOfEquity, maxPerRunUsd, amountUsd, accountValue, }); const canSpend = budgetUsd > 0 && withdrawable >= budgetUsd; if (!canSpend) { await store({ source: "hyperliquid", ref: `${STRATEGY_ID}-${Date.now()}`, status: "info", walletAddress: ctx.address, action: "noop", notional: budgetUsd.toString(), network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet", metadata: { strategyId: STRATEGY_ID, environment, schedule, momentumTilt, momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS, momentumTiltStrength: MOMENTUM_TILT_STRENGTH, allocationMode, percentOfEquity, maxPercentOfEquity, maxPerRunUsd, amountUsd, accountValue, withdrawable, budgetUsd, slippageBps, skipReason: "insufficient_funds", }, }); return Response.json({ ok: true, skipped: true, reason: "insufficient_funds", strategyId: STRATEGY_ID, environment, schedule, allocationMode, percentOfEquity, maxPercentOfEquity, maxPerRunUsd, amountUsd, accountValue, withdrawable, budgetUsd, }); } for (const entry of normalized) { const price = priceBySymbol.get(entry.normalizedSymbol) ?? 0; const size = resolvePositionSize(assetPositions, entry.normalizedSymbol); const notional = size > 0 && Number.isFinite(price) ? size * price : 0; currentNotional.set(entry.normalizedSymbol, notional); totalCurrent += notional; } let allocationWeights = targetWeights; if (totalCurrent > 0) { const deficits = normalized.map((entry, index) => { const target = totalCurrent * targetWeights[index]; const current = currentNotional.get(entry.normalizedSymbol) ?? 0; return Math.max(target - current, 0); }); const deficitSum = deficits.reduce((acc, value) => acc + value, 0); if (deficitSum > 0) { allocationWeights = deficits.map((value) => value / deficitSum); } } const orders: Array<Parameters<typeof placeHyperliquidOrder>[0]["orders"][number]> = []; const allocations = normalized.map((entry, index) => { const price = priceBySymbol.get(entry.normalizedSymbol) ?? 0; const usdAllocation = budgetUsd * allocationWeights[index]; const size = price > 0 ? usdAllocation / price : 0; const szDecimals = sizeDecimalsBySymbol.get(entry.normalizedSymbol); if (szDecimals === undefined) { throw new Error(`Missing size decimals for ${entry.normalizedSymbol}.`); } const sizeStr = formatOrderSize(size, szDecimals); const sizeValue = Number.parseFloat(sizeStr) || 0; if (sizeValue > 0) { orders.push({ symbol: entry.normalizedSymbol, side: "buy", price: formatMarketablePrice(price, "buy", slippageBps), size: sizeStr, tif: "FrontendMarket", reduceOnly: false, }); } return { symbol: entry.symbol, normalizedSymbol: entry.normalizedSymbol, baseWeight: baseWeights[index], targetWeight: targetWeights[index], allocationWeight: allocationWeights[index], momentumReturn: momentumReturns ? momentumReturns[index] ?? 0 : null, usdAllocation, price, size: sizeValue, }; }); let orderResponse: HyperliquidOrderResponse | null = null; if (orders.length) { orderResponse = (await placeHyperliquidOrder({ wallet: ctx as WalletFullContext, environment, orders, })) as HyperliquidOrderResponse; } const orderIds = orderResponse ? extractOrderIds(orderResponse) : { cloids: [], oids: [] }; const ref = orderIds.cloids[0] ?? orderIds.oids[0] ?? `${STRATEGY_ID}-${Date.now()}`; await store({ source: "hyperliquid", ref, status: orders.length ? "submitted" : "info", walletAddress: ctx.address, action: orders.length ? "dca" : "noop", notional: budgetUsd.toString(), network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet", metadata: { strategyId: STRATEGY_ID, environment, schedule, momentumTilt, momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS, momentumTiltStrength: MOMENTUM_TILT_STRENGTH, allocationMode, percentOfEquity, maxPercentOfEquity, maxPerRunUsd, amountUsd, accountValue, withdrawable, budgetUsd, slippageBps, allocations, orderIds, orderResponse: orderResponse ?? undefined, }, }); return Response.json({ ok: true, strategyId: STRATEGY_ID, environment, schedule, momentumTilt, momentumLookbackDays: MOMENTUM_LOOKBACK_DAYS, allocationMode, percentOfEquity, maxPercentOfEquity, maxPerRunUsd, amountUsd, accountValue, withdrawable, budgetUsd, allocations, orders: orders.length, orderIds, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const debugConfig = configRaw ?? "null"; console.error("hyperliquid-crypto-movers.error", { message, configRaw, configParsed: config, }); return Response.json( { ok: false, error: `${message} | ${CONFIG_ENV_NAME}=${debugConfig}`, configRaw, configParsed: config, }, { status: 500 } ); } }