From 98abd96fd2195febda793fc31e9a5abcd4e2b700 Mon Sep 17 00:00:00 2001 From: chemavx Date: Mon, 8 Jun 2026 12:19:31 +0000 Subject: [PATCH] feat(manifold): add persistent cooldowns to reduce redundant evaluations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trading loop re-evaluated the same ~22 politics/tech markets every ~60s, flooding manifold_match_audit with ~76k rows (~3,500 attempts/market) of which none carried new information, making the metrics uninterpretable. Add per-market persistent cooldowns: - New table manifold_eval_cooldown (poly_market_id PK, last_evaluated_at, last_status, retry_after, cooldown_reason) created via run_migrations. - bayesian.evaluate() consults the cooldown BEFORE calling the matcher and skips the call entirely while now() < retry_after — no matcher call, no audit row, no signal (equivalent to no_results). After a real evaluation it upserts the cooldown with a verdict-dependent backoff: no_results/low_score/outcome_mismatch/ ambiguous_inversion -> 24h, conditional_market -> 7d, accepted -> 1h. - manifold.py stays a pure client/matcher with no DB access. - New db methods get_manifold_cooldown / upsert_manifold_cooldown. - /api/metrics/manifold-matches summary gains unique_markets {evaluated, accepted, coverage_rate} for per-market (not per-attempt) coverage. Matching logic, MANIFOLD_MATCHER_VERSION, MANIFOLD_LOGODDS_WEIGHT, edge/exposure thresholds and existing audit rows are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/main.py | 4 + bot/data/db.py | 57 ++++++++++++ bot/data/schema.sql | 26 ++++++ bot/strategy/bayesian.py | 184 ++++++++++++++++++++++++++------------- 4 files changed, 212 insertions(+), 59 deletions(-) diff --git a/api/main.py b/api/main.py index f72e092..705182d 100644 --- a/api/main.py +++ b/api/main.py @@ -226,6 +226,10 @@ async def get_manifold_matches(): summary.trades_dominated_by_mfld — non-excluded accepted-match trades where feat_mfld_lo is the largest signal (consistent with attribution/features, which also exclude excluded_from_metrics trades). + summary.unique_markets — distinct-market coverage (per-market, not per-attempt): + evaluated — DISTINCT poly_market_id in manifold_match_audit (all versions) + accepted — DISTINCT poly_market_id accepted by the current matcher + coverage_rate — accepted / evaluated (null when evaluated=0) recent_matches: last 50 rows from manifold_match_audit, newest first, each tagged with matcher_version. diff --git a/bot/data/db.py b/bot/data/db.py index 0ac20f0..c3d430e 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -553,6 +553,46 @@ class Database: poly_outcome_type, mfld_outcome_type, matcher_version, ) + async def get_manifold_cooldown(self, poly_market_id: str) -> Optional[dict]: + """Return the cooldown row for a market, or None if no cooldown is recorded. + + The caller decides whether the cooldown is still active by comparing + now() against retry_after (see bayesian.evaluate()). + """ + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT poly_market_id, last_evaluated_at, last_status, " + "retry_after, cooldown_reason FROM manifold_eval_cooldown " + "WHERE poly_market_id = $1", + poly_market_id, + ) + return dict(row) if row else None + + async def upsert_manifold_cooldown( + self, + poly_market_id: str, + last_status: str, + retry_after, + cooldown_reason: Optional[str] = None, + ) -> None: + """Insert or refresh the cooldown for a market after a real evaluation. + + last_evaluated_at is stamped server-side with now(); retry_after is the + caller-computed earliest re-evaluation time. + """ + async with self._pool.acquire() as conn: + await conn.execute(""" + INSERT INTO manifold_eval_cooldown ( + poly_market_id, last_evaluated_at, last_status, + retry_after, cooldown_reason + ) VALUES ($1, now(), $2, $3, $4) + ON CONFLICT (poly_market_id) DO UPDATE SET + last_evaluated_at = now(), + last_status = EXCLUDED.last_status, + retry_after = EXCLUDED.retry_after, + cooldown_reason = EXCLUDED.cooldown_reason + """, poly_market_id, last_status, retry_after, cooldown_reason) + async def mark_manifold_audit_used(self, audit_id: str) -> None: async with self._pool.acquire() as conn: await conn.execute( @@ -592,6 +632,15 @@ class Database: WHERE matcher_version = 'legacy_pre_outcome_guard' AND match_status = 'accepted' """) + unique_markets = await conn.fetchrow(""" + SELECT + COUNT(DISTINCT poly_market_id) AS evaluated, + COUNT(DISTINCT poly_market_id) FILTER ( + WHERE match_status = 'accepted' + AND matcher_version = $1 + ) AS accepted + FROM manifold_match_audit + """, MANIFOLD_MATCHER_VERSION) mfld_dominated = await conn.fetchrow(""" SELECT COUNT(*) AS cnt FROM trades WHERE (excluded_from_metrics IS NOT TRUE) @@ -627,6 +676,14 @@ class Database: int(legacy["accepted_without_outcome_type"] or 0), }, "trades_dominated_by_mfld": int(mfld_dominated["cnt"] or 0), + "unique_markets": { + "evaluated": int(unique_markets["evaluated"] or 0), + "accepted": int(unique_markets["accepted"] or 0), + "coverage_rate": ( + float(unique_markets["accepted"]) / float(unique_markets["evaluated"]) + if unique_markets["evaluated"] else None + ), + }, }, "recent_matches": [dict(r) for r in rows], } diff --git a/bot/data/schema.sql b/bot/data/schema.sql index 2fc00db..b02d248 100644 --- a/bot/data/schema.sql +++ b/bot/data/schema.sql @@ -291,3 +291,29 @@ CREATE TABLE IF NOT EXISTS checkpoint_alerts ( fired_at TIMESTAMPTZ NOT NULL, last_fired_at TIMESTAMPTZ ); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Manifold evaluation cooldown — per-market backoff for the Manifold matcher +-- +-- The trading loop re-evaluates the same ~stable set of politics/tech markets +-- every cycle (~60s). Most resolve to a stable terminal verdict (no Manifold +-- coverage, low-score, outcome mismatch, conditional market) that will not change +-- on the next cycle. Re-querying them every minute floods manifold_match_audit +-- with redundant rows and makes the metrics uninterpretable. +-- +-- This table records, per poly_market_id, when the market was last evaluated and +-- the earliest time it should be evaluated again (retry_after). evaluate() in +-- bot/strategy/bayesian.py consults it BEFORE calling the matcher and skips the +-- call (and the audit write) entirely while now() < retry_after. +-- +-- last_status / cooldown_reason are stored for observability only. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS manifold_eval_cooldown ( + poly_market_id TEXT PRIMARY KEY, + last_evaluated_at TIMESTAMPTZ NOT NULL, + last_status TEXT NOT NULL, + retry_after TIMESTAMPTZ NOT NULL, + cooldown_reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_mfld_cooldown_retry ON manifold_eval_cooldown(retry_after); diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index 0bacd41..a7e14ae 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -14,7 +14,7 @@ import logging import math import uuid from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Optional, TYPE_CHECKING from bot.data.polymarket import Market, market_family_key @@ -66,6 +66,34 @@ MANIFOLD_LOGODDS_WEIGHT = 0.6 MAX_NEWS_QUERIES_PER_CYCLE = 5 +# ───────────────────────────────────────────────────────────────────────────── +# Manifold evaluation cooldown +# +# Per-market backoff so the trading loop stops re-querying Manifold (and flooding +# manifold_match_audit) for markets whose verdict is stable. Computed from the +# match result; longer for verdicts that essentially never change. +# no_results → 24 h (Manifold has no market on this topic) +# rejected/low_score → 24 h (best candidate below Jaccard threshold) +# rejected/outcome_mism. → 24 h (outcome types differ) +# rejected/ambiguous → 24 h (party named but inversion unverifiable) +# rejected/conditional → 7 d (premise-gated market; structural, won't change) +# accepted → 1 h (signal is live; refresh probability hourly) +# ───────────────────────────────────────────────────────────────────────────── +def _cooldown_for(result: ManifoldMatchResult) -> tuple[timedelta, str]: + """Map a Manifold match result to (retry_delay, cooldown_reason).""" + if result.status == "accepted": + return timedelta(hours=1), "accepted" + if result.status == "no_results": + return timedelta(hours=24), "no_results" + # rejected — classify by the reason text the matcher produced + reason = result.match_reason or "rejected" + if "conditional_market" in reason: + return timedelta(days=7), reason + # outcome_mismatch, ambiguous_inversion, and low_score (jaccard