""" Polymarket read-only market layer. P0 scope: - Market discovery from Gamma REST - Price / midpoint / spread / orderbook read from CLOB REST - No signing, no order placement """ from __future__ import annotations import json import math import os import re import threading import time import unicodedata from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple import httpx from loguru import logger from src.data_collection.city_registry import ALIASES, CITY_REGISTRY def _safe_float(value: Any) -> Optional[float]: if value is None: return None try: if isinstance(value, str): value = value.strip() if not value: return None numeric = float(value) if math.isnan(numeric) or math.isinf(numeric): return None return numeric except Exception: return None def _safe_int(value: Any, default: int) -> int: try: return int(value) except Exception: return default def _safe_bool(value: Any) -> Optional[bool]: if value is None: return None if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False return bool(value) def _normalize_text(value: Any) -> str: text = str(value or "").strip().lower() if not text: return "" text = unicodedata.normalize("NFKD", text) text = "".join(ch for ch in text if not unicodedata.combining(ch)) text = text.replace("_", " ").replace("-", " ") return " ".join(text.split()) def _normalize_city_key(city: Any) -> str: raw = _normalize_text(city) if not raw: return "" return ALIASES.get(raw, raw) MARKET_CITY_ALIASES: Dict[str, str] = { # Lau Fau Shan has its own HKO observation / settlement layer, but # Polymarket lists this temperature market under nearby Shenzhen. "lau fau shan": "shenzhen", } def _resolve_market_city_key(city_key: str) -> str: return MARKET_CITY_ALIASES.get(city_key, city_key) def _contains_token(haystack: str, token: str) -> bool: token = _normalize_text(token) if not token: return False pattern = r"\b" + re.escape(token) + r"\b" try: return re.search(pattern, haystack) is not None except re.error: return False def _json_or_list(value: Any) -> List[Any]: if value is None: return [] if isinstance(value, list): return value if isinstance(value, tuple): return list(value) if isinstance(value, str): text = value.strip() if not text: return [] try: parsed = json.loads(text) if isinstance(parsed, list): return parsed except Exception: return [] return [] def _to_plain_dict(value: Any) -> Dict[str, Any]: if isinstance(value, dict): return value if value is None: return {} if hasattr(value, "dict") and callable(value.dict): try: data = value.dict() if isinstance(data, dict): return data except Exception: pass if hasattr(value, "__dict__"): try: data = dict(vars(value)) if isinstance(data, dict): return data except Exception: pass return {} def _extract_price(value: Any) -> Optional[float]: if value is None: return None direct = _safe_float(value) if direct is not None: return direct if isinstance(value, dict): for key in ( "price", "mid", "midpoint", "value", "last_trade_price", "lastPrice", ): numeric = _safe_float(value.get(key)) if numeric is not None: return numeric plain = _to_plain_dict(value) if plain: for key in ( "price", "mid", "midpoint", "value", "last_trade_price", "lastPrice", ): numeric = _safe_float(plain.get(key)) if numeric is not None: return numeric return None def _clamp_probability(value: Optional[float]) -> Optional[float]: if value is None: return None if value < 0.0: return 0.0 if value > 1.0: return 1.0 return value def _clamp_float(value: Optional[float], lower: float, upper: float) -> Optional[float]: if value is None: return None return max(lower, min(upper, float(value))) def _parse_hhmm_to_minutes(value: Any) -> Optional[int]: text = str(value or "").strip() if not text or ":" not in text: return None try: hh, mm = text.split(":", 1) hour = int(hh) minute = int(mm[:2]) except Exception: return None if hour < 0 or hour > 23 or minute < 0 or minute > 59: return None return hour * 60 + minute def _extract_iso_date(value: Any) -> Optional[str]: if not value: return None text = str(value).strip() if not text: return None if len(text) >= 10 and text[4] == "-" and text[7] == "-": return text[:10] # Common API formats from Gamma/CLOB candidates = ( text, text.replace("Z", "+00:00"), text.split(".")[0] + "Z" if "." in text and "T" in text else text, ) for candidate in candidates: try: dt = datetime.fromisoformat(candidate.replace("Z", "+00:00")) return dt.date().isoformat() except Exception: continue return None def _parse_iso_datetime_utc(value: Any) -> Optional[datetime]: if not value: return None text = str(value).strip() if not text: return None # Prefer timestamps that include a time component; plain dates are ambiguous. if "T" not in text: return None try: dt = datetime.fromisoformat(text.replace("Z", "+00:00")) except Exception: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _build_city_token_index() -> Dict[str, List[str]]: result: Dict[str, List[str]] = {} for key, info in CITY_REGISTRY.items(): normalized_key = _normalize_text(key) tokens = {normalized_key, normalized_key.replace(" ", "")} display_name = _normalize_text(info.get("name")) if display_name: tokens.add(display_name) tokens.add(display_name.replace(" ", "")) for alias, target in ALIASES.items(): if target != key: continue norm_alias = _normalize_text(alias) if not norm_alias: continue # Ignore very short aliases to reduce false-positive matching. if len(norm_alias) < 3 and norm_alias not in {"nyc"}: continue tokens.add(norm_alias) if key == "new york": tokens.update({"central park", "new yorks central park"}) if key == "sao paulo": tokens.update({"sao paulo", "sao-paulo", "sao paulo"}) result[key] = sorted(tokens, key=len, reverse=True) return result CITY_TOKEN_INDEX = _build_city_token_index() WEATHER_KEYWORDS = ( "temperature", "temp", "high", "low", "hotter", "colder", "above", "below", ) MONTH_TO_NUM = { "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3, "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7, "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12, } def _parse_target_date(value: str) -> Optional[datetime]: try: return datetime.fromisoformat(value) except Exception: return None def _extract_dates_from_text( text: str, default_year: Optional[int], ) -> List[str]: dates: List[str] = [] for year, month, day in re.findall(r"\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b", text): try: parsed = datetime(int(year), int(month), int(day)).date().isoformat() dates.append(parsed) except Exception: continue month_pattern = "|".join(sorted(MONTH_TO_NUM.keys(), key=len, reverse=True)) for month_name, day_raw, year_raw in re.findall( rf"\b({month_pattern})\s+(\d{{1,2}})(?:st|nd|rd|th)?(?:\s*(20\d{{2}}))?\b", text, ): year = int(year_raw) if year_raw else default_year if not year: continue try: parsed = datetime(year, MONTH_TO_NUM[month_name], int(day_raw)).date().isoformat() dates.append(parsed) except Exception: continue for day_raw, month_name, year_raw in re.findall( rf"\b(\d{{1,2}})(?:st|nd|rd|th)?\s+({month_pattern})(?:\s*(20\d{{2}}))?\b", text, ): year = int(year_raw) if year_raw else default_year if not year: continue try: parsed = datetime(year, MONTH_TO_NUM[month_name], int(day_raw)).date().isoformat() dates.append(parsed) except Exception: continue # Deduplicate while preserving order unique: List[str] = [] seen = set() for value in dates: if value in seen: continue seen.add(value) unique.append(value) return unique class PolymarketReadOnlyLayer: def __init__(self) -> None: self.enabled = ( str(os.getenv("POLYMARKET_MARKET_SCAN_ENABLED", "true")).strip().lower() not in {"0", "false", "no", "off"} ) self.gamma_url = ( str(os.getenv("POLYMARKET_GAMMA_URL", "https://gamma-api.polymarket.com")) .strip() .rstrip("/") ) self.clob_url = ( str(os.getenv("POLYMARKET_CLOB_URL", "https://clob.polymarket.com")) .strip() .rstrip("/") ) 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", "60"), 60, ) self.price_cache_ttl = _safe_int( os.getenv("POLYMARKET_PRICE_CACHE_TTL_SEC", "30"), 30, ) self.discovery_pages = _safe_int( os.getenv("POLYMARKET_DISCOVERY_PAGES", "6"), 6, ) self.discovery_limit = _safe_int( os.getenv("POLYMARKET_DISCOVERY_LIMIT", "200"), 200, ) self.min_liquidity_for_signal = ( _safe_float(os.getenv("POLYMARKET_SIGNAL_MIN_LIQUIDITY")) or 500.0 ) self.edge_threshold = _safe_float(os.getenv("POLYMARKET_SIGNAL_EDGE_PCT")) or 2.0 self._session = httpx.Client( timeout=self.http_timeout, follow_redirects=True, ) self._markets_cache: Dict[str, Dict[str, Any]] = {} self._active_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0} self._broad_markets_cache: Dict[str, Any] = {"data": [], "t": 0.0} self._price_cache: Dict[str, Dict[str, Any]] = {} self._lock = threading.Lock() def _market_scan_debug_enabled(self) -> bool: return ( str(os.getenv("POLYMARKET_MARKET_SCAN_DEBUG", "false")).strip().lower() in {"1", "true", "yes", "on"} ) def _debug_market_scan(self, message: str, **payload: Any) -> None: if not self._market_scan_debug_enabled(): return try: details = json.dumps(payload, ensure_ascii=False, default=str) except Exception: details = str(payload) logger.info(f"POLYMARKET_MARKET_SCAN_DEBUG {message} {details}") def build_market_scan( self, city: Any, target_date: Any, temperature_bucket: Optional[Dict[str, Any]] = None, model_probability: Optional[float] = None, probability_distribution: Optional[List[Dict[str, Any]]] = None, temp_symbol: Optional[str] = None, fallback_sparkline: Optional[List[float]] = None, forced_market_slug: Optional[str] = None, include_related_buckets: bool = True, scan_filters: Optional[Dict[str, Any]] = None, scan_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: date_str = _extract_iso_date(target_date) or str(target_date or "") city_key = _normalize_city_key(city) market_city_key = _resolve_market_city_key(city_key) requested_slug = str(forced_market_slug or "").strip().lower() or None scan: Dict[str, Any] = { "available": False, "reason": None, "city_key": city_key or None, "market_city_key": market_city_key or None, "primary_market": None, "selected_date": date_str or None, "selected_condition_id": None, "selected_slug": requested_slug, "temperature_bucket": temperature_bucket, "model_probability": model_probability, "market_price": None, "midpoint": None, "spread": None, "edge_percent": None, "signal_label": "MONITOR", "confidence": "low", "yes_token": None, "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": [], "scan_scope": "full" if include_related_buckets else "lite", "distribution_bias": None, "window_phase": None, "window_score": None, "primary_signal": None, "signal_status": "no_market", "candidate_count": 0, "scan_rows": [], "resolved_market_type": "maxtemp", "websocket": { "enabled": False, "status": "disabled_rest_only", }, } if not self.enabled: scan["reason"] = "Market scan disabled by POLYMARKET_MARKET_SCAN_ENABLED." self._debug_market_scan("disabled", city=city_key, date=date_str) return scan if not city_key or city_key not in CITY_REGISTRY: scan["reason"] = "City is not supported by the Polymarket market layer." self._debug_market_scan("unsupported_city", city=city, normalized=city_key) return scan if not market_city_key or market_city_key not in CITY_REGISTRY: scan["reason"] = "Mapped market city is not supported by the Polymarket market layer." self._debug_market_scan( "unsupported_market_city", city=city_key, market_city=market_city_key, ) return scan if not date_str: scan["reason"] = "Missing target date for market discovery." self._debug_market_scan("missing_date", city=city_key, market_city=market_city_key) return scan try: preferred_temp = None if isinstance(temperature_bucket, dict): preferred_temp = _safe_float(temperature_bucket.get("temp")) market, reason = self._find_primary_market( market_city_key, date_str, forced_market_slug=requested_slug, preferred_temp=preferred_temp, ) except Exception as exc: logger.warning( f"Polymarket market discovery failed ({city_key}->{market_city_key}): {exc}" ) scan["reason"] = "Market discovery failed." self._debug_market_scan( "discovery_exception", city=city_key, market_city=market_city_key, error=str(exc), ) return scan if not market: scan["reason"] = reason or "No active Polymarket market matched city/date." self._debug_market_scan( "no_market", city=city_key, market_city=market_city_key, date=date_str, forced_slug=requested_slug, reason=scan["reason"], ) return scan market_date = self._extract_market_date(market) condition_id = str( market.get("conditionId") or market.get("condition_id") or market.get("conditionID") or "" ).strip() or None market_slug = str(market.get("slug") or "").strip() or None liquidity = _extract_price( market.get("liquidityNum") or market.get("liquidity") or market.get("liquidityClob") ) volume = _extract_price( market.get("volumeNum") or market.get("volume") or market.get("volume24hr") ) trade_state = self._market_trade_state(market) primary_market_payload = { "id": market.get("id"), "question": market.get("question") or market.get("title"), "slug": market_slug, "condition_id": condition_id, "end_date": market_date, "active": trade_state.get("active"), "closed": trade_state.get("closed"), "accepting_orders": trade_state.get("accepting_orders"), "ended_at_utc": trade_state.get("ended_at_utc"), "tradable": trade_state.get("tradable"), "tradable_reason": trade_state.get("reason"), "liquidity": liquidity, "volume": volume, } if not trade_state.get("tradable"): scan["reason"] = ( "Matched market is not tradable." + ( f" reason={trade_state.get('reason')}" if trade_state.get("reason") else "" ) ) scan["primary_market"] = primary_market_payload scan["selected_condition_id"] = condition_id scan["selected_slug"] = market_slug scan["liquidity"] = liquidity scan["volume"] = volume self._debug_market_scan( "not_tradable", city=city_key, market_city=market_city_key, date=date_str, slug=market_slug, trade_state=trade_state, ) return scan tokens = self._extract_market_tokens(market) yes_token, no_token = self._resolve_yes_no_tokens(tokens) if not yes_token or not no_token: scan["reason"] = "Matched market has no resolvable YES/NO token pair." scan["primary_market"] = primary_market_payload scan["selected_condition_id"] = condition_id scan["selected_slug"] = market_slug scan["liquidity"] = liquidity scan["volume"] = volume self._debug_market_scan( "missing_tokens", city=city_key, market_city=market_city_key, date=date_str, slug=market_slug, token_count=len(tokens), ) return scan yes_prices = self._get_token_market_data(str(yes_token.get("token_id"))) no_prices = self._get_token_market_data(str(no_token.get("token_id"))) if liquidity is None: liquidity = _extract_price(yes_prices.get("book_liquidity")) last_trade_price = _extract_price(yes_prices.get("last_trade_price")) market_price = ( _extract_price(yes_prices.get("midpoint")) or _extract_price(yes_prices.get("buy")) or _extract_price(yes_token.get("implied_probability")) ) distribution_model_probability = self._aggregate_distribution_probability_for_market( market=market, probability_distribution=probability_distribution, temp_symbol=temp_symbol, ) if distribution_model_probability is not None: model_probability = distribution_model_probability edge_percent = None if model_probability is not None and market_price is not None: edge_percent = (model_probability - market_price) * 100.0 signal_label, confidence = self._derive_signal(edge_percent, liquidity) 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, probability_distribution=probability_distribution, temp_symbol=temp_symbol, limit=all_bucket_limit, ) top_buckets = list(all_buckets[:top_bucket_limit]) yes_payload = { "outcome": yes_token.get("outcome") or "Yes", "token_id": yes_token.get("token_id"), "implied_probability": _extract_price(yes_token.get("implied_probability")), "buy_price": _extract_price(yes_prices.get("buy")), "sell_price": _extract_price(yes_prices.get("sell")), "midpoint": _extract_price(yes_prices.get("midpoint")), "last_trade_price": _extract_price(yes_prices.get("last_trade_price")), "quote_source": yes_prices.get("quote_source"), "quote_age_ms": _safe_int(yes_prices.get("quote_age_ms"), 0), "book": yes_prices.get("book"), } no_payload = { "outcome": no_token.get("outcome") or "No", "token_id": no_token.get("token_id"), "implied_probability": _extract_price(no_token.get("implied_probability")), "buy_price": _extract_price(no_prices.get("buy")), "sell_price": _extract_price(no_prices.get("sell")), "midpoint": _extract_price(no_prices.get("midpoint")), "last_trade_price": _extract_price(no_prices.get("last_trade_price")), "quote_source": no_prices.get("quote_source"), "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=yes_buy, yes_sell=yes_sell, no_buy=no_buy, no_sell=no_sell, ) distribution_scan = self._build_distribution_scan_pack( city_key=market_city_key, target_date=date_str, primary_market=market, probability_distribution=probability_distribution, temp_symbol=temp_symbol, scan_context=scan_context, scan_filters=scan_filters, ) primary_signal = distribution_scan.get("primary_signal") if isinstance(primary_signal, dict): signal_label = str(primary_signal.get("action") or signal_label or "").strip() or signal_label signal_score = _safe_float(primary_signal.get("final_score")) if signal_score is not None and signal_score >= 85.0: confidence = "high" elif signal_score is not None and signal_score >= 70.0: confidence = "medium" elif signal_score is not None: confidence = "low" signal_edge = _safe_float(primary_signal.get("edge_percent")) if signal_edge is not None: edge_percent = signal_edge sparkline_values: List[float] = [] for candidate in ( _extract_price(yes_payload.get("sell_price")), _extract_price(yes_payload.get("buy_price")), market_price, model_probability, ): if candidate is None: continue sparkline_values.append(round(candidate * 100.0, 2)) if not sparkline_values: sparkline_values = fallback_sparkline or [] market_url = self._build_market_url(market) scan.update( { "available": True, "reason": None, "primary_market": primary_market_payload, "selected_condition_id": condition_id, "selected_slug": market_slug, "model_probability": model_probability, "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": 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, "distribution_bias": distribution_scan.get("distribution_bias"), "window_phase": distribution_scan.get("window_phase"), "window_score": distribution_scan.get("window_score"), "primary_signal": primary_signal, "signal_status": distribution_scan.get("signal_status"), "candidate_count": distribution_scan.get("candidate_count"), "scan_rows": distribution_scan.get("rows") or [], "resolved_market_type": distribution_scan.get("resolved_market_type") or "maxtemp", "websocket": { "enabled": False, "status": "disabled_rest_only", "market_url": market_url, "asset_ids": [ token for token in [ yes_payload.get("token_id"), no_payload.get("token_id"), ] if token ], "condition_ids": [condition_id] if condition_id else [], }, } ) self._debug_market_scan( "scan_ready", city=city_key, market_city=market_city_key, date=date_str, selected_slug=market_slug, market_price=scan.get("market_price"), yes_buy=scan.get("yes_buy"), yes_sell=scan.get("yes_sell"), no_buy=scan.get("no_buy"), no_sell=scan.get("no_sell"), price_analysis_available=bool(price_analysis and price_analysis.get("available")), all_buckets_count=len(all_buckets), top_buckets=[ { "temp": row.get("temp"), "yes_buy": row.get("yes_buy"), "market_price": row.get("market_price"), "quote_source": row.get("quote_source"), "slug": row.get("slug"), } for row in all_buckets[:6] if isinstance(row, dict) ], websocket=scan.get("websocket"), ) return scan def _hydrate_bucket_prices(self, buckets: List[Dict[str, Any]]) -> None: for bucket in buckets: if not isinstance(bucket, dict): continue yes_token_id = str(bucket.get("yes_token_id") or "").strip() no_token_id = str(bucket.get("no_token_id") or "").strip() if not yes_token_id: continue yes_prices = self._get_token_market_data(yes_token_id) no_prices = self._get_token_market_data(no_token_id) if no_token_id else {} yes_buy = _extract_price(yes_prices.get("buy")) yes_sell = _extract_price(yes_prices.get("sell")) no_buy = _extract_price(no_prices.get("buy")) no_sell = _extract_price(no_prices.get("sell")) yes_midpoint = _extract_price(yes_prices.get("midpoint")) if yes_buy is not None: bucket["yes_buy"] = yes_buy if yes_sell is not None: bucket["yes_sell"] = yes_sell if no_buy is not None: bucket["no_buy"] = no_buy if no_sell is not None: bucket["no_sell"] = no_sell reference_price = yes_midpoint if reference_price is None and yes_buy is not None and yes_sell is not None: reference_price = (yes_buy + yes_sell) / 2.0 if reference_price is None: reference_price = yes_buy if yes_buy is not None else yes_sell if reference_price is not None: reference_price = max(0.0, min(1.0, float(reference_price))) bucket["market_price"] = reference_price bucket["probability"] = reference_price if yes_prices.get("quote_source"): bucket["quote_source"] = yes_prices.get("quote_source") if yes_prices.get("quote_age_ms") is not None: bucket["quote_age_ms"] = _safe_int(yes_prices.get("quote_age_ms"), 0) def _build_price_analysis( self, *, model_probability: Optional[float], yes_buy: Optional[float], yes_sell: Optional[float], no_buy: Optional[float], no_sell: Optional[float], ) -> Dict[str, Any]: """Build read-only market price diagnostics. Polymarket CLOB naming is from the user's perspective: BUY is the executable ask to buy that outcome, SELL is the executable bid. Kelly here is a sizing reference only; no order execution is performed. """ p_yes = _clamp_probability(_safe_float(model_probability)) p_no = _clamp_probability(1.0 - p_yes if p_yes is not None else None) yes_ask = _clamp_probability(_safe_float(yes_buy)) no_ask = _clamp_probability(_safe_float(no_buy)) yes_bid = _clamp_probability(_safe_float(yes_sell)) no_bid = _clamp_probability(_safe_float(no_sell)) yes = self._build_side_price_analysis("yes", p_yes, yes_ask, yes_bid) no = self._build_side_price_analysis("no", p_no, no_ask, no_bid) ask_sum = None lock_edge = None lock_available = False if yes_ask is not None and no_ask is not None: ask_sum = yes_ask + no_ask lock_edge = 1.0 - ask_sum lock_available = lock_edge > 0 bid_sum = None sell_side_edge = None if yes_bid is not None and no_bid is not None: bid_sum = yes_bid + no_bid sell_side_edge = bid_sum - 1.0 best_side = None side_rows = [ row for row in [yes, no] if isinstance(row.get("edge"), (int, float)) and isinstance(row.get("kelly_fraction"), (int, float)) and row.get("kelly_fraction") > 0 ] if side_rows: best_side = max( side_rows, key=lambda row: ( float(row.get("edge") or 0.0), float(row.get("kelly_fraction") or 0.0), ), ).get("side") return { "available": any( value is not None for value in (yes_ask, no_ask, yes_bid, no_bid, p_yes) ), "source": "polymarket_clob_orderbook", "model_probability": p_yes, "yes": yes, "no": no, "best_side": best_side, "lock": { "available": lock_available, "ask_sum": ask_sum, "edge": lock_edge, }, "sell_side": { "bid_sum": bid_sum, "edge": sell_side_edge, }, } def _build_side_price_analysis( self, side: str, probability: Optional[float], ask: Optional[float], bid: Optional[float], ) -> Dict[str, Any]: edge = None kelly_fraction = None if probability is not None and ask is not None: edge = probability - ask if 0.0 < ask < 1.0: kelly_fraction = edge / (1.0 - ask) return { "side": side, "model_probability": probability, "ask": ask, "bid": bid, "edge": edge, "edge_percent": edge * 100.0 if edge is not None else None, "kelly_fraction": kelly_fraction, "quarter_kelly": ( max(0.0, kelly_fraction) / 4.0 if kelly_fraction is not None else None ), } def _market_trade_state(self, market: Dict[str, Any]) -> Dict[str, Any]: active = _safe_bool(market.get("active")) closed_raw = _safe_bool(market.get("closed")) closed = bool(closed_raw) if closed_raw is not None else False accepting_orders = _safe_bool( market.get("acceptingOrders", market.get("accepting_orders")) ) ended_at = None for key in ("endDate", "resolutionDate", "closedTime", "gameStartTime"): parsed = _parse_iso_datetime_utc(market.get(key)) if parsed is not None: ended_at = parsed break tradable = True reason = None if closed: tradable = False reason = "closed" elif active is False: tradable = False reason = "inactive" elif accepting_orders is False: tradable = False reason = "not_accepting_orders" return { "active": active, "closed": closed, "accepting_orders": accepting_orders, "ended_at_utc": ended_at.isoformat() if ended_at is not None else None, "tradable": tradable, "reason": reason, } def _derive_signal( self, edge_percent: Optional[float], liquidity: Optional[float], ) -> Tuple[str, str]: if edge_percent is None: return "MONITOR", "low" if liquidity is not None and liquidity < self.min_liquidity_for_signal: return "MONITOR", "low" absolute_edge = abs(edge_percent) if absolute_edge >= 8: confidence = "high" elif absolute_edge >= 4: confidence = "medium" else: confidence = "low" if edge_percent >= self.edge_threshold: return "BUY YES", confidence if edge_percent <= -self.edge_threshold: return "BUY NO", confidence return "MONITOR", confidence def _find_primary_market( self, city_key: str, target_date: str, forced_market_slug: Optional[str] = None, preferred_temp: Optional[float] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: if forced_market_slug: return self._find_market_by_slug( forced_market_slug, preferred_temp=preferred_temp, ) preferred_temp_key = ( f"{float(preferred_temp):.2f}" if preferred_temp is not None else "none" ) cache_key = f"{city_key}|{target_date}|{preferred_temp_key}" now = time.time() with self._lock: cached = self._markets_cache.get(cache_key) if cached and now - cached.get("t", 0) < self.market_cache_ttl: return cached.get("market"), cached.get("reason") markets = self._load_markets(active_only=True) if not markets: return None, "No active markets returned by Gamma API." scored: List[Tuple[float, Dict[str, Any]]] = [] for market in markets: score = self._score_market( city_key, target_date, market, preferred_temp=preferred_temp, ) if score <= 0: continue scored.append((score, market)) # Fallback to broader active universe when strict filters miss. if not scored: broader = self._load_markets(active_only=False) for market in broader: score = self._score_market( city_key, target_date, market, preferred_temp=preferred_temp, ) if score <= 0: continue scored.append((score, market)) # Deterministic weather event fallback: # If Gamma /markets discovery misses, resolve by canonical weather event slug. if not scored: event_slug = self._build_weather_event_slug(city_key, target_date) if event_slug: fallback_market, _ = self._find_market_by_slug( event_slug, preferred_temp=preferred_temp, ) if fallback_market: with self._lock: self._markets_cache[cache_key] = { "market": fallback_market, "reason": None, "t": now, } return fallback_market, None scored.sort( key=lambda item: ( item[0], _extract_price( item[1].get("volumeNum") or item[1].get("volume") or item[1].get("volume24hr") ) or 0.0, ), reverse=True, ) market = scored[0][1] if scored else None reason = None if market else "No market matched city/date with weather filters." with self._lock: self._markets_cache[cache_key] = {"market": market, "reason": reason, "t": now} return market, reason def _find_market_by_slug( self, market_slug: str, preferred_temp: Optional[float] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: normalized_slug = str(market_slug or "").strip().lower() if not normalized_slug: return None, "market_slug is empty." # 0) Event slug path (Polymarket weather pages are often event slugs). try: resp = self._session.get( f"{self.gamma_url}/events", params={"slug": normalized_slug, "limit": 5}, timeout=self.http_timeout, ) resp.raise_for_status() payload = resp.json() events = payload if isinstance(payload, list) else [] for event in events: if not isinstance(event, dict): continue event_slug = str(event.get("slug") or "").strip().lower() markets = event.get("markets") if isinstance(event.get("markets"), list) else [] market_candidates = [m for m in markets if isinstance(m, dict)] # Try exact market slug match first. for market in market_candidates: item_slug = str(market.get("slug") or "").strip().lower() if item_slug == normalized_slug: market["eventSlug"] = market.get("eventSlug") or event_slug market["eventTitle"] = market.get("eventTitle") or event.get("title") return market, None # If input is event slug, pick the most liquid active/ready market. if event_slug == normalized_slug and market_candidates: def _event_market_rank(m: Dict[str, Any]) -> Tuple[float, bool, bool, float]: market_temp = self._extract_market_bucket_temp(m) temp_score = 0.0 if preferred_temp is not None and market_temp is not None: temp_score = max(0.0, 100.0 - abs(market_temp - preferred_temp) * 10.0) liquidity_score = ( _extract_price( m.get("volumeNum") or m.get("volume") or m.get("liquidityNum") or m.get("liquidity") ) or 0.0 ) return ( temp_score, bool(m.get("active", False)), not bool(m.get("closed", False)), liquidity_score, ) market_candidates.sort( key=_event_market_rank, reverse=True, ) best = market_candidates[0] best["eventSlug"] = best.get("eventSlug") or event_slug best["eventTitle"] = best.get("eventTitle") or event.get("title") return best, None except Exception: pass # 1) Direct Gamma query by slug (fast-path for debug and deterministic checks). query_params = [ {"slug": normalized_slug, "limit": 20, "offset": 0, "archived": "false"}, {"search": normalized_slug, "limit": 50, "offset": 0, "archived": "false"}, ] for params in query_params: try: resp = self._session.get( f"{self.gamma_url}/markets", params=params, timeout=self.http_timeout, ) resp.raise_for_status() payload = resp.json() if isinstance(payload, dict): candidates = payload.get("markets") if not isinstance(candidates, list): candidates = [] elif isinstance(payload, list): candidates = payload else: candidates = [] for item in candidates: if not isinstance(item, dict): continue item_slug = str(item.get("slug") or "").strip().lower() if item_slug == normalized_slug: return item, None except Exception: continue # 2) Fallback to cached discovery lists. for active_only in (True, False): for item in self._load_markets(active_only=active_only): item_slug = str(item.get("slug") or "").strip().lower() if item_slug == normalized_slug: return item, None return None, f"Specified market_slug not found: {normalized_slug}" def _score_market( self, city_key: str, target_date: str, market: Dict[str, Any], preferred_temp: Optional[float] = None, ) -> float: city_tokens = CITY_TOKEN_INDEX.get(city_key, [city_key]) text_parts = [ market.get("question"), market.get("title"), market.get("slug"), market.get("eventSlug"), market.get("description"), ] haystack = _normalize_text(" ".join(str(part or "") for part in text_parts)) if not haystack: return 0.0 city_hit = any(_contains_token(haystack, token) for token in city_tokens) if not city_hit: return 0.0 if not self._is_temperature_market(market): return 0.0 score = 40.0 score += 18.0 d_target = _parse_target_date(target_date) text_dates = _extract_dates_from_text(haystack, d_target.year if d_target else None) if d_target and text_dates: diffs: List[int] = [] for date_str in text_dates: try: diffs.append(abs((datetime.fromisoformat(date_str).date() - d_target.date()).days)) except Exception: continue if diffs: best = min(diffs) if best == 0: score += 45.0 elif best == 1: score += 20.0 elif best == 2: score += 10.0 else: score -= 6.0 else: market_date = self._extract_market_date(market) if market_date and d_target: try: d_market = datetime.fromisoformat(market_date).date() diff = abs((d_market - d_target.date()).days) if diff == 0: score += 18.0 elif diff == 1: score += 8.0 elif diff == 2: score += 3.0 else: score -= 2.0 except Exception: pass if bool(market.get("active", False)): score += 5.0 if not bool(market.get("closed", False)): score += 5.0 if bool(market.get("enableOrderBook", market.get("enable_order_book", False))): score += 4.0 volume = ( _extract_price( market.get("volumeNum") or market.get("volume") or market.get("volume24hr") ) or 0.0 ) score += min(volume / 50000.0, 8.0) if preferred_temp is not None: market_temp = self._extract_market_bucket_temp(market) bucket_range = self._extract_market_bucket_range(market) direction = self._extract_market_bucket_direction(market) if market_temp is not None: diff = abs(float(market_temp) - float(preferred_temp)) score += max(-40.0, 60.0 - diff * 15.0) if bucket_range is not None: lower, upper, _unit = bucket_range contains_preferred = False if upper is not None: contains_preferred = lower <= float(preferred_temp) <= upper elif direction == "above": contains_preferred = float(preferred_temp) >= lower elif direction == "below": contains_preferred = float(preferred_temp) <= lower else: contains_preferred = abs(float(preferred_temp) - lower) <= 0.51 if contains_preferred: if upper is not None or direction == "exact": score += 28.0 else: score += 18.0 return score def _is_temperature_market(self, market: Dict[str, Any]) -> bool: text_parts = [ market.get("question"), market.get("title"), market.get("slug"), market.get("eventSlug"), market.get("description"), ] raw_text = " ".join(str(part or "") for part in text_parts) if not raw_text: return False # Hard signal: contains explicit Celsius bucket text like "10C" / "10°C" if re.search(r"(-?\d+(?:\.\d+)?)\s*[°º]?\s*c\b", raw_text, re.IGNORECASE): return True text = _normalize_text(raw_text) if not text: return False # Weather temperature event patterns. if "highest temperature" in text: return True if "temperature in" in text: return True if "high temperature" in text: return True # Conservative fallback: must explicitly mention temperature and boundary wording. if "temperature" in text and any( key in text for key in ("or higher", "or above", "or lower", "or below", "and above", "and below") ): return True return False def _extract_market_date(self, market: Dict[str, Any]) -> Optional[str]: for key in ( "endDate", "endDateIso", "endDateISO", "resolutionDate", "gameStartTime", "closedTime", ): date_str = _extract_iso_date(market.get(key)) if date_str: return date_str 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"), ) ) # 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: 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]: try: dt = datetime.fromisoformat(str(target_date)) except Exception: return None city_slug = str(city_key or "").strip().lower().replace(" ", "-") if not city_slug: return None month_name = dt.strftime("%B").lower() return f"highest-temperature-in-{city_slug}-on-{month_name}-{dt.day}-{dt.year}" def _is_fahrenheit_symbol(self, symbol: Optional[str]) -> bool: return "F" in str(symbol or "").upper() def _convert_temp_to_market_unit( self, value: Optional[float], source_symbol: Optional[str], market_unit: Optional[str], ) -> Optional[float]: numeric = _safe_float(value) if numeric is None: return None normalized_unit = str(market_unit or "").upper() source_is_f = self._is_fahrenheit_symbol(source_symbol) if normalized_unit == "F": return numeric if source_is_f else (numeric * 9.0 / 5.0) + 32.0 return ((numeric - 32.0) * 5.0 / 9.0) if source_is_f else numeric def _market_bucket_contains_distribution_temp( self, market: Dict[str, Any], distribution_temp: Optional[float], temp_symbol: Optional[str], ) -> bool: compare_temp = self._convert_temp_to_market_unit( distribution_temp, source_symbol=temp_symbol, market_unit=(self._extract_market_bucket_range(market) or (None, None, "C"))[2], ) if compare_temp is None: return False bucket_range = self._extract_market_bucket_range(market) 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 "C" direction = self._extract_market_bucket_direction(market) if lower is not None and upper is not None: return compare_temp >= lower - 0.01 and compare_temp <= upper + 0.01 if lower is not None and direction == "above": return compare_temp >= lower - 0.01 if lower is not None and direction == "below": return compare_temp <= lower + 0.01 reference = self._extract_market_bucket_temp(market) if reference is None: return False tolerance = 0.56 if str(unit or "").upper() == "F" else 0.26 return abs(compare_temp - reference) <= tolerance def _aggregate_distribution_probability_for_market( self, market: Dict[str, Any], probability_distribution: Optional[List[Dict[str, Any]]], temp_symbol: Optional[str], ) -> Optional[float]: if not isinstance(probability_distribution, list) or not probability_distribution: return None total = 0.0 matched = 0 for row in probability_distribution: if not isinstance(row, dict): continue distribution_temp = _safe_float(row.get("value")) if distribution_temp is None: continue if not self._market_bucket_contains_distribution_temp( market, distribution_temp, temp_symbol, ): continue raw_probability = _safe_float(row.get("probability")) if raw_probability is None: continue probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability probability = max(0.0, min(1.0, probability)) total += probability matched += 1 if matched <= 0: return None return max(0.0, min(1.0, total)) def _load_markets(self, active_only: bool = True) -> List[Dict[str, Any]]: now = time.time() with self._lock: cached = self._active_markets_cache if active_only else self._broad_markets_cache if now - float(cached.get("t", 0)) < self.market_cache_ttl: data = cached.get("data") if isinstance(data, list): return data all_markets: List[Dict[str, Any]] = [] offset = 0 for _ in range(max(self.discovery_pages, 1)): params = {"archived": "false", "limit": self.discovery_limit, "offset": offset} if active_only: params.update({"active": "true", "closed": "false"}) else: params.update({"active": "true"}) url = f"{self.gamma_url}/markets" try: resp = self._session.get(url, params=params, timeout=self.http_timeout) resp.raise_for_status() payload = resp.json() except Exception as exc: logger.warning(f"Gamma markets fetch failed (offset={offset}): {exc}") break if isinstance(payload, dict): batch = payload.get("markets") if not isinstance(batch, list): # Gamma can also return object arrays directly. batch = [] elif isinstance(payload, list): batch = payload else: batch = [] if not batch: break all_markets.extend(item for item in batch if isinstance(item, dict)) if len(batch) < self.discovery_limit: break offset += self.discovery_limit with self._lock: if active_only: self._active_markets_cache = {"data": all_markets, "t": now} else: self._broad_markets_cache = {"data": all_markets, "t": now} return all_markets def _extract_market_tokens(self, market: Dict[str, Any]) -> List[Dict[str, Any]]: result: List[Dict[str, Any]] = [] direct_tokens = market.get("tokens") if isinstance(direct_tokens, list): for token in direct_tokens: token_obj = _to_plain_dict(token) if not token_obj: continue token_id = str( token_obj.get("token_id") or token_obj.get("tokenId") or token_obj.get("id") or token_obj.get("clobTokenId") or "" ).strip() if not token_id: continue result.append( { "outcome": token_obj.get("outcome") or token_obj.get("name"), "token_id": token_id, "implied_probability": _extract_price( token_obj.get("price") or token_obj.get("probability") or token_obj.get("lastPrice") ), } ) if result: return result outcomes = _json_or_list(market.get("outcomes")) prices = _json_or_list(market.get("outcomePrices")) token_ids = _json_or_list(market.get("clobTokenIds")) if not token_ids: token_ids = _json_or_list(market.get("tokenIds")) for index, outcome in enumerate(outcomes): token_id = str(token_ids[index]).strip() if index < len(token_ids) else "" if not token_id: continue implied_probability = ( _extract_price(prices[index]) if index < len(prices) else None ) result.append( { "outcome": str(outcome), "token_id": token_id, "implied_probability": implied_probability, } ) return result def _resolve_yes_no_tokens( self, tokens: List[Dict[str, Any]], ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: if not tokens: return None, None yes_token = None no_token = None for token in tokens: label = _normalize_text(token.get("outcome")) if label in {"yes", "true", "above", "over"}: yes_token = token elif label in {"no", "false", "below", "under"}: no_token = token if yes_token and no_token: return yes_token, no_token if len(tokens) == 2: # Fallback for markets with unnamed binary outcomes. return tokens[0], tokens[1] return None, 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 {} now = time.time() with self._lock: cached = self._price_cache.get(token_id) if cached and now - cached.get("t", 0) < self.price_cache_ttl: return cached.get("data", {}) data = self._fetch_token_market_data(token_id) with self._lock: self._price_cache[token_id] = {"data": data, "t": now} return data def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]: # REST-only path: CLOB public endpoints. bid = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"})) ask = _extract_price( self._clob_get("/price", {"token_id": token_id, "side": "SELL"}) ) midpoint = _extract_price(self._clob_get("/midpoint", {"token_id": token_id})) last_trade = _extract_price( self._clob_get("/last-trade-price", {"token_id": token_id}) ) orderbook_raw = self._clob_get("/book", {"token_id": token_id}) book, book_liquidity = self._normalize_orderbook(orderbook_raw) buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=book) return { "buy": buy, "sell": sell, "midpoint": midpoint, "last_trade_price": last_trade, "quote_source": "polymarket_clob_rest", "quote_age_ms": 0, "book": book, "book_liquidity": book_liquidity, } def _clob_get(self, path: str, params: Dict[str, Any]) -> Any: url = f"{self.clob_url}{path}" try: resp = self._session.get(url, params=params, timeout=self.http_timeout) resp.raise_for_status() return resp.json() except Exception: return None def _resolve_trade_prices( self, buy: Optional[float], sell: Optional[float], book: Optional[Dict[str, Any]], ) -> Tuple[Optional[float], Optional[float]]: payload = book if isinstance(book, dict) else {} best_bid = _extract_price(payload.get("best_bid")) best_ask = _extract_price(payload.get("best_ask")) resolved_buy = best_ask if best_ask is not None else buy resolved_sell = best_bid if best_bid is not None else sell return resolved_buy, resolved_sell def _normalize_orderbook(self, orderbook_raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: payload = _to_plain_dict(orderbook_raw) if not payload and isinstance(orderbook_raw, dict): payload = orderbook_raw if not payload: return None, None bids_raw = payload.get("bids") or [] asks_raw = payload.get("asks") or [] bid_levels: List[List[float]] = [] ask_levels: List[List[float]] = [] book_liquidity = 0.0 def _parse_side(items: Any, sink: List[List[float]]) -> None: nonlocal book_liquidity if not isinstance(items, list): return for item in items: item_dict = _to_plain_dict(item) if item_dict: price = _extract_price(item_dict.get("price")) size = _extract_price(item_dict.get("size") or item_dict.get("quantity")) elif isinstance(item, (list, tuple)) and len(item) >= 2: price = _extract_price(item[0]) size = _extract_price(item[1]) else: continue if price is None or size is None: continue sink.append([price, size]) book_liquidity += max(0.0, price * size) _parse_side(bids_raw, bid_levels) _parse_side(asks_raw, ask_levels) bid_levels.sort(key=lambda level: level[0], reverse=True) ask_levels.sort(key=lambda level: level[0]) best_bid = bid_levels[0][0] if bid_levels else None best_ask = ask_levels[0][0] if ask_levels else None normalized = { "best_bid": best_bid, "best_ask": best_ask, "bid_levels": bid_levels[:10], "ask_levels": ask_levels[:10], } return normalized, (book_liquidity if book_liquidity > 0 else None) def _build_market_url(self, market: Dict[str, Any]) -> Optional[str]: slug = str(market.get("slug") or "").strip() event_slug = str(market.get("eventSlug") or "").strip() if event_slug: return f"https://polymarket.com/event/{event_slug}" if slug: return f"https://polymarket.com/market/{slug}" return None def _build_top_temperature_buckets( self, city_key: str, target_date: str, primary_market: Dict[str, Any], probability_distribution: Optional[List[Dict[str, Any]]] = None, temp_symbol: Optional[str] = None, limit: int = 4, ) -> List[Dict[str, Any]]: candidate_markets = self._collect_related_temperature_markets( city_key=city_key, target_date=target_date, primary_market=primary_market, ) if not candidate_markets: return [] ranked: List[ Tuple[ float, float, float, float, Dict[str, Any], Dict[str, Any], 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 tokens = self._extract_market_tokens(market) yes_token, no_token = self._resolve_yes_no_tokens(tokens) if not yes_token or not no_token: continue yes_token_id = str(yes_token.get("token_id") or "").strip() no_token_id = str(no_token.get("token_id") or "").strip() yes_prices = self._get_token_market_data(yes_token_id) if yes_token_id else {} no_prices = self._get_token_market_data(no_token_id) if no_token_id else {} yes_midpoint = _extract_price(yes_prices.get("midpoint")) yes_implied = _extract_price(yes_token.get("implied_probability")) no_implied = _extract_price(no_token.get("implied_probability")) market_prob = ( yes_midpoint if yes_midpoint is not None else ( yes_implied if yes_implied is not None else (1.0 - no_implied if no_implied is not None else None) ) ) if market_prob is None: continue market_prob = max(0.0, min(1.0, float(market_prob))) model_prob = self._aggregate_distribution_probability_for_market( market=market, probability_distribution=probability_distribution, temp_symbol=temp_symbol, ) volume = ( _extract_price( market.get("volumeNum") or market.get("volume") or market.get("volume24hr") ) or 0.0 ) ranked.append( ( model_prob if model_prob is not None else market_prob, volume, bucket_temp, market_prob, market, yes_token, no_token, yes_prices, no_prices, bucket_range, ) ) if not ranked: return [] ranked.sort(key=lambda item: (item[0], item[1]), reverse=True) 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() def _append_rows(enforce_primary_direction: bool) -> None: for ( model_prob, _volume, bucket_temp, market_prob, market, yes_token, no_token, yes_prices, no_prices, bucket_range, ) 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 temp_key = f"{round(float(bucket_temp), 2):.2f}" if temp_key in seen_temp_keys: continue 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")) 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() row_yes_token_id = str(yes_token.get("token_id") or "").strip() row_no_token_id = str(no_token.get("token_id") or "").strip() top_rows.append( { "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": model_prob, "model_probability": model_prob, "market_price": yes_midpoint, "edge_percent": ( (model_prob - yes_midpoint) * 100.0 if model_prob is not None and yes_midpoint is not None else None ), "yes_buy": yes_buy, "yes_sell": yes_sell, "no_buy": no_buy, "no_sell": no_sell, "yes_token_id": row_yes_token_id or None, "no_token_id": row_no_token_id or None, "quote_source": yes_prices.get("quote_source"), "quote_age_ms": _safe_int(yes_prices.get("quote_age_ms"), 0), "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 def _collect_related_temperature_markets( self, city_key: str, target_date: str, primary_market: Dict[str, Any], ) -> List[Dict[str, Any]]: related: List[Dict[str, Any]] = [] canonical_event_slug = self._build_weather_event_slug(city_key, target_date) if canonical_event_slug: related.extend(self._load_event_markets(canonical_event_slug)) event_slug = self._extract_event_slug(primary_market) if event_slug and event_slug != canonical_event_slug: related.extend(self._load_event_markets(event_slug)) if not related: for market in self._load_markets(active_only=True): if self._score_market(city_key, target_date, market) <= 0: continue if self._extract_market_bucket_temp(market) is None: continue related.append(market) related.append(primary_market) unique: List[Dict[str, Any]] = [] seen = set() for market in related: if not isinstance(market, dict): continue dedupe_key = str( market.get("id") or market.get("slug") or market.get("conditionId") or "" ).strip() if not dedupe_key: continue if dedupe_key in seen: continue seen.add(dedupe_key) unique.append(market) return unique def _extract_event_slug(self, market: Dict[str, Any]) -> Optional[str]: event_slug = str(market.get("eventSlug") or "").strip().lower() if event_slug: return event_slug slug = str(market.get("slug") or "").strip().lower() if not slug: return None trimmed = re.sub( r"-(?:m)?\d+(?:-\d+)?c(?:-or-(?:higher|lower|above|below))?$", "", slug, ) trimmed = trimmed.strip("-") return trimmed or None def _load_event_markets(self, event_slug: str) -> List[Dict[str, Any]]: normalized_slug = str(event_slug or "").strip().lower() if not normalized_slug: return [] try: resp = self._session.get( f"{self.gamma_url}/events", params={"slug": normalized_slug, "limit": 5}, timeout=self.http_timeout, ) resp.raise_for_status() payload = resp.json() except Exception: return [] events = payload if isinstance(payload, list) else [] out: List[Dict[str, Any]] = [] for event in events: if not isinstance(event, dict): continue event_item_slug = str(event.get("slug") or "").strip().lower() if event_item_slug and event_item_slug != normalized_slug: continue for market in event.get("markets") or []: if not isinstance(market, dict): continue market["eventSlug"] = market.get("eventSlug") or event_item_slug market["eventTitle"] = market.get("eventTitle") or event.get("title") out.append(market) return out def _extract_market_bucket_label( self, market: Dict[str, Any], bucket_temp: Optional[float], ) -> 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}{unit}+" if direction == "below": 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: 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" def _clob_post(self, path: str, payload: Any) -> Any: url = f"{self.clob_url}{path}" try: resp = self._session.post(url, json=payload, timeout=self.http_timeout) resp.raise_for_status() return resp.json() except Exception: return None def _batch_chunks(self, values: List[str], size: int = 200) -> List[List[str]]: if not values: return [] chunk_size = max(1, min(int(size or 200), 500)) return [values[index : index + chunk_size] for index in range(0, len(values), chunk_size)] def _extract_payload_token_id(self, payload: Any) -> Optional[str]: item = _to_plain_dict(payload) if not item and isinstance(payload, dict): item = payload if not item: return None token_id = str( item.get("asset_id") or item.get("assetId") or item.get("token_id") or item.get("tokenId") or item.get("id") or "" ).strip() return token_id or None def _extract_batch_scalar_map(self, payload: Any) -> Dict[str, float]: if not payload: return {} data = payload if isinstance(data, dict): for key in ("data", "midpoints", "spreads", "items", "results"): nested = data.get(key) if isinstance(nested, (dict, list)): data = nested break result: Dict[str, float] = {} if isinstance(data, dict): for key, value in data.items(): numeric = _extract_price(value) if numeric is None: continue result[str(key).strip()] = numeric return result if isinstance(data, list): for item in data: token_id = self._extract_payload_token_id(item) if not token_id: continue item_dict = _to_plain_dict(item) numeric = _extract_price( item_dict.get("midpoint") or item_dict.get("mid_price") or item_dict.get("spread") or item_dict.get("price") or item_dict.get("last_trade_price") or item_dict.get("value") ) if numeric is None: continue result[token_id] = numeric return result def _extract_batch_price_map(self, payload: Any, side: str) -> Dict[str, float]: if not payload: return {} data = payload.get("data") if isinstance(payload, dict) and isinstance(payload.get("data"), dict) else payload result: Dict[str, float] = {} if not isinstance(data, dict): return result for token_id, side_map in data.items(): token_key = str(token_id).strip() if not token_key: continue item = _to_plain_dict(side_map) if not item and isinstance(side_map, dict): item = side_map numeric = _extract_price(item.get(side) if item else side_map) if numeric is None: continue result[token_key] = numeric return result def _extract_batch_book_map(self, payload: Any) -> Dict[str, Dict[str, Any]]: if not payload: return {} data = payload if isinstance(data, dict): for key in ("data", "books", "items", "results"): nested = data.get(key) if isinstance(nested, (dict, list)): data = nested break result: Dict[str, Dict[str, Any]] = {} if isinstance(data, dict): for token_id, book in data.items(): token_key = str(token_id).strip() book_dict = _to_plain_dict(book) if not token_key or not book_dict: continue result[token_key] = book_dict return result if isinstance(data, list): for item in data: token_id = self._extract_payload_token_id(item) book_dict = _to_plain_dict(item) if not token_id or not book_dict: continue result[token_id] = book_dict return result def _batch_get_token_market_data( self, token_ids: List[str], *, include_books: bool = False, ) -> Dict[str, Dict[str, Any]]: unique_tokens = [] seen = set() for token_id in token_ids: normalized = str(token_id or "").strip() if not normalized or normalized in seen: continue seen.add(normalized) unique_tokens.append(normalized) if not unique_tokens: return {} now = time.time() results: Dict[str, Dict[str, Any]] = {} missing: List[str] = [] with self._lock: for token_id in unique_tokens: cached = self._price_cache.get(token_id) if not cached or now - cached.get("t", 0) >= self.price_cache_ttl: missing.append(token_id) continue cached_data = cached.get("data", {}) or {} if include_books and not cached_data.get("book") and cached_data.get("book_liquidity") is None: missing.append(token_id) continue results[token_id] = dict(cached_data) if not missing: return results ask_map: Dict[str, float] = {} bid_map: Dict[str, float] = {} midpoint_map: Dict[str, float] = {} spread_map: Dict[str, float] = {} last_trade_map: Dict[str, float] = {} book_map: Dict[str, Dict[str, Any]] = {} for chunk in self._batch_chunks(missing): sell_payload = self._clob_post( "/prices", [{"token_id": token_id, "side": "SELL"} for token_id in chunk], ) buy_payload = self._clob_post( "/prices", [{"token_id": token_id, "side": "BUY"} for token_id in chunk], ) midpoint_payload = self._clob_post( "/midpoints", [{"token_id": token_id} for token_id in chunk], ) spread_payload = self._clob_post( "/spreads", [{"token_id": token_id} for token_id in chunk], ) last_trade_payload = self._clob_post( "/last-trade-prices", [{"token_id": token_id} for token_id in chunk], ) books_payload = ( self._clob_post( "/books", [{"token_id": token_id} for token_id in chunk], ) if include_books else None ) ask_map.update(self._extract_batch_price_map(sell_payload, "SELL")) bid_map.update(self._extract_batch_price_map(buy_payload, "BUY")) midpoint_map.update(self._extract_batch_scalar_map(midpoint_payload)) spread_map.update(self._extract_batch_scalar_map(spread_payload)) last_trade_map.update(self._extract_batch_scalar_map(last_trade_payload)) if include_books: book_map.update(self._extract_batch_book_map(books_payload)) fetched: Dict[str, Dict[str, Any]] = {} unresolved: List[str] = [] for token_id in missing: raw_book = book_map.get(token_id) book, book_liquidity = self._normalize_orderbook(raw_book) ask = _extract_price(ask_map.get(token_id)) bid = _extract_price(bid_map.get(token_id)) midpoint = _extract_price(midpoint_map.get(token_id)) spread = _extract_price(spread_map.get(token_id)) last_trade = _extract_price(last_trade_map.get(token_id)) buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=book) if midpoint is None and buy is not None and sell is not None: midpoint = (buy + sell) / 2.0 if midpoint is None: midpoint = _extract_price(raw_book.get("last_trade_price") if isinstance(raw_book, dict) else None) if spread is None and buy is not None and sell is not None: spread = max(0.0, float(buy) - float(sell)) if spread is not None and midpoint is not None: midpoint = _clamp_probability(midpoint) if buy is None and midpoint is not None and spread is not None: buy = _clamp_probability(midpoint + spread / 2.0) if sell is None and midpoint is not None and spread is not None: sell = _clamp_probability(midpoint - spread / 2.0) if ( buy is None and sell is None and midpoint is None and last_trade is None and book is None ): unresolved.append(token_id) continue fetched[token_id] = { "buy": buy, "sell": sell, "midpoint": midpoint, "spread": spread, "last_trade_price": last_trade, "quote_source": "polymarket_clob_rest_batch", "quote_age_ms": 0, "book": book, "book_liquidity": book_liquidity, } for token_id in unresolved: fetched[token_id] = self._fetch_token_market_data(token_id) with self._lock: for token_id, data in fetched.items(): self._price_cache[token_id] = {"data": data, "t": now} results.update(fetched) return results def _normalize_scan_filters(self, scan_filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: raw = scan_filters if isinstance(scan_filters, dict) else {} min_price = _clamp_float(_safe_float(raw.get("min_price")), 0.0, 1.0) max_price = _clamp_float(_safe_float(raw.get("max_price")), 0.0, 1.0) if min_price is None: min_price = 0.05 if max_price is None: max_price = 0.95 if min_price > max_price: min_price, max_price = max_price, min_price high_liquidity_only = bool(_safe_bool(raw.get("high_liquidity_only"))) min_liquidity = _safe_float(raw.get("min_liquidity")) if min_liquidity is None: min_liquidity = 5000.0 if high_liquidity_only else float(self.min_liquidity_for_signal or 500.0) if high_liquidity_only: min_liquidity = max(min_liquidity, 5000.0) return { "scan_mode": str(raw.get("scan_mode") or "tradable").strip().lower() or "tradable", "min_price": float(min_price), "max_price": float(max_price), "min_edge_pct": max(0.0, _safe_float(raw.get("min_edge_pct")) or float(self.edge_threshold or 2.0)), "min_liquidity": max(0.0, float(min_liquidity)), "high_liquidity_only": high_liquidity_only, "market_type": str(raw.get("market_type") or "maxtemp").strip().lower() or "maxtemp", "time_range": str(raw.get("time_range") or "today").strip().lower() or "today", "limit": max(1, _safe_int(raw.get("limit"), 60)), "max_spread": max(0.0, _safe_float(raw.get("max_spread")) or 0.03), } def _build_window_meta( self, target_date: str, scan_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: context = scan_context if isinstance(scan_context, dict) else {} local_date = _extract_iso_date(context.get("local_date")) or _extract_iso_date(target_date) local_time = context.get("local_time") peak = context.get("peak") if isinstance(context.get("peak"), dict) else {} first_h = int(_safe_float(peak.get("first_h")) or 13) last_h = int(_safe_float(peak.get("last_h")) or 15) target_iso = _extract_iso_date(target_date) if not local_date or not target_iso: return { "phase": "today_default", "score": 0.65, "remaining_minutes": None, "same_day": True, } try: diff_days = ( datetime.fromisoformat(target_iso).date() - datetime.fromisoformat(local_date).date() ).days except Exception: diff_days = 0 if diff_days >= 2: return { "phase": "week_ahead", "score": 0.45, "remaining_minutes": None, "same_day": False, } if diff_days == 1: return { "phase": "tomorrow", "score": 0.60, "remaining_minutes": None, "same_day": False, } if diff_days < 0: return { "phase": "past", "score": 0.0, "remaining_minutes": None, "same_day": False, } now_minutes = _parse_hhmm_to_minutes(local_time) if now_minutes is None: return { "phase": "today_default", "score": 0.65, "remaining_minutes": None, "same_day": True, } first_minutes = max(0, first_h * 60) last_minutes = min(23 * 60 + 59, last_h * 60) if now_minutes > last_minutes + 120: return { "phase": "post_peak", "score": 0.50, "remaining_minutes": 0, "same_day": True, } if first_minutes <= now_minutes <= last_minutes + 120: return { "phase": "active_peak", "score": 1.00, "remaining_minutes": max(0, last_minutes + 120 - now_minutes), "same_day": True, } if first_minutes - 180 <= now_minutes < first_minutes: return { "phase": "setup_today", "score": 0.85, "remaining_minutes": max(0, last_minutes + 120 - now_minutes), "same_day": True, } return { "phase": "early_today", "score": 0.70, "remaining_minutes": max(0, first_minutes - now_minutes), "same_day": True, } def _resolve_market_target_threshold( self, market_direction: str, bucket_range: Optional[Tuple[float, Optional[float], str]], bucket_temp: Optional[float], ) -> Optional[float]: if not bucket_range: return bucket_temp lower, upper, _unit = bucket_range if market_direction in {"above", "below"}: return lower if upper is not None: return (lower + upper) / 2.0 return lower def _resolve_temperature_direction( self, *, side: str, market_direction: str, target_threshold: Optional[float], current_reference: Optional[float], ) -> str: if market_direction == "above": return "hotter" if side == "yes" else "colder" if market_direction == "below": return "colder" if side == "yes" else "hotter" hotter_bias = True if target_threshold is not None and current_reference is not None: hotter_bias = target_threshold >= current_reference if side == "yes": return "hotter" if hotter_bias else "colder" return "colder" if hotter_bias else "hotter" def _is_trend_aligned( self, *, temperature_direction: str, trend_info: Optional[Dict[str, Any]], network_lead_signal: Optional[Dict[str, Any]], ) -> bool: trend = trend_info if isinstance(trend_info, dict) else {} network = network_lead_signal if isinstance(network_lead_signal, dict) else {} trend_direction = _normalize_text(trend.get("direction")) if temperature_direction == "hotter" and trend_direction == "rising": return True if temperature_direction == "colder" and trend_direction in {"falling", "stagnant"}: return True lead_delta = _safe_float(network.get("delta")) if lead_delta is None: return False if temperature_direction == "hotter": return lead_delta > 0 return lead_delta < 0 def _build_distribution_scan_pack( self, *, city_key: str, target_date: str, primary_market: Dict[str, Any], probability_distribution: Optional[List[Dict[str, Any]]] = None, temp_symbol: Optional[str] = None, scan_context: Optional[Dict[str, Any]] = None, scan_filters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: filters = self._normalize_scan_filters(scan_filters) window_meta = self._build_window_meta(target_date, scan_context) rows: List[Dict[str, Any]] = [] related_markets = self._collect_related_temperature_markets( city_key=city_key, target_date=target_date, primary_market=primary_market, ) if not related_markets: return { "rows": [], "distribution_bias": { "available": False, "value": None, "direction": "balanced", "score": 0.0, "valid_markets": 0, }, "primary_signal": None, "signal_status": "no_market", "candidate_count": 0, "window_phase": window_meta.get("phase"), "window_score": window_meta.get("score"), "resolved_market_type": "maxtemp", } market_entries: List[Dict[str, Any]] = [] token_ids: List[str] = [] for market in related_markets: tokens = self._extract_market_tokens(market) yes_token, no_token = self._resolve_yes_no_tokens(tokens) if not yes_token or not no_token: continue yes_token_id = str(yes_token.get("token_id") or "").strip() no_token_id = str(no_token.get("token_id") or "").strip() if not yes_token_id or not no_token_id: continue bucket_range = self._extract_market_bucket_range(market) bucket_temp = self._extract_market_bucket_temp(market) raw_direction = self._extract_market_bucket_direction(market) market_direction = "range" if bucket_range and bucket_range[1] is not None else raw_direction model_event_probability = self._aggregate_distribution_probability_for_market( market=market, probability_distribution=probability_distribution, temp_symbol=temp_symbol, ) token_ids.extend([yes_token_id, no_token_id]) market_entries.append( { "market": market, "yes_token": yes_token, "no_token": no_token, "yes_token_id": yes_token_id, "no_token_id": no_token_id, "bucket_range": bucket_range, "bucket_temp": bucket_temp, "market_direction": market_direction, "target_threshold": self._resolve_market_target_threshold( market_direction, bucket_range, bucket_temp, ), "target_label": self._extract_market_bucket_label(market, bucket_temp), "model_event_probability": model_event_probability, "market_liquidity": _extract_price( market.get("liquidityNum") or market.get("liquidity") or market.get("liquidityClob") ), "volume": _extract_price( market.get("volumeNum") or market.get("volume") or market.get("volume24hr") ), "trade_state": self._market_trade_state(market), "enable_order_book": bool( market.get("enableOrderBook", market.get("enable_order_book", False)) ), } ) broad_quotes = self._batch_get_token_market_data(token_ids, include_books=False) bias_inputs: List[Tuple[float, float]] = [] for entry in market_entries: yes_quote = broad_quotes.get(entry["yes_token_id"], {}) no_quote = broad_quotes.get(entry["no_token_id"], {}) market_event_probability = ( _extract_price(yes_quote.get("midpoint")) or _extract_price(yes_quote.get("buy")) or _extract_price(yes_quote.get("sell")) or _extract_price(entry["yes_token"].get("implied_probability")) ) if market_event_probability is not None: market_event_probability = _clamp_probability(market_event_probability) yes_ask = _extract_price(yes_quote.get("buy")) yes_bid = _extract_price(yes_quote.get("sell")) no_ask = _extract_price(no_quote.get("buy")) no_bid = _extract_price(no_quote.get("sell")) if no_ask is None and yes_ask is not None: no_ask = _clamp_probability(1.0 - yes_bid) if yes_bid is not None else None if no_bid is None and yes_bid is not None: no_bid = _clamp_probability(1.0 - yes_ask) if yes_ask is not None else None spread = _extract_price(yes_quote.get("spread")) if spread is None and yes_ask is not None and yes_bid is not None: spread = max(0.0, yes_ask - yes_bid) entry["market_event_probability"] = market_event_probability entry["yes_ask"] = yes_ask entry["yes_bid"] = yes_bid entry["no_ask"] = no_ask entry["no_bid"] = no_bid entry["midpoint"] = _extract_price(yes_quote.get("midpoint")) or market_event_probability entry["spread"] = spread entry["yes_book_liquidity"] = _extract_price(yes_quote.get("book_liquidity")) entry["no_book_liquidity"] = _extract_price(no_quote.get("book_liquidity")) entry["quote_source"] = yes_quote.get("quote_source") or no_quote.get("quote_source") entry["quote_age_ms"] = _safe_int( yes_quote.get("quote_age_ms") if yes_quote.get("quote_age_ms") is not None else no_quote.get("quote_age_ms"), 0, ) model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability"))) if ( model_event_probability is not None and market_event_probability is not None and entry["market_direction"] in {"above", "below"} ): gap = model_event_probability - market_event_probability signed_gap = -gap if entry["market_direction"] == "below" else gap bias_inputs.append((max(model_event_probability, 0.08), signed_gap)) distribution_bias_value = None distribution_bias_score = 0.0 distribution_bias_direction = "balanced" if len(bias_inputs) >= 3: total_weight = sum(weight for weight, _signed_gap in bias_inputs) if total_weight > 0: distribution_bias_value = sum(weight * signed_gap for weight, signed_gap in bias_inputs) / total_weight distribution_bias_score = max(0.0, min(abs(distribution_bias_value) / 0.08, 1.0)) * 100.0 if distribution_bias_value >= 0.015: distribution_bias_direction = "hotter" elif distribution_bias_value <= -0.015: distribution_bias_direction = "colder" distribution_bias = { "available": len(bias_inputs) >= 3 and distribution_bias_value is not None, "value": distribution_bias_value, "direction": distribution_bias_direction, "score": distribution_bias_score, "valid_markets": len(bias_inputs), } current_reference_raw = _safe_float( (scan_context or {}).get("current_max_so_far") or (scan_context or {}).get("current_temp") ) def _row_from_entry(entry: Dict[str, Any], side: str) -> Optional[Dict[str, Any]]: model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability"))) market_event_probability = _clamp_probability(_safe_float(entry.get("market_event_probability"))) ask = _clamp_probability(_safe_float(entry.get("yes_ask") if side == "yes" else entry.get("no_ask"))) bid = _clamp_probability(_safe_float(entry.get("yes_bid") if side == "yes" else entry.get("no_bid"))) if model_event_probability is None or ask is None: return None model_probability = ( model_event_probability if side == "yes" else _clamp_probability(1.0 - model_event_probability) ) market_probability = ( market_event_probability if side == "yes" else _clamp_probability(1.0 - market_event_probability) ) if model_probability is None: return None market = entry["market"] target_threshold = _safe_float(entry.get("target_threshold")) bucket_range = entry.get("bucket_range") market_unit = bucket_range[2] if bucket_range else ("F" if self._is_fahrenheit_symbol(temp_symbol) else "C") current_reference = self._convert_temp_to_market_unit( current_reference_raw, source_symbol=temp_symbol, market_unit=market_unit, ) gap_to_target = ( target_threshold - current_reference if target_threshold is not None and current_reference is not None else None ) temperature_direction = self._resolve_temperature_direction( side=side, market_direction=str(entry.get("market_direction") or "exact"), target_threshold=target_threshold, current_reference=current_reference, ) trend_alignment = self._is_trend_aligned( temperature_direction=temperature_direction, trend_info=(scan_context or {}).get("trend"), network_lead_signal=(scan_context or {}).get("network_lead_signal"), ) edge = model_probability - ask edge_percent = edge * 100.0 liquidity_reference = max( _safe_float( entry.get("yes_book_liquidity") if side == "yes" else entry.get("no_book_liquidity") ) or 0.0, _safe_float(entry.get("market_liquidity")) or 0.0, ) if liquidity_reference >= 10000: liquidity_score = 1.0 elif liquidity_reference >= 5000: liquidity_score = 0.8 elif liquidity_reference >= 1000: liquidity_score = 0.6 else: liquidity_score = 0.4 if 0.10 <= ask <= 0.90: price_usefulness_score = 1.0 elif 0.05 <= ask < 0.10 or 0.90 < ask <= 0.95: price_usefulness_score = 0.7 else: price_usefulness_score = 0.0 bias_score = 0.0 if distribution_bias["available"]: if distribution_bias_direction == "balanced" or distribution_bias_direction == temperature_direction: bias_score = distribution_bias_score / 100.0 spread = _safe_float(entry.get("spread")) spread_penalty = max( 0.0, min(((spread or 0.0) - 0.01) / 0.02, 1.0), ) * 15.0 edge_score = max(0.0, min(edge_percent / 12.0, 1.0)) final_score = 100.0 * ( 0.35 * edge_score + 0.25 * bias_score + 0.20 * float(window_meta.get("score") or 0.0) + 0.10 * liquidity_score + 0.10 * price_usefulness_score ) - spread_penalty market_slug = str(market.get("slug") or "").strip() target_label = str(entry.get("target_label") or "").strip() action = f"BUY {'YES' if side == 'yes' else 'NO'}" if target_label: action = f"{action} {target_label}" return { "id": f"{city_key}|{target_date}|{market_slug}|{side}", "city": city_key, "selected_date": target_date, "market_slug": market_slug or None, "market_question": market.get("question") or market.get("title"), "market_url": self._build_market_url(market), "side": side, "action": action, "market_direction": entry.get("market_direction"), "temperature_direction": temperature_direction, "target_label": entry.get("target_label"), "target_value": entry.get("bucket_temp"), "target_threshold": target_threshold, "target_lower": bucket_range[0] if bucket_range else None, "target_upper": bucket_range[1] if bucket_range else None, "target_unit": market_unit, "model_probability": model_probability, "market_probability": market_probability, "model_event_probability": model_event_probability, "market_event_probability": market_event_probability, "gap": ( model_event_probability - market_event_probability if model_event_probability is not None and market_event_probability is not None else None ), "signed_gap": ( -1.0 * (model_event_probability - market_event_probability) if model_event_probability is not None and market_event_probability is not None and entry.get("market_direction") == "below" else ( model_event_probability - market_event_probability if model_event_probability is not None and market_event_probability is not None else None ) ), "yes_token_id": entry.get("yes_token_id"), "no_token_id": entry.get("no_token_id"), "yes_ask": entry.get("yes_ask"), "yes_bid": entry.get("yes_bid"), "no_ask": entry.get("no_ask"), "no_bid": entry.get("no_bid"), "ask": ask, "bid": bid, "midpoint": entry.get("midpoint"), "spread": spread, "book_liquidity": _safe_float( entry.get("yes_book_liquidity") if side == "yes" else entry.get("no_book_liquidity") ), "market_liquidity": entry.get("market_liquidity"), "volume": entry.get("volume"), "quote_source": entry.get("quote_source"), "quote_age_ms": entry.get("quote_age_ms"), "edge": edge, "edge_percent": edge_percent, "edge_score": edge_score, "bias_score": bias_score, "window_phase": window_meta.get("phase"), "window_score": window_meta.get("score"), "remaining_window_minutes": window_meta.get("remaining_minutes"), "liquidity_score": liquidity_score, "price_usefulness_score": price_usefulness_score, "spread_penalty": spread_penalty, "final_score": final_score, "distribution_bias_direction": distribution_bias_direction, "distribution_bias_score": distribution_bias_score, "distribution_bias_available": distribution_bias["available"], "current_reference": current_reference, "gap_to_target": gap_to_target, "touch_distance": abs(gap_to_target) if gap_to_target is not None else None, "trend_alignment": trend_alignment, "tradable": bool(entry["trade_state"].get("tradable")), "active": entry["trade_state"].get("active"), "closed": entry["trade_state"].get("closed"), "accepting_orders": entry["trade_state"].get("accepting_orders"), "enable_order_book": entry.get("enable_order_book"), "is_primary_market": bool( str(primary_market.get("slug") or "").strip().lower() and market_slug and str(primary_market.get("slug") or "").strip().lower() == market_slug.lower() ), } preliminary_rows: List[Dict[str, Any]] = [] for entry in market_entries: row_yes = _row_from_entry(entry, "yes") row_no = _row_from_entry(entry, "no") if row_yes: preliminary_rows.append(row_yes) if row_no: preliminary_rows.append(row_no) preliminary_rows.sort(key=lambda row: float(row.get("final_score") or 0.0), reverse=True) shortlist_market_slugs = [] seen_slugs = set() for row in preliminary_rows: market_slug = str(row.get("market_slug") or "").strip() if not market_slug or market_slug in seen_slugs: continue seen_slugs.add(market_slug) shortlist_market_slugs.append(market_slug) if len(shortlist_market_slugs) >= 10: break shortlisted_tokens: List[str] = [] for entry in market_entries: market_slug = str(entry["market"].get("slug") or "").strip() if market_slug not in seen_slugs: continue shortlisted_tokens.extend([entry["yes_token_id"], entry["no_token_id"]]) precise_quotes = self._batch_get_token_market_data(shortlisted_tokens, include_books=True) for entry in market_entries: market_slug = str(entry["market"].get("slug") or "").strip() if market_slug not in seen_slugs: continue yes_quote = precise_quotes.get(entry["yes_token_id"], {}) no_quote = precise_quotes.get(entry["no_token_id"], {}) if yes_quote: entry["yes_ask"] = _extract_price(yes_quote.get("buy")) or entry.get("yes_ask") entry["yes_bid"] = _extract_price(yes_quote.get("sell")) or entry.get("yes_bid") entry["midpoint"] = _extract_price(yes_quote.get("midpoint")) or entry.get("midpoint") entry["spread"] = _extract_price(yes_quote.get("spread")) or entry.get("spread") entry["yes_book_liquidity"] = _extract_price(yes_quote.get("book_liquidity")) or entry.get("yes_book_liquidity") entry["quote_source"] = yes_quote.get("quote_source") or entry.get("quote_source") if no_quote: entry["no_ask"] = _extract_price(no_quote.get("buy")) or entry.get("no_ask") entry["no_bid"] = _extract_price(no_quote.get("sell")) or entry.get("no_bid") entry["no_book_liquidity"] = _extract_price(no_quote.get("book_liquidity")) or entry.get("no_book_liquidity") entry["quote_source"] = no_quote.get("quote_source") or entry.get("quote_source") if entry.get("spread") is None and entry.get("yes_ask") is not None and entry.get("yes_bid") is not None: entry["spread"] = max(0.0, float(entry["yes_ask"]) - float(entry["yes_bid"])) final_rows: List[Dict[str, Any]] = [] for entry in market_entries: for side in ("yes", "no"): row = _row_from_entry(entry, side) if row: final_rows.append(row) def _passes_hard_filters(row: Dict[str, Any]) -> bool: ask = _safe_float(row.get("ask")) edge_percent = _safe_float(row.get("edge_percent")) spread = _safe_float(row.get("spread")) liquidity = max( _safe_float(row.get("book_liquidity")) or 0.0, _safe_float(row.get("market_liquidity")) or 0.0, ) if ask is None or edge_percent is None: return False if not row.get("tradable") or row.get("accepting_orders") is False: return False if row.get("enable_order_book") is False: return False if ask < filters["min_price"] or ask > filters["max_price"]: return False if edge_percent < filters["min_edge_pct"]: return False if spread is None or spread > filters["max_spread"]: return False if liquidity < filters["min_liquidity"]: return False return True def _passes_mode_filters(row: Dict[str, Any]) -> bool: scan_mode = filters["scan_mode"] if scan_mode == "tradable": return float(row.get("window_score") or 0.0) >= 0.65 if scan_mode == "early": return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"} if scan_mode == "touch": return ( bool(window_meta.get("same_day")) and str(row.get("window_phase") or "") in {"setup_today", "active_peak"} and (_safe_float(row.get("touch_distance")) is not None) and float(row.get("touch_distance")) <= 2.0 ) if scan_mode == "trend": return bool(row.get("trend_alignment")) return True filtered_rows = [ row for row in final_rows if _passes_hard_filters(row) and _passes_mode_filters(row) ] filtered_rows.sort( key=lambda row: ( float(row.get("final_score") or 0.0), float(row.get("edge_percent") or 0.0), ), reverse=True, ) primary_signal = filtered_rows[0] if filtered_rows else None signal_status = "ready" if primary_signal else "no_signal" return { "rows": filtered_rows[: filters["limit"]], "distribution_bias": distribution_bias, "primary_signal": primary_signal, "signal_status": signal_status, "candidate_count": len(filtered_rows), "window_phase": window_meta.get("phase"), "window_score": window_meta.get("score"), "resolved_market_type": "maxtemp", }