feat(bot): 5-phase strategy upgrade — edge neto, families, GNews priority, regimes
CI/CD / build-and-push (push) Successful in 2m30s
CI/CD / build-and-push (push) Successful in 2m30s
Phase 1 — Edge neto real (paper.py, bayesian.py, risk/manager.py, db.py):
- Trade records now store edge_gross, edge_net, prior_prob, final_prob,
mid_price, spread_estimate, commission, family_key
- edge_net = edge_gross - SPREAD_ESTIMATE(0.02) - COMMISSION_RATE(0.02)
NOTE: both constants are heuristics, not exact Polymarket exchange costs
- Execution gate changed from edge_gross > MIN_EDGE to edge_net > regime_min_edge
Phase 2 — Market families (polymarket.py):
- market_family_key(market) groups related markets:
texas-republican-2026, fed-april-2026, openai-2026, etc.
- At most 1 trade per family per cycle; occupied_families propagated via main.py
- Family key logged on every TRADE and SKIP line
Phase 3 — GNews priority (news.py, bayesian.py, main.py):
- NewsClient.get_freshness() returns 1.0/0.75/0.40/0.10 by cache age
- gnews_priority(market, news) = uncertainty × volume_score × freshness
- Politics markets sorted by priority DESC before eval so best markets get
the 5-query/cycle GNews budget first
Phase 4 — Regime min-edge by category/horizon (bayesian.py):
- politics >60d → 0.12, 30-60d → 0.10, <30d → 0.08
- tech / crypto/finance → 0.10
- All thresholds applied to edge_net (not edge_gross)
Phase 5 — Observability (bayesian.py, main.py):
- Structured skip labels: SKIP_UNSUPPORTED, SKIP_NO_SIGNALS,
SKIP_PRIOR_EXTREME, SKIP_FAMILY, SKIP_GNEWS_PRIORITY, SKIP_EDGE_NET
- TRADE lines now include family_key, edge_gross, edge_net, regime_min, days
- schema.sql: 8 new cols on trades, 7 new cols on signals (via ALTER TABLE IF NOT EXISTS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+66
-33
@@ -1,17 +1,16 @@
|
||||
"""
|
||||
Polymarket Trading Bot — Main Entry Point
|
||||
# ci-test: 2026-04-14
|
||||
# ci-test: 2026-04-16
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bot.data.polymarket import PolymarketClient
|
||||
from bot.data.polymarket import PolymarketClient, market_family_key
|
||||
from bot.data.external import ExternalDataClient
|
||||
from bot.data.news import NewsClient
|
||||
from bot.strategy.bayesian import BayesianStrategy
|
||||
from bot.strategy.bayesian import BayesianStrategy, gnews_priority
|
||||
from bot.risk.manager import RiskManager
|
||||
from bot.executor.paper import PaperExecutor
|
||||
from bot.metrics.tracker import MetricsTracker
|
||||
@@ -34,65 +33,100 @@ async def run_trading_loop(
|
||||
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 crypto/finance markets
|
||||
# 1. Fetch active markets (90-day window)
|
||||
markets = await poly.get_active_markets()
|
||||
log.info("Found %d active markets", len(markets))
|
||||
|
||||
# Sort: politics markets first (soonest-resolving → highest GNews priority),
|
||||
# then all others. This ensures the 5-query-per-cycle cap hits the most
|
||||
# time-sensitive political markets before the budget runs out.
|
||||
def _sort_key(m):
|
||||
is_pol = m.category == "politics"
|
||||
try:
|
||||
dt = datetime.fromisoformat(m.end_date.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
dt = datetime(9999, 12, 31, tzinfo=timezone.utc)
|
||||
return (0 if is_pol else 1, dt)
|
||||
|
||||
markets = sorted(markets, key=_sort_key)
|
||||
for _m in markets:
|
||||
log.info(" [market] %s | ends: %s | yes_price: %.3f",
|
||||
_m.question, _m.end_date, _m.yes_price)
|
||||
|
||||
# 2. Get external signals
|
||||
ext_data = await external.get_all_signals()
|
||||
|
||||
# Reset per-cycle GNews counter so the limit applies fresh each cycle
|
||||
# 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:
|
||||
# 3. Estimate true probability
|
||||
signal = await strategy.evaluate(market, ext_data)
|
||||
# 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: market=%s poly_price=%.3f our_estimate=%.3f confidence=%.2f",
|
||||
"Signal generated: market=%-50s | edge_gross=%+.3f | edge_net=%+.3f | "
|
||||
"regime_min=%.2f | family=%s | conf=%.2f",
|
||||
market.question[:50],
|
||||
signal.polymarket_price,
|
||||
signal.estimated_prob,
|
||||
signal.edge_gross,
|
||||
signal.edge_net,
|
||||
signal.regime_min_edge,
|
||||
signal.family_key,
|
||||
signal.confidence,
|
||||
)
|
||||
|
||||
# 4. Risk check + position sizing
|
||||
order = risk.size_order(signal, executor.get_portfolio())
|
||||
# 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
|
||||
|
||||
# 5. Execute (paper or real)
|
||||
# 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
|
||||
|
||||
# 6. Update daily metrics
|
||||
log.info("Cycle complete — trades this cycle: %d", cycle_trades)
|
||||
|
||||
# 8. Update daily metrics
|
||||
await metrics.update_daily_summary()
|
||||
|
||||
except Exception as e:
|
||||
@@ -123,7 +157,6 @@ async def main() -> None:
|
||||
metrics = MetricsTracker(db=db)
|
||||
|
||||
if executor is None:
|
||||
# Import real executor only when explicitly needed
|
||||
from bot.executor.real import RealExecutor # noqa
|
||||
executor = RealExecutor(db=db)
|
||||
|
||||
@@ -131,7 +164,7 @@ async def main() -> None:
|
||||
await executor.initialize()
|
||||
|
||||
try:
|
||||
await run_trading_loop(poly, external, strategy, risk, executor, metrics)
|
||||
await run_trading_loop(poly, external, strategy, risk, executor, metrics, db)
|
||||
finally:
|
||||
await db.disconnect()
|
||||
await news.close()
|
||||
|
||||
Reference in New Issue
Block a user