mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-19 02:28:08 +00:00
Compare commits
32
Commits
0ef05ea734
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5441e8659 | ||
|
|
c7abdf8d4d | ||
|
|
97186482df | ||
|
|
a1fc52bc20 | ||
|
|
c20ac0e78f | ||
|
|
7001366334 | ||
|
|
c78a13bbb7 | ||
|
|
25622d2ee5 | ||
|
|
846d1f1482 | ||
|
|
b768d92f36 | ||
|
|
ad11de39ad | ||
|
|
7b81dbfa4b | ||
|
|
0db53c46f8 | ||
|
|
aea8f5281d | ||
|
|
8f89847cd6 | ||
|
|
bc5de3c013 | ||
|
|
807eb1b506 | ||
|
|
c6c7dc30b4 | ||
|
|
36345e7cad | ||
|
|
38945b2045 | ||
|
|
f341136d80 | ||
|
|
901ad8a959 | ||
|
|
8cdbcd00ba | ||
|
|
4b35b89f6d | ||
|
|
283a009e00 | ||
|
|
9fea16988c | ||
|
|
dc9199a433 | ||
|
|
b906e9654d | ||
|
|
e4e27c885a | ||
|
|
444fd10e79 | ||
|
|
449cbdfd94 | ||
|
|
ab333dd045 |
@@ -14,8 +14,186 @@ Personal research mode:
|
||||
https://polymarket-site-eta.vercel.app/personal.html
|
||||
|
||||
The site fetches live Polymarket markets, generates agent suggestions, lets you
|
||||
run frequent paper cycles, and syncs the shared arena state through the Vercel
|
||||
API when `BLOB_READ_WRITE_TOKEN` is configured.
|
||||
run frequent paper cycles, and syncs the shared arena state through Neon or
|
||||
Vercel Blob. Build 56 also installs an offline app shell and caches timestamped
|
||||
market snapshots. During an outage, cycles continue locally; cached entries are
|
||||
allowed for 90 minutes, older snapshots become mark-only, and all cached data
|
||||
expires after 24 hours.
|
||||
|
||||
Build 56 ranks the competition by each agent's return since Strategy 50 began.
|
||||
Historical replay equity remains visible for context, but it no longer makes an
|
||||
agent look like the current leader when the live adaptive strategy is losing.
|
||||
|
||||
Each agent learns bounded weights from its own v34+ trade outcomes across signal
|
||||
type, setup quality, category, side, entry-price band, and time to resolution.
|
||||
The learner shrinks small samples toward neutral, caps sizing changes to
|
||||
0.68x-1.30x, and reserves
|
||||
15% of candidates for deterministic exploration so a stale regime cannot become
|
||||
permanent.
|
||||
|
||||
Strategy 50 treats each binary stake as capable of falling to zero even when the
|
||||
18% stop cannot fill. New core positions are capped at 2.5%-4% of equity and
|
||||
aggressive positions at 3%-5%, with lower limits for near-term, extreme-price,
|
||||
reversal, and fast-moving setups. Oversized positions inherited from older
|
||||
engines are reduced to the same loss budget during live marking.
|
||||
The two-agent overlap guard counts only positions worth at least 1.25% of an
|
||||
agent's equity, so tiny profit-lock runners do not block a new material trade.
|
||||
|
||||
A separate walk-forward ledger records every confirmed signal before its future
|
||||
price is known, grades it at least 24 hours later, and combines that broad market
|
||||
calibration with each agent's personal outcomes. This expands the learning sample
|
||||
without forcing observation-only signals into portfolios or backfilling future
|
||||
information into old decisions. The 24-hour horizon
|
||||
matches the engine's minimum ordinary holding policy; stops and profit locks still
|
||||
act immediately from fresh prices.
|
||||
|
||||
The initial seven-day chart seed is an approximate replay, not a live return.
|
||||
It uses only prices available on each simulated date, computes daily and weekly
|
||||
changes from those historical prices, disables unavailable hourly reversal data,
|
||||
and labels the combined number as legacy/replay. Adaptive-strategy returns are the
|
||||
clean live comparison.
|
||||
|
||||
Run `npm run evaluate:signals` to test the price-signal rules against one month
|
||||
of hourly Polymarket history. The evaluator forms signals only from prior
|
||||
one-hour, one-day, and one-week prices, marks them 6, 12, 24, and 72 hours later,
|
||||
applies a conservative half-cent cost estimate, and reports a chronological
|
||||
70/30 split plus three consecutive time segments. Results are clustered by
|
||||
Polymarket event so repeated observations and correlated outcome contracts cannot
|
||||
masquerade as broad evidence. Set `EVAL_MARKETS`, `EVAL_CONCURRENCY`, `EVAL_HORIZONS`, or
|
||||
`EVAL_COST_CENTS` to change the audit. Set `EVAL_SUMMARY=1` for the compact,
|
||||
decision-focused report.
|
||||
|
||||
The latest 120-active-market audit produced 1,241 twelve-hour observations from
|
||||
41 markets with no fetch failures. The broad rule averaged -1.35% net and was
|
||||
negative in all three chronological segments. Reversals averaged -3.69%, with a
|
||||
market-clustered 90% interval entirely below zero. Crypto and Sports were also
|
||||
negative but covered only three and five markets. The 24-hour cohort improved to
|
||||
-0.82% row mean and +1.31% market mean, with no rule robustly negative across all
|
||||
segments. Strategy 50 therefore keeps reversal entries observation-only until
|
||||
their recent signal and quality cohorts independently earn promotion, retains
|
||||
their signals for paper grading, and evaluates adaptation at 24 hours.
|
||||
|
||||
The expanded active-market audit loaded history for 498 of the top 500 active
|
||||
markets with no failures and produced 3,597 net-of-cost 24-hour outcomes across
|
||||
142 markets. No tested follow or fade rule was robustly positive. Crypto trends
|
||||
averaged -3.83% per observation and -3.99% per market; Sports trends averaged
|
||||
-5.44% and -6.61%. Both stayed negative in every chronological segment and their
|
||||
market-clustered 90% intervals were entirely below zero. Strategy 50 therefore
|
||||
keeps Crypto and Sports trends observation-only while continuing to grade them.
|
||||
|
||||
The August 18 event-clustered rerun loaded 499 of 500 active markets and produced
|
||||
3,894 twelve-hour observations across 156 markets and 102 independent events.
|
||||
The broad mean was -1.14%, the event mean was -1.11%, and the event-clustered
|
||||
90% interval stayed below zero. No tested category, side, price band, signal
|
||||
strength, or combined feature cohort was robustly positive. Broad trends,
|
||||
YES trends, favorite trends, strong trends, and hour-confirmed trends were all
|
||||
robustly negative. Strategy 50 therefore makes every directional trend or
|
||||
reversal observation-only until its own signal, side, and category cohorts each
|
||||
earn positive promotion from recent independent events. This is a strategy reset,
|
||||
so current adaptive returns begin from the portfolio equity at migration.
|
||||
|
||||
A corrected 200-market audit paged through 197 markets with usable history and
|
||||
1,912 twelve-hour outcomes. Reversals remained negative in every chronological
|
||||
segment and averaged -4.13%. Sports trends were negative in train and test and
|
||||
averaged -3.53% at 72 hours. Politics trends were the sole cohort with positive
|
||||
row-level returns in all three 72-hour segments, but its market-cluster interval
|
||||
still crossed zero; that supports a longer hold test, not a larger entry bet.
|
||||
Strategy 50 gives previously opened Politics trend positions that 72-hour observation window before
|
||||
ordinary signal exits. Stops, profit locks, settlement handling, and risk-budget
|
||||
reductions remain immediate.
|
||||
|
||||
Strategy 50 also subtracts a half-cent round-trip cost when grading each live
|
||||
walk-forward signal. Confidence uses the largest independent matching bucket,
|
||||
not the sum of five overlapping feature buckets, and evidence from older engine
|
||||
versions is down-weighted. This prevents a handful of duplicated observations
|
||||
from authorizing larger positions or hiding a modest negative regime.
|
||||
|
||||
Strategy 50 adds uncertainty-aware promotion and demotion. A matching setup must
|
||||
accumulate at least eight effective observations and agree across at least two
|
||||
feature views before repeatable positive evidence can increase size or repeatable
|
||||
negative evidence can block a new entry. Mixed evidence stays close to neutral
|
||||
instead of being mistaken for an edge.
|
||||
|
||||
Build 56 enforces the documented offline boundary end to end. Cached snapshots
|
||||
under 90 minutes old may continue paper execution. Older snapshots remain usable
|
||||
for valuation and chart snapshots for up to 24 hours, but cannot trigger entries,
|
||||
stop-losses, gain-stops, risk rebalances, settlements, or policy exits. Network
|
||||
requests have bounded timeouts so a weak connection falls back to cache instead
|
||||
of leaving a cycle hanging indefinitely.
|
||||
|
||||
Build identity is separate from strategy lineage starting with build 42. The
|
||||
service worker and deployment metadata advance with each code release, but
|
||||
adaptive baselines, pending signal grades, and trade evidence remain in one strategy
|
||||
lineage until the actual entry, sizing, or exit logic changes. Legacy build 40 and 41
|
||||
records are migrated into the same strategy lineage without losing evidence.
|
||||
|
||||
Build 56 independently refreshes markets for matured pending signals that have
|
||||
left the current top-500 activity scan. Unavailable markets remain queued for a
|
||||
bounded retry window. This prevents activity-rank survivorship from deciding
|
||||
which wins and losses reach the adaptive calibration ledger.
|
||||
|
||||
Strategy 50 coordinates high-risk exploration globally. Near-term, extreme-price,
|
||||
and other gap-prone positions may be held materially by only one agent, while
|
||||
ordinary independently confirmed markets retain the two-agent cap. The robustly
|
||||
negative Sports- and Crypto-trend cohorts cannot enter through exploration.
|
||||
Reversal and short-dated NO signals remain observation-only until their own recent
|
||||
feature cohorts pass the promotion gate.
|
||||
|
||||
Run `npm run evaluate:settlements` to evaluate fixed decisions made 1, 3, 7,
|
||||
14, 30, and 90 days before known binary settlements. The audit uses one
|
||||
observation per resolved market and horizon, includes losing contracts at zero,
|
||||
applies the same half-cent cost assumption, clusters related contracts by event,
|
||||
and requires positive event-clustered confidence bounds in train and test plus
|
||||
positive results in three chronological segments before it calls a settlement
|
||||
cohort robust. Environment variables beginning with
|
||||
`SETTLEMENT_` control its market count, concurrency, horizons, and cost. Set
|
||||
`SETTLEMENT_SUMMARY=1` for the compact report.
|
||||
|
||||
Run `npm run evaluate:neg-risk` to scan complete active negative-risk events for
|
||||
whole-event YES or NO bundles using executable best asks/bids, per-leg costs, and
|
||||
a minimum-liquidity requirement. An earlier 500-event audit found 33 complete
|
||||
liquid negative-risk events and zero positive worst-case bundle returns after
|
||||
costs. Midpoint price sums sometimes looked attractive, but executable spreads
|
||||
removed the apparent edge. The August 18 rerun found 35 eligible events and one
|
||||
three-leg NO bundle with a 0.25%
|
||||
modeled margin after estimated costs. Strategy 50 can paper-trade such a bundle
|
||||
only from live executable prices, opens every leg together, and holds the hedge
|
||||
intact until settlement. It also requires at least a 0.15% modeled net return so
|
||||
large bundles cannot tie up capital for a negligible absolute edge. Cached bundle
|
||||
prices are never allowed to open positions.
|
||||
|
||||
The expanded event-clustered run loaded history for 498 of the 500 highest-volume
|
||||
resolved markets with no fetch failures. No side, price band, category, trend,
|
||||
or 1-90 day holding rule passed the required train/test confidence checks. In
|
||||
particular, older YES/underdog gains
|
||||
reversed in the recent test segment. The engine therefore does not install a
|
||||
static settlement-direction boost from this audit.
|
||||
|
||||
The earlier 200-resolved-market audit found short-dated NO entries strongly
|
||||
negative, but the 500-market rerun did not reproduce that loss in its newer test
|
||||
segment. Strategy 50 therefore treats the result as a provisional prior instead
|
||||
of a permanent ban: NO entries with 21 days or less remain observation-only until
|
||||
the recent walk-forward calibration promotes their matching side and duration
|
||||
cohorts. Exact numeric-range contracts are excluded from new entries because a
|
||||
settlement jump can pass directly through an 18% stop; the live audit found that
|
||||
this failure mode caused the largest latest-day loss.
|
||||
|
||||
Strategy 50 also excludes path-dependent barriers such as "reach $66,000," "hit
|
||||
$90," and "dip to $62,000." These contracts can resolve abruptly as soon as the
|
||||
barrier is touched, so a later hourly stop cannot reliably cap the loss. Fixed-date
|
||||
level questions such as "above $66,000 on August 23" remain eligible.
|
||||
|
||||
Strategy 50 clusters live walk-forward observations by Polymarket event before
|
||||
calculating confidence. Multiple six-hour snapshots and correlated outcome
|
||||
markets from the same event are averaged into one effective outcome, so one
|
||||
election or tournament cannot promote or demote an entire feature cohort.
|
||||
Promotion still requires at least eight weighted event clusters and agreement
|
||||
across two feature views.
|
||||
|
||||
The pending signal ledger keeps only one ungraded observation for each market and
|
||||
side. When its bounded queue is full, it preserves the oldest evidence until the
|
||||
24-hour grade is available and admits new signals in ranked order as space opens.
|
||||
This prevents frequent cycles from evicting every signal shortly before maturity.
|
||||
|
||||
Paper accounts created with a password are also saved through the backend, so a
|
||||
user can log in from another device and see the same paper portfolio, activity,
|
||||
@@ -43,7 +221,8 @@ Pick one — all give you a public URL:
|
||||
|
||||
Use `.env.example` as the setup template.
|
||||
|
||||
- `BLOB_READ_WRITE_TOKEN` enables cross-device shared state.
|
||||
- `DATABASE_URL` or `NEON_DATABASE_URL` enables Neon-backed shared state;
|
||||
`BLOB_READ_WRITE_TOKEN` is the fallback provider.
|
||||
- `ACCOUNT_SESSION_SECRET` signs cloud paper-account sessions. If omitted, the
|
||||
app falls back to the existing server secret/token, but production should use
|
||||
a dedicated value.
|
||||
|
||||
+31
-4
@@ -10,6 +10,7 @@ const PAPER_KEY = "pma_paper_accounts_v1";
|
||||
const LIVE_KEY = "pma_live_readiness_v1";
|
||||
const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "catalyst", "reversal", "breakout", "tailalpha", "conviction"];
|
||||
const LIMITS = { closed: 80, history: 160, snapshots: 240, suggestions: 900, paperHistory: 120, paperSnapshots: 120, audit: 120 };
|
||||
const SIGNAL_LEDGER_LIMITS = { pending: 300, outcomes: 500 };
|
||||
|
||||
function withBlobAuth(options = {}) {
|
||||
const token = process.env.BLOB_READ_WRITE_TOKEN;
|
||||
@@ -160,6 +161,12 @@ function compactAgentState(st) {
|
||||
for (const id of AGENT_IDS) {
|
||||
out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
|
||||
}
|
||||
if (st.signal_ledger && typeof st.signal_ledger === "object") {
|
||||
out.signal_ledger = {
|
||||
pending: Array.isArray(st.signal_ledger.pending) ? st.signal_ledger.pending.slice(-SIGNAL_LEDGER_LIMITS.pending) : [],
|
||||
outcomes: Array.isArray(st.signal_ledger.outcomes) ? st.signal_ledger.outcomes.slice(-SIGNAL_LEDGER_LIMITS.outcomes) : [],
|
||||
};
|
||||
}
|
||||
delete out.whales;
|
||||
delete out.copycatLeader;
|
||||
return out;
|
||||
@@ -225,9 +232,19 @@ export default async function handler(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const state = await readJsonBlob();
|
||||
if (state && state.items) state.items = compactItems(state.items);
|
||||
return res.status(200).json({ ok: true, state });
|
||||
try {
|
||||
const state = await readJsonBlob();
|
||||
if (state && state.items) state.items = compactItems(state.items);
|
||||
return res.status(200).json({ ok: true, state, degraded: false });
|
||||
} catch (err) {
|
||||
// A storage outage must not prevent the installed app from using its local paper state.
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
state: null,
|
||||
degraded: true,
|
||||
error: err && err.message ? err.message : "Cloud state provider unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
@@ -235,7 +252,17 @@ export default async function handler(req, res) {
|
||||
if (!body || typeof body !== "object" || !body.items || typeof body.items !== "object") {
|
||||
return res.status(400).json({ ok: false, error: "Invalid state payload" });
|
||||
}
|
||||
const current = await readJsonBlob();
|
||||
let current;
|
||||
try {
|
||||
current = await readJsonBlob();
|
||||
} catch (err) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
degraded: true,
|
||||
retryable: true,
|
||||
error: err && err.message ? err.message : "Cloud state provider unavailable",
|
||||
});
|
||||
}
|
||||
const incomingItems = { ...body.items };
|
||||
const currentAgents = agentStateFromItems(current && current.items);
|
||||
let incomingAgents = agentStateFromItems(incomingItems);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const INTERVAL_MS = 60000;
|
||||
|
||||
postMessage({ type: "ready", at: Date.now() });
|
||||
|
||||
self.onmessage = event => {
|
||||
if(event.data && event.data.type === "ping") postMessage({ type: "ready", at: Date.now() });
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
postMessage({ type: "cycle", at: Date.now() });
|
||||
}, INTERVAL_MS);
|
||||
+1316
-356
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"scripts": {
|
||||
"evaluate:neg-risk": "node scripts/evaluate-neg-risk.mjs",
|
||||
"evaluate:signals": "node scripts/evaluate-signals.mjs",
|
||||
"evaluate:settlements": "node scripts/evaluate-settlements.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neondatabase/serverless": "^1.1.0",
|
||||
"@vercel/blob": "2.5.0",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const EVENT_LIMIT = Math.max(20, Math.min(1000, Number(process.env.NEG_RISK_EVENTS || 300)));
|
||||
const COST_CENTS = Math.max(0, Math.min(5, Number(process.env.NEG_RISK_COST_CENTS || 0.5)));
|
||||
const MIN_LIQUIDITY = Math.max(0, Number(process.env.NEG_RISK_MIN_LIQUIDITY || 1000));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function fetchEvents(limit) {
|
||||
const events = [], pageSize = 100;
|
||||
for (let offset = 0; offset < limit; offset += pageSize) {
|
||||
const size = Math.min(pageSize, limit - offset);
|
||||
const params = new URLSearchParams({ active: "true", closed: "false", archived: "false",
|
||||
limit: String(size), offset: String(offset), order: "volume24hr", ascending: "false" });
|
||||
const page = await fetchJson(`${GAMMA}/events?${params}`);
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
events.push(...page);
|
||||
if (page.length < size) break;
|
||||
}
|
||||
return events.slice(0, limit);
|
||||
}
|
||||
|
||||
function evaluateEvent(event) {
|
||||
if (!event?.negRisk || event.enableNegRisk === false) return null;
|
||||
const allMarkets = Array.isArray(event.markets) ? event.markets : [];
|
||||
if (allMarkets.length < 2 || allMarkets.some((market) => market.closed || market.active === false || market.acceptingOrders === false)) return null;
|
||||
const legs = allMarkets.map((market) => {
|
||||
const prices = parseJson(market.outcomePrices).map(number);
|
||||
return { id: String(market.id || ""), question: market.question || "", yes: prices[0],
|
||||
bid: number(market.bestBid), ask: number(market.bestAsk), liquidity: number(market.liquidityNum || market.liquidity) || 0 };
|
||||
});
|
||||
if (legs.some((leg) => !leg.id || leg.yes == null || leg.bid == null || leg.ask == null
|
||||
|| leg.bid < 0 || leg.ask > 1 || leg.ask < leg.bid || leg.liquidity < MIN_LIQUIDITY)) return null;
|
||||
const count = legs.length, costPerLeg = COST_CENTS / 100;
|
||||
const yesCost = legs.reduce((sum, leg) => sum + leg.ask, 0);
|
||||
const yesProfit = 1 - yesCost - count * costPerLeg;
|
||||
const noCost = count - legs.reduce((sum, leg) => sum + leg.bid, 0);
|
||||
const noProfit = count - 1 - noCost - count * costPerLeg;
|
||||
const yesReturn = yesCost > 0 ? yesProfit / yesCost : 0;
|
||||
const noReturn = noCost > 0 ? noProfit / noCost : 0;
|
||||
const side = yesReturn >= noReturn ? "YES_BUNDLE" : "NO_BUNDLE";
|
||||
return { eventId: String(event.id || ""), title: event.title || "", slug: event.slug || "", markets: count,
|
||||
minimumLiquidity: Math.min(...legs.map((leg) => leg.liquidity)), side,
|
||||
executableCost: side === "YES_BUNDLE" ? yesCost : noCost,
|
||||
worstCasePayout: side === "YES_BUNDLE" ? 1 : count - 1,
|
||||
netProfitPerBundle: side === "YES_BUNDLE" ? yesProfit : noProfit,
|
||||
netReturn: side === "YES_BUNDLE" ? yesReturn : noReturn,
|
||||
theoreticalYesSum: legs.reduce((sum, leg) => sum + leg.yes, 0), legs };
|
||||
}
|
||||
|
||||
const events = await fetchEvents(EVENT_LIMIT);
|
||||
const evaluated = events.map(evaluateEvent).filter(Boolean).sort((a, b) => b.netReturn - a.netReturn);
|
||||
const actionable = evaluated.filter((event) => event.netProfitPerBundle > 0);
|
||||
const compact = (event) => ({ eventId: event.eventId, title: event.title, markets: event.markets, side: event.side,
|
||||
executableCost: +event.executableCost.toFixed(4), worstCasePayout: event.worstCasePayout,
|
||||
netProfitPerBundle: +event.netProfitPerBundle.toFixed(4), netReturn: +event.netReturn.toFixed(4),
|
||||
minimumLiquidity: +event.minimumLiquidity.toFixed(2), theoreticalYesSum: +event.theoreticalYesSum.toFixed(4),
|
||||
url: event.slug ? `https://polymarket.com/event/${event.slug}` : "" });
|
||||
|
||||
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), requestedEvents: EVENT_LIMIT,
|
||||
fetchedEvents: events.length, eligibleNegativeRiskEvents: evaluated.length, actionableBundles: actionable.length,
|
||||
estimatedCostCentsPerLeg: COST_CENTS, minimumLiquidityPerLeg: MIN_LIQUIDITY,
|
||||
actionable: actionable.slice(0, 50).map(compact), bestObserved: evaluated.slice(0, 20).map(compact) }, null, 2));
|
||||
@@ -0,0 +1,261 @@
|
||||
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 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 DAY = 86400;
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
|
||||
function toTimestamp(value) {
|
||||
const parsed = Date.parse(String(value || "").replace(" ", "T").replace(/\+00$/, "Z"));
|
||||
return Number.isFinite(parsed) ? parsed / 1000 : null;
|
||||
}
|
||||
|
||||
function categoryOf(raw) {
|
||||
const text = `${raw.category || ""} ${raw.question || ""} ${(raw.events || []).flatMap((event) => event.tags || [])
|
||||
.map((tag) => tag.slug || tag.label || "").join(" ")}`.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(nba|nfl|nhl|mlb|soccer|football|baseball|basketball|tennis|ufc|boxing|championship|match|game|tournament|league)\b/.test(text)) return "Sports";
|
||||
if (/\b(fed|inflation|gdp|recession|stock|company|economy|tariff|interest rate|unemployment|earnings)\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, options = {}, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(25000),
|
||||
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, 500 * (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], index); }
|
||||
catch (error) { output[index] = { error: error.message }; }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return output;
|
||||
}
|
||||
|
||||
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 priceBand(price) {
|
||||
if (price < 0.25) return "longshot";
|
||||
if (price < 0.55) return "mid";
|
||||
if (price < 0.78) return "favorite";
|
||||
return "heavy-favorite";
|
||||
}
|
||||
|
||||
function confirmedTrendAt(points, target, current) {
|
||||
const dayPoint = atOrBefore(points, target - DAY), weekPoint = atOrBefore(points, target - 7 * DAY);
|
||||
if (!dayPoint || !weekPoint || target - DAY - dayPoint.t > 36 * 3600 || target - 7 * DAY - weekPoint.t > 36 * 3600) return null;
|
||||
const dayMove = current.p - dayPoint.p, weekMove = current.p - weekPoint.p;
|
||||
const daySign = Math.sign(dayMove), weekSign = Math.sign(weekMove);
|
||||
const confirmed = daySign && daySign === weekSign && Math.abs(dayMove) >= 0.006 && Math.abs(weekMove) >= 0.012
|
||||
&& Math.abs(dayMove) <= 0.08 && Math.abs(weekMove) <= 0.18;
|
||||
if (!confirmed) return null;
|
||||
return { side: daySign > 0 ? "YES" : "NO", dayMove, weekMove,
|
||||
strong: Math.abs(dayMove) >= 0.015 && Math.abs(weekMove) >= 0.03,
|
||||
moderate: Math.abs(dayMove) <= 0.03 && Math.abs(weekMove) <= 0.10 };
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
for (const market of page) {
|
||||
const id = String(market.id || ""), outcomes = parseJson(market.outcomePrices).map(Number);
|
||||
const tokens = parseJson(market.clobTokenIds), closedAt = toTimestamp(market.closedTime || market.endDate);
|
||||
const resolved = outcomes.length === 2 && outcomes.every(Number.isFinite)
|
||||
&& ((outcomes[0] >= 0.99 && outcomes[1] <= 0.01) || (outcomes[1] >= 0.99 && outcomes[0] <= 0.01));
|
||||
if (!id || seen.has(id) || !resolved || tokens.length !== 2 || !closedAt) continue;
|
||||
seen.add(id); raw.push({ id, question: market.question || "", category: categoryOf(market),
|
||||
eventId: String(market.events?.[0]?.id || id),
|
||||
tokenId: String(tokens[0]), finalYes: outcomes[0] >= 0.99 ? 1 : 0, closedAt,
|
||||
volume: Number(market.volumeNum || market.volume || 0) });
|
||||
if (raw.length >= limit) break;
|
||||
}
|
||||
if (page.length < pageSize) break;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function evaluateMarket(market, points) {
|
||||
const rows = [];
|
||||
for (const horizonDays of HORIZON_DAYS) {
|
||||
const target = market.closedAt - horizonDays * DAY, point = atOrBefore(points, target);
|
||||
const maximumStaleness = Math.max(36 * 3600, horizonDays * DAY * 0.15);
|
||||
if (!point || target - point.t > maximumStaleness || point.p <= 0.03 || point.p >= 0.97) continue;
|
||||
const yesEntry = point.p, noEntry = 1 - point.p, favoriteSide = yesEntry >= noEntry ? "YES" : "NO";
|
||||
const winningSide = market.finalYes ? "YES" : "NO", trend = confirmedTrendAt(points, target, point);
|
||||
for (const side of ["YES", "NO"]) {
|
||||
const entry = side === "YES" ? yesEntry : noEntry, final = side === winningSide ? 1 : 0;
|
||||
const netReturn = final / entry - 1 - (COST_CENTS / 100) / entry;
|
||||
rows.push({ marketId: market.id, eventId: market.eventId, question: market.question, category: market.category, closedAt: market.closedAt,
|
||||
horizonDays, side, favorite: side === favoriteSide, winner: side === winningSide,
|
||||
trend: Boolean(trend && trend.side === side), trendSide: trend?.side || null,
|
||||
dayMove: trend?.dayMove || 0, weekMove: trend?.weekMove || 0,
|
||||
strongTrend: Boolean(trend?.strong), moderateTrend: Boolean(trend?.moderate),
|
||||
entry, band: priceBand(entry), netReturn });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function summarize(rows) {
|
||||
if (!rows.length) return { count: 0, events: 0, mean: 0, median: 0, winRate: 0, lower90: 0, upper90: 0,
|
||||
eventMean: 0, eventLower90: 0, eventUpper90: 0, worst: 0, best: 0 };
|
||||
const values = rows.map((row) => row.netReturn), mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
const variance = values.length > 1 ? values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1) : 0;
|
||||
const margin90 = 1.645 * Math.sqrt(variance / values.length);
|
||||
const eventBuckets = new Map();
|
||||
rows.forEach((row) => {
|
||||
const bucket = eventBuckets.get(row.eventId) || [];
|
||||
bucket.push(row.netReturn); eventBuckets.set(row.eventId, bucket);
|
||||
});
|
||||
const eventReturns = [...eventBuckets.values()].map((bucket) => bucket.reduce((sum, value) => sum + value, 0) / bucket.length);
|
||||
const eventMean = eventReturns.reduce((sum, value) => sum + value, 0) / eventReturns.length;
|
||||
const eventVariance = eventReturns.length > 1
|
||||
? eventReturns.reduce((sum, value) => sum + (value - eventMean) ** 2, 0) / (eventReturns.length - 1) : 0;
|
||||
const eventMargin90 = 1.645 * Math.sqrt(eventVariance / eventReturns.length);
|
||||
return { count: values.length, mean, median: median(values), winRate: rows.filter((row) => row.winner).length / rows.length,
|
||||
lower90: mean - margin90, upper90: mean + margin90, events: eventReturns.length,
|
||||
eventMean, eventLower90: eventMean - eventMargin90, eventUpper90: eventMean + eventMargin90,
|
||||
worst: Math.min(...values), best: Math.max(...values) };
|
||||
}
|
||||
|
||||
const RULES = [
|
||||
{ name: "buy_favorite", test: (row) => row.favorite },
|
||||
{ name: "buy_heavy_favorite", test: (row) => row.favorite && row.entry >= 0.78 },
|
||||
{ name: "buy_60_78_favorite", test: (row) => row.favorite && row.entry >= 0.60 && row.entry < 0.78 },
|
||||
{ name: "buy_55_60_favorite", test: (row) => row.favorite && row.entry >= 0.55 && row.entry < 0.60 },
|
||||
{ name: "buy_underdog", test: (row) => !row.favorite },
|
||||
{ name: "buy_yes", test: (row) => row.side === "YES" },
|
||||
{ name: "buy_no", test: (row) => row.side === "NO" },
|
||||
{ name: "follow_trend", test: (row) => row.trend },
|
||||
{ name: "follow_trend_yes", test: (row) => row.trend && row.side === "YES" },
|
||||
{ name: "follow_trend_no", test: (row) => row.trend && row.side === "NO" },
|
||||
{ name: "follow_trend_favorite", test: (row) => row.trend && row.favorite },
|
||||
{ name: "follow_trend_underdog", test: (row) => row.trend && !row.favorite },
|
||||
{ name: "follow_strong_trend", test: (row) => row.trend && row.strongTrend },
|
||||
{ name: "follow_moderate_trend", test: (row) => row.trend && row.moderateTrend },
|
||||
...["Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"].flatMap((category) => [
|
||||
{ name: `buy_favorite_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => row.favorite && row.category === category },
|
||||
{ name: `buy_underdog_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => !row.favorite && row.category === category },
|
||||
{ name: `follow_trend_${category.toLowerCase().replace(/\s+/g, "_")}`, test: (row) => row.trend && row.category === category },
|
||||
]),
|
||||
];
|
||||
|
||||
function evaluateRules(rows) {
|
||||
return Object.fromEntries(RULES.map((rule) => [rule.name, summarize(rows.filter(rule.test))]));
|
||||
}
|
||||
|
||||
function chronologicalEvaluation(rows) {
|
||||
const ordered = [...rows].sort((a, b) => a.closedAt - b.closedAt);
|
||||
const splitTime = ordered[Math.floor(ordered.length * 0.70)]?.closedAt || 0;
|
||||
const cut1 = ordered[Math.floor(ordered.length / 3)]?.closedAt || 0;
|
||||
const cut2 = ordered[Math.floor(ordered.length * 2 / 3)]?.closedAt || 0;
|
||||
const train = ordered.filter((row) => row.closedAt < splitTime), test = ordered.filter((row) => row.closedAt >= splitTime);
|
||||
const thirds = [ordered.filter((row) => row.closedAt < cut1),
|
||||
ordered.filter((row) => row.closedAt >= cut1 && row.closedAt < cut2),
|
||||
ordered.filter((row) => row.closedAt >= cut2)];
|
||||
const pooled = evaluateRules(ordered), trainRules = evaluateRules(train), testRules = evaluateRules(test), thirdRules = thirds.map(evaluateRules);
|
||||
const robustRules = Object.fromEntries(RULES.map((rule) => {
|
||||
const segments = thirdRules.map((result) => result[rule.name]), all = pooled[rule.name];
|
||||
const enoughData = all.events >= 15 && segments.every((segment) => segment.count >= 15 && segment.events >= 5)
|
||||
&& trainRules[rule.name].count >= 30 && trainRules[rule.name].events >= 10
|
||||
&& testRules[rule.name].count >= 15 && testRules[rule.name].events >= 5;
|
||||
const allPositive = enoughData && all.eventLower90 > 0 && trainRules[rule.name].eventLower90 > 0
|
||||
&& testRules[rule.name].eventLower90 > 0 && segments.every((segment) => segment.mean > 0 && segment.eventMean > 0);
|
||||
const allNegative = enoughData && all.eventUpper90 < 0 && trainRules[rule.name].eventUpper90 < 0
|
||||
&& testRules[rule.name].eventUpper90 < 0 && segments.every((segment) => segment.mean < 0 && segment.eventMean < 0);
|
||||
return [rule.name, { enoughData, allPositive, allNegative, pooled: all, train: trainRules[rule.name], test: testRules[rule.name], segments }];
|
||||
}));
|
||||
return { splitTime: splitTime ? new Date(splitTime * 1000).toISOString() : null, trainCount: train.length,
|
||||
testCount: test.length, train: trainRules, test: testRules, thirds: thirdRules, robustRules };
|
||||
}
|
||||
|
||||
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, rows: evaluateMarket(market, points) };
|
||||
});
|
||||
const successful = histories.filter((result) => result && !result.error && result.points.length);
|
||||
const rows = successful.flatMap((result) => result.rows);
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(), requestedMarkets: MARKET_LIMIT, resolvedMarkets: markets.length,
|
||||
marketsWithHistory: successful.length, failures: histories.filter((result) => result?.error).length,
|
||||
methodology: { horizonDays: HORIZON_DAYS, estimatedRoundTripCostCents: COST_CENTS,
|
||||
historyFidelityMinutes: 1440,
|
||||
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) => {
|
||||
const horizonRows = rows.filter((row) => row.horizonDays === horizon);
|
||||
return [horizon, { observations: horizonRows.length / 2, chronological: chronologicalEvaluation(horizonRows) }];
|
||||
})),
|
||||
};
|
||||
const compact = process.env.SETTLEMENT_SUMMARY === "1";
|
||||
const compactStats = (stats = {}) => ({ count: stats.count || 0, events: stats.events || 0,
|
||||
mean: stats.mean || 0, eventMean: stats.eventMean || 0, eventLower90: stats.eventLower90 || 0,
|
||||
eventUpper90: stats.eventUpper90 || 0, winRate: stats.winRate || 0 });
|
||||
const compactRules = (rules = {}) => Object.fromEntries(Object.entries(rules)
|
||||
.filter(([, result]) => result.enoughData && (result.allPositive || result.allNegative))
|
||||
.map(([name, result]) => [name, { direction: result.allPositive ? "positive" : "negative",
|
||||
pooled: compactStats(result.pooled), train: compactStats(result.train), test: compactStats(result.test) }]));
|
||||
const summary = {
|
||||
generatedAt: report.generatedAt, requestedMarkets: report.requestedMarkets, resolvedMarkets: report.resolvedMarkets,
|
||||
marketsWithHistory: report.marketsWithHistory, failures: report.failures,
|
||||
horizons: Object.fromEntries(Object.entries(report.horizons).map(([days, value]) => [days, {
|
||||
observations: value.observations,
|
||||
favorite: compactStats(value.chronological.train.buy_favorite),
|
||||
favoriteTest: compactStats(value.chronological.test.buy_favorite),
|
||||
underdog: compactStats(value.chronological.train.buy_underdog),
|
||||
underdogTest: compactStats(value.chronological.test.buy_underdog),
|
||||
yes: compactStats(value.chronological.train.buy_yes),
|
||||
yesTest: compactStats(value.chronological.test.buy_yes),
|
||||
no: compactStats(value.chronological.train.buy_no),
|
||||
noTest: compactStats(value.chronological.test.buy_no),
|
||||
trend: compactStats(value.chronological.train.follow_trend),
|
||||
trendTest: compactStats(value.chronological.test.follow_trend),
|
||||
robustRules: compactRules(value.chronological.robustRules),
|
||||
}])),
|
||||
};
|
||||
console.log(JSON.stringify(compact ? summary : report, null, 2));
|
||||
@@ -0,0 +1,330 @@
|
||||
const GAMMA = "https://gamma-api.polymarket.com";
|
||||
const CLOB = "https://clob.polymarket.com";
|
||||
const MARKET_LIMIT = Math.max(10, Math.min(500, Number(process.env.EVAL_MARKETS || 80)));
|
||||
const CONCURRENCY = Math.max(1, Math.min(12, Number(process.env.EVAL_CONCURRENCY || 6)));
|
||||
const HORIZONS = [...new Set(String(process.env.EVAL_HORIZONS || "6,12,24,72").split(",")
|
||||
.map(Number).filter((value) => Number.isFinite(value) && value >= 1 && value <= 168))].sort((a, b) => a - b);
|
||||
const COST_CENTS = Math.max(0, Math.min(5, Number(process.env.EVAL_COST_CENTS || 0.5)));
|
||||
const HOUR = 3600;
|
||||
|
||||
const CATEGORY_RULES = [
|
||||
["Politics", ["politics", "election", "elections", "us-politics", "geopolitics", "trump", "government", "congress", "policy", "democrats", "republicans"]],
|
||||
["Crypto", ["crypto", "bitcoin", "ethereum", "btc", "eth", "solana", "defi", "stablecoin", "xrp"]],
|
||||
["Sports", ["sports", "soccer", "football", "nba", "nfl", "mlb", "nhl", "tennis", "basketball", "baseball", "ufc", "boxing", "golf", "f1"]],
|
||||
["Economy", ["economy", "business", "fed", "inflation", "interest-rates", "gdp", "jobs", "recession", "stocks", "earnings", "tariffs"]],
|
||||
["Pop Culture", ["pop-culture", "entertainment", "movies", "music", "tv", "awards", "celebrity", "gaming", "ai"]],
|
||||
];
|
||||
|
||||
function parseJson(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
try { return JSON.parse(value || "[]"); } catch { return []; }
|
||||
}
|
||||
|
||||
function categoryOf(raw) {
|
||||
const tags = (Array.isArray(raw.tags) ? raw.tags : []).map((tag) => String(tag.slug || tag.label || "").toLowerCase());
|
||||
return CATEGORY_RULES.find(([, keys]) => tags.some((tag) => keys.includes(tag)))?.[0] || "Other";
|
||||
}
|
||||
|
||||
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 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], index); }
|
||||
catch (error) { output[index] = { error: error.message }; }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return output;
|
||||
}
|
||||
|
||||
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 atOrAfter(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]; hi = mid - 1; }
|
||||
else lo = mid + 1;
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
function signalAt(points, index) {
|
||||
const current = points[index], hour = atOrBefore(points, current.t - HOUR);
|
||||
const day = atOrBefore(points, current.t - 24 * HOUR), week = atOrBefore(points, current.t - 7 * 24 * HOUR);
|
||||
if (!hour || !day || !week || current.t - week.t > 8 * 24 * HOUR) return null;
|
||||
const hourMove = current.p - hour.p, dayMove = current.p - day.p, weekMove = current.p - week.p;
|
||||
const daySign = Math.sign(dayMove), weekSign = Math.sign(weekMove), hourSign = Math.sign(hourMove);
|
||||
const trend = daySign && daySign === weekSign && Math.abs(dayMove) >= 0.006 && Math.abs(weekMove) >= 0.012
|
||||
&& Math.abs(dayMove) <= 0.08 && Math.abs(weekMove) <= 0.18
|
||||
&& (!hourSign || hourSign === daySign || Math.abs(hourMove) < 0.008);
|
||||
const reversal = daySign && Math.abs(dayMove) >= 0.04 && Math.abs(dayMove) <= 0.18
|
||||
&& hourSign === -daySign && Math.abs(hourMove) >= 0.004
|
||||
&& (!weekSign || weekSign !== daySign || Math.abs(weekMove) < Math.abs(dayMove) * 1.6);
|
||||
if (!trend && !reversal) return null;
|
||||
const sign = reversal ? -daySign : daySign;
|
||||
return { type: reversal ? "reversal" : "trend", side: sign > 0 ? "YES" : "NO", hourMove, dayMove, weekMove };
|
||||
}
|
||||
|
||||
function priceBand(price) {
|
||||
if (price < 0.25) return "longshot";
|
||||
if (price < 0.55) return "mid";
|
||||
if (price < 0.78) return "favorite";
|
||||
return "heavy-favorite";
|
||||
}
|
||||
|
||||
function evaluateMarket(market, points) {
|
||||
const outcomes = [];
|
||||
let previousBucket = null;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const current = points[index], bucket = Math.floor(current.t / (6 * HOUR));
|
||||
if (bucket === previousBucket || current.p < 0.08 || current.p > 0.92) continue;
|
||||
const signal = signalAt(points, index);
|
||||
if (!signal) continue;
|
||||
const entry = signal.side === "YES" ? current.p : 1 - current.p;
|
||||
const fadeEntry = signal.side === "YES" ? 1 - current.p : current.p;
|
||||
if (entry <= 0.02 || entry >= 0.98) continue;
|
||||
let captured = false;
|
||||
for (const horizonHours of HORIZONS) {
|
||||
const future = atOrAfter(points, current.t + horizonHours * HOUR);
|
||||
if (!future || future.t - (current.t + horizonHours * HOUR) > 3 * HOUR) continue;
|
||||
const exit = signal.side === "YES" ? future.p : 1 - future.p;
|
||||
const fadeExit = signal.side === "YES" ? 1 - future.p : future.p;
|
||||
const grossReturn = exit / entry - 1;
|
||||
const netReturn = grossReturn - (COST_CENTS / 100) / entry;
|
||||
const fadeNetReturn = fadeEntry > 0.02 && fadeEntry < 0.98
|
||||
? fadeExit / fadeEntry - 1 - (COST_CENTS / 100) / fadeEntry : null;
|
||||
outcomes.push({ marketId: market.id, eventKey: market.eventKey, question: market.question, category: market.category,
|
||||
type: signal.type, side: signal.side, band: priceBand(entry), entry, exit, horizonHours,
|
||||
grossReturn, netReturn, fadeNetReturn, hourMove: signal.hourMove, dayMove: signal.dayMove, weekMove: signal.weekMove,
|
||||
observedAt: current.t, evaluatedAt: future.t });
|
||||
captured = true;
|
||||
}
|
||||
if (captured) previousBucket = bucket;
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function summarize(rows, field = "netReturn") {
|
||||
if (!rows.length) return { count: 0, markets: 0, events: 0, mean: 0, median: 0, winRate: 0, worst: 0, best: 0, marketMean: 0, lower90: 0, upper90: 0 };
|
||||
const returns = rows.map((row) => row[field]).filter(Number.isFinite);
|
||||
if (!returns.length) return { count: 0, markets: 0, events: 0, mean: 0, median: 0, winRate: 0, worst: 0, best: 0, marketMean: 0, lower90: 0, upper90: 0 };
|
||||
const eventBuckets = new Map();
|
||||
rows.forEach((row) => {
|
||||
const value = row[field];
|
||||
if (!Number.isFinite(value)) return;
|
||||
const key = row.eventKey || row.marketId;
|
||||
const bucket = eventBuckets.get(key) || [];
|
||||
bucket.push(value); eventBuckets.set(key, bucket);
|
||||
});
|
||||
const marketReturns = [...eventBuckets.values()].map((values) => values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
const marketMean = marketReturns.reduce((sum, value) => sum + value, 0) / Math.max(1, marketReturns.length);
|
||||
const variance = marketReturns.length > 1
|
||||
? marketReturns.reduce((sum, value) => sum + (value - marketMean) ** 2, 0) / (marketReturns.length - 1) : 0;
|
||||
const margin90 = 1.645 * Math.sqrt(variance / Math.max(1, marketReturns.length));
|
||||
return { count: returns.length,
|
||||
mean: returns.reduce((sum, value) => sum + value, 0) / returns.length,
|
||||
median: median(returns), winRate: returns.filter((value) => value > 0).length / returns.length,
|
||||
worst: Math.min(...returns), best: Math.max(...returns), markets: new Set(rows.map((row) => row.marketId)).size, events: marketReturns.length,
|
||||
marketMean, lower90: marketMean - margin90, upper90: marketMean + margin90 };
|
||||
}
|
||||
|
||||
function grouped(rows, key) {
|
||||
return Object.fromEntries([...new Set(rows.map((row) => row[key]))].sort().map((value) => [value, summarize(rows.filter((row) => row[key] === value))]));
|
||||
}
|
||||
|
||||
const RULES = [
|
||||
{ name: "follow_all", field: "netReturn", test: () => true },
|
||||
{ name: "follow_trend", field: "netReturn", test: (row) => row.type === "trend" },
|
||||
{ name: "follow_trend_no", field: "netReturn", test: (row) => row.type === "trend" && row.side === "NO" },
|
||||
{ name: "follow_trend_yes", field: "netReturn", test: (row) => row.type === "trend" && row.side === "YES" },
|
||||
{ name: "follow_trend_mid", field: "netReturn", test: (row) => row.type === "trend" && row.band === "mid" },
|
||||
{ name: "follow_trend_favorites", field: "netReturn", test: (row) => row.type === "trend" && ["favorite", "heavy-favorite"].includes(row.band) },
|
||||
{ name: "follow_trend_non_longshot", field: "netReturn", test: (row) => row.type === "trend" && row.band !== "longshot" },
|
||||
{ name: "follow_strong_trend", field: "netReturn", test: (row) => row.type === "trend" && Math.abs(row.dayMove) >= 0.015 && Math.abs(row.weekMove) >= 0.03 },
|
||||
{ name: "follow_moderate_trend", field: "netReturn", test: (row) => row.type === "trend" && Math.abs(row.dayMove) <= 0.03 && Math.abs(row.weekMove) <= 0.10 },
|
||||
{ name: "follow_hour_confirmed_trend", field: "netReturn", test: (row) => row.type === "trend" && Math.sign(row.hourMove) === Math.sign(row.dayMove) },
|
||||
...["Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"].map((category) => ({
|
||||
name: `follow_trend_${category.toLowerCase().replace(/\s+/g, "_")}`, field: "netReturn",
|
||||
test: (row) => row.type === "trend" && row.category === category,
|
||||
})),
|
||||
{ name: "follow_reversal", field: "netReturn", test: (row) => row.type === "reversal" },
|
||||
{ name: "fade_trend", field: "fadeNetReturn", test: (row) => row.type === "trend" },
|
||||
{ name: "fade_trend_yes_move", field: "fadeNetReturn", test: (row) => row.type === "trend" && row.side === "YES" },
|
||||
{ name: "fade_trend_no_move", field: "fadeNetReturn", test: (row) => row.type === "trend" && row.side === "NO" },
|
||||
{ name: "fade_trend_mid", field: "fadeNetReturn", test: (row) => row.type === "trend" && row.band === "mid" },
|
||||
{ name: "fade_trend_favorites", field: "fadeNetReturn", test: (row) => row.type === "trend" && ["favorite", "heavy-favorite"].includes(row.band) },
|
||||
{ name: "fade_trend_longshots", field: "fadeNetReturn", test: (row) => row.type === "trend" && row.band === "longshot" },
|
||||
{ name: "fade_strong_trend", field: "fadeNetReturn", test: (row) => row.type === "trend" && Math.abs(row.dayMove) >= 0.015 && Math.abs(row.weekMove) >= 0.03 },
|
||||
{ name: "fade_moderate_trend", field: "fadeNetReturn", test: (row) => row.type === "trend" && Math.abs(row.dayMove) <= 0.03 && Math.abs(row.weekMove) <= 0.10 },
|
||||
...["Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"].map((category) => ({
|
||||
name: `fade_trend_${category.toLowerCase().replace(/\s+/g, "_")}`, field: "fadeNetReturn",
|
||||
test: (row) => row.type === "trend" && row.category === category,
|
||||
})),
|
||||
];
|
||||
|
||||
const COMBINATION_CATEGORIES = ["Politics", "Sports", "Crypto", "Economy", "Pop Culture", "Other"];
|
||||
const COMBINATION_BANDS = ["longshot", "mid", "favorite", "heavy-favorite"];
|
||||
for (const category of COMBINATION_CATEGORIES) {
|
||||
const slug = category.toLowerCase().replace(/\s+/g, "_");
|
||||
for (const side of ["YES", "NO"]) {
|
||||
RULES.push({ name: `follow_trend_${slug}_${side.toLowerCase()}`, field: "netReturn",
|
||||
test: (row) => row.type === "trend" && row.category === category && row.side === side });
|
||||
RULES.push({ name: `follow_reversal_${slug}_${side.toLowerCase()}`, field: "netReturn",
|
||||
test: (row) => row.type === "reversal" && row.category === category && row.side === side });
|
||||
for (const band of COMBINATION_BANDS) {
|
||||
RULES.push({ name: `follow_trend_${slug}_${side.toLowerCase()}_${band.replace("-", "_")}`, field: "netReturn",
|
||||
test: (row) => row.type === "trend" && row.category === category && row.side === side && row.band === band });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const side of ["YES", "NO"]) {
|
||||
for (const band of COMBINATION_BANDS) {
|
||||
RULES.push({ name: `follow_trend_${side.toLowerCase()}_${band.replace("-", "_")}`, field: "netReturn",
|
||||
test: (row) => row.type === "trend" && row.side === side && row.band === band });
|
||||
RULES.push({ name: `follow_reversal_${side.toLowerCase()}_${band.replace("-", "_")}`, field: "netReturn",
|
||||
test: (row) => row.type === "reversal" && row.side === side && row.band === band });
|
||||
}
|
||||
}
|
||||
RULES.push(
|
||||
{ name: "follow_strong_trend_yes", field: "netReturn", test: (row) => row.type === "trend" && row.side === "YES" && Math.abs(row.dayMove) >= 0.015 && Math.abs(row.weekMove) >= 0.03 },
|
||||
{ name: "follow_strong_trend_no", field: "netReturn", test: (row) => row.type === "trend" && row.side === "NO" && Math.abs(row.dayMove) >= 0.015 && Math.abs(row.weekMove) >= 0.03 },
|
||||
{ name: "follow_hour_confirmed_trend_yes", field: "netReturn", test: (row) => row.type === "trend" && row.side === "YES" && Math.sign(row.hourMove) === Math.sign(row.dayMove) },
|
||||
{ name: "follow_hour_confirmed_trend_no", field: "netReturn", test: (row) => row.type === "trend" && row.side === "NO" && Math.sign(row.hourMove) === Math.sign(row.dayMove) },
|
||||
);
|
||||
|
||||
function evaluateRules(rows) {
|
||||
return Object.fromEntries(RULES.map((rule) => [rule.name, summarize(rows.filter(rule.test), rule.field)]));
|
||||
}
|
||||
|
||||
function chronologicalEvaluation(rows) {
|
||||
const ordered = [...rows].sort((a, b) => a.observedAt - b.observedAt);
|
||||
const splitTime = ordered[Math.floor(ordered.length * 0.70)]?.observedAt || 0;
|
||||
const train = ordered.filter((row) => row.observedAt < splitTime), test = ordered.filter((row) => row.observedAt >= splitTime);
|
||||
const cut1 = ordered[Math.floor(ordered.length / 3)]?.observedAt || 0;
|
||||
const cut2 = ordered[Math.floor(ordered.length * 2 / 3)]?.observedAt || 0;
|
||||
const thirds = [ordered.filter((row) => row.observedAt < cut1),
|
||||
ordered.filter((row) => row.observedAt >= cut1 && row.observedAt < cut2),
|
||||
ordered.filter((row) => row.observedAt >= cut2)];
|
||||
const thirdRules = thirds.map(evaluateRules), pooled = evaluateRules(ordered);
|
||||
const trainRules = evaluateRules(train), testRules = evaluateRules(test);
|
||||
const robustRules = Object.fromEntries(RULES.map((rule) => {
|
||||
const segments = thirdRules.map((result) => result[rule.name]);
|
||||
const trainStats = trainRules[rule.name], testStats = testRules[rule.name], pooledStats = pooled[rule.name];
|
||||
const enoughData = segments.every((segment) => segment.count >= 20 && segment.events >= 5);
|
||||
const trainTestPositive = trainStats.count >= 40 && testStats.count >= 20
|
||||
&& trainStats.mean > 0 && trainStats.marketMean > 0 && testStats.mean > 0 && testStats.marketMean > 0;
|
||||
const trainTestNegative = trainStats.count >= 40 && testStats.count >= 20
|
||||
&& trainStats.mean < 0 && trainStats.marketMean < 0 && testStats.mean < 0 && testStats.marketMean < 0;
|
||||
const allPositive = enoughData && trainTestPositive && pooledStats.lower90 > 0
|
||||
&& segments.every((segment) => segment.mean > 0 && segment.marketMean > 0);
|
||||
const allNegative = enoughData && trainTestNegative && pooledStats.upper90 < 0
|
||||
&& segments.every((segment) => segment.mean < 0 && segment.marketMean < 0);
|
||||
return [rule.name, { enoughData, allPositive, allNegative,
|
||||
minimumSegmentMean: Math.min(...segments.map((segment) => segment.mean)),
|
||||
maximumSegmentMean: Math.max(...segments.map((segment) => segment.mean)), pooled: pooledStats }];
|
||||
}));
|
||||
return { splitTime: splitTime ? new Date(splitTime * 1000).toISOString() : null,
|
||||
trainCount: train.length, testCount: test.length, train: trainRules, test: testRules,
|
||||
thirds: thirdRules, robustRules };
|
||||
}
|
||||
|
||||
async function fetchActiveMarkets(limit) {
|
||||
const markets = [], seen = new Set(), pageSize = 100;
|
||||
for (let offset = 0; offset < limit; offset += pageSize) {
|
||||
const params = new URLSearchParams({ active: "true", closed: "false", archived: "false", include_tag: "true",
|
||||
limit: String(Math.min(pageSize, limit - offset)), offset: String(offset), order: "volume24hr", ascending: "false" });
|
||||
const page = await fetchJson(`${GAMMA}/markets?${params}`);
|
||||
if (!Array.isArray(page) || !page.length) break;
|
||||
for (const market of page) {
|
||||
const id = String(market.id || "");
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id); markets.push(market);
|
||||
}
|
||||
if (page.length < Math.min(pageSize, limit - offset)) break;
|
||||
}
|
||||
return markets.slice(0, limit);
|
||||
}
|
||||
|
||||
const rawMarkets = await fetchActiveMarkets(MARKET_LIMIT);
|
||||
const markets = rawMarkets.map((raw) => ({ id: String(raw.id), question: raw.question || "", category: categoryOf(raw),
|
||||
eventKey: String(raw.events?.[0]?.id || raw.events?.[0]?.slug || raw.eventId || raw.id),
|
||||
tokenId: String(parseJson(raw.clobTokenIds)[0] || "") })).filter((market) => market.id && market.tokenId);
|
||||
const histories = await mapLimit(markets, CONCURRENCY, async (market) => {
|
||||
const data = await fetchJson(`${CLOB}/prices-history?market=${encodeURIComponent(market.tokenId)}&interval=1m&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, outcomes: evaluateMarket(market, points) };
|
||||
});
|
||||
const successful = histories.filter((result) => result && !result.error && result.points.length);
|
||||
const outcomes = successful.flatMap((result) => result.outcomes);
|
||||
const primaryHorizon = HORIZONS.includes(12) ? 12 : HORIZONS[0];
|
||||
const primaryOutcomes = outcomes.filter((row) => row.horizonHours === primaryHorizon);
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(), marketLimit: MARKET_LIMIT, marketsWithHistory: successful.length,
|
||||
methodology: { horizonHours: HORIZONS, primaryHorizon, observationBucketHours: 6, historyInterval: "1m", fidelityMinutes: 60,
|
||||
estimatedRoundTripCostCents: COST_CENTS, clusterUnit: "event",
|
||||
note: "Current active-market selection and current category tags are a survivorship-biased proxy; signal inputs and future marks are time-ordered without lookahead. Confidence intervals cluster correlated markets by Polymarket event." },
|
||||
overall: summarize(primaryOutcomes), byType: grouped(primaryOutcomes, "type"), byCategory: grouped(primaryOutcomes, "category"),
|
||||
byBand: grouped(primaryOutcomes, "band"), bySide: grouped(primaryOutcomes, "side"),
|
||||
chronologicalSplit: chronologicalEvaluation(primaryOutcomes),
|
||||
horizons: Object.fromEntries(HORIZONS.map((horizon) => {
|
||||
const rows = outcomes.filter((row) => row.horizonHours === horizon);
|
||||
return [horizon, { overall: summarize(rows), chronological: chronologicalEvaluation(rows) }];
|
||||
})),
|
||||
failures: histories.filter((result) => result?.error).length,
|
||||
};
|
||||
const compact = process.env.EVAL_SUMMARY === "1";
|
||||
const compactStats = (stats = {}) => ({ count: stats.count || 0, markets: stats.markets || 0, events: stats.events || 0,
|
||||
mean: stats.mean || 0, marketMean: stats.marketMean || 0, lower90: stats.lower90 || 0, upper90: stats.upper90 || 0,
|
||||
winRate: stats.winRate || 0 });
|
||||
const compactRules = (rules = {}) => Object.fromEntries(Object.entries(rules)
|
||||
.filter(([, result]) => result.enoughData && (result.allPositive || result.allNegative))
|
||||
.map(([name, result]) => [name, { direction: result.allPositive ? "positive" : "negative",
|
||||
minimumSegmentMean: result.minimumSegmentMean, maximumSegmentMean: result.maximumSegmentMean,
|
||||
pooled: compactStats(result.pooled) }]));
|
||||
const summary = {
|
||||
generatedAt: report.generatedAt, marketLimit: report.marketLimit, marketsWithHistory: report.marketsWithHistory,
|
||||
primaryHorizon: report.methodology.primaryHorizon, failures: report.failures,
|
||||
overall: compactStats(report.overall),
|
||||
byType: Object.fromEntries(Object.entries(report.byType).map(([key, value]) => [key, compactStats(value)])),
|
||||
byCategory: Object.fromEntries(Object.entries(report.byCategory).map(([key, value]) => [key, compactStats(value)])),
|
||||
byBand: Object.fromEntries(Object.entries(report.byBand).map(([key, value]) => [key, compactStats(value)])),
|
||||
bySide: Object.fromEntries(Object.entries(report.bySide).map(([key, value]) => [key, compactStats(value)])),
|
||||
train: compactStats(report.chronologicalSplit.train.follow_all),
|
||||
test: compactStats(report.chronologicalSplit.test.follow_all),
|
||||
robustRules: compactRules(report.chronologicalSplit.robustRules),
|
||||
horizons: Object.fromEntries(Object.entries(report.horizons).map(([hours, value]) => [hours, {
|
||||
overall: compactStats(value.overall), robustRules: compactRules(value.chronological.robustRules),
|
||||
}])),
|
||||
};
|
||||
console.log(JSON.stringify(compact ? summary : report, null, 2));
|
||||
@@ -0,0 +1,36 @@
|
||||
const CACHE_NAME = "polymarket-arena-build-56";
|
||||
const APP_SHELL = ["/", "/index.html", "/personal.html", "/cycle-worker.js"];
|
||||
|
||||
self.addEventListener("install", event => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(APP_SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil(caches.keys()
|
||||
.then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))))
|
||||
.then(() => self.clients.claim()));
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
if(event.request.method !== "GET") return;
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if(event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request)
|
||||
.then(response => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(async () => (await caches.match(event.request)) || (await caches.match("/index.html"))));
|
||||
return;
|
||||
}
|
||||
|
||||
if(url.origin === self.location.origin && !url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(caches.match(event.request).then(cached => cached || fetch(event.request).then(response => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
||||
return response;
|
||||
})));
|
||||
}
|
||||
});
|
||||
+18
@@ -50,6 +50,24 @@
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/sw.js",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, max-age=0, must-revalidate"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/cycle-worker.js",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache, max-age=0, must-revalidate"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user