feat(manifold): add persistent cooldowns to reduce redundant evaluations
CI/CD / build-and-push (push) Successful in 8s
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:
co-authored by
Claude Opus 4.8
parent
df988a36b6
commit
98abd96fd2
@@ -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.
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+125
-59
@@ -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,68 +472,106 @@ 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:
|
||||||
manifold_result = await self._manifold.get_match(market.question)
|
# ── Cooldown gate ────────────────────────────────────────────────
|
||||||
|
# Skip markets whose Manifold verdict was recently settled to a
|
||||||
# Persist audit record for ALL outcomes (accepted / rejected / no_results)
|
# stable value. A skip is equivalent to a no-signal: the matcher is
|
||||||
if self._db is not None:
|
# NOT called and NO manifold_match_audit row is written, so only real
|
||||||
if not market.id:
|
# evaluations are recorded. See _cooldown_for() and the
|
||||||
log.error(
|
# manifold_eval_cooldown table.
|
||||||
"MANIFOLD_AUDIT: market.id is None/empty — skipping audit save | "
|
in_cooldown = False
|
||||||
"question=%r", market.question[:60],
|
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],
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
audit_id = str(uuid.uuid4())
|
if not in_cooldown:
|
||||||
try:
|
manifold_result = await self._manifold.get_match(market.question)
|
||||||
await self._db.save_manifold_audit(
|
|
||||||
audit_id=audit_id,
|
# Persist audit record for ALL outcomes (accepted / rejected / no_results)
|
||||||
poly_market_id=market.id,
|
if self._db is not None:
|
||||||
poly_question=market.question,
|
if not market.id:
|
||||||
search_query=manifold_result.search_query,
|
log.error(
|
||||||
mfld_market_id=manifold_result.market_id,
|
"MANIFOLD_AUDIT: market.id is None/empty — skipping audit save | "
|
||||||
mfld_market_title=manifold_result.market_title,
|
"question=%r", market.question[:60],
|
||||||
mfld_market_url=manifold_result.market_url,
|
|
||||||
prob_raw=manifold_result.prob_raw,
|
|
||||||
prob_final=manifold_result.prob_final,
|
|
||||||
inverted=manifold_result.inverted,
|
|
||||||
match_score=manifold_result.match_score,
|
|
||||||
match_reason=manifold_result.match_reason,
|
|
||||||
match_status=manifold_result.status,
|
|
||||||
poly_outcome_type=manifold_result.poly_outcome_type,
|
|
||||||
mfld_outcome_type=manifold_result.mfld_outcome_type,
|
|
||||||
matcher_version=MANIFOLD_MATCHER_VERSION,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
else:
|
||||||
log.warning("Failed to save manifold audit: %s", exc)
|
audit_id = str(uuid.uuid4())
|
||||||
audit_id = None
|
try:
|
||||||
|
await self._db.save_manifold_audit(
|
||||||
|
audit_id=audit_id,
|
||||||
|
poly_market_id=market.id,
|
||||||
|
poly_question=market.question,
|
||||||
|
search_query=manifold_result.search_query,
|
||||||
|
mfld_market_id=manifold_result.market_id,
|
||||||
|
mfld_market_title=manifold_result.market_title,
|
||||||
|
mfld_market_url=manifold_result.market_url,
|
||||||
|
prob_raw=manifold_result.prob_raw,
|
||||||
|
prob_final=manifold_result.prob_final,
|
||||||
|
inverted=manifold_result.inverted,
|
||||||
|
match_score=manifold_result.match_score,
|
||||||
|
match_reason=manifold_result.match_reason,
|
||||||
|
match_status=manifold_result.status,
|
||||||
|
poly_outcome_type=manifold_result.poly_outcome_type,
|
||||||
|
mfld_outcome_type=manifold_result.mfld_outcome_type,
|
||||||
|
matcher_version=MANIFOLD_MATCHER_VERSION,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to save manifold audit: %s", exc)
|
||||||
|
audit_id = None
|
||||||
|
|
||||||
# Structured log — both forms for compatibility
|
# Record the cooldown so this market is not re-queried every
|
||||||
log.info(
|
# cycle. Written even if the audit save above failed — we
|
||||||
"MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s"
|
# still performed a real evaluation.
|
||||||
" inverted=%s status=%s reason=%s",
|
if market.id:
|
||||||
market.question, manifold_result.market_title,
|
delay, cd_reason = _cooldown_for(manifold_result)
|
||||||
manifold_result.match_score, manifold_result.prob_raw,
|
try:
|
||||||
manifold_result.prob_final, manifold_result.inverted,
|
await self._db.upsert_manifold_cooldown(
|
||||||
manifold_result.status, manifold_result.match_reason,
|
poly_market_id=market.id,
|
||||||
)
|
last_status=manifold_result.status,
|
||||||
log.info("MANIFOLD_MATCH", extra={
|
retry_after=datetime.now(timezone.utc) + delay,
|
||||||
"poly_question": market.question,
|
cooldown_reason=cd_reason,
|
||||||
"mfld_title": manifold_result.market_title,
|
)
|
||||||
"score": manifold_result.match_score,
|
except Exception as exc:
|
||||||
"prob_raw": manifold_result.prob_raw,
|
log.warning("Failed to save manifold cooldown: %s", exc)
|
||||||
"prob_final": manifold_result.prob_final,
|
|
||||||
"inverted": manifold_result.inverted,
|
|
||||||
"status": manifold_result.status,
|
|
||||||
"reason": manifold_result.match_reason,
|
|
||||||
})
|
|
||||||
|
|
||||||
if manifold_result.status == "accepted" and manifold_result.prob_final is not None:
|
# Structured log — both forms for compatibility
|
||||||
manifold_used = True
|
log.info(
|
||||||
self._manifold_fetched += 1
|
"MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s"
|
||||||
m_clamped = max(0.05, min(0.95, manifold_result.prob_final))
|
" inverted=%s status=%s reason=%s",
|
||||||
m_log = math.log(m_clamped / (1 - m_clamped))
|
market.question, manifold_result.market_title,
|
||||||
p_log = math.log(prior / (1 - prior))
|
manifold_result.match_score, manifold_result.prob_raw,
|
||||||
manifold_log_adj = (m_log - p_log) * MANIFOLD_LOGODDS_WEIGHT
|
manifold_result.prob_final, manifold_result.inverted,
|
||||||
sources.append(f"Manifold:{manifold_result.prob_final:.2f}")
|
manifold_result.status, manifold_result.match_reason,
|
||||||
|
)
|
||||||
|
log.info("MANIFOLD_MATCH", extra={
|
||||||
|
"poly_question": market.question,
|
||||||
|
"mfld_title": manifold_result.market_title,
|
||||||
|
"score": manifold_result.match_score,
|
||||||
|
"prob_raw": manifold_result.prob_raw,
|
||||||
|
"prob_final": manifold_result.prob_final,
|
||||||
|
"inverted": manifold_result.inverted,
|
||||||
|
"status": manifold_result.status,
|
||||||
|
"reason": manifold_result.match_reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
if manifold_result.status == "accepted" and manifold_result.prob_final is not None:
|
||||||
|
manifold_used = True
|
||||||
|
self._manifold_fetched += 1
|
||||||
|
m_clamped = max(0.05, min(0.95, manifold_result.prob_final))
|
||||||
|
m_log = math.log(m_clamped / (1 - m_clamped))
|
||||||
|
p_log = math.log(prior / (1 - prior))
|
||||||
|
manifold_log_adj = (m_log - p_log) * MANIFOLD_LOGODDS_WEIGHT
|
||||||
|
sources.append(f"Manifold:{manifold_result.prob_final:.2f}")
|
||||||
|
|
||||||
# Confidence cap: macro/politics/tech signals are weaker proxies
|
# Confidence cap: macro/politics/tech signals are weaker proxies
|
||||||
confidence_cap = 0.65 if (is_macro or is_politics or is_tech or is_events) else 0.90
|
confidence_cap = 0.65 if (is_macro or is_politics or is_tech or is_events) else 0.90
|
||||||
|
|||||||
Reference in New Issue
Block a user