openpondai/agents/hyperliquid-delta-neutral
OpenTool app
typescript
import { resolveBacktestAccountValueUsd, resolveBacktestWindow } from "opentool/backtest";
import {
fetchHyperliquidPerpMarketInfo,
fetchHyperliquidSpotMarketInfo,
fetchHyperliquidSpotTickSize,
fetchHyperliquidTickSize,
normalizeHyperliquidIndicatorBars,
resolveHyperliquidPerpSymbol,
resolveHyperliquidSpotSymbol,
} from "opentool/adapters/hyperliquid";
import type { DeltaNeutralConfig } from "../config";
import { loadDeltaNeutralHistoricalFundingRates } from "../funding-rates";
import {
computeWindowCountBack,
fetchHyperliquidBarsForBacktestWindow,
fetchPerpBarsWithResolutionFallback,
mergeSpotBarsWithPerpProxy,
normalizeHistoricalFundingRates,
} from "./backtest-market";
import {
applyPerpExecution,
applySpotExecution,
buildBacktestMetrics,
} from "./backtest-simulation";
import {
buildResolvedOrders,
planDeltaNeutralCycle,
resolveDecisionSignal,
resolveHyperliquidTotalFeeRate,
} from "./planner";
import {
DEFAULT_DELTA_SCHEDULE_MINUTES,
resolveBacktestResolution,
resolveDeltaNeutralLegAssets,
resolveDeltaScheduleMinutes,
} from "./schedule";
import type {
DeltaNeutralBacktestDecisionPoint,
DeltaNeutralBacktestDecisionSeriesResult,
DeltaNeutralBacktestReplayArtifacts,
DeltaNeutralBacktestState,
DeltaNeutralHistoricalFundingRatePoint,
} from "./types";
export async function buildBacktestDecisionSeries(params: {
config: DeltaNeutralConfig;
symbol?: string;
timeframeStart?: string;
timeframeEnd?: string;
from?: number;
to?: number;
lookbackDays?: number;
accountValueUsd?: number;
fundingRates?: DeltaNeutralHistoricalFundingRatePoint[];
}): Promise<DeltaNeutralBacktestDecisionSeriesResult> {
const symbolOverride = params.symbol?.trim();
const config: DeltaNeutralConfig = symbolOverride
? {
...params.config,
spotAsset: symbolOverride.toUpperCase(),
perpAsset: symbolOverride.toUpperCase(),
}
: params.config;
const { spotAsset, perpAsset, pairLabel } = resolveDeltaNeutralLegAssets(config);
const perpSymbol = resolveHyperliquidPerpSymbol(perpAsset);
const spot = resolveHyperliquidSpotSymbol(spotAsset);
const scheduleMinutes = resolveDeltaScheduleMinutes(config);
const preferredResolution = resolveBacktestResolution(scheduleMinutes);
const window = resolveBacktestWindow({
fallbackCountBack: Math.max(
50,
Math.ceil(Math.max(scheduleMinutes, DEFAULT_DELTA_SCHEDULE_MINUTES) * 8),
),
lookbackDays: params.lookbackDays,
resolution: preferredResolution,
from: params.from,
to: params.to,
timeframeStart: params.timeframeStart,
timeframeEnd: params.timeframeEnd,
});
const resolvedFrom = window.fromSeconds;
const resolvedTo = window.toSeconds;
const fallbackCountBack = window.countBack;
const perpBarWindow = await fetchPerpBarsWithResolutionFallback({
symbol: perpSymbol,
preferredResolution,
fallbackCountBack,
...(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 resolution = perpBarWindow.resolution;
const countBack = computeWindowCountBack({
fallbackCountBack,
resolution,
...(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 [perpBarsRaw, spotBarsRaw] = await Promise.all([
Promise.resolve(perpBarWindow.bars),
fetchHyperliquidBarsForBacktestWindow({
symbol: spot.symbol,
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)) }
: {}),
}).catch(() => []),
]);
const [perpMarket, spotMarket] = await Promise.all([
fetchHyperliquidPerpMarketInfo({ environment: config.environment, symbol: perpSymbol }),
fetchHyperliquidSpotMarketInfo({
environment: config.environment,
base: spot.base,
quote: spot.quote,
}),
]);
const [perpTick, spotTick] = await Promise.all([
fetchHyperliquidTickSize({ environment: config.environment, symbol: perpSymbol }),
fetchHyperliquidSpotTickSize({
environment: config.environment,
marketIndex: spotMarket.marketIndex,
}),
]);
const perpBars = normalizeHyperliquidIndicatorBars(perpBarsRaw);
const resolvedSpotBars = mergeSpotBarsWithPerpProxy(
perpBars,
normalizeHyperliquidIndicatorBars(spotBarsRaw),
);
const spotBars = resolvedSpotBars.bars;
if (perpBars.length === 0) {
throw new Error("No delta-neutral price data returned.");
}
const requestedAccountValue = resolveBacktestAccountValueUsd(params.accountValueUsd);
const initialEquityUsd = requestedAccountValue ?? config.targetNotionalUsd;
const hydratedFundingRates =
params.fundingRates && params.fundingRates.length > 0
? params.fundingRates
: await loadDeltaNeutralHistoricalFundingRates({
config,
timeframeStart:
resolvedFrom != null
? new Date(resolvedFrom * 1000).toISOString()
: perpBars[0]
? new Date(perpBars[0].time).toISOString()
: new Date().toISOString(),
timeframeEnd:
resolvedTo != null
? new Date(resolvedTo * 1000).toISOString()
: perpBars[perpBars.length - 1]
? new Date(perpBars[perpBars.length - 1]!.time).toISOString()
: new Date().toISOString(),
});
const fundingRates = normalizeHistoricalFundingRates(hydratedFundingRates);
const decisions: DeltaNeutralBacktestDecisionPoint[] = [];
const equityPoints: DeltaNeutralBacktestReplayArtifacts["equityPoints"] = [];
const fullBtcBenchmarkPoints: Array<{
ts: string;
equityUsd: number;
returnPct: number | null;
}> = [];
const sameNotionalBenchmarkPoints: Array<{
ts: string;
equityUsd: number;
returnPct: number | null;
}> = [];
const trades: DeltaNeutralBacktestReplayArtifacts["trades"] = [];
const realizedPnls: number[] = [];
const holdingMinutes: number[] = [];
let turnoverUsd = 0;
let totalFeesUsd = 0;
let totalExchangeFeesUsd = 0;
let totalBuilderFeesUsd = 0;
let totalSlippageImpactUsd = 0;
const initialSpotPrice =
(spotBars[0]?.close && spotBars[0]!.close > 0 ? spotBars[0]!.close : perpBars[0]?.close) ??
0;
const fullBtcBalance = initialSpotPrice > 0 ? initialEquityUsd / initialSpotPrice : 0;
const sameNotionalUsd = Math.min(initialEquityUsd, config.targetNotionalUsd);
const sameNotionalBtcBalance =
initialSpotPrice > 0 ? sameNotionalUsd / initialSpotPrice : 0;
const sameNotionalCashRemainderUsd = Math.max(0, initialEquityUsd - sameNotionalUsd);
let peakEquity = initialEquityUsd;
let cumulativeFundingUsd = 0;
let fundingPointsApplied = 0;
let state: DeltaNeutralBacktestState = {
cashUsd: initialEquityUsd,
spotBalance: 0,
spotCostBasisUsd: 0,
spotOpenedAt: null,
perpSize: 0,
perpEntryPrice: null,
perpOpenedAt: null,
};
let spotIndex = 0;
let nextFundingIndex = 0;
let currentFundingRateBps: number | null = null;
const effectiveWindowStartMs = perpBars[0]?.time ?? Date.now();
while (
nextFundingIndex < fundingRates.length &&
(fundingRates[nextFundingIndex]?.tsMs ?? Number.POSITIVE_INFINITY) < effectiveWindowStartMs
) {
currentFundingRateBps = fundingRates[nextFundingIndex]?.rateBps ?? currentFundingRateBps;
nextFundingIndex += 1;
}
for (let index = 0; index < perpBars.length; index += 1) {
const perpBar = perpBars[index]!;
while (
spotIndex + 1 < spotBars.length &&
(spotBars[spotIndex + 1]?.time ?? Number.POSITIVE_INFINITY) <= perpBar.time
) {
spotIndex += 1;
}
const spotBar = spotBars[spotIndex] ?? perpBar;
const ts = new Date(perpBar.time);
const spotPrice = spotBar.close > 0 ? spotBar.close : perpBar.close;
const perpPrice = perpBar.close;
let fundingCashflowUsd = 0;
while (
nextFundingIndex < fundingRates.length &&
(fundingRates[nextFundingIndex]?.tsMs ?? Number.POSITIVE_INFINITY) <= perpBar.time
) {
const fundingPoint = fundingRates[nextFundingIndex]!;
currentFundingRateBps = fundingPoint.rateBps;
if (state.perpSize !== 0 && perpPrice > 0) {
fundingCashflowUsd += -(state.perpSize * perpPrice) * (fundingPoint.rateBps / 10_000);
fundingPointsApplied += 1;
}
nextFundingIndex += 1;
}
if (fundingCashflowUsd !== 0) {
state.cashUsd += fundingCashflowUsd;
cumulativeFundingUsd += fundingCashflowUsd;
}
const perpUnrealizedPnl =
state.perpEntryPrice != null
? (perpPrice - state.perpEntryPrice) * state.perpSize
: null;
const plan = planDeltaNeutralCycle({
config,
spotPrice,
perpPrice,
fundingRateBps: currentFundingRateBps,
spotBalance: state.spotBalance,
spotEntryNtl: state.spotBalance > 0 ? state.spotCostBasisUsd : null,
perpSize: state.perpSize,
perpUnrealizedPnl,
maxPerRunUsd: Math.max(0, config.maxPerRunUsd),
});
const resolvedOrders = buildResolvedOrders({
slippageBps: Math.max(0, config.slippageBps),
perpSymbol,
spotSymbol: spot.symbol,
perpPrice,
spotPrice,
perpSzDecimals: perpMarket.szDecimals,
spotSzDecimals: spotMarket.szDecimals,
perpTick,
spotTick,
currentPerpNotionalUsd: plan.metrics.perpNotionalUsd,
plan,
});
const effectiveReason =
resolvedOrders.length === 0 && plan.action !== "delta-neutral-hold"
? "order-too-small"
: (plan.reason ?? null);
const effectiveAction =
resolvedOrders.length === 0 ? "delta-neutral-hold" : plan.action;
const executionOrderKinds =
effectiveAction === "delta-neutral-open" ? ["spot", "perp"] : ["perp", "spot"];
for (const kind of executionOrderKinds) {
const order = resolvedOrders.find((entry) => entry.kind === kind);
if (!order) continue;
const executionResult =
order.kind === "spot"
? applySpotExecution({
state,
order,
ts,
})
: applyPerpExecution({
state,
order,
ts,
});
state = executionResult.nextState;
realizedPnls.push(executionResult.realizedPnlUsd);
if (executionResult.holdingMinutes != null) {
holdingMinutes.push(executionResult.holdingMinutes);
}
turnoverUsd += executionResult.notionalUsd;
totalFeesUsd += executionResult.feeUsd;
totalExchangeFeesUsd += executionResult.exchangeFeeUsd;
totalBuilderFeesUsd += executionResult.builderFeeUsd;
totalSlippageImpactUsd += executionResult.slippageImpactUsd;
trades.push({
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
price: order.fillPrice,
notionalUsd: executionResult.notionalUsd,
feeUsd: executionResult.feeUsd,
pnlUsd: executionResult.realizedPnlUsd,
openedAt: ts.toISOString(),
closedAt: ts.toISOString(),
metadata: {
strategy: "delta-neutral",
kind: order.kind,
action: effectiveAction,
reduceOnly: Boolean(order.reduceOnly),
referencePrice: order.referencePrice,
exchangeFeeUsd: executionResult.exchangeFeeUsd,
builderFeeUsd: executionResult.builderFeeUsd,
},
});
}
const currentPerpUnrealizedPnl =
state.perpEntryPrice != null ? (perpPrice - state.perpEntryPrice) * state.perpSize : 0;
const equityUsd = state.cashUsd + state.spotBalance * spotPrice + currentPerpUnrealizedPnl;
peakEquity = Math.max(peakEquity, equityUsd);
const drawdownPct =
peakEquity > 0 ? ((peakEquity - equityUsd) / peakEquity) * 100 : null;
const returnPct =
initialEquityUsd > 0 ? ((equityUsd - initialEquityUsd) / initialEquityUsd) * 100 : null;
equityPoints.push({
ts: ts.toISOString(),
equityUsd,
drawdownPct,
returnPct,
});
if (fullBtcBalance > 0) {
const equityUsd = fullBtcBalance * spotPrice;
fullBtcBenchmarkPoints.push({
ts: ts.toISOString(),
equityUsd,
returnPct:
initialEquityUsd > 0 ? ((equityUsd - initialEquityUsd) / initialEquityUsd) * 100 : null,
});
}
if (sameNotionalBtcBalance > 0 || sameNotionalCashRemainderUsd > 0) {
const equityUsd = sameNotionalCashRemainderUsd + sameNotionalBtcBalance * spotPrice;
sameNotionalBenchmarkPoints.push({
ts: ts.toISOString(),
equityUsd,
returnPct:
initialEquityUsd > 0 ? ((equityUsd - initialEquityUsd) / initialEquityUsd) * 100 : null,
});
}
decisions.push({
ts: ts.toISOString(),
price: spotPrice,
signal: resolveDecisionSignal({
spotOrderUsd: plan.spotOrderUsd,
perpOrderUsd: plan.perpOrderUsd,
orders: resolvedOrders,
}),
targetSize: spotPrice > 0 ? config.targetNotionalUsd / spotPrice : 0,
budgetUsd: initialEquityUsd,
indicators: {
strategy: "delta-neutral",
action: effectiveAction,
skipped: resolvedOrders.length === 0,
reason: effectiveReason,
spotPrice,
perpPrice,
basisBps: plan.metrics.basisBps,
fundingRateBps: currentFundingRateBps,
fundingCashflowUsd,
cumulativeFundingUsd,
hedgeRatio: config.hedgeRatio,
targetNotionalUsd: config.targetNotionalUsd,
spotBalance: state.spotBalance,
perpSize: state.perpSize,
equityUsd,
plannedOrders: resolvedOrders.map((order) => ({
kind: order.kind,
side: order.side,
quantity: order.quantity,
price: order.fillPrice,
notionalUsd: order.notionalUsd,
reduceOnly: Boolean(order.reduceOnly),
exchangeFeeRate:
resolveHyperliquidTotalFeeRate({
kind: order.kind,
side: order.side,
}).exchangeFeeRate,
builderFeeRate:
resolveHyperliquidTotalFeeRate({
kind: order.kind,
side: order.side,
}).builderFeeRate,
})),
},
});
}
const replay = {
equityPoints,
trades,
metrics: buildBacktestMetrics({
resolution,
initialEquityUsd,
equityPoints,
realizedPnls,
holdingMinutes,
turnoverUsd,
totalFeesUsd,
totalExchangeFeesUsd,
totalBuilderFeesUsd,
totalSlippageImpactUsd,
tradeCount: trades.length,
cumulativeFundingUsd,
fundingPointsApplied,
spotProxyBarsUsed: resolvedSpotBars.proxyBarsUsed,
}),
benchmarks: [
{
label: "full-btc-buy-and-hold",
equityPoints: fullBtcBenchmarkPoints,
totalReturnPct:
fullBtcBenchmarkPoints[fullBtcBenchmarkPoints.length - 1]?.returnPct ?? null,
excessReturnPct:
equityPoints[equityPoints.length - 1]?.returnPct != null &&
fullBtcBenchmarkPoints[fullBtcBenchmarkPoints.length - 1]?.returnPct != null
? (equityPoints[equityPoints.length - 1]!.returnPct as number) -
(fullBtcBenchmarkPoints[fullBtcBenchmarkPoints.length - 1]!.returnPct as number)
: null,
},
{
label: "same-notional-btc-hold",
equityPoints: sameNotionalBenchmarkPoints,
totalReturnPct:
sameNotionalBenchmarkPoints[sameNotionalBenchmarkPoints.length - 1]?.returnPct ?? null,
excessReturnPct:
equityPoints[equityPoints.length - 1]?.returnPct != null &&
sameNotionalBenchmarkPoints[sameNotionalBenchmarkPoints.length - 1]?.returnPct != null
? (equityPoints[equityPoints.length - 1]!.returnPct as number) -
(sameNotionalBenchmarkPoints[sameNotionalBenchmarkPoints.length - 1]!.returnPct as number)
: null,
},
],
} satisfies DeltaNeutralBacktestReplayArtifacts;
const lastIndex = decisions.length - 1;
return {
symbol: pairLabel,
timeframeStart: decisions[0]!.ts,
timeframeEnd: decisions[lastIndex]!.ts,
barsEvaluated: decisions.length,
resolution,
mode: "long-short",
indicator: "delta-neutral",
decisions,
replay,
};
}