diff --git a/bot_listener.py b/bot_listener.py index 21b87489..2cbf4e3c 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -6,6 +6,7 @@ import re from datetime import datetime from src.utils.config_loader import load_config from src.utils.notifier import TelegramNotifier +from src.data_collection.polymarket_api import PolymarketClient def start_bot(): @@ -369,24 +370,133 @@ def start_bot(): return html_path + @bot.message_handler(func=lambda m: True) + def handle_city_query(message): + """输入城市名直查当日天气市场""" + import re + from datetime import datetime + + query = message.text.strip() + if len(query) < 2 or query.startswith("/"): + return + + bot.send_chat_action(message.chat.id, "typing") + + try: + # 1. 优先从本地全量市场缓存读取 (速度快,不依赖实时全量扫描) + cache_path = "data/all_markets.json" + if not os.path.exists(cache_path): + # 扫码还没完成的情形 + bot.reply_to(message, "⏳ 系统正在进行首次数据同步(约需1分钟),请稍后再试。") + return + + with open(cache_path, "r", encoding="utf-8") as f: + cached_data = json.load(f) + + pm = PolymarketClient(config["polymarket"]) + + # 2. 筛选匹配城市及日期的市场 + today_str = datetime.now().strftime("%Y-%m-%d") + city_markets = [] + + for m_id, m in cached_data.items(): + title = m.get("event_title", "") + m.get("question", "") + m.get("full_title", "") + if query.lower() in title.lower(): + # 提取并验证日期 + target_date = m.get("target_date") + if not target_date: + date_match = re.search(r'(\d{4}-\d{2}-\d{2})', title) + target_date = date_match.group(1) if date_match else "Unknown" + + if target_date != "Unknown" and target_date < today_str: + continue + + m["target_date"] = target_date + city_markets.append(m) + + if not city_markets: + if message.chat.type == "private": + bot.reply_to(message, f"❌ 未找到相关的活跃天气市场。\n提示:请确保输入的是城市常用名(如 Seattle, London)。") + return + + # 获取最早日期 + valid_dates = [m["target_date"] for m in city_markets if m["target_date"] != "Unknown"] + if not valid_dates: + bot.reply_to(message, "❌ 该城市目前没有已标明结算日期的活跃市场。") + return + + earliest_date = min(valid_dates) + target_markets = [m for m in city_markets if m["target_date"] == earliest_date] + + # 3. 构建报告 + msg_lines = [ + f"🌡️ {query.upper()} 概率报告 ({earliest_date})\n", + "隐含概率 (Midpoint) 及买入报价:\n" + ] + + # 批量获取实时价格 (确保报价最新) + price_reqs = [] + for m in target_markets: + t_ids = m.get("tokens", []) + if len(t_ids) >= 1: + price_reqs.append({"token_id": t_ids[0], "side": "ask"}) + price_reqs.append({"token_id": t_ids[0], "side": "bid"}) + + price_map = pm.get_multiple_prices(price_reqs) + + for m in target_markets: + tid = m.get("active_token_id") or (m.get("tokens", [])[0] if m.get("tokens") else None) + if not tid: continue + + # 获取中点价 (概率) + mid = pm.get_midpoint(tid) + prob = f"{mid*100:.1f}%" if mid is not None else "N/A" + + # 获取报价 + buy_yes = price_map.get(f"{tid}:ask") + bid_yes = price_map.get(f"{tid}:bid") + buy_no = (1.0 - bid_yes) if bid_yes is not None else None + + yes_str = f"{int(buy_yes*100)}¢" if buy_yes else "??¢" + no_str = f"{int(buy_no*100)}¢" if buy_no else "??¢" + + opt = m.get("option") or m.get("question") or "" + # 简化选项显示 + opt = re.sub(r'.*temperature in.*be ', '', opt, flags=re.I) + + msg_lines.append( + f"🔹 {opt}\n" + f" └ 隐含概率: {prob}\n" + f" └ 买入 是:{yes_str} | 买入 否:{no_str}\n" + ) + + msg_lines.append(f"\n🔗 在 Polymarket 查看") + bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML", disable_web_page_preview=True) + + except Exception as e: + logger.error(f"城市直查失败: {e}") + if message.chat.type == "private": + bot.reply_to(message, "❌ 抱歉,数据处理出现异常。") + @bot.message_handler(commands=["status"]) def get_status(message): bot.reply_to( message, "✅ 监控引擎正在运行中...\n7x24h 实时扫码 Polymarket 气温市场。" ) - try: - while True: - try: - bot.infinity_polling(timeout=60, long_polling_timeout=60) - except (KeyboardInterrupt, SystemExit): - print("\n检测到退出信号,机器人正在关机...") - break - except Exception as e: - print(f"Bot 轮询连接异常 (通常是网络问题): {e}") - time.sleep(10) # 等待10秒后自动重连 - except KeyboardInterrupt: - print("\n机器人已停止。") + import logging + # 强制关闭 telebot 内部的刷屏日志 + telebot.logger.setLevel(logging.CRITICAL) + + while True: + try: + bot.infinity_polling(timeout=60, long_polling_timeout=60) + except (KeyboardInterrupt, SystemExit): + print("\n检测到退出信号,机器人正在关机...") + break + except Exception as e: + print(f"Bot 轮询连接异常: {e}") + time.sleep(10) if __name__ == "__main__": diff --git a/main.py b/main.py index b71ab85b..f2425665 100644 --- a/main.py +++ b/main.py @@ -477,10 +477,9 @@ def main(): cached_signals[market_id] = cache_entry # --- 循环结束后统一推送本城市汇总 --- - if city_alerts: - notifier.send_combined_alert( - city, city_alerts, local_time=city_local_time - ) + notifier.send_combined_alert( + city, city_alerts, local_time=city_local_time + ) except Exception as e: logger.error(f"分析城市 {city} 时出错: {e}") @@ -488,22 +487,25 @@ def main(): # --- 每处理完一个城市,立即更新 JSON 文件 --- try: - # 1. 更新活跃信号缓存 (合并旧数据避免扫描中途变空) - final_signals = {} - if os.path.exists("data/active_signals.json"): - try: - with open( - "data/active_signals.json", "r", encoding="utf-8" - ) as f: - final_signals = json.load(f) - except: - pass - - final_signals.update(all_markets_cache) - + # --- 周期性结算:保存高价值信号 --- + active_signals = [] + for mid, entry in all_markets_cache.items(): + # 核心过滤:只有 ACTIVE 且 价格未锁定、日期未过期的才进入 signals 列表 + if entry.get("rationale") not in ["ENDED", "EXPIRED", "ERROR"]: + # 再次双重检查日期 (硬核拦截 2026-02-06) + target_dt = entry.get("target_date") + if target_dt and target_dt < "2026-02-06": + continue + active_signals.append(entry) + + # 按分数排序 + active_signals.sort(key=lambda x: x.get("score", 0), reverse=True) + with open("data/active_signals.json", "w", encoding="utf-8") as f: - json.dump(final_signals, f, ensure_ascii=False, indent=2) - + json.dump(active_signals, f, ensure_ascii=False, indent=4) + + logger.info(f"已更新活跃信号库,包含 {len(active_signals)} 个有效信号。") + # 2. 更新全量市场缓存 try: with open("data/all_markets.json", "r", encoding="utf-8") as f: diff --git a/src/data_collection/polymarket_api.py b/src/data_collection/polymarket_api.py index c1722198..4c53effb 100644 --- a/src/data_collection/polymarket_api.py +++ b/src/data_collection/polymarket_api.py @@ -6,31 +6,25 @@ from typing import Dict, List, Optional from loguru import logger from datetime import datetime -# 官方组件 -try: - from py_clob_client.client import ClobClient - from py_clob_client.constants import POLYGON -except ImportError: - logger.debug("官方库 py-clob-client 未安装,将回退到 requests 模式") - ClobClient = None - POLYGON = 137 +from py_clob_client.client import ClobClient +from py_clob_client.constants import POLYGON class PolymarketClient: """ - Polymarket API Client for market data and trading + Polymarket API Client for market data and trading (Exclusive py-clob-client mode) """ def __init__(self, config: Dict): self.base_url = config.get("base_url", "https://clob.polymarket.com") - self.timeout = config.get("timeout", 10) + self.timeout = config.get("timeout", 20) self.session = requests.Session() # 统一代理设置 proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") if proxy: self.session.proxies = {"http": proxy, "https": proxy} - logger.info(f"正在使用代理: {proxy}") # Added this line for logging + logger.info(f"正在使用代理: {proxy}") # 设置公开接口通用的 User-Agent self.session.headers.update( @@ -40,27 +34,41 @@ class PolymarketClient: } ) - # 只有在明确需要签名交易时才注入私钥相关头 (目前我们只拉取报价) + # 初始化官方 CLOB 客户端 self.api_key = config.get("api_key") self.api_secret = config.get("api_secret") self.api_passphrase = config.get("api_passphrase") - # 初始化官方 CLOB 客户端 - if ClobClient: - try: - self.clob_client = ClobClient( - host=self.base_url, - key=self.api_key, - secret=self.api_secret, - passphrase=self.api_passphrase, - chain_id=POLYGON + try: + from py_clob_client.clob_types import ApiCreds + + # 组装凭据对象 (如果提供) + creds = None + if self.api_key and self.api_secret: + creds = ApiCreds( + api_key=self.api_key, + api_secret=self.api_secret, + api_passphrase=self.api_passphrase ) - logger.info("官方 py-clob-client 初始化成功") - except Exception as e: - logger.error(f"官方客户端初始化失败: {e}") - self.clob_client = None - else: - self.clob_client = None + + self.clob_client = ClobClient( + host=self.base_url, + key=None, # 除非有 0x 开头的私钥,否则传入 None 以避免报错 + creds=creds, + chain_id=POLYGON + ) + # 注入代理到官方客户端 (官方库使用 httpx 或 requests) + if proxy: + # 尝试给官方 client 的内部 session 设置代理 (取决于版本实现) + try: + if hasattr(self.clob_client, 'session'): + self.clob_client.session.proxies = {"http": proxy, "https": proxy} + except: pass + + logger.info("✅ 官方 py-clob-client 已满血上线,Requests 模式已彻底退役。") + except Exception as e: + logger.error(f"官方客户端启动失败: {e}") + raise RuntimeError("必须安装并配置正确的 py-clob-client 才能运行。") self._setup_headers() logger.info(f"Polymarket 客户端初始化完成。Base URL: {self.base_url}") @@ -73,107 +81,44 @@ class PolymarketClient: if self.api_key: self.session.headers.update({"POLY_API_KEY": self.api_key}) - def _request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict]: - """Make HTTP request with error handling""" - url = f"{self.base_url}{endpoint}" - - try: - response = self.session.request( - method=method, url=url, timeout=self.timeout, **kwargs - ) - response.raise_for_status() - return response.json() - except requests.exceptions.Timeout: - logger.error(f"Request timeout: {url}") - return None - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - logger.debug(f"Resource not found (404): {url}") - else: - logger.error(f"HTTP error: {e}") - return None - except Exception as e: - logger.error(f"Request failed: {e}") - return None - def get_markets(self, next_cursor: str = None) -> Optional[Dict]: """ - Get list of all markets - - Returns: - dict: Market list with pagination info + 获取全量市场列表 (官方接口) """ - params = {} - if next_cursor: - params["next_cursor"] = next_cursor - - return self._request("GET", "/markets", params=params) + try: + return self.clob_client.get_markets(next_cursor=next_cursor) + except: return None def get_market(self, market_id: str) -> Optional[Dict]: """ - Get specific market details - - Args: - market_id: The market condition ID - - Returns: - dict: Market details + 获取特定市场详情 (官方接口) """ - return self._request("GET", f"/markets/{market_id}") + try: + return self.clob_client.get_market(market_id=market_id) + except: return None def get_price(self, token_id: str, side: str = "ask") -> Optional[float]: """ - 获取 Token 的实时盘口价格 (优先使用官方库) + 获取 Token 的实时盘口价格 (纯官方库实现) """ - # 官方库模式 - if self.clob_client: - try: - # 再次强调:Polymarket 的 side=BUY 对应的是 Ask (买入成本) - side_val = "BUY" if side == "ask" else "SELL" - price_str = self.clob_client.get_price(token_id=token_id, side=side_val) - if price_str: - return float(price_str) - except Exception as e: - logger.debug(f"官方库 get_price 失败: {e}") - - # 回退模式 try: - book = self.get_orderbook(token_id) - if book and isinstance(book, dict): - if side == "ask" and book.get("asks"): - return float(book["asks"][0].get("price")) - elif side == "bid" and book.get("bids"): - return float(book["bids"][0].get("price")) - - # 如果 orderbook 拿不到,尝试直接查 price 接口 - # 根据 Polymarket 文档:BUY 侧价格 = 市场上的最佳 ask (即你的买入成本) + # 官方语义:BUY 对应的是我们的买入成本 (Ask) side_val = "BUY" if side == "ask" else "SELL" - res = self._request("GET", "/price", params={"token_id": token_id, "side": side_val}) - if res and isinstance(res, dict) and "price" in res: - return float(res["price"]) + price_str = self.clob_client.get_price(token_id=token_id, side=side_val) + if price_str: + return float(price_str) except Exception as e: - logger.debug(f"抓取 CLOB 价格失败 ({token_id}): {e}") - + logger.debug(f"官方库 get_price 失败 ({token_id}): {e}") return None def get_orderbook(self, token_id: str) -> Optional[Dict]: """ - 获取订单簿深度 (优先使用官方库) + 获取订单簿深度 (纯官方库实现) """ - if self.clob_client: - try: - return self.clob_client.get_order_book(token_id=token_id) - except Exception as e: - logger.debug(f"官方库 get_order_book 失败: {e}") - - # 回退到 requests 模式 try: - url = f"{self.base_url}/book" - response = self.session.get(url, params={"token_id": token_id}, timeout=15) - if response.status_code == 200: - return response.json() + return self.clob_client.get_orderbook(token_id=token_id) except Exception as e: - logger.debug(f"获取订单簿失败: {e}") + logger.debug(f"官方库 get_orderbook 失败 ({token_id}): {e}") return None def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]: @@ -220,98 +165,73 @@ class PolymarketClient: def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]: """ - 批量获取多个 token 的价格 (使用 Polymarket 批量接口) + 批量获取多个 token 的价格 (官方接口实现) """ if not token_requests: return {} + all_prices = {} try: - # 批量获取价格端点 - url = f"{self.base_url}/prices" + # 准备官方批量请求格式 + # 我们映射 ask->BUY, bid->SELL + batch_req = [] + for r in token_requests: + side_val = "BUY" if r.get("side") == "ask" else "SELL" + batch_req.append({"token_id": r["token_id"], "side": side_val}) - # Polymarket 期望的查询参数格式 - # 我们需要获取可买入的价格,所以 side 应该是 "buy" - all_prices = {} - - # 分批处理以提高稳定性 - for i in range(0, len(token_requests), 50): - batch = token_requests[i : i + 50] - # 根据 Polymarket CLOB 文档,获取买入成本应使用 side=BUY (即 Ask 价格) - payload = [] - for r in batch: - # 遵循 CLOB API 规范:side=BUY 为买入成交价(Ask),side=SELL 为卖出成交价(Bid) - side_val = "BUY" if r.get("side") == "ask" else "SELL" - payload.append({"token_id": r["token_id"], "side": side_val}) - - response = self.session.post(url, json=payload, timeout=20) - if response.status_code == 200: - results = response.json() - - def robust_float(val): - if isinstance(val, (int, float)): return float(val) - if isinstance(val, str): - try: return float(val) - except: return 0.0 - if isinstance(val, dict): - for k in ["price", "p", "avg", "amount"]: - if k in val: return robust_float(val[k]) - return 0.0 - - if isinstance(results, dict): - for tid, p in results.items(): - val = robust_float(p) - # 如果是字典格式,默认我们请求的是 BUY(ask) - all_prices[tid] = val - all_prices[f"{tid}:ask"] = val - elif isinstance(results, list): - for item in results: - tid = item.get("token_id") - price_raw = item.get("price") - side = item.get("side") - if tid and price_raw: - val = robust_float(price_raw) - # 存储映射:API 的 BUY 对应我们的 ask 键 - key_side = "ask" if side == "BUY" else "bid" - all_prices[f"{tid}:{key_side}"] = val - all_prices[tid] = val - else: - logger.warning(f"批量价格返回非预期格式: {type(results)}") + # 使用官方批量获取接口 + results = self.clob_client.get_prices(batch_req) + + def robust_float(val): + if isinstance(val, (int, float)): return float(val) + if isinstance(val, str): + try: return float(val) + except: return 0.0 + return 0.0 + if isinstance(results, list): + for item in results: + tid = item.get("token_id") + price_raw = item.get("price") + side = item.get("side") + if tid and price_raw: + val = robust_float(price_raw) + key_side = "ask" if side == "BUY" else "bid" + all_prices[f"{tid}:{key_side}"] = val + all_prices[tid] = val + return all_prices except Exception as e: - logger.warning(f"批量获取盘口价格严重失败: {e}") - import traceback - logger.debug(traceback.format_exc()) + logger.warning(f"官方库批量获取报价失败: {e}") return {} def get_midpoint(self, token_id: str) -> Optional[float]: """ - Get midpoint price for a token - - Args: - token_id: The token ID - - Returns: - float: Midpoint price + 获取中点价格 (官方接口) """ - result = self._request("GET", f"/midpoint", params={"token_id": token_id}) - if result and "mid" in result: - return float(result["mid"]) + try: + res = self.clob_client.get_midpoint(token_id) + if res and "mid" in res: + return float(res["mid"]) + except: pass return None def search_markets(self, query: str) -> Optional[Dict]: """ - Search markets by query - - Args: - query: Search query string - - Returns: - dict: Search results + 搜索市场 """ - return self._request("GET", "/markets", params={"tag": query}) + try: + # Note: The py-clob-client's get_markets method does not directly support a 'query' parameter for searching. + # It primarily supports pagination (next_cursor). + # This implementation will fetch the first page of markets and return them. + # A more robust search would involve fetching all markets and filtering locally, + # or using a different API endpoint if available. + return self.clob_client.get_markets(next_cursor=None) + except Exception as e: + logger.error(f"搜索市场失败: {e}") + return None - # Trading methods (require authentication) + # --- 交易指令 (强依赖官方库) --- def create_order( self, token_id: str, @@ -321,49 +241,32 @@ class PolymarketClient: order_type: str = "GTC", ) -> Optional[Dict]: """ - Create a new order (requires API key) - - Args: - token_id: Token to trade - side: "BUY" or "SELL" - price: Order price - size: Order size - order_type: Order type (GTC, GTD, FOK) - - Returns: - dict: Order confirmation + 创建新订单 """ - if not self.api_key: - logger.error("API key required for trading") + try: + # 转换方向 + side_val = "BUY" if side.upper() == "BUY" else "SELL" + # 使用官方签名下单 (SDK 会自动处理签名) + return self.clob_client.create_order( + token_id=token_id, + price=price, + size=size, + side=side_val + ) + except Exception as e: + logger.error(f"下单失败: {e}") return None - order_data = { - "tokenID": token_id, - "side": side.upper(), - "price": str(price), - "size": str(size), - "type": order_type, - } - - logger.info(f"Creating order: {side} {size} @ {price}") - return self._request("POST", "/order", json=order_data) - def cancel_order(self, order_id: str) -> Optional[Dict]: """ - Cancel an existing order - - Args: - order_id: Order ID to cancel - - Returns: - dict: Cancellation confirmation + 取消订单 """ - if not self.api_key: - logger.error("API key required for trading") + try: + return self.clob_client.cancel_order(order_id) + except Exception as e: + logger.error(f"取消订单失败: {e}") return None - return self._request("DELETE", f"/order/{order_id}") - def discover_weather_markets(self) -> list: """ 通过全量扫描活跃事件发现最高温天气市场。 @@ -424,7 +327,7 @@ class PolymarketClient: logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约") try: - # 1. 扫描活跃且未合并的 (全量) - 增加到20000以确保抓取所有天气市场 + # 1. 扫描活跃且未合并的 (全量) for offset in range(0, 20000, 1000): params = { "active": "true", @@ -432,18 +335,31 @@ class PolymarketClient: "limit": 1000, "offset": offset, } - response = self.session.get( - gamma_url, params=params, timeout=self.timeout - ) - if response.status_code == 200: - events = response.json() - if not events: - break - process_events(events, f"Open-O{offset}") - else: + + # 增加重试机制 + success = False + for retry in range(3): + try: + response = self.session.get( + gamma_url, params=params, timeout=self.timeout + ) + if response.status_code == 200: + events = response.json() + if not events: + break + process_events(events, f"Open-O{offset}") + success = True + break + else: + logger.warning(f"Gamma API 状态码异常 ({response.status_code}),第 {retry+1} 次重试...") + except Exception as e: + logger.warning(f"发现市场请求出错: {e},第 {retry+1} 次重试...") + time.sleep(2) + + if not success: break - # 2. 扫描活跃但已关闭的 - 增加到20000以覆盖更多历史 + # 2. 扫描活跃但已关闭的 for offset in range(0, 20000, 1000): params = { "active": "true", @@ -451,15 +367,25 @@ class PolymarketClient: "limit": 1000, "offset": offset, } - response = self.session.get( - gamma_url, params=params, timeout=self.timeout - ) - if response.status_code == 200: - events = response.json() - if not events: - break - process_events(events, f"Closed-O{offset}") - else: + + success = False + for retry in range(3): + try: + response = self.session.get( + gamma_url, params=params, timeout=self.timeout + ) + if response.status_code == 200: + events = response.json() + if not events: + break + process_events(events, f"Closed-O{offset}") + success = True + break + except Exception as e: + logger.warning(f"发现关闭市场请求出错: {e},第 {retry+1} 次重试...") + time.sleep(2) + + if not success: break # 3. 扫描非活跃但未关闭的市场