feat: Add Polymarket read-only data layer, Telegram push utility, and market alert engine.

This commit is contained in:
2569718930@qq.com
2026-03-12 10:06:09 +08:00
parent c156809231
commit 20d3e940e0
3 changed files with 220 additions and 28 deletions
+15 -5
View File
@@ -704,15 +704,16 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
top_bucket_rows = all_bucket_rows[:4] top_bucket_rows = all_bucket_rows[:4]
market_url = None market_url = None
primary_market = scan.get("primary_market") or {}
if not isinstance(primary_market, dict):
primary_market = {}
websocket = scan.get("websocket") or {} websocket = scan.get("websocket") or {}
if isinstance(websocket, dict): if isinstance(websocket, dict):
market_url = str(websocket.get("market_url") or "").strip() or None market_url = str(websocket.get("market_url") or "").strip() or None
if not market_url: if not market_url:
primary_market = scan.get("primary_market") or {} slug = str(primary_market.get("slug") or "").strip()
if isinstance(primary_market, dict): if slug:
slug = str(primary_market.get("slug") or "").strip() market_url = f"https://polymarket.com/market/{slug}"
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_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather)
anchor_settlement = wu_round(anchor_today_high_c) 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_today_high_c": anchor_today_high_c,
"open_meteo_settlement": anchor_settlement, "open_meteo_settlement": anchor_settlement,
"forecast_bucket": forecast_bucket, "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, "primary_market_url": market_url,
"market_url": forecast_market_url or market_url, "market_url": forecast_market_url or market_url,
} }
+110 -21
View File
@@ -16,7 +16,7 @@ import re
import threading import threading
import time import time
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import requests import requests
@@ -53,6 +53,22 @@ def _safe_int(value: Any, default: int) -> int:
return default 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: def _normalize_text(value: Any) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
if not text: if not text:
@@ -180,6 +196,24 @@ def _extract_iso_date(value: Any) -> Optional[str]:
return None 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]]: def _build_city_token_index() -> Dict[str, List[str]]:
result: Dict[str, List[str]] = {} result: Dict[str, List[str]] = {}
for key, info in CITY_REGISTRY.items(): for key, info in CITY_REGISTRY.items():
@@ -448,20 +482,43 @@ class PolymarketReadOnlyLayer:
or market.get("volume") or market.get("volume")
or market.get("volume24hr") 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) tokens = self._extract_market_tokens(market)
yes_token, no_token = self._resolve_yes_no_tokens(tokens) yes_token, no_token = self._resolve_yes_no_tokens(tokens)
if not yes_token or not no_token: if not yes_token or not no_token:
scan["reason"] = "Matched market has no resolvable YES/NO token pair." scan["reason"] = "Matched market has no resolvable YES/NO token pair."
scan["primary_market"] = { scan["primary_market"] = primary_market_payload
"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["selected_condition_id"] = condition_id scan["selected_condition_id"] = condition_id
scan["selected_slug"] = market_slug scan["selected_slug"] = market_slug
scan["liquidity"] = liquidity scan["liquidity"] = liquidity
@@ -541,17 +598,7 @@ class PolymarketReadOnlyLayer:
{ {
"available": True, "available": True,
"reason": None, "reason": None,
"primary_market": { "primary_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": bool(market.get("active", False)),
"closed": bool(market.get("closed", False)),
"liquidity": liquidity,
"volume": volume,
},
"selected_condition_id": condition_id, "selected_condition_id": condition_id,
"selected_slug": market_slug, "selected_slug": market_slug,
"market_price": market_price, "market_price": market_price,
@@ -586,6 +633,46 @@ class PolymarketReadOnlyLayer:
) )
return scan 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( def _derive_signal(
self, self,
edge_percent: Optional[float], edge_percent: Optional[float],
@@ -1274,6 +1361,8 @@ class PolymarketReadOnlyLayer:
] ]
] = [] ] = []
for market in candidate_markets: for market in candidate_markets:
if not self._market_trade_state(market).get("tradable"):
continue
bucket_temp = self._extract_market_bucket_temp(market) bucket_temp = self._extract_market_bucket_temp(market)
if bucket_temp is None: if bucket_temp is None:
continue continue
+95 -2
View File
@@ -3,7 +3,7 @@ import json
import os import os
import threading import threading
import time import time
from datetime import datetime from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from loguru import logger from loguru import logger
@@ -58,6 +58,39 @@ def _norm_prob(v: Any) -> Optional[float]:
return max(0.0, min(1.0, n)) 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]: def _parse_city_list(raw: Optional[str]) -> List[str]:
if not raw: if not raw:
return list(CITY_REGISTRY.keys()) return list(CITY_REGISTRY.keys())
@@ -152,6 +185,63 @@ def _market_price_cap_ok(
return False return False
return True 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. # Strict rule: use the bucket mapped from multi-model anchor settlement.
forecast_bucket = market.get("forecast_bucket") or {} forecast_bucket = market.get("forecast_bucket") or {}
settle_ref = market.get("anchor_settlement") settle_ref = market.get("anchor_settlement")
@@ -310,7 +400,10 @@ def build_trade_alert_for_city(
city_weather = _analyze(city, force_refresh=force_refresh) city_weather = _analyze(city, force_refresh=force_refresh)
try: 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") market_scan = aggregate_detail.get("market_scan")
if isinstance(market_scan, dict): if isinstance(market_scan, dict):
city_weather = {**city_weather, "market_scan": market_scan} city_weather = {**city_weather, "market_scan": market_scan}