// Utility functions for Open Broker import type { OrderType, OrderWire, OrderRequest } from './types.js'; /** * Round price to 5 significant figures and appropriate decimals * Perps: max 6 decimals, Spot: max 8 decimals */ export function roundPrice(price: number, szDecimals: number, isSpot: boolean = false): string { // Round to 5 significant figures first const sigFigs = parseFloat(price.toPrecision(5)); // Then round to max decimals (6 for perps - szDecimals adjustment, 8 for spot) const maxDecimals = isSpot ? 8 : Math.max(0, 6 - szDecimals); return sigFigs.toFixed(maxDecimals); } /** * Round size to asset's szDecimals */ export function roundSize(size: number, szDecimals: number): string { return size.toFixed(szDecimals); } /** * Calculate slippage-adjusted price for market orders */ export function getSlippagePrice( midPrice: number, isBuy: boolean, slippageBps: number ): number { const slippageMultiplier = slippageBps / 10000; return isBuy ? midPrice * (1 + slippageMultiplier) : midPrice * (1 - slippageMultiplier); } /** * Convert order request to wire format */ export function orderToWire( order: OrderRequest, assetIndex: number, szDecimals: number ): OrderWire { const wire: OrderWire = { a: assetIndex, b: order.is_buy, p: roundPrice(order.limit_px, szDecimals), s: roundSize(order.sz, szDecimals), r: order.reduce_only ?? false, t: order.order_type, }; if (order.cloid) { wire.c = order.cloid; } return wire; } /** * Get current timestamp in milliseconds */ export function getTimestampMs(): number { return Date.now(); } /** * Format USD amount for display */ export function formatUsd(amount: number | string): string { const num = typeof amount === 'string' ? parseFloat(amount) : amount; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(num); } /** * Format percentage for display */ export function formatPercent(value: number | string, decimals: number = 2): string { const num = typeof value === 'string' ? parseFloat(value) : value; return `${(num * 100).toFixed(decimals)}%`; } /** * Format funding rate (hourly to annualized) */ export function annualizeFundingRate(hourlyRate: number | string): number { const rate = typeof hourlyRate === 'string' ? parseFloat(hourlyRate) : hourlyRate; return rate * 8760; // 24 * 365 hours } /** * Parse CLI arguments into key-value pairs */ export function parseArgs(args: string[]): Record { const result: Record = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith('--')) { const key = arg.slice(2); const nextArg = args[i + 1]; if (nextArg && !nextArg.startsWith('--')) { result[key] = nextArg; i++; } else { result[key] = true; } } } return result; } /** * Normalize a coin name: uppercase the asset, keep dex prefix lowercase. * "eth" → "ETH", "xyz:cl" → "xyz:CL", "xyz:CL" → "xyz:CL" */ export function normalizeCoin(coin: string): string { const colonIdx = coin.indexOf(':'); if (colonIdx >= 0) { return coin.slice(0, colonIdx).toLowerCase() + ':' + coin.slice(colonIdx + 1).toUpperCase(); } return coin.toUpperCase(); } /** * Sleep for specified milliseconds */ export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** Hyperliquid rejects orders below this notional (waived for reduce-only). */ export const MIN_ORDER_NOTIONAL_USD = 10; export type ParsedOrderStatus = | { kind: 'resting'; oid: number } | { kind: 'filled'; totalSz: number; avgPx: number; oid: number } /** normalTpsl children behind an unfilled parent — success states, not errors. */ | { kind: 'waiting'; state: 'waitingForFill' | 'waitingForTrigger' } | { kind: 'error'; error: string } | { kind: 'unknown'; raw: unknown }; /** * Parse a single entry of an order response's `statuses` array. Handles the * plain-string success states ("waitingForFill" / "waitingForTrigger") that * Hyperliquid returns for normalTpsl children armed behind an unfilled parent, * alongside the usual resting/filled/error objects. */ export function parseOrderStatus(status: unknown): ParsedOrderStatus { if (status === 'waitingForFill' || status === 'waitingForTrigger') { return { kind: 'waiting', state: status }; } if (status && typeof status === 'object') { const data = status as Record; if (data.resting) { return { kind: 'resting', oid: (data.resting as { oid: number }).oid }; } if (data.filled) { const f = data.filled as { totalSz: string; avgPx: string; oid: number }; return { kind: 'filled', totalSz: parseFloat(f.totalSz), avgPx: parseFloat(f.avgPx), oid: f.oid }; } if (data.error) { return { kind: 'error', error: String(data.error) }; } } return { kind: 'unknown', raw: status }; } /** * Generate a random client order ID */ export function generateCloid(): string { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 16; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } /** * Check if builder fee is approved and print warning if not * Returns true if approved, false if not */ export async function checkBuilderFeeApproval( client: { getMaxBuilderFee: () => Promise; builderAddress: string; isTestnet: boolean } ): Promise { // Skip builder fee check on testnet — builder may not have balance if (client.isTestnet) return true; const approval = await client.getMaxBuilderFee(); if (!approval) { console.log('⚠️ Builder fee not approved!'); console.log(` Run: npx tsx scripts/setup/approve-builder.ts`); console.log(` Builder: ${client.builderAddress}\n`); return false; } return true; }