2Branches0Tags
GL
glucryptoSync production with master template updates
dc969c112 days ago54Commits
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, }; }