Compare commits

..
32 Commits
Author SHA1 Message Date
Theodore Song a5441e8659 Quarantine losing signals and add bundle arbitrage 2026-08-18 16:40:18 -04:00
Theodore Song c7abdf8d4d Verify accounting and rank current strategy 2026-08-18 13:05:10 -04:00
Theodore Song 97186482df Cluster adaptive evidence by event 2026-08-18 12:58:09 -04:00
Theodore Song a1fc52bc20 Preserve adaptive signals until grading 2026-08-18 12:54:16 -04:00
Theodore Song c20ac0e78f Cluster adaptive evidence by market 2026-08-18 12:50:42 -04:00
Theodore Song 7001366334 Prioritize settlement risk explanations 2026-08-18 12:44:37 -04:00
Theodore Song c78a13bbb7 Block path-dependent settlement barriers 2026-08-18 12:41:22 -04:00
Theodore Song 25622d2ee5 Block audited losing signal cohorts 2026-08-18 12:36:12 -04:00
Theodore Song 846d1f1482 Make historical risk gates adaptive 2026-08-18 11:01:30 -04:00
Theodore Song b768d92f36 Migrate renamed agent chat identity 2026-08-18 10:48:53 -04:00
Theodore Song ad11de39ad Align adaptation with audited outcomes 2026-08-18 10:45:58 -04:00
Theodore Song 7b81dbfa4b Coordinate high-risk agent exposure 2026-08-18 10:32:15 -04:00
Theodore Song 0db53c46f8 Grade signals beyond the active scan 2026-08-18 10:24:05 -04:00
Theodore Song aea8f5281d Preserve strategy evidence across builds 2026-08-18 10:19:12 -04:00
Theodore Song 8f89847cd6 Make stale offline cycles mark-only 2026-08-18 10:12:42 -04:00
Theodore Song bc5de3c013 Gate adaptation on repeatable evidence 2026-08-18 10:06:59 -04:00
Theodore Song 807eb1b506 Fix adaptive confidence and trading costs 2026-08-18 10:01:43 -04:00
Theodore Song c6c7dc30b4 Add settlement calibration audit 2026-08-18 09:58:55 -04:00
Theodore Song 36345e7cad Apply multi-horizon market priors 2026-08-18 09:52:06 -04:00
Theodore Song 38945b2045 Expand chronological signal audit 2026-08-18 09:50:46 -04:00
Theodore Song f341136d80 Apply history-tested risk priors 2026-08-18 09:45:45 -04:00
Theodore Song 901ad8a959 Add chronological signal evaluation 2026-08-18 09:43:29 -04:00
Theodore Song 8cdbcd00ba Remove lookahead from historical replay 2026-08-18 09:36:53 -04:00
Theodore Song 4b35b89f6d Align conviction capacity with risk budgets 2026-08-18 09:32:17 -04:00
Theodore Song 283a009e00 Explain every blocked agent entry 2026-08-18 08:44:12 -04:00
Theodore Song 9fea16988c Ignore tiny runners in agent overlap limits 2026-08-18 08:41:01 -04:00
Theodore Song dc9199a433 Cap binary settlement risk in adaptive engine 2026-08-18 08:36:55 -04:00
Theodore Song b906e9654d Keep cached cycles active in background tabs 2026-08-18 08:03:29 -04:00
Theodore Song e4e27c885a Add walk-forward signal calibration 2026-08-17 21:49:19 -04:00
Theodore Song 444fd10e79 Contain severe losing signal regimes early 2026-08-17 21:17:13 -04:00
Theodore Song 449cbdfd94 Add adaptive offline agent engine 2026-08-17 21:11:59 -04:00
Theodore Song ab333dd045 Replace forced exposure with confirmed signals 2026-08-09 15:47:06 -04:00
10 changed files with 2274 additions and 363 deletions
+182 -3
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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",
+84
View File
@@ -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));
+261
View File
@@ -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));
+330
View File
@@ -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));
+36
View File
@@ -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
View File
@@ -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"
}
]
}
]
}