From d8cc1936188714249e943c95563328bff3b18a26 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 11 Mar 2026 11:04:24 +0800 Subject: [PATCH] feat: Integrate Polymarket read-only data collection and display in dashboard panels. --- .../components/dashboard/PanelSections.tsx | 33 +++- src/data_collection/polymarket_readonly.py | 150 ++++++++++++------ tests/test_polymarket_readonly.py | 82 ++++++++++ 3 files changed, 216 insertions(+), 49 deletions(-) diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 229131b2..87d155cd 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -119,6 +119,19 @@ function getMarketTopBuckets(scan?: MarketScan | null) { ); } +function getMarketTopBucketKey(bucket: MarketTopBucket) { + const valueNum = Number(bucket?.value); + if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; + + const tempNum = Number(bucket?.temp); + if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; + + const parsed = parseTempFromText(bucket?.label); + if (parsed != null) return `l:${parsed.toFixed(2)}`; + + return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`; +} + export function HeroSummary() { const { data } = useCityData(); const { locale } = useI18n(); @@ -367,11 +380,23 @@ export function ProbabilityDistribution({ const marketNoText = toPercent(marketNoPrice); const isToday = !targetDate || targetDate === detail.local_date; const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; - const sortedMarketTopBuckets = [...marketTopBuckets] - .sort((a, b) => Number(b.probability || 0) - Number(a.probability || 0)) - .slice(0, 4); + const sortedMarketTopBuckets = (() => { + const sorted = [...marketTopBuckets].sort( + (a, b) => Number(b.probability || 0) - Number(a.probability || 0), + ); + const deduped: Array = []; + const seenKeys = new Set(); + for (const row of sorted) { + const key = getMarketTopBucketKey(row); + if (seenKeys.has(key)) continue; + seenKeys.add(key); + deduped.push(row); + if (deduped.length >= 4) break; + } + return deduped; + })(); const useMarketTopBuckets = - marketScan?.available && sortedMarketTopBuckets.length > 0; + marketScan?.available && sortedMarketTopBuckets.length >= 2; const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability); return ( diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 8868c3f8..7c24eb7b 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -1332,52 +1332,72 @@ class PolymarketReadOnlyLayer: top_rows: List[Dict[str, Any]] = [] max_items = max(1, int(limit or 4)) primary_slug = str(primary_market.get("slug") or "").strip().lower() + primary_direction = self._extract_market_bucket_direction(primary_market) + seen_temp_keys: set = set() - for ( - market_prob, - _volume, - bucket_temp, - market, - yes_token, - no_token, - yes_prices, - no_prices, - ) in ranked[ - :max_items - ]: - yes_buy = _extract_price(yes_prices.get("buy")) - yes_sell = _extract_price(yes_prices.get("sell")) - yes_midpoint = _extract_price(yes_prices.get("midpoint")) or market_prob - no_buy = _extract_price(no_prices.get("buy")) - no_sell = _extract_price(no_prices.get("sell")) + def _append_rows(enforce_primary_direction: bool) -> None: + for ( + market_prob, + _volume, + bucket_temp, + market, + yes_token, + no_token, + yes_prices, + no_prices, + ) in ranked: + row_direction = self._extract_market_bucket_direction(market) + if ( + enforce_primary_direction + and primary_direction in {"above", "below"} + and row_direction != primary_direction + ): + continue - if no_buy is None and yes_buy is not None: - no_buy = max(0.0, min(1.0, 1.0 - yes_buy)) - if no_sell is None and yes_sell is not None: - no_sell = max(0.0, min(1.0, 1.0 - yes_sell)) + temp_key = f"{round(float(bucket_temp), 2):.2f}" + if temp_key in seen_temp_keys: + continue - market_slug = str(market.get("slug") or "").strip() + yes_buy = _extract_price(yes_prices.get("buy")) + yes_sell = _extract_price(yes_prices.get("sell")) + yes_midpoint = _extract_price(yes_prices.get("midpoint")) or market_prob + no_buy = _extract_price(no_prices.get("buy")) + no_sell = _extract_price(no_prices.get("sell")) - top_rows.append( - { - "label": self._extract_market_bucket_label(market, bucket_temp), - "value": bucket_temp, - "temp": bucket_temp, - "probability": market_prob, - "market_price": yes_midpoint, - "yes_buy": yes_buy, - "yes_sell": yes_sell, - "no_buy": no_buy, - "no_sell": no_sell, - "slug": market_slug or None, - "question": market.get("question") or market.get("title"), - "is_primary": bool( - primary_slug - and market_slug - and primary_slug == market_slug.strip().lower() - ), - } - ) + if no_buy is None and yes_buy is not None: + no_buy = max(0.0, min(1.0, 1.0 - yes_buy)) + if no_sell is None and yes_sell is not None: + no_sell = max(0.0, min(1.0, 1.0 - yes_sell)) + + market_slug = str(market.get("slug") or "").strip() + top_rows.append( + { + "label": self._extract_market_bucket_label(market, bucket_temp), + "value": bucket_temp, + "temp": bucket_temp, + "probability": market_prob, + "market_price": yes_midpoint, + "yes_buy": yes_buy, + "yes_sell": yes_sell, + "no_buy": no_buy, + "no_sell": no_sell, + "slug": market_slug or None, + "question": market.get("question") or market.get("title"), + "is_primary": bool( + primary_slug + and market_slug + and primary_slug == market_slug.strip().lower() + ), + } + ) + seen_temp_keys.add(temp_key) + if len(top_rows) >= max_items: + break + + if primary_direction in {"above", "below"}: + _append_rows(enforce_primary_direction=True) + if len(top_rows) < max_items: + _append_rows(enforce_primary_direction=False) return top_rows @@ -1480,11 +1500,51 @@ class PolymarketReadOnlyLayer: bucket_temp: Optional[float], ) -> str: question = str(market.get("question") or market.get("title") or "").strip() - text = question.lower() + direction = self._extract_market_bucket_direction(market) if bucket_temp is not None: - if "or higher" in text or "or above" in text or "and above" in text: + if direction == "above": return f"{bucket_temp:g}C+" - if "or lower" in text or "or below" in text or "and below" in text: + if direction == "below": return f"<={bucket_temp:g}C" return f"{bucket_temp:g}C" return question or str(market.get("slug") or "") + + def _extract_market_bucket_direction(self, market: Dict[str, Any]) -> str: + text = " ".join( + str(part or "") + for part in ( + market.get("question"), + market.get("title"), + market.get("slug"), + ) + ).lower() + if not text: + return "exact" + + if any( + token in text + for token in ( + "or higher", + "or above", + "and above", + "forhigher", + "forabove", + "or-higher", + "or-above", + ) + ): + return "above" + if any( + token in text + for token in ( + "or lower", + "or below", + "and below", + "forlower", + "forbelow", + "or-lower", + "or-below", + ) + ): + return "below" + return "exact" diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index fdecde32..a810e548 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -60,3 +60,85 @@ def test_fetch_token_market_data_prefers_orderbook_executable_prices(): assert data["midpoint"] == 0.5 assert data["last_trade_price"] == 0.49 + +def test_build_top_temperature_buckets_dedupes_same_temperature(): + layer = PolymarketReadOnlyLayer() + + primary_market = { + "slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher", + "question": "Will the highest temperature in Ankara be 14C or higher on March 12?", + "volumeNum": 1000, + } + markets = [ + primary_market, + { + "slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher-v2", + "question": "Will the highest temperature in Ankara be 14C or higher on March 12? (v2)", + "volumeNum": 900, + }, + { + "slug": "highest-temperature-in-ankara-on-march-12-2026-13c-or-higher", + "question": "Will the highest temperature in Ankara be 13C or higher on March 12?", + "volumeNum": 1100, + }, + { + "slug": "highest-temperature-in-ankara-on-march-12-2026-12c-or-higher", + "question": "Will the highest temperature in Ankara be 12C or higher on March 12?", + "volumeNum": 1200, + }, + { + "slug": "highest-temperature-in-ankara-on-march-12-2026-14c-or-lower", + "question": "Will the highest temperature in Ankara be 14C or lower on March 12?", + "volumeNum": 1300, + }, + ] + layer._collect_related_temperature_markets = ( + lambda city_key, target_date, primary_market: markets + ) + + def _fake_extract_market_tokens(market): + slug = str(market.get("slug") or "") + return [ + {"outcome": "Yes", "token_id": f"{slug}|yes"}, + {"outcome": "No", "token_id": f"{slug}|no"}, + ] + + layer._extract_market_tokens = _fake_extract_market_tokens + + midpoint_map = { + "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher": 0.79, + "highest-temperature-in-ankara-on-march-12-2026-14c-or-higher-v2": 0.16, + "highest-temperature-in-ankara-on-march-12-2026-13c-or-higher": 0.06, + "highest-temperature-in-ankara-on-march-12-2026-12c-or-higher": 0.01, + "highest-temperature-in-ankara-on-march-12-2026-14c-or-lower": 0.92, + } + + def _fake_get_token_market_data(token_id): + slug, side = str(token_id).split("|", 1) + if side == "yes": + midpoint = midpoint_map.get(slug, 0.5) + return { + "midpoint": midpoint, + "buy": max(0.0, min(1.0, midpoint + 0.01)), + "sell": max(0.0, min(1.0, midpoint - 0.01)), + } + midpoint = 1.0 - midpoint_map.get(slug, 0.5) + return { + "midpoint": midpoint, + "buy": max(0.0, min(1.0, midpoint + 0.01)), + "sell": max(0.0, min(1.0, midpoint - 0.01)), + } + + layer._get_token_market_data = _fake_get_token_market_data + + rows = layer._build_top_temperature_buckets( + city_key="ankara", + target_date="2026-03-12", + primary_market=primary_market, + limit=4, + ) + + values = [row.get("value") for row in rows] + assert len(values) == len(set(values)) + assert rows[0]["value"] == 14.0 + assert all(not str(row.get("label") or "").startswith("<=") for row in rows)