Add Polymarket trending-bets panel with an independently-scrolling split rail

New backend/app/polymarket.py polls Polymarket's public Gamma REST API
(gamma-api.polymarket.com, no key/wallet needed) for the top 10 markets by
24h volume. Their site itself renders market data entirely client-side —
confirmed by inspecting the raw HTML response, which contains no embedded
listings — so this is the only way to get real data; there's nothing to
scrape from the page. In-memory snapshot like flights.py, not persisted.

Right rail is now a fixed 50/50 split between the Polymarket list and the
markets ticker, each scrolling independently instead of one long list
pushing the other down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Um48tTvZDrEgDeweFyhPYC
main
Amir Alexander Abdelbaki 2026-07-20 16:02:49 +02:00
parent c3a266f6d9
commit d66c6b08aa
9 changed files with 200 additions and 6 deletions

View File

@ -29,6 +29,7 @@ FLIGHTS_POLL_SECONDS=300
RSS_POLL_MINUTES=10 RSS_POLL_MINUTES=10
MARKET_POLL_MINUTES=15 MARKET_POLL_MINUTES=15
CONFLICT_POLL_MINUTES=60 CONFLICT_POLL_MINUTES=60
POLYMARKET_POLL_MINUTES=15
# How long an article stays "live" on the globe before aging out of clusters # How long an article stays "live" on the globe before aging out of clusters
ARTICLE_WINDOW_HOURS=72 ARTICLE_WINDOW_HOURS=72

View File

@ -12,9 +12,16 @@ composition diagram when Wikipedia has one.
- **Left rail** — latest news, newest first, auto-refreshing. A 📍 button on - **Left rail** — latest news, newest first, auto-refreshing. A 📍 button on
any geocoded story flies the globe to it. any geocoded story flies the globe to it.
- **Right rail** — index/oil ticker with a 7-day sparkline per instrument. - **Right rail** — top: the 10 highest-24h-volume Polymarket prediction
Click one to expand its recent-spikes-with-candidate-articles preview markets (title, leading outcome, volume), via their public Gamma REST API
(see "Economic Incident History" below for the full log). (`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 - **Center** — the globe. News clusters render as newspaper icons (tinted
violet→pink by corroborating-source count); conflict events render as violet→pink by corroborating-source count); conflict events render as
magenta helmet icons plus a pulsing ring. Clicking a news icon opens a 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/conflict-events?hours=168`
- `GET /api/flights` — latest global OpenSky snapshot, with `is_military` - `GET /api/flights` — latest global OpenSky snapshot, with `is_military`
per aircraft (see the heuristic caveat above) 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/weather/tiles/{layer}/{z}/{x}/{y}.png` — OWM tile proxy
- `GET /api/wikipedia/summary?title=`, `GET /api/wikipedia/search?q=`, - `GET /api/wikipedia/summary?title=`, `GET /api/wikipedia/search?q=`,
`GET /api/wikipedia/parliament?country=` `GET /api/wikipedia/parliament?country=`

View File

@ -50,6 +50,9 @@ class Settings(BaseSettings):
flights_poll_seconds: int = 300 flights_poll_seconds: int = 300
military_ranges_file: str = str(APP_DIR / "data" / "military_ranges.yaml") 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: class Config:
env_file = ".env" env_file = ".env"

View File

@ -14,6 +14,7 @@ from .clustering import build_clusters
from .config import settings from .config import settings
from .db import get_session, init_db, SessionLocal from .db import get_session, init_db, SessionLocal
from .flights import get_latest_flights from .flights import get_latest_flights
from .polymarket import get_latest_polymarket
from .ingest import fetch_all from .ingest import fetch_all
from .markets import poll_markets, merge_spikes_into_incidents, has_fetch_error, FROZEN_SYMBOLS, INSTRUMENTS 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 from .conflict import enabled as conflict_enabled, poll_conflict_events
@ -68,6 +69,13 @@ def api_flights():
return get_latest_flights() return get_latest_flights()
# ---- Polymarket ---------------------------------------------------------
@app.get("/api/polymarket")
def api_polymarket():
return get_latest_polymarket()
# ---- Articles / clusters ----------------------------------------------- # ---- Articles / clusters -----------------------------------------------
@app.get("/api/clusters") @app.get("/api/clusters")

86
backend/app/polymarket.py Normal file
View File

@ -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

View File

@ -10,6 +10,7 @@ from .db import SessionLocal
from .flights import poll_flights from .flights import poll_flights
from .ingest import fetch_all from .ingest import fetch_all
from .markets import backfill_market_history, poll_markets from .markets import backfill_market_history, poll_markets
from .polymarket import poll_polymarket
log = logging.getLogger("newsatlas.scheduler") log = logging.getLogger("newsatlas.scheduler")
@ -55,12 +56,18 @@ def _run_market_backfill_job() -> None:
session.close() session.close()
def _run_polymarket_job() -> None:
count = poll_polymarket()
log.info("Polymarket poll complete: %d trending markets", count)
def start_scheduler() -> BackgroundScheduler: def start_scheduler() -> BackgroundScheduler:
scheduler = BackgroundScheduler(timezone="UTC") scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(_run_rss_job, "interval", minutes=settings.rss_poll_minutes, next_run_time=None) 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_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_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_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() scheduler.start()
# Kick off an immediate first run of each recurring job in the # Kick off an immediate first run of each recurring job in the

View File

@ -128,6 +128,22 @@ html, body {
#leftPanel { left: 0; border-right: 1px solid var(--c-panel-border); } #leftPanel { left: 0; border-right: 1px solid var(--c-panel-border); }
#rightPanel { right: 0; border-left: 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 { .rail h2 {
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
@ -141,6 +157,28 @@ html, body {
.scroll-list { display: flex; flex-direction: column; gap: 2px; } .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 feed items */
.news-item { .news-item {
padding: 9px 0; padding: 9px 0;

View File

@ -37,9 +37,15 @@
<div id="newsFeed" class="scroll-list"></div> <div id="newsFeed" class="scroll-list"></div>
</aside> </aside>
<aside id="rightPanel" class="rail"> <aside id="rightPanel" class="rail split-rail">
<div class="rail-half">
<h2>Trending on Polymarket</h2>
<div id="polymarketList" class="scroll-list"></div>
</div>
<div class="rail-half">
<h2>Markets</h2> <h2>Markets</h2>
<div id="marketsList" class="scroll-list"></div> <div id="marketsList" class="scroll-list"></div>
</div>
</aside> </aside>
<div id="clusterModal" class="modal hidden"> <div id="clusterModal" class="modal hidden">

View File

@ -719,6 +719,40 @@ async function loadConflictEvents() {
} }
} }
// --------------------------------------------------------- polymarket --
//
// Trending prediction-market bets, via the backend's proxy of Polymarket's
// public Gamma API (no key/auth — see backend/app/polymarket.py). Sorted
// by 24h volume server-side; this just renders what comes back.
async function loadPolymarket() {
const el = document.getElementById("polymarketList");
try {
const res = await fetch(`${API}/polymarket`);
const rows = await res.json();
if (!rows.length) {
el.innerHTML = `<p class="subtle">No trending markets loaded yet.</p>`;
return;
}
el.innerHTML = rows
.map((m) => {
const vol = m.volume_24h != null ? `$${Math.round(m.volume_24h).toLocaleString()}` : "";
return `
<div class="poly-item">
<a href="${m.url}" target="_blank" rel="noopener">${escapeHtml(m.title)}</a>
<div class="poly-meta">
${m.leading_outcome ? `<span class="poly-outcome">${escapeHtml(m.leading_outcome)}</span>` : ""}
${vol ? `<span class="subtle">${vol} · 24h vol</span>` : ""}
</div>
</div>`;
})
.join("");
} catch (e) {
console.error("Failed to load Polymarket data", e);
el.innerHTML = `<p class="subtle">Could not load trending markets.</p>`;
}
}
// ------------------------------------------------------------ markets -- // ------------------------------------------------------------ markets --
async function loadMarkets() { async function loadMarkets() {
@ -1469,12 +1503,14 @@ async function init() {
await loadClusters(); await loadClusters();
await loadNewsFeed(); await loadNewsFeed();
await loadMarkets(); await loadMarkets();
await loadPolymarket();
await loadConflictEvents(); await loadConflictEvents();
resizeGlobe(); resizeGlobe();
setInterval(loadClusters, 60_000); setInterval(loadClusters, 60_000);
setInterval(loadNewsFeed, 60_000); setInterval(loadNewsFeed, 60_000);
setInterval(loadMarkets, 5 * 60_000); setInterval(loadMarkets, 5 * 60_000);
setInterval(loadPolymarket, 5 * 60_000);
setInterval(loadConflictEvents, 10 * 60_000); setInterval(loadConflictEvents, 10 * 60_000);
} }