feat: Introduce Polymarket API client to enable direct city weather market queries in the bot and improve active signal processing.

This commit is contained in:
2569718930@qq.com
2026-02-06 23:29:16 +08:00
parent e0d759819c
commit fb3efeb13b
3 changed files with 303 additions and 265 deletions
+122 -12
View File
@@ -6,6 +6,7 @@ import re
from datetime import datetime from datetime import datetime
from src.utils.config_loader import load_config from src.utils.config_loader import load_config
from src.utils.notifier import TelegramNotifier from src.utils.notifier import TelegramNotifier
from src.data_collection.polymarket_api import PolymarketClient
def start_bot(): def start_bot():
@@ -369,24 +370,133 @@ def start_bot():
return html_path 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"🌡️ <b>{query.upper()} 概率报告 ({earliest_date})</b>\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"🔹 <b>{opt}</b>\n"
f" └ 隐含概率: <code>{prob}</code>\n"
f" └ 买入 是:{yes_str} | 买入 否:{no_str}\n"
)
msg_lines.append(f"\n🔗 <a href='https://polymarket.com/event/{target_markets[0]['slug']}'>在 Polymarket 查看</a>")
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"]) @bot.message_handler(commands=["status"])
def get_status(message): def get_status(message):
bot.reply_to( bot.reply_to(
message, "✅ 监控引擎正在运行中...\n7x24h 实时扫码 Polymarket 气温市场。" message, "✅ 监控引擎正在运行中...\n7x24h 实时扫码 Polymarket 气温市场。"
) )
try: import logging
while True: # 强制关闭 telebot 内部的刷屏日志
try: telebot.logger.setLevel(logging.CRITICAL)
bot.infinity_polling(timeout=60, long_polling_timeout=60)
except (KeyboardInterrupt, SystemExit): while True:
print("\n检测到退出信号,机器人正在关机...") try:
break bot.infinity_polling(timeout=60, long_polling_timeout=60)
except Exception as e: except (KeyboardInterrupt, SystemExit):
print(f"Bot 轮询连接异常 (通常是网络问题): {e}") print("\n检测到退出信号,机器人正在关机...")
time.sleep(10) # 等待10秒后自动重连 break
except KeyboardInterrupt: except Exception as e:
print("\n机器人已停止。") print(f"Bot 轮询连接异常: {e}")
time.sleep(10)
if __name__ == "__main__": if __name__ == "__main__":
+18 -16
View File
@@ -477,10 +477,9 @@ def main():
cached_signals[market_id] = cache_entry cached_signals[market_id] = cache_entry
# --- 循环结束后统一推送本城市汇总 --- # --- 循环结束后统一推送本城市汇总 ---
if city_alerts: notifier.send_combined_alert(
notifier.send_combined_alert( city, city_alerts, local_time=city_local_time
city, city_alerts, local_time=city_local_time )
)
except Exception as e: except Exception as e:
logger.error(f"分析城市 {city} 时出错: {e}") logger.error(f"分析城市 {city} 时出错: {e}")
@@ -488,21 +487,24 @@ def main():
# --- 每处理完一个城市,立即更新 JSON 文件 --- # --- 每处理完一个城市,立即更新 JSON 文件 ---
try: try:
# 1. 更新活跃信号缓存 (合并旧数据避免扫描中途变空) # --- 周期性结算:保存高价值信号 ---
final_signals = {} active_signals = []
if os.path.exists("data/active_signals.json"): for mid, entry in all_markets_cache.items():
try: # 核心过滤:只有 ACTIVE 且 价格未锁定、日期未过期的才进入 signals 列表
with open( if entry.get("rationale") not in ["ENDED", "EXPIRED", "ERROR"]:
"data/active_signals.json", "r", encoding="utf-8" # 再次双重检查日期 (硬核拦截 2026-02-06)
) as f: target_dt = entry.get("target_date")
final_signals = json.load(f) if target_dt and target_dt < "2026-02-06":
except: continue
pass active_signals.append(entry)
final_signals.update(all_markets_cache) # 按分数排序
active_signals.sort(key=lambda x: x.get("score", 0), reverse=True)
with open("data/active_signals.json", "w", encoding="utf-8") as f: 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. 更新全量市场缓存 # 2. 更新全量市场缓存
try: try:
+158 -232
View File
@@ -6,31 +6,25 @@ from typing import Dict, List, Optional
from loguru import logger from loguru import logger
from datetime import datetime from datetime import datetime
# 官方组件 from py_clob_client.client import ClobClient
try: from py_clob_client.constants import POLYGON
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
class PolymarketClient: 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): def __init__(self, config: Dict):
self.base_url = config.get("base_url", "https://clob.polymarket.com") 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() self.session = requests.Session()
# 统一代理设置 # 统一代理设置
proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
if proxy: if proxy:
self.session.proxies = {"http": proxy, "https": proxy} self.session.proxies = {"http": proxy, "https": proxy}
logger.info(f"正在使用代理: {proxy}") # Added this line for logging logger.info(f"正在使用代理: {proxy}")
# 设置公开接口通用的 User-Agent # 设置公开接口通用的 User-Agent
self.session.headers.update( self.session.headers.update(
@@ -40,27 +34,41 @@ class PolymarketClient:
} }
) )
# 只有在明确需要签名交易时才注入私钥相关头 (目前我们只拉取报价) # 初始化官方 CLOB 客户端
self.api_key = config.get("api_key") self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret") self.api_secret = config.get("api_secret")
self.api_passphrase = config.get("api_passphrase") self.api_passphrase = config.get("api_passphrase")
# 初始化官方 CLOB 客户端 try:
if ClobClient: from py_clob_client.clob_types import ApiCreds
try:
self.clob_client = ClobClient( # 组装凭据对象 (如果提供)
host=self.base_url, creds = None
key=self.api_key, if self.api_key and self.api_secret:
secret=self.api_secret, creds = ApiCreds(
passphrase=self.api_passphrase, api_key=self.api_key,
chain_id=POLYGON api_secret=self.api_secret,
api_passphrase=self.api_passphrase
) )
logger.info("官方 py-clob-client 初始化成功")
except Exception as e: self.clob_client = ClobClient(
logger.error(f"官方客户端初始化失败: {e}") host=self.base_url,
self.clob_client = None key=None, # 除非有 0x 开头的私钥,否则传入 None 以避免报错
else: creds=creds,
self.clob_client = None 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() self._setup_headers()
logger.info(f"Polymarket 客户端初始化完成。Base URL: {self.base_url}") logger.info(f"Polymarket 客户端初始化完成。Base URL: {self.base_url}")
@@ -73,107 +81,44 @@ class PolymarketClient:
if self.api_key: if self.api_key:
self.session.headers.update({"POLY_API_KEY": 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]: def get_markets(self, next_cursor: str = None) -> Optional[Dict]:
""" """
Get list of all markets 获取全量市场列表 (官方接口)
Returns:
dict: Market list with pagination info
""" """
params = {} try:
if next_cursor: return self.clob_client.get_markets(next_cursor=next_cursor)
params["next_cursor"] = next_cursor except: return None
return self._request("GET", "/markets", params=params)
def get_market(self, market_id: str) -> Optional[Dict]: 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]: 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: try:
book = self.get_orderbook(token_id) # 官方语义:BUY 对应的是我们的买入成本 (Ask)
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 (即你的买入成本)
side_val = "BUY" if side == "ask" else "SELL" side_val = "BUY" if side == "ask" else "SELL"
res = self._request("GET", "/price", params={"token_id": token_id, "side": side_val}) price_str = self.clob_client.get_price(token_id=token_id, side=side_val)
if res and isinstance(res, dict) and "price" in res: if price_str:
return float(res["price"]) return float(price_str)
except Exception as e: except Exception as e:
logger.debug(f"抓取 CLOB 价格失败 ({token_id}): {e}") logger.debug(f"官方库 get_price 失败 ({token_id}): {e}")
return None return None
def get_orderbook(self, token_id: str) -> Optional[Dict]: 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: try:
url = f"{self.base_url}/book" return self.clob_client.get_orderbook(token_id=token_id)
response = self.session.get(url, params={"token_id": token_id}, timeout=15)
if response.status_code == 200:
return response.json()
except Exception as e: except Exception as e:
logger.debug(f"获取订单簿失败: {e}") logger.debug(f"官方库 get_orderbook 失败 ({token_id}): {e}")
return None return None
def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]: 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]: def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]:
""" """
批量获取多个 token 的价格 (使用 Polymarket 批量接口) 批量获取多个 token 的价格 (官方接口实现)
""" """
if not token_requests: if not token_requests:
return {} return {}
all_prices = {}
try: 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" results = self.clob_client.get_prices(batch_req)
all_prices = {}
# 分批处理以提高稳定性 def robust_float(val):
for i in range(0, len(token_requests), 50): if isinstance(val, (int, float)): return float(val)
batch = token_requests[i : i + 50] if isinstance(val, str):
# 根据 Polymarket CLOB 文档,获取买入成本应使用 side=BUY (即 Ask 价格) try: return float(val)
payload = [] except: return 0.0
for r in batch: return 0.0
# 遵循 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 isinstance(results, list):
if response.status_code == 200: for item in results:
results = response.json() tid = item.get("token_id")
price_raw = item.get("price")
def robust_float(val): side = item.get("side")
if isinstance(val, (int, float)): return float(val) if tid and price_raw:
if isinstance(val, str): val = robust_float(price_raw)
try: return float(val) key_side = "ask" if side == "BUY" else "bid"
except: return 0.0 all_prices[f"{tid}:{key_side}"] = val
if isinstance(val, dict): all_prices[tid] = val
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)}")
return all_prices return all_prices
except Exception as e: except Exception as e:
logger.warning(f"批量获取盘口价格严重失败: {e}") logger.warning(f"官方库批量获取报价失败: {e}")
import traceback
logger.debug(traceback.format_exc())
return {} return {}
def get_midpoint(self, token_id: str) -> Optional[float]: 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}) try:
if result and "mid" in result: res = self.clob_client.get_midpoint(token_id)
return float(result["mid"]) if res and "mid" in res:
return float(res["mid"])
except: pass
return None return None
def search_markets(self, query: str) -> Optional[Dict]: 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( def create_order(
self, self,
token_id: str, token_id: str,
@@ -321,49 +241,32 @@ class PolymarketClient:
order_type: str = "GTC", order_type: str = "GTC",
) -> Optional[Dict]: ) -> 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: try:
logger.error("API key required for trading") # 转换方向
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 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]: 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: try:
logger.error("API key required for trading") return self.clob_client.cancel_order(order_id)
except Exception as e:
logger.error(f"取消订单失败: {e}")
return None return None
return self._request("DELETE", f"/order/{order_id}")
def discover_weather_markets(self) -> list: def discover_weather_markets(self) -> list:
""" """
通过全量扫描活跃事件发现最高温天气市场。 通过全量扫描活跃事件发现最高温天气市场。
@@ -424,7 +327,7 @@ class PolymarketClient:
logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约") logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约")
try: try:
# 1. 扫描活跃且未合并的 (全量) - 增加到20000以确保抓取所有天气市场 # 1. 扫描活跃且未合并的 (全量)
for offset in range(0, 20000, 1000): for offset in range(0, 20000, 1000):
params = { params = {
"active": "true", "active": "true",
@@ -432,18 +335,31 @@ class PolymarketClient:
"limit": 1000, "limit": 1000,
"offset": offset, "offset": offset,
} }
response = self.session.get(
gamma_url, params=params, timeout=self.timeout # 增加重试机制
) success = False
if response.status_code == 200: for retry in range(3):
events = response.json() try:
if not events: response = self.session.get(
break gamma_url, params=params, timeout=self.timeout
process_events(events, f"Open-O{offset}") )
else: 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 break
# 2. 扫描活跃但已关闭的 - 增加到20000以覆盖更多历史 # 2. 扫描活跃但已关闭的
for offset in range(0, 20000, 1000): for offset in range(0, 20000, 1000):
params = { params = {
"active": "true", "active": "true",
@@ -451,15 +367,25 @@ class PolymarketClient:
"limit": 1000, "limit": 1000,
"offset": offset, "offset": offset,
} }
response = self.session.get(
gamma_url, params=params, timeout=self.timeout success = False
) for retry in range(3):
if response.status_code == 200: try:
events = response.json() response = self.session.get(
if not events: gamma_url, params=params, timeout=self.timeout
break )
process_events(events, f"Closed-O{offset}") if response.status_code == 200:
else: 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 break
# 3. 扫描非活跃但未关闭的市场 # 3. 扫描非活跃但未关闭的市场