1Branch0Tags
GL
glucryptoReserve only trade targets in price trigger profil...
4a63a6312 days ago20Commits
typescript
import { resolveBacktestWindow, type BacktestResolution, } from "opentool/backtest"; import { fetchHyperliquidBars, normalizeHyperliquidIndicatorBars, } from "opentool/adapters/hyperliquid"; import { normalizeSymbol, type PriceTriggerConfig } from "../config"; import { evaluateRule, resolveBudgetPerRule } from "./evaluate"; import { resolvePriceTriggerBacktestResolution, resolvePriceTriggerScheduleMinutes } from "./schedule"; import type { PriceTriggerBacktestDecisionPoint, PriceTriggerBacktestDecisionSeriesResult, } from "./types"; type PriceBar = { time: number; close: number; }; type RelevantRule = { rule: PriceTriggerConfig["rules"][number]; primaryTargetWeight: number; }; function resolvePrimarySymbol(params: { config: PriceTriggerConfig; symbolOverride?: string; }) { const normalizedOverride = normalizeSymbol(params.symbolOverride); if (normalizedOverride) { return normalizedOverride; } for (const rule of params.config.rules) { const firstTarget = rule.targets[0]?.symbol; const normalizedTarget = normalizeSymbol(firstTarget); if (normalizedTarget) { return normalizedTarget; } } const firstRuleSource = normalizeSymbol(params.config.rules[0]?.sourceSymbol); if (firstRuleSource) { return firstRuleSource; } return normalizeSymbol(params.config.asset); } function resolveRelevantRules(config: PriceTriggerConfig, primarySymbol: string): RelevantRule[] { const relevant: RelevantRule[] = []; for (const rule of config.rules) { const totalWeight = rule.targets.reduce((sum, target) => sum + target.weight, 0); const normalizedTotalWeight = totalWeight > 0 ? totalWeight : 1; const matchingWeight = rule.targets.reduce((sum, target) => { return normalizeSymbol(target.symbol) === primarySymbol ? sum + target.weight : sum; }, 0); if (matchingWeight <= 0) continue; relevant.push({ rule, primaryTargetWeight: matchingWeight / normalizedTotalWeight, }); } return relevant; } function resolveFallbackCountBack(resolution: BacktestResolution) { if (resolution === "1") return 24 * 60; if (resolution === "5") return 24 * 12 * 30; if (resolution === "15") return 24 * 4 * 45; if (resolution === "30") return 24 * 2 * 60; if (resolution === "60") return 24 * 90; if (resolution === "240") return 6 * 120; return 365; } function buildBarsBySymbol(params: { symbols: string[]; resolution: BacktestResolution; countBack: number; fromSeconds?: number; toSeconds?: number; }) { return Promise.all( params.symbols.map(async (symbol) => { const rawBars = await fetchHyperliquidBars({ symbol, resolution: params.resolution, countBack: params.countBack, ...(params.fromSeconds != null && Number.isFinite(params.fromSeconds) ? { fromSeconds: Math.max(0, Math.trunc(params.fromSeconds)) } : {}), ...(params.toSeconds != null && Number.isFinite(params.toSeconds) ? { toSeconds: Math.max(0, Math.trunc(params.toSeconds)) } : {}), }); return [symbol, normalizeHyperliquidIndicatorBars(rawBars)] as const; }), ); } export async function buildBacktestDecisionSeries(params: { config: PriceTriggerConfig; symbol?: string; timeframeStart?: string; timeframeEnd?: string; from?: number; to?: number; lookbackDays?: number; accountValueUsd?: number; }): Promise<PriceTriggerBacktestDecisionSeriesResult> { const primarySymbol = resolvePrimarySymbol({ config: params.config, symbolOverride: params.symbol, }); const relevantRules = resolveRelevantRules(params.config, primarySymbol); if (relevantRules.length === 0) { throw new Error(`No price-trigger rules target ${primarySymbol}.`); } const scheduleMinutes = resolvePriceTriggerScheduleMinutes(params.config); const resolution = resolvePriceTriggerBacktestResolution(scheduleMinutes); 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 symbols = Array.from( new Set([ primarySymbol, ...relevantRules.map(({ rule }) => normalizeSymbol(rule.sourceSymbol)), ]), ).filter((symbol): symbol is string => Boolean(symbol)); const barsEntries = await buildBarsBySymbol({ symbols, resolution, countBack: window.countBack, ...(resolvedFrom != null ? { fromSeconds: resolvedFrom } : {}), ...(resolvedTo != null ? { toSeconds: resolvedTo } : {}), }); const barsBySymbol = new Map<string, PriceBar[]>( barsEntries.filter(([, bars]) => bars.length > 0), ); const primaryBars = barsBySymbol.get(primarySymbol); if (!primaryBars || primaryBars.length === 0) { throw new Error(`No backtest price data returned for ${primarySymbol}.`); } const sourceIndexes = new Map<string, number>(symbols.map((symbol) => [symbol, 0])); const previousObservedPrices = new Map<string, number>(); const hasBuyRules = relevantRules.some(({ rule }) => rule.actionSide === "buy"); const hasSellRules = relevantRules.some(({ rule }) => rule.actionSide === "sell"); const mode = hasSellRules && !hasBuyRules ? "long-short" : "long-only"; const decisions: PriceTriggerBacktestDecisionPoint[] = []; let targetSize = 0; for (const primaryBar of primaryBars) { const evaluations = relevantRules .map(({ rule, primaryTargetWeight }) => { const sourceSymbol = normalizeSymbol(rule.sourceSymbol); const sourceBars = barsBySymbol.get(sourceSymbol); if (!sourceBars || sourceBars.length === 0) return null; let sourceIndex = sourceIndexes.get(sourceSymbol) ?? 0; while ( sourceIndex + 1 < sourceBars.length && (sourceBars[sourceIndex + 1]?.time ?? Number.POSITIVE_INFINITY) <= primaryBar.time ) { sourceIndex += 1; } sourceIndexes.set(sourceSymbol, sourceIndex); const sourceBar = sourceBars[sourceIndex]; if (!sourceBar || sourceBar.time > primaryBar.time) { return null; } const previousObservedPrice = previousObservedPrices.get(rule.id) ?? null; const triggered = evaluateRule({ rule, currentPrice: sourceBar.close, previousState: previousObservedPrice == null ? null : { lastObservedPrice: previousObservedPrice }, }); return { currentPrice: sourceBar.close, primaryTargetWeight, rule, triggered, }; }) .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); const triggeredEvaluations = evaluations.filter((entry) => entry.triggered); const budgetPerRule = resolveBudgetPerRule(params.config, triggeredEvaluations.length); let signal: "buy" | "sell" | "hold" = "hold"; let budgetUsd = 0; if (mode === "long-short") { let shortDeltaSize = 0; for (const evaluation of triggeredEvaluations) { const targetBudgetUsd = budgetPerRule * evaluation.primaryTargetWeight; shortDeltaSize += targetBudgetUsd / primaryBar.close; budgetUsd += targetBudgetUsd; } if (shortDeltaSize > 0) { targetSize += shortDeltaSize; signal = "sell"; } } else { const hasSellTrigger = triggeredEvaluations.some( (evaluation) => evaluation.rule.actionSide === "sell", ); const buyBudgetUsd = triggeredEvaluations .filter((evaluation) => evaluation.rule.actionSide === "buy") .reduce((sum, evaluation) => { return sum + budgetPerRule * evaluation.primaryTargetWeight; }, 0); if (hasSellTrigger && targetSize > 0) { budgetUsd = targetSize * primaryBar.close; targetSize = 0; signal = "sell"; } else if (buyBudgetUsd > 0) { targetSize += buyBudgetUsd / primaryBar.close; budgetUsd = buyBudgetUsd; signal = "buy"; } } decisions.push({ ts: new Date(primaryBar.time).toISOString(), price: primaryBar.close, signal, targetSize, budgetUsd, indicator: "price-trigger", indicators: { primarySymbol, scheduleCron: params.config.schedule.cron, scheduleMinutes, relevantRuleIds: relevantRules.map(({ rule }) => rule.id), triggeredRuleIds: triggeredEvaluations.map(({ rule }) => rule.id), sourcePrices: Object.fromEntries( evaluations.map((evaluation) => [evaluation.rule.id, evaluation.currentPrice]), ), }, }); for (const evaluation of evaluations) { previousObservedPrices.set(evaluation.rule.id, evaluation.currentPrice); } } return { symbol: primarySymbol.toUpperCase(), timeframeStart: resolvedFrom != null ? new Date(resolvedFrom * 1000).toISOString() : new Date(primaryBars[0]!.time).toISOString(), timeframeEnd: resolvedTo != null ? new Date(resolvedTo * 1000).toISOString() : new Date(primaryBars[primaryBars.length - 1]!.time).toISOString(), barsEvaluated: primaryBars.length, resolution, mode, indicator: "price-trigger", decisions, }; }