openpondai/agents/twap-bot
OpenTool app
typescript
import { resolveBacktestWindow, type BacktestResolution } from "opentool/backtest";
import {
fetchHyperliquidBars,
normalizeHyperliquidIndicatorBars,
} from "opentool/adapters/hyperliquid";
import { normalizeMarketSymbol, type TwapBotConfig } from "../config";
import {
resolutionToMinutes,
resolveTwapBacktestResolution,
resolveTwapScheduleMinutes,
} from "./schedule";
import type {
TradeSignal,
TwapBacktestDecisionPoint,
TwapBacktestDecisionSeriesResult,
} from "./types";
type ActiveTwapWindow = {
startMs: number;
totalSize: number;
};
function resolveBacktestSymbol(config: TwapBotConfig, symbolOverride?: string) {
const normalizedOverride = normalizeMarketSymbol(symbolOverride);
if (normalizedOverride) {
return normalizedOverride;
}
return normalizeMarketSymbol(config.marketSymbol || config.asset);
}
function resolveFallbackCountBack(resolution: BacktestResolution) {
if (resolution === "1") return 24 * 60;
if (resolution === "5") return 24 * 12 * 14;
if (resolution === "15") return 24 * 4 * 30;
if (resolution === "30") return 24 * 2 * 45;
if (resolution === "60") return 24 * 60;
if (resolution === "240") return 6 * 120;
return 365;
}
function resolveSignal(deltaSize: number): TradeSignal {
const epsilon = 1e-9;
if (deltaSize > epsilon) return "buy";
if (deltaSize < -epsilon) return "sell";
return "hold";
}
export async function buildBacktestDecisionSeries(params: {
config: TwapBotConfig;
symbol?: string;
timeframeStart?: string;
timeframeEnd?: string;
from?: number;
to?: number;
lookbackDays?: number;
}): Promise<TwapBacktestDecisionSeriesResult> {
const marketSymbol = resolveBacktestSymbol(params.config, params.symbol);
const config: TwapBotConfig = {
...params.config,
asset: params.config.asset,
marketSymbol,
};
const scheduleMinutes = resolveTwapScheduleMinutes(config);
const twapMinutes = Math.max(1, Math.trunc(config.twap.minutes));
const resolution = resolveTwapBacktestResolution({
scheduleMinutes,
twapMinutes,
});
const window = resolveBacktestWindow({
fallbackCountBack: resolveFallbackCountBack(resolution),
lookbackDays: params.lookbackDays,
resolution,
from: params.from,
to: params.to,
timeframeStart: params.timeframeStart,
timeframeEnd: params.timeframeEnd,
});
const resolvedFrom = window.fromSeconds;
const resolvedTo = window.toSeconds;
const rawBars = await fetchHyperliquidBars({
symbol: marketSymbol,
resolution,
countBack: window.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)) }
: {}),
});
const bars = normalizeHyperliquidIndicatorBars(rawBars);
if (bars.length === 0) {
throw new Error("No TWAP price data returned.");
}
const barStepMs = resolutionToMinutes(resolution) * 60 * 1000;
const scheduleIntervalMs = scheduleMinutes * 60 * 1000;
const twapDurationMs = Math.max(barStepMs, twapMinutes * 60 * 1000);
const mode = config.twap.side === "sell" ? "long-short" : "long-only";
const sideDirection = config.twap.side === "sell" ? -1 : 1;
const decisions: TwapBacktestDecisionPoint[] = [];
const activeWindows: ActiveTwapWindow[] = [];
let completedTargetSize = 0;
let previousTargetSize = 0;
let lastStartMs: number | null = null;
for (const bar of bars) {
const barTimeMs = bar.time;
if (lastStartMs == null || barTimeMs - lastStartMs >= scheduleIntervalMs) {
const totalSize = (config.amountUsd / bar.close) * Math.abs(sideDirection);
activeWindows.push({
startMs: barTimeMs,
totalSize,
});
lastStartMs = barTimeMs;
}
let inFlightTargetSize = 0;
const nextWindows: ActiveTwapWindow[] = [];
for (const windowEntry of activeWindows) {
const progress = Math.max(
0,
Math.min(1, (barTimeMs - windowEntry.startMs + barStepMs) / twapDurationMs),
);
const contribution = windowEntry.totalSize * progress;
inFlightTargetSize += contribution;
if (progress >= 1) {
completedTargetSize += windowEntry.totalSize;
} else {
nextWindows.push(windowEntry);
}
}
activeWindows.length = 0;
activeWindows.push(...nextWindows);
const targetSize = completedTargetSize + inFlightTargetSize;
const deltaSize = targetSize - previousTargetSize;
const signal = resolveSignal(sideDirection * deltaSize);
const budgetUsd = Math.abs(deltaSize) * bar.close;
decisions.push({
ts: new Date(barTimeMs).toISOString(),
price: bar.close,
signal,
targetSize,
budgetUsd,
indicator: "twap",
indicators: {
marketSymbol,
side: config.twap.side,
scheduleCron: config.schedule.cron,
scheduleMinutes,
twapMinutes,
randomize: config.twap.randomize,
activeWindowCount: activeWindows.length,
},
});
previousTargetSize = targetSize;
}
return {
symbol: marketSymbol.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,
mode,
indicator: "twap",
decisions,
};
}