1Branch0Tags
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 } : {}),
};
}