openpondai/agents/directional-bot
OpenTool app
typescript
import {
buildHyperliquidMarketIdentity,
extractHyperliquidOrderIds,
fetchHyperliquidBars,
fetchHyperliquidSizeDecimals,
formatHyperliquidMarketablePrice,
formatHyperliquidOrderSize,
normalizeHyperliquidBaseSymbol,
normalizeHyperliquidIndicatorBars,
placeHyperliquidOrder,
resolveHyperliquidChainConfig,
resolveHyperliquidErrorDetail,
resolveHyperliquidPair,
resolveHyperliquidPerpSymbol,
type HyperliquidOrderResponse,
} from "opentool/adapters/hyperliquid";
import {
resolveBacktestAccountValueUsd,
resolveBacktestWindow,
type BacktestResolution,
} from "opentool/backtest";
import { store } from "opentool/store";
import { wallet } from "opentool/wallet";
import type { WalletFullContext } from "opentool/wallet";
import type { SimpleHyperliquidBacktestConfig } from "./config";
export type SignalBotDecisionPoint = {
ts: string;
price: number;
signal: "sell" | "hold";
targetSize: number;
budgetUsd: number;
indicators: Record<string, unknown>;
};
export type SignalBotBacktestDecisionSeriesResult = {
symbol: string;
timeframeStart: string;
timeframeEnd: string;
barsEvaluated: number;
resolution: BacktestResolution;
strategy: "twap_sell";
decisions: SignalBotDecisionPoint[];
};
export type SignalBotExecutionResult = {
enabled: boolean;
action: "order" | "noop" | "error";
environment: SimpleHyperliquidBacktestConfig["environment"];
symbol: string;
slippageBps: number;
targetSize: number;
sizeDecimals?: number;
orderIds?: ReturnType<typeof extractHyperliquidOrderIds>;
orderResponses?: HyperliquidOrderResponse[];
error?: string;
errorDetail?: unknown;
};
export type SignalBotLiveResult = {
ok: boolean;
simulated: boolean;
environment: SimpleHyperliquidBacktestConfig["environment"];
asset: string;
symbol: string;
action: SignalBotDecisionPoint["signal"];
latestPrice: number;
budgetUsd: number;
summary: string;
indicators: Record<string, unknown>;
execution?: SignalBotExecutionResult;
error?: string;
};
type NormalizedBar = ReturnType<typeof normalizeHyperliquidIndicatorBars>[number];
const MAX_BACKTEST_BARS_PER_FETCH = 400;
function extractOrderIds(
responses: HyperliquidOrderResponse[],
): ReturnType<typeof extractHyperliquidOrderIds> {
return extractHyperliquidOrderIds(
responses as unknown as Array<{
response?: {
data?: {
statuses?: Array<Record<string, unknown>>;
};
};
}>,
);
}
function resolutionToMinutes(resolution: BacktestResolution): number {
if (resolution === "1") return 1;
if (resolution === "5") return 5;
if (resolution === "15") return 15;
if (resolution === "30") return 30;
if (resolution === "60") return 60;
if (resolution === "240") return 240;
if (resolution === "1D") return 1_440;
return 10_080;
}
async function fetchHyperliquidBarsForWindow(params: {
symbol: string;
resolution: BacktestResolution;
countBack: number;
fromSeconds?: number;
toSeconds?: number;
}): Promise<Awaited<ReturnType<typeof fetchHyperliquidBars>>> {
if (
params.fromSeconds == null ||
params.toSeconds == null ||
!Number.isFinite(params.fromSeconds) ||
!Number.isFinite(params.toSeconds) ||
params.toSeconds <= params.fromSeconds
) {
return fetchHyperliquidBars(params);
}
const resolutionSeconds = resolutionToMinutes(params.resolution) * 60;
const chunkSpanSeconds = resolutionSeconds * MAX_BACKTEST_BARS_PER_FETCH;
const merged = new Map<number, Awaited<ReturnType<typeof fetchHyperliquidBars>>[number]>();
let chunkStart = Math.max(0, Math.trunc(params.fromSeconds));
const finalTo = Math.max(chunkStart + resolutionSeconds, Math.trunc(params.toSeconds));
while (chunkStart < finalTo) {
const chunkEnd = Math.min(finalTo, chunkStart + chunkSpanSeconds);
const chunkCountBack = Math.max(
1,
Math.ceil((chunkEnd - chunkStart) / resolutionSeconds) + 5
);
const bars = await fetchHyperliquidBars({
symbol: params.symbol,
resolution: params.resolution,
countBack: chunkCountBack,
fromSeconds: chunkStart,
toSeconds: chunkEnd,
});
for (const bar of bars) {
merged.set(bar.time, bar);
}
chunkStart = chunkEnd;
}
return Array.from(merged.values()).sort((a, b) => a.time - b.time);
}
function roundMetric(value: number): number {
return Number(value.toFixed(6));
}
function resolveBudgetUsd(params: {
config: SimpleHyperliquidBacktestConfig;
accountValueUsd?: number;
}): number {
const configured = Math.max(1, params.config.budgetUsd);
const accountValueUsd = resolveBacktestAccountValueUsd(params.accountValueUsd);
if (accountValueUsd == null) return configured;
return Math.max(1, Math.min(configured, accountValueUsd));
}
function resolveMonitoringBars(params: {
config: SimpleHyperliquidBacktestConfig;
resolution: BacktestResolution;
}): number {
const monitoringMinutes = Math.max(60, params.config.monitoringPeriodHours * 60);
return Math.max(1, Math.round(monitoringMinutes / resolutionToMinutes(params.resolution)));
}
function buildDecisionSeriesFromBars(params: {
bars: NormalizedBar[];
config: SimpleHyperliquidBacktestConfig;
resolution: BacktestResolution;
accountValueUsd?: number;
}): SignalBotDecisionPoint[] {
const budgetUsd = resolveBudgetUsd(params);
const monitoringBars = resolveMonitoringBars({
config: params.config,
resolution: params.resolution,
});
const totalSlices = Math.max(
1,
Math.ceil(
(params.config.twapDurationDays * 24) / params.config.monitoringPeriodHours
)
);
let remainingBudgetUsd = budgetUsd;
let executedSlices = 0;
const decisions: SignalBotDecisionPoint[] = [];
for (let index = 0; index < params.bars.length; index += 1) {
const bar = params.bars[index]!;
const price = Math.max(bar.close, 0);
const referenceIndex = Math.max(0, index - monitoringBars);
const referencePrice = params.bars[referenceIndex]?.close ?? null;
const changePct =
referencePrice && referencePrice > 0
? ((price - referencePrice) / referencePrice) * 100
: null;
const aggressive =
changePct != null &&
changePct <= -Math.abs(params.config.aggressiveSellThresholdPct);
const sliceBoundary = index > 0 && index % monitoringBars === 0;
let signal: SignalBotDecisionPoint["signal"] = "hold";
let targetSize = 0;
let sliceBudgetUsd: number | null = null;
if (sliceBoundary && remainingBudgetUsd > 0 && price > 0) {
const remainingSlices = Math.max(1, totalSlices - executedSlices);
const baselineSliceBudgetUsd = remainingBudgetUsd / remainingSlices;
const plannedSliceBudgetUsd = aggressive
? baselineSliceBudgetUsd * params.config.aggressiveMultiplier
: baselineSliceBudgetUsd;
sliceBudgetUsd = Math.min(remainingBudgetUsd, plannedSliceBudgetUsd);
signal = "sell";
targetSize = sliceBudgetUsd / price;
remainingBudgetUsd = Math.max(0, remainingBudgetUsd - sliceBudgetUsd);
executedSlices += 1;
}
decisions.push({
ts: new Date(bar.time).toISOString(),
price,
signal,
targetSize: roundMetric(targetSize),
budgetUsd: roundMetric(budgetUsd),
indicators: {
aggressive,
referencePrice:
referencePrice != null ? roundMetric(referencePrice) : null,
recentChangePct: changePct != null ? roundMetric(changePct) : null,
sliceBudgetUsd:
sliceBudgetUsd != null ? roundMetric(sliceBudgetUsd) : null,
remainingBudgetUsd: roundMetric(remainingBudgetUsd),
twapDurationDays: params.config.twapDurationDays,
monitoringPeriodHours: params.config.monitoringPeriodHours,
aggressiveSellThresholdPct: params.config.aggressiveSellThresholdPct,
aggressiveMultiplier: params.config.aggressiveMultiplier,
},
});
}
return decisions;
}
function buildLiveSummary(params: {
asset: string;
signal: SignalBotDecisionPoint["signal"];
indicators: Record<string, unknown>;
}): string {
const thresholdPct =
typeof params.indicators.aggressiveSellThresholdPct === "number"
? params.indicators.aggressiveSellThresholdPct
: null;
const changePct =
typeof params.indicators.recentChangePct === "number"
? params.indicators.recentChangePct
: null;
const sliceBudgetUsd =
typeof params.indicators.sliceBudgetUsd === "number"
? params.indicators.sliceBudgetUsd
: null;
if (params.signal === "sell" && sliceBudgetUsd != null) {
return `${params.asset} scheduled a TWAP sell slice for ${sliceBudgetUsd.toFixed(
2
)} USD notional${changePct != null && thresholdPct != null ? ` after a ${Math.abs(
changePct
).toFixed(2)}% move (threshold ${thresholdPct.toFixed(2)}%).` : "."}`;
}
return `${params.asset} is waiting for the next TWAP monitoring boundary before selling again.`;
}
export async function buildBacktestDecisionSeries(params: {
config: SimpleHyperliquidBacktestConfig;
symbol?: string;
timeframeStart?: string;
timeframeEnd?: string;
from?: number;
to?: number;
lookbackDays?: number;
accountValueUsd?: number;
}): Promise<SignalBotBacktestDecisionSeriesResult> {
const asset = (params.symbol?.trim() || params.config.asset).toUpperCase();
const symbol = resolveHyperliquidPerpSymbol(asset);
const resolution = params.config.resolution as BacktestResolution;
const monitoringBars = resolveMonitoringBars({
config: params.config,
resolution,
});
const executionBars = Math.max(
1,
Math.ceil(
(params.config.twapDurationDays * 24 * 60) / resolutionToMinutes(resolution)
)
);
const window = resolveBacktestWindow({
fallbackCountBack: Math.max(executionBars + monitoringBars + 2, 96),
lookbackDays: params.lookbackDays,
resolution,
from: params.from,
to: params.to,
timeframeStart: params.timeframeStart,
timeframeEnd: params.timeframeEnd,
});
const rawBars = await fetchHyperliquidBarsForWindow({
symbol,
resolution,
countBack: window.countBack,
...(window.fromSeconds != null ? { fromSeconds: window.fromSeconds } : {}),
...(window.toSeconds != null ? { toSeconds: window.toSeconds } : {}),
});
const bars = normalizeHyperliquidIndicatorBars(rawBars);
if (bars.length === 0) {
throw new Error(`No Hyperliquid bars returned for ${asset}.`);
}
const decisions = buildDecisionSeriesFromBars({
bars,
config: {
...params.config,
asset,
},
resolution,
accountValueUsd: params.accountValueUsd,
});
const firstBar = bars[0]!;
const lastBar = bars[bars.length - 1]!;
return {
symbol,
timeframeStart: new Date(firstBar.time).toISOString(),
timeframeEnd: new Date(lastBar.time).toISOString(),
barsEvaluated: bars.length,
resolution,
strategy: "twap_sell",
decisions,
};
}
export async function runSignalBot(
config: SimpleHyperliquidBacktestConfig
): Promise<SignalBotLiveResult> {
const asset = config.asset.trim().toUpperCase();
const symbol = resolveHyperliquidPerpSymbol(asset);
const resolution = config.resolution as BacktestResolution;
const monitoringBars = resolveMonitoringBars({ config, resolution });
const rawBars = await fetchHyperliquidBars({
symbol,
resolution: config.resolution,
countBack: Math.max(monitoringBars * 4, 48),
});
const bars = normalizeHyperliquidIndicatorBars(rawBars);
if (bars.length === 0) {
throw new Error(`No Hyperliquid bars returned for ${asset}.`);
}
const decisions = buildDecisionSeriesFromBars({
bars,
config: {
...config,
asset,
},
resolution,
});
const latest = decisions[decisions.length - 1]!;
const summary = buildLiveSummary({
asset,
signal: latest.signal,
indicators: latest.indicators,
});
const executionEnabled = config.execution?.enabled ?? false;
const environment = config.execution?.environment ?? config.environment;
const slippageBps = Math.max(0, Math.min(500, config.execution?.slippageBps ?? 30));
if (!executionEnabled) {
return {
ok: true,
simulated: true,
environment,
asset,
symbol,
action: latest.signal,
latestPrice: latest.price,
budgetUsd: latest.budgetUsd,
summary,
indicators: latest.indicators,
};
}
if (latest.signal !== "sell" || latest.targetSize <= 0 || latest.price <= 0) {
return {
ok: true,
simulated: false,
environment,
asset,
symbol,
action: latest.signal,
latestPrice: latest.price,
budgetUsd: latest.budgetUsd,
summary,
indicators: latest.indicators,
execution: {
enabled: true,
action: "noop",
environment,
symbol,
slippageBps,
targetSize: latest.targetSize,
},
};
}
try {
const chain = resolveHyperliquidChainConfig(environment).chain;
const ctx = await wallet({ chain });
const price = formatHyperliquidMarketablePrice({
mid: latest.price,
side: "sell",
slippageBps,
});
const sizeDecimals = await fetchHyperliquidSizeDecimals({ symbol, environment });
const size = formatHyperliquidOrderSize(latest.targetSize, sizeDecimals);
if (size === "0") {
throw new Error(`Order size too small for ${symbol} (szDecimals=${sizeDecimals}).`);
}
const response = await placeHyperliquidOrder({
wallet: ctx as WalletFullContext,
environment,
orders: [
{
symbol,
side: "sell",
price,
size,
tif: "FrontendMarket",
reduceOnly: false,
},
],
});
const orderIds = extractOrderIds([response]);
const orderRef =
orderIds.cloids[0] ??
orderIds.oids[0] ??
`directional-bot-${Date.now()}`;
const baseSymbol = normalizeHyperliquidBaseSymbol(symbol);
const pair = resolveHyperliquidPair(symbol);
const marketIdentity = buildHyperliquidMarketIdentity({
environment,
symbol: pair ?? symbol,
rawSymbol: symbol,
isSpot: false,
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: "sell",
price,
size,
reduceOnly: false,
environment,
orderIds,
orderResponse: response,
strategy: "directional-bot",
},
});
return {
ok: true,
simulated: false,
environment,
asset,
symbol,
action: latest.signal,
latestPrice: latest.price,
budgetUsd: latest.budgetUsd,
summary,
indicators: latest.indicators,
execution: {
enabled: true,
action: "order",
environment,
symbol,
slippageBps,
targetSize: latest.targetSize,
sizeDecimals,
orderIds,
orderResponses: [response],
},
};
} catch (error) {
const executionError = error instanceof Error ? error.message : "execution_failed";
const errorDetail = resolveHyperliquidErrorDetail(error);
return {
ok: false,
simulated: false,
environment,
asset,
symbol,
action: latest.signal,
latestPrice: latest.price,
budgetUsd: latest.budgetUsd,
summary,
indicators: latest.indicators,
error: executionError,
execution: {
enabled: true,
action: "error",
environment,
symbol,
slippageBps,
targetSize: latest.targetSize,
error: executionError,
errorDetail,
},
};
}
}