mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-24 13:08:10 +00:00
Audit maker rewards before shadow quoting
This commit is contained in:
@@ -1,75 +1,8 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const LIMIT = Math.max(100, Math.min(500, Number(process.env.LIQUIDITY_MARKETS || 500)));
|
||||
import { auditLiquidity } from "../lib/liquidity-audit.js";
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
const report = await auditLiquidity({
|
||||
marketLimit: Number(process.env.LIQUIDITY_MARKETS || 100),
|
||||
minHoursToEnd: Number(process.env.LIQUIDITY_MIN_HOURS || 48),
|
||||
});
|
||||
|
||||
async function fetchJson(url, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(20000), 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, 500 * (attempt + 1)));
|
||||
}
|
||||
throw lastError || new Error("request failed");
|
||||
}
|
||||
|
||||
async function fetchMarkets(limit) {
|
||||
const markets = [];
|
||||
for (let offset = 0; markets.length < limit && offset < limit * 4; 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((outcome) => String(outcome).trim().toLowerCase());
|
||||
if (labels[0] !== "yes" || labels[1] !== "no") continue;
|
||||
markets.push(raw);
|
||||
if (markets.length >= limit) break;
|
||||
}
|
||||
if (page.length < 100) break;
|
||||
}
|
||||
return markets;
|
||||
}
|
||||
|
||||
function candidate(raw) {
|
||||
const bestBid = Number(raw.bestBid), bestAsk = Number(raw.bestAsk), spread = bestAsk - bestBid;
|
||||
const dailyRate = (raw.clobRewards || []).reduce((sum, reward) => sum + Number(reward.rewardsDailyRate || 0), 0);
|
||||
const minSize = Math.max(Number(raw.rewardsMinSize || 0), Number(raw.orderMinSize || 0), 5);
|
||||
const maxSpread = Number(raw.rewardsMaxSpread || 0) / 100;
|
||||
const tick = Number(raw.orderPriceMinTickSize || 0.01), dayMove = Math.abs(Number(raw.oneDayPriceChange || 0));
|
||||
const yesQuote = bestBid, noQuote = 1 - bestAsk, pairedCost = yesQuote + noQuote;
|
||||
const requiredCapital = minSize * pairedCost, lockedProfit = minSize * (1 - pairedCost);
|
||||
const scoring = dailyRate > 0 && spread > 0 && minSize > 0 && maxSpread > 0 && spread / 2 <= maxSpread
|
||||
&& Number.isFinite(yesQuote) && Number.isFinite(noQuote) && yesQuote >= tick && noQuote >= tick;
|
||||
return { marketId: String(raw.id || ""), conditionId: raw.conditionId || "", question: raw.question || "",
|
||||
url: raw.events?.[0]?.slug ? `https://polymarket.com/event/${raw.events[0].slug}` : "",
|
||||
dailyRate, minSize, maxSpread, spread, tick, bestBid, bestAsk, yesQuote, noQuote, pairedCost,
|
||||
lockedProfit, requiredCapital, maximumRewardYield: requiredCapital > 0 ? dailyRate / requiredCapital : 0,
|
||||
competitiveness: Number(raw.competitive || raw.events?.[0]?.competitive || 0), dayMove,
|
||||
volume24hr: Number(raw.volume24hr || 0), liquidity: Number(raw.liquidityNum || raw.liquidity || 0), scoring };
|
||||
}
|
||||
|
||||
const markets = await fetchMarkets(LIMIT), candidates = markets.map(candidate).filter((row) => row.scoring);
|
||||
const balanced = candidates.filter((row) => row.spread >= 0.02 && row.dayMove <= Math.max(0.02, row.spread)
|
||||
&& row.liquidity >= 5000 && row.volume24hr >= 2000 && row.requiredCapital <= 5000)
|
||||
.sort((a, b) => (b.dailyRate / Math.max(1, b.requiredCapital)) - (a.dailyRate / Math.max(1, a.requiredCapital)) || b.spread - a.spread);
|
||||
const compact = (row) => ({ ...row, dailyRate: +row.dailyRate.toFixed(3), maxSpread: +row.maxSpread.toFixed(4),
|
||||
spread: +row.spread.toFixed(4), bestBid: +row.bestBid.toFixed(4), bestAsk: +row.bestAsk.toFixed(4),
|
||||
yesQuote: +row.yesQuote.toFixed(4), noQuote: +row.noQuote.toFixed(4), pairedCost: +row.pairedCost.toFixed(4),
|
||||
lockedProfit: +row.lockedProfit.toFixed(2), requiredCapital: +row.requiredCapital.toFixed(2),
|
||||
maximumRewardYield: +row.maximumRewardYield.toFixed(4), competitiveness: +row.competitiveness.toFixed(4),
|
||||
dayMove: +row.dayMove.toFixed(4), volume24hr: +row.volume24hr.toFixed(2), liquidity: +row.liquidity.toFixed(2) });
|
||||
|
||||
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedMarkets: LIMIT, fetchedEligibleMarkets: markets.length,
|
||||
rewardScoringMarkets: candidates.length, balancedPairedQuoteCandidates: balanced.length,
|
||||
methodology: { fillCredit: "none; scanner identifies resting-quote candidates only",
|
||||
rewardCredit: "none; maximumRewardYield assumes an impossible 100% reward share and is ranking context only",
|
||||
pairedPayout: "$1 if both complementary bids eventually fill", singleFillRisk: "directional until the complementary quote fills or inventory exits" },
|
||||
candidates: balanced.slice(0, 50).map(compact) }, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const MARKET_LIMIT = Math.max(20, Math.min(500, Number(process.env.SETTLEMENT_MARKETS || 200)));
|
||||
const MARKET_LIMIT = Math.max(20, Math.min(3000, Number(process.env.SETTLEMENT_MARKETS || 200)));
|
||||
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.SETTLEMENT_CONCURRENCY || 6)));
|
||||
const HORIZON_DAYS = [...new Set(String(process.env.SETTLEMENT_HORIZONS || "1,3,7,14,30,90").split(",")
|
||||
.map(Number).filter((value) => Number.isFinite(value) && value >= 1 && value <= 365))].sort((a, b) => a - b);
|
||||
const COST_CENTS = Math.max(0, Math.min(5, Number(process.env.SETTLEMENT_COST_CENTS || 0.5)));
|
||||
const SELECTION_ORDER = ["volumeNum", "closedTime", "createdAt", "id"].includes(process.env.SETTLEMENT_ORDER)
|
||||
? process.env.SETTLEMENT_ORDER : "volumeNum";
|
||||
const SELECTION_ASCENDING = String(process.env.SETTLEMENT_ASCENDING || "false").toLowerCase() === "true";
|
||||
const DAY = 86400;
|
||||
|
||||
function parseJson(value) {
|
||||
@@ -89,10 +92,12 @@ function confirmedTrendAt(points, target, current) {
|
||||
|
||||
async function fetchResolvedMarkets(limit) {
|
||||
const raw = [], seen = new Set(), pageSize = 100;
|
||||
for (let offset = 0; raw.length < limit && offset < limit * 3; offset += pageSize) {
|
||||
const params = new URLSearchParams({ closed: "true", order: "volumeNum", ascending: "false",
|
||||
limit: String(pageSize), offset: String(offset) });
|
||||
const page = await fetchJson(`${GAMMA}/markets?${params}`);
|
||||
let cursor = "";
|
||||
while (raw.length < limit) {
|
||||
const params = new URLSearchParams({ closed: "true", order: SELECTION_ORDER, ascending: String(SELECTION_ASCENDING),
|
||||
limit: String(pageSize) });
|
||||
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 market of page) {
|
||||
const id = String(market.id || ""), labels = parseJson(market.outcomes).map((outcome) => String(outcome).trim().toLowerCase());
|
||||
@@ -107,7 +112,8 @@ async function fetchResolvedMarkets(limit) {
|
||||
volume: Number(market.volumeNum || market.volume || 0) });
|
||||
if (raw.length >= limit) break;
|
||||
}
|
||||
if (page.length < pageSize) break;
|
||||
if (page.length < pageSize || !payload.next_cursor || payload.next_cursor === cursor) break;
|
||||
cursor = payload.next_cursor;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -226,6 +232,7 @@ const report = {
|
||||
marketsWithHistory: successful.length, failures: histories.filter((result) => result?.error).length,
|
||||
methodology: { horizonDays: HORIZON_DAYS, estimatedRoundTripCostCents: COST_CENTS,
|
||||
historyFidelityMinutes: 1440,
|
||||
marketSelection: `${SELECTION_ORDER} ${SELECTION_ASCENDING ? "ascending" : "descending"}`,
|
||||
clusterUnit: "event",
|
||||
note: "Each rule uses only daily prices available at or before the decision horizon and a subsequently published binary settlement. Trend replays require aligned one-day and one-week direction under the production move bounds. Confidence bounds cluster related markets by event. Markets are selected by resolved volume, so results still carry historical-selection and execution-model limitations." },
|
||||
horizons: Object.fromEntries(HORIZON_DAYS.map((horizon) => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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 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, 4));
|
||||
}
|
||||
|
||||
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 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 });
|
||||
|
||||
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: "equal-dollar event baskets split across at most four highest-priced eligible favorites", 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,
|
||||
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));
|
||||
Reference in New Issue
Block a user