OpenPond
1Branch0Tags
GL
glucryptoUpdate OPENTOOL_PUBLIC config + budget caps
e3a57cda month ago12Commits
typescript
import { z } from "zod"; export type WeightedSymbol = { symbol: string; weight: number; }; export type SymbolInput = WeightedSymbol | string; export const DEFAULT_SYMBOLS: WeightedSymbol[] = [ { symbol: "MSFT", weight: 0.2 }, { symbol: "AAPL", weight: 0.2 }, { symbol: "NVDA", weight: 0.2 }, { symbol: "AMZN", weight: 0.15 }, { symbol: "GOOGL", weight: 0.15 }, { symbol: "META", weight: 0.1 }, ]; const CONFIG_ENV = "OPENTOOL_PUBLIC_HL_TECH_DCA_CONFIG"; const numericString = z.union([z.string(), z.number()]).transform((value) => String(value)); export const weightedSymbolSchema = z.object({ symbol: z.string().min(1), weight: z.number().positive(), }); const symbolInputSchema = z.union([weightedSymbolSchema, z.string().min(1)]); export const configSchema = z.object({ environment: z.enum(["mainnet", "testnet"]).optional(), schedule: z.string().optional(), symbols: z.array(symbolInputSchema).min(1).optional(), allocationMode: z.enum(["percent", "fixed"]).optional(), percentOfEquity: numericString.optional(), maxPercentOfEquity: numericString.optional(), maxPerRunUsd: numericString.optional(), amountUsd: numericString.optional(), slippageBps: z.number().int().min(0).max(1000).optional(), momentumTilt: z.boolean().optional(), rebalance: z.boolean().optional(), }); export type TechDcaConfig = z.infer<typeof configSchema>; function parseJson(raw: string | null) { if (!raw) return null; try { return JSON.parse(raw) as unknown; } catch { return null; } } export function readConfig(): TechDcaConfig { const envConfig = parseJson(process.env[CONFIG_ENV] ?? null); const parsed = configSchema.safeParse(envConfig); return parsed.success ? parsed.data : {}; } export function resolveChainConfig(environment: "mainnet" | "testnet") { return environment === "mainnet" ? { chain: "arbitrum", rpcUrl: process.env.ARBITRUM_RPC_URL } : { chain: "arbitrum-sepolia", rpcUrl: process.env.ARBITRUM_SEPOLIA_RPC_URL }; } export function normalizeTechSymbol(symbol: string): string { const cleaned = symbol.trim().toUpperCase(); if (!cleaned) return cleaned; if (cleaned.includes(":") || cleaned.includes("-")) return cleaned; return `xyz:${cleaned}`; } export function normalizeWeights(entries: WeightedSymbol[]): WeightedSymbol[] { const cleaned = entries .map((entry) => ({ symbol: entry.symbol.trim(), weight: entry.weight, })) .filter((entry) => entry.symbol.length > 0 && Number.isFinite(entry.weight)); if (cleaned.length === 0) return []; const total = cleaned.reduce((acc, entry) => acc + entry.weight, 0); if (!Number.isFinite(total) || total <= 0) { const weight = Number((1 / cleaned.length).toFixed(6)); return cleaned.map((entry) => ({ ...entry, weight })); } return cleaned.map((entry) => ({ ...entry, weight: entry.weight / total, })); } export function normalizeSymbolInputs(entries: SymbolInput[]): WeightedSymbol[] { const normalized = entries .map((entry) => { if (typeof entry === "string") { return { symbol: entry.trim(), weight: 1 }; } return { symbol: entry.symbol.trim(), weight: entry.weight, }; }) .filter((entry) => entry.symbol.length > 0 && Number.isFinite(entry.weight)); return normalizeWeights(normalized); } export function normalizeWeightValues(weights: number[]): number[] { if (weights.length === 0) return []; const total = weights.reduce((acc, value) => acc + value, 0); if (!Number.isFinite(total) || total <= 0) { const fallback = Number((1 / weights.length).toFixed(6)); return weights.map(() => fallback); } return weights.map((value) => value / total); } function countDecimals(value: number): number { if (!Number.isFinite(value)) return 0; const s = value.toString(); const [, dec = ""] = s.split("."); return dec.length; } export function formatMarketablePrice( mid: number, side: "buy" | "sell", slippageBps: number ): string { const decimals = countDecimals(mid); const factor = 10 ** decimals; const adjusted = mid * (side === "buy" ? 1 + slippageBps / 10_000 : 1 - slippageBps / 10_000); const scaled = adjusted * factor; const rounded = side === "buy" ? Math.ceil(scaled) / factor : Math.floor(scaled) / factor; return rounded.toString(); } type MarketListResponse = { markets?: Array<{ symbol?: string; price?: number | null; }>; }; const MARKET_LIST_TTL_MS = 10_000; let cachedMarketList: { fetchedAt: number; prices: Map<string, number> } | null = null; async function fetchMarketListPrices(): Promise<Map<string, number>> { if (cachedMarketList && Date.now() - cachedMarketList.fetchedAt < MARKET_LIST_TTL_MS) { return cachedMarketList.prices; } const gatewayBase = process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, ""); if (!gatewayBase) { throw new Error("OPENPOND_GATEWAY_URL is not configured."); } const url = `${gatewayBase}/v1/hyperliquid/market-list`; const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch market list (${res.status}).`); } const data = (await res.json().catch(() => null)) as MarketListResponse | null; const markets = Array.isArray(data?.markets) ? data?.markets : []; const prices = new Map<string, number>(); for (const market of markets) { const symbol = typeof market.symbol === "string" ? market.symbol.trim() : ""; const price = typeof market.price === "number" ? market.price : null; if (!symbol || price == null || price <= 0) continue; prices.set(symbol.toLowerCase(), price); } cachedMarketList = { fetchedAt: Date.now(), prices }; return prices; } export async function fetchMarkPrice(symbol: string) { const prices = await fetchMarketListPrices(); const key = symbol.trim().toLowerCase(); const price = prices.get(key); if (price == null || price <= 0) { throw new Error(`Gateway did not return a price for ${symbol}.`); } return price; } type PerpMetaResponse = { universe?: Array<{ name?: string; szDecimals?: number; isDelisted?: boolean; }>; }; const META_CACHE_TTL_MS = 5 * 60 * 1000; const metaCache = new Map< string, { fetchedAt: number; universe: Array<{ name: string; szDecimals: number; isDelisted: boolean }>; } >(); function resolveHyperliquidApiBase(environment: "mainnet" | "testnet") { return environment === "mainnet" ? "https://api.hyperliquid.xyz" : "https://api.hyperliquid-testnet.xyz"; } function extractDex(symbol: string): string | null { const idx = symbol.indexOf(":"); if (idx <= 0) return null; const dex = symbol.slice(0, idx).trim().toLowerCase(); return dex || null; } async function fetchPerpUniverse( environment: "mainnet" | "testnet", dex?: string | null ) { const dexKey = dex ? dex.trim().toLowerCase() : ""; const cacheKey = `${environment}:${dexKey}`; const cached = metaCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < META_CACHE_TTL_MS) { return cached.universe; } const baseUrl = resolveHyperliquidApiBase(environment); const payload = dexKey ? { type: "meta", dex: dexKey } : { type: "meta" }; const res = await fetch(`${baseUrl}/info`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const json = (await res.json().catch(() => null)) as PerpMetaResponse | null; const universe = Array.isArray(json?.universe) ? json?.universe : null; if (!res.ok || !universe) { throw new Error(`Failed to load Hyperliquid perp metadata (${res.status}).`); } const normalized = universe .map((entry) => ({ name: typeof entry.name === "string" ? entry.name : "", szDecimals: typeof entry.szDecimals === "number" ? entry.szDecimals : NaN, isDelisted: Boolean(entry.isDelisted), })) .filter( (entry) => entry.name.length > 0 && Number.isFinite(entry.szDecimals) ) as Array<{ name: string; szDecimals: number; isDelisted: boolean }>; metaCache.set(cacheKey, { fetchedAt: Date.now(), universe: normalized }); return normalized; } export async function fetchSymbolMeta( symbol: string, environment: "mainnet" | "testnet" ): Promise<{ szDecimals: number; isDelisted: boolean }> { const dex = extractDex(symbol); const universe = await fetchPerpUniverse(environment, dex); const match = universe.find( (entry) => entry.name.toUpperCase() === symbol.toUpperCase() ); if (!match) { throw new Error(`No size decimals found for ${symbol}.`); } return { szDecimals: match.szDecimals, isDelisted: match.isDelisted }; } export async function fetchSizeDecimals( symbol: string, environment: "mainnet" | "testnet" ) { const meta = await fetchSymbolMeta(symbol, environment); return meta.szDecimals; } type CandleSnapshot = { t?: number; time?: number; c?: string; close?: string; }; export const MOMENTUM_LOOKBACK_DAYS = 7; export const MOMENTUM_TILT_STRENGTH = 0.5; function normalizeCandleTime(rawTime: number): number { return rawTime > 1_000_000_000_000 ? rawTime : rawTime * 1000; } export async function fetchMomentumReturn( symbol: string, environment: "mainnet" | "testnet", lookbackDays: number ): Promise<number> { const baseUrl = resolveHyperliquidApiBase(environment); const endTime = Date.now(); const startTime = endTime - lookbackDays * 24 * 60 * 60 * 1000; const res = await fetch(`${baseUrl}/info`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "candleSnapshot", req: { coin: symbol, interval: "1d", startTime, endTime, }, }), }); const data = (await res.json().catch(() => null)) as CandleSnapshot[] | null; if (!res.ok || !Array.isArray(data)) { throw new Error(`Failed to load momentum candles for ${symbol} (${res.status}).`); } const candles = data .map((candle) => { const rawTime = candle.t ?? candle.time ?? 0; const close = Number.parseFloat(candle.c ?? candle.close ?? "0"); return { time: normalizeCandleTime(rawTime), close, }; }) .filter((candle) => Number.isFinite(candle.close) && candle.close > 0) .sort((a, b) => a.time - b.time); if (candles.length < 2) return 0; const start = candles[0]?.close ?? 0; const end = candles[candles.length - 1]?.close ?? 0; if (!Number.isFinite(start) || start <= 0 || !Number.isFinite(end)) return 0; return (end - start) / start; } export function applyMomentumTilt( baseWeights: number[], returns: number[], strength: number ): number[] { if (baseWeights.length === 0 || baseWeights.length !== returns.length) { return baseWeights; } const min = Math.min(...returns); const max = Math.max(...returns); if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) { return baseWeights; } const scaled = returns.map((value) => (value - min) / (max - min)); const multipliers = scaled.map((value) => 1 + strength * value); const tilted = baseWeights.map((weight, index) => weight * multipliers[index]); return normalizeWeightValues(tilted); } export function formatOrderSize(value: number, szDecimals: number): string { if (!Number.isFinite(value) || value <= 0) return "0"; const factor = 10 ** szDecimals; const rounded = Math.floor(value * factor) / factor; const fixed = rounded.toFixed(szDecimals); return fixed.replace(/\.?0+$/, ""); }