feat(strategy): Manifold cross-market signal + per-feature contribution logging
CI/CD / build-and-push (push) Successful in 2m21s

Signal 5: ManifoldClient queries Manifold Markets API for a matching binary
market by keyword overlap (threshold 0.25) and applies a log-odds adjustment
proportional to the divergence from the Polymarket prior.

  manifold_log_adj = (log_odds(manifold_prob) - log_odds(prior)) × 0.6

A 30pp divergence (Manifold 0.75 vs Poly 0.45) produces edge_gross ≈ 0.19,
clearing the politics far-horizon regime_min=0.12 after costs. Confidence
boosted +0.08 when Manifold match found.

Per-feature observability: every SKIP_EDGE_NET and TRADE log line now includes
  fg=±X.XXX  mom=±X.XXX  mfld=±X.XXXX  news=±X.XXXX
so the contribution of each signal to edge is auditable per market.

Files: bot/data/manifold.py (new), bot/strategy/bayesian.py, bot/main.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-04-17 10:07:47 +00:00
parent 411d346261
commit 0cdb0758c4
3 changed files with 185 additions and 9 deletions
+135
View File
@@ -0,0 +1,135 @@
"""
Manifold Markets client — cross-platform prediction market probability signals.
For each Polymarket question, searches Manifold for a matching binary market
by keyword overlap and returns its probability as a calibration signal.
Used for politics and tech markets where Manifold often has independent
probability estimates that diverge from Polymarket.
Cache TTL: 30 minutes (Manifold markets move slowly vs our 60 s cycle).
Match threshold: >= 0.25 keyword overlap ratio between significant tokens.
Weight choice: MANIFOLD_LOGODDS_WEIGHT = 0.6 in bayesian.py means a 30 pp
divergence (Manifold 0.75 vs Poly 0.45) produces edge_gross ≈ 0.19, which
clears the politics far-horizon regime threshold of 0.12 after costs.
"""
import logging
import re
import time
from typing import Optional
import httpx
MANIFOLD_API = "https://api.manifold.markets/v0"
CACHE_TTL_SEC = 1800 # 30 minutes
log = logging.getLogger(__name__)
_MATCH_THRESHOLD = 0.25
_STOP_WORDS = frozenset([
"will", "the", "a", "an", "is", "are", "was", "were", "be", "been",
"by", "in", "on", "at", "to", "for", "of", "and", "or", "not",
"this", "that", "with", "from", "have", "has", "had", "do", "does",
"did", "can", "could", "would", "should", "may", "might", "shall",
"win", "lose", "get", "become", "make", "take", "give", "see",
"any", "who", "what", "when", "where", "which", "how", "over", "under",
"than", "more", "most", "least", "its", "their", "they",
"him", "her", "his", "she", "been", "being", "into", "after",
"before", "during", "until", "against", "between", "through",
])
def _significant_words(text: str) -> set[str]:
words = re.findall(r"[a-zA-Z]+", text.lower())
return {w for w in words if w not in _STOP_WORDS and len(w) >= 3}
def _build_search_query(question: str, max_words: int = 6) -> str:
words = re.findall(r"[a-zA-Z0-9]+", question)
sig = [w for w in words if w.lower() not in _STOP_WORDS and len(w) >= 3]
return " ".join(sig[:max_words])
def _best_match(poly_question: str, results: list[dict]) -> Optional[dict]:
"""Return best-matching open binary Manifold market, or None if below threshold."""
poly_words = _significant_words(poly_question)
if not poly_words:
return None
best_score = 0.0
best: Optional[dict] = None
for result in results:
if result.get("outcomeType") != "BINARY":
continue
prob = result.get("probability")
if prob is None or not (0.02 < float(prob) < 0.98):
continue
title = result.get("question", "")
m_words = _significant_words(title)
if not m_words:
continue
overlap = len(poly_words & m_words)
score = overlap / min(len(poly_words), len(m_words))
if score > best_score:
best_score = score
best = result
if best_score >= _MATCH_THRESHOLD and best is not None:
return best
return None
class ManifoldClient:
"""Async Manifold Markets client for cross-platform probability signals."""
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=15)
# question → (fetched_at_monotonic, probability_or_None)
self._cache: dict[str, tuple[float, Optional[float]]] = {}
async def get_probability(self, question: str) -> Optional[float]:
"""
Return Manifold probability for a matching market, or None.
Searches by keyword overlap. Returns None if no match exceeds
_MATCH_THRESHOLD or on any API error (caller degrades gracefully).
"""
now = time.monotonic()
cached = self._cache.get(question)
if cached and (now - cached[0]) < CACHE_TTL_SEC:
return cached[1]
query = _build_search_query(question)
if not query:
self._cache[question] = (now, None)
return None
try:
resp = await self._client.get(
f"{MANIFOLD_API}/search-markets",
params={"term": query, "limit": 5, "filter": "open"},
)
resp.raise_for_status()
results = resp.json()
match = _best_match(question, results)
prob = float(match["probability"]) if match else None
self._cache[question] = (now, prob)
if prob is not None:
log.info(
"Manifold match: %-50s%.3f | %s",
question[:50], prob, match.get("question", "")[:60],
)
else:
log.debug("Manifold no match for: %s (query=%r)", question[:50], query)
return prob
except Exception as e:
log.warning("Manifold API error for %r: %s", question[:40], e)
self._cache[question] = (now, None)
return None
async def close(self) -> None:
await self._client.aclose()