diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py index 2ad32e69..166d3991 100644 --- a/src/analysis/market_alert_engine.py +++ b/src/analysis/market_alert_engine.py @@ -704,15 +704,16 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]: top_bucket_rows = all_bucket_rows[:4] market_url = None + primary_market = scan.get("primary_market") or {} + if not isinstance(primary_market, dict): + primary_market = {} websocket = scan.get("websocket") or {} if isinstance(websocket, dict): market_url = str(websocket.get("market_url") or "").strip() or None if not market_url: - primary_market = scan.get("primary_market") or {} - if isinstance(primary_market, dict): - slug = str(primary_market.get("slug") or "").strip() - if slug: - market_url = f"https://polymarket.com/market/{slug}" + slug = str(primary_market.get("slug") or "").strip() + if slug: + market_url = f"https://polymarket.com/market/{slug}" anchor_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather) anchor_settlement = wu_round(anchor_today_high_c) @@ -749,6 +750,15 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]: "open_meteo_today_high_c": anchor_today_high_c, "open_meteo_settlement": anchor_settlement, "forecast_bucket": forecast_bucket, + "selected_date": scan.get("selected_date"), + "selected_slug": scan.get("selected_slug"), + "primary_market": primary_market, + "market_active": primary_market.get("active"), + "market_closed": primary_market.get("closed"), + "market_accepting_orders": primary_market.get("accepting_orders"), + "market_tradable": primary_market.get("tradable"), + "market_tradable_reason": primary_market.get("tradable_reason"), + "market_ended_at_utc": primary_market.get("ended_at_utc"), "primary_market_url": market_url, "market_url": forecast_market_url or market_url, } diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 7c24eb7b..d46816e6 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -16,7 +16,7 @@ import re import threading import time import unicodedata -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple import requests @@ -53,6 +53,22 @@ def _safe_int(value: Any, default: int) -> int: 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: @@ -180,6 +196,24 @@ def _extract_iso_date(value: Any) -> Optional[str]: 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(): @@ -448,20 +482,43 @@ class PolymarketReadOnlyLayer: 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 + 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"] = { - "condition_id": condition_id, - "end_date": market_date, - "id": market.get("id"), - "liquidity": liquidity, - "question": market.get("question") or market.get("title"), - "slug": market_slug, - "volume": volume, - } + scan["primary_market"] = primary_market_payload scan["selected_condition_id"] = condition_id scan["selected_slug"] = market_slug scan["liquidity"] = liquidity @@ -541,17 +598,7 @@ class PolymarketReadOnlyLayer: { "available": True, "reason": None, - "primary_market": { - "id": market.get("id"), - "question": market.get("question") or market.get("title"), - "slug": market_slug, - "condition_id": condition_id, - "end_date": market_date, - "active": bool(market.get("active", False)), - "closed": bool(market.get("closed", False)), - "liquidity": liquidity, - "volume": volume, - }, + "primary_market": primary_market_payload, "selected_condition_id": condition_id, "selected_slug": market_slug, "market_price": market_price, @@ -586,6 +633,46 @@ class PolymarketReadOnlyLayer: ) return scan + 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 + + now_utc = datetime.now(timezone.utc) + 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" + elif ended_at is not None and ended_at <= now_utc: + tradable = False + reason = "past_end_time" + + 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], @@ -1274,6 +1361,8 @@ class PolymarketReadOnlyLayer: ] ] = [] for market in candidate_markets: + if not self._market_trade_state(market).get("tradable"): + continue bucket_temp = self._extract_market_bucket_temp(market) if bucket_temp is None: continue diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index aa8a092f..255f6755 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -3,7 +3,7 @@ import json import os import threading import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from loguru import logger @@ -58,6 +58,39 @@ def _norm_prob(v: Any) -> Optional[float]: return max(0.0, min(1.0, n)) +def _optional_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 _parse_iso_datetime_utc(value: Any) -> Optional[datetime]: + if not value: + return None + text = str(value).strip() + if not text: + return None + 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 _parse_city_list(raw: Optional[str]) -> List[str]: if not raw: return list(CITY_REGISTRY.keys()) @@ -152,6 +185,63 @@ def _market_price_cap_ok( return False return True + primary_market = market.get("primary_market") or {} + if not isinstance(primary_market, dict): + primary_market = {} + market_slug = ( + str(market.get("selected_slug") or "").strip() + or str(primary_market.get("slug") or "").strip() + or "--" + ) + active = market.get("market_active") + if active is None: + active = primary_market.get("active") + active = _optional_bool(active) + closed = market.get("market_closed") + if closed is None: + closed = primary_market.get("closed") + closed = _optional_bool(closed) + accepting_orders = market.get("market_accepting_orders") + if accepting_orders is None: + accepting_orders = primary_market.get( + "accepting_orders", + primary_market.get("acceptingOrders"), + ) + accepting_orders = _optional_bool(accepting_orders) + market_tradable = _optional_bool(market.get("market_tradable")) + tradable_reason = str( + market.get("market_tradable_reason") + or primary_market.get("tradable_reason") + or "" + ).strip() + ended_at = str( + market.get("market_ended_at_utc") + or primary_market.get("ended_at_utc") + or "" + ).strip() + ended_dt = _parse_iso_datetime_utc(ended_at) + is_past_end = ended_dt is not None and ended_dt <= datetime.now(timezone.utc) + if ( + market_tradable is False + or closed is True + or active is False + or accepting_orders is False + or is_past_end + ): + reason = tradable_reason or ("past_end_time" if is_past_end else "market_not_tradable") + logger.info( + "trade alert skipped: market not tradable city={} slug={} reason={} active={} closed={} accepting_orders={} ended_at={}".format( + alert_payload.get("city"), + market_slug, + reason, + active, + closed, + accepting_orders, + ended_at or "--", + ) + ) + return False + # Strict rule: use the bucket mapped from multi-model anchor settlement. forecast_bucket = market.get("forecast_bucket") or {} settle_ref = market.get("anchor_settlement") @@ -310,7 +400,10 @@ def build_trade_alert_for_city( city_weather = _analyze(city, force_refresh=force_refresh) try: - aggregate_detail = _build_city_detail_payload(city_weather) + aggregate_detail = _build_city_detail_payload( + city_weather, + target_date=target_date, + ) market_scan = aggregate_detail.get("market_scan") if isinstance(market_scan, dict): city_weather = {**city_weather, "market_scan": market_scan}