openpondai/agents/news-bot
OpenTool app
typescript
import {
buildBacktestDecisionSeriesInput,
resolveBacktestAccountValueUsd,
resolveBacktestWindow,
type BacktestDecisionRequest,
} from "opentool/backtest";
import {
fetchHyperliquidBars,
resolveHyperliquidTargetSize,
} from "opentool/adapters/hyperliquid";
import { DEFAULT_EXECUTION_MODE, type NewsTradeSignal, type SimpleNewsBotBacktest, type SimpleNewsBotConfig } from "./config";
import { resolveExecutableMarketSymbol } from "./market-symbol";
import { buildBacktestCheckpoints, evaluateGate, fetchSignal, type SimpleNewsBotBacktestPoint } from "./news-core";
import { resolveNewsTradeDecision } from "./news-trade";
type BacktestDecisionPoint = {
ts: string;
price: number;
signal: NewsTradeSignal;
targetSize: number;
budgetUsd: number;
indicator: "news";
indicators: Record<string, unknown>;
};
export type NewsBacktestDecisionSeriesResult = {
symbol: string;
timeframeStart: string;
timeframeEnd: string;
barsEvaluated: number;
resolution: SimpleNewsBotConfig["resolution"];
mode: NonNullable<NonNullable<SimpleNewsBotConfig["execution"]>["mode"]>;
indicator: "news";
decisions: BacktestDecisionPoint[];
};
const DEFAULT_BACKTEST_DECISION_STEP_HOURS = 24;
const DEFAULT_BACKTEST_CANDIDATE_LIMIT = 2;
// Preview backtests only need to prove the strategy tool can resolve a valid
// decision path. Keep this to a single checkpoint so news+Polymarket replay
// does not timeout during create/change preview validation.
const DEFAULT_BACKTEST_DECISION_MAX_CHECKPOINTS = 1;
function resolveBacktestConfig(config: SimpleNewsBotConfig): SimpleNewsBotConfig {
const includePredictionMarkets =
config.predictionMarketSignal != null
? config.includePredictionMarkets
: false;
if (config.mode === "proposition") {
return {
...config,
includePredictionMarkets,
candidateLimit: Math.min(config.candidateLimit, DEFAULT_BACKTEST_CANDIDATE_LIMIT),
};
}
return {
...config,
includePredictionMarkets,
};
}
export type SimpleNewsBotBacktestResult = {
ok: true;
mode: SimpleNewsBotConfig["mode"];
fetchedAt: string;
backtest: {
startDate: string;
endDate: string;
stepHours: number;
points: SimpleNewsBotBacktestPoint[];
decisions: BacktestDecisionPoint[];
};
};
function findCheckpointPrice(
bars: Array<{ time: number; close: number }>,
checkpointMs: number,
startIndex: number,
): { price: number; nextIndex: number } {
let index = startIndex;
while (index + 1 < bars.length && bars[index + 1]!.time <= checkpointMs) {
index += 1;
}
const bar = bars[index] ?? bars[0];
if (!bar) {
throw new Error("No price data returned.");
}
return { price: bar.close, nextIndex: index };
}
export function downsampleBacktestCheckpoints(
checkpoints: string[],
maxCheckpoints: number,
): string[] {
if (checkpoints.length <= maxCheckpoints) return checkpoints;
if (maxCheckpoints <= 1) {
const lastCheckpoint = checkpoints[checkpoints.length - 1];
return lastCheckpoint ? [lastCheckpoint] : [];
}
const selectedIndexes = new Set<number>();
for (let index = 0; index < maxCheckpoints; index += 1) {
const ratio = index / (maxCheckpoints - 1);
const scaledIndex = Math.round(ratio * (checkpoints.length - 1));
selectedIndexes.add(scaledIndex);
}
return [...selectedIndexes]
.sort((left, right) => left - right)
.map((index) => checkpoints[index]!)
.filter(Boolean);
}
async function buildReplay(params: {
config: SimpleNewsBotConfig;
startDate: string;
endDate: string;
stepHours: number;
accountValueUsd?: number;
checkpoints?: string[];
}): Promise<SimpleNewsBotBacktestResult["backtest"]> {
const { config, startDate, endDate, stepHours, accountValueUsd } = params;
const executableSymbol = await resolveExecutableMarketSymbol(config);
const window = resolveBacktestWindow({
fallbackCountBack: config.countBack,
resolution: config.resolution,
timeframeStart: startDate,
timeframeEnd: endDate,
});
const bars = await fetchHyperliquidBars({
symbol: executableSymbol,
resolution: config.resolution,
countBack: window.countBack,
...(window.fromSeconds != null ? { fromSeconds: window.fromSeconds } : {}),
...(window.toSeconds != null ? { toSeconds: window.toSeconds } : {}),
});
if (bars.length === 0) {
throw new Error("No price data returned.");
}
const checkpoints =
params.checkpoints ?? buildBacktestCheckpoints({ startDate, endDate, stepHours });
const points: SimpleNewsBotBacktestPoint[] = [];
const decisions: BacktestDecisionPoint[] = [];
const execution = config.execution ?? {};
const resolvedAccountValue =
resolveBacktestAccountValueUsd(accountValueUsd) ??
(config.allocationMode === "percent_equity" ? 10_000 : null);
let barIndex = 0;
for (const checkpoint of checkpoints) {
const signal = await fetchSignal(config, checkpoint);
const gate = evaluateGate(config, signal);
const checkpointMs = new Date(checkpoint).getTime();
const { price, nextIndex } = findCheckpointPrice(bars, checkpointMs, barIndex);
barIndex = nextIndex;
const decision = resolveNewsTradeDecision(config, signal, gate);
const { targetSize, budgetUsd } = resolveHyperliquidTargetSize({
config,
execution,
accountValue: resolvedAccountValue,
currentPrice: price,
});
points.push({
asOf: checkpoint,
signal,
gate,
});
decisions.push({
ts: checkpoint,
price,
signal: decision.signal,
targetSize,
budgetUsd,
indicator: "news",
indicators: {
confidence: decision.confidence,
reason: decision.reason,
blockedByGate: decision.blockedByGate,
eventState: decision.eventState,
propositionAnswer: decision.propositionAnswer,
gate,
signal,
},
});
}
return {
startDate,
endDate,
stepHours,
points,
decisions,
};
}
export async function runSimpleNewsBotBacktest(
config: SimpleNewsBotConfig,
backtest: SimpleNewsBotBacktest,
): Promise<SimpleNewsBotBacktestResult> {
const replay = await buildReplay({
config,
startDate: backtest.startDate,
endDate: backtest.endDate ?? new Date().toISOString().slice(0, 10),
stepHours: backtest.stepHours ?? 24,
});
return {
ok: true,
mode: config.mode,
fetchedAt: new Date().toISOString(),
backtest: replay,
};
}
export async function buildBacktestDecisionSeries(params: {
config: SimpleNewsBotConfig;
request: Partial<BacktestDecisionRequest>;
}): Promise<NewsBacktestDecisionSeriesResult> {
const input = buildBacktestDecisionSeriesInput(params.request);
const window = resolveBacktestWindow({
fallbackCountBack: params.config.countBack,
lookbackDays: input.lookbackDays,
resolution: params.config.resolution,
from: input.from,
to: input.to,
timeframeStart: input.timeframeStart,
timeframeEnd: input.timeframeEnd,
});
const effectiveConfig = resolveBacktestConfig(
input.symbol?.trim()
? {
...params.config,
asset: input.symbol.trim(),
marketSymbol: input.symbol.trim(),
}
: params.config,
);
const startDate = new Date(
(window.fromSeconds ?? Math.floor(Date.now() / 1000) - 86400) * 1000,
).toISOString();
const endDate = new Date(
(window.toSeconds ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString();
const decisionSeriesCheckpoints = downsampleBacktestCheckpoints(
buildBacktestCheckpoints({
startDate,
endDate,
stepHours: DEFAULT_BACKTEST_DECISION_STEP_HOURS,
}),
DEFAULT_BACKTEST_DECISION_MAX_CHECKPOINTS,
);
const replay = await buildReplay({
config: effectiveConfig,
startDate,
endDate,
stepHours: DEFAULT_BACKTEST_DECISION_STEP_HOURS,
accountValueUsd: input.accountValueUsd,
checkpoints: decisionSeriesCheckpoints,
});
const executableSymbol = await resolveExecutableMarketSymbol(effectiveConfig);
const lastDecision = replay.decisions[replay.decisions.length - 1];
return {
symbol: executableSymbol,
timeframeStart: replay.startDate,
timeframeEnd: replay.endDate,
barsEvaluated: replay.decisions.length,
resolution: params.config.resolution,
mode: params.config.execution?.mode ?? DEFAULT_EXECUTION_MODE,
indicator: "news",
decisions: replay.decisions.map((decision) => ({
...decision,
budgetUsd: decision.budgetUsd,
targetSize: decision.targetSize,
price: decision.price,
signal: decision.signal,
ts: decision.ts,
indicator: "news",
indicators: {
...decision.indicators,
latestSignal: lastDecision?.signal ?? null,
},
})),
};
}