mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-08-19 10:38:09 +00:00
Compare commits
57
Commits
3c39cfe9f3
..
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 | ||
|
|
0ef05ea734 | ||
|
|
cfecd2e78b | ||
|
|
6233efc098 | ||
|
|
ecbdbf4cbf | ||
|
|
a4402134ff | ||
|
|
e57d84af1d | ||
|
|
c7c8359f09 | ||
|
|
8dc7c9506c | ||
|
|
313c4f9326 | ||
|
|
1effdd8373 | ||
|
|
4630bd482b | ||
|
|
91a6c5678c | ||
|
|
8a897b9a6b | ||
|
|
2a2f8af765 | ||
|
|
a352e86a2b | ||
|
|
21f89a9fea | ||
|
|
f8e3799f21 | ||
|
|
5f57608ee7 | ||
|
|
697830a782 | ||
|
|
cb4a539256 | ||
|
|
09b4eac736 | ||
|
|
c7ce683db9 | ||
|
|
d04ed59088 | ||
|
|
82a1e0c831 | ||
|
|
67beab0144 |
@@ -14,8 +14,186 @@ Personal research mode:
|
|||||||
https://polymarket-site-eta.vercel.app/personal.html
|
https://polymarket-site-eta.vercel.app/personal.html
|
||||||
|
|
||||||
The site fetches live Polymarket markets, generates agent suggestions, lets you
|
The site fetches live Polymarket markets, generates agent suggestions, lets you
|
||||||
run frequent paper cycles, and syncs the shared arena state through the Vercel
|
run frequent paper cycles, and syncs the shared arena state through Neon or
|
||||||
API when `BLOB_READ_WRITE_TOKEN` is configured.
|
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
|
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,
|
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.
|
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
|
- `ACCOUNT_SESSION_SECRET` signs cloud paper-account sessions. If omitted, the
|
||||||
app falls back to the existing server secret/token, but production should use
|
app falls back to the existing server secret/token, but production should use
|
||||||
a dedicated value.
|
a dedicated value.
|
||||||
|
|||||||
+40
@@ -2,6 +2,7 @@ import { neon } from "@neondatabase/serverless";
|
|||||||
|
|
||||||
let sqlClient;
|
let sqlClient;
|
||||||
let schemaReady;
|
let schemaReady;
|
||||||
|
let sharedStateSchemaReady;
|
||||||
|
|
||||||
export function databaseUrl() {
|
export function databaseUrl() {
|
||||||
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
|
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
|
||||||
@@ -20,6 +21,45 @@ export function sql() {
|
|||||||
return sqlClient;
|
return sqlClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSharedStateSchema() {
|
||||||
|
if (sharedStateSchemaReady) return;
|
||||||
|
const db = sql();
|
||||||
|
await db`
|
||||||
|
create table if not exists shared_app_state (
|
||||||
|
state_key text primary key,
|
||||||
|
payload jsonb not null,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
sharedStateSchemaReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSharedAppState(stateKey) {
|
||||||
|
await ensureSharedStateSchema();
|
||||||
|
const db = sql();
|
||||||
|
const rows = await db`
|
||||||
|
select payload, updated_at
|
||||||
|
from shared_app_state
|
||||||
|
where state_key = ${stateKey}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSharedAppState(stateKey, payload) {
|
||||||
|
await ensureSharedStateSchema();
|
||||||
|
const db = sql();
|
||||||
|
const rows = await db`
|
||||||
|
insert into shared_app_state (state_key, payload, updated_at)
|
||||||
|
values (${stateKey}, ${JSON.stringify(payload)}::jsonb, now())
|
||||||
|
on conflict (state_key) do update set
|
||||||
|
payload = excluded.payload,
|
||||||
|
updated_at = now()
|
||||||
|
returning updated_at
|
||||||
|
`;
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureSchema() {
|
export async function ensureSchema() {
|
||||||
if (schemaReady) return;
|
if (schemaReady) return;
|
||||||
const db = sql();
|
const db = sql();
|
||||||
|
|||||||
+186
-16
@@ -1,20 +1,131 @@
|
|||||||
import { get, put } from "@vercel/blob";
|
import { get, list, put } from "@vercel/blob";
|
||||||
|
import { hasDatabase, readSharedAppState, writeSharedAppState } from "./_db.js";
|
||||||
|
|
||||||
const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json";
|
const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json";
|
||||||
|
const STATE_VERSION_PREFIX = process.env.PMA_STATE_VERSION_PREFIX || "shared/state-versions/";
|
||||||
|
const DATABASE_STATE_KEY = process.env.PMA_DATABASE_STATE_KEY || "polymarket-arena";
|
||||||
const AGENTS_KEY = "pma_agents_v2";
|
const AGENTS_KEY = "pma_agents_v2";
|
||||||
const SUG_KEY = "pma_suggestions_v5";
|
const SUG_KEY = "pma_suggestions_v5";
|
||||||
const PAPER_KEY = "pma_paper_accounts_v1";
|
const PAPER_KEY = "pma_paper_accounts_v1";
|
||||||
const LIVE_KEY = "pma_live_readiness_v1";
|
const LIVE_KEY = "pma_live_readiness_v1";
|
||||||
const AGENT_IDS = ["value", "momentum", "favorite", "longshot", "diversifier", "copycat", "whale1", "whale2", "whale3", "whale4"];
|
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 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;
|
||||||
|
return token ? { ...options, token } : options;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJsonBlob() {
|
async function readJsonBlob() {
|
||||||
const blob = await get(STATE_PATH, { access: "private" });
|
let databaseAvailable = false;
|
||||||
|
if (hasDatabase()) {
|
||||||
|
try {
|
||||||
|
const row = await readSharedAppState(DATABASE_STATE_KEY);
|
||||||
|
databaseAvailable = true;
|
||||||
|
if (row && row.payload) return row.payload;
|
||||||
|
} catch {
|
||||||
|
databaseAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let primaryError = null;
|
||||||
|
try {
|
||||||
|
const primary = await readBlobJson(STATE_PATH);
|
||||||
|
if (primary) return primary;
|
||||||
|
} catch (err) {
|
||||||
|
primaryError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latest = await latestVersionedStateBlob();
|
||||||
|
if (latest) return latest;
|
||||||
|
} catch (err) {
|
||||||
|
if (!primaryError) primaryError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryError && !databaseAvailable) throw primaryError;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistState(state) {
|
||||||
|
let databaseSaved = false;
|
||||||
|
let databaseError = null;
|
||||||
|
if (hasDatabase()) {
|
||||||
|
try {
|
||||||
|
await writeSharedAppState(DATABASE_STATE_KEY, state);
|
||||||
|
databaseSaved = true;
|
||||||
|
} catch (err) {
|
||||||
|
databaseError = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let blobSaved = false;
|
||||||
|
let blobError = null;
|
||||||
|
try {
|
||||||
|
await put(STATE_PATH, JSON.stringify(state), withBlobAuth({
|
||||||
|
access: "private",
|
||||||
|
allowOverwrite: true,
|
||||||
|
contentType: "application/json",
|
||||||
|
cacheControlMaxAge: 0,
|
||||||
|
}));
|
||||||
|
blobSaved = true;
|
||||||
|
} catch (err) {
|
||||||
|
blobError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databaseSaved && !blobSaved) throw databaseError || blobError || new Error("No state provider is available");
|
||||||
|
return { databaseSaved, blobSaved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBlobJson(pathname) {
|
||||||
|
const blob = await get(pathname, withBlobAuth({
|
||||||
|
access: "private",
|
||||||
|
headers: { "cache-control": "no-cache" },
|
||||||
|
}));
|
||||||
if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
|
if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
|
||||||
const text = await new Response(blob.stream).text();
|
const text = await new Response(blob.stream).text();
|
||||||
return text ? JSON.parse(text) : null;
|
return text ? JSON.parse(text) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function latestVersionedStateBlob() {
|
||||||
|
let cursor;
|
||||||
|
let newest = null;
|
||||||
|
do {
|
||||||
|
const page = await list(withBlobAuth({ prefix: STATE_VERSION_PREFIX, limit: 1000, cursor }));
|
||||||
|
for (const blob of page.blobs || []) {
|
||||||
|
if (!newest || new Date(blob.uploadedAt).getTime() > new Date(newest.uploadedAt).getTime()) newest = blob;
|
||||||
|
}
|
||||||
|
cursor = page.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
if (!newest) return null;
|
||||||
|
return readBlobJson(newest.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleVersion(cycle = "") {
|
||||||
|
const m = String(cycle).match(/\|v(\d+)$/);
|
||||||
|
return m ? Number(m[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPositionCount(st) {
|
||||||
|
if (!st || !st.agents) return 0;
|
||||||
|
return AGENT_IDS.reduce((sum, id) => sum + (Array.isArray(st.agents[id]?.positions) ? st.agents[id].positions.length : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRejectStaleAgentWrite(currentAgents, incomingAgents) {
|
||||||
|
if (!currentAgents || !incomingAgents) return false;
|
||||||
|
const currentCycle = currentAgents.last_cycle_hour || "";
|
||||||
|
const incomingCycle = incomingAgents.last_cycle_hour || "";
|
||||||
|
if (!currentCycle || !incomingCycle) return false;
|
||||||
|
const currentVersion = cycleVersion(currentCycle);
|
||||||
|
const incomingVersion = cycleVersion(incomingCycle);
|
||||||
|
if (incomingVersion < currentVersion) return true;
|
||||||
|
if (incomingVersion === currentVersion && incomingCycle < currentCycle) return true;
|
||||||
|
const currentOpen = openPositionCount(currentAgents);
|
||||||
|
const incomingOpen = openPositionCount(incomingAgents);
|
||||||
|
return incomingVersion === currentVersion && incomingCycle === currentCycle && currentOpen > 0 && incomingOpen === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function agentStateFromItems(items) {
|
function agentStateFromItems(items) {
|
||||||
if (!items || !items[AGENTS_KEY]) return null;
|
if (!items || !items[AGENTS_KEY]) return null;
|
||||||
try {
|
try {
|
||||||
@@ -24,6 +135,15 @@ function agentStateFromItems(items) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suggestionStateFromItems(items) {
|
||||||
|
if (!items || !items[SUG_KEY]) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(items[SUG_KEY]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function compactPortfolio(p) {
|
function compactPortfolio(p) {
|
||||||
if (!p || typeof p !== "object") return p;
|
if (!p || typeof p !== "object") return p;
|
||||||
const out = { ...p };
|
const out = { ...p };
|
||||||
@@ -41,7 +161,14 @@ function compactAgentState(st) {
|
|||||||
for (const id of AGENT_IDS) {
|
for (const id of AGENT_IDS) {
|
||||||
out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
|
out.agents[id] = compactPortfolio(st.agents && st.agents[id]);
|
||||||
}
|
}
|
||||||
out.whales = st.whales || {};
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +178,11 @@ function compactSuggestion(s) {
|
|||||||
market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category,
|
market_id: s.market_id, question: s.question, event: s.event, url: s.url, category: s.category,
|
||||||
clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price,
|
clob_yes: s.clob_yes, clob_no: s.clob_no, yes_price: s.yes_price, no_price: s.no_price,
|
||||||
fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price,
|
fair_value: s.fair_value, edge: s.edge, side: s.side, entry_price: s.entry_price,
|
||||||
|
net_edge: s.net_edge, friction: s.friction, chase_penalty: s.chase_penalty,
|
||||||
|
evidence_score: s.evidence_score, evidence_source_count: s.evidence_source_count, quality: s.quality,
|
||||||
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity,
|
conviction: s.conviction, volume: s.volume, volume_24hr: s.volume_24hr, liquidity: s.liquidity,
|
||||||
trade_ready: s.trade_ready, watch_only: s.watch_only,
|
spread: s.spread, price_change_1h: s.price_change_1h, price_change_1d: s.price_change_1d, price_change_1w: s.price_change_1w,
|
||||||
|
momentum_strength: s.momentum_strength, trade_ready: s.trade_ready, watch_only: s.watch_only, jump_risk: s.jump_risk,
|
||||||
days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale,
|
days_to_resolution: s.days_to_resolution, drivers: s.drivers, rationale: s.rationale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,14 +227,24 @@ function conflictResponse(res, error, current) {
|
|||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
try {
|
try {
|
||||||
if (!process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) {
|
if (!hasDatabase() && !process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) {
|
||||||
return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
|
return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
|
try {
|
||||||
const state = await readJsonBlob();
|
const state = await readJsonBlob();
|
||||||
if (state && state.items) state.items = compactItems(state.items);
|
if (state && state.items) state.items = compactItems(state.items);
|
||||||
return res.status(200).json({ ok: true, state });
|
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") {
|
if (req.method === "POST") {
|
||||||
@@ -112,31 +252,61 @@ export default async function handler(req, res) {
|
|||||||
if (!body || typeof body !== "object" || !body.items || typeof body.items !== "object") {
|
if (!body || typeof body !== "object" || !body.items || typeof body.items !== "object") {
|
||||||
return res.status(400).json({ ok: false, error: "Invalid state payload" });
|
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);
|
const currentAgents = agentStateFromItems(current && current.items);
|
||||||
const incomingAgents = agentStateFromItems(body.items);
|
let incomingAgents = agentStateFromItems(incomingItems);
|
||||||
|
const staleAgentWrite = shouldRejectStaleAgentWrite(currentAgents, incomingAgents);
|
||||||
|
if (body.force && staleAgentWrite && current?.items?.[AGENTS_KEY]) {
|
||||||
|
incomingItems[AGENTS_KEY] = current.items[AGENTS_KEY];
|
||||||
|
incomingAgents = currentAgents;
|
||||||
|
}
|
||||||
|
const currentSuggestions = suggestionStateFromItems(current && current.items);
|
||||||
|
const incomingSuggestions = suggestionStateFromItems(incomingItems);
|
||||||
|
if (currentSuggestions && incomingSuggestions
|
||||||
|
&& Number(incomingSuggestions.engine_version || 0) < Number(currentSuggestions.engine_version || 0)) {
|
||||||
|
incomingItems[SUG_KEY] = current.items[SUG_KEY];
|
||||||
|
}
|
||||||
if (!body.force && currentAgents) {
|
if (!body.force && currentAgents) {
|
||||||
const currentCycle = currentAgents.last_cycle_hour || "";
|
const currentCycle = currentAgents.last_cycle_hour || "";
|
||||||
const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : "";
|
const incomingCycle = incomingAgents && incomingAgents.last_cycle_hour ? incomingAgents.last_cycle_hour : "";
|
||||||
if (currentCycle && (!incomingAgents || !incomingCycle)) {
|
if (currentCycle && (!incomingAgents || !incomingCycle)) {
|
||||||
return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current);
|
return conflictResponse(res, "Cloud already has a cycle result; refusing unscheduled local state", current);
|
||||||
}
|
}
|
||||||
if (currentCycle && incomingCycle && incomingCycle < currentCycle) {
|
|
||||||
return conflictResponse(res, "Incoming state is older than the shared cloud result", current);
|
|
||||||
}
|
|
||||||
const sameCycle = currentCycle && currentCycle === incomingCycle;
|
const sameCycle = currentCycle && currentCycle === incomingCycle;
|
||||||
const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run;
|
const differentRun = currentAgents.last_run && incomingAgents.last_run && currentAgents.last_run !== incomingAgents.last_run;
|
||||||
if (sameCycle && differentRun) {
|
if (sameCycle && differentRun) {
|
||||||
return conflictResponse(res, "This cycle already has a cloud result", current);
|
return conflictResponse(res, "This cycle already has a cloud result", current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(body.items) };
|
if (!body.force && staleAgentWrite) {
|
||||||
await put(STATE_PATH, JSON.stringify(state), {
|
return conflictResponse(res, "Incoming state would replace newer active positions with stale cash-only data", current);
|
||||||
|
}
|
||||||
|
const state = { version: 1, updated_at: new Date().toISOString(), items: compactItems(incomingItems) };
|
||||||
|
const saved = await persistState(state);
|
||||||
|
if (saved.blobSaved && process.env.PMA_ENABLE_STATE_HISTORY === "true") {
|
||||||
|
const versionedPath = `${STATE_VERSION_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||||
|
try {
|
||||||
|
await put(versionedPath, JSON.stringify(state), withBlobAuth({
|
||||||
access: "private",
|
access: "private",
|
||||||
allowOverwrite: true,
|
allowOverwrite: false,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
cacheControlMaxAge: 0,
|
cacheControlMaxAge: 0,
|
||||||
});
|
}));
|
||||||
|
} catch {
|
||||||
|
// The shared state is authoritative; backup retention must not block a cycle.
|
||||||
|
}
|
||||||
|
}
|
||||||
return res.status(200).json({ ok: true, state });
|
return res.status(200).json({ ok: true, state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
+1531
-523
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": {
|
"dependencies": {
|
||||||
"@neondatabase/serverless": "^1.1.0",
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@vercel/blob": "2.5.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"
|
"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