Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c608057d9e |
-22
@@ -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
@@ -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
|
||||
|
||||
+74
-220
@@ -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,24 +43,9 @@ _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
|
||||
_DEMOCRAT_WORDS = frozenset(["democrat", "democrats", "democratic"])
|
||||
|
||||
|
||||
def _significant_words(text: str) -> set[str]:
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Core
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
httpx==0.28.1
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
pydantic==2.7.0
|
||||
@@ -15,4 +15,4 @@ vaderSentiment==3.3.2
|
||||
# Testing
|
||||
pytest==8.2.0
|
||||
pytest-asyncio==0.23.6
|
||||
httpx==0.27.0
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user