1 Commits

Author SHA1 Message Date
Renovate Bot d9f3d08456 chore(deps): update dependency pytest-asyncio to v0.26.0 2026-05-25 12:01:35 +00:00
11 changed files with 89 additions and 917 deletions
-22
View File
@@ -209,28 +209,6 @@ 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.
+9 -108
View File
@@ -36,15 +36,11 @@ 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,
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
feat_fg_lo, feat_mom_lo, feat_news_lo, feat_mfld_lo, feat_btc_dom_lo
) 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,
$26,$27,$28,$29,$30,$31,$32,$33,$34
$21,$22,$23,$24,$25
)
ON CONFLICT (id) DO NOTHING
""",
@@ -57,10 +53,6 @@ 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:
@@ -226,11 +218,8 @@ class Database:
COUNT(*) AS total_trades,
COUNT(*) FILTER (WHERE closed_at IS NULL) AS open_count,
COUNT(*) FILTER (WHERE closed_at IS NOT NULL) AS closed_count,
-- excluded_from_metrics trades are omitted from resolved_count,
-- realized_pnl, wins_realized, and calibration_score.
COUNT(*) FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)) AS resolved_count,
AND final_prob IS NOT NULL) AS resolved_count,
COALESCE(SUM(net_cost)
FILTER (WHERE closed_at IS NULL), 0) AS total_deployed,
@@ -243,17 +232,15 @@ class Database:
FILTER (WHERE closed_at IS NULL
AND edge_net IS NOT NULL), 0) AS unrealized_pnl_est,
-- Realized PnL: admin-excluded trades omitted (close_pnl=0 by convention
-- but excluded explicitly so they don't skew the aggregate).
-- Realized PnL: closed trades with a known resolution.
-- close_pnl is computed at close time from actual resolution.
COALESCE(SUM(close_pnl)
FILTER (WHERE closed_at IS NOT NULL
AND close_pnl IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)), 0) AS realized_pnl,
AND close_pnl IS NOT NULL), 0) AS realized_pnl,
COUNT(*) FILTER (WHERE closed_at IS NOT NULL
AND close_pnl IS NOT NULL
AND close_pnl > 0
AND (excluded_from_metrics IS NOT TRUE)) AS wins_realized,
AND close_pnl > 0) AS wins_realized,
-- Calibration (Brier score transformed to higher-is-better):
-- 1 AVG((final_prob resolution)²) on resolved trades.
@@ -261,15 +248,12 @@ class Database:
-- resolution is 1.0 (YES won) or 0.0 (NO won).
-- Perfect calibration → 1.0 | Random → ~0.75 | Worst → 0.0
-- Returns NULL if fewer than 10 resolved trades with final_prob.
-- Admin-excluded trades omitted from both threshold and average.
CASE
WHEN COUNT(*) FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)) >= 10
AND final_prob IS NOT NULL) >= 10
THEN 1.0 - AVG((final_prob - resolution) * (final_prob - resolution))
FILTER (WHERE resolution IS NOT NULL
AND final_prob IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE))
AND final_prob IS NOT NULL)
ELSE NULL
END AS calibration_score
@@ -376,27 +360,22 @@ class Database:
feat_fg_lo AS fval,
edge_net, net_cost, fee_usdc, closed_at, close_pnl
FROM trades WHERE feat_fg_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
UNION ALL
SELECT 'mom', 0.05, feat_mom_lo,
edge_net, net_cost, fee_usdc, closed_at, close_pnl
FROM trades WHERE feat_mom_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
UNION ALL
SELECT 'news', 0.10, feat_news_lo,
edge_net, net_cost, fee_usdc, closed_at, close_pnl
FROM trades WHERE feat_news_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
UNION ALL
SELECT 'mfld', 0.10, feat_mfld_lo,
edge_net, net_cost, fee_usdc, closed_at, close_pnl
FROM trades WHERE feat_mfld_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
UNION ALL
SELECT 'btc_dom', 0.05, feat_btc_dom_lo,
edge_net, net_cost, fee_usdc, closed_at, close_pnl
FROM trades WHERE feat_btc_dom_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
)
SELECT
feature,
@@ -480,7 +459,6 @@ class Database:
) AS dominant
FROM trades
WHERE feat_fg_lo IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
)
SELECT
COALESCE(dominant, 'none') AS dominant_feature,
@@ -515,83 +493,6 @@ 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,
poly_outcome_type: Optional[str] = None,
mfld_outcome_type: Optional[str] = None,
) -> 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,
poly_outcome_type, mfld_outcome_type
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,FALSE,$14,$15)
""",
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,
poly_outcome_type, mfld_outcome_type,
)
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
+73 -219
View File
@@ -2,33 +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 a ManifoldMatchResult with full audit metadata.
by keyword overlap and returns its probability as a calibration signal.
Match threshold: >= 0.40 Jaccard overlap (raised from 0.25 for stricter semantics).
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.
Outcome compatibility guard (conservative):
- Conditional Manifold markets ("If X, will Y?" / "Conditional on..." / "Assuming..."
/ "Given that..." / mid-sentence "...if X is nominated, will...") are rejected:
a premise-gated question is not equivalent to a direct outcome question even when
token overlap is high. reason='conditional_market'.
- Each side is classified into an outcome_type (nomination | primary_win |
general_win | conditional | other). Matches with differing outcome_type — or any
conditional side — are rejected. reason='outcome_mismatch: poly=... manifold=...'.
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.
Cache TTL: 30 minutes (Manifold markets move slowly vs our 60 s cycle).
Match threshold: >= 0.25 keyword overlap ratio between significant tokens.
"""
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Optional
import httpx
@@ -38,7 +29,7 @@ CACHE_TTL_SEC = 1800 # 30 minutes
log = logging.getLogger(__name__)
_MATCH_THRESHOLD = 0.40 # raised from 0.25
_MATCH_THRESHOLD = 0.25
_STOP_WORDS = frozenset([
"will", "the", "a", "an", "is", "are", "was", "were", "be", "been",
@@ -52,26 +43,11 @@ _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"])
@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 = ""
poly_outcome_type: Optional[str] = None # nomination|primary_win|general_win|conditional|other
mfld_outcome_type: Optional[str] = None
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}
@@ -93,53 +69,27 @@ def _detect_party(text: str) -> Optional[str]:
return None
# ── Conditional-market detection (Task 1) ──────────────────────────────────────
# A market is "conditional" when its resolution is gated on a premise rather than
# asking the outcome directly (e.g. "If X is the nominee, will he win?"). Such a
# market is NOT equivalent to a direct outcome question even with high token overlap.
_CONDITIONAL_PREFIXES = ("if ", "conditional on", "assuming ", "given that")
# " if <clause>," — a mid-sentence conditional clause closed by a comma.
_CONDITIONAL_CLAUSE_RE = re.compile(r"\sif\s[^,]*,")
def _is_conditional(text: str) -> bool:
"""True if the question is phrased conditionally (premise-gated)."""
t = (text or "").strip().lower()
if t.startswith(_CONDITIONAL_PREFIXES):
return True
return bool(_CONDITIONAL_CLAUSE_RE.search(t))
def _classify_outcome(text: str) -> str:
def _best_match_with_audit(
poly_question: str,
results: list[dict],
) -> tuple[Optional[dict], float, bool]:
"""
Coarse classification of what a question is *asking about*, used to reject
matches whose outcomes are not equivalent even when tokens overlap.
Find the best-matching open binary Manifold market.
Returns one of: nomination | primary_win | general_win | conditional | other.
Order matters: conditional is checked first (premise-gated), then nomination
(which subsumes "primary nominee"), then primary, then general election.
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)
"""
t = (text or "").strip().lower()
if t.startswith(_CONDITIONAL_PREFIXES):
return "conditional"
if any(k in t for k in ("nominee", "nominated", "nomination")):
return "nomination"
if any(k in t for k in ("primary", "win the primary", "first round")):
return "primary_win"
if any(k in t for k in ("win the election", "win the race",
"win the seat", "general election")):
return "general_win"
return "other"
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
return None, 0.0, False
best_score = 0.0
best: Optional[dict] = None
best_needs_inv = False
for result in results:
if result.get("outcomeType") != "BINARY":
@@ -156,14 +106,18 @@ def _find_best_candidate(poly_question: str, results: list[dict]) -> tuple[Optio
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
)
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
if best_score >= _MATCH_THRESHOLD and best is not None:
return best, best_score, best_needs_inv
return None, best_score, False
class ManifoldClient:
@@ -171,32 +125,27 @@ class ManifoldClient:
def __init__(self) -> None:
self._client = httpx.AsyncClient(timeout=15)
# question → (fetched_at_monotonic, ManifoldMatchResult)
self._cache: dict[str, tuple[float, ManifoldMatchResult]] = {}
# question → (fetched_at_monotonic, probability_or_None)
self._cache: dict[str, tuple[float, Optional[float]]] = {}
async def get_match(self, question: str) -> ManifoldMatchResult:
async def get_probability(self, question: str) -> Optional[float]:
"""
Return a ManifoldMatchResult for the given Polymarket question.
Return Manifold probability for a matching market, or None.
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
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.
"""
now = time.monotonic()
cached = self._cache.get(question)
if cached and (now - cached[0]) < CACHE_TTL_SEC:
return cached[1]
poly_outcome = _classify_outcome(question)
query = _build_search_query(question)
if not query:
result = ManifoldMatchResult(
status="no_results", search_query="",
poly_outcome_type=poly_outcome,
)
self._cache[question] = (now, result)
return result
self._cache[question] = (now, None)
return None
try:
resp = await self._client.get(
@@ -205,140 +154,45 @@ class ManifoldClient:
)
resp.raise_for_status()
results = resp.json()
except Exception as exc:
log.warning("Manifold API error for %r: %s", question[:40], exc)
result = ManifoldMatchResult(
status="no_results", search_query=query,
poly_outcome_type=poly_outcome,
)
self._cache[question] = (now, result)
return result
except Exception as e:
log.warning("Manifold API error for %r: %s", question[:40], e)
self._cache[question] = (now, None)
return None
if not results:
result = ManifoldMatchResult(
status="no_results", search_query=query,
poly_outcome_type=poly_outcome,
)
self._cache[question] = (now, result)
return result
match, score, needs_inv = _best_match_with_audit(question, results)
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}"
if match is None:
log.info(
"Manifold REJECTED %-50s | score=%.2f < threshold=%.2f | query=%r",
"Manifold no_match: %-50s | best_score=%.2f < %.2f | query=%r",
question[:50], score, _MATCH_THRESHOLD, query,
)
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,
poly_outcome_type=poly_outcome,
mfld_outcome_type=_classify_outcome(best.get("question", "")) if best else None,
)
self._cache[question] = (now, result)
return result
self._cache[question] = (now, None)
return None
# ── Outcome compatibility + inversion analysis (conservative) ─────────
mfld_title = best.get("question", "")
mfld_outcome = _classify_outcome(mfld_title)
poly_party = _detect_party(question)
manifold_party = _detect_party(mfld_title)
prob_raw = float(match["probability"])
prob_final = (1.0 - prob_raw) if needs_inv else prob_raw
poly_words = _significant_words(question)
mfld_words = _significant_words(mfld_title)
matched_tokens = sorted(poly_words & mfld_words)[:6]
inverted = False
rejection_reason: Optional[str] = None
# Task 1 — conditional Manifold market is never equivalent to a direct
# outcome question, regardless of token overlap.
if _is_conditional(mfld_title):
rejection_reason = "conditional_market: manifold question is conditional"
# Task 2 — outcome types must match; any conditional side is rejected.
elif (poly_outcome == "conditional" or mfld_outcome == "conditional"
or poly_outcome != mfld_outcome):
rejection_reason = (
f"outcome_mismatch: poly={poly_outcome} manifold={mfld_outcome}"
)
elif 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,
poly_outcome_type=poly_outcome,
mfld_outcome_type=mfld_outcome,
)
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})"
# 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"
log.info(
"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 ",
"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",
question[:50],
question,
best.get("question", ""),
url or "n/a",
score, prob_raw, inverted, prob_final,
match.get("question", ""),
url,
score, prob_raw, needs_inv, 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,
poly_outcome_type=poly_outcome,
mfld_outcome_type=mfld_outcome,
)
self._cache[question] = (now, result)
return result
self._cache[question] = (now, prob_final)
return prob_final
async def close(self) -> None:
await self._client.aclose()
-79
View File
@@ -168,73 +168,6 @@ 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,
poly_outcome_type TEXT,
mfld_outcome_type TEXT
);
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);
-- Backfill outcome-type columns on pre-existing tables (idempotent).
ALTER TABLE manifold_match_audit ADD COLUMN IF NOT EXISTS poly_outcome_type TEXT;
ALTER TABLE manifold_match_audit ADD COLUMN IF NOT EXISTS mfld_outcome_type TEXT;
-- ─────────────────────────────────────────────────────────────────────────────
-- Metric exclusion — administrative closure flag
--
-- excluded_from_metrics: TRUE for trades closed for non-signal reasons
-- (bad matcher, data error, admin close). These trades are excluded from
-- win_rate, calibration_score, realized_pnl, and feature attribution.
-- exclusion_reason: free-text label for the exclusion cause.
-- e.g. 'invalid_manifold_match_legacy'
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE trades ADD COLUMN IF NOT EXISTS excluded_from_metrics BOOLEAN DEFAULT FALSE;
ALTER TABLE trades ADD COLUMN IF NOT EXISTS exclusion_reason TEXT;
CREATE INDEX IF NOT EXISTS idx_trades_excluded ON trades(excluded_from_metrics)
WHERE excluded_from_metrics = TRUE;
-- ─────────────────────────────────────────────────────────────────────────────
-- Fix 3: extended metrics_daily columns for DB-computed metrics
--
@@ -249,15 +182,3 @@ ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS realized_pnl DOUBLE PRE
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS open_count INTEGER;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS closed_count INTEGER;
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS resolved_count INTEGER;
-- ─────────────────────────────────────────────────────────────────────────────
-- Checkpoint alerts — one-shot and rate-limited Telegram observation alerts
--
-- fired_at: timestamp of the first fire (immutable for one-shot checkpoints)
-- last_fired_at: updated on every fire (used for rate-limiting repeatable alerts)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS checkpoint_alerts (
checkpoint_name TEXT PRIMARY KEY,
fired_at TIMESTAMPTZ NOT NULL,
last_fired_at TIMESTAMPTZ
);
-20
View File
@@ -57,16 +57,6 @@ 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 (
@@ -186,16 +176,6 @@ 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
+1 -20
View File
@@ -16,7 +16,6 @@ from bot.risk.manager import RiskManager
from bot.executor.paper import PaperExecutor
from bot.metrics.tracker import MetricsTracker
from bot.data.db import Database
from bot.notify.checkpoints import CheckpointMonitor
logging.basicConfig(
level=logging.INFO,
@@ -39,7 +38,6 @@ async def run_trading_loop(
) -> None:
"""Main trading loop — runs every 60 seconds."""
log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE)
checkpoint_monitor = CheckpointMonitor()
while True:
try:
@@ -143,12 +141,6 @@ 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()
@@ -203,17 +195,6 @@ async def run_trading_loop(
# 9. Update daily metrics
await metrics.update_daily_summary()
# 10. Checkpoint alerts — one-shot / rate-limited Telegram notifications
current_portfolio = executor.get_portfolio()
try:
await checkpoint_monitor.check_all(
db,
exposure_pct=current_portfolio.exposure_pct,
exposure_cap_pct=risk.max_exposure_pct,
)
except Exception as exc:
log.warning("checkpoint_monitor.check_all failed: %s", exc)
except Exception as e:
log.error("Error in trading loop: %s", e, exc_info=True)
@@ -394,7 +375,7 @@ async def main() -> None:
external = ExternalDataClient()
news = NewsClient()
manifold = ManifoldClient()
strategy = BayesianStrategy(news=news, manifold=manifold, db=db)
strategy = BayesianStrategy(news=news, manifold=manifold)
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)
-184
View File
@@ -1,184 +0,0 @@
"""One-shot and rate-limited Telegram checkpoint alerts.
Called from the main trading loop at the end of each cycle.
Errors are swallowed checkpoint failures must never break the loop.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from bot.notify import telegram
log = logging.getLogger(__name__)
_EXPOSURE_COOLDOWN_HOURS = 6
class CheckpointMonitor:
async def check_all(
self,
db,
exposure_pct: float,
exposure_cap_pct: float,
) -> None:
for name, coro in [
("primer_match_accepted", self._check_primer_match_accepted(db)),
("primer_trade_phase6", self._check_primer_trade_phase6(db)),
("primer_resolved", self._check_primer_resolved(db)),
("exposure_cerca_cap", self._check_exposure_cerca_cap(db, exposure_pct, exposure_cap_pct)),
]:
try:
await coro
except Exception as exc:
log.warning("checkpoint %s failed: %s", name, exc)
# ── helpers ──────────────────────────────────────────────────────────────
async def _one_shot_fired(self, db, name: str) -> bool:
async with db._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT 1 FROM checkpoint_alerts WHERE checkpoint_name = $1", name
)
return row is not None
async def _mark_one_shot(self, db, name: str) -> None:
async with db._pool.acquire() as conn:
await conn.execute(
"INSERT INTO checkpoint_alerts (checkpoint_name, fired_at) VALUES ($1, NOW())",
name,
)
async def _last_fired_at(self, db, name: str) -> Optional[datetime]:
async with db._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT last_fired_at FROM checkpoint_alerts WHERE checkpoint_name = $1",
name,
)
if row is None:
return None
return row["last_fired_at"]
async def _upsert_repeatable(self, db, name: str) -> None:
async with db._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO checkpoint_alerts (checkpoint_name, fired_at, last_fired_at)
VALUES ($1, NOW(), NOW())
ON CONFLICT (checkpoint_name) DO UPDATE SET last_fired_at = NOW()
""",
name,
)
# ── checkpoints ──────────────────────────────────────────────────────────
async def _check_primer_match_accepted(self, db) -> None:
if await self._one_shot_fired(db, "primer_match_accepted"):
return
async with db._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT match_score, poly_question, mfld_market_title
FROM manifold_match_audit
WHERE match_status = 'accepted'
ORDER BY timestamp ASC
LIMIT 1
"""
)
if not row:
return
score = float(row["match_score"] or 0.0)
poly_q = (row["poly_question"] or "")[:60]
mfld_t = (row["mfld_market_title"] or "")[:60]
await telegram._send(
f"✅ Primer match Manifold accepted — score={score:.2f} "
f"poly='{poly_q}' mfld='{mfld_t}'"
)
await self._mark_one_shot(db, "primer_match_accepted")
log.info("checkpoint primer_match_accepted fired")
async def _check_primer_trade_phase6(self, db) -> None:
if await self._one_shot_fired(db, "primer_trade_phase6"):
return
async with db._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT question, mfld_match_score, edge_net
FROM trades
WHERE mfld_match_score IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
ORDER BY timestamp ASC
LIMIT 1
"""
)
if not row:
return
question = (row["question"] or "")[:70]
score = float(row["mfld_match_score"] or 0.0)
edge = float(row["edge_net"] or 0.0)
await telegram._send(
f"🎯 Primer trade Phase-6 limpio — {question} "
f"score={score:.2f} edge={edge:.3f}"
)
await self._mark_one_shot(db, "primer_trade_phase6")
log.info("checkpoint primer_trade_phase6 fired")
async def _check_primer_resolved(self, db) -> None:
if await self._one_shot_fired(db, "primer_resolved"):
return
async with db._pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT question, resolution, close_pnl
FROM trades
WHERE resolution IS NOT NULL
AND (excluded_from_metrics IS NOT TRUE)
ORDER BY closed_at ASC
LIMIT 1
"""
)
if not row:
return
question = (row["question"] or "")[:70]
resolution = float(row["resolution"] or 0.0)
pnl = float(row["close_pnl"] or 0.0)
await telegram._send(
f"🏁 Primer mercado resuelto — {question} "
f"result={resolution} pnl={pnl:.2f}"
)
await self._mark_one_shot(db, "primer_resolved")
log.info("checkpoint primer_resolved fired")
async def _check_exposure_cerca_cap(
self, db, exposure_pct: float, exposure_cap_pct: float
) -> None:
if exposure_pct < 0.80 * exposure_cap_pct:
return
last = await self._last_fired_at(db, "exposure_cerca_cap")
if last is not None:
now_utc = datetime.now(timezone.utc)
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
elapsed_hours = (now_utc - last).total_seconds() / 3600
if elapsed_hours < _EXPOSURE_COOLDOWN_HOURS:
return
await telegram._send(
f"⚠️ Exposure al 80% del cap — revisar posiciones "
f"({exposure_pct * 100:.1f}% / {exposure_cap_pct * 100:.1f}%)"
)
await self._upsert_repeatable(db, "exposure_cerca_cap")
log.info(
"checkpoint exposure_cerca_cap fired (%.1f%% / %.1f%%)",
exposure_pct * 100, exposure_cap_pct * 100,
)
-22
View File
@@ -62,17 +62,6 @@ 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:
@@ -170,15 +159,4 @@ 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,
)
+4 -89
View File
@@ -12,19 +12,16 @@ 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__)
@@ -173,19 +170,6 @@ 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:
@@ -217,12 +201,10 @@ 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
@@ -437,74 +419,18 @@ 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_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,
poly_outcome_type=manifold_result.poly_outcome_type,
mfld_outcome_type=manifold_result.mfld_outcome_type,
)
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_prob = await self._manifold.get_probability(market.question)
if manifold_prob is not None:
manifold_used = True
self._manifold_fetched += 1
m_clamped = max(0.05, min(0.95, manifold_result.prob_final))
m_clamped = max(0.05, min(0.95, manifold_prob))
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}")
sources.append(f"Manifold:{manifold_prob:.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
@@ -634,17 +560,6 @@ 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,
)
+1 -1
View File
@@ -14,5 +14,5 @@ vaderSentiment==3.3.2
# Testing
pytest==8.2.0
pytest-asyncio==0.23.6
pytest-asyncio==0.26.0
httpx==0.27.0
-152
View File
@@ -1,152 +0,0 @@
"""
Tests for the Manifold outcome-compatibility guard.
Regression: a Polymarket *nomination* question must not match a Manifold
*conditional* question ("If X is the nominee, will he win?") even at Jaccard=1.0.
"""
import asyncio
import pytest
from bot.data.manifold import (
ManifoldClient,
_classify_outcome,
_is_conditional,
)
# ── _is_conditional ────────────────────────────────────────────────────────────
def test_is_conditional_prefixes():
assert _is_conditional("If Graham Platner is the nominee, will he win?")
assert _is_conditional("Conditional on a recession, will rates fall?")
assert _is_conditional("Assuming Trump runs, will he win?")
assert _is_conditional("Given that X happens, will Y?")
def test_is_conditional_midsentence_clause():
assert _is_conditional("Will Biden, if he is nominated, win the election?")
def test_is_not_conditional():
assert not _is_conditional("Will Graham Platner be the Democratic nominee?")
assert not _is_conditional("Will the GOP win the Senate?")
# "if" without a closing comma clause is not flagged
assert not _is_conditional("What happens if everything goes right")
# ── _classify_outcome ───────────────────────────────────────────────────────────
def test_classify_nomination():
assert _classify_outcome("Will X be the Democratic nominee for Senate?") == "nomination"
assert _classify_outcome("Will X be nominated?") == "nomination"
# "primary nominee" → nomination (checked before primary)
assert _classify_outcome("Will X be the primary nominee?") == "nomination"
def test_classify_primary_win():
assert _classify_outcome("Will X win the primary?") == "primary_win"
assert _classify_outcome("Will X advance in the first round?") == "primary_win"
def test_classify_general_win():
assert _classify_outcome("Will X win the election?") == "general_win"
assert _classify_outcome("Will X win the seat?") == "general_win"
assert _classify_outcome("Will X win the general election?") == "general_win"
def test_classify_conditional():
assert _classify_outcome("If X is the nominee, will he win?") == "conditional"
assert _classify_outcome("Assuming a runoff, who wins?") == "conditional"
def test_classify_other():
assert _classify_outcome("Will it rain tomorrow?") == "other"
# ── End-to-end get_match with a stubbed Manifold API ────────────────────────────
class _StubResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
pass
def json(self):
return self._payload
class _StubHTTP:
def __init__(self, payload):
self._payload = payload
async def get(self, *args, **kwargs):
return _StubResponse(self._payload)
async def aclose(self):
pass
async def _match(poly, mfld_market):
client = ManifoldClient()
client._client = _StubHTTP([mfld_market])
try:
return await client.get_match(poly)
finally:
await client.close()
def test_graham_platner_conditional_rejected():
"""Poly nomination vs Manifold conditional → rejected (Task 4.1)."""
poly = ("Will Graham Platner be the Democratic nominee for Senate "
"in Maine in 2026?")
mfld_market = {
"outcomeType": "BINARY",
"probability": 0.55,
"question": ("If Graham Platner is the Democratic nominee for Senate "
"in Maine, will he win the general election?"),
"id": "abc123",
"slug": "graham-platner-win",
"creatorUsername": "someone",
}
result = asyncio.run(_match(poly, mfld_market))
assert result.status == "rejected"
assert result.match_reason is not None
assert ("conditional" in result.match_reason
or "outcome_mismatch" in result.match_reason)
# outcome types are classified and available for persistence
assert result.poly_outcome_type == "nomination"
assert result.mfld_outcome_type == "conditional"
def test_outcome_mismatch_nomination_vs_general_rejected():
"""Poly nomination vs Manifold general_win (non-conditional) → rejected."""
poly = "Will Jane Doe be the Republican nominee for Governor?"
mfld_market = {
"outcomeType": "BINARY",
"probability": 0.4,
"question": "Will Jane Doe win the election for Governor?",
"id": "x", "slug": "jane-doe", "creatorUsername": "u",
}
result = asyncio.run(_match(poly, mfld_market))
assert result.status == "rejected"
assert "outcome_mismatch" in result.match_reason
assert result.poly_outcome_type == "nomination"
assert result.mfld_outcome_type == "general_win"
def test_matching_nomination_accepted():
"""Poly nomination vs Manifold nomination (same outcome) → accepted."""
poly = "Will Graham Platner be the Democratic nominee for Senate in Maine?"
mfld_market = {
"outcomeType": "BINARY",
"probability": 0.62,
"question": "Will Graham Platner be the Democratic Senate nominee in Maine?",
"id": "ok", "slug": "platner-nominee", "creatorUsername": "u",
}
result = asyncio.run(_match(poly, mfld_market))
assert result.status == "accepted"
assert result.poly_outcome_type == "nomination"
assert result.mfld_outcome_type == "nomination"
assert result.prob_final == pytest.approx(0.62)