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:
@@ -0,0 +1,218 @@
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const BOOK_BATCH_SIZE = 200;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function activeRate(market) {
|
||||
return (market.rewards_config || []).reduce((sum, row) => sum + Number(row.rate_per_day || 0), 0);
|
||||
}
|
||||
|
||||
function categoryFor(question) {
|
||||
const text = String(question || "").toLowerCase();
|
||||
if (/election|president|senate|governor|mayor|minister|parliament|ceasefire|war|trump|congress/.test(text)) return "Politics";
|
||||
if (/bitcoin|ethereum|crypto|btc|eth|solana|xrp|doge|token/.test(text)) return "Crypto";
|
||||
if (/nba|nfl|mlb|nhl|soccer|football|tennis|ufc|boxing|tournament|championship|match|game/.test(text)) return "Sports";
|
||||
if (/gdp|inflation|interest rate|fed|stock|nasdaq|s&p|oil|gold|gross margin|unemployment/.test(text)) return "Economy";
|
||||
if (/movie|album|music|tv|views|award|ai lab|model release|code arena/.test(text)) return "Pop Culture";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
async function fetchRewardMarkets(marketLimit, minHoursToEnd) {
|
||||
const params = new URLSearchParams({ order_by: "rate_per_day", position: "DESC", page_size: "500" });
|
||||
const response = await fetchJson(`${CLOB}/rewards/markets/multi?${params}`);
|
||||
const cutoff = Date.now() + minHoursToEnd * 60 * 60 * 1000;
|
||||
return (response.data || [])
|
||||
.filter((market) => {
|
||||
const tokens = market.tokens || [];
|
||||
const labels = tokens.map((token) => String(token.outcome || "").trim().toLowerCase());
|
||||
const endTime = Date.parse(market.end_date || "");
|
||||
return labels[0] === "yes" && labels[1] === "no" && tokens.every((token) => token.token_id)
|
||||
&& activeRate(market) >= 1 && Number(market.rewards_min_size) > 0
|
||||
&& Number(market.rewards_max_spread) > 0 && Number.isFinite(endTime) && endTime >= cutoff;
|
||||
})
|
||||
.slice(0, marketLimit);
|
||||
}
|
||||
|
||||
async function fetchBooks(tokenIds) {
|
||||
const result = new Map();
|
||||
for (let index = 0; index < tokenIds.length; index += BOOK_BATCH_SIZE) {
|
||||
const batch = tokenIds.slice(index, index + BOOK_BATCH_SIZE);
|
||||
const books = await fetchJson(`${CLOB}/books`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(batch.map((token_id) => ({ token_id }))),
|
||||
});
|
||||
for (const book of books || []) result.set(String(book.asset_id), book);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function levels(raw, side) {
|
||||
return (raw || [])
|
||||
.map((row) => ({ price: Number(row.price), size: Number(row.size) }))
|
||||
.filter((row) => Number.isFinite(row.price) && Number.isFinite(row.size) && row.price > 0 && row.price < 1 && row.size > 0)
|
||||
.sort((a, b) => side === "bid" ? b.price - a.price : a.price - b.price);
|
||||
}
|
||||
|
||||
function adjustedLevel(rows, minimumSize) {
|
||||
let cumulative = 0;
|
||||
for (const row of rows) {
|
||||
cumulative += row.size;
|
||||
if (cumulative + 1e-9 >= minimumSize) return row.price;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function midpoint(book, minimumSize, ownBid) {
|
||||
const bids = levels(book.bids, "bid");
|
||||
if (ownBid) bids.push(ownBid);
|
||||
bids.sort((a, b) => b.price - a.price);
|
||||
const asks = levels(book.asks, "ask");
|
||||
const bid = adjustedLevel(bids, minimumSize);
|
||||
const ask = adjustedLevel(asks, minimumSize);
|
||||
return Number.isFinite(bid) && Number.isFinite(ask) && bid < ask ? (bid + ask) / 2 : null;
|
||||
}
|
||||
|
||||
function utility(maxDistance, distance) {
|
||||
if (!(maxDistance > 0) || distance > maxDistance) return 0;
|
||||
return ((maxDistance - distance) / maxDistance) ** 2;
|
||||
}
|
||||
|
||||
function publicUtility(book, mid, maxDistance) {
|
||||
return [...levels(book.bids, "bid"), ...levels(book.asks, "ask")]
|
||||
.reduce((sum, row) => sum + utility(maxDistance, Math.abs(row.price - mid)) * row.size, 0);
|
||||
}
|
||||
|
||||
function proposedBid(book) {
|
||||
const bids = levels(book.bids, "bid");
|
||||
const asks = levels(book.asks, "ask");
|
||||
if (!bids.length || !asks.length) return null;
|
||||
const tick = Number(book.tick_size || 0.01);
|
||||
const bestBid = bids[0].price;
|
||||
const bestAsk = asks[0].price;
|
||||
const improved = bestAsk - bestBid >= tick * 3 ? bestBid + tick : bestBid;
|
||||
return { price: Math.min(bestAsk - tick, improved), bestBid, bestAsk, tick };
|
||||
}
|
||||
|
||||
function evaluateMarket(market, books) {
|
||||
const [yesToken, noToken] = market.tokens;
|
||||
const yesBook = books.get(String(yesToken.token_id));
|
||||
const noBook = books.get(String(noToken.token_id));
|
||||
if (!yesBook || !noBook) return null;
|
||||
const yes = proposedBid(yesBook), no = proposedBid(noBook);
|
||||
if (!yes || !no || yes.price <= 0 || no.price <= 0) return null;
|
||||
|
||||
const size = Math.max(Number(market.rewards_min_size), Number(yesBook.min_order_size || 0), Number(noBook.min_order_size || 0));
|
||||
const maxDistance = Number(market.rewards_max_spread) / 100;
|
||||
const yesMid = midpoint(yesBook, size, { price: yes.price, size });
|
||||
const noMid = midpoint(noBook, size, { price: no.price, size });
|
||||
if (!Number.isFinite(yesMid) || !Number.isFinite(noMid)) return null;
|
||||
|
||||
const ownQMin = Math.min(
|
||||
utility(maxDistance, Math.abs(yes.price - yesMid)) * size,
|
||||
utility(maxDistance, Math.abs(no.price - noMid)) * size,
|
||||
);
|
||||
if (!(ownQMin > 0)) return null;
|
||||
|
||||
const competitorUpperScore = publicUtility(yesBook, yesMid, maxDistance)
|
||||
+ publicUtility(noBook, noMid, maxDistance);
|
||||
const conservativeShareAtB1 = ownQMin / (ownQMin + competitorUpperScore);
|
||||
const dailyRate = activeRate(market);
|
||||
const estimatedDailyRewardFloor = dailyRate * conservativeShareAtB1;
|
||||
const pairedCost = yes.price + no.price;
|
||||
const capital = pairedCost * size;
|
||||
const pairedFillProfit = (1 - pairedCost) * size;
|
||||
const maximumOneLegLoss = Math.max(yes.price, no.price) * size;
|
||||
const hoursToEnd = (Date.parse(market.end_date) - Date.now()) / 3600000;
|
||||
const payoutEligible = estimatedDailyRewardFloor >= 1;
|
||||
const shadowQualified = payoutEligible && pairedCost <= 1 && conservativeShareAtB1 >= 0.0025
|
||||
&& estimatedDailyRewardFloor / Math.max(capital, 1) >= 0.001;
|
||||
|
||||
return {
|
||||
audit_version: 1,
|
||||
market_id: String(market.market_id),
|
||||
condition_id: market.condition_id,
|
||||
event_key: String(market.event_slug || market.market_slug || market.market_id),
|
||||
question: market.question,
|
||||
event: market.event_slug || "",
|
||||
url: `https://polymarket.com/event/${market.event_slug || market.market_slug}`,
|
||||
category: categoryFor(market.question),
|
||||
clob_token_ids: [String(yesToken.token_id), String(noToken.token_id)],
|
||||
reward_daily_rate: dailyRate,
|
||||
reward_min_size: size,
|
||||
reward_max_spread: Number(market.rewards_max_spread),
|
||||
hours_to_end: hoursToEnd,
|
||||
yes_quote: yes.price,
|
||||
no_quote: no.price,
|
||||
yes_adjusted_mid: yesMid,
|
||||
no_adjusted_mid: noMid,
|
||||
paired_cost: pairedCost,
|
||||
required_capital: capital,
|
||||
locked_profit: pairedFillProfit,
|
||||
maximum_one_leg_loss: maximumOneLegLoss,
|
||||
own_q_min: ownQMin,
|
||||
competitor_upper_score: competitorUpperScore,
|
||||
reward_share_floor: conservativeShareAtB1,
|
||||
estimated_reward_floor_daily: estimatedDailyRewardFloor,
|
||||
payout_eligible: payoutEligible,
|
||||
shadow_qualified: shadowQualified,
|
||||
spread: Math.max(yes.bestAsk - yes.bestBid, no.bestAsk - no.bestBid),
|
||||
tick_size: Math.min(yes.tick, no.tick),
|
||||
};
|
||||
}
|
||||
|
||||
function roundRow(row) {
|
||||
const rounded = { ...row };
|
||||
for (const key of ["reward_daily_rate", "reward_min_size", "reward_max_spread", "hours_to_end", "yes_quote", "no_quote",
|
||||
"yes_adjusted_mid", "no_adjusted_mid", "paired_cost", "required_capital", "locked_profit", "maximum_one_leg_loss",
|
||||
"own_q_min", "competitor_upper_score", "reward_share_floor", "estimated_reward_floor_daily", "spread", "tick_size"]) {
|
||||
rounded[key] = +Number(row[key]).toFixed(6);
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export async function auditLiquidity({ marketLimit = 100, minHoursToEnd = 48 } = {}) {
|
||||
const safeLimit = Math.max(10, Math.min(250, Number(marketLimit) || 100));
|
||||
const safeHours = Math.max(1, Number(minHoursToEnd) || 48);
|
||||
const markets = await fetchRewardMarkets(safeLimit, safeHours);
|
||||
const books = await fetchBooks(markets.flatMap((market) => market.tokens.map((token) => String(token.token_id))));
|
||||
const evaluated = markets.map((market) => evaluateMarket(market, books)).filter(Boolean);
|
||||
const qualified = evaluated.filter((row) => row.shadow_qualified)
|
||||
.sort((a, b) => (b.estimated_reward_floor_daily / Math.max(b.required_capital, 1))
|
||||
- (a.estimated_reward_floor_daily / Math.max(a.required_capital, 1))
|
||||
|| b.estimated_reward_floor_daily - a.estimated_reward_floor_daily);
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
requested_markets: safeLimit,
|
||||
minimum_hours_to_end: safeHours,
|
||||
reward_markets: markets.length,
|
||||
complete_book_pairs: evaluated.length,
|
||||
shadow_qualified: qualified.length,
|
||||
methodology: {
|
||||
execution: "two resting BUY quotes, one on each complementary outcome; no fill or reward is credited",
|
||||
midpoint: "minimum-qualifying-size adjusted midpoint recomputed with the proposed quote",
|
||||
competition: "sum of public order utility upper-bounds competitors' aggregate Q_min at a uniform b=1",
|
||||
reward_estimate: "single-snapshot lower-share estimate, not a guaranteed payout; $1 daily payout minimum enforced",
|
||||
unresolved_risk: "in-game multipliers, future competition, queue priority, fills, and adverse selection require shadow evidence",
|
||||
},
|
||||
candidates: qualified.slice(0, 50).map(roundRow),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user