OpenPond
1Branch0Tags
GL
glucryptochore: bump opentool to v0.8.28 and add template p...
1ee7b423 days ago4Commits
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 } : {}), }; }