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.SPORTS_FAVORITE_MARKETS || 3000))); const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.SPORTS_FAVORITE_CONCURRENCY || 10))); const COST = Math.max(0, Math.min(0.10, Number(process.env.SPORTS_FAVORITE_COST_CENTS || 5) / 100)); const MAX_STALENESS_HOURS = Math.max(1, Math.min(12, Number(process.env.SPORTS_FAVORITE_MAX_STALENESS_HOURS || 3))); const EVENT_LEGS = Math.max(1, Math.min(4, Number(process.env.SPORTS_FAVORITE_EVENT_LEGS || 1))); const COST_GRID = String(process.env.SPORTS_FAVORITE_COST_GRID_CENTS || "0,1,2,3,4,5") .split(",").map(Number).filter((value) => Number.isFinite(value) && value >= 0 && value <= 10).map((value) => value / 100); const HOUR = 3600; 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; } 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, 700 * (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; } function sportsMarket(raw) { const tags = [...(raw.tags || []), ...(raw.events || []).flatMap((event) => event.tags || [])] .map((tag) => `${tag.slug || ""} ${tag.label || ""}`).join(" "); const text = `${raw.question || ""} ${raw.sportsMarketType || ""} ${tags}`.toLowerCase(); return /\b(sports?|soccer|football|basketball|baseball|tennis|hockey|cricket|golf|boxing|ufc|nba|nfl|nhl|mlb|fifa|epl|match|game|tournament)\b/.test(text); } function gameStart(raw) { const event = raw.events?.[0] || {}; return timestamp(raw.gameStartTime || raw.eventStartTime || event.startTime || event.eventDate); } async function fetchMarkets(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 startsAt = gameStart(raw), closedAt = timestamp(raw.closedTime || raw.endDate); if (!id || seen.has(id) || labels[0] !== "yes" || labels[1] !== "no" || tokens.length !== 2 || finalYes == null || !startsAt || !closedAt || !sportsMarket(raw)) continue; seen.add(id); markets.push({ id, question: raw.question || "", eventKey: String(raw.events?.[0]?.id || id), event: raw.events?.[0]?.title || "", tokenId: tokens[0], finalYes, startsAt, closedAt, marketType: String(raw.sportsMarketType || "unknown") }); 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 observation(market, points, leadHours) { const target = market.startsAt - leadHours * HOUR, point = atOrBefore(points, target); if (!point || target - point.t > MAX_STALENESS_HOURS * HOUR || point.p <= 0.03 || point.p >= 0.97) return null; const side = point.p >= 0.5 ? "YES" : "NO", entry = side === "YES" ? point.p : 1 - point.p; const won = side === (market.finalYes ? "YES" : "NO"), netReturn = (won ? 1 : 0) / entry - 1 - COST / entry; return { marketId: market.id, eventKey: market.eventKey, question: market.question, event: market.event, marketType: market.marketType, startsAt: market.startsAt, closedAt: market.closedAt, leadHours, side, entry, won, netReturn }; } function summarize(rows) { if (!rows.length) return { trades: 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 { 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 rules = []; for (const leadHours of [12, 18, 24, 30, 36]) { for (const minEntry of [0.55, 0.60, 0.65, 0.70]) { for (const maxEntry of [0.75, 0.85, 0.95]) { if (minEntry >= maxEntry) continue; rules.push({ id: `lead${leadHours}_${minEntry}-${maxEntry}`, leadHours, minEntry, maxEntry }); } } } const markets = await fetchMarkets(MARKET_LIMIT); const histories = await mapLimit(markets, CONCURRENCY, async (market) => { const data = await fetchJson(`${CLOB}/prices-history?market=${encodeURIComponent(market.tokenId)}&interval=max&fidelity=60`); 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 }; }); const usable = histories.filter((row) => row && !row.error && row.points.length); const times = usable.map((row) => row.market.startsAt).sort((a, b) => a - b); const trainCut = times[Math.floor(times.length * 0.6)] || 0, validationCut = times[Math.floor(times.length * 0.8)] || 0; const partitions = { train: usable.filter((row) => row.market.startsAt < trainCut), validation: usable.filter((row) => row.market.startsAt >= trainCut && row.market.startsAt < validationCut), holdout: usable.filter((row) => row.market.startsAt >= validationCut), }; function tradesFor(partition, rule) { const eligible = partition.map(({ market, points }) => observation(market, points, rule.leadHours)).filter(Boolean) .filter((row) => row.entry >= rule.minEntry && row.entry < rule.maxEntry); const events = new Map(); eligible.forEach((row) => { const rows = events.get(row.eventKey) || []; rows.push(row);events.set(row.eventKey, rows); }); return [...events.values()].flatMap((rows) => rows.sort((a, b) => b.entry - a.entry || a.marketId.localeCompare(b.marketId)).slice(0, EVENT_LEGS)); } const evaluated = rules.map((rule) => { const trainRows = tradesFor(partitions.train, rule), validationRows = tradesFor(partitions.validation, rule), holdoutRows = tradesFor(partitions.holdout, rule); const train = summarize(trainRows), validation = summarize(validationRows), holdout = summarize(holdoutRows); const trainPassed = train.trades >= 60 && train.events >= 25 && train.lower > 0; const validationPassed = trainPassed && validation.trades >= 25 && validation.events >= 10 && validation.lower > 0; const passesHoldout = validationPassed && holdout.trades >= 25 && holdout.events >= 10 && holdout.lower > 0; return { rule, train, validation, holdout, 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 baseline = evaluated.find((row) => row.rule.leadHours === 24 && row.rule.minEntry === 0.55 && row.rule.maxEntry === 0.95); const retiredPilot = evaluated.find((row) => row.rule.leadHours === 24 && row.rule.minEntry === 0.60 && row.rule.maxEntry === 0.85); const forwardResearch = evaluated.find((row) => row.rule.leadHours === 12 && row.rule.minEntry === 0.60 && row.rule.maxEntry === 0.75); 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), passesHoldout: row.passesHoldout }); function costCurve(rule) { if (!rule) return []; const raw = Object.fromEntries(Object.entries(partitions).map(([name, partition]) => [name, tradesFor(partition, rule)])); return COST_GRID.map((cost) => ({ modeledCostCents: cost * 100, ...Object.fromEntries(Object.entries(raw).map(([name, rows]) => [name, compact(summarize(rows.map((row) => ({ ...row, netReturn: (row.won ? 1 : 0) / row.entry - 1 - cost / row.entry }))))])) })); } const closest = evaluated.filter((row) => row.train.events >= 25 && row.validation.events >= 10 && row.holdout.events >= 10) .sort((a, b) => Math.min(b.train.lower, b.validation.lower, b.holdout.lower) - Math.min(a.train.lower, a.validation.lower, a.holdout.lower)); console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, sportsMarkets: markets.length, historiesWithData: usable.length, failures: histories.filter((row) => row?.error).length, methodology: { selection: "most recently closed eligible Yes/No sports markets", decisionAnchor: "published game start", historyFidelityMinutes: 60, maxPriceStalenessHours: MAX_STALENESS_HOURS, modeledCostCents: COST * 100, split: "60% train / 20% validation / 20% untouched holdout", clusterUnit: `one equal-dollar position in each event's highest-priced eligible favorite (event legs ${EVENT_LEGS})`, testedRules: rules.length }, partitionMarkets: Object.fromEntries(Object.entries(partitions).map(([key, value]) => [key, value.length])), trainPassed: evaluated.filter((row) => row.trainPassed).length, validationSelected: selected.length, holdoutPassed: selected.filter((row) => row.passesHoldout).length, baseline: baseline ? candidate(baseline) : null, retiredPilot: retiredPilot ? candidate(retiredPilot) : null, forwardResearch: forwardResearch ? candidate(forwardResearch) : null, costSensitivity: { retiredPilot: costCurve(retiredPilot?.rule), forwardResearch: costCurve(forwardResearch?.rule) }, closestCandidates: closest.slice(0, 15).map(candidate), candidates: selected.slice(0, 20).map(candidate), holdoutExamples: (selected.find((row) => row.passesHoldout)?.holdoutRows || []).slice(0, 12) .map((row) => ({ question: row.question, event: row.event, side: row.side, entry: +row.entry.toFixed(4), won: row.won, netReturn: +row.netReturn.toFixed(4) })) }, null, 2));