mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 04:58:08 +00:00
319 lines
19 KiB
JavaScript
319 lines
19 KiB
JavaScript
const GAMMA = "https://gamma-api.polymarket.com";
|
|
const CLOB = "https://clob.polymarket.com";
|
|
const MARKET_LIMIT = Math.max(100, Math.min(500, Number(process.env.SHOCK_MARKETS || 500)));
|
|
const EXTERNAL_MARKET_LIMIT = Math.max(100, Math.min(2000, Number(process.env.SHOCK_EXTERNAL_MARKETS || 1000)));
|
|
const HISTORY_DAYS = Math.max(14, Math.min(30, Number(process.env.SHOCK_HISTORY_DAYS || 30)));
|
|
const CONCURRENCY = Math.max(1, Math.min(8, Number(process.env.SHOCK_CONCURRENCY || 6)));
|
|
const COST = Math.max(0, Math.min(0.05, Number(process.env.SHOCK_COST_CENTS || 0.5) / 100));
|
|
const SUMMARY_ONLY = process.env.SHOCK_SUMMARY === "1";
|
|
const HOUR = 3600;
|
|
const HORIZONS = [3, 6, 12, 24];
|
|
const WINDOWS = [1, 3, 6, 24];
|
|
const MIN_MOVES = [0.005, 0.01, 0.02, 0.04, 0.08];
|
|
const CONFIRMATIONS = ["any", "aligned", "opposed", "accelerating"];
|
|
const MAX_VOLS = [0.01, 0.02, 0.04, 1];
|
|
|
|
function parseJson(value) {
|
|
if (Array.isArray(value)) return value;
|
|
try { return JSON.parse(value || "[]"); } catch { return []; }
|
|
}
|
|
|
|
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(30000),
|
|
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, 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], index); }
|
|
catch (error) { output[index] = { error: error.message }; }
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
return output;
|
|
}
|
|
|
|
function categoryOf(raw) {
|
|
const text = `${raw.question || ""} ${(raw.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|celebrity|award|television|gaming|game release)\b/.test(text)) return "Pop Culture";
|
|
return "Other";
|
|
}
|
|
|
|
async function fetchMarkets(limit, universe = "active") {
|
|
const markets = [], seen = new Set();
|
|
if (universe === "closed") {
|
|
let cursor = "";
|
|
while (markets.length < limit) {
|
|
const params = new URLSearchParams({ closed: "true", order: "volumeNum", 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 labels = parseJson(raw.outcomes).map((value) => String(value).trim().toLowerCase());
|
|
const tokens = parseJson(raw.clobTokenIds).map(String), outcomes = parseJson(raw.outcomePrices).map(Number), id = String(raw.id || "");
|
|
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) continue;
|
|
seen.add(id);
|
|
markets.push({ id, token: tokens[0], category: categoryOf(raw), question: raw.question || "",
|
|
eventKey: String(raw.events?.[0]?.id || raw.events?.[0]?.slug || raw.eventId || id) });
|
|
if (markets.length >= limit) break;
|
|
}
|
|
if (page.length < 100 || !payload.next_cursor || payload.next_cursor === cursor) break;
|
|
cursor = payload.next_cursor;
|
|
}
|
|
return markets;
|
|
}
|
|
for (let offset = 0; markets.length < limit && offset < limit * 5; offset += 100) {
|
|
const params = new URLSearchParams({ active: "true", closed: "false", archived: "false", include_tag: "true",
|
|
limit: "100", offset: String(offset), order: "volume24hr", ascending: "false" });
|
|
const page = await fetchJson(`${GAMMA}/markets?${params}`);
|
|
if (!Array.isArray(page) || !page.length) break;
|
|
for (const raw of page) {
|
|
const labels = parseJson(raw.outcomes).map((value) => String(value).trim().toLowerCase());
|
|
const tokens = parseJson(raw.clobTokenIds).map(String), id = String(raw.id || "");
|
|
if (!id || seen.has(id) || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2) continue;
|
|
seen.add(id);
|
|
markets.push({ id, token: tokens[0], category: categoryOf(raw), question: raw.question || "",
|
|
eventKey: String(raw.events?.[0]?.id || raw.events?.[0]?.slug || raw.eventId || id) });
|
|
if (markets.length >= limit) break;
|
|
}
|
|
if (page.length < 100) break;
|
|
}
|
|
return markets;
|
|
}
|
|
|
|
function chunks(items, size) {
|
|
const output = [];
|
|
for (let index = 0; index < items.length; index += size) output.push(items.slice(index, index + size));
|
|
return output;
|
|
}
|
|
|
|
async function fetchHistories(markets) {
|
|
const responses = await mapLimit(chunks(markets, 20), CONCURRENCY, async (chunk) => fetchJson(`${CLOB}/batch-prices-history`, {
|
|
method: "POST", headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ markets: chunk.map((market) => market.token), interval: "1m", fidelity: 60 })
|
|
}));
|
|
const history = {};
|
|
responses.forEach((response) => {
|
|
if (!response?.history) return;
|
|
Object.entries(response.history).forEach(([token, points]) => {
|
|
history[token] = (points || []).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 { history, failures: responses.filter((response) => response?.error).length };
|
|
}
|
|
|
|
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 atOrAfter(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]; hi = mid - 1; }
|
|
else lo = mid + 1;
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
function stddev(values) {
|
|
if (values.length < 2) return 0;
|
|
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
return Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1));
|
|
}
|
|
|
|
function stableHash(value) {
|
|
let hash = 2166136261;
|
|
for (const char of String(value)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
|
|
return hash >>> 0;
|
|
}
|
|
|
|
function priceBand(price) {
|
|
if (price < 0.25) return "longshot";
|
|
if (price < 0.45) return "lower-mid";
|
|
if (price <= 0.55) return "center";
|
|
if (price <= 0.75) return "upper-mid";
|
|
return "favorite";
|
|
}
|
|
|
|
function observations(market, points) {
|
|
const rows = [], seen = new Set();
|
|
for (const current of points) {
|
|
const bucket = Math.floor(current.t / (3 * HOUR));
|
|
if (seen.has(bucket) || current.p < 0.05 || current.p > 0.95) continue;
|
|
const past = Object.fromEntries(WINDOWS.map((window) => [window, atOrBefore(points, current.t - window * HOUR)]));
|
|
const priorOne = atOrBefore(points, current.t - HOUR), priorTwo = atOrBefore(points, current.t - 2 * HOUR);
|
|
if (WINDOWS.some((window) => !past[window]) || !priorOne || !priorTwo || current.t - past[24].t > 26 * HOUR) continue;
|
|
const prior = points.filter((point) => point.t >= current.t - 24 * HOUR && point.t <= current.t);
|
|
if (prior.length < 12) continue;
|
|
const hourlyReturns = [];
|
|
for (let index = 1; index < prior.length; index++) hourlyReturns.push(prior[index].p - prior[index - 1].p);
|
|
const features = { moves: Object.fromEntries(WINDOWS.map((window) => [window, current.p - past[window].p])),
|
|
oneMove: current.p - priorOne.p, priorOneMove: priorOne.p - priorTwo.p, volatility: stddev(hourlyReturns) };
|
|
let captured = false;
|
|
for (const horizon of HORIZONS) {
|
|
const future = atOrAfter(points, current.t + horizon * HOUR);
|
|
if (!future || future.t - (current.t + horizon * HOUR) > 2 * HOUR) continue;
|
|
rows.push({ marketId: market.id, eventKey: market.eventKey, category: market.category, observedAt: current.t,
|
|
evaluatedAt: future.t, horizon, price: current.p, futurePrice: future.p, ...features });
|
|
captured = true;
|
|
}
|
|
if (captured) seen.add(bucket);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function confirmationPass(row, move, confirmation) {
|
|
const direction = Math.sign(move), oneDirection = Math.sign(row.oneMove);
|
|
if (confirmation === "any") return true;
|
|
if (confirmation === "aligned") return direction !== 0 && oneDirection === direction;
|
|
if (confirmation === "opposed") return direction !== 0 && oneDirection === -direction;
|
|
return direction !== 0 && oneDirection === direction && Math.abs(row.oneMove) >= Math.abs(row.priorOneMove);
|
|
}
|
|
|
|
function simulate(row, rule) {
|
|
if (row.horizon !== rule.horizon || row.volatility > rule.maxVol) return null;
|
|
if (rule.category !== "All" && row.category !== rule.category) return null;
|
|
const move = row.moves[rule.window];
|
|
if (Math.abs(move) < rule.minMove || !confirmationPass(row, move, rule.confirmation)) return null;
|
|
const followedSide = Math.sign(move) > 0 ? "YES" : "NO";
|
|
const side = rule.direction === "continue" ? followedSide : followedSide === "YES" ? "NO" : "YES";
|
|
const entry = side === "YES" ? row.price : 1 - row.price, exit = side === "YES" ? row.futurePrice : 1 - row.futurePrice;
|
|
if (entry <= 0.02 || entry >= 0.98) return null;
|
|
const band = priceBand(entry);
|
|
if (rule.band !== "All" && band !== rule.band) return null;
|
|
const netReturn = exit / entry - 1 - COST / entry;
|
|
return { marketId: row.marketId, eventKey: row.eventKey, observedAt: row.observedAt, evaluatedAt: row.evaluatedAt,
|
|
category: row.category, band, side, entry, exit, netReturn };
|
|
}
|
|
|
|
function summary(rows) {
|
|
if (!rows.length) return { attempts: 0, markets: 0, events: 0, mean: 0, eventMean: 0, lower: 0, upper: 0, winRate: 0 };
|
|
const events = new Map();
|
|
rows.forEach((row) => { const bucket = events.get(row.eventKey) || []; bucket.push(row.netReturn); events.set(row.eventKey, bucket); });
|
|
const eventReturns = [...events.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 = 1.645 * Math.sqrt(variance / Math.max(1, eventReturns.length));
|
|
return { attempts: rows.length, markets: new Set(rows.map((row) => row.marketId)).size, 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.netReturn > 0).length / rows.length };
|
|
}
|
|
|
|
const baseRules = [];
|
|
for (const horizon of HORIZONS) for (const window of WINDOWS) for (const minMove of MIN_MOVES)
|
|
for (const direction of ["continue", "fade"]) for (const confirmation of CONFIRMATIONS) for (const maxVol of MAX_VOLS)
|
|
baseRules.push({ id: `${direction}_h${horizon}_w${window}_m${minMove}_${confirmation}_v${maxVol}`, horizon, window, minMove,
|
|
direction, confirmation, maxVol, category: "All", band: "All" });
|
|
|
|
const markets = await fetchMarkets(MARKET_LIMIT), fetched = await fetchHistories(markets);
|
|
const rows = markets.flatMap((market) => observations(market, fetched.history[market.token] || []));
|
|
const eventHoldoutRows = rows.filter((row) => stableHash(row.eventKey) % 4 === 0);
|
|
const developmentRows = rows.filter((row) => stableHash(row.eventKey) % 4 !== 0);
|
|
const timestamps = [...new Set(developmentRows.map((row) => row.observedAt))].sort((a, b) => a - b);
|
|
const trainCut = timestamps[Math.floor(timestamps.length * 0.6)] || 0, validationCut = timestamps[Math.floor(timestamps.length * 0.8)] || 0;
|
|
const partitions = { train: developmentRows.filter((row) => row.observedAt < trainCut && row.evaluatedAt < trainCut),
|
|
validation: developmentRows.filter((row) => row.observedAt >= trainCut && row.evaluatedAt < validationCut),
|
|
holdout: developmentRows.filter((row) => row.observedAt >= validationCut) };
|
|
|
|
function evaluate(rule, partition) { return summary(partitions[partition].map((row) => simulate(row, rule)).filter(Boolean)); }
|
|
|
|
const trainWinners = baseRules.map((rule) => ({ rule, train: evaluate(rule, "train") }))
|
|
.filter((candidate) => candidate.train.attempts >= 100 && candidate.train.events >= 20 && candidate.train.lower > 0);
|
|
const refinedRules = [];
|
|
for (const candidate of trainWinners) {
|
|
for (const category of ["All", "Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"])
|
|
for (const band of ["All", "longshot", "lower-mid", "center", "upper-mid", "favorite"])
|
|
refinedRules.push({ ...candidate.rule, id: `${candidate.rule.id}_${category}_${band}`, category, band });
|
|
}
|
|
const uniqueRefined = [...new Map(refinedRules.map((rule) => [rule.id, rule])).values()];
|
|
const validationWinners = uniqueRefined.map((rule) => ({ rule, train: evaluate(rule, "train"), validation: evaluate(rule, "validation") }))
|
|
.filter((candidate) => candidate.train.attempts >= 80 && candidate.train.events >= 15 && candidate.train.lower > 0
|
|
&& candidate.validation.attempts >= 35 && candidate.validation.events >= 8 && candidate.validation.lower > 0);
|
|
const holdout = validationWinners.map((candidate) => ({ ...candidate, holdout: evaluate(candidate.rule, "holdout") }))
|
|
.map((candidate) => ({ ...candidate, passesHoldout: candidate.holdout.attempts >= 35 && candidate.holdout.events >= 8 && candidate.holdout.lower > 0 }))
|
|
.sort((a, b) => Number(b.passesHoldout) - Number(a.passesHoldout) || b.holdout.lower - a.holdout.lower);
|
|
|
|
const finalists = holdout.filter((candidate) => candidate.passesHoldout);
|
|
finalists.forEach((candidate) => {
|
|
candidate.eventHoldout = summary(eventHoldoutRows.map((row) => simulate(row, candidate.rule)).filter(Boolean));
|
|
candidate.passesEventHoldout = candidate.eventHoldout.attempts >= 35 && candidate.eventHoldout.events >= 8
|
|
&& candidate.eventHoldout.mean > 0 && candidate.eventHoldout.lower > 0;
|
|
});
|
|
let external = { markets: [], histories: {}, failures: 0, rows: [] };
|
|
if (finalists.length) {
|
|
const activeEvents = new Set(markets.map((market) => market.eventKey));
|
|
external.markets = (await fetchMarkets(EXTERNAL_MARKET_LIMIT, "closed")).filter((market) => !activeEvents.has(market.eventKey));
|
|
const externalFetched = await fetchHistories(external.markets);
|
|
external.histories = externalFetched.history;
|
|
external.failures = externalFetched.failures;
|
|
external.rows = external.markets.flatMap((market) => observations(market, external.histories[market.token] || []));
|
|
finalists.forEach((candidate) => {
|
|
candidate.archive = summary(external.rows.map((row) => simulate(row, candidate.rule)).filter(Boolean));
|
|
candidate.passesArchive = candidate.archive.attempts >= 100 && candidate.archive.events >= 20
|
|
&& candidate.archive.mean > 0 && candidate.archive.lower > 0;
|
|
});
|
|
}
|
|
|
|
const compact = (stats) => Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, Number.isFinite(value) ? +value.toFixed(5) : value]));
|
|
const candidateRow = (candidate) => ({ rule: candidate.rule, passesHoldout: candidate.passesHoldout,
|
|
passesEventHoldout: Boolean(candidate.passesEventHoldout), passesArchive: Boolean(candidate.passesArchive),
|
|
train: compact(candidate.train), validation: compact(candidate.validation), holdout: compact(candidate.holdout),
|
|
eventHoldout: candidate.eventHoldout ? compact(candidate.eventHoldout) : null, archive: candidate.archive ? compact(candidate.archive) : null });
|
|
const report = { generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, markets: markets.length,
|
|
marketsWithHistory: markets.filter((market) => fetched.history[market.token]?.length).length, fetchFailures: fetched.failures,
|
|
observations: rows.length, testedBaseRules: baseRules.length, trainWinners: trainWinners.length, testedRefinements: uniqueRefined.length,
|
|
validationWinners: validationWinners.length, holdoutPassed: holdout.filter((candidate) => candidate.passesHoldout).length,
|
|
eventHoldoutObservations: eventHoldoutRows.length, eventHoldoutEvents: new Set(eventHoldoutRows.map((row) => row.eventKey)).size,
|
|
eventHoldoutPassed: finalists.filter((candidate) => candidate.passesEventHoldout).length,
|
|
externalRequestedMarkets: finalists.length ? EXTERNAL_MARKET_LIMIT : 0, externalMarkets: external.markets.length,
|
|
externalMarketsWithHistory: external.markets.filter((market) => external.histories[market.token]?.length).length,
|
|
externalFetchFailures: external.failures, externalObservations: external.rows.length,
|
|
archivePassed: finalists.filter((candidate) => candidate.passesArchive).length,
|
|
methodology: { historyDays: HISTORY_DAYS, observationSpacingHours: 3, horizons: HORIZONS, windows: WINDOWS,
|
|
costCents: COST * 100, eventSplit: "25% deterministic event-disjoint holdout reserved before search",
|
|
split: "Remaining events use 60% train / 20% validation / 20% untouched chronological holdout with future-mark purge",
|
|
clusterUnit: "Polymarket event", refinementSource: "Only base rules with a positive train lower bound are refined by category and entry band",
|
|
limitation: "Current active-market selection is survivorship biased; any holdout winner still requires resolved-market external validation" },
|
|
partitions: Object.fromEntries(Object.entries(partitions).map(([key, value]) => [key, value.length])),
|
|
candidates: holdout.slice(0, 30).map(candidateRow) };
|
|
|
|
if (SUMMARY_ONLY) console.log(JSON.stringify({ generatedAt: report.generatedAt, markets: report.markets,
|
|
marketsWithHistory: report.marketsWithHistory, fetchFailures: report.fetchFailures, observations: report.observations,
|
|
testedBaseRules: report.testedBaseRules, trainWinners: report.trainWinners, testedRefinements: report.testedRefinements,
|
|
validationWinners: report.validationWinners, holdoutPassed: report.holdoutPassed,
|
|
eventHoldoutObservations: report.eventHoldoutObservations, eventHoldoutEvents: report.eventHoldoutEvents,
|
|
eventHoldoutPassed: report.eventHoldoutPassed, externalRequestedMarkets: report.externalRequestedMarkets,
|
|
externalMarkets: report.externalMarkets, externalMarketsWithHistory: report.externalMarketsWithHistory,
|
|
externalFetchFailures: report.externalFetchFailures, externalObservations: report.externalObservations,
|
|
archivePassed: report.archivePassed, partitions: report.partitions,
|
|
candidates: report.candidates.slice(0, 10) }, null, 2));
|
|
else console.log(JSON.stringify(report, null, 2));
|