openpondai/agents/price-trigger-bot
OpenTool app
1Branch0Tags
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,
};
}