feat(manifold): add persistent cooldowns to reduce redundant evaluations
CI/CD / build-and-push (push) Successful in 8s

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) <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-06-08 12:19:31 +00:00
co-authored by Claude Opus 4.8
parent df988a36b6
commit 98abd96fd2
4 changed files with 212 additions and 59 deletions
+4
View File
@@ -226,6 +226,10 @@ async def get_manifold_matches():
summary.trades_dominated_by_mfld — non-excluded accepted-match trades where summary.trades_dominated_by_mfld — non-excluded accepted-match trades where
feat_mfld_lo is the largest signal (consistent with attribution/features, feat_mfld_lo is the largest signal (consistent with attribution/features,
which also exclude excluded_from_metrics trades). 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 recent_matches: last 50 rows from manifold_match_audit, newest first, each
tagged with matcher_version. tagged with matcher_version.
+57
View File
@@ -553,6 +553,46 @@ class Database:
poly_outcome_type, mfld_outcome_type, matcher_version, 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 def mark_manifold_audit_used(self, audit_id: str) -> None:
async with self._pool.acquire() as conn: async with self._pool.acquire() as conn:
await conn.execute( await conn.execute(
@@ -592,6 +632,15 @@ class Database:
WHERE matcher_version = 'legacy_pre_outcome_guard' WHERE matcher_version = 'legacy_pre_outcome_guard'
AND match_status = 'accepted' 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(""" mfld_dominated = await conn.fetchrow("""
SELECT COUNT(*) AS cnt FROM trades SELECT COUNT(*) AS cnt FROM trades
WHERE (excluded_from_metrics IS NOT TRUE) WHERE (excluded_from_metrics IS NOT TRUE)
@@ -627,6 +676,14 @@ class Database:
int(legacy["accepted_without_outcome_type"] or 0), int(legacy["accepted_without_outcome_type"] or 0),
}, },
"trades_dominated_by_mfld": int(mfld_dominated["cnt"] 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], "recent_matches": [dict(r) for r in rows],
} }
+26
View File
@@ -291,3 +291,29 @@ CREATE TABLE IF NOT EXISTS checkpoint_alerts (
fired_at TIMESTAMPTZ NOT NULL, fired_at TIMESTAMPTZ NOT NULL,
last_fired_at TIMESTAMPTZ 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);
+67 -1
View File
@@ -14,7 +14,7 @@ import logging
import math import math
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from bot.data.polymarket import Market, market_family_key from bot.data.polymarket import Market, market_family_key
@@ -66,6 +66,34 @@ MANIFOLD_LOGODDS_WEIGHT = 0.6
MAX_NEWS_QUERIES_PER_CYCLE = 5 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<threshold)
# all settle in 24 h.
return timedelta(hours=24), reason
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Phase 4 — Regime-based minimum edge (uses edge_NET, not edge_gross) # Phase 4 — Regime-based minimum edge (uses edge_NET, not edge_gross)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -444,6 +472,29 @@ class BayesianStrategy:
audit_id: Optional[str] = None audit_id: Optional[str] = None
if (is_politics or is_tech) and self._manifold is not None: if (is_politics or is_tech) and self._manifold is not None:
# ── Cooldown gate ────────────────────────────────────────────────
# Skip markets whose Manifold verdict was recently settled to a
# stable value. A skip is equivalent to a no-signal: the matcher is
# NOT called and NO manifold_match_audit row is written, so only real
# evaluations are recorded. See _cooldown_for() and the
# manifold_eval_cooldown table.
in_cooldown = False
if self._db is not None and market.id:
try:
cd = await self._db.get_manifold_cooldown(market.id)
except Exception as exc:
log.warning("Failed to read manifold cooldown: %s", exc)
cd = None
if cd is not None and datetime.now(timezone.utc) < cd["retry_after"]:
in_cooldown = True
log.info(
"MANIFOLD_COOLDOWN skip market=%s | last_status=%s "
"retry_after=%s | %s",
market.id, cd["last_status"],
cd["retry_after"].isoformat(), market.question[:50],
)
if not in_cooldown:
manifold_result = await self._manifold.get_match(market.question) manifold_result = await self._manifold.get_match(market.question)
# Persist audit record for ALL outcomes (accepted / rejected / no_results) # Persist audit record for ALL outcomes (accepted / rejected / no_results)
@@ -478,6 +529,21 @@ class BayesianStrategy:
log.warning("Failed to save manifold audit: %s", exc) log.warning("Failed to save manifold audit: %s", exc)
audit_id = None audit_id = None
# Record the cooldown so this market is not re-queried every
# cycle. Written even if the audit save above failed — we
# still performed a real evaluation.
if market.id:
delay, cd_reason = _cooldown_for(manifold_result)
try:
await self._db.upsert_manifold_cooldown(
poly_market_id=market.id,
last_status=manifold_result.status,
retry_after=datetime.now(timezone.utc) + delay,
cooldown_reason=cd_reason,
)
except Exception as exc:
log.warning("Failed to save manifold cooldown: %s", exc)
# Structured log — both forms for compatibility # Structured log — both forms for compatibility
log.info( log.info(
"MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s" "MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s"