feat: Add Polymarket read-only data layer, Telegram push utility, and market alert engine.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user