feat(manifold): audit matching quality with ManifoldMatchResult and manifold_match_audit table
CI/CD / build-and-push (push) Successful in 14s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-05-27 15:57:48 +00:00
parent ae7c737153
commit 9abaae44fd
8 changed files with 431 additions and 84 deletions
+22
View File
@@ -209,6 +209,28 @@ async def get_attribution():
return {"attribution": attribution, "total_attributed_trades": total} 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") @app.get("/api/summary")
async def get_summary(): async def get_summary():
"""Dashboard summary card data. """Dashboard summary card data.
+83 -2
View File
@@ -36,11 +36,15 @@ class Database:
entry_price, shares, fee_usdc, net_cost, timestamp, reasoning, paper, entry_price, shares, fee_usdc, net_cost, timestamp, reasoning, paper,
edge_gross, edge_net, prior_prob, final_prob, edge_gross, edge_net, prior_prob, final_prob,
mid_price, spread_estimate, commission, family_key, 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 ( ) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
$13,$14,$15,$16,$17,$18,$19,$20, $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 ON CONFLICT (id) DO NOTHING
""", """,
@@ -53,6 +57,10 @@ class Database:
# Phase 6 feature log-odds # Phase 6 feature log-odds
trade.feat_fg_lo, trade.feat_mom_lo, trade.feat_news_lo, trade.feat_fg_lo, trade.feat_mom_lo, trade.feat_news_lo,
trade.feat_mfld_lo, trade.feat_btc_dom_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: async def save_daily_metrics(self, metrics: dict) -> None:
@@ -493,6 +501,79 @@ class Database:
return result 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]: def _f(v) -> Optional[float]:
"""None-safe float cast for asyncpg Decimal/None values.""" """None-safe float cast for asyncpg Decimal/None values."""
return float(v) if v is not None else None return float(v) if v is not None else None
+143 -76
View File
@@ -2,24 +2,24 @@
Manifold Markets client — cross-platform prediction market probability signals. Manifold Markets client — cross-platform prediction market probability signals.
For each Polymarket question, searches Manifold for a matching binary market 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) Match threshold: >= 0.40 Jaccard overlap (raised from 0.25 for stricter semantics).
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.
Rejection guard: if the match score falls below _MATCH_THRESHOLD the market is Inversion guard (conservative):
rejected, even if inversion would otherwise apply. All decisions are logged at - If Polymarket question names a party (democrat/republican) AND the matched
INFO so they can be audited per-cycle. 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). Cache TTL: 30 minutes.
Match threshold: >= 0.25 keyword overlap ratio between significant tokens.
""" """
import logging import logging
import re import re
import time import time
from dataclasses import dataclass, field
from typing import Optional from typing import Optional
import httpx import httpx
@@ -29,7 +29,7 @@ CACHE_TTL_SEC = 1800 # 30 minutes
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_MATCH_THRESHOLD = 0.25 _MATCH_THRESHOLD = 0.40 # raised from 0.25
_STOP_WORDS = frozenset([ _STOP_WORDS = frozenset([
"will", "the", "a", "an", "is", "are", "was", "were", "be", "been", "will", "the", "a", "an", "is", "are", "was", "were", "be", "been",
@@ -43,11 +43,24 @@ _STOP_WORDS = frozenset([
"before", "during", "until", "against", "between", "through", "before", "during", "until", "against", "between", "through",
]) ])
# Mutually exclusive political parties used for complement detection
_REPUBLICAN_WORDS = frozenset(["republican", "republicans", "gop"]) _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]: def _significant_words(text: str) -> set[str]:
words = re.findall(r"[a-zA-Z]+", text.lower()) 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} return {w for w in words if w not in _STOP_WORDS and len(w) >= 3}
@@ -69,27 +82,14 @@ def _detect_party(text: str) -> Optional[str]:
return None return None
def _best_match_with_audit( def _find_best_candidate(poly_question: str, results: list[dict]) -> tuple[Optional[dict], float]:
poly_question: str, """Find the highest-scoring open binary Manifold market by Jaccard overlap."""
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)
"""
poly_words = _significant_words(poly_question) poly_words = _significant_words(poly_question)
poly_party = _detect_party(poly_question)
if not poly_words: if not poly_words:
return None, 0.0, False return None, 0.0
best_score = 0.0 best_score = 0.0
best: Optional[dict] = None best: Optional[dict] = None
best_needs_inv = False
for result in results: for result in results:
if result.get("outcomeType") != "BINARY": if result.get("outcomeType") != "BINARY":
@@ -106,18 +106,14 @@ def _best_match_with_audit(
if score > best_score: if score > best_score:
best_score = score best_score = score
best = result 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
return best, best_score, best_needs_inv
return None, best_score, False
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: class ManifoldClient:
@@ -125,17 +121,16 @@ class ManifoldClient:
def __init__(self) -> None: def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=15) self._client = httpx.AsyncClient(timeout=15)
# question → (fetched_at_monotonic, probability_or_None) # question → (fetched_at_monotonic, ManifoldMatchResult)
self._cache: dict[str, tuple[float, Optional[float]]] = {} 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 status='accepted' → prob_final is set and ready to use as signal
the matched Manifold market is the complement of our question. status='rejected' → match found but failed quality/inversion check
status='no_results' → API returned no results or call failed
Full audit log is emitted at INFO for every resolved query.
""" """
now = time.monotonic() now = time.monotonic()
cached = self._cache.get(question) cached = self._cache.get(question)
@@ -144,8 +139,9 @@ class ManifoldClient:
query = _build_search_query(question) query = _build_search_query(question)
if not query: if not query:
self._cache[question] = (now, None) result = ManifoldMatchResult(status="no_results", search_query="")
return None self._cache[question] = (now, result)
return result
try: try:
resp = await self._client.get( resp = await self._client.get(
@@ -154,45 +150,116 @@ class ManifoldClient:
) )
resp.raise_for_status() resp.raise_for_status()
results = resp.json() results = resp.json()
except Exception as e: except Exception as exc:
log.warning("Manifold API error for %r: %s", question[:40], e) log.warning("Manifold API error for %r: %s", question[:40], exc)
self._cache[question] = (now, None) result = ManifoldMatchResult(status="no_results", search_query=query)
return None 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( 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, question[:50], score, _MATCH_THRESHOLD, query,
) )
self._cache[question] = (now, None) result = ManifoldMatchResult(
return None 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"]) # ── Inversion analysis (conservative) ────────────────────────────────
prob_final = (1.0 - prob_raw) if needs_inv else prob_raw poly_party = _detect_party(question)
manifold_party = _detect_party(best.get("question", ""))
# Build market URL from slug (best-effort; may be missing) poly_words = _significant_words(question)
slug = match.get("slug", "") mfld_words = _significant_words(best.get("question", ""))
creator = match.get("creatorUsername", "") matched_tokens = sorted(poly_words & mfld_words)[:6]
url = f"https://manifold.markets/{creator}/{slug}" if slug else "n/a"
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( log.info(
"Manifold %s: %-50s\n" "Manifold %s %-50s\n"
" poly_question: %s\n" " poly: %s\n"
" manifold_title: %s\n" " mfld: %s\n"
" manifold_url: %s\n" " url: %s\n"
" match_score: %.2f | prob_raw=%.3f | inverted=%s | prob_final=%.3f", " score=%.2f | raw=%.3f | inverted=%s | final=%.3f",
"MATCH_INVERTED" if needs_inv else "MATCH", "ACCEPTED_INVERTED" if inverted else "ACCEPTED ",
question[:50], question[:50],
question, question,
match.get("question", ""), best.get("question", ""),
url, url or "n/a",
score, prob_raw, needs_inv, prob_final, score, prob_raw, inverted, prob_final,
) )
self._cache[question] = (now, prob_final) result = ManifoldMatchResult(
return prob_final 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: async def close(self) -> None:
await self._client.aclose() await self._client.aclose()
+46
View File
@@ -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_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; 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 -- Fix 3: extended metrics_daily columns for DB-computed metrics
-- --
+20
View File
@@ -57,6 +57,16 @@ class Trade:
feat_news_lo: float = 0.0 feat_news_lo: float = 0.0
feat_mfld_lo: float = 0.0 feat_mfld_lo: float = 0.0
feat_btc_dom_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: def __str__(self) -> str:
return ( return (
@@ -176,6 +186,16 @@ class PaperExecutor:
feat_news_lo=order.feat_news_lo, feat_news_lo=order.feat_news_lo,
feat_mfld_lo=order.feat_mfld_lo, feat_mfld_lo=order.feat_mfld_lo,
feat_btc_dom_lo=order.feat_btc_dom_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 # Update paper portfolio
+7 -1
View File
@@ -141,6 +141,12 @@ async def run_trading_loop(
# Block this family for the rest of the cycle (Phase 2) # Block this family for the rest of the cycle (Phase 2)
occupied_families.add(signal.family_key) occupied_families.add(signal.family_key)
cycle_trades += 1 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 # 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
stats = strategy.get_cycle_stats() stats = strategy.get_cycle_stats()
@@ -375,7 +381,7 @@ async def main() -> None:
external = ExternalDataClient() external = ExternalDataClient()
news = NewsClient() news = NewsClient()
manifold = ManifoldClient() 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) risk = RiskManager(max_position_pct=0.05, max_exposure_pct=0.30)
executor = PaperExecutor(db=db, bankroll=PAPER_BANKROLL) if PAPER_MODE else None executor = PaperExecutor(db=db, bankroll=PAPER_BANKROLL) if PAPER_MODE else None
metrics = MetricsTracker(db=db) metrics = MetricsTracker(db=db)
+22
View File
@@ -62,6 +62,17 @@ class Order:
feat_news_lo: float = 0.0 feat_news_lo: float = 0.0
feat_mfld_lo: float = 0.0 feat_mfld_lo: float = 0.0
feat_btc_dom_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: class RiskManager:
@@ -159,4 +170,15 @@ class RiskManager:
feat_news_lo=signal.feat_news_lo, feat_news_lo=signal.feat_news_lo,
feat_mfld_lo=signal.feat_mfld_lo, feat_mfld_lo=signal.feat_mfld_lo,
feat_btc_dom_lo=signal.feat_btc_dom_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,
) )
+87 -4
View File
@@ -12,16 +12,19 @@ Polymarket might reflect in a slow-moving order book.
""" """
import logging import logging
import math import math
import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, 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
from bot.data.external import ExternalSignals from bot.data.external import ExternalSignals
from bot.data.manifold import ManifoldMatchResult
if TYPE_CHECKING: if TYPE_CHECKING:
from bot.data.news import NewsClient from bot.data.news import NewsClient
from bot.data.manifold import ManifoldClient from bot.data.manifold import ManifoldClient
from bot.data.db import Database
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -170,6 +173,19 @@ class TradingSignal:
feat_news_lo: float = 0.0 feat_news_lo: float = 0.0
feat_mfld_lo: float = 0.0 feat_mfld_lo: float = 0.0
feat_btc_dom_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: class BayesianStrategy:
@@ -201,10 +217,12 @@ class BayesianStrategy:
self, self,
news: Optional["NewsClient"] = None, news: Optional["NewsClient"] = None,
manifold: Optional["ManifoldClient"] = None, manifold: Optional["ManifoldClient"] = None,
db: Optional["Database"] = None,
) -> None: ) -> None:
self._signal_count = 0 self._signal_count = 0
self._news = news self._news = news
self._manifold = manifold self._manifold = manifold
self._db = db
self._news_queries_this_cycle = 0 self._news_queries_this_cycle = 0
# Per-cycle counters — reset by reset_cycle(), read by get_cycle_stats() # Per-cycle counters — reset by reset_cycle(), read by get_cycle_stats()
self._skip_family: int = 0 self._skip_family: int = 0
@@ -419,18 +437,72 @@ class BayesianStrategy:
# Signal 5: Manifold cross-market probability (politics + tech) # Signal 5: Manifold cross-market probability (politics + tech)
# Applies a log-odds adjustment proportional to divergence from prior. # Applies a log-odds adjustment proportional to divergence from prior.
# No query budget — 30 min cache means network cost is paid once per cycle. # 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_log_adj = 0.0
manifold_used = False 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: if (is_politics or is_tech) and self._manifold is not None:
manifold_prob = await self._manifold.get_probability(market.question) manifold_result = await self._manifold.get_match(market.question)
if manifold_prob is not None:
# 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 manifold_used = True
self._manifold_fetched += 1 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)) m_log = math.log(m_clamped / (1 - m_clamped))
p_log = math.log(prior / (1 - prior)) p_log = math.log(prior / (1 - prior))
manifold_log_adj = (m_log - p_log) * MANIFOLD_LOGODDS_WEIGHT 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: 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
@@ -560,6 +632,17 @@ class BayesianStrategy:
feat_news_lo=feat_news_lo, feat_news_lo=feat_news_lo,
feat_mfld_lo=feat_mfld_lo, feat_mfld_lo=feat_mfld_lo,
feat_btc_dom_lo=feat_btc_dom_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,
) )