mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 21:18:08 +00:00
221 lines
13 KiB
JavaScript
221 lines
13 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 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 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 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";
|
|
}
|
|
|
|
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) {
|
|
const markets = [], seen = new Set();
|
|
let cursor = "";
|
|
while (markets.length < limit) {
|
|
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);
|
|
if (!id || seen.has(id) || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2 || finalYes == null || !closedAt || !createdAt) continue;
|
|
seen.add(id);
|
|
markets.push({ id, question: raw.question || "", eventKey: String(raw.events?.[0]?.id || id), tokenId: tokens[0],
|
|
finalYes, closedAt, createdAt, category: categoryOf(raw) });
|
|
if (markets.length >= limit) break;
|
|
}
|
|
if (page.length < 100 || !payload.next_cursor || payload.next_cursor === cursor) break;
|
|
cursor = payload.next_cursor;
|
|
}
|
|
return markets;
|
|
}
|
|
|
|
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.closedAt - 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");
|
|
return { marketId: market.id, eventKey: market.eventKey, question: market.question, category: market.category,
|
|
closedAt: market.closedAt, decisionAt, horizonDays, side, entry, favorite: entry >= 0.5, won,
|
|
netReturn: (won ? 1 : 0) / entry - 1 - COST / 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);
|
|
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,
|
|
historyFidelityMinutes: 1440, maximumPriceStalenessHours: 36, modeledCostCents: COST * 100,
|
|
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 event",
|
|
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 recent-closure cohort, and midpoint-plus-cost 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),
|
|
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));
|