openpondai/agents/rsi-signal-bot
OpenTool app
typescript
import { resolveBacktestAccountValueUsd, resolveBacktestWindow } from "opentool/backtest";
import {
buildHyperliquidMarketIdentity,
clampHyperliquidFloat,
clampHyperliquidInt,
extractHyperliquidOrderIds,
fetchHyperliquidBars,
fetchHyperliquidClearinghouseState,
fetchHyperliquidSizeDecimals,
fetchHyperliquidSpotAccountValue,
fetchHyperliquidSpotClearinghouseState,
formatHyperliquidMarketablePrice,
formatHyperliquidOrderSize,
isHyperliquidSpotSymbol,
normalizeHyperliquidBaseSymbol,
placeHyperliquidOrder,
planHyperliquidTrade,
readHyperliquidAccountValue,
readHyperliquidPerpPositionSize,
readHyperliquidSpotBalanceSize,
resolveHyperliquidChainConfig,
resolveHyperliquidErrorDetail,
resolveHyperliquidLeverageMode,
resolveHyperliquidPair,
resolveHyperliquidSymbol,
resolveHyperliquidTargetSize,
updateHyperliquidLeverage,
type HyperliquidEnvironment,
type HyperliquidOrderResponse,
} from "opentool/adapters/hyperliquid";
import { store } from "opentool/store";
import { wallet } from "opentool/wallet";
import type { WalletFullContext } from "opentool/wallet";
import {
DEFAULT_EXECUTION_ENV,
DEFAULT_EXECUTION_MODE,
DEFAULT_RSI_OVERBOUGHT,
DEFAULT_RSI_OVERSOLD,
DEFAULT_RSI_PRESET,
DEFAULT_SLIPPAGE_BPS,
RSI_PRESETS,
type ExecutionConfig,
type IndicatorType,
type SignalBotConfig,
} from "./config";
import { computeRsi } from "./indicators/computeRsi";
type TradeSignal = "buy" | "sell" | "hold" | "unknown";
type TradePlan = {
side: "buy" | "sell";
size: number;
reduceOnly: boolean;
targetSize: number;
};
type BacktestDecisionPoint = {
ts: string;
price: number;
signal: TradeSignal;
targetSize: number;
budgetUsd: number;
indicator: IndicatorType;
indicators: Record<string, unknown>;
};
export type BacktestDecisionSeriesResult = {
symbol: string;
timeframeStart: string;
timeframeEnd: string;
barsEvaluated: number;
resolution: SignalBotConfig["resolution"];
mode: NonNullable<ExecutionConfig["mode"]>;
indicator: IndicatorType;
decisions: BacktestDecisionPoint[];
};
function extractOrderIds(
responses: HyperliquidOrderResponse[],
): ReturnType<typeof extractHyperliquidOrderIds> {
return extractHyperliquidOrderIds(
responses as unknown as Array<{
response?: {
data?: {
statuses?: Array<Record<string, unknown>>;
};
};
}>,
);
}
function resolveTradeSignal(output: Record<string, unknown>): TradeSignal {
const rsiRecord = output.rsi;
if (!rsiRecord || typeof rsiRecord !== "object") return "unknown";
const signal =
typeof (rsiRecord as { signal?: unknown }).signal === "string"
? ((rsiRecord as { signal: string }).signal as string)
: "";
if (signal === "oversold") return "buy";
if (signal === "overbought") return "sell";
return signal ? "hold" : "unknown";
}
function buildIndicatorDecisionOutput(params: {
config: SignalBotConfig;
bars: Array<{ close: number }>;
}): Record<string, unknown> {
const { config, bars } = params;
const closes = bars.map((bar) => bar.close);
const priceConfig = config.price ?? {};
const rsiPreset = priceConfig.rsiPreset ?? DEFAULT_RSI_PRESET;
const rsiDefaults = RSI_PRESETS[rsiPreset] ?? RSI_PRESETS[DEFAULT_RSI_PRESET];
const overbought = clampHyperliquidFloat(
priceConfig.rsi?.overbought,
1,
100,
rsiDefaults?.overbought ?? DEFAULT_RSI_OVERBOUGHT,
);
const oversold = clampHyperliquidFloat(
priceConfig.rsi?.oversold,
1,
100,
rsiDefaults?.oversold ?? DEFAULT_RSI_OVERSOLD,
);
const value = computeRsi(closes);
const signal =
value === null
? "unknown"
: value >= overbought
? "overbought"
: value <= oversold
? "oversold"
: "neutral";
return {
rsi: {
value,
signal,
overbought,
oversold,
preset: rsiPreset,
},
};
}
export async function buildBacktestDecisionSeries(params: {
config: SignalBotConfig;
symbol?: string;
timeframeStart?: string;
timeframeEnd?: string;
from?: number;
to?: number;
lookbackDays?: number;
accountValueUsd?: number;
}): Promise<BacktestDecisionSeriesResult> {
const symbolOverride = params.symbol?.trim();
const config: SignalBotConfig = symbolOverride
? { ...params.config, asset: symbolOverride }
: params.config;
const execution = config.execution ?? {};
const mode = execution.mode ?? DEFAULT_EXECUTION_MODE;
const window = resolveBacktestWindow({
fallbackCountBack: config.countBack,
lookbackDays: params.lookbackDays,
resolution: config.resolution,
from: params.from,
to: params.to,
timeframeStart: params.timeframeStart,
timeframeEnd: params.timeframeEnd,
});
const resolvedFrom = window.fromSeconds;
const resolvedTo = window.toSeconds;
const countBack = window.countBack;
const bars = await fetchHyperliquidBars({
symbol: config.asset,
resolution: config.resolution,
countBack,
...(resolvedFrom != null && Number.isFinite(resolvedFrom)
? { fromSeconds: Math.max(0, Math.trunc(resolvedFrom)) }
: {}),
...(resolvedTo != null && Number.isFinite(resolvedTo)
? { toSeconds: Math.max(0, Math.trunc(resolvedTo)) }
: {}),
});
if (bars.length === 0) {
throw new Error("No price data returned.");
}
const requestedAccountValue = resolveBacktestAccountValueUsd(params.accountValueUsd);
const accountValue =
requestedAccountValue != null
? requestedAccountValue
: config.allocationMode === "percent_equity"
? 10_000
: null;
const decisions: BacktestDecisionPoint[] = [];
for (let index = 0; index < bars.length; index += 1) {
const snapshotBars = bars.slice(0, index + 1);
const bar = snapshotBars[snapshotBars.length - 1]!;
const indicators = buildIndicatorDecisionOutput({
config,
bars: snapshotBars,
});
const signal = resolveTradeSignal(indicators);
const { targetSize, budgetUsd } = resolveHyperliquidTargetSize({
config,
execution,
accountValue,
currentPrice: bar.close,
});
decisions.push({
ts: new Date(bar.time).toISOString(),
price: bar.close,
signal,
targetSize,
budgetUsd,
indicator: "rsi",
indicators,
});
}
return {
symbol: (normalizeHyperliquidBaseSymbol(config.asset) ?? config.asset).toUpperCase(),
timeframeStart:
resolvedFrom != null
? new Date(resolvedFrom * 1000).toISOString()
: new Date(bars[0]!.time).toISOString(),
timeframeEnd:
resolvedTo != null
? new Date(resolvedTo * 1000).toISOString()
: new Date(bars[bars.length - 1]!.time).toISOString(),
barsEvaluated: bars.length,
resolution: config.resolution,
mode,
indicator: "rsi",
decisions,
};
}
export async function runSignalBot(config: SignalBotConfig) {
const bars = await fetchHyperliquidBars({
symbol: 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 priceConfig = config.price ?? {};
const rsiPreset = priceConfig.rsiPreset ?? DEFAULT_RSI_PRESET;
const rsiDefaults = RSI_PRESETS[rsiPreset] ?? RSI_PRESETS[DEFAULT_RSI_PRESET];
const rsiOverbought = clampHyperliquidFloat(
priceConfig.rsi?.overbought,
1,
100,
rsiDefaults?.overbought ?? DEFAULT_RSI_OVERBOUGHT,
);
const rsiOversold = clampHyperliquidFloat(
priceConfig.rsi?.oversold,
1,
100,
rsiDefaults?.oversold ?? DEFAULT_RSI_OVERSOLD,
);
const rsiValue = computeRsi(closes);
const rsiSignal =
rsiValue === null
? "unknown"
: rsiValue >= rsiOverbought
? "overbought"
: rsiValue <= rsiOversold
? "oversold"
: "neutral";
const output: Record<string, unknown> = {
rsi: {
value: rsiValue,
signal: rsiSignal,
overbought: rsiOverbought,
oversold: rsiOversold,
},
};
let execution: Record<string, unknown> | undefined;
let executionError: string | null = null;
if (config.execution?.enabled) {
const tradeSignal = resolveTradeSignal(output);
const environment =
(config.execution.environment ?? DEFAULT_EXECUTION_ENV) as HyperliquidEnvironment;
const mode = config.execution.mode ?? DEFAULT_EXECUTION_MODE;
const slippageBps = clampHyperliquidInt(
config.execution.slippageBps,
0,
500,
DEFAULT_SLIPPAGE_BPS,
);
const orderSymbol = resolveHyperliquidSymbol(config.asset, config.execution.symbol);
const isSpot = isHyperliquidSpotSymbol(orderSymbol);
const baseSymbol = normalizeHyperliquidBaseSymbol(orderSymbol);
const pair = resolveHyperliquidPair(orderSymbol);
const leverageMode = resolveHyperliquidLeverageMode(orderSymbol);
const leverage = config.execution.leverage;
try {
const chain = resolveHyperliquidChainConfig(environment).chain;
const ctx = await wallet({ chain });
if (isSpot && orderSymbol.startsWith("@")) {
throw new Error("spot execution requires BASE-QUOTE or BASE/QUOTE symbols (no @ ids).");
}
if (isSpot && typeof leverage === "number" && Number.isFinite(leverage)) {
throw new Error("leverage is not supported for spot markets.");
}
const clearing = isSpot
? await fetchHyperliquidSpotClearinghouseState({
environment,
user: ctx.address as `0x${string}`,
})
: await fetchHyperliquidClearinghouseState({
environment,
walletAddress: ctx.address as `0x${string}`,
});
const currentSize = isSpot
? readHyperliquidSpotBalanceSize(clearing, orderSymbol)
: readHyperliquidPerpPositionSize(clearing, orderSymbol, { prefixMatch: true });
const accountValue = isSpot
? await fetchHyperliquidSpotAccountValue({
environment,
balances: (clearing as any)?.balances,
})
: readHyperliquidAccountValue(clearing);
const { targetSize, budgetUsd } = resolveHyperliquidTargetSize({
config,
execution: config.execution,
accountValue,
currentPrice,
});
const plan: TradePlan | null = planHyperliquidTrade({
signal: tradeSignal,
mode,
currentSize,
targetSize,
});
const orderResponses: HyperliquidOrderResponse[] = [];
let sizeDecimals: number | undefined;
if (!isSpot && typeof leverage === "number" && Number.isFinite(leverage)) {
await updateHyperliquidLeverage({
wallet: ctx as WalletFullContext,
environment,
input: {
symbol: orderSymbol,
leverageMode,
leverage,
},
});
}
if (plan) {
const price = formatHyperliquidMarketablePrice({
mid: currentPrice,
side: plan.side,
slippageBps,
});
const resolvedSizeDecimals = await fetchHyperliquidSizeDecimals({
symbol: orderSymbol,
environment,
});
sizeDecimals = resolvedSizeDecimals;
const size = formatHyperliquidOrderSize(plan.size, resolvedSizeDecimals);
if (size === "0") {
throw new Error(`Order size too small for ${orderSymbol} (szDecimals=${sizeDecimals}).`);
}
const response = await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders: [
{
symbol: orderSymbol,
side: plan.side,
price,
size,
tif: "FrontendMarket",
reduceOnly: plan.reduceOnly,
},
],
});
const orderIds = extractOrderIds([response]);
const orderRef =
orderIds.cloids[0] ?? orderIds.oids[0] ?? `rsi-signal-bot-${Date.now()}`;
const marketIdentity = buildHyperliquidMarketIdentity({
environment,
symbol: pair ?? orderSymbol,
rawSymbol: orderSymbol,
isSpot,
base: baseSymbol ?? null,
});
if (!marketIdentity) {
throw new Error("Unable to resolve market identity for order.");
}
await store({
source: "hyperliquid",
ref: orderRef,
status: "submitted",
walletAddress: ctx.address,
action: "order",
notional: size,
network: environment === "mainnet" ? "hyperliquid" : "hyperliquid-testnet",
market: marketIdentity,
metadata: {
symbol: baseSymbol,
pair: pair ?? undefined,
side: plan.side,
price,
size,
reduceOnly: plan.reduceOnly,
...(typeof leverage === "number" ? { leverage } : {}),
environment,
cloid: orderIds.cloids[0] ?? null,
orderIds,
orderResponse: response,
strategy: "rsi-signal-bot",
},
});
orderResponses.push(response);
}
execution = {
enabled: true,
indicator: "rsi",
signal: tradeSignal,
action: plan ? "order" : "noop",
environment,
mode,
symbol: baseSymbol,
pair,
leverageMode,
...(typeof leverage === "number" ? { leverage } : {}),
slippageBps,
sizeDecimals,
budgetUsd,
targetSize,
currentSize,
orderIds: extractOrderIds(orderResponses),
orderResponses: orderResponses.length ? orderResponses : undefined,
};
} catch (error) {
executionError = error instanceof Error ? error.message : "execution_failed";
const errorDetail = resolveHyperliquidErrorDetail(error);
execution = {
enabled: true,
indicator: "rsi",
signal: resolveTradeSignal(output),
action: "error",
environment,
mode,
symbol: baseSymbol,
leverageMode,
...(typeof leverage === "number" ? { leverage } : {}),
error: executionError,
...(errorDetail ? { errorDetail } : {}),
};
}
}
if (executionError) {
return {
ok: false,
error: executionError,
asset: config.asset,
signalType: config.signalType,
cadence: config.cadence,
scheduleEvery: config.scheduleEvery,
scheduleUnit: config.scheduleUnit,
resolution: config.resolution,
asOf,
price: currentPrice,
allocation: {
mode: config.allocationMode,
percentOfEquity: config.percentOfEquity,
maxPercentOfEquity: config.maxPercentOfEquity,
...(config.amountUsd ? { amountUsd: config.amountUsd } : {}),
},
indicators: output,
rsiPreset,
rsiThresholds: { overbought: rsiOverbought, oversold: rsiOversold },
...(execution ? { execution } : {}),
};
}
return {
ok: true,
asset: config.asset,
signalType: config.signalType,
cadence: config.cadence,
scheduleEvery: config.scheduleEvery,
scheduleUnit: config.scheduleUnit,
resolution: config.resolution,
asOf,
price: currentPrice,
allocation: {
mode: config.allocationMode,
percentOfEquity: config.percentOfEquity,
maxPercentOfEquity: config.maxPercentOfEquity,
...(config.amountUsd ? { amountUsd: config.amountUsd } : {}),
},
indicators: output,
rsiPreset,
rsiThresholds: { overbought: rsiOverbought, oversold: rsiOversold },
...(execution ? { execution } : {}),
};
}