diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 37376b28..2fe83592 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -95,23 +95,42 @@ function findMarketBucketForDisplayTemp( displayTemp: number | null, detail: Pick, ) { - const marketTemp = displayTempToMarketCelsius(displayTemp, detail); - if (marketTemp == null) return null; + if (displayTemp == null || !Number.isFinite(displayTemp)) return null; - const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26; let best: MarketTopBucket | null = null; let bestDelta = Number.POSITIVE_INFINITY; for (const bucket of buckets) { + const bucketUnit = String(bucket.unit || "").toUpperCase(); + const compareTemp = + bucketUnit === "F" + ? displayTemp + : displayTempToMarketCelsius(displayTemp, detail); + if (compareTemp == null) continue; + + const lower = bucket.lower != null ? Number(bucket.lower) : null; + const upper = bucket.upper != null ? Number(bucket.upper) : null; + if ( + lower != null && + upper != null && + Number.isFinite(lower) && + Number.isFinite(upper) && + compareTemp >= lower - 0.01 && + compareTemp <= upper + 0.01 + ) { + return bucket; + } + const rawTemp = bucket.temp ?? bucket.value ?? null; if (rawTemp == null) continue; const candidateTemp = Number(rawTemp); if (!Number.isFinite(candidateTemp)) continue; - const delta = Math.abs(candidateTemp - marketTemp); + const delta = Math.abs(candidateTemp - compareTemp); if (delta < bestDelta) { best = bucket; bestDelta = delta; } } + const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26; return best && bestDelta <= tolerance ? best : null; } diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index d1c261c2..3d5837a9 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -310,6 +310,9 @@ export interface MarketTopBucket { label?: string | null; value?: number | null; temp?: number | null; + lower?: number | null; + upper?: number | null; + unit?: string | null; probability?: number | null; market_price?: number | null; yes_buy?: number | null; diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 8de611b5..94247788 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -1249,20 +1249,69 @@ class PolymarketReadOnlyLayer: return None def _extract_market_bucket_temp(self, market: Dict[str, Any]) -> Optional[float]: + parsed = self._extract_market_bucket_range(market) + if parsed: + lower, upper, _unit = parsed + if upper is not None: + return (lower + upper) / 2.0 + return lower + return None + + def _extract_market_bucket_range( + self, + market: Dict[str, Any], + ) -> Optional[Tuple[float, Optional[float], str]]: + slug = str(market.get("slug") or "").strip().lower() + slug_range = re.search( + r"-(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)([cf])(?:$|-or-higher|-or-lower|orhigher|orlower)", + slug, + re.IGNORECASE, + ) + if slug_range: + lower = _safe_float(slug_range.group(1)) + upper = _safe_float(slug_range.group(2)) + unit = slug_range.group(3).upper() + if lower is not None and upper is not None and abs(upper - lower) <= 20: + return min(lower, upper), max(lower, upper), unit + text = " ".join( str(part or "") for part in ( market.get("question"), market.get("title"), - market.get("slug"), ) ) - if not text: - return None - # Match "... 9°C ..." / "... 9C ..." / "... -2 C ..." - match = re.search(r"(-?\d+(?:\.\d+)?)\s*°?\s*c\b", text, re.IGNORECASE) + # Match range buckets such as "80-81°F" and "80 to 81F". + range_match = re.search( + r"(-?\d+(?:\.\d+)?)\s*(?:-|–|—|\bto\b)\s*(-?\d+(?:\.\d+)?)\s*°?\s*([cf])\b", + text, + re.IGNORECASE, + ) + if range_match: + lower = _safe_float(range_match.group(1)) + upper = _safe_float(range_match.group(2)) + unit = range_match.group(3).upper() + if lower is not None and upper is not None and abs(upper - lower) <= 20: + return min(lower, upper), max(lower, upper), unit + + slug_exact = re.search( + r"-(\d+(?:\.\d+)?)([cf])(?:$|-or-higher|-or-lower|orhigher|orlower)", + slug, + re.IGNORECASE, + ) + if slug_exact: + value = _safe_float(slug_exact.group(1)) + unit = slug_exact.group(2).upper() + if value is not None: + return value, None, unit + + # Match "... 9°C ..." / "... 9F ..." / "... -2 C ..." + match = re.search(r"(-?\d+(?:\.\d+)?)\s*°?\s*([cf])\b", text, re.IGNORECASE) if match: - return _safe_float(match.group(1)) + value = _safe_float(match.group(1)) + unit = match.group(2).upper() + if value is not None: + return value, None, unit return None def _build_weather_event_slug(self, city_key: str, target_date: str) -> Optional[str]: @@ -1605,12 +1654,14 @@ class PolymarketReadOnlyLayer: Dict[str, Any], Dict[str, Any], Dict[str, Any], + Optional[Tuple[float, Optional[float], str]], ] ] = [] for market in candidate_markets: if not self._market_trade_state(market).get("tradable"): continue bucket_temp = self._extract_market_bucket_temp(market) + bucket_range = self._extract_market_bucket_range(market) if bucket_temp is None: continue @@ -1658,6 +1709,7 @@ class PolymarketReadOnlyLayer: no_token, yes_prices, no_prices, + bucket_range, ) ) @@ -1681,6 +1733,7 @@ class PolymarketReadOnlyLayer: no_token, yes_prices, no_prices, + bucket_range, ) in ranked: row_direction = self._extract_market_bucket_direction(market) if ( @@ -1713,6 +1766,9 @@ class PolymarketReadOnlyLayer: "label": self._extract_market_bucket_label(market, bucket_temp), "value": bucket_temp, "temp": bucket_temp, + "lower": bucket_range[0] if bucket_range else None, + "upper": bucket_range[1] if bucket_range else None, + "unit": bucket_range[2] if bucket_range else None, "probability": market_prob, "market_price": yes_midpoint, "yes_buy": yes_buy, @@ -1843,12 +1899,16 @@ class PolymarketReadOnlyLayer: ) -> str: question = str(market.get("question") or market.get("title") or "").strip() direction = self._extract_market_bucket_direction(market) + bucket_range = self._extract_market_bucket_range(market) + unit = bucket_range[2] if bucket_range else "C" + if bucket_range and bucket_range[1] is not None: + return f"{bucket_range[0]:g}-{bucket_range[1]:g}{unit}" if bucket_temp is not None: if direction == "above": - return f"{bucket_temp:g}C+" + return f"{bucket_temp:g}{unit}+" if direction == "below": - return f"<={bucket_temp:g}C" - return f"{bucket_temp:g}C" + return f"<={bucket_temp:g}{unit}" + return f"{bucket_temp:g}{unit}" return question or str(market.get("slug") or "") def _extract_market_bucket_direction(self, market: Dict[str, Any]) -> str: diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index 1f4f8002..e3197729 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -25,6 +25,18 @@ def test_normalize_orderbook_uses_sorted_best_prices(): assert book["ask_levels"][0][0] == 0.39 +def test_extract_market_bucket_range_supports_fahrenheit_ranges(): + layer = PolymarketReadOnlyLayer() + market = { + "question": "Will the highest temperature in Miami be between 80-81°F on April 21?", + "slug": "highest-temperature-in-miami-on-april-21-2026-80-81f", + } + + assert layer._extract_market_bucket_range(market) == (80.0, 81.0, "F") + assert layer._extract_market_bucket_temp(market) == 80.5 + assert layer._extract_market_bucket_label(market, 80.5) == "80-81F" + + def test_fetch_token_market_data_prefers_orderbook_executable_prices(): class FakeClob: @staticmethod