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
|
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
|
||||||
|
|
|
||||||
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
|
- **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=`
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 .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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<h2>Markets</h2>
|
<div class="rail-half">
|
||||||
<div id="marketsList" class="scroll-list"></div>
|
<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>
|
</aside>
|
||||||
|
|
||||||
<div id="clusterModal" class="modal hidden">
|
<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 --
|
// ------------------------------------------------------------ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue