1Branch0Tags
GL
glucryptoRefresh package-lock for opentool 0.19.5
a06de7114 hours ago10Commits
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, }, }; } }