Add MKT opt-in on threat intercept, jittered market fetches, and Sentinel multi-scene dossier.

Operators enable Polymarket/Kalshi correlation from Global Threat Intercept with a consent dialog; polls use a jittered schedule separate from the slow tier. Right-click Sentinel imagery returns up to three signed scenes again.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-04 09:01:21 -06:00
co-authored by Cursor
parent 3ac8442e4b
commit 80a01275ff
12 changed files with 536 additions and 36 deletions
+9
View File
@@ -62,7 +62,16 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# FIMI_ENABLED=false # FIMI_ENABLED=false
# #
# Polymarket + Kalshi — US political/election prediction markets. # Polymarket + Kalshi — US political/election prediction markets.
# Default off; enable from Global Threat Intercept (MKT toggle) or set true here.
# PREDICTION_MARKETS_ENABLED=false # PREDICTION_MARKETS_ENABLED=false
# When enabled, polls use a jittered schedule (not the fixed 5-minute slow tier):
# PREDICTION_MARKETS_INTERVAL_MINUTES=7
# PREDICTION_MARKETS_SCHEDULER_JITTER_S=240
# PREDICTION_MARKETS_INITIAL_DELAY_MAX_S=180
# PREDICTION_MARKETS_PRE_FETCH_JITTER_S=90
# PREDICTION_MARKETS_PROVIDER_GAP_JITTER_S=45
# MESH_POLYMARKET_PAGE_DELAY_JITTER_S=0.08
# MESH_KALSHI_PAGE_DELAY_JITTER_S=0.2
# #
# Finnhub fallback / yfinance — financial market data. # Finnhub fallback / yfinance — financial market data.
# Set FINNHUB_API_KEY to enable Finnhub, or set FINANCIAL_ENABLED=true to allow # Set FINNHUB_API_KEY to enable Finnhub, or set FINANCIAL_ENABLED=true to allow
+62
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import math import math
import os
import threading import threading
from typing import Any from typing import Any
from fastapi import APIRouter, Request, Response, Query, Depends from fastapi import APIRouter, Request, Response, Query, Depends
@@ -34,6 +35,10 @@ class LiveUamapOptInUpdate(BaseModel):
opted_in: bool opted_in: bool
class PredictionMarketsOptInUpdate(BaseModel):
opted_in: bool
_LAST_VIEWPORT_UPDATE: tuple | None = None _LAST_VIEWPORT_UPDATE: tuple | None = None
_LAST_VIEWPORT_UPDATE_TS = 0.0 _LAST_VIEWPORT_UPDATE_TS = 0.0
_VIEWPORT_UPDATE_LOCK = threading.Lock() _VIEWPORT_UPDATE_LOCK = threading.Lock()
@@ -422,6 +427,63 @@ def _run_liveuamap_refresh() -> None:
logger.warning("LiveUAMap refresh after opt-in failed: %s", e) logger.warning("LiveUAMap refresh after opt-in failed: %s", e)
@router.get("/api/prediction-markets/status", dependencies=[Depends(require_local_operator)])
async def api_prediction_markets_status():
"""Whether Polymarket/Kalshi fetches and news market correlation are enabled."""
from services.prediction_markets_settings import prediction_markets_status
return prediction_markets_status()
@router.post("/api/prediction-markets/opt-in", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_prediction_markets_opt_in(body: PredictionMarketsOptInUpdate, request: Request):
"""Enable or disable prediction market fetches + intercept story correlation."""
from services.config import get_settings
from services.prediction_markets_settings import (
prediction_markets_status,
set_prediction_markets_ui_opt_in,
)
from routers.ai_intel import _write_env_value
set_prediction_markets_ui_opt_in(body.opted_in)
_write_env_value("PREDICTION_MARKETS_ENABLED", "true" if body.opted_in else "false")
os.environ["PREDICTION_MARKETS_ENABLED"] = "true" if body.opted_in else "false"
get_settings.cache_clear()
if body.opted_in:
threading.Thread(target=_run_prediction_markets_refresh, daemon=True).start()
else:
threading.Thread(target=_run_prediction_markets_disable, daemon=True).start()
return prediction_markets_status()
def _run_prediction_markets_refresh() -> None:
try:
from services.fetchers.prediction_markets import fetch_prediction_markets
from services.fetchers.news import fetch_news
fetch_prediction_markets()
fetch_news()
except Exception as e:
logger.warning("Prediction markets refresh after opt-in failed: %s", e)
def _run_prediction_markets_disable() -> None:
try:
from services.fetchers._store import _data_lock, _mark_fresh, latest_data
from services.fetchers.news import fetch_news
with _data_lock:
latest_data["prediction_markets"] = []
latest_data["trending_markets"] = []
_mark_fresh("prediction_markets")
fetch_news()
except Exception as e:
logger.warning("Prediction markets disable cleanup failed: %s", e)
@router.post("/api/layers", dependencies=[Depends(require_local_operator)]) @router.post("/api/layers", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def update_layers(update: LayerUpdate, request: Request): async def update_layers(update: LayerUpdate, request: Request):
+22 -1
View File
@@ -19,6 +19,7 @@ import concurrent.futures
import json import json
import math import math
import os import os
import random
import threading import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -405,7 +406,6 @@ def update_slow_data():
logger.info("Slow-tier data update starting...") logger.info("Slow-tier data update starting...")
slow_funcs = [ slow_funcs = [
fetch_news, fetch_news,
fetch_prediction_markets,
fetch_earthquakes, fetch_earthquakes,
fetch_firms_fires, fetch_firms_fires,
fetch_firms_country_fires, fetch_firms_country_fires,
@@ -747,6 +747,27 @@ def start_scheduler():
misfire_grace_time=120, misfire_grace_time=120,
) )
# Prediction markets — own jittered cadence (Polymarket/Kalshi clearnet egress).
# Kept off the fixed 5-minute slow tier so poll timing is less fingerprintable.
from services.fetchers.prediction_markets import fetch_prediction_markets
_pm_interval_m = max(5, int(os.environ.get("PREDICTION_MARKETS_INTERVAL_MINUTES", "7")))
_pm_jitter_s = max(0, int(os.environ.get("PREDICTION_MARKETS_SCHEDULER_JITTER_S", "240")))
_pm_initial_max_s = max(0, int(os.environ.get("PREDICTION_MARKETS_INITIAL_DELAY_MAX_S", "180")))
_pm_first_run = datetime.utcnow() + timedelta(
seconds=random.randint(30, max(30, _pm_initial_max_s))
)
_scheduler.add_job(
lambda: _run_task_with_health(fetch_prediction_markets, "fetch_prediction_markets"),
"interval",
minutes=_pm_interval_m,
jitter=_pm_jitter_s,
next_run_time=_pm_first_run,
id="prediction_markets",
max_instances=1,
misfire_grace_time=300,
)
# Weather alerts — every 5 minutes (time-critical, separate from slow tier) # Weather alerts — every 5 minutes (time-critical, separate from slow tier)
_scheduler.add_job( _scheduler.add_job(
lambda: _run_task_with_health(fetch_weather_alerts, "fetch_weather_alerts"), lambda: _run_task_with_health(fetch_weather_alerts, "fetch_weather_alerts"),
@@ -9,6 +9,7 @@ import json
import logging import logging
import math import math
import os import os
import random
import threading import threading
import time import time
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -21,23 +22,34 @@ _prev_probabilities: dict[str, float] = {}
_market_cache = TTLCache(maxsize=1, ttl=300) _market_cache = TTLCache(maxsize=1, ttl=300)
_POLYMARKET_PAGE_DELAY_S = float(os.environ.get("MESH_POLYMARKET_PAGE_DELAY_S", "0.02")) _POLYMARKET_PAGE_DELAY_S = float(os.environ.get("MESH_POLYMARKET_PAGE_DELAY_S", "0.02"))
_KALSHI_PAGE_DELAY_S = float(os.environ.get("MESH_KALSHI_PAGE_DELAY_S", "0.08")) _KALSHI_PAGE_DELAY_S = float(os.environ.get("MESH_KALSHI_PAGE_DELAY_S", "0.08"))
_POLYMARKET_PAGE_DELAY_JITTER_S = float(os.environ.get("MESH_POLYMARKET_PAGE_DELAY_JITTER_S", "0.08"))
_KALSHI_PAGE_DELAY_JITTER_S = float(os.environ.get("MESH_KALSHI_PAGE_DELAY_JITTER_S", "0.2"))
# Random delay before each full Polymarket+Kalshi cycle (decorrelates from other slow-tier jobs).
_PRE_FETCH_JITTER_S = float(os.environ.get("PREDICTION_MARKETS_PRE_FETCH_JITTER_S", "90"))
# Random pause between finishing Polymarket pagination and starting Kalshi.
_PROVIDER_GAP_JITTER_S = float(os.environ.get("PREDICTION_MARKETS_PROVIDER_GAP_JITTER_S", "45"))
_provider_pace_lock = threading.Lock() _provider_pace_lock = threading.Lock()
_provider_last_request_at: dict[str, float] = {} _provider_last_request_at: dict[str, float] = {}
def prediction_markets_fetch_enabled() -> bool: def prediction_markets_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into Polymarket/Kalshi pulls.""" """Return True when UI opt-in or PREDICTION_MARKETS_ENABLED enables pulls."""
return str(os.environ.get("PREDICTION_MARKETS_ENABLED", "")).strip().lower() in { from services.prediction_markets_settings import prediction_markets_fetch_enabled as _enabled
"1",
"true", return _enabled()
"yes",
"on",
}
def _pace_provider(provider: str, min_interval_s: float) -> None: def _pace_provider(provider: str, min_interval_s: float) -> None:
if min_interval_s <= 0: if min_interval_s <= 0:
return return
jitter_s = (
_POLYMARKET_PAGE_DELAY_JITTER_S
if provider == "polymarket"
else _KALSHI_PAGE_DELAY_JITTER_S
if provider == "kalshi"
else 0.0
)
min_interval_s += random.uniform(0.0, jitter_s) if jitter_s > 0 else 0.0
with _provider_pace_lock: with _provider_pace_lock:
now = time.monotonic() now = time.monotonic()
wait_s = min_interval_s - (now - _provider_last_request_at.get(provider, 0.0)) wait_s = min_interval_s - (now - _provider_last_request_at.get(provider, 0.0))
@@ -47,6 +59,24 @@ def _pace_provider(provider: str, min_interval_s: float) -> None:
_provider_last_request_at[provider] = now _provider_last_request_at[provider] = now
def _apply_pre_fetch_jitter() -> None:
if _PRE_FETCH_JITTER_S <= 0:
return
delay = random.uniform(0.0, _PRE_FETCH_JITTER_S)
if delay >= 1.0:
logger.debug("Prediction markets: pre-fetch jitter %.1fs", delay)
time.sleep(delay)
def _apply_provider_gap_jitter() -> None:
if _PROVIDER_GAP_JITTER_S <= 0:
return
delay = random.uniform(0.0, _PROVIDER_GAP_JITTER_S)
if delay >= 1.0:
logger.debug("Prediction markets: provider gap jitter %.1fs", delay)
time.sleep(delay)
def _finite_or_none(value): def _finite_or_none(value):
try: try:
n = float(value) n = float(value)
@@ -750,7 +780,9 @@ def _merge_markets(poly_events: list[dict], kalshi_events: list[dict]) -> list[d
@cached(_market_cache) @cached(_market_cache)
def fetch_prediction_markets_raw() -> list[dict]: def fetch_prediction_markets_raw() -> list[dict]:
"""Fetch and merge prediction markets from both sources. Cached 5 min.""" """Fetch and merge prediction markets from both sources. Cached 5 min."""
_apply_pre_fetch_jitter()
poly = _fetch_polymarket_events() poly = _fetch_polymarket_events()
_apply_provider_gap_jitter()
kalshi = _fetch_kalshi_events() kalshi = _fetch_kalshi_events()
merged = _merge_markets(poly, kalshi) merged = _merge_markets(poly, kalshi)
logger.info( logger.info(
@@ -0,0 +1,81 @@
"""Operator opt-in for Polymarket/Kalshi outbound fetches (Global Threat Intercept)."""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_OPT_IN_FILE = Path(__file__).resolve().parent.parent / "data" / "prediction_markets_opt_in.json"
_OPT_IN_LOCK = threading.Lock()
def _env_flag(name: str) -> str:
return str(os.getenv(name, "")).strip().lower()
def get_prediction_markets_ui_opt_in() -> bool:
if not _OPT_IN_FILE.exists():
return False
try:
payload = json.loads(_OPT_IN_FILE.read_text(encoding="utf-8"))
return bool(payload.get("opted_in"))
except (OSError, json.JSONDecodeError, TypeError) as exc:
logger.warning("Prediction markets opt-in file unreadable: %s", exc)
return False
def set_prediction_markets_ui_opt_in(opted_in: bool) -> None:
_OPT_IN_FILE.parent.mkdir(parents=True, exist_ok=True)
with _OPT_IN_LOCK:
_OPT_IN_FILE.write_text(
json.dumps({"opted_in": bool(opted_in)}, indent=2),
encoding="utf-8",
)
def prediction_markets_env_forced_on() -> bool:
return _env_flag("PREDICTION_MARKETS_ENABLED") in {"1", "true", "yes", "on"}
def prediction_markets_env_forced_off() -> bool:
return _env_flag("PREDICTION_MARKETS_ENABLED") in {"0", "false", "no", "off"}
def prediction_markets_fetch_enabled() -> bool:
"""True when UI opt-in or env enables Polymarket/Kalshi pulls."""
if get_prediction_markets_ui_opt_in():
return True
return prediction_markets_env_forced_on()
def prediction_markets_status() -> dict[str, Any]:
ui_opted_in = get_prediction_markets_ui_opt_in()
env_on = prediction_markets_env_forced_on()
env_off = prediction_markets_env_forced_off()
env_override = None
if env_on:
env_override = "on"
elif env_off:
env_override = "off"
return {
"enabled": prediction_markets_fetch_enabled(),
"ui_opted_in": ui_opted_in,
"env_override": env_override,
"jitter": {
"scheduler_interval_minutes": int(
os.environ.get("PREDICTION_MARKETS_INTERVAL_MINUTES", "7")
),
"scheduler_jitter_seconds": int(
os.environ.get("PREDICTION_MARKETS_SCHEDULER_JITTER_S", "240")
),
"pre_fetch_jitter_seconds": float(
os.environ.get("PREDICTION_MARKETS_PRE_FETCH_JITTER_S", "90")
),
},
}
+44 -22
View File
@@ -26,6 +26,46 @@ def _planetary_user_agent() -> str:
return outbound_user_agent("sentinel2-planetary-computer") return outbound_user_agent("sentinel2-planetary-computer")
def _sign_planetary_href(href: str) -> str:
"""Sign a Planetary Computer blob URL with a short-lived SAS token."""
if not href or "blob.core.windows.net" not in href:
return href
try:
account = href.split(".blob.core.windows.net")[0].split("//")[-1]
token_resp = requests.get(
f"https://planetarycomputer.microsoft.com/api/sas/v1/token/{account}",
timeout=5,
headers={"User-Agent": _planetary_user_agent()},
)
token_resp.raise_for_status()
token = token_resp.json().get("token", "")
if not token:
return href
sep = "&" if "?" in href else "?"
return f"{href}{sep}{token}"
except (requests.RequestException, ValueError, KeyError):
return href
def _scene_from_stac_feature(item: dict) -> dict:
assets = item.get("assets", {}) or {}
rendered = assets.get("rendered_preview") or {}
thumbnail = assets.get("thumbnail") or {}
props = item.get("properties", {}) or {}
thumb_href = _sign_planetary_href(thumbnail.get("href") or rendered.get("href") or "")
full_href = _sign_planetary_href(rendered.get("href") or thumbnail.get("href") or "")
return {
"found": True,
"scene_id": item.get("id"),
"datetime": props.get("datetime"),
"cloud_cover": props.get("eo:cloud_cover"),
"thumbnail_url": thumb_href or None,
"fullres_url": full_href or None,
"bbox": list(item.get("bbox", [])) if item.get("bbox") else None,
"platform": props.get("platform", "Sentinel-2"),
}
def _esri_imagery_fallback(lat: float, lng: float) -> dict: def _esri_imagery_fallback(lat: float, lng: float) -> dict:
lat_span = 0.18 lat_span = 0.18
lng_span = 0.24 lng_span = 0.24
@@ -53,14 +93,14 @@ def _esri_imagery_fallback(lat: float, lng: float) -> dict:
def search_sentinel2_scene(lat: float, lng: float) -> dict: def search_sentinel2_scene(lat: float, lng: float) -> dict:
"""Search for the latest Sentinel-2 L2A scene covering a point.""" """Search for up to 3 recent Sentinel-2 L2A scenes covering a point."""
cache_key = f"{round(lat, 2)}_{round(lng, 2)}" cache_key = f"{round(lat, 2)}_{round(lng, 2)}"
if cache_key in _sentinel_cache: if cache_key in _sentinel_cache:
return _sentinel_cache[cache_key] return _sentinel_cache[cache_key]
try: try:
end = datetime.utcnow() end = datetime.utcnow()
start = end - timedelta(days=30) start = end - timedelta(days=60)
search_payload = { search_payload = {
"collections": ["sentinel-2-l2a"], "collections": ["sentinel-2-l2a"],
"intersects": {"type": "Point", "coordinates": [lng, lat]}, "intersects": {"type": "Point", "coordinates": [lng, lat]},
@@ -83,26 +123,8 @@ def search_sentinel2_scene(lat: float, lng: float) -> dict:
_sentinel_cache[cache_key] = result _sentinel_cache[cache_key] = result
return result return result
item = features[0] scenes = [_scene_from_stac_feature(item) for item in features[:3]]
assets = item.get("assets", {}) or {} result = {**scenes[0], "scenes": scenes}
rendered = assets.get("rendered_preview") or {}
thumbnail = assets.get("thumbnail") or {}
# Full-res image URL — what opens when user clicks
fullres_url = rendered.get("href") or thumbnail.get("href")
# Thumbnail URL — what shows in the popup card
thumb_url = thumbnail.get("href") or rendered.get("href")
result = {
"found": True,
"scene_id": item.get("id"),
"datetime": item.get("properties", {}).get("datetime"),
"cloud_cover": item.get("properties", {}).get("eo:cloud_cover"),
"thumbnail_url": thumb_url,
"fullres_url": fullres_url,
"bbox": list(item.get("bbox", [])) if item.get("bbox") else None,
"platform": item.get("properties", {}).get("platform", "Sentinel-2"),
}
_sentinel_cache[cache_key] = result _sentinel_cache[cache_key] = result
return result return result
@@ -0,0 +1,55 @@
"""Prediction market fetch timing uses jitter to reduce poll fingerprinting."""
from unittest.mock import MagicMock, patch
import pytest
from services.fetchers import prediction_markets as pm
@pytest.fixture(autouse=True)
def clear_market_cache():
pm._market_cache.clear()
yield
pm._market_cache.clear()
def test_pre_fetch_jitter_sleeps_when_configured(monkeypatch):
monkeypatch.setattr(pm, "_PRE_FETCH_JITTER_S", 10.0)
sleeps: list[float] = []
monkeypatch.setattr(pm.time, "sleep", lambda s: sleeps.append(s))
monkeypatch.setattr(pm.random, "uniform", lambda _a, _b: 4.5)
pm._apply_pre_fetch_jitter()
assert sleeps == [4.5]
def test_fetch_raw_applies_provider_gap(monkeypatch):
monkeypatch.setenv("PREDICTION_MARKETS_ENABLED", "true")
monkeypatch.setattr(pm, "_apply_pre_fetch_jitter", lambda: None)
gap_calls: list[int] = []
def _track_gap():
gap_calls.append(1)
monkeypatch.setattr(pm, "_apply_provider_gap_jitter", _track_gap)
monkeypatch.setattr(pm, "_fetch_polymarket_events", lambda: [])
monkeypatch.setattr(pm, "_fetch_kalshi_events", lambda: [])
monkeypatch.setattr(pm, "_merge_markets", lambda _p, _k: [])
pm.fetch_prediction_markets_raw()
assert gap_calls == [1]
def test_pace_provider_adds_per_page_jitter(monkeypatch):
monkeypatch.setattr(pm, "_POLYMARKET_PAGE_DELAY_JITTER_S", 1.0)
monkeypatch.setattr(pm, "_provider_last_request_at", {"polymarket": pm.time.monotonic()})
monkeypatch.setattr(pm.random, "uniform", lambda _a, _b: 0.5)
sleeps: list[float] = []
monkeypatch.setattr(pm.time, "sleep", lambda s: sleeps.append(s))
pm._pace_provider("polymarket", 0.02)
assert sleeps == [pytest.approx(0.52)]
@@ -0,0 +1,24 @@
"""UI opt-in for prediction markets (Global Threat Intercept)."""
from services import prediction_markets_settings as pm_settings
from services.fetchers import prediction_markets
def test_ui_opt_in_enables_fetch(monkeypatch, tmp_path):
opt_file = tmp_path / "prediction_markets_opt_in.json"
monkeypatch.setattr(pm_settings, "_OPT_IN_FILE", opt_file)
monkeypatch.delenv("PREDICTION_MARKETS_ENABLED", raising=False)
assert pm_settings.prediction_markets_fetch_enabled() is False
pm_settings.set_prediction_markets_ui_opt_in(True)
assert pm_settings.prediction_markets_fetch_enabled() is True
assert prediction_markets.prediction_markets_fetch_enabled() is True
def test_env_force_on_without_ui_file(monkeypatch, tmp_path):
opt_file = tmp_path / "prediction_markets_opt_in.json"
monkeypatch.setattr(pm_settings, "_OPT_IN_FILE", opt_file)
monkeypatch.setenv("PREDICTION_MARKETS_ENABLED", "true")
assert pm_settings.prediction_markets_fetch_enabled() is True
@@ -0,0 +1,74 @@
"""Right-click dossier returns up to 3 signed Sentinel-2 scenes."""
from unittest.mock import MagicMock, patch
import pytest
from services import sentinel_search as ss
@pytest.fixture(autouse=True)
def clear_sentinel_cache():
ss._sentinel_cache.clear()
yield
ss._sentinel_cache.clear()
def _stac_feature(scene_id: str, dt: str, cloud: float) -> dict:
href = f"https://sentinel2euwest.blob.core.windows.net/sentinel2-l2a/{scene_id}.tif"
return {
"id": scene_id,
"bbox": [0, 0, 1, 1],
"properties": {
"datetime": dt,
"eo:cloud_cover": cloud,
"platform": "Sentinel-2A",
},
"assets": {
"rendered_preview": {"href": href},
"thumbnail": {"href": href},
},
}
@patch("services.sentinel_search.requests.get")
@patch("services.sentinel_search.requests.post")
def test_search_returns_three_scenes(mock_post, mock_get):
mock_post.return_value = MagicMock(
ok=True,
raise_for_status=MagicMock(),
json=lambda: {
"features": [
_stac_feature("s1", "2026-05-28T10:00:00Z", 5.0),
_stac_feature("s2", "2026-05-20T10:00:00Z", 12.0),
_stac_feature("s3", "2026-05-10T10:00:00Z", 18.0),
],
},
)
mock_get.return_value = MagicMock(
ok=True,
raise_for_status=MagicMock(),
json=lambda: {"token": "sig=test"},
)
result = ss.search_sentinel2_scene(29.0, 51.0)
assert result["found"] is True
assert result["scene_id"] == "s1"
assert len(result["scenes"]) == 3
assert result["scenes"][1]["scene_id"] == "s2"
assert "sig=test" in (result["scenes"][0]["fullres_url"] or "")
@patch("services.sentinel_search.requests.post")
def test_search_esri_fallback_has_no_scenes(mock_post):
mock_post.return_value = MagicMock(
ok=True,
raise_for_status=MagicMock(),
json=lambda: {"features": []},
)
result = ss.search_sentinel2_scene(29.0, 51.0)
assert result["fallback"] is True
assert "scenes" not in result
@@ -45,8 +45,10 @@ def test_fimi_falsy_value_does_not_call_upstream(monkeypatch):
def test_prediction_markets_disabled_by_default(monkeypatch): def test_prediction_markets_disabled_by_default(monkeypatch):
from services.fetchers import _store, prediction_markets from services.fetchers import _store, prediction_markets
from services import prediction_markets_settings as pm_settings
monkeypatch.delenv("PREDICTION_MARKETS_ENABLED", raising=False) monkeypatch.delenv("PREDICTION_MARKETS_ENABLED", raising=False)
monkeypatch.setattr(pm_settings, "get_prediction_markets_ui_opt_in", lambda: False)
monkeypatch.setitem(_store.latest_data, "prediction_markets", [{"id": "old"}]) monkeypatch.setitem(_store.latest_data, "prediction_markets", [{"id": "old"}])
monkeypatch.setattr( monkeypatch.setattr(
prediction_markets, "fetch_prediction_markets_raw", _explode prediction_markets, "fetch_prediction_markets_raw", _explode
+70 -6
View File
@@ -2,7 +2,9 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, Clock, Minus, Plus, ExternalLink, Brain, Loader2 } from 'lucide-react'; import { AlertTriangle, Clock, Minus, Plus, ExternalLink, Brain, Loader2, TrendingUp } from 'lucide-react';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { usePredictionMarketsOptIn } from '@/hooks/usePredictionMarketsOptIn';
import React, { useEffect, useRef, useCallback } from 'react'; import React, { useEffect, useRef, useCallback } from 'react';
import WikiImage from '@/components/WikiImage'; import WikiImage from '@/components/WikiImage';
import { fetchWikipediaSummary } from '@/lib/wikimediaClient'; import { fetchWikipediaSummary } from '@/lib/wikimediaClient';
@@ -332,6 +334,9 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
const [aiSummaryOpen, setAiSummaryOpen] = useState(false); const [aiSummaryOpen, setAiSummaryOpen] = useState(false);
const [aiSummary, setAiSummary] = useState<any>(null); const [aiSummary, setAiSummary] = useState<any>(null);
const [aiSummaryLoading, setAiSummaryLoading] = useState(false); const [aiSummaryLoading, setAiSummaryLoading] = useState(false);
const [pmConsentOpen, setPmConsentOpen] = useState(false);
const { status: pmStatus, setOptIn: setPmOptIn } = usePredictionMarketsOptIn();
const marketsCorrelationEnabled = pmStatus?.enabled ?? false;
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Intentionally omitting map click triggers for expanding // Intentionally omitting map click triggers for expanding
@@ -1357,7 +1362,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</span> </span>
</div> </div>
)} )}
{item.prediction_odds && item.prediction_odds.consensus_pct != null && ( {marketsCorrelationEnabled && item.prediction_odds && item.prediction_odds.consensus_pct != null && (
<div className="border-b border-[var(--border-primary)] pb-2"> <div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">MARKET CORRELATION</span> <span className="text-[var(--text-muted)] text-[10px] block mb-1.5">MARKET CORRELATION</span>
<div className="p-2 bg-purple-950/30 border border-purple-500/30 rounded-sm"> <div className="p-2 bg-purple-950/30 border border-purple-500/30 rounded-sm">
@@ -1430,7 +1435,37 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
/* CCTV is now handled by the fullscreen OPTIC INTERCEPT modal in MaplibreViewer */ /* CCTV is now handled by the fullscreen OPTIC INTERCEPT modal in MaplibreViewer */
if (selectedEntity?.type === 'cctv') return null; if (selectedEntity?.type === 'cctv') return null;
const pmJitter = pmStatus?.jitter;
const pmConsentMessage =
'Enabling prediction markets lets this node contact Polymarket and Kalshi over clearnet from your server IP (not through the wormhole). ' +
'Matching headlines may show a purple MKT strip with consensus odds. ' +
(pmJitter
? `Poll timing is jittered (~${pmJitter.scheduler_interval_minutes} min base + up to ${pmJitter.scheduler_jitter_seconds}s) to reduce obvious patterns. `
: 'Poll timing is jittered to reduce obvious patterns. ') +
'Wormhole/Tor still only covers private mesh traffic. Turn off anytime with MKT OFF.';
return ( return (
<>
<ConfirmDialog
open={pmConsentOpen}
title="Enable prediction market correlation?"
message={pmConsentMessage}
confirmLabel="Enable MKT"
cancelLabel="Cancel"
danger={false}
onCancel={() => setPmConsentOpen(false)}
onConfirm={() => {
void (async () => {
try {
await setPmOptIn(true);
} catch (e) {
console.warn('Prediction markets opt-in failed:', e);
} finally {
setPmConsentOpen(false);
}
})();
}}
/>
<motion.div <motion.div
initial={{ y: 50, opacity: 0 }} initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
@@ -1485,10 +1520,38 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
initial={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
className="text-[10px] text-cyan-500/80 mt-1 flex items-center justify-between font-bold relative z-10" className="text-[10px] text-cyan-500/80 mt-1 flex items-center justify-between font-bold relative z-10 gap-2"
> >
<span className="px-1 border border-cyan-500/30">SYS.STATUS: MONITORING</span> <div className="flex items-center gap-1.5 min-w-0">
<span className="flex items-center gap-1"><Clock size={10} /> {data?.last_updated ? formatTime(data.last_updated) : "SCANNING"}</span> <span className="px-1 border border-cyan-500/30 shrink-0">SYS.STATUS: MONITORING</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (marketsCorrelationEnabled) {
void setPmOptIn(false).catch((err) => {
console.warn('Prediction markets opt-out failed:', err);
});
} else {
setPmConsentOpen(true);
}
}}
className={`shrink-0 flex items-center gap-1 px-1.5 py-0.5 border rounded-sm transition-colors ${
marketsCorrelationEnabled
? 'border-purple-500/50 bg-purple-950/40 text-purple-300'
: 'border-cyan-800/40 bg-black/40 text-cyan-700 hover:text-purple-300 hover:border-purple-600/40'
}`}
title={
marketsCorrelationEnabled
? 'Prediction market correlation on intercept stories (clearnet Polymarket/Kalshi)'
: 'Enable prediction market correlation on intercept stories'
}
>
<TrendingUp size={10} />
MKT {marketsCorrelationEnabled ? 'ON' : 'OFF'}
</button>
</div>
<span className="flex items-center gap-1 shrink-0"><Clock size={10} /> {data?.last_updated ? formatTime(data.last_updated) : "SCANNING"}</span>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -1841,7 +1904,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span> <span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
</div> </div>
)} )}
{item.prediction_odds && item.prediction_odds.consensus_pct != null && ( {marketsCorrelationEnabled && item.prediction_odds && item.prediction_odds.consensus_pct != null && (
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[11px] font-mono flex items-center gap-1.5"> <div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[11px] font-mono flex items-center gap-1.5">
<span className="text-purple-400 font-bold">MKT</span> <span className="text-purple-400 font-bold">MKT</span>
<span className="text-purple-300 truncate flex-1" title={item.prediction_odds.title}>{item.prediction_odds.title}</span> <span className="text-purple-300 truncate flex-1" title={item.prediction_odds.title}>{item.prediction_odds.title}</span>
@@ -1933,6 +1996,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</motion.div> </motion.div>
</>
); );
} }
@@ -0,0 +1,54 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { API_BASE } from '@/lib/api';
export type PredictionMarketsStatus = {
enabled: boolean;
ui_opted_in: boolean;
env_override: 'on' | 'off' | null;
jitter?: {
scheduler_interval_minutes: number;
scheduler_jitter_seconds: number;
pre_fetch_jitter_seconds: number;
};
};
export function usePredictionMarketsOptIn(enabled = true) {
const [status, setStatus] = useState<PredictionMarketsStatus | null>(null);
const refreshStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/prediction-markets/status`);
if (!res.ok) return;
const body = (await res.json()) as PredictionMarketsStatus;
setStatus(body);
} catch {
// Backend may still be starting.
}
}, []);
useEffect(() => {
if (!enabled) return;
void refreshStatus();
}, [enabled, refreshStatus]);
const setOptIn = useCallback(
async (optedIn: boolean) => {
const res = await fetch(`${API_BASE}/api/prediction-markets/opt-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opted_in: optedIn }),
});
if (!res.ok) {
throw new Error(`Prediction markets opt-in failed (${res.status})`);
}
const body = (await res.json()) as PredictionMarketsStatus;
setStatus(body);
return body;
},
[],
);
return { status, refreshStatus, setOptIn };
}