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_01Um48tTvZDrEgDeweFyhPYCmain
parent
c3a266f6d9
commit
d66c6b08aa
|
|
@ -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
|
||||
|
|
|
|||
15
README.md
15
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=`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -37,9 +37,15 @@
|
|||
<div id="newsFeed" class="scroll-list"></div>
|
||||
</aside>
|
||||
|
||||
<aside id="rightPanel" class="rail">
|
||||
<h2>Markets</h2>
|
||||
<div id="marketsList" class="scroll-list"></div>
|
||||
<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>
|
||||
<div id="marketsList" class="scroll-list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="clusterModal" class="modal hidden">
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
|
||||
async function loadMarkets() {
|
||||
|
|
@ -1469,12 +1503,14 @@ async function init() {
|
|||
await loadClusters();
|
||||
await loadNewsFeed();
|
||||
await loadMarkets();
|
||||
await loadPolymarket();
|
||||
await loadConflictEvents();
|
||||
resizeGlobe();
|
||||
|
||||
setInterval(loadClusters, 60_000);
|
||||
setInterval(loadNewsFeed, 60_000);
|
||||
setInterval(loadMarkets, 5 * 60_000);
|
||||
setInterval(loadPolymarket, 5 * 60_000);
|
||||
setInterval(loadConflictEvents, 10 * 60_000);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue