From 54b326ff420e3f59b1cb9182247694e52bec1be5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 23 Apr 2026 20:45:32 +0800 Subject: [PATCH] feat: implement comprehensive Polymarket weather analysis service with frontend dashboard and market scanning capabilities --- .env.example | 5 +- .../app/api/city/[name]/market-scan/route.ts | 5 + .../dashboard/FutureForecastModal.tsx | 3 +- .../dashboard/PolyWeatherDashboard.tsx | 55 +++++- frontend/hooks/useDashboardStore.tsx | 55 +++++- frontend/lib/dashboard-client.ts | 5 + frontend/lib/dashboard-types.ts | 11 ++ frontend/lib/types.ts | 11 ++ src/data_collection/polymarket_readonly.py | 174 ++++++++---------- tests/test_polymarket_readonly.py | 126 ++++++++----- web/analysis_service.py | 9 + web/routes.py | 18 +- 12 files changed, 323 insertions(+), 154 deletions(-) diff --git a/.env.example b/.env.example index 080a369b..beb8b63f 100644 --- a/.env.example +++ b/.env.example @@ -175,8 +175,9 @@ POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com POLYMARKET_CLOB_URL=https://clob.polymarket.com POLYMARKET_CHAIN_ID=137 POLYMARKET_HTTP_TIMEOUT_SEC=8 -POLYMARKET_MARKET_CACHE_TTL_SEC=180 -POLYMARKET_PRICE_CACHE_TTL_SEC=10 +POLYMARKET_MARKET_CACHE_TTL_SEC=60 +POLYMARKET_PRICE_CACHE_TTL_SEC=30 +POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30 POLYMARKET_WS_PRICE_ENABLED=false POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market POLYMARKET_WS_QUOTE_TTL_SEC=8 diff --git a/frontend/app/api/city/[name]/market-scan/route.ts b/frontend/app/api/city/[name]/market-scan/route.ts index a9b25929..394d20b3 100644 --- a/frontend/app/api/city/[name]/market-scan/route.ts +++ b/frontend/app/api/city/[name]/market-scan/route.ts @@ -33,6 +33,11 @@ export async function GET( params.set("market_slug", marketSlug); } + const lite = req.nextUrl.searchParams.get("lite"); + if (lite) { + params.set("lite", lite); + } + const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`; try { diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 3e6baf91..38586d62 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -746,6 +746,7 @@ export function FutureForecastModal() { dashboardClient .getCityMarketScan(cityName, { force: false, + lite: false, marketSlug: detail.market_scan?.primary_market?.slug || null, targetDate: dateStr, }) @@ -766,7 +767,7 @@ export function FutureForecastModal() { return; } refreshMarketScan(); - }, 3000); + }, 30_000); return () => { cancelled = true; diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index 04567918..50ea61a6 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -346,6 +346,7 @@ type AssistantMessage = { }; type OpportunityScanState = "pending" | "complete" | "empty" | "error"; +const HOME_OPPORTUNITY_REFRESH_MS = 30_000; type AssistantDockPosition = { right: number; @@ -2395,6 +2396,7 @@ function DashboardScreen() { const { t } = useI18n(); const didAutoFocusRef = useRef(false); const marketScanInflightRef = useRef>(new Set()); + const marketScanPollingRef = useRef(false); const [marketScanStatusByCity, setMarketScanStatusByCity] = useState< Record >({}); @@ -2517,7 +2519,7 @@ function DashboardScreen() { const existingDetail = store.cityDetailsByName[cityName]; const marketScan = existingDetail?.market_scan || - (await store.ensureCityMarketScan(cityName, false)); + (await store.ensureCityMarketScan(cityName, false, { lite: true })); if (cancelled) return; setMarketScanStatusByCity((current) => ({ ...current, @@ -2552,6 +2554,57 @@ function DashboardScreen() { store.proAccess.loading, ]); + useEffect(() => { + if (!showHomepageChrome) return; + if (store.proAccess.loading || !store.proAccess.authenticated) return; + if (!marketScanTargetNames.length) return; + + let cancelled = false; + let intervalId: number | null = null; + + const refreshAllMarketScans = async () => { + if (cancelled || marketScanPollingRef.current) return; + marketScanPollingRef.current = true; + const queue = [...marketScanTargetNames]; + try { + const runWorker = async () => { + while (!cancelled) { + const cityName = queue.shift(); + if (!cityName) return; + await store.ensureCityMarketScan(cityName, false, { lite: true }); + } + }; + await Promise.allSettled( + Array.from({ length: Math.min(3, queue.length) }, () => runWorker()), + ); + } finally { + marketScanPollingRef.current = false; + } + }; + + void refreshAllMarketScans(); + intervalId = window.setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + void refreshAllMarketScans(); + }, HOME_OPPORTUNITY_REFRESH_MS); + + return () => { + cancelled = true; + marketScanPollingRef.current = false; + if (intervalId != null) { + window.clearInterval(intervalId); + } + }; + }, [ + marketScanTargetNames, + showHomepageChrome, + store.ensureCityMarketScan, + store.proAccess.authenticated, + store.proAccess.loading, + ]); + return (
Promise; focusCity: (cityName: string) => Promise; forecastModalMode: ForecastModalMode | null; @@ -298,6 +303,39 @@ function mergeCityDetail( }; } +function mergeMarketScan( + current: CityDetail["market_scan"] | undefined, + incoming: CityDetail["market_scan"] | null | undefined, +): CityDetail["market_scan"] | undefined { + if (!incoming) return current; + if (!current) return incoming || undefined; + + const preserveHeavySlices = incoming.scan_scope === "lite"; + const nextTopBuckets = + preserveHeavySlices && + (!Array.isArray(incoming.top_buckets) || incoming.top_buckets.length === 0) + ? current.top_buckets + : incoming.top_buckets; + const nextAllBuckets = + preserveHeavySlices && + (!Array.isArray(incoming.all_buckets) || incoming.all_buckets.length === 0) + ? current.all_buckets + : incoming.all_buckets; + const nextRecentTrades = + preserveHeavySlices && + (!Array.isArray(incoming.recent_trades) || incoming.recent_trades.length === 0) + ? current.recent_trades + : incoming.recent_trades; + + return { + ...current, + ...incoming, + top_buckets: nextTopBuckets, + all_buckets: nextAllBuckets, + recent_trades: nextRecentTrades, + }; +} + function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta { const history = Array.isArray(payload.history) ? payload.history : []; const previewCount = Number(payload.preview_count || history.length || 0); @@ -569,7 +607,15 @@ export function DashboardStoreProvider({ return detail; }; - const ensureCityMarketScan = async (cityName: string, force = false) => { + const ensureCityMarketScan = async ( + cityName: string, + force = false, + options?: { + lite?: boolean; + marketSlug?: string | null; + targetDate?: string | null; + }, + ) => { let cached = cityDetailsByName[cityName]; try { if (!cached) { @@ -577,7 +623,10 @@ export function DashboardStoreProvider({ } const payload = await dashboardClient.getCityMarketScan(cityName, { force, - targetDate: cached?.local_date || selectedForecastDate || null, + lite: options?.lite === true, + marketSlug: options?.marketSlug || null, + targetDate: + options?.targetDate || cached?.local_date || selectedForecastDate || null, }); if (!payload.market_scan) return null; setCityDetailsByName((current) => { @@ -587,7 +636,7 @@ export function DashboardStoreProvider({ ...current, [cityName]: { ...detail, - market_scan: payload.market_scan || undefined, + market_scan: mergeMarketScan(detail.market_scan, payload.market_scan), }, }; }); diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 6b4e1273..03e62710 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -303,6 +303,7 @@ export const dashboardClient = { cityName: string, options?: { force?: boolean; + lite?: boolean; marketSlug?: string | null; targetDate?: string | null; }, @@ -318,9 +319,13 @@ export const dashboardClient = { if (options?.marketSlug) { params.set("market_slug", options.marketSlug); } + if (options?.lite) { + params.set("lite", "true"); + } const requestKey = [ cityName, force ? "force" : "cached", + options?.lite ? "lite" : "full", options?.targetDate || "", options?.marketSlug || "", ].join("::"); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 68909572..5a6c0f4c 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -369,23 +369,34 @@ export interface MarketScan { temperature_bucket?: ProbabilityBucket | null; model_probability?: number | null; market_price?: number | null; + midpoint?: number | null; + spread?: number | null; edge_percent?: number | null; signal_label?: string | null; confidence?: string | null; + probability_engine?: string | null; + probability_calibration_mode?: string | null; yes_token?: MarketToken | null; no_token?: MarketToken | null; yes_buy?: number | null; yes_sell?: number | null; + yes_midpoint?: number | null; + yes_spread?: number | null; no_buy?: number | null; no_sell?: number | null; + no_midpoint?: number | null; + no_spread?: number | null; last_trade_price?: number | null; liquidity?: number | null; volume?: number | null; + quote_source?: string | null; + quote_age_ms?: number | null; price_analysis?: MarketPriceAnalysis | null; sparkline?: number[]; top_buckets?: MarketTopBucket[] | null; all_buckets?: MarketTopBucket[] | null; recent_trades?: unknown[]; + scan_scope?: "lite" | "full" | string | null; websocket?: Record; } diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index bc682641..1648cb41 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -283,18 +283,28 @@ export interface MarketScan { temperature_bucket: any | null; model_probability: number | null; market_price: number | null; + midpoint?: number | null; + spread?: number | null; edge_percent: number | null; signal_label: "BUY YES" | "BUY NO" | "MONITOR"; confidence: "low" | "medium" | "high"; + probability_engine?: string | null; + probability_calibration_mode?: string | null; yes_token: MarketToken | null; no_token: MarketToken | null; yes_buy: number | null; yes_sell: number | null; + yes_midpoint?: number | null; + yes_spread?: number | null; no_buy: number | null; no_sell: number | null; + no_midpoint?: number | null; + no_spread?: number | null; last_trade_price: number | null; liquidity: number | null; volume: number | null; + quote_source?: string | null; + quote_age_ms?: number | null; sparkline: number[]; top_buckets?: Array<{ label?: string | null; @@ -311,6 +321,7 @@ export interface MarketScan { is_primary?: boolean; }>; recent_trades: Trade[]; + scan_scope?: "lite" | "full" | string | null; websocket: any; } diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 551411d2..e2a37641 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -3,7 +3,7 @@ Polymarket read-only market layer. P0 scope: - Market discovery from Gamma REST -- Price / orderbook read from py-clob-client public methods (fallback to CLOB REST) +- Price / midpoint / spread / orderbook read from CLOB REST - No signing, no order placement """ @@ -23,12 +23,6 @@ import httpx from loguru import logger from src.data_collection.city_registry import ALIASES, CITY_REGISTRY -from src.data_collection.polymarket_ws_cache import PolymarketWsQuoteCache - -try: - from py_clob_client.client import ClobClient # type: ignore -except Exception: # pragma: no cover - optional dependency in P0 - ClobClient = None def _safe_float(value: Any) -> Optional[float]: @@ -382,15 +376,14 @@ class PolymarketReadOnlyLayer: .strip() .rstrip("/") ) - self.chain_id = _safe_int(os.getenv("POLYMARKET_CHAIN_ID", "137"), 137) self.http_timeout = _safe_float(os.getenv("POLYMARKET_HTTP_TIMEOUT_SEC")) or 8.0 self.market_cache_ttl = _safe_int( - os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "180"), - 180, + os.getenv("POLYMARKET_MARKET_CACHE_TTL_SEC", "60"), + 60, ) self.price_cache_ttl = _safe_int( - os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "10"), - 10, + os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "30"), + 30, ) self.discovery_pages = _safe_int( os.getenv("POLYMARKET_DISCOVERY_PAGES", "6"), @@ -414,9 +407,6 @@ class PolymarketReadOnlyLayer: self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0} self._price_cache: Dict[str, Dict[str, Any]] = {} self._lock = threading.Lock() - self._clob_client: Any = None - self._clob_unavailable_reason: Optional[str] = None - self._ws_quote_cache = PolymarketWsQuoteCache.from_env() def _market_scan_debug_enabled(self) -> bool: return ( @@ -441,6 +431,7 @@ class PolymarketReadOnlyLayer: model_probability: Optional[float] = None, fallback_sparkline: Optional[List[float]] = None, forced_market_slug: Optional[str] = None, + include_related_buckets: bool = True, ) -> Dict[str, Any]: date_str = _extract_iso_date(target_date) or str(target_date or "") city_key = _normalize_city_key(city) @@ -459,6 +450,8 @@ class PolymarketReadOnlyLayer: "temperature_bucket": temperature_bucket, "model_probability": model_probability, "market_price": None, + "midpoint": None, + "spread": None, "edge_percent": None, "signal_label": "MONITOR", "confidence": "low", @@ -466,16 +459,27 @@ class PolymarketReadOnlyLayer: "no_token": None, "yes_buy": None, "yes_sell": None, + "yes_midpoint": None, + "yes_spread": None, "no_buy": None, "no_sell": None, + "no_midpoint": None, + "no_spread": None, "last_trade_price": None, "liquidity": None, "volume": None, + "quote_source": None, + "quote_age_ms": None, "price_analysis": None, "sparkline": fallback_sparkline or [], "top_buckets": [], + "all_buckets": [], "recent_trades": [], - "websocket": {}, + "scan_scope": "full" if include_related_buckets else "lite", + "websocket": { + "enabled": False, + "status": "disabled_rest_only", + }, } if not self.enabled: @@ -632,21 +636,24 @@ class PolymarketReadOnlyLayer: signal_label, confidence = self._derive_signal(edge_percent, liquidity) - top_bucket_limit = max( - 1, - _safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4), - ) - all_bucket_limit = max( - top_bucket_limit, - _safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8), - ) - all_buckets = self._build_top_temperature_buckets( - city_key=market_city_key, - target_date=date_str, - primary_market=market, - limit=all_bucket_limit, - ) - top_buckets = list(all_buckets[:top_bucket_limit]) + top_buckets: List[Dict[str, Any]] = [] + all_buckets: List[Dict[str, Any]] = [] + if include_related_buckets: + top_bucket_limit = max( + 1, + _safe_int(os.getenv("POLYMARKET_TOP_BUCKET_LIMIT", "4"), 4), + ) + all_bucket_limit = max( + top_bucket_limit, + _safe_int(os.getenv("POLYMARKET_ALL_BUCKET_LIMIT", "8"), 8), + ) + all_buckets = self._build_top_temperature_buckets( + city_key=market_city_key, + target_date=date_str, + primary_market=market, + limit=all_bucket_limit, + ) + top_buckets = list(all_buckets[:top_bucket_limit]) yes_payload = { "outcome": yes_token.get("outcome") or "Yes", @@ -672,12 +679,28 @@ class PolymarketReadOnlyLayer: "quote_age_ms": _safe_int(no_prices.get("quote_age_ms"), 0), "book": no_prices.get("book"), } + yes_midpoint = _extract_price(yes_payload.get("midpoint")) + no_midpoint = _extract_price(no_payload.get("midpoint")) + yes_buy = _extract_price(yes_payload.get("buy_price")) + yes_sell = _extract_price(yes_payload.get("sell_price")) + no_buy = _extract_price(no_payload.get("buy_price")) + no_sell = _extract_price(no_payload.get("sell_price")) + yes_spread = ( + max(0.0, float(yes_buy) - float(yes_sell)) + if yes_buy is not None and yes_sell is not None + else None + ) + no_spread = ( + max(0.0, float(no_buy) - float(no_sell)) + if no_buy is not None and no_sell is not None + else None + ) price_analysis = self._build_price_analysis( model_probability=model_probability, - yes_buy=_extract_price(yes_payload.get("buy_price")), - yes_sell=_extract_price(yes_payload.get("sell_price")), - no_buy=_extract_price(no_payload.get("buy_price")), - no_sell=_extract_price(no_payload.get("sell_price")), + yes_buy=yes_buy, + yes_sell=yes_sell, + no_buy=no_buy, + no_sell=no_sell, ) sparkline_values: List[float] = [] @@ -702,27 +725,35 @@ class PolymarketReadOnlyLayer: "selected_condition_id": condition_id, "selected_slug": market_slug, "market_price": market_price, + "midpoint": yes_midpoint if yes_midpoint is not None else market_price, + "spread": yes_spread, "edge_percent": edge_percent, "signal_label": signal_label, "confidence": confidence, "yes_token": yes_payload, "no_token": no_payload, - "yes_buy": _extract_price(yes_payload.get("buy_price")), - "yes_sell": _extract_price(yes_payload.get("sell_price")), - "no_buy": _extract_price(no_payload.get("buy_price")), - "no_sell": _extract_price(no_payload.get("sell_price")), + "yes_buy": yes_buy, + "yes_sell": yes_sell, + "yes_midpoint": yes_midpoint, + "yes_spread": yes_spread, + "no_buy": no_buy, + "no_sell": no_sell, + "no_midpoint": no_midpoint, + "no_spread": no_spread, "last_trade_price": last_trade_price, "liquidity": liquidity, "volume": volume, + "quote_source": yes_prices.get("quote_source"), + "quote_age_ms": _safe_int(yes_prices.get("quote_age_ms"), 0), "price_analysis": price_analysis, "sparkline": sparkline_values, "top_buckets": top_buckets, "all_buckets": all_buckets, - "websocket": { - "enabled": self._ws_quote_cache.enabled, - "status": self._ws_quote_cache.status(), - "market_url": market_url, - "asset_ids": [ + "websocket": { + "enabled": False, + "status": "disabled_rest_only", + "market_url": market_url, + "asset_ids": [ token for token in [ yes_payload.get("token_id"), @@ -1488,32 +1519,11 @@ class PolymarketReadOnlyLayer: return None, None - def _get_clob_client(self) -> Optional[Any]: - if self._clob_unavailable_reason: - return None - if self._clob_client is not None: - return self._clob_client - if ClobClient is None: - self._clob_unavailable_reason = "py-clob-client is not installed." - return None - try: - self._clob_client = ClobClient(host=self.clob_url, chain_id=self.chain_id) - return self._clob_client - except Exception as exc: - self._clob_unavailable_reason = f"ClobClient init failed: {exc}" - logger.warning(self._clob_unavailable_reason) - return None - def _get_token_market_data(self, token_id: str) -> Dict[str, Any]: token_id = str(token_id or "").strip() if not token_id: return {} - self._ws_quote_cache.subscribe([token_id]) - ws_data = self._ws_quote_cache.get_market_data(token_id) - if ws_data is not None: - return ws_data - now = time.time() with self._lock: cached = self._price_cache.get(token_id) @@ -1527,33 +1537,7 @@ class PolymarketReadOnlyLayer: return data def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]: - # 1) Preferred path: py-clob-client public methods. - clob = self._get_clob_client() - if clob is not None: - try: - buy = _extract_price(self._safe_call(clob, "get_price", token_id, "BUY")) - sell = _extract_price(self._safe_call(clob, "get_price", token_id, "SELL")) - midpoint = _extract_price(self._safe_call(clob, "get_midpoint", token_id)) - last_trade = _extract_price( - self._safe_call(clob, "get_last_trade_price", token_id) - ) - orderbook_raw = self._safe_call(clob, "get_order_book", token_id) - book, book_liquidity = self._normalize_orderbook(orderbook_raw) - buy, sell = self._resolve_trade_prices(buy=buy, sell=sell, book=book) - return { - "buy": buy, - "sell": sell, - "midpoint": midpoint, - "last_trade_price": last_trade, - "quote_source": "polymarket_clob_client", - "quote_age_ms": 0, - "book": book, - "book_liquidity": book_liquidity, - } - except Exception as exc: - logger.warning(f"py-clob-client read failed for {token_id}: {exc}") - - # 2) Fallback path: direct CLOB REST. + # REST-only path: CLOB public endpoints. buy = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"})) sell = _extract_price( self._clob_get("/price", {"token_id": token_id, "side": "SELL"}) @@ -1576,12 +1560,6 @@ class PolymarketReadOnlyLayer: "book_liquidity": book_liquidity, } - def _safe_call(self, client: Any, method: str, *args: Any) -> Any: - fn = getattr(client, method, None) - if not callable(fn): - return None - return fn(*args) - def _clob_get(self, path: str, params: Dict[str, Any]) -> Any: url = f"{self.clob_url}{path}" try: diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index edd29391..c9c6b445 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -37,31 +37,25 @@ def test_extract_market_bucket_range_supports_fahrenheit_ranges(): assert layer._extract_market_bucket_label(market, 80.5) == "80-81F" -def test_fetch_token_market_data_prefers_orderbook_executable_prices(): - class FakeClob: - @staticmethod - def get_price(_token_id: str, side: str): - if side == "BUY": - return {"price": "0.11"} - return {"price": "0.88"} - - @staticmethod - def get_midpoint(_token_id: str): - return {"midpoint": "0.50"} - - @staticmethod - def get_last_trade_price(_token_id: str): - return {"price": "0.49"} - - @staticmethod - def get_order_book(_token_id: str): - return { - "bids": [{"price": "0.24", "size": "10"}], - "asks": [{"price": "0.26", "size": "12"}], - } - +def test_fetch_token_market_data_uses_rest_orderbook_executable_prices(): layer = PolymarketReadOnlyLayer() - layer._get_clob_client = lambda: FakeClob() + payloads = { + ("/price", "BUY"): {"price": "0.11"}, + ("/price", "SELL"): {"price": "0.88"}, + ("/midpoint", None): {"midpoint": "0.50"}, + ("/last-trade-price", None): {"price": "0.49"}, + ("/book", None): { + "bids": [{"price": "0.24", "size": "10"}], + "asks": [{"price": "0.26", "size": "12"}], + }, + } + + def _fake_clob_get(path, params): + if path == "/price": + return payloads[(path, params.get("side"))] + return payloads[(path, None)] + + layer._clob_get = _fake_clob_get data = layer._fetch_token_market_data("token-1") @@ -71,36 +65,25 @@ def test_fetch_token_market_data_prefers_orderbook_executable_prices(): assert data["sell"] == 0.24 assert data["midpoint"] == 0.5 assert data["last_trade_price"] == 0.49 - assert data["quote_source"] == "polymarket_clob_client" + assert data["quote_source"] == "polymarket_clob_rest" -def test_get_token_market_data_prefers_fresh_ws_cache(): +def test_get_token_market_data_uses_price_cache_within_ttl(): layer = PolymarketReadOnlyLayer() + calls = [] - class FakeWsCache: - enabled = True + def _fake_fetch(_token_id): + calls.append(_token_id) + return {"buy": 0.33, "sell": 0.31, "midpoint": 0.32} - def subscribe(self, asset_ids): - self.asset_ids = list(asset_ids) + layer._fetch_token_market_data = _fake_fetch - @staticmethod - def get_market_data(_token_id): - return { - "buy": 0.33, - "sell": 0.31, - "midpoint": 0.32, - "quote_source": "polymarket_ws", - "quote_age_ms": 80, - } + first = layer._get_token_market_data("token-1") + second = layer._get_token_market_data("token-1") - layer._ws_quote_cache = FakeWsCache() - layer._fetch_token_market_data = lambda _token_id: {"buy": 0.99} - - data = layer._get_token_market_data("token-1") - - assert data["buy"] == 0.33 - assert data["sell"] == 0.31 - assert data["quote_source"] == "polymarket_ws" + assert first["buy"] == 0.33 + assert second["midpoint"] == 0.32 + assert calls == ["token-1"] def test_price_analysis_computes_edge_kelly_and_lock(): @@ -200,6 +183,55 @@ def test_lau_fau_shan_uses_shenzhen_market_city(): assert scan["selected_slug"] == "highest-temperature-in-shenzhen-on-april-23-2026-30c-or-higher" +def test_build_market_scan_lite_skips_related_buckets(): + layer = PolymarketReadOnlyLayer() + + layer._find_primary_market = lambda *_args, **_kwargs: ( + { + "id": "market-1", + "question": "Will the highest temperature in Shenzhen be 30C or higher on April 23?", + "slug": "highest-temperature-in-shenzhen-on-april-23-2026-30c-or-higher", + "conditionId": "condition-1", + "active": True, + "closed": False, + "acceptingOrders": True, + }, + None, + ) + layer._extract_market_tokens = lambda _market: [ + {"outcome": "Yes", "token_id": "yes-token"}, + {"outcome": "No", "token_id": "no-token"}, + ] + layer._get_token_market_data = lambda token_id: ( + {"buy": 0.42, "sell": 0.40, "midpoint": 0.41} + if token_id == "yes-token" + else {"buy": 0.61, "sell": 0.59, "midpoint": 0.60} + ) + + called = {"bucket": 0} + + def _fake_build_top_temperature_buckets(**_kwargs): + called["bucket"] += 1 + return [{"value": 30.0, "market_price": 0.41}] + + layer._build_top_temperature_buckets = _fake_build_top_temperature_buckets + + scan = layer.build_market_scan( + city="Shenzhen", + target_date="2026-04-23", + temperature_bucket={"temp": 30, "probability": 0.58}, + model_probability=0.58, + include_related_buckets=False, + ) + + assert scan["scan_scope"] == "lite" + assert scan["midpoint"] == 0.41 + assert round(scan["spread"], 6) == 0.02 + assert scan["top_buckets"] == [] + assert scan["all_buckets"] == [] + assert called["bucket"] == 0 + + def test_hydrate_bucket_prices_uses_executable_quotes_without_midpoint(): layer = PolymarketReadOnlyLayer() buckets = [ diff --git a/web/analysis_service.py b/web/analysis_service.py index 68387c85..7e81e19e 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2801,6 +2801,7 @@ def _build_city_market_scan_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ) -> Dict[str, Any]: city = str(data.get("name") or "").strip().lower() local_date = str(data.get("local_date") or "").strip() @@ -2882,12 +2883,20 @@ def _build_city_market_scan_payload( model_probability=model_probability, fallback_sparkline=fallback_sparkline, forced_market_slug=market_slug, + include_related_buckets=not lite, ) if isinstance(market_scan, dict): market_scan["anchor_model"] = anchor_model market_scan["anchor_high"] = anchor_temp market_scan["anchor_settlement"] = anchor_settlement market_scan["open_meteo_settlement"] = anchor_settlement + probabilities = data.get("probabilities") or {} + market_scan["probability_engine"] = str( + probabilities.get("engine") or "legacy" + ).strip() or "legacy" + market_scan["probability_calibration_mode"] = str( + probabilities.get("calibration_mode") or "legacy" + ).strip() or "legacy" return { "market_scan": market_scan, "selected_date": selected_date or data.get("local_date"), diff --git a/web/routes.py b/web/routes.py index c89ccfe8..86d51463 100644 --- a/web/routes.py +++ b/web/routes.py @@ -148,7 +148,7 @@ CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900"))) MARKET_SCAN_PAYLOAD_TTL_SEC = max( 5, - int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "20")), + int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")), ) CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max( 60, @@ -181,6 +181,7 @@ def _market_scan_cache_key( data: dict, market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ) -> str: local_date = str(data.get("local_date") or "").strip() requested_date = str(target_date or "").strip() @@ -189,7 +190,7 @@ def _market_scan_cache_key( if requested_date and isinstance(multi_model_daily, dict) and requested_date not in multi_model_daily: selected_date = local_date normalized_slug = str(market_slug or "").strip().lower() - return f"{selected_date}|{normalized_slug}" + return f"{selected_date}|{normalized_slug}|lite={1 if lite else 0}" def _attach_market_scan_payload( @@ -197,6 +198,7 @@ def _attach_market_scan_payload( *, market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ) -> dict: if not isinstance(payload, dict): return payload @@ -204,6 +206,7 @@ def _attach_market_scan_payload( payload, market_slug=market_slug, target_date=target_date, + lite=lite, ) now_ts = time.time() payload["market_scan_payload"] = scan_payload @@ -213,6 +216,7 @@ def _attach_market_scan_payload( payload, market_slug=market_slug, target_date=target_date, + lite=lite, ) return payload @@ -222,6 +226,7 @@ def _get_cached_market_scan_payload( *, market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ) -> Optional[dict]: if not isinstance(payload, dict): return None @@ -232,6 +237,7 @@ def _get_cached_market_scan_payload( payload, market_slug=market_slug, target_date=target_date, + lite=lite, ) cached_key = str(payload.get("market_scan_cache_key") or "") if cached_key != expected_key: @@ -250,11 +256,13 @@ def _refresh_market_scan_payload_from_cached_analysis( *, market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ) -> dict: _attach_market_scan_payload( payload, market_slug=market_slug, target_date=target_date, + lite=lite, ) _CACHE_DB.set_city_cache( "market", @@ -1629,6 +1637,7 @@ async def city_market_scan( force_refresh: bool = False, market_slug: Optional[str] = None, target_date: Optional[str] = None, + lite: bool = False, ): _assert_entitlement(request) city = _normalize_city_or_404(name) @@ -1638,6 +1647,7 @@ async def city_market_scan( data, market_slug=market_slug, target_date=target_date, + lite=lite, ) if cached_scan is not None: return cached_scan @@ -1649,6 +1659,7 @@ async def city_market_scan( data, market_slug=market_slug, target_date=target_date, + lite=lite, ) if cached_scan is not None: if not _market_analysis_cache_is_fresh(cached_entry): @@ -1661,6 +1672,7 @@ async def city_market_scan( data, market_slug=market_slug, target_date=target_date, + lite=lite, ) else: _schedule_cache_refresh(background_tasks, kind="market", city=city) @@ -1670,6 +1682,7 @@ async def city_market_scan( data, market_slug=market_slug, target_date=target_date, + lite=lite, ) if cached_scan is not None: return cached_scan @@ -1678,5 +1691,6 @@ async def city_market_scan( data, market_slug, target_date, + lite, )