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]
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,
}
+110 -21
View File
@@ -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
+95 -2
View File
@@ -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}