Files
polymarket-bot/bot/main.py
T
chemavx ebdcff5a6e
CI/CD / build-and-push (push) Successful in 2m23s
fix(critical): complementary market family grouping + Manifold inversion guard
FASE 1 — market_family_key() general election fix
General elections now group by office, not by party, so complementary
markets ("Republicans win Ohio governor" / "Democrats win Ohio governor")
share the same family key (ohio-gubernatorial-2026).  The second market
is blocked by the occupied_families check rather than traded as independent.

Primaries still keep the party (texas-republican-2026) because each party
runs its own separate primary race.

FASE 2 — Manifold party inversion guard
_detect_party() identifies the winning side in both the Polymarket question
and the matched Manifold title.  If they are confirmed opposites (republican
vs democrat), the probability is inverted (1 - prob) before use.

Full audit log per query:
  poly_question / manifold_title / manifold_url / match_score /
  prob_raw / inverted / prob_final

Root cause of Ohio Manifold:0.95 on both sides: both queries matched the
same Manifold market ("Republicans win Ohio governor" prob=0.95).  For the
"Democrats win" query the inversion now produces prob_final=0.05 instead of
blindly applying 0.95 to the wrong direction.

FASE 4 — startup contradiction scan
get_open_position_details() added to db.py.  main.py checks all open
positions at startup, warns on any family with >1 position, and recommends
keeping the one with the highest edge_net.  No auto-close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:26:29 +00:00

239 lines
9.4 KiB
Python

"""
Polymarket Trading Bot — Main Entry Point
# ci-test: 2026-04-16
"""
import asyncio
import logging
import os
from datetime import datetime, timezone
from bot.data.polymarket import PolymarketClient, market_family_key
from bot.data.external import ExternalDataClient
from bot.data.news import NewsClient
from bot.data.manifold import ManifoldClient
from bot.strategy.bayesian import BayesianStrategy, gnews_priority, MAX_NEWS_QUERIES_PER_CYCLE
from bot.risk.manager import RiskManager
from bot.executor.paper import PaperExecutor
from bot.metrics.tracker import MetricsTracker
from bot.data.db import Database
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
log = logging.getLogger("bot.main")
PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true"
PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000"))
async def run_trading_loop(
poly: PolymarketClient,
external: ExternalDataClient,
strategy: BayesianStrategy,
risk: RiskManager,
executor: PaperExecutor,
metrics: MetricsTracker,
db: Database,
) -> None:
"""Main trading loop — runs every 60 seconds."""
log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE)
while True:
try:
# 1. Fetch active markets (90-day window)
markets = await poly.get_active_markets()
log.info("Found %d active markets", len(markets))
# 2. Get external signals
ext_data = await external.get_all_signals()
# 3. Build occupied_families from the current open portfolio positions.
# This prevents re-entering a family where we already hold a position.
# We also pull from DB to survive pod restarts.
portfolio = executor.get_portfolio()
occupied_families: set[str] = set()
for market_id in portfolio.positions:
mkt = next((m for m in markets if m.id == market_id), None)
if mkt:
occupied_families.add(market_family_key(mkt))
# Also seed from DB in case a family was traded in a prior cycle
# that isn't reflected in the current markets list
db_families = await db.get_open_families()
occupied_families |= db_families
if occupied_families:
log.info("Occupied families (from portfolio): %s", sorted(occupied_families))
# 4. Sort markets.
# Politics: sort by gnews_priority DESC (highest-value markets get
# GNews budget first — Phase 3).
# Others: sort by end_date ASC (soonest-resolving first).
def _sort_key(m):
try:
dt = datetime.fromisoformat(m.end_date.replace("Z", "+00:00"))
except Exception:
dt = datetime(9999, 12, 31, tzinfo=timezone.utc)
if m.category == "politics":
priority = gnews_priority(m, strategy._news) if strategy._news else 0.0
# Bucket 0 = politics, sort by priority DESC (negate for asc sort)
return (0, -priority, dt)
return (1, 0.0, dt)
markets = sorted(markets, key=_sort_key)
for _m in markets:
log.info(
" [market] %-55s | cat=%-12s | family=%-28s | ends=%s | yes=%.3f",
_m.question[:55], _m.category, market_family_key(_m),
_m.end_date[:10] if _m.end_date else "?", _m.yes_price,
)
# Reset per-cycle GNews counter
strategy.reset_cycle()
# 5. Evaluate each market
cycle_trades = 0
for market in markets:
# evaluate() returns None for all skips — reasons are logged internally
signal = await strategy.evaluate(market, ext_data, occupied_families)
if signal is None:
continue
log.info(
"Signal generated: market=%-50s | edge_gross=%+.3f | edge_net=%+.3f | "
"regime_min=%.2f | family=%s | conf=%.2f",
market.question[:50],
signal.edge_gross,
signal.edge_net,
signal.regime_min_edge,
signal.family_key,
signal.confidence,
)
# 6. Risk check + position sizing
order = risk.size_order(signal, portfolio)
if order is None:
log.debug("Risk manager rejected order for %s", market.id)
continue
# 7. Execute (paper)
trade = await executor.execute(order)
if trade:
await metrics.record_trade(trade)
log.info("Trade executed: %s", trade)
# Block this family for the rest of the cycle (Phase 2)
occupied_families.add(signal.family_key)
cycle_trades += 1
# 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
stats = strategy.get_cycle_stats()
n_total = len(markets)
n_uncertainty = sum(1 for m in markets if 0.35 <= m.yes_price <= 0.65)
n_eval = stats["evaluated_count"]
def _pct(n: int, denom: int) -> str:
if denom == 0:
return "0% (0/0)"
return f"{n * 100 // denom}% ({n}/{denom})"
gnews_cap = strategy._news_queries_this_cycle # already updated by reset below
log.info(
"[CYCLE SUMMARY]\n"
" markets_total: %d\n"
" markets_uncertainty_zone: %d (prior 0.35-0.65)\n"
" max_edge_gross: %+.3f\n"
" max_edge_net: %+.3f\n"
" pct_edge_gross_gt_002: %s\n"
" pct_edge_gross_gt_004: %s\n"
" blocked_by_family: %d\n"
" blocked_by_prior_extreme: %d\n"
" blocked_by_edge_net_nonpositive:%d\n"
" blocked_by_edge_net_below_regime:%d\n"
" trades_executed: %d\n"
" gnews_queries_used: %d/%d",
n_total,
n_uncertainty,
stats["max_edge_gross"],
stats["max_edge_net"],
_pct(stats["gross_gt_002"], n_total),
_pct(stats["gross_gt_004"], n_total),
stats["skip_family"],
stats["skip_prior_extreme"],
stats["skip_edge_net_nonpositive"],
stats["skip_edge_net_below_regime"],
cycle_trades,
stats["gnews_queries_used"], MAX_NEWS_QUERIES_PER_CYCLE,
)
# 9. Update daily metrics
await metrics.update_daily_summary()
except Exception as e:
log.error("Error in trading loop: %s", e, exc_info=True)
await asyncio.sleep(60)
async def main() -> None:
if PAPER_MODE:
log.info("=" * 60)
log.info(" PAPER TRADING MODE — No real money at risk")
log.info(" Bankroll: $%.2f simulated", PAPER_BANKROLL)
log.info("=" * 60)
else:
log.warning("REAL TRADING MODE ACTIVE — Real money at risk!")
db = Database()
await db.connect()
await db.run_migrations()
poly = PolymarketClient()
external = ExternalDataClient()
news = NewsClient()
manifold = ManifoldClient()
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)
if executor is None:
from bot.executor.real import RealExecutor # noqa
executor = RealExecutor(db=db)
if PAPER_MODE:
await executor.initialize()
# Contradiction scan: warn if any two open positions share a family_key.
# This can happen when the family logic was less strict on a prior deploy.
# Bot does NOT auto-close — operator decides which position to keep.
positions = await db.get_open_position_details()
family_map: dict[str, list[dict]] = {}
for pos in positions:
fk = pos.get("family_key") or ""
if fk:
family_map.setdefault(fk, []).append(pos)
for fk, members in family_map.items():
if len(members) > 1:
best = max(members, key=lambda p: p.get("edge_net") or 0.0)
log.warning(
"CONTRADICTION family=%s has %d open positions — recommend keeping market_id=%s (edge_net=%.3f):",
fk, len(members), best["market_id"], best.get("edge_net") or 0.0,
)
for m in members:
marker = "KEEP" if m["market_id"] == best["market_id"] else "REVIEW"
log.warning(
" [%s] %s | dir=%s | edge_net=%.3f | %s",
marker, m["market_id"], m["direction"],
m.get("edge_net") or 0.0, m["question"][:60],
)
try:
await run_trading_loop(poly, external, strategy, risk, executor, metrics, db)
finally:
await db.disconnect()
await news.close()
await manifold.close()
if __name__ == "__main__":
asyncio.run(main())