openpondai/agents/dca-agent
dca-agent
typescript
import { resolveBacktestAccountValueUsd, resolveBacktestWindow } from "opentool/backtest";
import {
fetchHyperliquidBars,
normalizeHyperliquidBaseSymbol,
normalizeHyperliquidDcaEntries,
normalizeHyperliquidIndicatorBars,
resolveHyperliquidBudgetUsd,
} from "opentool/adapters/hyperliquid";
import {
DEFAULT_ASSET,
type DcaAgentConfig,
} from "../config";
import { resolveDcaScheduleSeconds } from "./backtest-schedule";
import type { DcaBacktestDecisionPoint, DcaBacktestDecisionSeriesResult } from "./types";
function resolveBacktestSymbolConfig(params: {
config: DcaAgentConfig;
symbolOverride?: string;
}) {
const normalizedOverride = params.symbolOverride?.trim().toUpperCase();
if (!normalizedOverride) {
return params.config;
}
return {
...params.config,
asset: normalizedOverride,
dca: {
preset: normalizedOverride,
symbols: [{ symbol: normalizedOverride, weight: 1 }],
slippageBps: params.config.dca?.slippageBps ?? 50,
},
} satisfies DcaAgentConfig;
}
export async function buildBacktestDecisionSeries(params: {
config: DcaAgentConfig;
symbol?: string;
timeframeStart?: string;
timeframeEnd?: string;
from?: number;
to?: number;
lookbackDays?: number;
accountValueUsd?: number;
}): Promise<DcaBacktestDecisionSeriesResult> {
const config = resolveBacktestSymbolConfig({
config: params.config,
symbolOverride: params.symbol,
});
const entries = normalizeHyperliquidDcaEntries({
entries: config.dca?.symbols,
fallbackSymbol: config.asset ?? DEFAULT_ASSET,
});
const primaryEntry = entries[0];
if (!primaryEntry) {
throw new Error("No DCA symbols configured.");
}
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 rawBars = await fetchHyperliquidBars({
symbol: primaryEntry.symbol,
resolution: config.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 price data returned.");
}
const requestedAccountValue = resolveBacktestAccountValueUsd(params.accountValueUsd);
const accountValue = requestedAccountValue ?? null;
const scheduleIntervalSeconds = resolveDcaScheduleSeconds(config);
const totalBudgetUsd = resolveHyperliquidBudgetUsd({ config, accountValue });
const symbolBudgetUsd = totalBudgetUsd * primaryEntry.normalizedWeight;
let cumulativeTargetSize = 0;
let lastBuySeconds: number | null = null;
const decisions: DcaBacktestDecisionPoint[] = [];
for (const bar of bars) {
const barSeconds = Math.trunc(bar.time / 1000);
const shouldBuy =
lastBuySeconds == null || barSeconds - lastBuySeconds >= scheduleIntervalSeconds;
if (shouldBuy && Number.isFinite(symbolBudgetUsd) && symbolBudgetUsd > 0 && bar.close > 0) {
cumulativeTargetSize += symbolBudgetUsd / bar.close;
lastBuySeconds = barSeconds;
}
decisions.push({
ts: new Date(bar.time).toISOString(),
price: bar.close,
signal: shouldBuy ? "buy" : "hold",
targetSize: cumulativeTargetSize,
budgetUsd: shouldBuy ? symbolBudgetUsd : 0,
indicator: "dca",
indicators: {
scheduleCron: config.schedule.cron,
allocationMode: config.allocationMode,
asset: primaryEntry.symbol,
weight: primaryEntry.weight,
},
});
}
return {
symbol:
(normalizeHyperliquidBaseSymbol(primaryEntry.symbol) ?? primaryEntry.symbol).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: "long-only",
indicator: "dca",
decisions,
};
}