1Branch0Tags
typescript
import { z } from "zod";
import { store } from "opentool/store";
import { wallet } from "opentool/wallet";
import {
formatUnits,
maxUint256,
parseUnits,
type Address,
type Hex,
} from "viem";
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 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";
type ExecutionEnvironment = "testnet" | "mainnet";
type ExecutionMode = "long-only" | "long-short";
type SwapVenue = "relay" | "uniswap-v3";
type ExecutionChain = "base" | "base-sepolia" | "ethereum";
type TokenInfo = {
symbol: string;
address: Address;
decimals: number;
};
export type ExecutionConfig = {
enabled?: boolean;
environment?: ExecutionEnvironment;
chain?: ExecutionChain;
mode?: ExecutionMode;
swapVenue?: SwapVenue;
indicator?: IndicatorType;
slippageBps?: number;
size?: number;
amountUsd?: number;
uniswapPoolFee?: number;
assetTokenSymbol?: string;
assetTokenAddress?: Address;
assetTokenDecimals?: number;
quoteTokenSymbol?: string;
quoteTokenAddress?: Address;
quoteTokenDecimals?: number;
aavePoolAddress?: Address;
};
export type SignalBotConfig = {
configVersion?: number;
platform: "evm";
asset: string;
signalType: "price" | "twitter" | "news" | "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?: string[];
slippageBps?: number;
};
};
const CONFIG_ENV = "OPENTOOL_PUBLIC_EVM_SIGNAL_BOT_CONFIG";
const DEFAULT_ASSET = "ETH";
const DEFAULT_SIGNAL_TYPE: SignalBotConfig["signalType"] = "price";
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: ExecutionEnvironment = "testnet";
const DEFAULT_EXECUTION_MODE: ExecutionMode = "long-only";
const DEFAULT_SWAP_VENUE: SwapVenue = "relay";
const DEFAULT_EXECUTION_CHAIN: ExecutionChain = "base-sepolia";
const DEFAULT_UNISWAP_FEE = 3000;
const DEFAULT_SLIPPAGE_BPS = 50;
const TEMPLATE_CONFIG_VERSION = 2;
const TEMPLATE_CONFIG_ENV_VAR = "OPENTOOL_PUBLIC_EVM_SIGNAL_BOT_CONFIG";
const RELAY_REFERRER = "openpond";
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 * *",
};
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 ERC20_ABI = [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
{
type: "function",
name: "allowance",
stateMutability: "view",
inputs: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
],
outputs: [{ name: "", type: "uint256" }],
},
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
] as const;
const AAVE_POOL_ABI = [
{
type: "function",
name: "supply",
stateMutability: "nonpayable",
inputs: [
{ name: "asset", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "onBehalfOf", type: "address" },
{ name: "referralCode", type: "uint16" },
],
outputs: [],
},
{
type: "function",
name: "borrow",
stateMutability: "nonpayable",
inputs: [
{ name: "asset", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "interestRateMode", type: "uint256" },
{ name: "referralCode", type: "uint16" },
{ name: "onBehalfOf", type: "address" },
],
outputs: [],
},
] as const;
const UNISWAP_ROUTER_ABI = [
{
type: "function",
name: "exactInputSingle",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "recipient", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMinimum", type: "uint256" },
{ name: "sqrtPriceLimitX96", type: "uint160" },
],
},
],
outputs: [{ name: "amountOut", type: "uint256" }],
},
] as const;
type RelayEnvironment = "mainnet" | "testnet";
type RelayQuoteRequest = {
user: string;
recipient?: string;
originChainId: number;
destinationChainId: number;
originCurrency: string;
destinationCurrency: string;
amount: string;
tradeType: "EXACT_INPUT" | "EXACT_OUTPUT";
referrer?: string;
};
type RelayStep = {
kind: string;
action?: string;
description?: string;
items: Array<{
status?: string;
data: Record<string, unknown>;
check?: {
endpoint: string;
method?: string;
};
}>;
};
type RelayQuoteResponse = {
steps: RelayStep[];
details?: Record<string, unknown>;
};
type ChainExecutionConfig = {
chain: ExecutionChain;
chainId: number;
relayEnvironment: RelayEnvironment;
aavePool: Address;
uniswapRouter: Address;
tokens: Record<string, TokenInfo>;
};
const CHAIN_EXECUTION: Record<ExecutionChain, ChainExecutionConfig> = {
base: {
chain: "base",
chainId: 8453,
relayEnvironment: "mainnet",
aavePool: "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5",
uniswapRouter: "0x2626664c2603336E57B271c5C0b26F421741e481",
tokens: {
USDC: {
symbol: "USDC",
address: "0x833589fCD6eDb6E08f4c7C31c9A8Ba32D74b86B2",
decimals: 6,
},
WETH: {
symbol: "WETH",
address: "0x4200000000000000000000000000000000000006",
decimals: 18,
},
CBBTC: {
symbol: "CBBTC",
address: "0xcBb7C0000AB88B473b1f5AFD9eF808440EED33BF",
decimals: 8,
},
},
},
"base-sepolia": {
chain: "base-sepolia",
chainId: 84532,
relayEnvironment: "testnet",
aavePool: "0x8bab6d1b75f19e9ed9fce8b9bd338844ff79ae27",
uniswapRouter: "0x2626664c2603336E57B271c5C0b26F421741e481",
tokens: {
USDC: {
symbol: "USDC",
address: "0xba50cd2a20f6da35d788639e581bca8d0b5d4d5f",
decimals: 6,
},
WETH: {
symbol: "WETH",
address: "0x4200000000000000000000000000000000000006",
decimals: 18,
},
},
},
ethereum: {
chain: "ethereum",
chainId: 1,
relayEnvironment: "mainnet",
aavePool: "0x87870Bca3F3fD6335C3f4ce8392D69350B4fA4E2",
uniswapRouter: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
tokens: {
USDC: {
symbol: "USDC",
address: "0xA0b86991c6218b36c1d19d4a2e9Eb0cE3606eB48",
decimals: 6,
},
WETH: {
symbol: "WETH",
address: "0xC02aaA39b223FE8D0A0E5C4F27eAD9083C756Cc2",
decimals: 18,
},
WBTC: {
symbol: "WBTC",
address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
decimals: 8,
},
},
},
};
const ASSET_SYMBOL_TO_CHAIN_TOKEN: Record<string, Partial<Record<ExecutionChain, string>>> = {
ETH: {
base: "WETH",
"base-sepolia": "WETH",
ethereum: "WETH",
},
WETH: {
base: "WETH",
"base-sepolia": "WETH",
ethereum: "WETH",
},
BTC: {
base: "CBBTC",
ethereum: "WBTC",
},
WBTC: {
base: "CBBTC",
ethereum: "WBTC",
},
CBBTC: {
base: "CBBTC",
},
};
const TEMPLATE_CONFIG_DEFAULTS: SignalBotConfig = {
configVersion: TEMPLATE_CONFIG_VERSION,
platform: "evm",
signalType: DEFAULT_SIGNAL_TYPE,
asset: DEFAULT_ASSET,
indicators: ["rsi"],
cadence: "hourly",
hourlyInterval: DEFAULT_HOURLY_INTERVAL,
allocationMode: "fixed",
percentOfEquity: DEFAULT_PERCENT_OF_EQUITY,
maxPercentOfEquity: DEFAULT_MAX_PERCENT_OF_EQUITY,
amountUsd: DEFAULT_AMOUNT_USD,
schedule: {
cron: CADENCE_TO_CRON.hourly,
enabled: false,
notifyEmail: false,
},
resolution: DEFAULT_RESOLUTION,
countBack: DEFAULT_COUNT_BACK,
price: {
rsiPreset: DEFAULT_RSI_PRESET,
rsi: {
overbought: DEFAULT_RSI_OVERBOUGHT,
oversold: DEFAULT_RSI_OVERSOLD,
},
},
execution: {
enabled: false,
environment: DEFAULT_EXECUTION_ENV,
chain: DEFAULT_EXECUTION_CHAIN,
mode: DEFAULT_EXECUTION_MODE,
swapVenue: DEFAULT_SWAP_VENUE,
indicator: "rsi",
},
};
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 schema version for this template.",
readOnly: true,
"x-hidden": true,
"x-section": "Meta",
"x-order": 1000,
},
platform: {
type: "string",
enum: ["evm"],
title: "Platform",
description: "Execution venue for this strategy.",
readOnly: true,
"x-hidden": true,
"x-section": "Meta",
"x-order": 1001,
},
signalType: {
type: "string",
enum: ["price", "twitter", "news", "dca"],
title: "Signal type",
description: "Signal source used to trigger decisions.",
"x-enumLabels": ["Price", "Twitter", "News", "DCA"],
"x-section": "Strategy",
"x-order": 1,
},
asset: {
type: "string",
title: "Asset",
description: "Default asset symbol (example: ETH).",
"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-section": "Strategy",
"x-order": 3,
"x-visibleIf": { field: "signalType", equals: "price" },
},
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-step": 1,
"x-unit": "hours",
"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,
"x-visibleIf": { field: "signalType", equals: "price" },
},
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,
"x-visibleIf": { field: "signalType", equals: "price" },
},
price: {
type: "object",
title: "Price settings",
description: "Indicator-specific tuning.",
"x-section": "Price model",
"x-visibleIf": { field: "signalType", equals: "price" },
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 ETH:1,WBTC:0.5.",
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,
},
},
"x-visibleIf": { field: "signalType", equals: "dca" },
},
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.",
"x-enumLabels": ["Testnet", "Mainnet"],
"x-order": 2,
},
chain: {
type: "string",
enum: ["base", "base-sepolia", "ethereum"],
title: "Chain",
description: "Execution chain for swaps and borrows.",
"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,
},
swapVenue: {
type: "string",
enum: ["relay", "uniswap-v3"],
title: "Swap venue",
description: "Swap route for execution orders.",
"x-enumLabels": ["Relay", "Uniswap v3"],
"x-order": 5,
},
size: {
type: "number",
title: "Explicit size",
description: "Optional explicit position size override.",
minimum: 0,
"x-step": 0.0001,
"x-order": 6,
},
amountUsd: {
type: "number",
title: "Execution fixed amount",
description: "Optional USD notional override for execution.",
minimum: 1,
"x-unit": "USD",
"x-format": "currency",
"x-step": 1,
"x-order": 7,
},
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": 8,
},
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": 9,
},
uniswapPoolFee: {
type: "number",
title: "Uniswap pool fee",
description: "Uniswap v3 fee tier (for uniswap-v3 venue).",
minimum: 1,
maximum: 1000000,
"x-step": 1,
"x-order": 10,
},
assetTokenSymbol: {
type: "string",
title: "Asset token symbol",
description: "Optional execution asset token symbol override.",
"x-order": 11,
},
assetTokenAddress: {
type: "string",
title: "Asset token address",
description: "Optional execution asset token address override.",
"x-order": 12,
},
assetTokenDecimals: {
type: "number",
title: "Asset token decimals",
description: "Optional execution asset token decimals override.",
minimum: 0,
maximum: 18,
"x-step": 1,
"x-order": 13,
},
quoteTokenSymbol: {
type: "string",
title: "Quote token symbol",
description: "Optional quote token symbol override.",
"x-order": 14,
},
quoteTokenAddress: {
type: "string",
title: "Quote token address",
description: "Optional quote token address override.",
"x-order": 15,
},
quoteTokenDecimals: {
type: "number",
title: "Quote token decimals",
description: "Optional quote token decimals override.",
minimum: 0,
maximum: 18,
"x-step": 1,
"x-order": 16,
},
aavePoolAddress: {
type: "string",
title: "Aave pool address",
description: "Optional Aave pool address override for long-short mode.",
"x-order": 17,
},
},
},
},
};
export const SIGNAL_BOT_TEMPLATE_CONFIG = {
version: TEMPLATE_CONFIG_VERSION,
schema: TEMPLATE_CONFIG_SCHEMA,
defaults: TEMPLATE_CONFIG_DEFAULTS,
envVar: TEMPLATE_CONFIG_ENV_VAR,
};
const indicatorSchema = z.enum([
"rsi",
"macd",
"bb",
"price-change",
"sma",
"ema",
"ma-cross",
"atr",
]);
const hexAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
const configSchema = z
.object({
platform: z.literal("evm").optional(),
asset: z.string().min(1).optional(),
signalType: z.enum(["price", "twitter", "news", "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", "percent", "fixed"]).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(),
chain: z.enum(["base", "base-sepolia", "ethereum"]).optional(),
mode: z.enum(["long-only", "long-short"]).optional(),
swapVenue: z.enum(["relay", "uniswap-v3"]).optional(),
indicator: indicatorSchema.optional(),
slippageBps: z.number().min(0).max(500).optional(),
size: z.number().positive().optional(),
amountUsd: z.number().positive().optional(),
uniswapPoolFee: z.number().int().min(1).max(1_000_000).optional(),
assetTokenSymbol: z.string().optional(),
assetTokenAddress: hexAddressSchema.optional(),
assetTokenDecimals: z.number().int().min(0).max(18).optional(),
quoteTokenSymbol: z.string().optional(),
quoteTokenAddress: hexAddressSchema.optional(),
quoteTokenDecimals: z.number().int().min(0).max(18).optional(),
aavePoolAddress: hexAddressSchema.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(),
}),
])
)
.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));
}
function roundForUnits(value: number, decimals: number) {
const precision = Math.min(Math.max(decimals, 0), 12);
return value.toFixed(precision);
}
function toUnits(value: number, decimals: number): bigint {
if (!Number.isFinite(value) || value <= 0) return 0n;
return parseUnits(roundForUnits(value, decimals), decimals);
}
function fromUnits(value: bigint, decimals: number): number {
const normalized = Number.parseFloat(formatUnits(value, decimals));
return Number.isFinite(normalized) ? normalized : 0;
}
function resolveExecutionChain(config?: ExecutionConfig): ChainExecutionConfig {
const explicit = config?.chain;
if (explicit) {
return CHAIN_EXECUTION[explicit];
}
const environment = config?.environment ?? DEFAULT_EXECUTION_ENV;
if (environment === "testnet") return CHAIN_EXECUTION["base-sepolia"];
return CHAIN_EXECUTION.base;
}
function normalizeSymbol(raw: string) {
return raw.trim().toUpperCase();
}
function parseDcaSymbolInput(input: DcaSymbolInput): { symbol: string; weight: number } | null {
if (typeof input === "string") {
const [symbolPart, weightPart] = input.split(":", 2);
const symbol = normalizeSymbol(symbolPart ?? "");
if (!symbol) return null;
if (weightPart == null || weightPart.trim() === "") {
return { symbol, weight: 1 };
}
const weight = Number(weightPart.trim());
if (!Number.isFinite(weight) || weight <= 0) return null;
return { symbol, weight };
}
if (!input || typeof input !== "object" || typeof input.symbol !== "string") {
return null;
}
const symbol = normalizeSymbol(input.symbol);
if (!symbol) return null;
const rawWeight =
input.weight == null || input.weight === "" ? 1 : Number(input.weight);
if (!Number.isFinite(rawWeight) || rawWeight <= 0) return null;
return { symbol, weight: rawWeight };
}
function formatDcaSymbolEntry(symbol: string, weight: number): string {
const normalizedWeight = Number(weight.toFixed(8));
if (Math.abs(normalizedWeight - 1) < 1e-8) {
return symbol;
}
return `${symbol}:${normalizedWeight}`;
}
function normalizeDcaConfigSymbols(
rawEntries: DcaSymbolInput[] | undefined,
fallbackSymbol: string
): string[] {
const map = new Map<string, number>();
for (const rawEntry of rawEntries ?? []) {
const parsed = parseDcaSymbolInput(rawEntry);
if (!parsed) continue;
const nextWeight = (map.get(parsed.symbol) ?? 0) + parsed.weight;
map.set(parsed.symbol, nextWeight);
}
if (map.size === 0) {
map.set(normalizeSymbol(fallbackSymbol), 1);
}
return Array.from(map.entries()).map(([symbol, weight]) =>
formatDcaSymbolEntry(symbol, weight)
);
}
function resolveChainTokenSymbol(asset: string, chain: ExecutionChain): string {
const normalized = normalizeSymbol(asset);
const mapped = ASSET_SYMBOL_TO_CHAIN_TOKEN[normalized]?.[chain];
if (mapped) return mapped;
return normalized;
}
function resolveTokenInfo(params: {
symbolHint: string;
chainConfig: ChainExecutionConfig;
overrideSymbol?: string;
overrideAddress?: Address;
overrideDecimals?: number;
}): TokenInfo {
const {
symbolHint,
chainConfig,
overrideSymbol,
overrideAddress,
overrideDecimals,
} = params;
if (overrideAddress && typeof overrideDecimals === "number") {
return {
symbol: normalizeSymbol(overrideSymbol ?? symbolHint),
address: overrideAddress,
decimals: overrideDecimals,
};
}
const candidateSymbol = normalizeSymbol(overrideSymbol ?? symbolHint);
const chainToken = chainConfig.tokens[candidateSymbol];
if (chainToken) return chainToken;
throw new Error(
`Token ${candidateSymbol} is not configured on ${chainConfig.chain}. Provide execution token overrides.`
);
}
function resolveExecutionTokens(config: SignalBotConfig, chainConfig: ChainExecutionConfig) {
const execution = config.execution;
const defaultAssetSymbol = resolveChainTokenSymbol(config.asset, chainConfig.chain);
const assetToken = resolveTokenInfo({
symbolHint: defaultAssetSymbol,
chainConfig,
overrideSymbol: execution?.assetTokenSymbol,
overrideAddress: execution?.assetTokenAddress,
overrideDecimals: execution?.assetTokenDecimals,
});
const quoteToken = resolveTokenInfo({
symbolHint: execution?.quoteTokenSymbol ?? "USDC",
chainConfig,
overrideSymbol: execution?.quoteTokenSymbol,
overrideAddress: execution?.quoteTokenAddress,
overrideDecimals: execution?.quoteTokenDecimals,
});
return { assetToken, quoteToken };
}
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 = input.signalType ?? DEFAULT_SIGNAL_TYPE;
const resolution = input.resolution ?? DEFAULT_RESOLUTION;
const cadenceOverrides =
signalType === "price" ? resolveCadenceFromResolution(resolution) : null;
const cadence =
cadenceOverrides?.cadence ??
input.cadence ??
(signalType === "dca" ? "weekly" : DEFAULT_CADENCE);
const allocationMode: SignalBotConfig["allocationMode"] =
input.allocationMode === "fixed" ? "fixed" : "percent_equity";
const resolvedIndicators =
input.indicators && input.indicators.length > 0
? input.indicators
: signalType === "dca"
? []
: ["rsi"];
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 =
cadenceOverrides?.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: "evm" 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,
chain:
input.execution.chain ??
(input.execution.environment === "testnet" ? "base-sepolia" : "base"),
mode: input.execution.mode ?? DEFAULT_EXECUTION_MODE,
swapVenue: input.execution.swapVenue ?? DEFAULT_SWAP_VENUE,
indicator: input.execution.indicator,
slippageBps: clampInt(
input.execution.slippageBps,
0,
500,
DEFAULT_SLIPPAGE_BPS
),
...(input.execution.size ? { size: input.execution.size } : {}),
...(input.execution.amountUsd ? { amountUsd: input.execution.amountUsd } : {}),
...(input.execution.uniswapPoolFee
? { uniswapPoolFee: input.execution.uniswapPoolFee }
: {}),
...(input.execution.assetTokenSymbol
? { assetTokenSymbol: input.execution.assetTokenSymbol }
: {}),
...(input.execution.assetTokenAddress
? { assetTokenAddress: input.execution.assetTokenAddress as Address }
: {}),
...(input.execution.assetTokenDecimals != null
? { assetTokenDecimals: input.execution.assetTokenDecimals }
: {}),
...(input.execution.quoteTokenSymbol
? { quoteTokenSymbol: input.execution.quoteTokenSymbol }
: {}),
...(input.execution.quoteTokenAddress
? { quoteTokenAddress: input.execution.quoteTokenAddress as Address }
: {}),
...(input.execution.quoteTokenDecimals != null
? { quoteTokenDecimals: input.execution.quoteTokenDecimals }
: {}),
...(input.execution.aavePoolAddress
? { aavePoolAddress: input.execution.aavePoolAddress as Address }
: {}),
}
: undefined;
if (signalType === "dca") {
const asset = normalizeSymbol(input.asset ?? DEFAULT_ASSET);
const symbols = normalizeDcaConfigSymbols(
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 } : {}),
};
}
return {
...base,
signalType,
asset: normalizeSymbol(input.asset ?? DEFAULT_ASSET),
indicators: resolvedIndicators,
...(execution ? { execution } : {}),
...(input.price ? { price: input.price } : {}),
};
}
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) => parseDcaSymbolInput(entry)?.symbol ?? null)
.filter((entry): entry is string => typeof entry === "string") ?? [];
const normalized = symbols
.filter((symbol): symbol is string => typeof symbol === "string")
.map((symbol) => normalizeSymbol(symbol))
.filter((symbol) => symbol.length > 0);
if (normalized.length > 0) {
return normalized;
}
}
return [normalizeSymbol(config.asset ?? DEFAULT_ASSET)];
}
export function resolveProfileAssets(config: SignalBotConfig): Array<{
venue: "relay" | "uniswap-v3";
chain: ExecutionChain;
assetSymbols: string[];
pair: string;
}> {
const chainConfig = resolveExecutionChain(config.execution);
const symbols = resolveProfileAssetSymbols(config)
.map((symbol) => symbol.trim())
.filter((symbol) => symbol.length > 0);
if (!symbols.length) return [];
const venue = config.execution?.swapVenue ?? DEFAULT_SWAP_VENUE;
return [
{
venue,
chain: chainConfig.chain,
assetSymbols: symbols,
pair: `${symbols[0] ?? DEFAULT_ASSET}/USDC`,
},
];
}
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 DcaSymbolInput = string | { symbol?: unknown; weight?: unknown };
type DcaEntry = {
symbol: string;
weight: number;
normalizedWeight: number;
};
type SwapExecutionResult = {
txHash: Hex;
metadata: Record<string, unknown>;
};
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 Record<string, unknown>).signal === "string"
? ((record as Record<string, unknown>).signal as string)
: "";
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 resolveBudgetUsd(params: {
config: SignalBotConfig;
accountValue: number | null;
executionAmountUsd?: number;
}): number {
const { config, accountValue, executionAmountUsd } = params;
if (Number.isFinite(executionAmountUsd ?? Number.NaN)) {
return executionAmountUsd as number;
}
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 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 };
}
const budgetUsd = resolveBudgetUsd({
config,
accountValue,
executionAmountUsd: execution.amountUsd,
});
return { targetSize: budgetUsd / currentPrice, budgetUsd };
}
function normalizeDcaEntries(config: SignalBotConfig): DcaEntry[] {
const entries = Array.isArray(config.dca?.symbols) ? config.dca?.symbols : [];
const fallbackSymbol = normalizeSymbol(config.asset ?? DEFAULT_ASSET);
const map = new Map<string, { symbol: string; weight: number }>();
for (const rawEntry of entries) {
const parsed = parseDcaSymbolInput(rawEntry);
if (!parsed) continue;
const key = parsed.symbol.toUpperCase();
const weight = clampFloat(parsed.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: parsed.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 buildDexMarketIdentity(params: {
venue: SwapVenue;
chainId: number;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
routeId: string;
}) {
const { venue, chainId, tokenIn, tokenOut, routeId } = params;
const marketVenue = venue === "uniswap-v3" ? "uniswap" : "relay";
return {
market_type: "dex_pool" as const,
venue: marketVenue,
environment: String(chainId),
canonical_symbol: `dex_pool:${marketVenue}:${chainId}:${routeId.toLowerCase()}`,
chain_id: chainId,
pool_address: routeId,
base: tokenIn.symbol,
quote: tokenOut.symbol,
};
}
function buildAaveMarketIdentity(params: {
chainId: number;
token: TokenInfo;
poolAddress: Address;
}) {
return {
market_type: "lending_market" as const,
venue: "aave-v3",
environment: String(params.chainId),
canonical_symbol: `lending_market:aave-v3:${params.chainId}:${params.poolAddress.toLowerCase()}:${params.token.symbol}`,
chain_id: params.chainId,
protocol_market_id: params.poolAddress,
base: params.token.symbol,
};
}
async function ensureAllowance(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
token: TokenInfo;
spender: Address;
amount: bigint;
}): Promise<Hex | null> {
const { ctx, token, spender, amount } = params;
if (amount <= 0n) return null;
const allowance = (await ctx.publicClient.readContract({
address: token.address,
abi: ERC20_ABI,
functionName: "allowance",
args: [ctx.address as Address, spender],
})) as bigint;
if (allowance >= amount) return null;
const approvalHash = await ctx.walletClient.writeContract({
address: token.address,
abi: ERC20_ABI,
functionName: "approve",
args: [spender, maxUint256],
account: ctx.account,
});
await ctx.publicClient.waitForTransactionReceipt({ hash: approvalHash });
return approvalHash;
}
async function readTokenBalance(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
token: TokenInfo;
}): Promise<bigint> {
const { ctx, token } = params;
const balance = (await ctx.publicClient.readContract({
address: token.address,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [ctx.address as Address],
})) as bigint;
return balance;
}
function minOutFromPrice(params: {
amountIn: bigint;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
priceUsd: number;
slippageBps: number;
}) {
const { amountIn, tokenIn, tokenOut, priceUsd, slippageBps } = params;
if (amountIn <= 0n) return 0n;
const amountInFloat = fromUnits(amountIn, tokenIn.decimals);
const tokenInIsQuote = tokenIn.symbol === "USDC";
const expectedOut = tokenInIsQuote
? amountInFloat / priceUsd
: amountInFloat * priceUsd;
const minOut = expectedOut * (1 - slippageBps / 10_000);
return toUnits(Math.max(minOut, 0), tokenOut.decimals);
}
function getRelayBaseUrl(environment: RelayEnvironment) {
return environment === "mainnet"
? "https://api.relay.link"
: "https://api.testnets.relay.link";
}
async function getRelayQuote(
environment: RelayEnvironment,
payload: RelayQuoteRequest
): Promise<RelayQuoteResponse> {
const response = await fetch(`${getRelayBaseUrl(environment)}/quote/v2`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Relay quote failed (${response.status}): ${body || "unknown"}`);
}
return (await response.json()) as RelayQuoteResponse;
}
async function getRelayStatus(environment: RelayEnvironment, endpoint: string) {
const response = await fetch(`${getRelayBaseUrl(environment)}${endpoint}`);
if (!response.ok) {
throw new Error(`Relay status failed (${response.status})`);
}
return (await response.json()) as Record<string, unknown>;
}
async function pollRelayStep(environment: RelayEnvironment, endpoint: string) {
for (let i = 0; i < 20; i += 1) {
const statusPayload = await getRelayStatus(environment, endpoint);
const status = String(statusPayload?.status ?? "").toLowerCase();
if (status === "success") return;
if (status === "failure" || status === "refund") {
throw new Error(`Relay reported ${status}`);
}
await new Promise((resolve) => setTimeout(resolve, 1_500));
}
throw new Error("Relay step timed out");
}
async function executeRelaySwap(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
chainConfig: ChainExecutionConfig;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
amountIn: bigint;
}): Promise<SwapExecutionResult> {
const { ctx, chainConfig, tokenIn, tokenOut, amountIn } = params;
const quote = await getRelayQuote(chainConfig.relayEnvironment, {
user: ctx.address,
recipient: ctx.address,
originChainId: chainConfig.chainId,
destinationChainId: chainConfig.chainId,
originCurrency: tokenIn.address,
destinationCurrency: tokenOut.address,
amount: amountIn.toString(),
tradeType: "EXACT_INPUT",
referrer: RELAY_REFERRER,
});
if (!Array.isArray(quote.steps) || quote.steps.length === 0) {
throw new Error("Relay returned no execution steps");
}
const txHashes: Hex[] = [];
for (const step of quote.steps) {
if (!Array.isArray(step.items)) continue;
if (step.kind === "signature") {
throw new Error(
"Relay quote requires signature steps. Use uniswap-v3 for this route or adjust pair/chain."
);
}
if (step.kind !== "transaction") {
throw new Error(`Unsupported Relay step kind: ${step.kind}`);
}
for (const item of step.items) {
if (item.status === "complete") continue;
const txData = item.data ?? {};
const to = txData.to as Address | undefined;
if (!to) {
throw new Error("Relay transaction step missing destination address");
}
const hash = await ctx.walletClient.sendTransaction({
account: ctx.account,
to,
value: txData.value ? BigInt(String(txData.value)) : 0n,
data: (txData.data as Hex | undefined) ?? "0x",
gas: txData.gas ? BigInt(String(txData.gas)) : undefined,
gasPrice: txData.gasPrice ? BigInt(String(txData.gasPrice)) : undefined,
maxFeePerGas: txData.maxFeePerGas
? BigInt(String(txData.maxFeePerGas))
: undefined,
maxPriorityFeePerGas: txData.maxPriorityFeePerGas
? BigInt(String(txData.maxPriorityFeePerGas))
: undefined,
nonce: txData.nonce != null ? Number(txData.nonce) : undefined,
});
txHashes.push(hash);
if (item.check?.endpoint) {
await pollRelayStep(chainConfig.relayEnvironment, item.check.endpoint);
} else {
await ctx.publicClient.waitForTransactionReceipt({ hash });
}
}
}
const txHash = txHashes[txHashes.length - 1];
if (!txHash) {
throw new Error("Relay execution produced no transaction hash");
}
return {
txHash,
metadata: {
relaySteps: quote.steps.length,
txHashes,
},
};
}
async function executeUniswapSwap(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
chainConfig: ChainExecutionConfig;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
amountIn: bigint;
minOut: bigint;
poolFee: number;
}): Promise<SwapExecutionResult> {
const { ctx, chainConfig, tokenIn, tokenOut, amountIn, minOut, poolFee } = params;
const approvalHash = await ensureAllowance({
ctx,
token: tokenIn,
spender: chainConfig.uniswapRouter,
amount: amountIn,
});
const swapHash = await ctx.walletClient.writeContract({
address: chainConfig.uniswapRouter,
abi: UNISWAP_ROUTER_ABI,
functionName: "exactInputSingle",
args: [
{
tokenIn: tokenIn.address,
tokenOut: tokenOut.address,
fee: poolFee,
recipient: ctx.address,
amountIn,
amountOutMinimum: minOut,
sqrtPriceLimitX96: 0n,
},
],
account: ctx.account,
});
await ctx.publicClient.waitForTransactionReceipt({ hash: swapHash });
return {
txHash: swapHash,
metadata: {
approvalHash,
router: chainConfig.uniswapRouter,
fee: poolFee,
minOut: minOut.toString(),
},
};
}
async function executeSwap(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
config: SignalBotConfig;
chainConfig: ChainExecutionConfig;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
amountIn: bigint;
currentPrice: number;
slippageBps: number;
}): Promise<SwapExecutionResult> {
const {
ctx,
config,
chainConfig,
tokenIn,
tokenOut,
amountIn,
currentPrice,
slippageBps,
} = params;
const venue = config.execution?.swapVenue ?? DEFAULT_SWAP_VENUE;
if (amountIn <= 0n) {
throw new Error("Swap amount must be > 0");
}
if (venue === "relay") {
return executeRelaySwap({
ctx,
chainConfig,
tokenIn,
tokenOut,
amountIn,
});
}
const minOut = minOutFromPrice({
amountIn,
tokenIn,
tokenOut,
priceUsd: currentPrice,
slippageBps,
});
const poolFee =
config.execution?.uniswapPoolFee && config.execution.uniswapPoolFee > 0
? config.execution.uniswapPoolFee
: DEFAULT_UNISWAP_FEE;
return executeUniswapSwap({
ctx,
chainConfig,
tokenIn,
tokenOut,
amountIn,
minOut,
poolFee,
});
}
async function storeSwapEvent(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
config: SignalBotConfig;
chainConfig: ChainExecutionConfig;
tokenIn: TokenInfo;
tokenOut: TokenInfo;
amountIn: bigint;
swap: SwapExecutionResult;
side: "buy" | "sell";
strategy: "signal" | "dca" | "short";
currentPrice: number;
}) {
const {
ctx,
config,
chainConfig,
tokenIn,
tokenOut,
amountIn,
swap,
side,
strategy,
currentPrice,
} = params;
const venue = config.execution?.swapVenue ?? DEFAULT_SWAP_VENUE;
const source = venue === "uniswap-v3" ? "uniswap" : "relay";
const routeId =
venue === "uniswap-v3"
? chainConfig.uniswapRouter
: `${tokenIn.symbol}-${tokenOut.symbol}`;
await store({
source,
ref: swap.txHash,
status: "submitted",
action: "swap",
chainId: chainConfig.chainId,
walletAddress: ctx.address,
notional: fromUnits(amountIn, tokenIn.decimals).toString(),
market: buildDexMarketIdentity({
venue,
chainId: chainConfig.chainId,
tokenIn,
tokenOut,
routeId,
}),
metadata: {
chain: chainConfig.chain,
side,
tokenIn,
tokenOut,
currentPrice,
strategy,
venue,
...swap.metadata,
},
});
}
async function executeAaveShort(params: {
ctx: Awaited<ReturnType<typeof wallet>>;
config: SignalBotConfig;
chainConfig: ChainExecutionConfig;
quoteToken: TokenInfo;
assetToken: TokenInfo;
collateralAmount: bigint;
borrowAmount: bigint;
currentPrice: number;
slippageBps: number;
}): Promise<Record<string, unknown>> {
const {
ctx,
config,
chainConfig,
quoteToken,
assetToken,
collateralAmount,
borrowAmount,
currentPrice,
slippageBps,
} = params;
if (collateralAmount <= 0n) {
throw new Error("Collateral amount must be greater than zero for short mode.");
}
if (borrowAmount <= 0n) {
throw new Error("Borrow amount must be greater than zero for short mode.");
}
const aavePool = config.execution?.aavePoolAddress ?? chainConfig.aavePool;
const approvalHash = await ensureAllowance({
ctx,
token: quoteToken,
spender: aavePool,
amount: collateralAmount,
});
const supplyHash = await ctx.walletClient.writeContract({
address: aavePool,
abi: AAVE_POOL_ABI,
functionName: "supply",
args: [quoteToken.address, collateralAmount, ctx.address as Address, 0],
account: ctx.account,
});
await ctx.publicClient.waitForTransactionReceipt({ hash: supplyHash });
await store({
source: "aave-v3",
ref: supplyHash,
status: "submitted",
action: "lend",
chainId: chainConfig.chainId,
walletAddress: ctx.address,
notional: fromUnits(collateralAmount, quoteToken.decimals).toString(),
market: buildAaveMarketIdentity({
chainId: chainConfig.chainId,
token: quoteToken,
poolAddress: aavePool,
}),
metadata: {
chain: chainConfig.chain,
pool: aavePool,
token: quoteToken,
approvalHash,
strategy: "signal-bot-short",
},
});
const borrowHash = await ctx.walletClient.writeContract({
address: aavePool,
abi: AAVE_POOL_ABI,
functionName: "borrow",
args: [assetToken.address, borrowAmount, 2n, 0, ctx.address as Address],
account: ctx.account,
});
await ctx.publicClient.waitForTransactionReceipt({ hash: borrowHash });
await store({
source: "aave-v3",
ref: borrowHash,
status: "submitted",
action: "borrow",
chainId: chainConfig.chainId,
walletAddress: ctx.address,
notional: fromUnits(borrowAmount, assetToken.decimals).toString(),
market: buildAaveMarketIdentity({
chainId: chainConfig.chainId,
token: assetToken,
poolAddress: aavePool,
}),
metadata: {
chain: chainConfig.chain,
pool: aavePool,
token: assetToken,
strategy: "signal-bot-short",
interestRateMode: "variable",
},
});
const swap = await executeSwap({
ctx,
config,
chainConfig,
tokenIn: assetToken,
tokenOut: quoteToken,
amountIn: borrowAmount,
currentPrice,
slippageBps,
});
await storeSwapEvent({
ctx,
config,
chainConfig,
tokenIn: assetToken,
tokenOut: quoteToken,
amountIn: borrowAmount,
swap,
side: "sell",
strategy: "short",
currentPrice,
});
return {
mode: "short",
collateralAmount: fromUnits(collateralAmount, quoteToken.decimals),
borrowAmount: fromUnits(borrowAmount, assetToken.decimals),
approvalHash,
supplyHash,
borrowHash,
swapHash: swap.txHash,
swapMetadata: swap.metadata,
};
}
async function runExecutionForPriceSignal(params: {
config: SignalBotConfig;
currentPrice: number;
output: Record<string, unknown>;
}) {
const { config, currentPrice, output } = params;
const execution = config.execution;
if (!execution?.enabled) return undefined;
const indicator = execution.indicator ?? config.indicators[0] ?? "rsi";
const signal = resolveTradeSignal(indicator, output);
if (signal === "hold" || signal === "unknown") {
return {
enabled: true,
indicator,
signal,
action: "noop",
};
}
const chainConfig = resolveExecutionChain(execution);
const { assetToken, quoteToken } = resolveExecutionTokens(config, chainConfig);
const ctx = await wallet({ chain: chainConfig.chain });
const slippageBps = clampInt(
execution.slippageBps,
0,
500,
DEFAULT_SLIPPAGE_BPS
);
const quoteBalance = await readTokenBalance({ ctx, token: quoteToken });
const quoteBalanceUsd = fromUnits(quoteBalance, quoteToken.decimals);
const { targetSize, budgetUsd } = resolveTargetSize({
config,
execution,
accountValue: quoteBalanceUsd,
currentPrice,
});
if (!Number.isFinite(targetSize) || targetSize <= 0) {
throw new Error("Computed target size is invalid.");
}
const quoteBudgetUnits = toUnits(Math.min(budgetUsd, quoteBalanceUsd), quoteToken.decimals);
const assetTargetUnits = toUnits(targetSize, assetToken.decimals);
const mode = execution.mode ?? DEFAULT_EXECUTION_MODE;
if (signal === "buy") {
if (quoteBudgetUnits <= 0n) {
throw new Error(`Insufficient ${quoteToken.symbol} balance to execute buy.`);
}
const swap = await executeSwap({
ctx,
config,
chainConfig,
tokenIn: quoteToken,
tokenOut: assetToken,
amountIn: quoteBudgetUnits,
currentPrice,
slippageBps,
});
await storeSwapEvent({
ctx,
config,
chainConfig,
tokenIn: quoteToken,
tokenOut: assetToken,
amountIn: quoteBudgetUnits,
swap,
side: "buy",
strategy: "signal",
currentPrice,
});
return {
enabled: true,
indicator,
signal,
action: "swap",
mode,
swapVenue: execution.swapVenue ?? DEFAULT_SWAP_VENUE,
chain: chainConfig.chain,
chainId: chainConfig.chainId,
budgetUsd,
targetSize,
quoteBalanceUsd,
txHash: swap.txHash,
tokenIn: quoteToken.symbol,
tokenOut: assetToken.symbol,
metadata: swap.metadata,
};
}
if (mode === "long-short") {
const shortResult = await executeAaveShort({
ctx,
config,
chainConfig,
quoteToken,
assetToken,
collateralAmount: quoteBudgetUnits,
borrowAmount: assetTargetUnits,
currentPrice,
slippageBps,
});
return {
enabled: true,
indicator,
signal,
action: "short",
mode,
swapVenue: execution.swapVenue ?? DEFAULT_SWAP_VENUE,
chain: chainConfig.chain,
chainId: chainConfig.chainId,
budgetUsd,
targetSize,
quoteBalanceUsd,
...shortResult,
};
}
const assetBalance = await readTokenBalance({ ctx, token: assetToken });
const amountToSell = assetBalance < assetTargetUnits ? assetBalance : assetTargetUnits;
if (amountToSell <= 0n) {
throw new Error(`Insufficient ${assetToken.symbol} balance to execute sell.`);
}
const swap = await executeSwap({
ctx,
config,
chainConfig,
tokenIn: assetToken,
tokenOut: quoteToken,
amountIn: amountToSell,
currentPrice,
slippageBps,
});
await storeSwapEvent({
ctx,
config,
chainConfig,
tokenIn: assetToken,
tokenOut: quoteToken,
amountIn: amountToSell,
swap,
side: "sell",
strategy: "signal",
currentPrice,
});
return {
enabled: true,
indicator,
signal,
action: "swap",
mode,
swapVenue: execution.swapVenue ?? DEFAULT_SWAP_VENUE,
chain: chainConfig.chain,
chainId: chainConfig.chainId,
budgetUsd,
targetSize,
quoteBalanceUsd,
txHash: swap.txHash,
tokenIn: assetToken.symbol,
tokenOut: quoteToken.symbol,
metadata: swap.metadata,
};
}
export async function runSignalBot(config: SignalBotConfig) {
if (config.signalType === "dca") {
return runDcaBot(config);
}
if (config.signalType !== "price") {
return {
ok: false,
error: "Only price and 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 executionResult: Record<string, unknown> | undefined;
let executionError: string | null = null;
if (config.execution?.enabled) {
try {
executionResult = await runExecutionForPriceSignal({
config,
currentPrice,
output,
});
} catch (error) {
executionError = error instanceof Error ? error.message : "execution_failed";
executionResult = {
enabled: true,
error: executionError,
};
}
}
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 },
...(executionResult ? { execution: executionResult } : {}),
};
}
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 },
...(executionResult ? { execution: executionResult } : {}),
};
}
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) {
try {
const chainConfig = resolveExecutionChain(config.execution);
const ctx = await wallet({ chain: chainConfig.chain });
const executionMode = config.execution.mode ?? DEFAULT_EXECUTION_MODE;
const slippageBps = clampInt(
config.execution.slippageBps ?? dca.slippageBps,
0,
500,
DEFAULT_SLIPPAGE_BPS
);
const quoteToken = resolveTokenInfo({
symbolHint: config.execution.quoteTokenSymbol ?? "USDC",
chainConfig,
overrideSymbol: config.execution.quoteTokenSymbol,
overrideAddress: config.execution.quoteTokenAddress,
overrideDecimals: config.execution.quoteTokenDecimals,
});
const quoteBalance = await readTokenBalance({ ctx, token: quoteToken });
const quoteBalanceUsd = fromUnits(quoteBalance, quoteToken.decimals);
const budgetUsd = resolveBudgetUsd({
config,
accountValue: quoteBalanceUsd,
executionAmountUsd: config.execution.amountUsd,
});
if (!Number.isFinite(budgetUsd) || budgetUsd <= 0) {
throw new Error("DCA budget must be greater than zero.");
}
const entries = normalizeDcaEntries(config);
const orders: Array<Record<string, unknown>> = [];
const errors: Array<Record<string, unknown>> = [];
for (const entry of entries) {
const symbolBudgetUsd = budgetUsd * entry.normalizedWeight;
if (!Number.isFinite(symbolBudgetUsd) || symbolBudgetUsd <= 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 assetToken = resolveTokenInfo({
symbolHint: resolveChainTokenSymbol(entry.symbol, chainConfig.chain),
chainConfig,
overrideSymbol: config.execution.assetTokenSymbol,
overrideAddress: config.execution.assetTokenAddress,
overrideDecimals: config.execution.assetTokenDecimals,
});
const budgetUnits = toUnits(symbolBudgetUsd, quoteToken.decimals);
if (budgetUnits <= 0n) {
errors.push({ symbol: entry.symbol, error: "budget rounds to zero" });
continue;
}
if (executionMode === "long-short") {
const shortResult = await executeAaveShort({
ctx,
config,
chainConfig,
quoteToken,
assetToken,
collateralAmount: budgetUnits,
borrowAmount: toUnits(symbolBudgetUsd / currentPrice, assetToken.decimals),
currentPrice,
slippageBps,
});
orders.push({
symbol: entry.symbol,
mode: "short",
budgetUsd: symbolBudgetUsd,
...shortResult,
});
continue;
}
const swap = await executeSwap({
ctx,
config,
chainConfig,
tokenIn: quoteToken,
tokenOut: assetToken,
amountIn: budgetUnits,
currentPrice,
slippageBps,
});
await storeSwapEvent({
ctx,
config,
chainConfig,
tokenIn: quoteToken,
tokenOut: assetToken,
amountIn: budgetUnits,
swap,
side: "buy",
strategy: "dca",
currentPrice,
});
orders.push({
symbol: entry.symbol,
mode: "long",
budgetUsd: symbolBudgetUsd,
txHash: swap.txHash,
tokenIn: quoteToken.symbol,
tokenOut: assetToken.symbol,
metadata: swap.metadata,
});
}
execution = {
enabled: true,
mode: executionMode,
chain: chainConfig.chain,
chainId: chainConfig.chainId,
swapVenue: config.execution.swapVenue ?? DEFAULT_SWAP_VENUE,
slippageBps,
budgetUsd,
quoteBalanceUsd,
orders,
errors: errors.length ? errors : undefined,
};
} catch (error) {
executionError = error instanceof Error ? error.message : "execution_failed";
execution = {
enabled: true,
error: executionError,
};
}
}
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 } : {}),
};
}