openpondai/agents/polymarket-market-watcher
OpenTool app
1Branch0Tags
OP
OpenPond Syncsync: merge local template master into production
typescript
import {
fetchHyperliquidBars,
normalizeHyperliquidIndicatorBars,
resolveHyperliquidTargetSize,
} from "opentool/adapters/hyperliquid";
import {
fetchPolymarketMarket,
fetchPolymarketMidpoint,
fetchPolymarketPriceHistory,
type PolymarketMarket,
} from "opentool/adapters/polymarket";
import {
buildBacktestDecisionSeriesInput,
resolveBacktestAccountValueUsd,
resolveBacktestWindow,
type BacktestDecisionRequest,
type BacktestResolution,
} from "opentool/backtest";
import type { PolymarketMarketWatcherConfig, PolymarketWatcherSignal } from "./config";
import { executePolymarketWatcherTrade } from "./execution";
import { resolveExecutableMarketSymbol } from "./market-symbol";
export type ResolvedPolymarketWatcherMarket = {
title: string;
marketId: string;
slug: string | null;
conditionId: string | null;
tokenId: string;
watchedOutcome: "yes" | "no";
probability: number;
probabilitySource: "outcomePrices" | "midpoint";
liquidity: number | null;
volume: number | null;
};
export type PolymarketWatcherDecision = {
signal: PolymarketWatcherSignal;
reason: string;
probability: number | null;
liquidity: number | null;
volume: number | null;
};
export type PolymarketWatcherDecisionPoint = {
ts: string;
price: number;
signal: PolymarketWatcherSignal;
targetSize: number;
budgetUsd: number;
indicator: "polymarket";
indicators: Record<string, unknown>;
};
export type PolymarketWatcherBacktestDecisionSeriesResult = {
symbol: string;
timeframeStart: string;
timeframeEnd: string;
barsEvaluated: number;
resolution: BacktestResolution;
indicator: "polymarket";
decisions: PolymarketWatcherDecisionPoint[];
};
export type PolymarketWatcherRunResult = {
ok: true;
fetchedAt: string;
asOf: string;
price: number;
market: ResolvedPolymarketWatcherMarket;
decision: PolymarketWatcherDecision;
execution: Record<string, unknown>;
allocation: {
mode: PolymarketMarketWatcherConfig["allocationMode"];
percentOfEquity: number | undefined;
maxPercentOfEquity: number | undefined;
amountUsd: number | undefined;
};
};
function resolutionToSeconds(resolution: BacktestResolution): number {
if (resolution === "1") return 60;
if (resolution === "5") return 300;
if (resolution === "15") return 900;
if (resolution === "30") return 1_800;
if (resolution === "60") return 3_600;
if (resolution === "240") return 14_400;
if (resolution === "1D") return 86_400;
return 604_800;
}
function normalizeOutcome(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function parseOptionalNumeric(value: string | null | undefined): number | null {
if (!value) return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function resolveOutcomeIndex(
market: PolymarketMarket,
watchedOutcome: "yes" | "no",
): number {
const normalizedOutcomes = (market.outcomes ?? []).map((outcome) => normalizeOutcome(outcome));
const exactIndex = normalizedOutcomes.findIndex((outcome) => outcome === watchedOutcome);
if (exactIndex >= 0) return exactIndex;
if (normalizedOutcomes.length === 2) {
return watchedOutcome === "yes" ? 0 : 1;
}
throw new Error(`Unable to find watched outcome '${watchedOutcome}' on Polymarket market.`);
}
async function resolveWatchedMarket(
config: PolymarketMarketWatcherConfig,
): Promise<ResolvedPolymarketWatcherMarket> {
const market = await fetchPolymarketMarket({
...(config.marketId ? { id: config.marketId } : {}),
...(config.marketSlug ? { slug: config.marketSlug } : {}),
...(config.conditionId ? { conditionId: config.conditionId } : {}),
});
if (!market) {
throw new Error("Polymarket market not found.");
}
const outcomeIndex = resolveOutcomeIndex(market, config.watchedOutcome);
const tokenId = market.clobTokenIds?.[outcomeIndex];
if (!tokenId) {
throw new Error("Polymarket market is missing a token id for the watched outcome.");
}
const outcomeProbability = market.outcomePrices?.[outcomeIndex] ?? null;
const midpoint =
outcomeProbability == null ? await fetchPolymarketMidpoint({ tokenId }) : null;
const probability = outcomeProbability ?? midpoint;
if (probability == null) {
throw new Error("Unable to resolve watched Polymarket probability.");
}
return {
title: market.question ?? market.slug ?? market.id,
marketId: market.id,
slug: market.slug ?? null,
conditionId: market.conditionId ?? null,
tokenId,
watchedOutcome: config.watchedOutcome,
probability,
probabilitySource: outcomeProbability != null ? "outcomePrices" : "midpoint",
liquidity: parseOptionalNumeric(market.liquidity),
volume: parseOptionalNumeric(market.volume),
};
}
export function resolvePolymarketWatcherDecision(params: {
config: PolymarketMarketWatcherConfig;
market: Pick<ResolvedPolymarketWatcherMarket, "title" | "watchedOutcome" | "probability" | "liquidity" | "volume">;
}): PolymarketWatcherDecision {
const { config, market } = params;
if (
typeof config.minLiquidity === "number" &&
market.liquidity != null &&
market.liquidity < config.minLiquidity
) {
return {
signal: config.onNoMatch,
reason: `market liquidity ${market.liquidity.toFixed(2)} below ${config.minLiquidity.toFixed(2)}`,
probability: market.probability,
liquidity: market.liquidity,
volume: market.volume,
};
}
if (market.probability < config.minProbability) {
return {
signal: config.onNoMatch,
reason: `${market.watchedOutcome} probability ${market.probability.toFixed(4)} below ${config.minProbability.toFixed(4)}`,
probability: market.probability,
liquidity: market.liquidity,
volume: market.volume,
};
}
return {
signal: config.action,
reason: `${market.watchedOutcome} probability ${market.probability.toFixed(4)} on ${market.title}`,
probability: market.probability,
liquidity: market.liquidity,
volume: market.volume,
};
}
function findAlignedBar(
bars: ReturnType<typeof normalizeHyperliquidIndicatorBars>,
targetSeconds: number,
startIndex: number,
): { bar: ReturnType<typeof normalizeHyperliquidIndicatorBars>[number]; nextIndex: number } {
let index = startIndex;
const targetMs = targetSeconds * 1000;
while (index + 1 < bars.length && bars[index + 1]!.time <= targetMs) {
index += 1;
}
const bar = bars[index] ?? bars[0];
if (!bar) {
throw new Error("No Hyperliquid bars returned.");
}
return { bar, nextIndex: index };
}
async function fetchCurrentPrice(config: PolymarketMarketWatcherConfig): Promise<{ asOf: string; price: number }> {
const symbol = await resolveExecutableMarketSymbol(config);
const bars = await fetchHyperliquidBars({
symbol,
resolution: config.resolution,
countBack: Math.max(2, Math.min(config.countBack, 10)),
});
const bar = bars[bars.length - 1];
if (!bar) {
throw new Error("No Hyperliquid price data returned.");
}
return {
asOf: new Date(bar.time).toISOString(),
price: bar.close,
};
}
async function fetchPriceHistory(params: {
tokenId: string;
resolution: BacktestResolution;
startTs?: number;
endTs?: number;
fidelitySeconds: number;
}) {
return fetchPolymarketPriceHistory({
tokenId: params.tokenId,
...(params.startTs ? { startTs: params.startTs } : {}),
...(params.endTs ? { endTs: params.endTs } : {}),
fidelity: Math.max(params.fidelitySeconds, resolutionToSeconds(params.resolution)),
});
}
async function fetchHyperliquidBarsForWindow(params: {
symbol: string;
resolution: BacktestResolution;
countBack: number;
fromSeconds?: number;
toSeconds?: number;
}) {
return normalizeHyperliquidIndicatorBars(
await fetchHyperliquidBars({
symbol: params.symbol,
resolution: params.resolution,
countBack: params.countBack,
...(params.fromSeconds != null ? { fromSeconds: params.fromSeconds } : {}),
...(params.toSeconds != null ? { toSeconds: params.toSeconds } : {}),
}),
);
}
export async function buildBacktestDecisionSeries(params: {
config: PolymarketMarketWatcherConfig;
request: Partial<BacktestDecisionRequest>;
}): Promise<PolymarketWatcherBacktestDecisionSeriesResult> {
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 config =
input.symbol?.trim()
? {
...params.config,
asset: input.symbol.trim(),
marketSymbol: input.symbol.trim(),
}
: params.config;
const nowSeconds = Math.floor(Date.now() / 1000);
const lookbackWindowSeconds =
typeof input.lookbackDays === "number" && Number.isFinite(input.lookbackDays)
? Math.max(1, Math.ceil(input.lookbackDays * 86400))
: Math.max(
resolutionToSeconds(config.resolution) * Math.max(window.countBack - 5, 1),
86400,
);
const historyStartSeconds = window.fromSeconds ?? Math.max(0, nowSeconds - lookbackWindowSeconds);
const historyEndSeconds = window.toSeconds ?? nowSeconds;
const market = await resolveWatchedMarket(config);
const symbol = await resolveExecutableMarketSymbol(config);
const bars = await fetchHyperliquidBarsForWindow({
symbol,
resolution: config.resolution,
countBack: window.countBack,
fromSeconds: historyStartSeconds,
toSeconds: historyEndSeconds,
});
if (bars.length === 0) {
throw new Error("No Hyperliquid bars returned.");
}
const history = await fetchPriceHistory({
tokenId: market.tokenId,
resolution: config.resolution,
startTs: historyStartSeconds,
endTs: historyEndSeconds,
fidelitySeconds: config.historyFidelitySeconds,
});
if (history.length === 0) {
throw new Error("No Polymarket price history returned.");
}
const resolvedAccountValue =
resolveBacktestAccountValueUsd(input.accountValueUsd) ??
(config.allocationMode === "percent_equity" ? 10_000 : null);
const decisions: PolymarketWatcherDecisionPoint[] = [];
let barIndex = 0;
for (const point of history) {
const decision = resolvePolymarketWatcherDecision({
config,
market: {
title: market.title,
watchedOutcome: market.watchedOutcome,
probability: point.p,
liquidity: market.liquidity,
volume: market.volume,
},
});
const { bar, nextIndex } = findAlignedBar(bars, point.t, barIndex);
barIndex = nextIndex;
const { targetSize, budgetUsd } = resolveHyperliquidTargetSize({
config,
execution: config.execution,
accountValue: resolvedAccountValue,
currentPrice: bar.close,
});
decisions.push({
ts: new Date(point.t * 1000).toISOString(),
price: bar.close,
signal: decision.signal,
targetSize,
budgetUsd,
indicator: "polymarket",
indicators: {
marketTitle: market.title,
marketSlug: market.slug,
conditionId: market.conditionId,
tokenId: market.tokenId,
watchedOutcome: market.watchedOutcome,
probability: point.p,
minProbability: config.minProbability,
liquidity: market.liquidity,
volume: market.volume,
reason: decision.reason,
},
});
}
return {
symbol,
timeframeStart: decisions[0]?.ts ?? new Date().toISOString(),
timeframeEnd: decisions[decisions.length - 1]?.ts ?? new Date().toISOString(),
barsEvaluated: decisions.length,
resolution: config.resolution,
indicator: "polymarket",
decisions,
};
}
export async function runSignalBot(
config: PolymarketMarketWatcherConfig,
): Promise<PolymarketWatcherRunResult> {
const [market, { asOf, price }] = await Promise.all([
resolveWatchedMarket(config),
fetchCurrentPrice(config),
]);
const decision = resolvePolymarketWatcherDecision({ config, market });
const execution = await executePolymarketWatcherTrade({
config,
tradeSignal: decision.signal,
currentPrice: price,
});
if (!execution.ok) {
throw new Error(execution.error);
}
return {
ok: true,
fetchedAt: new Date().toISOString(),
asOf,
price,
market,
decision,
execution: execution.execution,
allocation: {
mode: config.allocationMode,
percentOfEquity: config.percentOfEquity,
maxPercentOfEquity: config.maxPercentOfEquity,
amountUsd: config.amountUsd,
},
};
}