OpenPond
1Branch0Tags
GL
glucryptochore: bump opentool to v0.8.28 and add template p...
b9421303 days ago2Commits
typescript
import { z } from "zod"; import { store } from "opentool/store"; import { wallet } from "opentool/wallet"; import type { WalletFullContext } from "opentool/wallet"; import { buildHyperliquidProfileAssets, HyperliquidApiError, fetchHyperliquidClearinghouseState, fetchHyperliquidSpotClearinghouseState, normalizeHyperliquidBaseSymbol, placeHyperliquidOrder, updateHyperliquidLeverage, buildHyperliquidMarketIdentity, type HyperliquidOrderResponse, type HyperliquidEnvironment, } from "opentool/adapters/hyperliquid"; import { computeAtr } from "./indicators/computeAtr"; import { computeBollinger } from "./indicators/computeBollinger"; import { computeEma } from "./indicators/computeEma"; import { computeMacd } from "./indicators/computeMacd"; import { computeMaCross } from "./indicators/computeMaCross"; import { computePriceChange } from "./indicators/computePriceChange"; import { computeRsi } from "./indicators/computeRsi"; import { computeSma } from "./indicators/computeSma"; import { DEFAULT_SLIPPAGE_BPS, extractOrderIds, fetchSpotAccountValue, fetchSizeDecimals, formatOrderSize, formatMarketablePrice, isSpotSymbol, readAccountValue, readPositionSize, readSpotPositionSize, resolveChainConfig, resolveHyperliquidSymbol, } from "./hyperliquid"; import type { Bar } from "./types"; export type ScheduleConfig = { cron: string; enabled: boolean; notifyEmail: boolean; }; export type IndicatorType = | "rsi" | "macd" | "bb" | "price-change" | "sma" | "ema" | "ma-cross" | "atr"; export type ExecutionConfig = { enabled?: boolean; environment?: "testnet" | "mainnet"; symbol?: string; mode?: "long-only" | "long-short"; size?: number; leverage?: number; slippageBps?: number; indicator?: IndicatorType; }; export type SignalBotConfig = { configVersion?: number; platform: "hyperliquid"; asset: string; signalType: "dca"; indicators: IndicatorType[]; cadence: "daily" | "hourly" | "weekly" | "twice-weekly" | "monthly"; hourlyInterval?: number; allocationMode: "percent_equity" | "fixed"; percentOfEquity: number; maxPercentOfEquity: number; amountUsd?: number; schedule: ScheduleConfig; resolution: "1" | "5" | "15" | "30" | "60" | "240" | "1D" | "1W"; countBack: number; execution?: ExecutionConfig; price?: { rsiPreset?: string; rsi?: { overbought: number; oversold: number }; movingAverage?: { type: "sma" | "ema"; period: number }; maCross?: { type: "sma" | "ema"; fastPeriod: number; slowPeriod: number }; bollinger?: { period: number; stdDev: number }; priceChange?: { lookback: number }; atr?: { period: number }; }; dca?: { preset?: string; symbols?: Array<{ symbol: string; weight: number }>; slippageBps?: number; }; }; const CONFIG_ENV = "OPENTOOL_PUBLIC_HL_DCA_AGENT_CONFIG"; const DEFAULT_ASSET = "BTC"; const DEFAULT_SIGNAL_TYPE: SignalBotConfig["signalType"] = "dca"; const DEFAULT_CADENCE: SignalBotConfig["cadence"] = "daily"; const DEFAULT_PERCENT_OF_EQUITY = 2; const DEFAULT_MAX_PERCENT_OF_EQUITY = 10; const DEFAULT_AMOUNT_USD = 200; const DEFAULT_HOURLY_INTERVAL = 1; const DEFAULT_RESOLUTION: SignalBotConfig["resolution"] = "60"; const DEFAULT_COUNT_BACK = 240; const DEFAULT_RSI_PRESET = "balanced"; const DEFAULT_RSI_OVERBOUGHT = 70; const DEFAULT_RSI_OVERSOLD = 30; const DEFAULT_SMA_PERIOD = 200; const DEFAULT_EMA_PERIOD = 200; const DEFAULT_MA_CROSS_FAST = 50; const DEFAULT_MA_CROSS_SLOW = 200; const DEFAULT_BB_PERIOD = 20; const DEFAULT_BB_STD_DEV = 2; const DEFAULT_ATR_PERIOD = 14; const DEFAULT_PRICE_CHANGE_BARS = 24; const DEFAULT_EXECUTION_ENV: ExecutionConfig["environment"] = "mainnet"; const DEFAULT_EXECUTION_MODE: ExecutionConfig["mode"] = "long-only"; const TEMPLATE_CONFIG_VERSION = 3; const TEMPLATE_CONFIG_ENV_VAR = "OPENTOOL_PUBLIC_HL_DCA_AGENT_CONFIG"; const CADENCE_TO_CRON: Record<SignalBotConfig["cadence"], string> = { daily: "0 8 * * *", hourly: "0 * * * *", weekly: "0 8 * * 1", "twice-weekly": "0 8 * * 1,4", monthly: "0 8 1 * *", }; function resolveHyperliquidErrorDetail(error: unknown): unknown | null { if (error instanceof HyperliquidApiError) { return error.response ?? null; } if (error && typeof error === "object" && "response" in error) { return (error as { response?: unknown }).response ?? null; } return null; } function resolveLeverageMode(symbol: string): "cross" | "isolated" { return symbol.includes(":") ? "isolated" : "cross"; } const TEMPLATE_CONFIG_DEFAULTS: SignalBotConfig = { configVersion: TEMPLATE_CONFIG_VERSION, platform: "hyperliquid", signalType: DEFAULT_SIGNAL_TYPE, asset: DEFAULT_ASSET, indicators: [], cadence: "weekly", allocationMode: "fixed", percentOfEquity: DEFAULT_PERCENT_OF_EQUITY, maxPercentOfEquity: DEFAULT_MAX_PERCENT_OF_EQUITY, amountUsd: DEFAULT_AMOUNT_USD, schedule: { cron: CADENCE_TO_CRON.weekly, enabled: false, notifyEmail: false, }, resolution: DEFAULT_RESOLUTION, countBack: DEFAULT_COUNT_BACK, dca: { preset: DEFAULT_ASSET, symbols: [{ symbol: DEFAULT_ASSET, weight: 1 }], slippageBps: DEFAULT_SLIPPAGE_BPS, }, execution: { enabled: false, environment: DEFAULT_EXECUTION_ENV, mode: DEFAULT_EXECUTION_MODE, }, }; const TEMPLATE_CONFIG_SCHEMA = { type: "object", "x-budget": { modeField: "allocationMode", defaultMode: "fixed", title: "Budget & allocation", description: "Core exposure settings are shown first.", modes: { fixed: { fields: ["amountUsd"], }, percent_equity: { fields: ["percentOfEquity", "maxPercentOfEquity"], }, }, }, properties: { configVersion: { type: "number", title: "Config version", description: "Internal version for dca-agent defaults.", readOnly: true, "x-hidden": true, "x-section": "Meta", "x-order": 1000, }, platform: { type: "string", enum: ["hyperliquid"], title: "Platform", description: "Execution venue for this strategy.", readOnly: true, "x-hidden": true, "x-section": "Meta", "x-order": 1001, }, signalType: { type: "string", enum: ["dca"], title: "Signal type", description: "Signal source used to trigger decisions.", "x-enumLabels": ["DCA"], readOnly: true, "x-hidden": true, "x-section": "Strategy", "x-order": 1, }, asset: { type: "string", title: "Asset", description: "Default asset symbol (example: BTC).", "x-section": "Strategy", "x-order": 2, }, indicators: { type: "array", title: "Indicators", description: "Indicators to evaluate for price-based signals.", items: { type: "string", enum: ["rsi", "macd", "bb", "price-change", "sma", "ema", "ma-cross", "atr"], }, "x-hidden": true, "x-section": "Strategy", "x-order": 3, }, cadence: { type: "string", enum: ["daily", "hourly", "weekly", "twice-weekly", "monthly"], title: "Cadence", description: "How often the bot should run.", "x-enumLabels": ["Daily", "Hourly", "Weekly", "Twice weekly", "Monthly"], "x-section": "Schedule", "x-order": 1, }, hourlyInterval: { type: "number", title: "Hourly interval", description: "For hourly cadence, run every N hours.", minimum: 1, maximum: 24, "x-unit": "hours", "x-step": 1, "x-visibleIf": { field: "cadence", equals: "hourly" }, "x-section": "Schedule", "x-order": 2, }, allocationMode: { type: "string", enum: ["percent_equity", "fixed"], title: "Allocation mode", description: "Position sizing method.", "x-enumLabels": ["Percent of equity", "Fixed USD"], "x-section": "Risk", "x-order": 1, }, percentOfEquity: { type: "number", title: "Percent of equity", description: "Target percent of account value per run.", minimum: 0.01, "x-unit": "%", "x-format": "percent", "x-step": 0.1, "x-visibleIf": { field: "allocationMode", equals: "percent_equity" }, "x-section": "Risk", "x-order": 2, }, maxPercentOfEquity: { type: "number", title: "Max percent of equity", description: "Upper cap for percent-based sizing.", minimum: 0.01, "x-unit": "%", "x-format": "percent", "x-step": 0.1, "x-visibleIf": { field: "allocationMode", equals: "percent_equity" }, "x-section": "Risk", "x-order": 3, }, amountUsd: { type: "number", title: "Fixed amount", description: "USD notional per run when allocation mode is fixed.", minimum: 1, "x-unit": "USD", "x-format": "currency", "x-step": 1, "x-visibleIf": { field: "allocationMode", equals: "fixed" }, "x-section": "Risk", "x-order": 4, }, schedule: { type: "object", title: "Schedule", description: "Cron and notification settings.", "x-section": "Schedule", properties: { cron: { type: "string", title: "Cron expression", description: "Cron expression for automated runs.", "x-order": 3, }, enabled: { type: "boolean", title: "Enabled", description: "Enable scheduled runs.", "x-order": 4, }, notifyEmail: { type: "boolean", title: "Notify email", description: "Send an email after scheduled runs.", "x-order": 5, }, }, }, resolution: { type: "string", enum: ["1", "5", "15", "30", "60", "240", "1D", "1W"], title: "Resolution", description: "Bar timeframe for price data.", "x-section": "Price model", "x-order": 1, }, countBack: { type: "number", title: "Bars to fetch", description: "Number of historical bars for indicator calculations.", minimum: 50, maximum: 5000, "x-step": 1, "x-section": "Price model", "x-order": 2, }, price: { type: "object", title: "Price settings", description: "Indicator-specific tuning.", "x-section": "Price model", properties: { rsiPreset: { type: "string", title: "RSI preset", description: "Preset thresholds for RSI.", "x-visibleIf": { field: "indicators", contains: "rsi" }, "x-order": 10, }, rsi: { type: "object", title: "RSI thresholds", description: "Custom RSI overbought/oversold values.", properties: { overbought: { type: "number", title: "RSI overbought", description: "Overbought threshold.", minimum: 1, maximum: 100, "x-visibleIf": { field: "indicators", contains: "rsi" }, "x-order": 11, }, oversold: { type: "number", title: "RSI oversold", description: "Oversold threshold.", minimum: 1, maximum: 100, "x-visibleIf": { field: "indicators", contains: "rsi" }, "x-order": 12, }, }, }, movingAverage: { type: "object", title: "Moving average", description: "Configuration for SMA/EMA checks.", properties: { type: { type: "string", enum: ["sma", "ema"], title: "Average type", description: "Moving average type.", "x-visibleIf": { field: "indicators", containsAny: ["sma", "ema"] }, "x-order": 20, }, period: { type: "number", title: "Average period", description: "Lookback period for moving average.", minimum: 2, maximum: 240, "x-step": 1, "x-visibleIf": { field: "indicators", containsAny: ["sma", "ema"] }, "x-order": 21, }, }, }, maCross: { type: "object", title: "MA cross", description: "Fast/slow moving average cross settings.", properties: { type: { type: "string", enum: ["sma", "ema"], title: "Cross type", description: "Moving average type used in cross logic.", "x-visibleIf": { field: "indicators", contains: "ma-cross" }, "x-order": 30, }, fastPeriod: { type: "number", title: "Fast period", description: "Fast moving average lookback.", minimum: 2, maximum: 239, "x-step": 1, "x-visibleIf": { field: "indicators", contains: "ma-cross" }, "x-order": 31, }, slowPeriod: { type: "number", title: "Slow period", description: "Slow moving average lookback.", minimum: 3, maximum: 240, "x-step": 1, "x-visibleIf": { field: "indicators", contains: "ma-cross" }, "x-order": 32, }, }, }, bollinger: { type: "object", title: "Bollinger bands", description: "Period and standard deviation settings.", properties: { period: { type: "number", title: "BB period", description: "Lookback period for Bollinger bands.", minimum: 5, maximum: 240, "x-step": 1, "x-visibleIf": { field: "indicators", contains: "bb" }, "x-order": 40, }, stdDev: { type: "number", title: "BB std dev", description: "Standard deviation multiplier.", minimum: 0.5, maximum: 5, "x-step": 0.1, "x-visibleIf": { field: "indicators", contains: "bb" }, "x-order": 41, }, }, }, priceChange: { type: "object", title: "Price change", description: "Lookback used for momentum direction.", properties: { lookback: { type: "number", title: "Lookback bars", description: "Bars used for percent-change signal.", minimum: 1, maximum: 240, "x-step": 1, "x-visibleIf": { field: "indicators", contains: "price-change" }, "x-order": 50, }, }, }, atr: { type: "object", title: "ATR", description: "Average True Range settings.", properties: { period: { type: "number", title: "ATR period", description: "Lookback period for ATR.", minimum: 2, maximum: 240, "x-step": 1, "x-visibleIf": { field: "indicators", contains: "atr" }, "x-order": 60, }, }, }, }, }, dca: { type: "object", title: "DCA settings", description: "Dollar-cost averaging options.", "x-section": "DCA", properties: { preset: { type: "string", title: "DCA preset", description: "Named preset for DCA behavior.", "x-order": 1, }, symbols: { type: "array", title: "Symbol weights", description: "Comma-separated list like BTC:1,ETH:0.5,SOL:0.25.", items: { type: "string", }, "x-order": 2, }, slippageBps: { type: "number", title: "DCA slippage", description: "Max slippage for DCA execution.", minimum: 0, maximum: 5000, "x-unit": "bps", "x-format": "bps", "x-step": 1, "x-order": 3, }, }, }, execution: { type: "object", title: "Execution", description: "Live trading controls and routing.", "x-section": "Execution", properties: { enabled: { type: "boolean", title: "Execution enabled", description: "Submit live orders when signals trigger.", "x-order": 1, }, environment: { type: "string", enum: ["testnet", "mainnet"], title: "Environment", description: "Execution environment for Hyperliquid.", "x-enumLabels": ["Testnet", "Mainnet"], "x-order": 2, }, symbol: { type: "string", title: "Execution symbol", description: "Optional explicit trading symbol override.", "x-order": 3, }, mode: { type: "string", enum: ["long-only", "long-short"], title: "Execution mode", description: "Allow long-only or long-short positioning.", "x-enumLabels": ["Long only", "Long + short"], "x-order": 4, }, size: { type: "number", title: "Explicit size", description: "Optional explicit position size override.", minimum: 0, "x-step": 0.0001, "x-order": 5, }, indicator: { type: "string", enum: ["rsi", "macd", "bb", "price-change", "sma", "ema", "ma-cross", "atr"], title: "Execution indicator", description: "Indicator used to decide trade direction.", "x-order": 6, }, leverage: { type: "number", title: "Leverage", description: "Target leverage for perpetual execution.", minimum: 1, maximum: 50, "x-unit": "x", "x-step": 1, "x-order": 7, }, slippageBps: { type: "number", title: "Execution slippage", description: "Max slippage for signal-driven orders.", minimum: 0, maximum: 500, "x-unit": "bps", "x-format": "bps", "x-step": 1, "x-order": 8, }, }, }, }, }; export const SIGNAL_BOT_TEMPLATE_CONFIG = { version: TEMPLATE_CONFIG_VERSION, schema: TEMPLATE_CONFIG_SCHEMA, defaults: TEMPLATE_CONFIG_DEFAULTS, envVar: TEMPLATE_CONFIG_ENV_VAR, }; const RSI_PRESETS: Record<string, { overbought: number; oversold: number }> = { balanced: { overbought: 70, oversold: 30 }, tighter: { overbought: 65, oversold: 35 }, wider: { overbought: 80, oversold: 20 }, }; const LIMITS = { movingAverage: { min: 2, max: 240 }, maCross: { fastMin: 2, fastMax: 239, slowMin: 3, slowMax: 240 }, bollinger: { periodMin: 5, periodMax: 240, stdDevMin: 0.5, stdDevMax: 5 }, atr: { min: 2, max: 240 }, priceChange: { min: 1, max: 240 }, } as const; const indicatorSchema = z.enum([ "rsi", "macd", "bb", "price-change", "sma", "ema", "ma-cross", "atr", ]); const configSchema = z .object({ platform: z.literal("hyperliquid").optional(), asset: z.string().min(1).optional(), signalType: z.literal("dca").optional(), indicators: z.array(indicatorSchema).optional(), cadence: z.enum(["daily", "hourly", "weekly", "twice-weekly", "monthly"]).optional(), hourlyInterval: z.number().int().min(1).max(24).optional(), allocationMode: z.enum(["percent_equity", "fixed", "percent"]).optional(), percentOfEquity: z.number().positive().optional(), maxPercentOfEquity: z.number().positive().optional(), amountUsd: z.number().positive().optional(), schedule: z .object({ cron: z.string().min(1).optional(), enabled: z.boolean().optional(), notifyEmail: z.boolean().optional(), }) .optional(), resolution: z.enum(["1", "5", "15", "30", "60", "240", "1D", "1W"]).optional(), countBack: z.number().int().min(50).max(5000).optional(), price: z .object({ rsiPreset: z.string().optional(), rsi: z .object({ overbought: z.number().optional(), oversold: z.number().optional(), }) .optional(), movingAverage: z .object({ type: z.enum(["sma", "ema"]).optional(), period: z .number() .int() .min(LIMITS.movingAverage.min) .max(LIMITS.movingAverage.max) .optional(), }) .optional(), maCross: z .object({ type: z.enum(["sma", "ema"]).optional(), fastPeriod: z .number() .int() .min(LIMITS.maCross.fastMin) .max(LIMITS.maCross.fastMax) .optional(), slowPeriod: z .number() .int() .min(LIMITS.maCross.slowMin) .max(LIMITS.maCross.slowMax) .optional(), }) .optional(), bollinger: z .object({ period: z .number() .int() .min(LIMITS.bollinger.periodMin) .max(LIMITS.bollinger.periodMax) .optional(), stdDev: z .number() .min(LIMITS.bollinger.stdDevMin) .max(LIMITS.bollinger.stdDevMax) .optional(), }) .optional(), priceChange: z .object({ lookback: z .number() .int() .min(LIMITS.priceChange.min) .max(LIMITS.priceChange.max) .optional(), }) .optional(), atr: z .object({ period: z .number() .int() .min(LIMITS.atr.min) .max(LIMITS.atr.max) .optional(), }) .optional(), }) .optional(), execution: z .object({ enabled: z.boolean().optional(), environment: z.enum(["testnet", "mainnet"]).optional(), symbol: z.string().optional(), mode: z.enum(["long-only", "long-short"]).optional(), size: z.number().positive().optional(), leverage: z.number().positive().optional(), slippageBps: z.number().min(0).max(500).optional(), indicator: indicatorSchema.optional(), }) .optional(), dca: z .object({ preset: z.string().optional(), symbols: z .array( z.union([ z.string().min(1), z.object({ symbol: z.string().min(1), weight: z.number().positive().optional(), }), ]) ) .optional(), slippageBps: z.number().optional(), }) .optional(), }) .partial(); function parseJson(raw: string | null) { if (!raw) return null; try { return JSON.parse(raw) as unknown; } catch { return null; } } function resolveHourlyInterval(input?: number) { const parsed = Number.isFinite(input ?? Number.NaN) ? Number(input) : DEFAULT_HOURLY_INTERVAL; return Math.min(Math.max(Math.trunc(parsed), 1), 24); } function resolveCadenceCron( cadence: SignalBotConfig["cadence"], hourlyInterval?: number ) { if (cadence !== "hourly") return CADENCE_TO_CRON[cadence]; const interval = resolveHourlyInterval(hourlyInterval); return interval === 1 ? CADENCE_TO_CRON.hourly : `0 */${interval} * * *`; } function resolveCadenceFromResolution( resolution: SignalBotConfig["resolution"] ): { cadence: SignalBotConfig["cadence"]; hourlyInterval?: number } { if (resolution === "60") { return { cadence: "hourly", hourlyInterval: 1 }; } if (resolution === "240") { return { cadence: "hourly", hourlyInterval: 4 }; } if (resolution === "1W") { return { cadence: "weekly" }; } return { cadence: "daily" }; } function clampInt(value: unknown, min: number, max: number, fallback: number): number { if (!Number.isFinite(value)) return fallback; const numeric = Math.trunc(Number(value)); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, numeric)); } function clampFloat(value: unknown, min: number, max: number, fallback: number): number { if (!Number.isFinite(value)) return fallback; const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, numeric)); } type DcaSymbolInput = { symbol: string; weight?: number } | string; function resolveDcaSymbolEntries( inputs: DcaSymbolInput[] | undefined, fallbackSymbol: string ): Array<{ symbol: string; weight: number }> { const entries: Array<{ symbol: string; weight: number }> = []; const values = Array.isArray(inputs) ? inputs : []; for (const input of values) { if (typeof input === "string") { const trimmed = input.trim(); if (!trimmed) continue; const [rawSymbol, rawWeight] = trimmed.split(":"); const symbol = rawSymbol?.trim(); if (!symbol) continue; const parsedWeight = typeof rawWeight === "string" && rawWeight.trim().length > 0 ? Number.parseFloat(rawWeight.trim()) : 1; const weight = Number.isFinite(parsedWeight) && parsedWeight > 0 ? parsedWeight : 1; entries.push({ symbol, weight }); continue; } if (!input || typeof input !== "object") continue; const symbol = input.symbol?.trim(); if (!symbol) continue; const parsedWeight = typeof input.weight === "number" && Number.isFinite(input.weight) ? input.weight : 1; const weight = parsedWeight > 0 ? parsedWeight : 1; entries.push({ symbol, weight }); } if (entries.length > 0) { return entries; } return [{ symbol: fallbackSymbol, weight: 1 }]; } export function readConfig(): SignalBotConfig { const envConfig = parseJson(process.env[CONFIG_ENV] ?? null); const parsed = configSchema.safeParse(envConfig); const input = parsed.success ? parsed.data : {}; const signalType: SignalBotConfig["signalType"] = "dca"; const resolution = input.resolution ?? DEFAULT_RESOLUTION; const cadenceOverrides: null = null; const cadence = input.cadence ?? "weekly"; const allocationMode: SignalBotConfig["allocationMode"] = input.allocationMode === "percent_equity" || input.allocationMode === "percent" ? "percent_equity" : "fixed"; const percentOfEquity = input.percentOfEquity ?? DEFAULT_PERCENT_OF_EQUITY; const maxPercentOfEquity = input.maxPercentOfEquity ?? DEFAULT_MAX_PERCENT_OF_EQUITY; const amountUsd = allocationMode === "fixed" ? input.amountUsd ?? DEFAULT_AMOUNT_USD : undefined; const hourlyInterval = cadence === "hourly" ? resolveHourlyInterval(input.hourlyInterval) : undefined; const schedule = { cron: input.schedule?.cron ?? resolveCadenceCron(cadence, hourlyInterval), enabled: input.schedule?.enabled ?? false, notifyEmail: input.schedule?.notifyEmail ?? false, }; const base = { platform: "hyperliquid" as const, cadence, hourlyInterval, allocationMode, percentOfEquity, maxPercentOfEquity, ...(amountUsd ? { amountUsd } : {}), schedule, resolution, countBack: input.countBack ?? DEFAULT_COUNT_BACK, }; const execution = input.execution ? { enabled: input.execution.enabled ?? false, environment: input.execution.environment ?? DEFAULT_EXECUTION_ENV, mode: input.execution.mode ?? DEFAULT_EXECUTION_MODE, slippageBps: clampInt( input.execution.slippageBps, 0, 500, DEFAULT_SLIPPAGE_BPS ), ...(input.execution.symbol ? { symbol: input.execution.symbol } : {}), ...(input.execution.size ? { size: input.execution.size } : {}), ...(input.execution.leverage ? { leverage: input.execution.leverage } : {}), ...(input.execution.indicator ? { indicator: input.execution.indicator } : {}), } : undefined; if (signalType === "dca") { const asset = (input.asset ?? DEFAULT_ASSET).toUpperCase(); const symbols = resolveDcaSymbolEntries( input.dca?.symbols as DcaSymbolInput[] | undefined, asset ); return { ...base, signalType: "dca", asset, indicators: [], dca: { preset: input.dca?.preset ?? asset, symbols, slippageBps: input.dca?.slippageBps ?? 50, }, ...(execution ? { execution } : {}), }; } throw new Error("Only DCA signal type is supported."); } export function resolveScheduleConfig(config: SignalBotConfig): ScheduleConfig { return config.schedule; } function resolveProfileAssetSymbols(config: SignalBotConfig): string[] { if (config.signalType === "dca") { const symbols = config.dca?.symbols?.map((entry) => entry.symbol) ?? []; const normalized = symbols .filter((symbol): symbol is string => typeof symbol === "string") .map((symbol) => symbol.trim()) .filter((symbol) => symbol.length > 0); if (normalized.length > 0) { return normalized .map((symbol) => normalizeHyperliquidBaseSymbol(symbol)) .filter((symbol): symbol is string => Boolean(symbol)); } } const fallback = config.execution?.symbol ?? config.asset ?? DEFAULT_ASSET; const normalizedFallback = normalizeHyperliquidBaseSymbol(fallback); return normalizedFallback ? [normalizedFallback] : []; } export function resolveProfileAssets(config: SignalBotConfig): Array<{ venue: "hyperliquid"; chain: "hyperliquid" | "hyperliquid-testnet"; assetSymbols: string[]; pair?: string; leverage?: number; }> { const environment = config.execution?.environment ?? DEFAULT_EXECUTION_ENV; const symbols = resolveProfileAssetSymbols(config) .map((symbol) => symbol.trim()) .filter((symbol) => symbol.length > 0); if (symbols.length === 0) { return []; } return buildHyperliquidProfileAssets({ environment, assets: [ { assetSymbols: symbols, leverage: config.execution?.leverage, }, ], }); } function resolveGatewayBase() { return process.env.OPENPOND_GATEWAY_URL?.replace(/\/$/, "") ?? null; } function normalizeGatewaySymbol(value: string): string { const trimmed = value.trim(); if (!trimmed) return trimmed; const idx = trimmed.indexOf(":"); if (idx > 0) { const dex = trimmed.slice(0, idx).toLowerCase(); const rest = trimmed.slice(idx + 1); return `${dex}:${rest.toUpperCase()}`; } return trimmed.toUpperCase(); } export async function fetchBars(params: { asset: string; resolution: SignalBotConfig["resolution"]; countBack: number; }): Promise<Bar[]> { const gatewayBase = resolveGatewayBase(); if (!gatewayBase) { throw new Error("OPENPOND_GATEWAY_URL is required."); } const url = new URL(`${gatewayBase}/v1/hyperliquid/bars`); url.searchParams.set("symbol", normalizeGatewaySymbol(params.asset)); url.searchParams.set("resolution", params.resolution); url.searchParams.set("countBack", params.countBack.toString()); const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`Gateway error (${response.status})`); } const data = (await response.json().catch(() => null)) as { bars?: Bar[] } | null; const bars = data?.bars ?? []; return bars.filter((bar) => typeof bar.close === "number"); } type TradeSignal = "buy" | "sell" | "hold" | "unknown"; type TradePlan = { side: "buy" | "sell"; size: number; reduceOnly: boolean; targetSize: number; }; type DcaEntry = { symbol: string; weight: number; normalizedWeight: number; }; function resolveTradeSignal( indicator: IndicatorType, output: Record<string, unknown> ): TradeSignal { const key = indicator === "price-change" ? "priceChange" : indicator === "ma-cross" ? "maCross" : indicator; const record = output[key as keyof typeof output]; if (!record || typeof record !== "object") return "unknown"; const signal = typeof (record as any).signal === "string" ? (record as any).signal : ""; switch (indicator) { case "rsi": if (signal === "oversold") return "buy"; if (signal === "overbought") return "sell"; return signal ? "hold" : "unknown"; case "macd": if (signal === "bullish") return "buy"; if (signal === "bearish") return "sell"; return signal ? "hold" : "unknown"; case "bb": if (signal === "oversold") return "buy"; if (signal === "overbought") return "sell"; return signal ? "hold" : "unknown"; case "price-change": if (signal === "up") return "buy"; if (signal === "down") return "sell"; return signal ? "hold" : "unknown"; case "sma": case "ema": if (signal === "above" || signal === "crossed-up") return "buy"; if (signal === "below" || signal === "crossed-down") return "sell"; return signal ? "hold" : "unknown"; case "ma-cross": if (signal === "bullish" || signal === "bullish-cross") return "buy"; if (signal === "bearish" || signal === "bearish-cross") return "sell"; return signal ? "hold" : "unknown"; case "atr": return "hold"; } } function resolveTargetSize(params: { config: SignalBotConfig; execution: ExecutionConfig; accountValue: number | null; currentPrice: number; }): { targetSize: number; budgetUsd: number } { const { config, execution, accountValue, currentPrice } = params; if (execution.size && Number.isFinite(execution.size)) { return { targetSize: execution.size, budgetUsd: execution.size * currentPrice }; } if (config.allocationMode === "fixed") { const desiredUsd = config.amountUsd ?? 0; if (!Number.isFinite(desiredUsd) || desiredUsd <= 0) { throw new Error("fixed allocation requires amountUsd"); } const budgetUsd = desiredUsd; return { targetSize: budgetUsd / currentPrice, budgetUsd }; } if (!Number.isFinite(accountValue ?? Number.NaN)) { throw new Error("percent allocation requires accountValue"); } const rawUsd = (accountValue as number) * (config.percentOfEquity / 100); const maxPercentUsd = (accountValue as number) * (config.maxPercentOfEquity / 100); const budgetUsd = Math.min(rawUsd, maxPercentUsd); return { targetSize: budgetUsd / currentPrice, budgetUsd }; } function resolveBudgetUsd(params: { config: SignalBotConfig; accountValue: number | null; }): number { const { config, accountValue } = params; if (config.allocationMode === "fixed") { const desiredUsd = config.amountUsd ?? 0; if (!Number.isFinite(desiredUsd) || desiredUsd <= 0) { throw new Error("fixed allocation requires amountUsd"); } return desiredUsd; } if (!Number.isFinite(accountValue ?? Number.NaN)) { throw new Error("percent allocation requires accountValue"); } const rawUsd = (accountValue as number) * (config.percentOfEquity / 100); const maxPercentUsd = (accountValue as number) * (config.maxPercentOfEquity / 100); return Math.min(rawUsd, maxPercentUsd); } function normalizeDcaEntries(config: SignalBotConfig): DcaEntry[] { const entries = Array.isArray(config.dca?.symbols) ? config.dca?.symbols : []; const fallbackSymbol = config.asset ?? DEFAULT_ASSET; const map = new Map<string, { symbol: string; weight: number }>(); for (const entry of entries ?? []) { if (!entry || typeof entry !== "object") continue; const record = entry as { symbol?: unknown; weight?: unknown }; if (typeof record.symbol !== "string") continue; const symbol = record.symbol.trim(); if (!symbol) continue; const key = symbol.toUpperCase(); const weight = clampFloat(record.weight, 0, 1_000_000, 0); if (weight <= 0) continue; const existing = map.get(key); if (existing) { existing.weight += weight; } else { map.set(key, { symbol, weight }); } } if (map.size === 0) { map.set(fallbackSymbol.toUpperCase(), { symbol: fallbackSymbol, weight: 1 }); } const entriesList = Array.from(map.values()); const totalWeight = entriesList.reduce((sum, entry) => sum + entry.weight, 0); if (!Number.isFinite(totalWeight) || totalWeight <= 0) { return []; } return entriesList.map((entry) => ({ symbol: entry.symbol, weight: entry.weight, normalizedWeight: entry.weight / totalWeight, })); } function planTrade(params: { signal: TradeSignal; mode: "long-only" | "long-short"; currentSize: number; targetSize: number; }): TradePlan | null { const { signal, mode, currentSize, targetSize } = params; if (signal === "hold" || signal === "unknown") return null; if (signal === "buy") { const desired = mode === "long-short" ? targetSize : Math.max(targetSize, 0); const delta = desired - currentSize; if (delta <= 0) return null; return { side: "buy", size: delta, reduceOnly: false, targetSize: desired, }; } if (mode === "long-only") { if (currentSize <= 0) return null; return { side: "sell", size: currentSize, reduceOnly: true, targetSize: 0, }; } const desired = -Math.abs(targetSize); const delta = currentSize - desired; if (delta <= 0) return null; return { side: "sell", size: delta, reduceOnly: false, targetSize: desired, }; } export async function runSignalBot(config: SignalBotConfig) { if (config.signalType === "dca") { return runDcaBot(config); } if (config.signalType !== "price") { return { ok: false, error: "Only DCA signals are supported right now.", asset: config.asset, signalType: config.signalType, }; } const bars = await fetchBars({ asset: config.asset, resolution: config.resolution, countBack: config.countBack, }); if (bars.length === 0) { return { ok: false, error: "No price data returned.", asset: config.asset, signalType: config.signalType, }; } const closes = bars.map((bar) => bar.close); const currentPrice = closes[closes.length - 1]; const asOf = new Date(bars[bars.length - 1].time).toISOString(); const output: Record<string, unknown> = {}; const priceConfig = config.price ?? {}; const rsiPreset = priceConfig.rsiPreset ?? DEFAULT_RSI_PRESET; const rsiDefaults = RSI_PRESETS[rsiPreset] ?? RSI_PRESETS[DEFAULT_RSI_PRESET]; const rsiOverbought = clampFloat( priceConfig.rsi?.overbought, 1, 100, rsiDefaults?.overbought ?? DEFAULT_RSI_OVERBOUGHT ); const rsiOversold = clampFloat( priceConfig.rsi?.oversold, 1, 100, rsiDefaults?.oversold ?? DEFAULT_RSI_OVERSOLD ); const smaPeriod = clampInt( priceConfig.movingAverage?.type === "sma" ? priceConfig.movingAverage?.period : DEFAULT_SMA_PERIOD, LIMITS.movingAverage.min, LIMITS.movingAverage.max, DEFAULT_SMA_PERIOD ); const emaPeriod = clampInt( priceConfig.movingAverage?.type === "ema" ? priceConfig.movingAverage?.period : DEFAULT_EMA_PERIOD, LIMITS.movingAverage.min, LIMITS.movingAverage.max, DEFAULT_EMA_PERIOD ); const maCrossType = priceConfig.maCross?.type ?? "sma"; const maCrossSlow = clampInt( priceConfig.maCross?.slowPeriod, LIMITS.maCross.slowMin, LIMITS.maCross.slowMax, DEFAULT_MA_CROSS_SLOW ); const maCrossFastCandidate = clampInt( priceConfig.maCross?.fastPeriod, LIMITS.maCross.fastMin, LIMITS.maCross.fastMax, DEFAULT_MA_CROSS_FAST ); const maCrossFast = maCrossFastCandidate >= maCrossSlow ? Math.max( LIMITS.maCross.fastMin, Math.min(maCrossSlow - 1, LIMITS.maCross.fastMax) ) : maCrossFastCandidate; const bbPeriod = clampInt( priceConfig.bollinger?.period, LIMITS.bollinger.periodMin, LIMITS.bollinger.periodMax, DEFAULT_BB_PERIOD ); const bbStdDev = clampFloat( priceConfig.bollinger?.stdDev, LIMITS.bollinger.stdDevMin, LIMITS.bollinger.stdDevMax, DEFAULT_BB_STD_DEV ); const atrPeriod = clampInt( priceConfig.atr?.period, LIMITS.atr.min, LIMITS.atr.max, DEFAULT_ATR_PERIOD ); const priceChangeLookback = clampInt( priceConfig.priceChange?.lookback, LIMITS.priceChange.min, LIMITS.priceChange.max, DEFAULT_PRICE_CHANGE_BARS ); if (config.indicators.includes("rsi")) { const value = computeRsi(closes); const signal = value === null ? "unknown" : value >= rsiOverbought ? "overbought" : value <= rsiOversold ? "oversold" : "neutral"; output.rsi = { value, signal, overbought: rsiOverbought, oversold: rsiOversold, }; } if (config.indicators.includes("macd")) { const result = computeMacd(closes); const signal = result === null ? "unknown" : result.histogram > 0 ? "bullish" : result.histogram < 0 ? "bearish" : "neutral"; output.macd = result ? { ...result, signal } : { macd: null, signalLine: null, histogram: null, signal }; } if (config.indicators.includes("sma")) { const value = computeSma(closes, smaPeriod); const signal = value === null ? "unknown" : currentPrice > value ? "above" : currentPrice < value ? "below" : "at"; output.sma = { value, period: smaPeriod, signal }; } if (config.indicators.includes("ema")) { const value = computeEma(closes, emaPeriod); const signal = value === null ? "unknown" : currentPrice > value ? "above" : currentPrice < value ? "below" : "at"; output.ema = { value, period: emaPeriod, signal }; } if (config.indicators.includes("ma-cross")) { const result = computeMaCross(closes, maCrossType, maCrossFast, maCrossSlow); output.maCross = result ? { ...result, type: maCrossType, fastPeriod: maCrossFast, slowPeriod: maCrossSlow, } : { type: maCrossType, fastPeriod: maCrossFast, slowPeriod: maCrossSlow, fast: null, slow: null, signal: "unknown", }; } if (config.indicators.includes("bb")) { const result = computeBollinger(closes, bbPeriod, bbStdDev); const signal = result === null ? "unknown" : currentPrice > result.upper ? "overbought" : currentPrice < result.lower ? "oversold" : "neutral"; output.bb = result ? { ...result, signal, period: bbPeriod, stdDev: bbStdDev } : { upper: null, middle: null, lower: null, signal, period: bbPeriod, stdDev: bbStdDev, }; } if (config.indicators.includes("price-change")) { const result = computePriceChange(closes, priceChangeLookback); const signal = result === null ? "unknown" : result.percent >= 0 ? "up" : "down"; output.priceChange = result ? { ...result, signal, lookback: priceChangeLookback } : { percent: null, signal, lookback: priceChangeLookback }; } if (config.indicators.includes("atr")) { const value = computeAtr(bars, atrPeriod); output.atr = value === null ? { value: null, period: atrPeriod } : { value, period: atrPeriod }; } let execution: Record<string, unknown> | undefined; let executionError: string | null = null; if (config.execution?.enabled) { const indicator = config.execution.indicator ?? config.indicators[0] ?? "rsi"; const tradeSignal = resolveTradeSignal(indicator, output); const environment = (config.execution.environment ?? DEFAULT_EXECUTION_ENV) as HyperliquidEnvironment; const mode = config.execution.mode ?? DEFAULT_EXECUTION_MODE; const slippageBps = clampInt( config.execution.slippageBps, 0, 500, DEFAULT_SLIPPAGE_BPS ); const orderSymbol = resolveHyperliquidSymbol(config.asset, config.execution.symbol); const isSpot = isSpotSymbol(orderSymbol); const baseSymbol = normalizeHyperliquidBaseSymbol(orderSymbol); const pair = resolveHyperliquidPair(orderSymbol); const leverageMode = resolveLeverageMode(orderSymbol); const leverage = config.execution.leverage; try { const chain = resolveChainConfig(environment).chain; const ctx = await wallet({ chain }); if (isSpot && orderSymbol.startsWith("@")) { throw new Error("spot execution requires BASE/QUOTE symbols (no @ ids)."); } if (isSpot && typeof leverage === "number" && Number.isFinite(leverage)) { throw new Error("leverage is not supported for spot markets."); } const clearing = isSpot ? await fetchHyperliquidSpotClearinghouseState({ environment, walletAddress: ctx.address as `0x${string}`, }) : await fetchHyperliquidClearinghouseState({ environment, walletAddress: ctx.address as `0x${string}`, }); const currentSize = isSpot ? readSpotPositionSize(clearing, orderSymbol) : readPositionSize(clearing, orderSymbol); const accountValue = isSpot ? await fetchSpotAccountValue({ environment, balances: (clearing as any)?.balances, }) : readAccountValue(clearing); const { targetSize, budgetUsd } = resolveTargetSize({ config, execution: config.execution, accountValue, currentPrice, }); const plan = planTrade({ signal: tradeSignal, mode, currentSize, targetSize, }); const orderResponses: HyperliquidOrderResponse[] = []; let sizeDecimals: number | undefined; if (!isSpot && typeof leverage === "number" && Number.isFinite(leverage)) { await updateHyperliquidLeverage({ wallet: ctx as WalletFullContext, environment, input: { symbol: orderSymbol, leverageMode, leverage, }, }); } if (plan) { const price = formatMarketablePrice(currentPrice, plan.side, slippageBps); sizeDecimals = await fetchSizeDecimals(orderSymbol, environment); const size = formatOrderSize(plan.size, sizeDecimals); if (size === "0") { throw new Error( `Order size too small for ${orderSymbol} (szDecimals=${sizeDecimals}).` ); } const response = await placeHyperliquidOrder({ wallet: ctx as WalletFullContext, environment, orders: [ { symbol: orderSymbol, side: plan.side, price, size, tif: "FrontendMarket", reduceOnly: plan.reduceOnly, }, ], }); const orderIds = extractOrderIds([response]); const orderRef = orderIds.cloids[0] ?? orderIds.oids[0] ?? `dca-agent-${Date.now()}`; const marketIdentity = buildHyperliquidMarketIdentity({ environment, symbol: pair ?? orderSymbol, rawSymbol: orderSymbol, isSpot, base: baseSymbol ?? null, }); if (!marketIdentity) { throw new Error("Unable to resolve market identity for order."); } await store({ source: "hyperliquid", ref: orderRef, status: "submitted", walletAddress: ctx.address, action: "order", notional: size, network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet", market: marketIdentity, metadata: { symbol: baseSymbol, pair: pair ?? undefined, assetSymbols: config.signalType === "dca" ? resolveProfileAssetSymbols(config) : undefined, side: plan.side, price, size, reduceOnly: plan.reduceOnly, ...(typeof leverage === "number" ? { leverage } : {}), environment, cloid: orderIds.cloids[0] ?? null, orderIds, orderResponse: response, strategy: "dca-agent", }, }); orderResponses.push(response); } execution = { enabled: true, indicator, signal: tradeSignal, action: plan ? "order" : "noop", environment, mode, symbol: baseSymbol, pair, leverageMode, ...(typeof leverage === "number" ? { leverage } : {}), slippageBps, sizeDecimals, budgetUsd, targetSize, currentSize, orderIds: extractOrderIds(orderResponses), orderResponses: orderResponses.length ? orderResponses : undefined, }; } catch (error) { executionError = error instanceof Error ? error.message : "execution_failed"; const errorDetail = resolveHyperliquidErrorDetail(error); execution = { enabled: true, indicator, signal: tradeSignal, action: "error", environment, mode, symbol: baseSymbol, leverageMode, ...(typeof leverage === "number" ? { leverage } : {}), error: executionError, ...(errorDetail ? { errorDetail } : {}), }; } } if (executionError) { return { ok: false, error: executionError, asset: config.asset, signalType: config.signalType, cadence: config.cadence, hourlyInterval: config.hourlyInterval, resolution: config.resolution, asOf, price: currentPrice, allocation: { mode: config.allocationMode, percentOfEquity: config.percentOfEquity, maxPercentOfEquity: config.maxPercentOfEquity, ...(config.amountUsd ? { amountUsd: config.amountUsd } : {}), }, indicators: output, rsiPreset, rsiThresholds: { overbought: rsiOverbought, oversold: rsiOversold }, ...(execution ? { execution } : {}), }; } return { ok: true, asset: config.asset, signalType: config.signalType, cadence: config.cadence, hourlyInterval: config.hourlyInterval, resolution: config.resolution, asOf, price: currentPrice, allocation: { mode: config.allocationMode, percentOfEquity: config.percentOfEquity, maxPercentOfEquity: config.maxPercentOfEquity, ...(config.amountUsd ? { amountUsd: config.amountUsd } : {}), }, indicators: output, rsiPreset, rsiThresholds: { overbought: rsiOverbought, oversold: rsiOversold }, ...(execution ? { execution } : {}), }; } async function runDcaBot(config: SignalBotConfig) { const dca = config.dca ?? { preset: "custom", symbols: [], slippageBps: 50, }; let execution: Record<string, unknown> | undefined; let executionError: string | null = null; if (config.execution?.enabled) { const environment = (config.execution.environment ?? DEFAULT_EXECUTION_ENV) as HyperliquidEnvironment; const slippageBps = clampInt( config.execution.slippageBps ?? dca.slippageBps, 0, 500, DEFAULT_SLIPPAGE_BPS ); const leverage = config.execution.leverage; const entries = normalizeDcaEntries(config); const hasSpotEntries = entries.some((entry) => isSpotSymbol(resolveHyperliquidSymbol(entry.symbol)) ); const hasPerpEntries = entries.some( (entry) => !isSpotSymbol(resolveHyperliquidSymbol(entry.symbol)) ); try { const chain = resolveChainConfig(environment).chain; const ctx = await wallet({ chain }); if (hasSpotEntries && typeof leverage === "number" && Number.isFinite(leverage)) { throw new Error("leverage is not supported for spot markets."); } let accountValue: number | null = null; if (config.allocationMode !== "fixed") { if (hasPerpEntries) { const clearing = await fetchHyperliquidClearinghouseState({ environment, walletAddress: ctx.address as `0x${string}`, }); accountValue = readAccountValue(clearing); } if (hasSpotEntries) { const spotClearing = await fetchHyperliquidSpotClearinghouseState({ environment, walletAddress: ctx.address as `0x${string}`, }); const spotValue = await fetchSpotAccountValue({ environment, balances: (spotClearing as any)?.balances, }); if (spotValue != null) { accountValue = (accountValue ?? 0) + spotValue; } } } const budgetUsd = resolveBudgetUsd({ config, accountValue }); if (!Number.isFinite(budgetUsd) || budgetUsd <= 0) { throw new Error("DCA budget must be greater than zero."); } const orderResponses: HyperliquidOrderResponse[] = []; const results: Array<Record<string, unknown>> = []; const errors: Array<Record<string, unknown>> = []; for (const entry of entries) { const symbolBudget = budgetUsd * entry.normalizedWeight; if (!Number.isFinite(symbolBudget) || symbolBudget <= 0) { errors.push({ symbol: entry.symbol, error: "budget too small" }); continue; } const bars = await fetchBars({ asset: entry.symbol, resolution: config.resolution, countBack: config.countBack, }); if (bars.length === 0) { errors.push({ symbol: entry.symbol, error: "no price data" }); continue; } const currentPrice = bars[bars.length - 1].close; const orderSymbol = resolveHyperliquidSymbol(entry.symbol); const isSpot = isSpotSymbol(orderSymbol); if (isSpot && orderSymbol.startsWith("@")) { errors.push({ symbol: entry.symbol, error: "spot requires BASE/QUOTE" }); continue; } const baseSymbol = normalizeHyperliquidBaseSymbol(orderSymbol); const pair = resolveHyperliquidPair(orderSymbol); const leverageMode = resolveLeverageMode(orderSymbol); if (!isSpot && typeof leverage === "number" && Number.isFinite(leverage)) { await updateHyperliquidLeverage({ wallet: ctx as WalletFullContext, environment, input: { symbol: orderSymbol, leverageMode, leverage, }, }); } const price = formatMarketablePrice(currentPrice, "buy", slippageBps); const sizeDecimals = await fetchSizeDecimals(orderSymbol, environment); const size = formatOrderSize(symbolBudget / currentPrice, sizeDecimals); if (size === "0") { errors.push({ symbol: entry.symbol, error: `Order size too small (szDecimals=${sizeDecimals}).`, }); continue; } const response = await placeHyperliquidOrder({ wallet: ctx as WalletFullContext, environment, orders: [ { symbol: orderSymbol, side: "buy", price, size, tif: "FrontendMarket", reduceOnly: false, }, ], }); const orderIds = extractOrderIds([response]); const orderRef = orderIds.cloids[0] ?? orderIds.oids[0] ?? `dca-agent-${Date.now()}`; const marketIdentity = buildHyperliquidMarketIdentity({ environment, symbol: pair ?? orderSymbol, rawSymbol: orderSymbol, isSpot, base: baseSymbol ?? null, }); if (!marketIdentity) { throw new Error("Unable to resolve market identity for order."); } await store({ source: "hyperliquid", ref: orderRef, status: "submitted", walletAddress: ctx.address, action: "order", notional: size, network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet", market: marketIdentity, metadata: { symbol: baseSymbol, market: orderSymbol, pair: pair ?? undefined, assetSymbols: entries.map((entry) => entry.symbol), side: "buy", price, size, reduceOnly: false, ...(typeof leverage === "number" ? { leverage } : {}), environment, weight: entry.weight, budgetUsd: symbolBudget, cloid: orderIds.cloids[0] ?? null, orderIds, orderResponse: response, strategy: "dca-agent", }, }); orderResponses.push(response); results.push({ symbol: entry.symbol, budgetUsd: symbolBudget, orderSymbol, size, price, orderIds, }); } execution = { enabled: true, environment, mode: "long-only", slippageBps, budgetUsd, orders: results, errors: errors.length > 0 ? errors : undefined, orderIds: extractOrderIds(orderResponses), }; } catch (error) { executionError = error instanceof Error ? error.message : "execution_failed"; const errorDetail = resolveHyperliquidErrorDetail(error); execution = { enabled: true, environment, mode: "long-only", ...(typeof leverage === "number" ? { leverage } : {}), error: executionError, ...(errorDetail ? { errorDetail } : {}), }; } } if (executionError) { return { ok: false, error: executionError, asset: config.asset, signalType: "dca" as const, cadence: config.cadence, allocation: { mode: config.allocationMode, percentOfEquity: config.percentOfEquity, maxPercentOfEquity: config.maxPercentOfEquity, ...(config.amountUsd ? { amountUsd: config.amountUsd } : {}), }, dca: { preset: dca.preset, symbols: dca.symbols, slippageBps: dca.slippageBps, }, ...(execution ? { execution } : {}), }; } return { ok: true, asset: config.asset, signalType: "dca" as const, cadence: config.cadence, allocation: { mode: config.allocationMode, percentOfEquity: config.percentOfEquity, maxPercentOfEquity: config.maxPercentOfEquity, ...(config.amountUsd ? { amountUsd: config.amountUsd } : {}), }, dca: { preset: dca.preset, symbols: dca.symbols, slippageBps: dca.slippageBps, }, ...(execution ? { execution } : {}), }; }