Files
polymarket-analyst_github/scripts/evaluate-settlement-calibration.mjs
T

256 lines
16 KiB
JavaScript

const GAMMA = "https://gamma-api.polymarket.com";
const CLOB = "https://clob.polymarket.com";
const MARKET_LIMIT = Math.max(500, Math.min(5000, Number(process.env.CALIBRATION_MARKETS || 5000)));
const MARKET_SKIP = Math.max(0, Math.min(10000, Number(process.env.CALIBRATION_SKIP || 0)));
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.CALIBRATION_CONCURRENCY || 10)));
const COST = Math.max(0, Math.min(0.10, Number(process.env.CALIBRATION_COST_CENTS || 1) / 100));
const EXACT_GAMMA_FEES = process.env.CALIBRATION_EXACT_GAMMA_FEES === "1";
const HORIZONS = [...new Set(String(process.env.CALIBRATION_HORIZONS || "1,3,7,14,30")
.split(",").map(Number).filter((value) => Number.isFinite(value) && value >= 1 && value <= 180))].sort((a, b) => a - b);
const TARGET_RULE_IDS = String(process.env.CALIBRATION_TARGET_RULES || "3d_no_0.03-0.97_sports")
.split(",").map((value) => value.trim()).filter(Boolean);
const DAY = 86400;
function parseJson(value) {
if (Array.isArray(value)) return value;
try { return JSON.parse(value || "[]"); } catch { return []; }
}
function timestamp(value) {
const parsed = Date.parse(String(value || "").replace(" ", "T").replace(/\+00$/, "Z"));
return Number.isFinite(parsed) ? parsed / 1000 : null;
}
function categoryOf(raw) {
const tags = [...(raw.tags || []), ...(raw.events || []).flatMap((event) => event.tags || [])]
.map((tag) => `${tag.slug || ""} ${tag.label || ""}`).join(" ");
const text = `${raw.category || ""} ${raw.question || ""} ${raw.sportsMarketType || ""} ${tags}`.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(sports?|soccer|football|basketball|baseball|tennis|hockey|cricket|golf|boxing|ufc|nba|nfl|nhl|mlb|fifa|epl|match|game|tournament)\b/.test(text)) return "Sports";
if (/\b(fed|inflation|gdp|recession|stock|company|economy|tariff|interest rate|unemployment|earnings|ipo)\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 feeScheduleOf(raw) {
const rate = Number(raw?.feeSchedule?.rate), exponent = Number(raw?.feeSchedule?.exponent);
return Number.isFinite(rate) && rate >= 0 && Number.isFinite(exponent) && exponent > 0
? { rate, exponent } : null;
}
function takerFeePerShare(schedule, price) {
const p = Number(price), rate = Number(schedule?.rate), exponent = Number(schedule?.exponent);
if (!(p > 0 && p < 1) || !Number.isFinite(rate) || rate < 0 || !Number.isFinite(exponent) || exponent <= 0) return null;
return rate * Math.pow(p * (1 - p), exponent);
}
function sportsContestKey(raw, gameStartAt) {
const slug = String(raw?.slug || "").toLowerCase();
const datedPrefix = slug.match(/^(.+?-\d{4}-\d{2}-\d{2})(?:-|$)/)?.[1];
if (datedPrefix) return `sports:${datedPrefix}`;
const start = Number.isFinite(gameStartAt) ? String(gameStartAt) : "unknown-start";
const title = String(raw?.question || "").toLowerCase().split(":")[0]
.replace(/\b(will|win|exact score|leading at halftime|to score first)\b/g, " ").replace(/[^a-z0-9]+/g, " ").trim();
return `sports:${start}:${title || raw?.id || "unknown"}`;
}
async function fetchJson(url, attempts = 4) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(30000), headers: { accept: "application/json" } });
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, 600 * (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]); }
catch (error) { output[index] = { error: error.message }; }
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return output;
}
async function fetchResolvedMarkets(limit, skip = 0) {
const markets = [], seen = new Set();
const targetCount = limit + skip;
let cursor = "";
while (markets.length < targetCount) {
const params = new URLSearchParams({ closed: "true", order: "closedTime", ascending: "false", limit: "100", include_tag: "true" });
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 raw of page) {
const id = String(raw.id || ""), labels = parseJson(raw.outcomes).map((value) => String(value).trim().toLowerCase());
const outcomes = parseJson(raw.outcomePrices).map(Number), tokens = parseJson(raw.clobTokenIds).map(String);
const finalYes = outcomes[0] >= 0.99 && outcomes[1] <= 0.01 ? 1 : outcomes[1] >= 0.99 && outcomes[0] <= 0.01 ? 0 : null;
const closedAt = timestamp(raw.closedTime || raw.endDate), createdAt = timestamp(raw.createdAt), gameStartAt = timestamp(raw.gameStartTime);
if (!id || seen.has(id) || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2 || finalYes == null || !closedAt || !createdAt) continue;
seen.add(id);
const category = categoryOf(raw), eventKey = category === "Sports" ? sportsContestKey(raw, gameStartAt) : String(raw.events?.[0]?.id || id);
markets.push({ id, question: raw.question || "", eventKey, tokenId: tokens[0],
finalYes, closedAt, createdAt, gameStartAt, decisionAnchor:category === "Sports" && gameStartAt ? gameStartAt : closedAt,
category, feeSchedule: feeScheduleOf(raw) });
if (markets.length >= targetCount) break;
}
if (page.length < 100 || !payload.next_cursor || payload.next_cursor === cursor) break;
cursor = payload.next_cursor;
}
return markets.slice(skip, skip + limit);
}
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 observations(market, points) {
return HORIZONS.flatMap((horizonDays) => {
const decisionAt = market.decisionAnchor - horizonDays * DAY, point = atOrBefore(points, decisionAt);
const recent = points.filter((candidate) => candidate.t >= decisionAt - 7 * DAY && candidate.t <= decisionAt);
const recentRange = recent.length ? Math.max(...recent.map((candidate) => candidate.p)) - Math.min(...recent.map((candidate) => candidate.p)) : 0;
if (!point || decisionAt < market.createdAt + DAY || decisionAt - point.t > 36 * 3600
|| recent.length < 2 || recentRange < 0.005 || point.p <= 0.03 || point.p >= 0.97) return [];
return ["YES", "NO"].map((side) => {
const entry = side === "YES" ? point.p : 1 - point.p;
const won = side === (market.finalYes ? "YES" : "NO");
const fee = EXACT_GAMMA_FEES ? takerFeePerShare(market.feeSchedule, entry) : null;
const entryCost = COST + (EXACT_GAMMA_FEES && Number.isFinite(fee) ? fee : 0);
return { marketId: market.id, eventKey: market.eventKey, question: market.question, category: market.category,
closedAt: market.closedAt,gameStartAt:market.gameStartAt,decisionAt, horizonDays, side, entry, favorite: entry >= 0.5, won,
entryCost, exactFeeSchedule: EXACT_GAMMA_FEES && market.feeSchedule != null,
netReturn: (won ? 1 : 0) / entry - 1 - entryCost / entry };
});
});
}
function tradesFor(rows, rule) {
const grouped = new Map();
rows.filter((row) => matches(row, rule)).forEach((row) => {
const current = grouped.get(row.eventKey);
if (!current || row.entry > current.entry || (row.entry === current.entry && row.marketId < current.marketId)) {
grouped.set(row.eventKey, row);
}
});
return [...grouped.values()];
}
function summarize(rows, confidence = 1.96) {
if (!rows.length) return { trades: 0, events: 0, mean: 0, eventMean: 0, lower: 0, upper: 0, winRate: 0 };
const buckets = new Map();
rows.forEach((row) => { const values = buckets.get(row.eventKey) || []; values.push(row.netReturn); buckets.set(row.eventKey, values); });
const eventReturns = [...buckets.values()].map((values) => values.reduce((sum, value) => sum + value, 0) / values.length);
const eventMean = eventReturns.reduce((sum, value) => sum + value, 0) / eventReturns.length;
const variance = eventReturns.length > 1
? eventReturns.reduce((sum, value) => sum + (value - eventMean) ** 2, 0) / (eventReturns.length - 1) : 0;
const margin = confidence * Math.sqrt(variance / Math.max(1, eventReturns.length));
return { trades: rows.length, events: eventReturns.length,
mean: rows.reduce((sum, row) => sum + row.netReturn, 0) / rows.length,
eventMean, lower: eventMean - margin, upper: eventMean + margin,
winRate: rows.filter((row) => row.won).length / rows.length };
}
const PRICE_RANGES = [
[0.03, 0.97], [0.05, 0.25], [0.10, 0.30], [0.20, 0.40], [0.30, 0.50],
[0.40, 0.60], [0.50, 0.70], [0.60, 0.80], [0.70, 0.90], [0.75, 0.95],
];
const CATEGORIES = ["All", "Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"];
const rules = [];
for (const horizonDays of HORIZONS) {
for (const side of ["YES", "NO", "FAVORITE", "UNDERDOG"]) {
for (const [minEntry, maxEntry] of PRICE_RANGES) {
for (const category of CATEGORIES) {
rules.push({ id: `${horizonDays}d_${side.toLowerCase()}_${minEntry}-${maxEntry}_${category.toLowerCase().replace(/\s+/g, "-")}`,
horizonDays, side, minEntry, maxEntry, category });
}
}
}
}
function matches(row, rule) {
return row.horizonDays === rule.horizonDays && row.entry >= rule.minEntry && row.entry < rule.maxEntry
&& (rule.category === "All" || row.category === rule.category)
&& (rule.side === row.side || (rule.side === "FAVORITE" && row.favorite) || (rule.side === "UNDERDOG" && !row.favorite));
}
function splitByTime(rows) {
const times = [...new Set(rows.map((row) => row.closedAt))].sort((a, b) => a - b);
const trainCut = times[Math.floor(times.length * 0.60)] || 0, validationCut = times[Math.floor(times.length * 0.80)] || 0;
return {
train: rows.filter((row) => row.closedAt < trainCut),
validation: rows.filter((row) => row.closedAt >= trainCut && row.closedAt < validationCut),
holdout: rows.filter((row) => row.closedAt >= validationCut),
trainCut, validationCut,
};
}
function stabilityWindows(rows) {
const times = [...new Set(rows.map((row) => row.closedAt))].sort((a, b) => a - b);
const cuts = [0, 0.25, 0.5, 0.75, 1].map((fraction) => times[Math.min(times.length - 1, Math.floor(times.length * fraction))] || 0);
return Array.from({ length: 4 }, (_, index) => rows.filter((row) => row.closedAt >= cuts[index]
&& (index === 3 || row.closedAt < cuts[index + 1])));
}
const markets = await fetchResolvedMarkets(MARKET_LIMIT, MARKET_SKIP);
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, observations: observations(market, points) };
});
const usable = histories.filter((row) => row && !row.error && row.points.length), rows = usable.flatMap((row) => row.observations);
const partitions = splitByTime(rows), windows = stabilityWindows(rows);
const evaluated = rules.map((rule) => {
const train = summarize(tradesFor(partitions.train, rule));
const validation = summarize(tradesFor(partitions.validation, rule));
const holdoutRows = tradesFor(partitions.holdout, rule), holdout = summarize(holdoutRows);
const stable = windows.map((window) => summarize(tradesFor(window, rule)));
const trainPassed = train.trades >= 100 && train.events >= 50 && train.lower > 0;
const validationPassed = trainPassed && validation.trades >= 40 && validation.events >= 20 && validation.lower > 0
&& stable.every((summary) => summary.trades >= 20 && summary.events >= 10 && summary.eventMean > 0);
const passesHoldout = validationPassed && holdout.trades >= 40 && holdout.events >= 20 && holdout.lower > 0;
return { rule, train, validation, holdout, stable, trainPassed, validationPassed, passesHoldout, holdoutRows };
});
const selected = evaluated.filter((row) => row.validationPassed)
.sort((a, b) => Number(b.passesHoldout) - Number(a.passesHoldout) || b.holdout.lower - a.holdout.lower);
const compact = (stats) => Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, Number.isFinite(value) ? +value.toFixed(5) : value]));
const candidate = (row) => ({ rule: row.rule, train: compact(row.train), validation: compact(row.validation),
holdout: compact(row.holdout), stabilityMeans: row.stable.map((summary) => +summary.eventMean.toFixed(5)), passesHoldout: row.passesHoldout });
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, resolvedMarkets: markets.length,
historiesWithData: usable.length, failures: histories.filter((row) => row?.error).length, observations: rows.length / 2,
methodology: { selection: "most recently closed eligible Yes/No markets", horizonsDays: HORIZONS,
selectionOffset: MARKET_SKIP,historyFidelityMinutes: 1440, maximumPriceStalenessHours: 36,
modeledEntrySlippageCents: COST * 100,exactGammaEntryFeeSchedules:EXACT_GAMMA_FEES,settlementRedemptionExitFeeCents:0,
split: "60% train / 20% validation / 20% untouched holdout", confidence: "event-clustered 95% lower bound",
stability: "positive event mean in each of four chronological windows; at most one highest-entry market per underlying event or sports contest",
sportsTiming:"published gameStartTime minus horizon; sports contracts sharing the dated contest slug prefix form one cluster",
activityGate: "market open for at least 24h with at least two recent observations and a 0.5-cent seven-day price range", testedRules: rules.length,
note: "No final volume or settlement outcome enters rule features. Market selection remains a closure-time cohort, and midpoint-plus-fee-plus-slippage is still an execution approximation." },
partitionRows: { train: partitions.train.length / 2, validation: partitions.validation.length / 2, holdout: partitions.holdout.length / 2 },
trainPassed: evaluated.filter((row) => row.trainPassed).length, validationSelected: selected.length,
holdoutPassed: selected.filter((row) => row.passesHoldout).length, candidates: selected.slice(0, 25).map(candidate),
targets: evaluated.filter((row) => TARGET_RULE_IDS.includes(row.rule.id)).map(candidate),
holdoutExamples: (selected.find((row) => row.passesHoldout)?.holdoutRows || []).slice(0, 15)
.map((row) => ({ marketId: row.marketId, question: row.question, category: row.category, side: row.side,
entry: +row.entry.toFixed(4), won: row.won, netReturn: +row.netReturn.toFixed(4),
decisionAt: new Date(row.decisionAt * 1000).toISOString(), closedAt: new Date(row.closedAt * 1000).toISOString() }))
}, null, 2));