Files
polymarket-analyst_github/scripts/evaluate-settlements.mjs
T

324 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const GAMMA = "https://gamma-api.polymarket.com";
const CLOB = "https://clob.polymarket.com";
const MARKET_LIMIT = Math.max(20, Math.min(3000, Number(process.env.SETTLEMENT_MARKETS || 200)));
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.SETTLEMENT_CONCURRENCY || 6)));
const HORIZON_DAYS = [...new Set(String(process.env.SETTLEMENT_HORIZONS || "1,3,7,14,30,90").split(",")
.map(Number).filter((value) => Number.isFinite(value) && value >= 1 && value <= 365))].sort((a, b) => a - b);
const COST_CENTS = Math.max(0, Math.min(5, Number(process.env.SETTLEMENT_COST_CENTS || 0.5)));
const SELECTION_ORDER = ["volumeNum", "closedTime", "createdAt", "id"].includes(process.env.SETTLEMENT_ORDER)
? process.env.SETTLEMENT_ORDER : "volumeNum";
const SELECTION_ASCENDING = String(process.env.SETTLEMENT_ASCENDING || "false").toLowerCase() === "true";
const DAY = 86400;
function parseJson(value) {
if (Array.isArray(value)) return value;
try { return JSON.parse(value || "[]"); } catch { return []; }
}
function toTimestamp(value) {
const parsed = Date.parse(String(value || "").replace(" ", "T").replace(/\+00$/, "Z"));
return Number.isFinite(parsed) ? parsed / 1000 : null;
}
function categoryOf(raw) {
const text = `${raw.category || ""} ${raw.question || ""} ${(raw.events || []).flatMap((event) => event.tags || [])
.map((tag) => tag.slug || tag.label || "").join(" ")}`.toLowerCase();
if (/\b(election|president|politic|senate|congress|parliament|minister|governor|government|nominee|primary)\b/.test(text)) return "Politics";
if (/\b(bitcoin|crypto|ethereum|btc|eth|solana|xrp|token|stablecoin)\b/.test(text)) return "Crypto";
if (/\b(nba|nfl|nhl|mlb|soccer|football|baseball|basketball|tennis|ufc|boxing|championship|match|game|tournament|league)\b/.test(text)) return "Sports";
if (/\b(fed|inflation|gdp|recession|stock|company|economy|tariff|interest rate|unemployment|earnings)\b/.test(text)) return "Economy";
if (/\b(movie|music|album|box office|television|celebrity|award|gaming|youtube|stream)\b/.test(text)) return "Pop Culture";
return "Other";
}
function hardSettlementJumpRisk(raw) {
const text = `${raw?.question || ""} ${raw?.events?.[0]?.title || ""}`.toLowerCase();
const numericRange = /\b\d+(?:\.\d+)?\s*(?:%|percent)?\s*(?:-||—|to)\s*\d+(?:\.\d+)?\s*(?:%|percent|votes?|points?|seats?|bps|basis points?|tweets?|posts?|goals?)(?![a-z])/;
const currencyRange = /[$€£]\d+(?:\.\d+)?\s*(?:-||—|to)\s*[$€£]?\d+(?:\.\d+)?/;
const betweenRange = /\bbetween\s+[$€£]?\d+(?:\.\d+)?\s*(?:%|percent)?\s+(?:and|to)\s+[$€£]?\d+(?:\.\d+)?/;
const pathDependentBarrier = /\b(?:reach|hit|touch|dip(?:\s+(?:to|below))?|rise\s+(?:to|above)|fall\s+(?:to|below))\b[^?]{0,45}(?:[$€£]\s*)?\d[\d,]*(?:\.\d+)?\s*(?:%|percent|k|m|b|points?|bps|basis points?)?/;
return numericRange.test(text) || currencyRange.test(text) || betweenRange.test(text) || pathDependentBarrier.test(text)
|| /\bexact score\b|\bscore:\s*\d|\bposts? \d+-\d+|\bnumber of (tweets|posts)\b/.test(text);
}
async function fetchJson(url, options = {}, attempts = 3) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(25000),
headers: { accept: "application/json", ...(options.headers || {}) } });
if (response.ok) return response.json();
lastError = new Error(`${response.status} ${response.statusText}`);
if (response.status !== 429 && response.status < 500) break;
} catch (error) { lastError = error; }
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
}
throw lastError || new Error("request failed");
}
async function mapLimit(items, limit, task) {
const output = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
try { output[index] = await task(items[index], index); }
catch (error) { output[index] = { error: error.message }; }
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return output;
}
function atOrBefore(points, target) {
let lo = 0, hi = points.length - 1, answer = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (points[mid].t <= target) { answer = points[mid]; lo = mid + 1; }
else hi = mid - 1;
}
return answer;
}
function priceBand(price) {
if (price < 0.25) return "longshot";
if (price < 0.55) return "mid";
if (price < 0.78) return "favorite";
return "heavy-favorite";
}
function confirmedTrendAt(points, target, current) {
const dayPoint = atOrBefore(points, target - DAY), weekPoint = atOrBefore(points, target - 7 * DAY);
if (!dayPoint || !weekPoint || target - DAY - dayPoint.t > 36 * 3600 || target - 7 * DAY - weekPoint.t > 36 * 3600) return null;
const dayMove = current.p - dayPoint.p, weekMove = current.p - weekPoint.p;
const daySign = Math.sign(dayMove), weekSign = Math.sign(weekMove);
const confirmed = daySign && daySign === weekSign && Math.abs(dayMove) >= 0.006 && Math.abs(weekMove) >= 0.012
&& Math.abs(dayMove) <= 0.08 && Math.abs(weekMove) <= 0.18;
if (!confirmed) return null;
return { side: daySign > 0 ? "YES" : "NO", dayMove, weekMove,
strong: Math.abs(dayMove) >= 0.015 && Math.abs(weekMove) >= 0.03,
moderate: Math.abs(dayMove) <= 0.03 && Math.abs(weekMove) <= 0.10 };
}
async function fetchResolvedMarkets(limit) {
const raw = [], seen = new Set(), pageSize = 100;
let cursor = "";
while (raw.length < limit) {
const params = new URLSearchParams({ closed: "true", order: SELECTION_ORDER, ascending: String(SELECTION_ASCENDING),
limit: String(pageSize) });
if (cursor) params.set("after_cursor", cursor);
const payload = await fetchJson(`${GAMMA}/markets/keyset?${params}`), page = payload?.markets;
if (!Array.isArray(page) || !page.length) break;
for (const market of page) {
const id = String(market.id || ""), labels = parseJson(market.outcomes).map((outcome) => String(outcome).trim().toLowerCase());
const outcomes = parseJson(market.outcomePrices).map(Number);
const tokens = parseJson(market.clobTokenIds), closedAt = toTimestamp(market.closedTime || market.endDate);
const resolved = outcomes.length === 2 && outcomes.every(Number.isFinite)
&& ((outcomes[0] >= 0.99 && outcomes[1] <= 0.01) || (outcomes[1] >= 0.99 && outcomes[0] <= 0.01));
if (!id || seen.has(id) || !resolved || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2 || !closedAt) continue;
seen.add(id); raw.push({ id, question: market.question || "", category: categoryOf(market),
eventId: String(market.events?.[0]?.id || id),
tokenId: String(tokens[0]), finalYes: outcomes[0] >= 0.99 ? 1 : 0, closedAt,
safeContract: !hardSettlementJumpRisk(market), volume: Number(market.volumeNum || market.volume || 0) });
if (raw.length >= limit) break;
}
if (page.length < pageSize || !payload.next_cursor || payload.next_cursor === cursor) break;
cursor = payload.next_cursor;
}
return raw;
}
function evaluateMarket(market, points) {
const rows = [];
for (const horizonDays of HORIZON_DAYS) {
const target = market.closedAt - horizonDays * DAY, point = atOrBefore(points, target);
const maximumStaleness = Math.max(36 * 3600, horizonDays * DAY * 0.15);
if (!point || target - point.t > maximumStaleness || point.p <= 0.03 || point.p >= 0.97) continue;
const yesEntry = point.p, noEntry = 1 - point.p, favoriteSide = yesEntry >= noEntry ? "YES" : "NO";
const winningSide = market.finalYes ? "YES" : "NO", trend = confirmedTrendAt(points, target, point);
for (const side of ["YES", "NO"]) {
const entry = side === "YES" ? yesEntry : noEntry, final = side === winningSide ? 1 : 0;
const netReturn = final / entry - 1 - (COST_CENTS / 100) / entry;
rows.push({ marketId: market.id, eventId: market.eventId, question: market.question, category: market.category,
safeContract: market.safeContract, closedAt: market.closedAt,
horizonDays, side, favorite: side === favoriteSide, winner: side === winningSide,
trend: Boolean(trend && trend.side === side), trendSide: trend?.side || null,
dayMove: trend?.dayMove || 0, weekMove: trend?.weekMove || 0,
strongTrend: Boolean(trend?.strong), moderateTrend: Boolean(trend?.moderate),
entry, band: priceBand(entry), netReturn });
}
}
return rows;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function summarize(rows) {
if (!rows.length) return { count: 0, events: 0, mean: 0, median: 0, winRate: 0, lower90: 0, upper90: 0,
eventMean: 0, eventLower90: 0, eventUpper90: 0, worst: 0, best: 0 };
const values = rows.map((row) => row.netReturn), mean = values.reduce((sum, value) => sum + value, 0) / values.length;
const variance = values.length > 1 ? values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1) : 0;
const margin90 = 1.645 * Math.sqrt(variance / values.length);
const eventBuckets = new Map();
rows.forEach((row) => {
const bucket = eventBuckets.get(row.eventId) || [];
bucket.push(row.netReturn); eventBuckets.set(row.eventId, bucket);
});
const eventReturns = [...eventBuckets.values()].map((bucket) => bucket.reduce((sum, value) => sum + value, 0) / bucket.length);
const eventMean = eventReturns.reduce((sum, value) => sum + value, 0) / eventReturns.length;
const eventVariance = eventReturns.length > 1
? eventReturns.reduce((sum, value) => sum + (value - eventMean) ** 2, 0) / (eventReturns.length - 1) : 0;
const eventMargin90 = 1.645 * Math.sqrt(eventVariance / eventReturns.length);
return { count: values.length, mean, median: median(values), winRate: rows.filter((row) => row.winner).length / rows.length,
lower90: mean - margin90, upper90: mean + margin90, events: eventReturns.length,
eventMean, eventLower90: eventMean - eventMargin90, eventUpper90: eventMean + eventMargin90,
worst: Math.min(...values), best: Math.max(...values) };
}
const SAFE_PRICE_BANDS = Object.freeze([
{ label: "50_55", min: 0.50, max: 0.55 },
{ label: "55_60", min: 0.55, max: 0.60 },
{ label: "60_70", min: 0.60, max: 0.70 },
{ label: "70_80", min: 0.70, max: 0.80 },
{ label: "80_90", min: 0.80, max: 0.90 },
{ label: "90_95", min: 0.90, max: 0.95 },
{ label: "95_97", min: 0.95, max: 0.97 },
]);
const RULES = [
{ name: "buy_favorite", test: (row) => row.favorite },
{ name: "buy_heavy_favorite", test: (row) => row.favorite && row.entry >= 0.78 },
{ name: "buy_60_78_favorite", test: (row) => row.favorite && row.entry >= 0.60 && row.entry < 0.78 },
{ name: "buy_55_60_favorite", test: (row) => row.favorite && row.entry >= 0.55 && row.entry < 0.60 },
{ name: "buy_underdog", test: (row) => !row.favorite },
{ name: "buy_yes", test: (row) => row.side === "YES" },
{ name: "buy_no", test: (row) => row.side === "NO" },
{ name: "buy_no_favorite", test: (row) => row.side === "NO" && row.favorite },
{ name: "buy_no_45_50", test: (row) => row.side === "NO" && row.entry >= 0.45 && row.entry < 0.50 },
{ name: "buy_no_55_90", test: (row) => row.side === "NO" && row.entry >= 0.55 && row.entry < 0.90 },
{ name: "buy_no_50_55", test: (row) => row.side === "NO" && row.entry >= 0.50 && row.entry < 0.55 },
{ name: "buy_no_50_55_non_sports", test: (row) => row.side === "NO" && row.entry >= 0.50 && row.entry < 0.55 && row.category !== "Sports" },
{ name: "buy_no_50_55_safe_non_sports", test: (row) => row.side === "NO" && row.entry >= 0.50 && row.entry < 0.55
&& row.category !== "Sports" && row.safeContract },
{ name: "buy_no_55_60", test: (row) => row.side === "NO" && row.entry >= 0.55 && row.entry < 0.60 },
{ name: "buy_no_60_90", test: (row) => row.side === "NO" && row.entry >= 0.60 && row.entry < 0.90 },
{ name: "buy_no_90_95", test: (row) => row.side === "NO" && row.entry >= 0.90 && row.entry < 0.95 },
{ name: "buy_no_95_99", test: (row) => row.side === "NO" && row.entry >= 0.95 && row.entry < 0.99 },
{ name: "buy_no_90_99", test: (row) => row.side === "NO" && row.entry >= 0.90 && row.entry < 0.99 },
{ name: "follow_trend", test: (row) => row.trend },
{ name: "follow_trend_yes", test: (row) => row.trend && row.side === "YES" },
{ name: "follow_trend_no", test: (row) => row.trend && row.side === "NO" },
{ name: "follow_trend_favorite", test: (row) => row.trend && row.favorite },
{ name: "follow_trend_underdog", test: (row) => row.trend && !row.favorite },
{ name: "follow_strong_trend", test: (row) => row.trend && row.strongTrend },
{ name: "follow_moderate_trend", test: (row) => row.trend && row.moderateTrend },
...SAFE_PRICE_BANDS.flatMap((band) => [
{ name: `buy_safe_favorite_${band.label}`, test: (row) => row.favorite && row.entry >= band.min && row.entry < band.max
&& row.category !== "Sports" && row.safeContract },
{ name: `buy_safe_no_${band.label}`, test: (row) => row.side === "NO" && row.entry >= band.min && row.entry < band.max
&& row.category !== "Sports" && row.safeContract },
]),
...["Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"].flatMap((category) => [
{ name: `buy_favorite_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => row.favorite && row.category === category },
{ name: `buy_underdog_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => !row.favorite && row.category === category },
{ name: `buy_no_50_55_${category.toLowerCase().replace(/\s+/g, "_")}`,
test: (row) => row.side === "NO" && row.entry >= 0.50 && row.entry < 0.55 && row.category === category },
{ name: `follow_trend_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => row.trend && row.category === category },
]),
];
function evaluateRules(rows) {
return Object.fromEntries(RULES.map((rule) => [rule.name, summarize(rows.filter(rule.test))]));
}
function chronologicalEvaluation(rows) {
const ordered = [...rows].sort((a, b) => a.closedAt - b.closedAt);
const splitTime = ordered[Math.floor(ordered.length * 0.70)]?.closedAt || 0;
const cut1 = ordered[Math.floor(ordered.length / 3)]?.closedAt || 0;
const cut2 = ordered[Math.floor(ordered.length * 2 / 3)]?.closedAt || 0;
const train = ordered.filter((row) => row.closedAt < splitTime), test = ordered.filter((row) => row.closedAt >= splitTime);
const thirds = [ordered.filter((row) => row.closedAt < cut1),
ordered.filter((row) => row.closedAt >= cut1 && row.closedAt < cut2),
ordered.filter((row) => row.closedAt >= cut2)];
const pooled = evaluateRules(ordered), trainRules = evaluateRules(train), testRules = evaluateRules(test), thirdRules = thirds.map(evaluateRules);
const robustRules = Object.fromEntries(RULES.map((rule) => {
const segments = thirdRules.map((result) => result[rule.name]), all = pooled[rule.name];
const enoughData = all.events >= 15 && segments.every((segment) => segment.count >= 15 && segment.events >= 5)
&& trainRules[rule.name].count >= 30 && trainRules[rule.name].events >= 10
&& testRules[rule.name].count >= 15 && testRules[rule.name].events >= 5;
const allPositive = enoughData && all.eventLower90 > 0 && trainRules[rule.name].eventLower90 > 0
&& testRules[rule.name].eventLower90 > 0 && segments.every((segment) => segment.mean > 0 && segment.eventMean > 0);
const allNegative = enoughData && all.eventUpper90 < 0 && trainRules[rule.name].eventUpper90 < 0
&& testRules[rule.name].eventUpper90 < 0 && segments.every((segment) => segment.mean < 0 && segment.eventMean < 0);
return [rule.name, { enoughData, allPositive, allNegative, pooled: all, train: trainRules[rule.name], test: testRules[rule.name], segments }];
}));
return { splitTime: splitTime ? new Date(splitTime * 1000).toISOString() : null, trainCount: train.length,
testCount: test.length, train: trainRules, test: testRules, thirds: thirdRules, robustRules };
}
const markets = await fetchResolvedMarkets(MARKET_LIMIT);
const histories = await mapLimit(markets, CONCURRENCY, async (market) => {
const data = await fetchJson(`${CLOB}/prices-history?market=${encodeURIComponent(market.tokenId)}&interval=max&fidelity=1440`);
const points = (data.history || []).map((point) => ({ t: Number(point.t), p: Number(point.p) }))
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.p)).sort((a, b) => a.t - b.t);
return { market, points, rows: evaluateMarket(market, points) };
});
const successful = histories.filter((result) => result && !result.error && result.points.length);
const rows = successful.flatMap((result) => result.rows);
const report = {
generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, resolvedMarkets: markets.length,
marketsWithHistory: successful.length, failures: histories.filter((result) => result?.error).length,
methodology: { horizonDays: HORIZON_DAYS, estimatedRoundTripCostCents: COST_CENTS,
historyFidelityMinutes: 1440,
marketSelection: `${SELECTION_ORDER} ${SELECTION_ASCENDING ? "ascending" : "descending"}`,
clusterUnit: "event",
note: "Each rule uses only daily prices available at or before the decision horizon and a subsequently published binary settlement. Trend replays require aligned one-day and one-week direction under the production move bounds. Confidence bounds cluster related markets by event. Markets are selected by resolved volume, so results still carry historical-selection and execution-model limitations." },
horizons: Object.fromEntries(HORIZON_DAYS.map((horizon) => {
const horizonRows = rows.filter((row) => row.horizonDays === horizon);
return [horizon, { observations: horizonRows.length / 2, chronological: chronologicalEvaluation(horizonRows) }];
})),
};
const compact = process.env.SETTLEMENT_SUMMARY === "1";
const compactStats = (stats = {}) => ({ count: stats.count || 0, events: stats.events || 0,
mean: stats.mean || 0, eventMean: stats.eventMean || 0, eventLower90: stats.eventLower90 || 0,
eventUpper90: stats.eventUpper90 || 0, winRate: stats.winRate || 0 });
const compactRules = (rules = {}) => Object.fromEntries(Object.entries(rules)
.filter(([, result]) => result.enoughData && (result.allPositive || result.allNegative))
.map(([name, result]) => [name, { direction: result.allPositive ? "positive" : "negative",
pooled: compactStats(result.pooled), train: compactStats(result.train), test: compactStats(result.test) }]));
const TARGET_RULE_NAMES = String(process.env.SETTLEMENT_TARGET_RULES || "buy_no_favorite,buy_no_50_55,buy_no_55_90,buy_no_60_90,buy_no_90_95,buy_no_95_99,buy_no_90_99")
.split(",").map((name) => name.trim()).filter(Boolean);
const targetRuleStats = (chronological, names = TARGET_RULE_NAMES) => Object.fromEntries(names.map((name) => {
const result = chronological.robustRules[name] || {};
return [name, { enoughData: Boolean(result.enoughData), allPositive: Boolean(result.allPositive), allNegative: Boolean(result.allNegative),
pooled: compactStats(result.pooled), train: compactStats(result.train), test: compactStats(result.test),
segments: (result.segments || []).map(compactStats) }];
}));
const summary = {
generatedAt: report.generatedAt, requestedMarkets: report.requestedMarkets, resolvedMarkets: report.resolvedMarkets,
marketsWithHistory: report.marketsWithHistory, failures: report.failures,
horizons: Object.fromEntries(Object.entries(report.horizons).map(([days, value]) => [days, {
observations: value.observations,
favorite: compactStats(value.chronological.train.buy_favorite),
favoriteTest: compactStats(value.chronological.test.buy_favorite),
underdog: compactStats(value.chronological.train.buy_underdog),
underdogTest: compactStats(value.chronological.test.buy_underdog),
yes: compactStats(value.chronological.train.buy_yes),
yesTest: compactStats(value.chronological.test.buy_yes),
no: compactStats(value.chronological.train.buy_no),
noTest: compactStats(value.chronological.test.buy_no),
trend: compactStats(value.chronological.train.follow_trend),
trendTest: compactStats(value.chronological.test.follow_trend),
targetRules: targetRuleStats(value.chronological),
robustRules: compactRules(value.chronological.robustRules),
}])),
};
const targetOnly = process.env.SETTLEMENT_TARGET_ONLY === "1";
const targetSummary = { generatedAt: summary.generatedAt, requestedMarkets: summary.requestedMarkets,
resolvedMarkets: summary.resolvedMarkets, marketsWithHistory: summary.marketsWithHistory, failures: summary.failures,
horizons: Object.fromEntries(Object.entries(summary.horizons).map(([days, value]) => [days, { observations: value.observations, targetRules: value.targetRules }])) };
console.log(JSON.stringify(targetOnly ? targetSummary : (compact ? summary : report), null, 2));