diff --git a/.env.example b/.env.example index 1479118..8636dc9 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ FLIGHTS_POLL_SECONDS=300 RSS_POLL_MINUTES=10 MARKET_POLL_MINUTES=15 CONFLICT_POLL_MINUTES=60 +POLYMARKET_POLL_MINUTES=15 # How long an article stays "live" on the globe before aging out of clusters ARTICLE_WINDOW_HOURS=72 diff --git a/README.md b/README.md index fcc90ad..57261db 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,16 @@ composition diagram when Wikipedia has one. - **Left rail** β€” latest news, newest first, auto-refreshing. A πŸ“ button on any geocoded story flies the globe to it. -- **Right rail** β€” index/oil ticker with a 7-day sparkline per instrument. - Click one to expand its recent-spikes-with-candidate-articles preview - (see "Economic Incident History" below for the full log). +- **Right rail** β€” top: the 10 highest-24h-volume Polymarket prediction + markets (title, leading outcome, volume), via their public Gamma REST API + (`gamma-api.polymarket.com`, no key/auth β€” the same endpoint their own + site's frontend calls; their site itself renders market data client-side, + so there's nothing to scrape from the HTML). Below that: the index/oil + ticker with a 7-day sparkline per instrument, plus a ❄ FROZEN badge on + any instrument with a known-stale feed (MOEX) or a ⚠ ERROR badge if its + last poll genuinely failed. Click an instrument to expand its + recent-spikes-with-candidate-articles preview (see "Economic Incident + History" below for the full log). - **Center** β€” the globe. News clusters render as newspaper icons (tinted violetβ†’pink by corroborating-source count); conflict events render as magenta helmet icons plus a pulsing ring. Clicking a news icon opens a @@ -203,6 +210,8 @@ into the red dots. - `GET /api/conflict-events?hours=168` - `GET /api/flights` β€” latest global OpenSky snapshot, with `is_military` per aircraft (see the heuristic caveat above) +- `GET /api/polymarket` β€” top 10 trending prediction markets by 24h volume + (Polymarket's public Gamma API, no key needed) - `GET /api/weather/tiles/{layer}/{z}/{x}/{y}.png` β€” OWM tile proxy - `GET /api/wikipedia/summary?title=`, `GET /api/wikipedia/search?q=`, `GET /api/wikipedia/parliament?country=` diff --git a/backend/app/config.py b/backend/app/config.py index ee77a24..6cb7337 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -50,6 +50,9 @@ class Settings(BaseSettings): flights_poll_seconds: int = 300 military_ranges_file: str = str(APP_DIR / "data" / "military_ranges.yaml") + # --- Polymarket trending bets (public Gamma API, no key needed) --- + polymarket_poll_minutes: int = 15 + class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index b7f92f3..fc6de61 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from .clustering import build_clusters from .config import settings from .db import get_session, init_db, SessionLocal from .flights import get_latest_flights +from .polymarket import get_latest_polymarket from .ingest import fetch_all from .markets import poll_markets, merge_spikes_into_incidents, has_fetch_error, FROZEN_SYMBOLS, INSTRUMENTS from .conflict import enabled as conflict_enabled, poll_conflict_events @@ -68,6 +69,13 @@ def api_flights(): return get_latest_flights() +# ---- Polymarket --------------------------------------------------------- + +@app.get("/api/polymarket") +def api_polymarket(): + return get_latest_polymarket() + + # ---- Articles / clusters ----------------------------------------------- @app.get("/api/clusters") diff --git a/backend/app/polymarket.py b/backend/app/polymarket.py new file mode 100644 index 0000000..4e8168e --- /dev/null +++ b/backend/app/polymarket.py @@ -0,0 +1,86 @@ +"""Trending Polymarket bets, via their public Gamma REST API +(gamma-api.polymarket.com) β€” unauthenticated, no key/wallet required, the +same endpoint their own frontend calls (the site itself renders market +data client-side, so there's nothing to scrape from its HTML). + +Positions/volumes are high-churn like flights β€” kept as an in-memory +snapshot rather than persisted history, same pattern as flights.py. +""" + +import json +import logging + +import httpx + +log = logging.getLogger("newsatlas.polymarket") + +EVENTS_URL = "https://gamma-api.polymarket.com/events" +_HEADERS = {"User-Agent": "Mozilla/5.0 (NewsAtlas; self-hosted news dashboard)"} + +_latest_markets: list[dict] = [] + + +def _leading_outcome(event: dict) -> str: + """Best-effort one-line summary of where the odds currently sit.""" + markets = event.get("markets") or [] + try: + if len(markets) == 1: + outcomes = json.loads(markets[0].get("outcomes") or "[]") + prices = json.loads(markets[0].get("outcomePrices") or "[]") + pairs = dict(zip(outcomes, prices)) + if "Yes" in pairs: + return f"Yes {float(pairs['Yes']) * 100:.0f}%" + elif len(markets) > 1: + best_label, best_price = None, -1.0 + for m in markets: + outcomes = json.loads(m.get("outcomes") or "[]") + prices = json.loads(m.get("outcomePrices") or "[]") + pairs = dict(zip(outcomes, prices)) + price = float(pairs.get("Yes", 0) or 0) + if price > best_price: + best_label = m.get("groupItemTitle") or m.get("question") or "" + best_price = price + if best_label is not None: + return f"{best_label} {best_price * 100:.0f}%" + except (ValueError, TypeError, KeyError): + pass + return "" + + +def poll_polymarket() -> int: + global _latest_markets + + params = { + "order": "volume24hr", + "ascending": "false", + "active": "true", + "closed": "false", + "limit": "10", + } + try: + resp = httpx.get(EVENTS_URL, params=params, headers=_HEADERS, timeout=15) + resp.raise_for_status() + events = resp.json() + except Exception: + log.exception("Failed to fetch Polymarket events") + return 0 + + markets = [] + for e in events: + markets.append( + { + "title": e.get("title", ""), + "slug": e.get("slug", ""), + "url": f"https://polymarket.com/event/{e.get('slug', '')}", + "volume_24h": e.get("volume24hr"), + "leading_outcome": _leading_outcome(e), + } + ) + + _latest_markets = markets + log.info("Polymarket poll complete: %d trending markets", len(markets)) + return len(markets) + + +def get_latest_polymarket() -> list[dict]: + return _latest_markets diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 1db21c5..f495adc 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -10,6 +10,7 @@ from .db import SessionLocal from .flights import poll_flights from .ingest import fetch_all from .markets import backfill_market_history, poll_markets +from .polymarket import poll_polymarket log = logging.getLogger("newsatlas.scheduler") @@ -55,12 +56,18 @@ def _run_market_backfill_job() -> None: session.close() +def _run_polymarket_job() -> None: + count = poll_polymarket() + log.info("Polymarket poll complete: %d trending markets", count) + + def start_scheduler() -> BackgroundScheduler: scheduler = BackgroundScheduler(timezone="UTC") scheduler.add_job(_run_rss_job, "interval", minutes=settings.rss_poll_minutes, next_run_time=None) scheduler.add_job(_run_market_job, "interval", minutes=settings.market_poll_minutes, next_run_time=None) scheduler.add_job(_run_conflict_job, "interval", minutes=settings.conflict_poll_minutes, next_run_time=None) scheduler.add_job(_run_flights_job, "interval", seconds=settings.flights_poll_seconds, next_run_time=None) + scheduler.add_job(_run_polymarket_job, "interval", minutes=settings.polymarket_poll_minutes, next_run_time=None) scheduler.start() # Kick off an immediate first run of each recurring job in the diff --git a/frontend/css/style.css b/frontend/css/style.css index b6ac169..4052b2a 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -128,6 +128,22 @@ html, body { #leftPanel { left: 0; border-right: 1px solid var(--c-panel-border); } #rightPanel { right: 0; border-left: 1px solid var(--c-panel-border); } +/* right rail: Polymarket + Markets each get a fixed 50% and scroll + independently, rather than one long list pushing the other down */ +.split-rail { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} +.rail-half { + flex: 1 1 50%; + min-height: 0; /* required for a flex child to actually scroll instead of overflowing */ + overflow-y: auto; + padding: 14px; +} +.rail-half:first-child { border-bottom: 1px solid var(--c-panel-border); } + .rail h2 { font-size: 12px; text-transform: uppercase; @@ -141,6 +157,28 @@ html, body { .scroll-list { display: flex; flex-direction: column; gap: 2px; } +/* polymarket trending list */ +.poly-item { + padding: 8px 0; + border-bottom: 1px solid #241533; +} +.poly-item a { + color: var(--c-text); + text-decoration: none; + font-size: 13px; + line-height: 1.35; +} +.poly-item a:hover { color: var(--c-highlight-soft); text-decoration: underline; } +.poly-meta { font-size: 11px; margin-top: 3px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.poly-outcome { + background: var(--c-tag-bg); + border: 1px solid var(--c-panel-border); + color: var(--c-dark-soft); + border-radius: 3px; + padding: 1px 6px; + font-weight: 600; +} + /* news feed items */ .news-item { padding: 9px 0; diff --git a/frontend/index.html b/frontend/index.html index 40605c8..0bea9e2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,9 +37,15 @@
-