diff --git a/api/main.py b/api/main.py index a365c0b..5a0af27 100644 --- a/api/main.py +++ b/api/main.py @@ -209,6 +209,28 @@ async def get_attribution(): return {"attribution": attribution, "total_attributed_trades": total} +@app.get("/api/metrics/manifold-matches") +async def get_manifold_matches(): + """Manifold match audit — summary stats and recent match attempts. + + summary: + total_accepted — matches accepted (score >= 0.40, inversion unambiguous) + total_rejected — matches rejected (low score or ambiguous inversion) + total_no_results — no Manifold market found or API error + avg_match_score — mean Jaccard score for accepted matches + trades_dominated_by_mfld — open trades where feat_mfld_lo is the largest signal + + recent_matches: last 50 rows from manifold_match_audit, newest first. + used_in_trade=True only when status='accepted' AND a trade was actually executed. + """ + data = await db.get_manifold_matches(limit=50) + for match in data["recent_matches"]: + ts = match.get("timestamp") + if ts is not None and hasattr(ts, "isoformat"): + match["timestamp"] = ts.isoformat() + return data + + @app.get("/api/summary") async def get_summary(): """Dashboard summary card data. diff --git a/bot/data/db.py b/bot/data/db.py index fd54d66..e06d882 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -36,11 +36,15 @@ class Database: entry_price, shares, fee_usdc, net_cost, timestamp, reasoning, paper, edge_gross, edge_net, prior_prob, final_prob, mid_price, spread_estimate, commission, family_key, - feat_fg_lo, feat_mom_lo, feat_news_lo, feat_mfld_lo, feat_btc_dom_lo + feat_fg_lo, feat_mom_lo, feat_news_lo, feat_mfld_lo, feat_btc_dom_lo, + mfld_market_id, mfld_market_title, mfld_market_url, + mfld_prob_raw, mfld_prob_final, mfld_inverted, + mfld_match_score, mfld_match_reason, mfld_match_status ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, $13,$14,$15,$16,$17,$18,$19,$20, - $21,$22,$23,$24,$25 + $21,$22,$23,$24,$25, + $26,$27,$28,$29,$30,$31,$32,$33,$34 ) ON CONFLICT (id) DO NOTHING """, @@ -53,6 +57,10 @@ class Database: # Phase 6 feature log-odds trade.feat_fg_lo, trade.feat_mom_lo, trade.feat_news_lo, trade.feat_mfld_lo, trade.feat_btc_dom_lo, + # Manifold audit fields + trade.mfld_market_id, trade.mfld_market_title, trade.mfld_market_url, + trade.mfld_prob_raw, trade.mfld_prob_final, trade.mfld_inverted, + trade.mfld_match_score, trade.mfld_match_reason, trade.mfld_match_status, ) async def save_daily_metrics(self, metrics: dict) -> None: @@ -493,6 +501,79 @@ class Database: return result + async def save_manifold_audit( + self, + audit_id: str, + poly_market_id: str, + poly_question: str, + search_query: str, + mfld_market_id: Optional[str], + mfld_market_title: Optional[str], + mfld_market_url: Optional[str], + prob_raw: Optional[float], + prob_final: Optional[float], + inverted: bool, + match_score: Optional[float], + match_reason: Optional[str], + match_status: str, + ) -> None: + async with self._pool.acquire() as conn: + await conn.execute(""" + INSERT INTO manifold_match_audit ( + id, poly_market_id, poly_question, search_query, + mfld_market_id, mfld_market_title, mfld_market_url, + prob_raw, prob_final, inverted, + match_score, match_reason, match_status, used_in_trade + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,FALSE) + """, + audit_id, poly_market_id, poly_question, search_query, + mfld_market_id, mfld_market_title, mfld_market_url, + prob_raw, prob_final, inverted, + match_score, match_reason, match_status, + ) + + async def mark_manifold_audit_used(self, audit_id: str) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + "UPDATE manifold_match_audit SET used_in_trade = TRUE WHERE id = $1", + audit_id, + ) + + async def get_manifold_matches(self, limit: int = 50) -> dict: + async with self._pool.acquire() as conn: + summary = await conn.fetchrow(""" + SELECT + COUNT(*) FILTER (WHERE match_status = 'accepted') AS total_accepted, + COUNT(*) FILTER (WHERE match_status = 'rejected') AS total_rejected, + COUNT(*) FILTER (WHERE match_status = 'no_results') AS total_no_results, + AVG(match_score) FILTER (WHERE match_status = 'accepted') AS avg_match_score + FROM manifold_match_audit + """) + mfld_dominated = await conn.fetchrow(""" + SELECT COUNT(*) AS cnt FROM trades + WHERE feat_mfld_lo IS NOT NULL + AND ABS(feat_mfld_lo) > 0.0001 + AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_fg_lo, 0)) + AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_mom_lo, 0)) + AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_news_lo, 0)) + AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_btc_dom_lo, 0)) + """) + rows = await conn.fetch( + "SELECT * FROM manifold_match_audit ORDER BY timestamp DESC LIMIT $1", + limit, + ) + return { + "summary": { + "total_accepted": int(summary["total_accepted"] or 0), + "total_rejected": int(summary["total_rejected"] or 0), + "total_no_results": int(summary["total_no_results"] or 0), + "avg_match_score": _f(summary["avg_match_score"]), + "trades_dominated_by_mfld": int(mfld_dominated["cnt"] or 0), + }, + "recent_matches": [dict(r) for r in rows], + } + + def _f(v) -> Optional[float]: """None-safe float cast for asyncpg Decimal/None values.""" return float(v) if v is not None else None diff --git a/bot/data/manifold.py b/bot/data/manifold.py index d46294f..445b5f1 100644 --- a/bot/data/manifold.py +++ b/bot/data/manifold.py @@ -2,24 +2,24 @@ 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. +by keyword overlap and returns a ManifoldMatchResult with full audit metadata. -Inversion guard: if the Manifold market's winning side (Republican / Democrat) -is the complement of the Polymarket question's winning side, the probability is -automatically inverted (1 - prob). This prevents "Democrats win Ohio governor" -from consuming the probability of a Manifold market titled "Republicans win Ohio -governor" without adjustment. +Match threshold: >= 0.40 Jaccard overlap (raised from 0.25 for stricter semantics). -Rejection guard: if the match score falls below _MATCH_THRESHOLD the market is -rejected, even if inversion would otherwise apply. All decisions are logged at -INFO so they can be audited per-cycle. +Inversion guard (conservative): + - If Polymarket question names a party (democrat/republican) AND the matched + Manifold market names the OPPOSITE party → invert probability (1 - prob). + - If Polymarket question names a party AND Manifold market has NO party keyword + → reject with reason='ambiguous_inversion' (can't determine if inversion applies). + - All other cases: no inversion, accept if score >= threshold. + - Ante duda, reject. -Cache TTL: 30 minutes (Manifold markets move slowly vs our 60 s cycle). -Match threshold: >= 0.25 keyword overlap ratio between significant tokens. +Cache TTL: 30 minutes. """ import logging import re import time +from dataclasses import dataclass, field from typing import Optional import httpx @@ -29,7 +29,7 @@ CACHE_TTL_SEC = 1800 # 30 minutes log = logging.getLogger(__name__) -_MATCH_THRESHOLD = 0.25 +_MATCH_THRESHOLD = 0.40 # raised from 0.25 _STOP_WORDS = frozenset([ "will", "the", "a", "an", "is", "are", "was", "were", "be", "been", @@ -43,9 +43,22 @@ _STOP_WORDS = frozenset([ "before", "during", "until", "against", "between", "through", ]) -# Mutually exclusive political parties used for complement detection _REPUBLICAN_WORDS = frozenset(["republican", "republicans", "gop"]) -_DEMOCRAT_WORDS = frozenset(["democrat", "democrats", "democratic"]) +_DEMOCRAT_WORDS = frozenset(["democrat", "democrats", "democratic"]) + + +@dataclass +class ManifoldMatchResult: + status: str # 'accepted' | 'rejected' | 'no_results' + prob_final: Optional[float] = None + prob_raw: Optional[float] = None + market_id: Optional[str] = None # Manifold internal market ID + market_title: Optional[str] = None + market_url: Optional[str] = None + match_score: Optional[float] = None # 0-1 Jaccard + match_reason: Optional[str] = None # human-readable explanation + inverted: bool = False + search_query: str = "" def _significant_words(text: str) -> set[str]: @@ -69,27 +82,14 @@ def _detect_party(text: str) -> Optional[str]: return None -def _best_match_with_audit( - poly_question: str, - results: list[dict], -) -> tuple[Optional[dict], float, bool]: - """ - Find the best-matching open binary Manifold market. - - Returns (match, score, needs_inversion): - match — best result dict, or None if below threshold - score — keyword overlap score of best candidate (even if rejected) - needs_inversion — True when Manifold market favours the OPPOSITE party/side - to the Polymarket question (probability should be 1 - prob) - """ +def _find_best_candidate(poly_question: str, results: list[dict]) -> tuple[Optional[dict], float]: + """Find the highest-scoring open binary Manifold market by Jaccard overlap.""" poly_words = _significant_words(poly_question) - poly_party = _detect_party(poly_question) if not poly_words: - return None, 0.0, False + return None, 0.0 best_score = 0.0 best: Optional[dict] = None - best_needs_inv = False for result in results: if result.get("outcomeType") != "BINARY": @@ -106,18 +106,14 @@ def _best_match_with_audit( if score > best_score: best_score = score best = result - manifold_party = _detect_party(title) - # Inversion is warranted only when both sides are unambiguously detected - # and they are confirmed opposites (republican ≠ democrat). - best_needs_inv = ( - poly_party is not None - and manifold_party is not None - and poly_party != manifold_party - ) - if best_score >= _MATCH_THRESHOLD and best is not None: - return best, best_score, best_needs_inv - return None, best_score, False + return best, best_score + + +def _market_url(match: dict) -> Optional[str]: + slug = match.get("slug", "") + creator = match.get("creatorUsername", "") + return f"https://manifold.markets/{creator}/{slug}" if slug else None class ManifoldClient: @@ -125,17 +121,16 @@ class ManifoldClient: 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]]] = {} + # question → (fetched_at_monotonic, ManifoldMatchResult) + self._cache: dict[str, tuple[float, ManifoldMatchResult]] = {} - async def get_probability(self, question: str) -> Optional[float]: + async def get_match(self, question: str) -> ManifoldMatchResult: """ - Return Manifold probability for a matching market, or None. + Return a ManifoldMatchResult for the given Polymarket question. - Probability is already adjusted for party-direction inversion when - the matched Manifold market is the complement of our question. - - Full audit log is emitted at INFO for every resolved query. + status='accepted' → prob_final is set and ready to use as signal + status='rejected' → match found but failed quality/inversion check + status='no_results' → API returned no results or call failed """ now = time.monotonic() cached = self._cache.get(question) @@ -144,8 +139,9 @@ class ManifoldClient: query = _build_search_query(question) if not query: - self._cache[question] = (now, None) - return None + result = ManifoldMatchResult(status="no_results", search_query="") + self._cache[question] = (now, result) + return result try: resp = await self._client.get( @@ -154,45 +150,116 @@ class ManifoldClient: ) resp.raise_for_status() results = resp.json() - except Exception as e: - log.warning("Manifold API error for %r: %s", question[:40], e) - self._cache[question] = (now, None) - return None + except Exception as exc: + log.warning("Manifold API error for %r: %s", question[:40], exc) + result = ManifoldMatchResult(status="no_results", search_query=query) + self._cache[question] = (now, result) + return result - match, score, needs_inv = _best_match_with_audit(question, results) + if not results: + result = ManifoldMatchResult(status="no_results", search_query=query) + self._cache[question] = (now, result) + return result - if match is None: + best, score = _find_best_candidate(question, results) + + # ── Score threshold ─────────────────────────────────────────────────── + if best is None or score < _MATCH_THRESHOLD: + reason = f"jaccard={score:.2f}<{_MATCH_THRESHOLD:.2f}" log.info( - "Manifold no_match: %-50s | best_score=%.2f < %.2f | query=%r", + "Manifold REJECTED %-50s | score=%.2f < threshold=%.2f | query=%r", question[:50], score, _MATCH_THRESHOLD, query, ) - self._cache[question] = (now, None) - return None + result = ManifoldMatchResult( + status="rejected", + market_title=best.get("question") if best else None, + match_score=score if best else None, + match_reason=reason, + search_query=query, + ) + self._cache[question] = (now, result) + return result - prob_raw = float(match["probability"]) - prob_final = (1.0 - prob_raw) if needs_inv else prob_raw + # ── Inversion analysis (conservative) ──────────────────────────────── + poly_party = _detect_party(question) + manifold_party = _detect_party(best.get("question", "")) - # Build market URL from slug (best-effort; may be missing) - slug = match.get("slug", "") - creator = match.get("creatorUsername", "") - url = f"https://manifold.markets/{creator}/{slug}" if slug else "n/a" + poly_words = _significant_words(question) + mfld_words = _significant_words(best.get("question", "")) + matched_tokens = sorted(poly_words & mfld_words)[:6] + + inverted = False + rejection_reason: Optional[str] = None + + if poly_party is not None: + if manifold_party is None: + # Poly specifies a party; Manifold does not → can't verify inversion safety + rejection_reason = ( + f"ambiguous_inversion: poly_party={poly_party}, mfld_party=none" + ) + elif manifold_party != poly_party: + # Clear opposite parties — apply inversion + inverted = True + # manifold_party == poly_party → same party, no inversion needed + + if rejection_reason is not None: + url = _market_url(best) + log.info( + "Manifold REJECTED %-50s | score=%.2f | reason=%s\n" + " mfld_title: %s", + question[:50], score, rejection_reason, best.get("question", "")[:70], + ) + result = ManifoldMatchResult( + status="rejected", + market_id=str(best.get("id", "")) or None, + market_title=best.get("question"), + market_url=url, + match_score=score, + match_reason=( + f"jaccard={score:.2f}, tokens={matched_tokens}, {rejection_reason}" + ), + search_query=query, + ) + self._cache[question] = (now, result) + return result + + # ── Accepted ────────────────────────────────────────────────────────── + prob_raw = float(best["probability"]) + prob_final = (1.0 - prob_raw) if inverted else prob_raw + url = _market_url(best) + + match_reason = f"jaccard={score:.2f}, tokens={matched_tokens}" + if inverted: + match_reason += f", inverted=party({poly_party}≠{manifold_party})" log.info( - "Manifold %s: %-50s\n" - " poly_question: %s\n" - " manifold_title: %s\n" - " manifold_url: %s\n" - " match_score: %.2f | prob_raw=%.3f | inverted=%s | prob_final=%.3f", - "MATCH_INVERTED" if needs_inv else "MATCH", + "Manifold %s %-50s\n" + " poly: %s\n" + " mfld: %s\n" + " url: %s\n" + " score=%.2f | raw=%.3f | inverted=%s | final=%.3f", + "ACCEPTED_INVERTED" if inverted else "ACCEPTED ", question[:50], question, - match.get("question", ""), - url, - score, prob_raw, needs_inv, prob_final, + best.get("question", ""), + url or "n/a", + score, prob_raw, inverted, prob_final, ) - self._cache[question] = (now, prob_final) - return prob_final + result = ManifoldMatchResult( + status="accepted", + prob_final=prob_final, + prob_raw=prob_raw, + market_id=str(best.get("id", "")) or None, + market_title=best.get("question"), + market_url=url, + match_score=score, + match_reason=match_reason, + inverted=inverted, + search_query=query, + ) + self._cache[question] = (now, result) + return result async def close(self) -> None: await self._client.aclose() diff --git a/bot/data/schema.sql b/bot/data/schema.sql index cc0d79e..142ac39 100644 --- a/bot/data/schema.sql +++ b/bot/data/schema.sql @@ -168,6 +168,52 @@ ALTER TABLE trades ADD COLUMN IF NOT EXISTS feat_btc_dom_lo DOUBLE PRECISION; CREATE INDEX IF NOT EXISTS idx_trades_feat_fg ON trades(feat_fg_lo) WHERE feat_fg_lo IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_trades_feat_mfld ON trades(feat_mfld_lo) WHERE feat_mfld_lo IS NOT NULL; +-- ───────────────────────────────────────────────────────────────────────────── +-- Manifold match audit — per-trade columns in trades +-- +-- Persisted for every trade where Manifold was queried (status='accepted'). +-- mfld_match_status: 'accepted' | 'rejected' | 'no_results' +-- mfld_inverted: TRUE when prob_final = 1 - prob_raw (party complement match) +-- ───────────────────────────────────────────────────────────────────────────── +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_id TEXT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_title TEXT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_url TEXT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_prob_raw DOUBLE PRECISION; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_prob_final DOUBLE PRECISION; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_inverted BOOLEAN; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_score DOUBLE PRECISION; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_reason TEXT; +ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_status TEXT; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Manifold match audit table — records every Manifold query attempt +-- +-- Populated for ALL queries: accepted, rejected, and no_results. +-- used_in_trade=TRUE is set after executor confirms a trade was executed. +-- poly_market_id: Market.id from the Polymarket Market dataclass (never NULL). +-- ───────────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS manifold_match_audit ( + id TEXT PRIMARY KEY, + timestamp TIMESTAMPTZ DEFAULT NOW(), + poly_market_id TEXT NOT NULL, + poly_question TEXT NOT NULL, + search_query TEXT, + mfld_market_id TEXT, + mfld_market_title TEXT, + mfld_market_url TEXT, + prob_raw DOUBLE PRECISION, + prob_final DOUBLE PRECISION, + inverted BOOLEAN DEFAULT FALSE, + match_score DOUBLE PRECISION, + match_reason TEXT, + match_status TEXT NOT NULL, + used_in_trade BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_mfld_audit_timestamp ON manifold_match_audit(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_mfld_audit_status ON manifold_match_audit(match_status); +CREATE INDEX IF NOT EXISTS idx_mfld_audit_poly_mkt ON manifold_match_audit(poly_market_id); + -- ───────────────────────────────────────────────────────────────────────────── -- Fix 3: extended metrics_daily columns for DB-computed metrics -- diff --git a/bot/executor/paper.py b/bot/executor/paper.py index 4c80a9c..9e7fc36 100644 --- a/bot/executor/paper.py +++ b/bot/executor/paper.py @@ -57,6 +57,16 @@ class Trade: feat_news_lo: float = 0.0 feat_mfld_lo: float = 0.0 feat_btc_dom_lo: float = 0.0 + # ── Manifold match audit ────────────────────────────────────────────────── + mfld_market_id: Optional[str] = None + mfld_market_title: Optional[str] = None + mfld_market_url: Optional[str] = None + mfld_prob_raw: Optional[float] = None + mfld_prob_final: Optional[float] = None + mfld_inverted: bool = False + mfld_match_score: Optional[float] = None + mfld_match_reason: Optional[str] = None + mfld_match_status: Optional[str] = None def __str__(self) -> str: return ( @@ -176,6 +186,16 @@ class PaperExecutor: feat_news_lo=order.feat_news_lo, feat_mfld_lo=order.feat_mfld_lo, feat_btc_dom_lo=order.feat_btc_dom_lo, + # Manifold audit + mfld_market_id=order.mfld_market_id, + mfld_market_title=order.mfld_market_title, + mfld_market_url=order.mfld_market_url, + mfld_prob_raw=order.mfld_prob_raw, + mfld_prob_final=order.mfld_prob_final, + mfld_inverted=order.mfld_inverted, + mfld_match_score=order.mfld_match_score, + mfld_match_reason=order.mfld_match_reason, + mfld_match_status=order.mfld_match_status, ) # Update paper portfolio diff --git a/bot/main.py b/bot/main.py index bd9b05c..af3d1a8 100644 --- a/bot/main.py +++ b/bot/main.py @@ -141,6 +141,12 @@ async def run_trading_loop( # Block this family for the rest of the cycle (Phase 2) occupied_families.add(signal.family_key) cycle_trades += 1 + # Mark manifold audit record as used in this trade + if signal.mfld_audit_id: + try: + await db.mark_manifold_audit_used(signal.mfld_audit_id) + except Exception as exc: + log.warning("Failed to mark manifold audit used: %s", exc) # 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare stats = strategy.get_cycle_stats() @@ -375,7 +381,7 @@ async def main() -> None: external = ExternalDataClient() news = NewsClient() manifold = ManifoldClient() - strategy = BayesianStrategy(news=news, manifold=manifold) + strategy = BayesianStrategy(news=news, manifold=manifold, db=db) risk = RiskManager(max_position_pct=0.05, max_exposure_pct=0.30) executor = PaperExecutor(db=db, bankroll=PAPER_BANKROLL) if PAPER_MODE else None metrics = MetricsTracker(db=db) diff --git a/bot/risk/manager.py b/bot/risk/manager.py index ed07034..c5bcd5c 100644 --- a/bot/risk/manager.py +++ b/bot/risk/manager.py @@ -62,6 +62,17 @@ class Order: feat_news_lo: float = 0.0 feat_mfld_lo: float = 0.0 feat_btc_dom_lo: float = 0.0 + # Manifold audit fields (propagated from TradingSignal → Trade → DB) + mfld_audit_id: Optional[str] = None + mfld_market_id: Optional[str] = None + mfld_market_title: Optional[str] = None + mfld_market_url: Optional[str] = None + mfld_prob_raw: Optional[float] = None + mfld_prob_final: Optional[float] = None + mfld_inverted: bool = False + mfld_match_score: Optional[float] = None + mfld_match_reason: Optional[str] = None + mfld_match_status: Optional[str] = None class RiskManager: @@ -159,4 +170,15 @@ class RiskManager: feat_news_lo=signal.feat_news_lo, feat_mfld_lo=signal.feat_mfld_lo, feat_btc_dom_lo=signal.feat_btc_dom_lo, + # Manifold audit + mfld_audit_id=signal.mfld_audit_id, + mfld_market_id=signal.mfld_market_id, + mfld_market_title=signal.mfld_market_title, + mfld_market_url=signal.mfld_market_url, + mfld_prob_raw=signal.mfld_prob_raw, + mfld_prob_final=signal.mfld_prob_final, + mfld_inverted=signal.mfld_inverted, + mfld_match_score=signal.mfld_match_score, + mfld_match_reason=signal.mfld_match_reason, + mfld_match_status=signal.mfld_match_status, ) diff --git a/bot/strategy/bayesian.py b/bot/strategy/bayesian.py index 46ea5c9..007564e 100644 --- a/bot/strategy/bayesian.py +++ b/bot/strategy/bayesian.py @@ -12,16 +12,19 @@ Polymarket might reflect in a slow-moving order book. """ import logging import math +import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from bot.data.polymarket import Market, market_family_key from bot.data.external import ExternalSignals +from bot.data.manifold import ManifoldMatchResult if TYPE_CHECKING: from bot.data.news import NewsClient from bot.data.manifold import ManifoldClient + from bot.data.db import Database log = logging.getLogger(__name__) @@ -170,6 +173,19 @@ class TradingSignal: feat_news_lo: float = 0.0 feat_mfld_lo: float = 0.0 feat_btc_dom_lo: float = 0.0 + # ── Manifold match audit (propagated → Order → Trade → DB) ─────────────── + # mfld_audit_id: UUID of the manifold_match_audit row; used to mark + # used_in_trade=TRUE after executor confirms the trade was executed. + mfld_audit_id: Optional[str] = None + mfld_market_id: Optional[str] = None + mfld_market_title: Optional[str] = None + mfld_market_url: Optional[str] = None + mfld_prob_raw: Optional[float] = None + mfld_prob_final: Optional[float] = None + mfld_inverted: bool = False + mfld_match_score: Optional[float] = None + mfld_match_reason: Optional[str] = None + mfld_match_status: Optional[str] = None class BayesianStrategy: @@ -201,10 +217,12 @@ class BayesianStrategy: self, news: Optional["NewsClient"] = None, manifold: Optional["ManifoldClient"] = None, + db: Optional["Database"] = None, ) -> None: self._signal_count = 0 self._news = news self._manifold = manifold + self._db = db self._news_queries_this_cycle = 0 # Per-cycle counters — reset by reset_cycle(), read by get_cycle_stats() self._skip_family: int = 0 @@ -419,18 +437,72 @@ class BayesianStrategy: # Signal 5: Manifold cross-market probability (politics + tech) # Applies a log-odds adjustment proportional to divergence from prior. # No query budget — 30 min cache means network cost is paid once per cycle. + # Now uses ManifoldMatchResult for stricter semantic validation and audit. manifold_log_adj = 0.0 manifold_used = False + manifold_result: Optional[ManifoldMatchResult] = None + audit_id: Optional[str] = None + if (is_politics or is_tech) and self._manifold is not None: - manifold_prob = await self._manifold.get_probability(market.question) - if manifold_prob is not None: + manifold_result = await self._manifold.get_match(market.question) + + # Persist audit record for ALL outcomes (accepted / rejected / no_results) + if self._db is not None: + if not market.id: + log.error( + "MANIFOLD_AUDIT: market.id is None/empty — skipping audit save | " + "question=%r", market.question[:60], + ) + else: + audit_id = str(uuid.uuid4()) + 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, + ) + except Exception as exc: + log.warning("Failed to save manifold audit: %s", exc) + audit_id = None + + # Structured log — both forms for compatibility + log.info( + "MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s" + " inverted=%s status=%s reason=%s", + market.question, manifold_result.market_title, + manifold_result.match_score, manifold_result.prob_raw, + manifold_result.prob_final, manifold_result.inverted, + 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_prob)) + 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_prob:.2f}") + sources.append(f"Manifold:{manifold_result.prob_final:.2f}") # 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 @@ -560,6 +632,17 @@ class BayesianStrategy: feat_news_lo=feat_news_lo, feat_mfld_lo=feat_mfld_lo, feat_btc_dom_lo=feat_btc_dom_lo, + # Manifold match audit — propagated through Order → Trade → DB + mfld_audit_id=audit_id, + mfld_market_id=manifold_result.market_id if manifold_result else None, + mfld_market_title=manifold_result.market_title if manifold_result else None, + mfld_market_url=manifold_result.market_url if manifold_result else None, + mfld_prob_raw=manifold_result.prob_raw if manifold_result else None, + mfld_prob_final=manifold_result.prob_final if manifold_result else None, + mfld_inverted=manifold_result.inverted if manifold_result else False, + mfld_match_score=manifold_result.match_score if manifold_result else None, + mfld_match_reason=manifold_result.match_reason if manifold_result else None, + mfld_match_status=manifold_result.status if manifold_result else None, )