mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 13:08:10 +00:00
Launch bounded sports contest exploration
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
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) {
|
||||
@@ -29,6 +33,28 @@ function categoryOf(raw) {
|
||||
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++) {
|
||||
@@ -57,10 +83,11 @@ async function mapLimit(items, limit, task) {
|
||||
return output;
|
||||
}
|
||||
|
||||
async function fetchResolvedMarkets(limit) {
|
||||
async function fetchResolvedMarkets(limit, skip = 0) {
|
||||
const markets = [], seen = new Set();
|
||||
const targetCount = limit + skip;
|
||||
let cursor = "";
|
||||
while (markets.length < limit) {
|
||||
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;
|
||||
@@ -69,17 +96,19 @@ async function fetchResolvedMarkets(limit) {
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
return markets.slice(skip, skip + limit);
|
||||
}
|
||||
|
||||
function atOrBefore(points, target) {
|
||||
@@ -94,7 +123,7 @@ function atOrBefore(points, target) {
|
||||
|
||||
function observations(market, points) {
|
||||
return HORIZONS.flatMap((horizonDays) => {
|
||||
const decisionAt = market.closedAt - horizonDays * DAY, point = atOrBefore(points, decisionAt);
|
||||
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
|
||||
@@ -102,9 +131,12 @@ function observations(market, points) {
|
||||
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, decisionAt, horizonDays, side, entry, favorite: entry >= 0.5, won,
|
||||
netReturn: (won ? 1 : 0) / entry - 1 - COST / entry };
|
||||
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 };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -176,7 +208,7 @@ function stabilityWindows(rows) {
|
||||
&& (index === 3 || row.closedAt < cuts[index + 1])));
|
||||
}
|
||||
|
||||
const markets = await fetchResolvedMarkets(MARKET_LIMIT);
|
||||
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) }))
|
||||
@@ -205,14 +237,17 @@ const candidate = (row) => ({ rule: row.rule, train: compact(row.train), validat
|
||||
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,
|
||||
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 event",
|
||||
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 recent-closure cohort, and midpoint-plus-cost is still an execution approximation." },
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user