diff --git a/MARKET_DISCOVERY.md b/MARKET_DISCOVERY.md new file mode 100644 index 00000000..06f46ff4 --- /dev/null +++ b/MARKET_DISCOVERY.md @@ -0,0 +1,71 @@ +# Polymarket Weather Market Discovery Technical Documentation + +This document explains the technical implementation of how PolyWeather identifies and tracks weather markets on Polymarket. + +## 1. Data Sources + +We bypass high-level SDKs and interact directly with the **Polymarket Gamma API**, which is the primary metadata layer for Discovery. + +- **Base URL:** `https://gamma-api.polymarket.com` +- **Endpoint:** `/markets` + +## 2. Discovery Strategy + +The system uses a multi-layered search approach to ensure no city segments are missed. + +### 2.1 Keyword Triple-Search + +Instead of one query, we execute three concurrent search patterns: + +1. `"highest temperature"`: Targets the primary question text. +2. `"temperature in"`: Broad search for regional markets. +3. `"daily weather"`: Fallback for markets with different naming conventions. + +### 2.2 Prioritization + +We apply specific sorting to find the **latest** available contracts (e.g., February 9th, 2026): + +- `order=id` & `ascending=false`: Scans the newest created markets first. +- `active=true` & `closed=false`: Filters out resolved or expired contracts. + +## 3. Filtering & Parsing Logic + +Since Polymarket hosts thousands of events, we apply a strict "Weather Filter" in the code: + +### 3.1 Text Validation + +We inspect both the `question` and the `slug`: + +- **Pattern Match:** Must contain `"highest temperature in"` or `"highest-temperature-in"`. +- **Exclusion:** (Implicitly handled by keyword search) filtered from sports or politics. + +### 3.2 Negative Risk Market Handling + +Weather markets on Polymarket are often structured as **Negative Risk** groups (where multiple outcomes like "70°F or higher" and "68-69°F" belong to one event). + +**Technical Challenge:** In the API's list view, the `activeTokenId` field is often `null` for these complex markets. +**Our Solution:** + +1. Check `clobTokenIds`. +2. If it's a JSON string (common in Gamma), parse it into a Python list. +3. If `activeTokenId` is missing, we treat the first token ID in the list as the **"YES" Token**. +4. This allows us to fetch the real-time orderbook/price even for markets that haven't fully "activated" in the front-end metadata. + +## 4. Market Data Structure + +Every market found is normalized into this structure for the Decision Engine: + +- `condition_id`: The UMA condition ID for resolution. +- `active_token_id`: The specific ERC1155 token ID we want to buy/monitor. +- `group_id`: The `negRiskMarketID`, which allows the bot to understand that specific temperature ranges (e.g., 70°F vs 72°F) are related to the same city. +- `slug`: Used for generating direct dashboard links. + +## 5. Frequency & Caching + +- **Discovery Frequency:** The system rescans for new cities/dates every **5 minutes**. +- **Caching:** Found markets are stored in an internal memory cache (`_weather_markets_cache`) to reduce API pressure and avoid rate limits. + +--- + +_Created on: 2026-02-07_ +_PolyWeather System Documentation_ diff --git a/MARKET_DISCOVERY_ZH.md b/MARKET_DISCOVERY_ZH.md new file mode 100644 index 00000000..a69b8869 --- /dev/null +++ b/MARKET_DISCOVERY_ZH.md @@ -0,0 +1,71 @@ +# Polymarket 天气市场搜寻技术文档 + +本文档详细说明了 PolyWeather 如何在 Polymarket 上自动识别、筛选并跟踪天气相关市场的技术实现逻辑。 + +## 1. 数据来源 + +我们跳过了复杂的官方 SDK,直接与 **Polymarket Gamma API** 交互。这是 Polymarket 的官方元数据层,负责所有市场的发现与展示。 + +- **Base URL:** `https://gamma-api.polymarket.com` +- **Endpoint:** `/markets` + +## 2. 搜寻策略 + +由于 Polymarket 同时挂载数千个预测市场,系统采用多层搜索方案以确保不会遗漏任何城市的分段合约。 + +### 2.1 关键词三重搜索 + +程序并非只搜索一个词,而是并发执行三个搜索模式: + +1. `"highest temperature"`: 匹配大多数天气问题的核心描述。 +2. `"temperature in"`: 针对特定地区市场的宽泛搜索。 +3. `"daily weather"`: 针对某些命名不规范市场的兜底搜索。 + +### 2.3 优先级与排序 + +为了确保能搜到**最新**发布的合约(例如 2026年2月9日 的市场),我们应用了特定的 API 排序参数: + +- `order=id` & `ascending=false`: 优先扫描最新创建的市场 ID。 +- `active=true` & `closed=false`: 过滤掉已结算或已关闭的无效合约。 + +## 3. 过滤与解析逻辑 + +系统在获取 API 返回的列表后,会进行二次深度筛选: + +### 3.1 文本校验 + +检查市场的 `question`(问题描述)和 `slug`(URL 路径): + +- **模式匹配:** 必须包含 `"highest temperature in"` 或 `"highest-temperature-in"`。 +- **城市提取:** 逻辑会自动识别问题中的城市名(如 芝加哥、伦敦 等)。 + +### 3.2 负风险(Negative Risk)市场处理 + +Polymarket 的天气市场通常以 **Negative Risk** 分组形式存在(一个事件下包含多个互斥的区间,如“70°F以上”和“68-69°F”)。 + +**技术挑战:** 在 API 的列表视图中,这类市场的 `activeTokenId` 字段经常返回 `null`。 +**我们的解决方案:** + +1. 检查 `clobTokenIds` 字段。 +2. 如果该字段是 JSON 字符串(Gamma API 的常见返回格式),则将其解析为 Python 列表。 +3. 如果 `activeTokenId` 缺失,我们将列表中的第一个 Token ID 视为 **"YES" Token**。 +4. 这使系统能够绕过元数据同步延迟,直接在 CLOB 层面抓取实时买入/卖出价格。 + +## 4. 市场规范化结构 + +每个搜寻到的分段都会被规范化为以下结构,供决策引擎(Decision Engine)使用: + +- `condition_id`: 用于结果判定的 UMA 条件 ID。 +- `active_token_id`: 我们需要监控并买入的特定 ERC1155 Token ID。 +- `group_id`: 即 `negRiskMarketID`。这让机器人知道哪些不同的温度区间是属于同一个城市的,从而进行跨区间套利或对冲分析。 +- `slug`: 市场的唯一路径名,用于在 Telegram 预警中生成直接跳转链接。 + +## 5. 频率与缓存机制 + +- **搜寻频率:** 系统每 **5 分钟** 重新扫描一次新城市和新日期。 +- **缓存策略:** 搜寻到的市场会存入内存缓存(`_weather_markets_cache`),以减轻 API 压力并避免触发现速限制。 + +--- + +_创建日期: 2026-02-07_ +_PolyWeather 系统技术文档_ diff --git a/PAPER_TRADING_GUIDE.md b/PAPER_TRADING_GUIDE.md index 5363ef4b..18b616ec 100644 --- a/PAPER_TRADING_GUIDE.md +++ b/PAPER_TRADING_GUIDE.md @@ -19,7 +19,7 @@ ## 🎯 四层风控仓位策略 -系统结合 **Open-Meteo 天气预测**、**市场深度**、**结算时间** 和 **成交量** 自动决定仓位大小: +系统结合 **Open-Meteo 天气预测**、**结算时间** 和 **成交量** 自动决定仓位大小: | 条件组合 | 基础仓位 | 标签 | 说明 | |----------|----------|------|------| @@ -30,14 +30,13 @@ ### 风控过滤规则 -1. **流动性过滤**: 市场深度 < $50 跳过;深度 < 5×仓位则按比例缩减 -2. **时间衰减**: +1. **时间衰减**: - ≤1小时: 停止建仓 (0%) - 1-4小时: 缩小至 40% - 4-12小时: 缩小至 70% - >12小时: 100% -3. **预算上限**: 每日最高投入 $50 -4. **成交量加权**: 低活跃市场额外缩减 20% +2. **预算上限**: 每日最高投入 $50 +3. **成交量加权**: 低活跃市场额外缩减 20% ### 天气支持判断逻辑 @@ -52,6 +51,15 @@ • 预测温度19.0°C落在21°C区间,市场与模型一致 ``` +### METAR 实测数据 + +当天结算的市场会额外显示机场实测数据,帮助验证预测准确性: +``` +✈️ 机场实测 (KORD): + 🌡️ 32.0°F | 风速:12kt + 🕐 观测: 14:00 UTC +``` + ## 📊 盈亏计算公式 - **持仓份额** = $5 / (买入价格 / 100) diff --git a/README.md b/README.md index 1dc0c557..e4529e0b 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,45 @@ This command launches: ## 🤖 Telegram Bot Commands -| Command | Description | Usage | -| :----------- | :---------------------- | :--------------------------------------------- | -| `/signal` | **Get Trading Signals** | Returns Top 5 markets with earliest settlement | -| `/portfolio` | **View Portfolio** | Get real-time paper trading profit report | -| `/status` | **Check Status** | Confirm if the monitoring engine is online | -| `/help` | **Help** | Display all available commands | +| Command | Description | Usage | +| :---------------- | :---------------------- | :--------------------------------------------- | +| `/signal` | **Get Trading Signals** | Returns Top 5 markets with earliest settlement | +| `/city [name]` | **Query City Details** | Get market info, forecast & live temperature | +| `/portfolio` | **View Portfolio** | Get real-time paper trading profit report | +| `/status` | **Check Status** | Confirm if the monitoring engine is online | +| `/help` | **Help** | Display all available commands | + +### /city Command Example + +``` +/city chicago +``` + +Output: +``` +📍 Chicago Market Details +════════════════════ + +🕐 Local Time: 08:30 + +📊 Open-Meteo Forecast +👉 Today: High 38°F + 02-08: High 42°F + 02-09: High 45°F + +✈️ Airport Obs (KORD) + 🌡️ 32.0°F + 💨 Wind: 12kt + 🕐 Observed: 14:00 UTC + +📅 2026-02-07 Forecast:38°F +────────────────── +🔥 40-41°F: No 94¢ →Buy NO +🔥 38-39°F: Yes 91¢ →Buy YES +⭐ 36-37°F: No 87¢ →Buy NO +``` + +Supported abbreviations: `chi` (Chicago), `nyc` (New York), `atl` (Atlanta), `sea` (Seattle), `dal` (Dallas), `mia` (Miami) --- @@ -80,29 +113,66 @@ The system automatically decides the position size based on **Open-Meteo Weather - **Push Format**: ``` - ⚡ 40-41°F (2026-02-06): Buy No 87¢ | Prediction:38°F [🛒 $10.0 🔥High Conf] + 📍 Chicago Market Update + 🕐 Local 08:30 | Forecast High:38°F + ═══════════════════════ + + ✈️ Airport Obs (KORD): + 🌡️ 32.0°F | Wind:12kt + 🕐 Observed: 14:00 UTC + + ⚡ 40-41°F (2026-02-07): Buy No 87¢ | Prediction:38°F [🛒 $10.0 🔥High Conf] 💡 Strategy Tips: - • Predicted temp 38.0°C falls within 40-41°F range, market aligns with model + • Predicted temp 38.0°F falls within 40-41°F range, market aligns with model ``` -### 2. ⚡ Price Alerts (Auto Paper Trade) +### 2. ✈️ METAR Aviation Weather Data + +For same-day settlement markets, the system fetches **METAR airport observation data** and displays real measurements: + +``` +✈️ Airport Obs (KORD): + 🌡️ 12.0°F | Wind:15kt + 🕐 Observed: 11:00 UTC +``` + +**ICAO Airport Code Mapping**: + +| City | ICAO | Airport | +| ------------- | ---- | ---------------------------- | +| Seattle | KSEA | Seattle-Tacoma International | +| London | EGLC | London City Airport | +| Dallas | KDAL | Dallas Love Field | +| Miami | KMIA | Miami International | +| Atlanta | KATL | Hartsfield-Jackson Atlanta | +| Chicago | KORD | O'Hare International | +| New York | KLGA | LaGuardia Airport | +| Seoul | RKSI | Incheon International | +| Ankara | LTAC | Esenboga Airport | +| Toronto | CYYZ | Pearson International | +| Wellington | NZWN | Wellington International | +| Buenos Aires | SAEZ | Ministro Pistarini | + +**Data Source**: NOAA Aviation Weather Center (Free API, no key required) + +### 3. ⚡ Price Alerts (Auto Paper Trade) - **Trigger**: Buy Yes or Buy No price enters the **85¢-95¢** range. - **Auto Action**: System executes a **$3-$10 Paper Trade** based on the dynamic position strategy. - **Purpose**: High-probability / Near-settlement reminders. -### 3. 👀 Market Anomalies +### 4. 👀 Market Anomalies - **Whale Inflow**: Large single trades (>$5,000) with imbalanced ratios. - **Volume Spikes**: Sudden increase in volume (>2x historical std dev). -### 4. 📅 Daily PnL Summary +### 5. 📅 Daily PnL Summary - **Trigger**: Triggered automatically around 23:55 (Beijing Time). - **Content**: Summarizes daily floating PnL, balance changes, and win rate. -### 5. 🎯 Trading Signals (`/signal`) +### 6. 🎯 Trading Signals (`/signal`) Prioritizes markets with the **earliest settlement date**, sorted by opportunity value, returns **Top 5**: diff --git a/README_ZH.md b/README_ZH.md index ba49714e..ea5e2d6f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -40,12 +40,45 @@ python3.11 run.py ## 🤖 电报机器人指令集 -| 指令 | 描述 | 用法 | -| :----------- | :--------------- | :---------------------------- | -| `/signal` | **获取交易信号** | 返回最早结算市场的 Top 5 档位 | -| `/portfolio` | **查看模拟仓位** | 获取实时模拟交易盈亏汇总报告 | -| `/status` | **检查系统状态** | 确认监控引擎是否在线 | -| `/help` | **指令帮助** | 显示所有可用指令 | +| 指令 | 描述 | 用法 | +| :---------------- | :--------------- | :---------------------------- | +| `/signal` | **获取交易信号** | 返回最早结算市场的 Top 5 档位 | +| `/city [城市名]` | **查询城市详情** | 获取指定城市的市场、预测和实测温度 | +| `/portfolio` | **查看模拟仓位** | 获取实时模拟交易盈亏汇总报告 | +| `/status` | **检查系统状态** | 确认监控引擎是否在线 | +| `/help` | **指令帮助** | 显示所有可用指令 | + +### /city 指令示例 + +``` +/city chicago +``` + +返回内容: +``` +📍 Chicago 市场详情 +════════════════════ + +🕐 当地时间: 08:30 + +📊 Open-Meteo 预测 +👉 今天: 最高 38°F + 02-08: 最高 42°F + 02-09: 最高 45°F + +✈️ 机场实测 (KORD) + 🌡️ 32.0°F + 💨 风速: 12kt + 🕐 观测: 14:00 UTC + +📅 2026-02-07 预测:38°F +────────────────── +🔥 40-41°F: No 94¢ →买NO +🔥 38-39°F: Yes 91¢ →买YES +⭐ 36-37°F: No 87¢ →买NO +``` + +支持城市缩写: `chi` (Chicago), `nyc` (New York), `atl` (Atlanta), `sea` (Seattle), `dal` (Dallas), `mia` (Miami) --- @@ -81,29 +114,66 @@ python3.11 run.py - **推送格式**: ``` - ⚡ 40-41°F (2026-02-06): Buy No 87¢ | 预测:38°F [🛒 $10.0 🔥高置信] + 📍 Chicago 市场动态 + 🕐 当地 08:30 | 预测最高:38°F + ═══════════════════════ + + ✈️ 机场实测 (KORD): + 🌡️ 32.0°F | 风速:12kt + 🕐 观测: 14:00 UTC + + ⚡ 40-41°F (2026-02-07): Buy No 87¢ | 预测:38°F [🛒 $10.0 🔥高置信] 💡 策略建议: - • 预测温度38.0°C落在40-41°F区间,市场与模型一致 + • 预测温度38.0°F落在40-41°F区间,市场与模型一致 ``` -### 2. ⚡ 价格预警 (触发模拟买入) +### 2. ✈️ METAR 航空气象实测数据 + +当天结算的市场会自动获取 **METAR 机场实测数据**,在预警中显示真实观测值: + +``` +✈️ 机场实测 (KORD): + 🌡️ 12.0°F | 风速:15kt + 🕐 观测: 11:00 UTC +``` + +**ICAO 机场代码映射表**: + +| 城市 | ICAO | 机场名称 | +| ------------- | ---- | --------------------------- | +| Seattle | KSEA | Seattle-Tacoma International | +| London | EGLC | London City Airport | +| Dallas | KDAL | Dallas Love Field | +| Miami | KMIA | Miami International | +| Atlanta | KATL | Hartsfield-Jackson Atlanta | +| Chicago | KORD | O'Hare International | +| New York | KLGA | LaGuardia Airport | +| Seoul | RKSI | Incheon International | +| Ankara | LTAC | Esenboga Airport | +| Toronto | CYYZ | Pearson International | +| Wellington | NZWN | Wellington International | +| Buenos Aires | SAEZ | Ministro Pistarini | + +**数据源**: NOAA Aviation Weather Center (免费 API,无需 Key) + +### 3. ⚡ 价格预警 (触发模拟买入) - **触发条件**: Buy Yes 或 Buy No 价格在 **85¢-95¢** 区间。 - **关联动作**: 系统根据智能仓位策略自动执行 **$3-$10** 的模拟开仓。 - **用途**: 高胜率/即将锁定区间提醒,适合平仓或收割。 -### 3. 👀 市场异常 +### 4. 👀 市场异常 - **大户入场**: 检测到单笔 >$5000 的大额交易且买卖比失衡。 - **异常交易流**: 成交量突然放大 (>2倍历史标准差)。 -### 4. 📅 每日盈亏总结 +### 5. 📅 每日盈亏总结 - **触发时间**: 北京时间 23:55 左右自动推送。 - **内容**: 汇总当日所有模拟仓位的浮动盈亏、余额变动及胜率统计。 -### 5. 🎯 交易信号 (`/signal` 指令) +### 6. 🎯 交易信号 (`/signal` 指令) 优先显示**最早结算日期**的市场,按机会价值排序,返回 **Top 5** 档位: diff --git a/bot_listener.py b/bot_listener.py index bf8eeabf..c5f04da2 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -7,6 +7,7 @@ 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 +from src.data_collection.weather_sources import WeatherDataCollector def start_bot(): @@ -20,6 +21,7 @@ def start_bot(): bot = telebot.TeleBot(bot_token) notifier = TelegramNotifier(config["telegram"]) + weather = WeatherDataCollector(config.get("weather", {})) print(f"Bot is starting and listening for commands...") @@ -29,9 +31,11 @@ def start_bot(): "🌡️ PolyWeather 监控机器人\n\n" "可用指令:\n" "/signal - 获取当前高置信度交易信号\n" + "/city [城市名] - 查询城市市场详情与天气\n" "/portfolio - 查看当前模拟交易报告\n" "/status - 检查监控系统状态\n" - "/id - 获取当前聊天的 Chat ID" + "/id - 获取当前聊天的 Chat ID\n\n" + "示例: /city chicago" ) bot.reply_to(message, welcome_text, parse_mode="HTML") @@ -70,20 +74,22 @@ def start_bot(): price = s.get("price", 50) if 5 <= price <= 95 and s.get("target_date"): active_signals.append(s) - + if not active_signals: bot.send_message(message.chat.id, "📭 当前没有值得关注的活跃市场。") return # 按日期排序,优先最早结算的 active_signals.sort(key=lambda x: x.get("target_date", "9999-99-99")) - + # 获取最早的日期 earliest_date = active_signals[0].get("target_date") - + # 只取最早日期的市场 - earliest_markets = [s for s in active_signals if s.get("target_date") == earliest_date] - + earliest_markets = [ + s for s in active_signals if s.get("target_date") == earliest_date + ] + # 按"机会价值"排序:接近锁定区间(85-95¢)的优先 def opportunity_score(s): price = s.get("price", 50) @@ -97,16 +103,16 @@ def start_bot(): return max_price # 接近锁定 else: return max_price / 2 # 远离锁定 - + earliest_markets.sort(key=opportunity_score, reverse=True) top_markets = earliest_markets[:5] # 构建消息 msg_lines = [ f"🎯 即将结算市场 ({earliest_date})\n", - f"共 {len(earliest_markets)} 个活跃选项\n" + f"共 {len(earliest_markets)} 个活跃选项\n", ] - + for i, s in enumerate(top_markets, 1): city = s.get("city", "Unknown") option = s.get("option", "Unknown") @@ -115,17 +121,18 @@ def start_bot(): buy_no = s.get("buy_no", 100 - s.get("price", 50)) volume = s.get("volume", 0) url = s.get("url", "") - + # 解析选项区间 import re - range_match = re.search(r'(\d+)-(\d+)', option) - below_match = re.search(r'(\d+).*or below', option, re.I) - higher_match = re.search(r'(\d+).*or higher', option, re.I) - + + range_match = re.search(r"(\d+)-(\d+)", option) + below_match = re.search(r"(\d+).*or below", option, re.I) + higher_match = re.search(r"(\d+).*or higher", option, re.I) + # 判断预测与区间关系 analysis = "" try: - pred_val = float(re.search(r'[\d.]+', str(prediction)).group()) + pred_val = float(re.search(r"[\d.]+", str(prediction)).group()) if range_match: low, high = int(range_match.group(1)), int(range_match.group(2)) if pred_val < low: @@ -148,7 +155,7 @@ def start_bot(): analysis = f"预测{pred_val}°低于{threshold}° → 买NO ✓" except: analysis = f"预测: {prediction}" - + # 判断最佳方向 if buy_no >= 85: direction = f"Buy No {buy_no}¢" @@ -170,18 +177,18 @@ def start_bot(): direction = f"Yes:{buy_yes}¢ No:{buy_no}¢" lock_status = "⚖️均衡" confidence = "📊" - + # 提取修复后的精确当地时间 local_time = s.get("local_time", "") time_only = local_time.split(" ")[1] if " " in local_time else "" time_suffix = f" | 🕒{time_only}" if time_only else "" - + msg_lines.append( f"{confidence} {i}. {city} {option}\n" f" 💡 {analysis}\n" f" 📊 {direction} | {lock_status}{time_suffix}\n" ) - + bot.send_message(message.chat.id, "\n".join(msg_lines), parse_mode="HTML") except Exception as e: @@ -215,9 +222,9 @@ def start_bot(): html_path = generate_portfolio_html(data) with open(html_path, "rb") as f: bot.send_document( - message.chat.id, - f, - caption=f"📊 完整持仓报告 ({len(positions)}个持仓)\n💳 余额: ${balance:.2f}" + message.chat.id, + f, + caption=f"📊 完整持仓报告 ({len(positions)}个持仓)\n💳 余额: ${balance:.2f}", ) return @@ -229,20 +236,28 @@ def start_bot(): for pid, pos in positions.items(): target_date = pos.get("target_date") or "未知" if target_date not in positions_by_date: - positions_by_date[target_date] = {"count": 0, "pnl": 0, "cost": 0} + positions_by_date[target_date] = { + "count": 0, + "pnl": 0, + "cost": 0, + } positions_by_date[target_date]["count"] += 1 positions_by_date[target_date]["pnl"] += pos.get("pnl_usd", 0) positions_by_date[target_date]["cost"] += pos.get("cost_usd", 0) - + msg_lines.append(f"\n📌 持仓概览 (共{len(positions)}个)") for target_date in sorted(positions_by_date.keys()): info = positions_by_date[target_date] icon = "📈" if info["pnl"] >= 0 else "📉" - msg_lines.append(f"{icon} {target_date}: {info['count']}笔 ${info['cost']:.0f}投入 {info['pnl']:+.2f}$") - + msg_lines.append( + f"{icon} {target_date}: {info['count']}笔 ${info['cost']:.0f}投入 {info['pnl']:+.2f}$" + ) + total_pnl = sum(p.get("pnl_usd", 0) for p in positions.values()) total_cost = sum(p.get("cost_usd", 0) for p in positions.values()) - msg_lines.append(f"💰 合计: ${total_cost:.0f}投入 {total_pnl:+.2f}$") + msg_lines.append( + f"💰 合计: ${total_cost:.0f}投入 {total_pnl:+.2f}$" + ) msg_lines.append("\n📋 最新持仓:") recent_positions = list(positions.values())[-5:] @@ -251,14 +266,20 @@ def start_bot(): icon = "🟢" if pnl >= 0 else "🔴" pred = pos.get("predicted_temp", "") pred_text = f"预测:{pred}" if pred else "" - msg_lines.append(f"{icon} {pos['city']} {pos['option']} {pred_text} {pnl:+.2f}$") + msg_lines.append( + f"{icon} {pos['city']} {pos['option']} {pred_text} {pnl:+.2f}$" + ) trades = data.get("trades", []) if trades: msg_lines.append("\n📝 最近操作:") for t in reversed(trades[-3:]): t_type = "🛒" if t["type"] == "BUY" else "💰" - t_time = t.get("time", "").split(" ")[1] if " " in t.get("time", "") else "" + t_time = ( + t.get("time", "").split(" ")[1] + if " " in t.get("time", "") + else "" + ) msg_lines.append(f"• {t_time} {t_type} {t['city']} {t['option']}") if history: @@ -267,7 +288,9 @@ def start_bot(): total_cost = sum(p.get("cost_usd", 0) for p in history) total_profit = sum(p.get("pnl_usd", 0) for p in history) win_rate = (wins / total_trades) * 100 if total_trades > 0 else 0 - msg_lines.append(f"\n📈 历史: {total_trades}笔 胜率{win_rate:.0f}% 盈亏{total_profit:+.2f}$") + msg_lines.append( + f"\n📈 历史: {total_trades}笔 胜率{win_rate:.0f}% 盈亏{total_profit:+.2f}$" + ) msg_lines.append(f"\n💳 余额: ${balance:.2f}") @@ -276,15 +299,14 @@ def start_bot(): except Exception as e: bot.reply_to(message, f"❌ 获取持仓失败: {e}") - def generate_portfolio_html(data): """生成漂亮的 HTML 持仓报告""" from datetime import datetime, timedelta - + positions = data.get("positions", {}) history = data.get("history", []) balance = data.get("balance", 1000.0) - + # 按日期分组 positions_by_date = {} for pid, pos in positions.items(): @@ -292,13 +314,13 @@ def start_bot(): if target_date not in positions_by_date: positions_by_date[target_date] = [] positions_by_date[target_date].append(pos) - + total_pnl = sum(p.get("pnl_usd", 0) for p in positions.values()) total_cost = sum(p.get("cost_usd", 0) for p in positions.values()) - + # 生成 HTML now_bj = (datetime.utcnow() + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M") - + html = f""" @@ -324,19 +346,19 @@ def start_bot():
💳 余额: ${balance:.2f}
📦 持仓: {len(positions)}
💰 投入: ${total_cost:.2f}
-
📈 浮盈: {total_pnl:+.2f}$
+
📈 浮盈: = 0 else "negative"}">{total_pnl:+.2f}$
""" - + for target_date in sorted(positions_by_date.keys()): date_positions = positions_by_date[target_date] date_pnl = sum(p.get("pnl_usd", 0) for p in date_positions) date_cost = sum(p.get("cost_usd", 0) for p in date_positions) - + html += f"""
📅 {target_date} | {len(date_positions)}笔 | 投入${date_cost:.0f} | - {date_pnl:+.2f}$ + = 0 else "negative"}">{date_pnl:+.2f}$
@@ -346,30 +368,29 @@ def start_bot(): pnl_class = "positive" if pnl >= 0 else "negative" pred = pos.get("predicted_temp", "-") html += f""" - - - - - + + + + + """ html += "
城市选项方向入场当前预测盈亏
{pos.get('city', '-')}{pos.get('option', '-')}{pos.get('side', '-')}{pos.get('entry_price', 0)}¢{pos.get('current_price', 0)}¢{pos.get("city", "-")}{pos.get("option", "-")}{pos.get("side", "-")}{pos.get("entry_price", 0)}¢{pos.get("current_price", 0)}¢ {pred} {pnl:+.2f}$
\n" - + html += f""" """ - + html_path = "data/portfolio_report.html" with open(html_path, "w", encoding="utf-8") as f: f.write(html) - - return html_path + return html_path @bot.message_handler(commands=["status"]) def get_status(message): @@ -377,10 +398,157 @@ def start_bot(): message, "✅ 监控引擎正在运行中...\n7x24h 实时扫码 Polymarket 气温市场。" ) + @bot.message_handler(commands=["city"]) + def get_city_info(message): + """查询指定城市的市场详情、天气预测和实时温度""" + try: + # 解析城市名称 + parts = message.text.split(maxsplit=1) + if len(parts) < 2: + bot.reply_to( + message, + "❓ 请输入城市名称\n\n用法: /city chicago\n\n" + "支持城市: Seattle, London, Dallas, Miami, Atlanta, Chicago, " + "New York, Seoul, Ankara, Toronto, Wellington, Buenos Aires", + parse_mode="HTML", + ) + return + + city_input = parts[1].strip().lower() + + # 城市别名映射 + city_aliases = { + "nyc": "new york", + "ny": "new york", + "la": "los angeles", + "chi": "chicago", + "atl": "atlanta", + "sea": "seattle", + "dal": "dallas", + "mia": "miami", + "tor": "toronto", + "ank": "ankara", + "sel": "seoul", + "wel": "wellington", + "ba": "buenos aires", + "buenosaires": "buenos aires", + "伦敦": "london", + "纽约": "new york", + "西雅图": "seattle", + "芝加哥": "chicago", + "多伦多": "toronto", + "首尔": "seoul", + "惠灵顿": "wellington", + "达拉斯": "dallas", + "亚特兰大": "atlanta", + } + city_name = city_aliases.get(city_input, city_input) + + bot.send_message( + message.chat.id, f"🔍 正在查询 {city_name.title()} 的市场信息..." + ) + + # 1. 获取城市坐标 + coords = weather.get_coordinates(city_name) + if not coords: + bot.reply_to(message, f"❌ 未找到城市: {city_name}") + return + + # 2. 获取天气数据 (Open-Meteo + METAR) + weather_data = weather.fetch_all_sources( + city_name, lat=coords["lat"], lon=coords["lon"] + ) + + # 3. 从缓存中获取该城市的市场数据 + city_markets = [] + if os.path.exists("data/active_signals.json"): + with open("data/active_signals.json", "r", encoding="utf-8") as f: + all_signals = json.load(f) + city_markets = [ + s + for s in all_signals + if s.get("city", "").lower() == city_name.lower() + ] + + # 4. 构建消息 + msg_lines = [f"📍 {city_name.title()} 市场详情"] + msg_lines.append("═" * 20) + + # 天气信息 + open_meteo = weather_data.get("open-meteo", {}) + metar = weather_data.get("metar", {}) + + temp_unit = open_meteo.get("unit", "celsius") + temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C" + + # 当前时间 + local_time = open_meteo.get("current", {}).get("local_time", "") + if local_time: + time_only = ( + local_time.split(" ")[1] if " " in local_time else local_time + ) + msg_lines.append(f"🕐 当地时间: {time_only}") + + # Open-Meteo 预测 + daily = open_meteo.get("daily", {}) + dates = daily.get("time", []) + max_temps = daily.get("temperature_2m_max", []) + + today_str = datetime.now().strftime("%Y-%m-%d") + + msg_lines.append(f"\n📊 Open-Meteo 预测") + for i, (d, t) in enumerate(zip(dates[:7], max_temps[:7])): + day_label = "今天" if d == today_str else d[5:] # MM-DD + is_today = "👉 " if d == today_str else " " + msg_lines.append(f"{is_today}{day_label}: 最高 {t}{temp_symbol}") + + # METAR 实测 + if metar: + icao = metar.get("icao", "") + metar_temp = metar.get("current", {}).get("temp") + wind_speed = metar.get("current", {}).get("wind_speed_kt") + obs_time = metar.get("observation_time", "") + + # 解析观测时间 + if obs_time: + try: + obs_dt = datetime.fromisoformat(obs_time.replace("Z", "+00:00")) + obs_time_str = obs_dt.strftime("%H:%M UTC") + except: + obs_time_str = obs_time[:16] if len(obs_time) > 16 else obs_time + else: + obs_time_str = "N/A" + + msg_lines.append(f"\n✈️ 机场实测 ({icao})") + if metar_temp is not None: + msg_lines.append(f" 🌡️ {metar_temp}{temp_symbol}") + if wind_speed is not None: + msg_lines.append(f" 💨 风速: {wind_speed}kt") + msg_lines.append(f" 🕐 观测: {obs_time_str}") + + # 市场信息已根据需求暂时移除 + # (已在此处删除了之前的市场数据处理逻辑) + + # 发送消息 + final_msg = "\n".join(msg_lines) + if len(final_msg) > 4000: + # 消息太长,分段发送 + bot.send_message(message.chat.id, final_msg[:4000], parse_mode="HTML") + bot.send_message(message.chat.id, final_msg[4000:], parse_mode="HTML") + else: + bot.send_message(message.chat.id, final_msg, parse_mode="HTML") + + except Exception as e: + import traceback + + traceback.print_exc() + bot.reply_to(message, f"❌ 查询失败: {e}") + import logging + # 强制关闭 telebot 内部的刷屏日志 telebot.logger.setLevel(logging.CRITICAL) - + while True: try: bot.infinity_polling(timeout=60, long_polling_timeout=60) diff --git a/check_tg.py b/check_tg.py deleted file mode 100644 index 78377fa0..00000000 --- a/check_tg.py +++ /dev/null @@ -1,21 +0,0 @@ -import requests -import os -from dotenv import load_dotenv - -load_dotenv() - -def get_updates(): - token = os.getenv("TELEGRAM_BOT_TOKEN") - proxy = os.getenv("HTTPS_PROXY") - proxies = {"http": proxy, "https": proxy} if proxy else None - - url = f"https://api.telegram.org/bot{token}/getUpdates" - try: - resp = requests.get(url, proxies=proxies) - data = resp.json() - print(f"Updates: {data}") - except Exception as e: - print(f"Error: {e}") - -if __name__ == "__main__": - get_updates() diff --git a/main.py b/main.py index 06b0e272..69378c96 100644 --- a/main.py +++ b/main.py @@ -12,9 +12,6 @@ from src.data_collection.polymarket_api import PolymarketClient from src.data_collection.weather_sources import WeatherDataCollector from src.data_collection.onchain_tracker import OnchainTracker from src.models.statistical_model import TemperaturePredictor -from src.analysis.volume_analyzer import VolumeAnalyzer -from src.analysis.orderbook_analyzer import OrderbookAnalyzer -from src.analysis.technical_indicators import TechnicalIndicators from src.analysis.whale_tracker import WhaleTracker from src.strategy.decision_engine import DecisionEngine from src.strategy.risk_manager import RiskManager @@ -38,7 +35,6 @@ def main(): # 3. 初始化分析与交易组件 predictor = TemperaturePredictor() risk_manager = RiskManager(config_data.get("config", {})) - orderbook_analyzer = OrderbookAnalyzer(config_data.get("config", {})) decision_engine = DecisionEngine(config_data.get("config", {})) whale_tracker = WhaleTracker(config_data.get("config", {}), onchain) paper_trader = PaperTrader() @@ -139,16 +135,18 @@ def main(): active_tid = m.get("active_token_id") - # 如果是多选一市场(比如 Dallas 76-77°F) - if len(ts) > 2 and active_tid: + # 智能识别买入/买否 Token + if active_tid and isinstance(ts, list): # 获取该档位的买入价 (Ask) price_requests.append({"token_id": active_tid, "side": "ask"}) - # 获取该档位的买入“否”价所需的 Bid 价 - price_requests.append({"token_id": active_tid, "side": "bid"}) - # 如果是传统的 Yes/No 二选一市场 - elif len(ts) == 2: - price_requests.append({"token_id": ts[0], "side": "ask"}) # Buy Yes - price_requests.append({"token_id": ts[1], "side": "ask"}) # Buy No + + if len(ts) == 2: + # 传统的二选一,直接获取 No Token 的 Ask + no_tid = ts[1] if ts[0] == active_tid else ts[0] + price_requests.append({"token_id": no_tid, "side": "ask"}) + else: + # 多选一,需要用 1 - Bid(Yes) 来模拟 Buy No + price_requests.append({"token_id": active_tid, "side": "bid"}) if price_requests: logger.info(f"正在同步 {len(price_requests)} 个档位的真实盘口价格...") @@ -157,13 +155,13 @@ def main(): # 3. 按城市分组(按condition_id去重) markets_by_city = {} - seen_condition_ids = set() - + seen_condition_ids = set() # Initialize seen_condition_ids here for i, m in enumerate(all_weather_markets): - c_id = m.get("condition_id") - if c_id in seen_condition_ids: - continue # 跳过重复 - seen_condition_ids.add(c_id) + # Use condition_id + active_token_id as unique key to support multi-bracket markets + unique_market_key = f"{m.get('condition_id')}_{m.get('active_token_id')}" + if unique_market_key in seen_condition_ids: + continue + seen_condition_ids.add(unique_market_key) # 注入实时批量价格 ts = m.get("tokens", []) @@ -175,24 +173,24 @@ def main(): active_tid = m.get("active_token_id") - # 多选一市场逻辑 - if len(ts) > 2 and active_tid: + if active_tid and isinstance(ts, list): m["buy_yes_live"] = token_price_map.get(f"{active_tid}:ask") - # 买入“否”的价格 = 1 - 该档位的 Bid - bid_val = token_price_map.get(f"{active_tid}:bid") - if bid_val: - m["buy_no_live"] = 1.0 - bid_val - # 二选一市场逻辑 - elif len(ts) == 2: - m["buy_yes_live"] = token_price_map.get(f"{ts[0]}:ask") - m["buy_no_live"] = token_price_map.get(f"{ts[1]}:ask") + + if len(ts) == 2: + no_tid = ts[1] if ts[0] == active_tid else ts[0] + m["buy_no_live"] = token_price_map.get(f"{no_tid}:ask") + else: + # 1 - Bid(Yes) = Ask(No) + bid_val = token_price_map.get(f"{active_tid}:bid") + if bid_val: + m["buy_no_live"] = 1.0 - bid_val # 优先使用发现阶段已经识别出的城市名 city = m.get("city") - # 如果发现阶段没识别出,再尝试从问题文本提取 + # 如果发现阶段没识别出,再尝试从问题文本或 Slug 提取 if not city or city == "Unknown": - full_context = f"{m.get('event_title', '')} {m.get('question', '')}" + full_context = f"{m.get('event_title', '')} {m.get('question', '')} {m.get('slug', '')}" city = weather.extract_city_from_question(full_context) if i < 5: @@ -341,34 +339,21 @@ def main(): # 严格触发条件: 价格必须处于 85-95¢ 区间 (真正的高概率信号) yes_in_range = buy_yes_price and 0.85 <= buy_yes_price <= 0.95 no_in_range = buy_no_price and 0.85 <= buy_no_price <= 0.95 - + # 50¢ 保护:价格接近 50% 说明市场无明确方向,跳过 is_undecided = 0.45 <= current_prob <= 0.55 - + if (yes_in_range or no_in_range) and not is_undecided: alert_key = f"alert_{market_id}_{int(current_prob * 100)}" if alert_key not in pushed_signals: - # 深度分析订单簿 - ob_data = ( - polymarket.get_orderbook(active_tid) - if active_tid - else None + # 获取温度符号(在此处定义以便后续使用) + temp_unit = weather_data.get("open-meteo", {}).get( + "unit", "celsius" ) - ob_analysis = ( - orderbook_analyzer.analyze(ob_data) - if ob_data - else { - "tradeable": False, - "liquidity": "枯竭", - "spread": 0, - "mid_price": current_prob, - } + temp_symbol = ( + "°F" if temp_unit == "fahrenheit" else "°C" ) - # 获取温度符号(在此处定义以便后续使用) - temp_unit = weather_data.get("open-meteo", {}).get("unit", "celsius") - temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C" - # 预测偏差分析 if ref_temp: city_pred_high = ref_temp # 记录到城市概览 @@ -391,7 +376,6 @@ def main(): f"预测温度{ref_temp}{temp_symbol}落在{question}区间,市场与模型一致" ) - # 模拟下单 - 使用 Ask 价格(实际可成交价格) if buy_yes_price and buy_yes_price > 0.5: trigger_side = "Buy Yes" @@ -405,10 +389,14 @@ def main(): ) # 构建预测文本 - forecast_text = f"{ref_temp}{temp_symbol}" if ref_temp else "N/A" - + forecast_text = ( + f"{ref_temp}{temp_symbol}" if ref_temp else "N/A" + ) + # 构建简约版消息 - side_display = "Buy No" if trigger_side == "Buy No" else "Buy Yes" + side_display = ( + "Buy No" if trigger_side == "Buy No" else "Buy Yes" + ) msg = f"⚡ {question} ({target_date}): {side_display} {trigger_price}¢ | 预测:{forecast_text}" success = paper_trader.open_position( @@ -421,7 +409,7 @@ def main(): target_date=target_date, predicted_temp=ref_temp, ) - + # 添加模拟交易标签 if success: msg += " [🛒 $5.0 💡试探]" @@ -534,10 +522,8 @@ def main(): if is_categorical: # 语义转换逻辑保持一致 if buy_no_price and buy_no_price >= 0.85: - trigger_side = "Sell Yes" - trigger_price = int( - buy_no_price * 100 - ) # 预估价 + trigger_side = "Buy No" # 直接统一为 Buy No + trigger_price = int(buy_no_price * 100) else: trigger_side = "Buy Yes" trigger_price = int(buy_yes_price * 100) @@ -551,84 +537,6 @@ def main(): else int(buy_no_price * 100) ) - # --- 深度流动性与 Spread 检查 --- - target_tid = ( - active_tid - if is_categorical - else (ts[0] if trigger_side == "Buy Yes" else ts[1]) - ) - ob_data = ( - polymarket.get_orderbook(target_tid) - if target_tid - else None - ) - - ob_analysis = { - "tradeable": True, - "liquidity": "未知", - "spread": 0, - "mid_price": trigger_price / 100, - } - if ob_data: - ob_analysis = orderbook_analyzer.analyze(ob_data) - - if not ob_analysis.get("tradeable", True): - confidence_tag = ( - f"🔴不可交易 ({ob_analysis.get('liquidity')})" - ) - if not is_categorical: - logger.warning( - f"跳过不可交易信号 (Spread {ob_analysis.get('spread')}): {city} {question}" - ) - continue - - # 更新实时数据显示 - mid_c = round(ob_analysis.get("mid_price", 0) * 100, 1) - spr_c = round(ob_analysis.get("spread", 0) * 100, 1) - depth = ob_analysis.get( - "ask_depth" - if trigger_side.startswith("Buy") - else "bid_depth", - 0, - ) - - # 流动性图标 - liq_map = { - "充裕": "✅ 充裕", - "正常": "🟡 正常", - "稀薄": "🟠 稀薄", - "枯竭": "🔴 枯竭", - } - liq_status = liq_map.get( - ob_analysis.get("liquidity", "未知"), "❓ 未知" - ) - - if is_categorical: - ask_str = ( - "--" - if trigger_side == "Sell Yes" - else f"{trigger_price}¢" - ) - bid_str = ( - f"{trigger_price}¢" - if trigger_side == "Sell Yes" - else "--" - ) - - display_side = ( - f"📊 {question}\n" - f"Ask: {ask_str} | Bid: {bid_str} | Mid: {mid_c}¢\n" - f"Spread: {spr_c}¢ | 深度: ${depth}\n" - f"流动性: {liq_status}" - ) - else: - display_side = ( - f"📊 {question}\n" - f"报价: {trigger_side} {trigger_price}¢ | Mid: {mid_c}¢\n" - f"Spread: {spr_c}¢ | 深度: ${depth}\n" - f"流动性: {liq_status}" - ) - # --- 智能动态仓位计算 --- # 1. 获取 Open-Meteo 对目标日期的最高温预测 predicted_high = None @@ -745,11 +653,10 @@ def main(): elif trigger_price >= 92: base_pos, confidence_tag = 5.0, "📌价格锁定" - # 4. 四层过滤决策 + # 4. 仓位决策 amount_usd, risk_reason = ( risk_manager.calculate_position_size( base_confidence_usd=base_pos, - depth=depth, hours_to_settle=hours_to_settle, is_high_relative_volume=is_rel_high_vol, ) @@ -758,7 +665,7 @@ def main(): logger.info( f"【Pro仓位】{city} {question} | " f"基础:{base_pos}$ -> 最终:{amount_usd}$ | 原因:{risk_reason} | " - f"深度:${depth} | 剩:{hours_to_settle:.1f}h" + f"剩:{hours_to_settle:.1f}h" ) # --- 模拟交易触发逻辑 --- @@ -798,9 +705,7 @@ def main(): ) # 构建简约版消息: ⚡ {question} ({date}): {side} {price}¢ | 预测:{forecast} [🛒 ${amount} {tag}] - side_display = ( - "Buy No" if trigger_side == "Buy No" else "Buy Yes" - ) + side_display = trigger_side msg = ( f"⚡ {question} ({target_date}): {side_display} {trigger_price}¢ | " f"预测:{forecast_text} [🛒 ${amount_usd} {confidence_tag}]" @@ -825,17 +730,28 @@ def main(): if city_alerts: # 去重策略建议 unique_tips = list(dict.fromkeys(city_strategy_tips)) - notifier.send_combined_alert( - city=city, - alerts=city_alerts, - local_time=city_local_time, - forecast_temp=f"{city_pred_high}{temp_symbol}" - if city_pred_high - else "N/A", - total_volume=city_total_vol, - brackets_count=len(city_markets), - strategy_tips=unique_tips, + # 获取 METAR 数据(仅当天结算的市场才显示) + today_str = datetime.now().strftime("%Y-%m-%d") + # 检查是否有当天结算的市场 + has_today_market = any( + a.get("market") == today_str or a.get("market") == "今日" + for a in city_alerts ) + metar_data = ( + weather_data.get("metar") if has_today_market else None + ) + # notifier.send_combined_alert( + # city=city, + # alerts=city_alerts, + # local_time=city_local_time, + # forecast_temp=f"{city_pred_high}{temp_symbol}" + # if city_pred_high + # else "N/A", + # total_volume=city_total_vol, + # brackets_count=len(city_markets), + # strategy_tips=unique_tips, + # metar_data=metar_data, + # ) except Exception as e: logger.error(f"分析城市 {city} 时出错: {e}") @@ -844,13 +760,17 @@ def main(): # --- 周期性结算:保存高价值信号 --- 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) + # Relaxed filtering: Let the bot decide, but mark ENDED + rationale = entry.get("rationale") + if rationale == "ERROR": + continue + + target_dt = entry.get("target_date") + # Only filter out truly ancient history + if target_dt and target_dt < "2026-02-01": + continue + + active_signals.append(entry) # 按分数排序 active_signals.sort(key=lambda x: x.get("score", 0), reverse=True) @@ -926,7 +846,7 @@ def main(): report.append( f"📈 累计浮动盈亏: {total_pnl:+.2f}$" ) - notifier._send_message("\n".join(report)) + # notifier._send_message("\n".join(report)) pushed_signals[summary_key] = time.time() except Exception as e: diff --git a/requirements.txt b/requirements.txt index 0458e24f..904e0af6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,4 @@ pyTelegramBotAPI python-dotenv pytz numpy -py-clob-client web3 diff --git a/src/data_collection/polymarket_api.py b/src/data_collection/polymarket_api.py index f4ff209a..d186a27f 100644 --- a/src/data_collection/polymarket_api.py +++ b/src/data_collection/polymarket_api.py @@ -7,587 +7,285 @@ from loguru import logger from datetime import datetime from concurrent.futures import ThreadPoolExecutor -from py_clob_client.client import ClobClient -from py_clob_client.constants import POLYGON -from py_clob_client.clob_types import ApiCreds, BookParams, OpenOrderParams - - class PolymarketClient: """ - Polymarket API Client for market data and trading (Exclusive py-clob-client mode) + Polymarket API Client (Pure REST API version) + Directly uses Gamma API and CLOB REST API without py-clob-client dependency. """ def __init__(self, config: Dict): - self.base_url = config.get("base_url", "https://clob.polymarket.com") + self.clob_url = config.get("base_url", "https://clob.polymarket.com") + self.gamma_url = "https://gamma-api.polymarket.com" self.timeout = config.get("timeout", 20) self.session = requests.Session() - # 缓存机制 + # Cache mechanism self._weather_markets_cache = [] self._last_discovery_time = 0 - self._cache_ttl = 300 # 5 分钟缓存 + self._cache_ttl = 300 # 5 minutes cache - # 统一代理设置 + # Proxy settings (automatically read from environment) proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") if proxy: self.session.proxies = {"http": proxy, "https": proxy} - logger.info(f"正在使用代理: {proxy}") + logger.info(f"Requests session using proxy: {proxy}") - # 设置公开接口通用的 User-Agent + # Set common User-Agent and headers self.session.headers.update( { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json", + "Content-Type": "application/json" } ) - # 初始化官方 CLOB 客户端 self.api_key = config.get("api_key") - self.api_secret = config.get("api_secret") - self.api_passphrase = config.get("api_passphrase") - - try: - - # 组装凭据对象 (如果提供) - 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 - ) - - 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}") - - def _setup_headers(self): - """Setup default headers for API requests""" - self.session.headers.update( - {"Content-Type": "application/json", "Accept": "application/json"} - ) if self.api_key: self.session.headers.update({"POLY_API_KEY": self.api_key}) + logger.info(f"Polymarket REST Client initialized. CLOB: {self.clob_url}, Gamma: {self.gamma_url}") + def get_markets(self, next_cursor: str = None) -> Optional[Dict]: - """ - 获取全量市场列表 (官方接口) - """ + """Fetch markets list via CLOB REST API""" try: - return self.clob_client.get_markets(next_cursor=next_cursor) - except: return None + params = {} + if next_cursor: + params["next_cursor"] = next_cursor + resp = self.session.get(f"{self.clob_url}/markets", params=params, timeout=self.timeout) + return resp.json() if resp.status_code == 200 else None + except Exception as e: + logger.debug(f"get_markets failed: {e}") + return None def get_market(self, market_id: str) -> Optional[Dict]: - """ - 获取特定市场详情 (官方接口) - """ + """Fetch market details via CLOB REST API""" try: - return self.clob_client.get_market(market_id=market_id) - except: return None + resp = self.session.get(f"{self.clob_url}/markets/{market_id}", timeout=self.timeout) + return resp.json() if resp.status_code == 200 else None + except Exception as e: + logger.debug(f"get_market failed: {e}") + return None def get_price(self, token_id: str, side: str = "ask") -> Optional[float]: - """ - 获取 Token 的实时盘口价格 - 优先使用官方库,失败时使用直接 REST API - """ - # 方法1: 尝试官方库 + """Fetch real-time price for a token via CLOB REST API""" try: - sdk_side = "BUY" if side == "ask" else "SELL" - price_str = self.clob_client.get_price(token_id=token_id, side=sdk_side) - if price_str: - return float(price_str) - except Exception as e: - logger.debug(f"官方库 get_price 失败 ({token_id}): {e}") - - # 方法2: 直接调用 REST API (与 test_price.py 一致) - try: - resp = self.session.get(f"{self.base_url}/price", params={ - "token_id": token_id, - "side": side.upper() - }, timeout=10) + # Correct CLOB Mapping: + # 'sell' side price is the ASK (price you pay to BUY) + # 'buy' side price is the BID (price you get to SELL) + clob_side = "sell" if side.lower() in ["ask", "buy"] else "buy" + resp = self.session.get( + f"{self.clob_url}/price", + params={"token_id": token_id, "side": clob_side}, + timeout=10 + ) data = resp.json() - return float(data.get("price", 0)) + return float(data.get("price", 0)) if resp.status_code == 200 else None except Exception as e: - logger.debug(f"REST API get_price 失败 ({token_id}): {e}") + logger.debug(f"get_price failed ({token_id}): {e}") return None def get_orderbook(self, token_id: str) -> Optional[Dict]: - """ - 获取订单簿深度 - 优先使用官方库,失败时使用直接 REST API - """ - # 方法1: 尝试官方库 + """Fetch orderbook for a token via CLOB REST API""" try: - return self.clob_client.get_orderbook(token_id=token_id) + resp = self.session.get( + f"{self.clob_url}/book", params={"token_id": token_id}, timeout=10 + ) + return resp.json() if resp.status_code == 200 else None except Exception as e: - logger.debug(f"官方库 get_orderbook 失败 ({token_id}): {e}") - - # 方法2: 直接调用 REST API (与 test_price.py 一致) - try: - resp = self.session.get(f"{self.base_url}/book", params={ - "token_id": token_id - }, timeout=10) - return resp.json() - except Exception as e: - logger.debug(f"REST API get_orderbook 失败 ({token_id}): {e}") + logger.debug(f"get_orderbook failed ({token_id}): {e}") return None - def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]: - """ - 获取买入价格 (Buy Yes 和 Buy No) - - Args: - yes_token_id: Yes token ID - no_token_id: No token ID - - Returns: - dict: {"buy_yes": float, "buy_no": float} 或 None - """ + """Fetch buy prices for both YES and NO tokens""" try: - # Buy Yes = Yes token 的最佳卖单 (asks) - yes_book = self.get_orderbook(yes_token_id) - buy_yes = None - if ( - yes_book - and isinstance(yes_book, dict) - and yes_book.get("asks") - and len(yes_book["asks"]) > 0 - ): - buy_yes = float(yes_book["asks"][0].get("price", 0)) - - # Buy No = No token 的最佳卖单 (asks) - no_book = self.get_orderbook(no_token_id) - buy_no = None - if ( - no_book - and isinstance(no_book, dict) - and no_book.get("asks") - and len(no_book["asks"]) > 0 - ): - buy_no = float(no_book["asks"][0].get("price", 0)) + # Buy Yes = Ask price of YES token + buy_yes = self.get_price(yes_token_id, "BUY") + # Buy No = Ask price of NO token + buy_no = self.get_price(no_token_id, "BUY") if buy_yes is not None and buy_no is not None: return {"buy_yes": buy_yes, "buy_no": buy_no} - except Exception as e: - logger.debug(f"获取买入价格失败: {e}") - + logger.debug(f"get_buy_prices failed: {e}") return None def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]: - """ - 批量获取多个 token 的价格 - 优先使用官方库,失败时使用直接 REST API - """ + """Batch fetch prices for multiple tokens using ThreadPoolExecutor""" if not token_requests: return {} all_prices = {} - batch_size = 20 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 + try: return float(val) + except: return 0.0 - chunks = [token_requests[i : i + batch_size] for i in range(0, len(token_requests), batch_size)] - - def fetch_chunk_sdk(chunk): - """使用官方 SDK 批量获取""" - try: - batch_req = [] - for r in chunk: - sdk_side = "BUY" if r.get("side") == "ask" else "SELL" - batch_req.append(BookParams(token_id=r["token_id"], side=sdk_side)) - - results = self.clob_client.get_prices(batch_req) - - chunk_prices = {} - if isinstance(results, list): - for item in results: - tid = item.get("token_id") - price_raw = item.get("price") - res_side = item.get("side") - if tid and price_raw: - val = robust_float(price_raw) - key_side = "ask" if res_side == "BUY" else "bid" - chunk_prices[f"{tid}:{key_side}"] = val - return chunk_prices - except Exception as e: - logger.debug(f"SDK batch fetch failed: {e}") - return None - - def fetch_chunk_rest(chunk): - """使用 REST API 逐个获取 (备用方案)""" - chunk_prices = {} - for r in chunk: - try: - resp = self.session.get(f"{self.base_url}/price", params={ - "token_id": r["token_id"], - "side": r.get("side", "ask").upper() - }, timeout=10) - data = resp.json() - price = robust_float(data.get("price", 0)) - if price > 0: - chunk_prices[f"{r['token_id']}:{r.get('side', 'ask')}"] = price - except Exception as e: - logger.debug(f"REST API price fetch failed for {r['token_id'][:16]}...: {e}") - return chunk_prices + def fetch_single(req): + tid = req["token_id"] + side = req.get("side", "ask").lower() + # To get ASK (price to buy), request 'sell' side + # To get BID (price to sell), request 'buy' side + api_side = "sell" if side == "ask" else "buy" + val = self.get_price(tid, api_side) + if val: + return f"{tid}:{side.lower()}", val + return None - # 使用线程池并发抓取 - with ThreadPoolExecutor(max_workers=3) as executor: - future_results = list(executor.map(fetch_chunk_sdk, chunks)) - - # 检查结果,如果 SDK 全部失败,使用 REST API 备用 - sdk_success = False - for chunk_result in future_results: - if chunk_result: - all_prices.update(chunk_result) - sdk_success = True - - # 如果 SDK 完全失败,使用 REST API - if not sdk_success and token_requests: - logger.info("SDK 批量获取失败,使用 REST API 逐个获取...") - with ThreadPoolExecutor(max_workers=5) as executor: - future_results = list(executor.map(fetch_chunk_rest, chunks)) - for chunk_result in future_results: - all_prices.update(chunk_result) - + with ThreadPoolExecutor(max_workers=5) as executor: + results = list(executor.map(fetch_single, token_requests)) + + for res in results: + if res: + key, val = res + all_prices[key] = val + return all_prices - def get_midpoint(self, token_id: str) -> Optional[float]: - """ - 获取中点价格 (官方接口) - """ + """Fetch midpoint price via CLOB REST API""" 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]: - """ - 搜索市场 - """ - 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 - - # --- 交易指令 (强依赖官方库) --- - def create_order( - self, - token_id: str, - side: str, - price: float, - size: float, - order_type: str = "GTC", - ) -> Optional[Dict]: - """ - 创建新订单 - """ - 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 - - def cancel_order(self, order_id: str) -> Optional[Dict]: - """ - 取消订单 - """ - try: - return self.clob_client.cancel_order(order_id) - except Exception as e: - logger.error(f"取消订单失败: {e}") - return None - - def get_orders(self, market_id: str = None) -> Optional[Dict]: - """ - 获取当前活跃挂单 - """ - try: - params = OpenOrderParams(market=market_id) if market_id else None - return self.clob_client.get_orders(params=params) - except Exception as e: - logger.error(f"获取挂单失败: {e}") + resp = self.session.get(f"{self.clob_url}/midpoint", params={"token_id": token_id}, timeout=10) + data = resp.json() + return float(data.get("mid", 0)) if resp.status_code == 200 else None + except: return None def discover_weather_markets(self) -> list: - """ - 通过全量扫描活跃事件发现最高温天气市场 (支持缓存机制) - """ - # 缓存检查 + """Scan Gamma API for all weather-related markets with prioritized search and city targeting""" + # Cache check current_time = time.time() if self._weather_markets_cache and (current_time - self._last_discovery_time < self._cache_ttl): - logger.debug(f"使用缓存的市场列表 (剩余寿命: {int(self._cache_ttl - (current_time - self._last_discovery_time))}s)") + logger.debug(f"Using cached market list ({len(self._weather_markets_cache)} items)") return self._weather_markets_cache - logger.info("📡 正在全量扫描 Polymarket 发现天气市场...") - gamma_url = "https://gamma-api.polymarket.com/events" + logger.info("📡 Scanning Polymarket via Gamma API for weather markets...") all_weather_markets = [] - seen_condition_ids = set() - - def process_events(events, source_label): - if not isinstance(events, list): - return - - new_markets_count = 0 - for event in events: - title = event.get("title", "") - is_weather_event = ( - "Highest temperature" in title or "temperature in" in title.lower() - ) - - event_slug = event.get("slug", "") - for m in event.get("markets", []): - question = m.get("groupItemTitle") or m.get("question") or "" - - # 强化过滤:必须在标题中包含明确的气温气象词,且排除非气温市场 - t_lower = title.lower() - q_lower = question.lower() - - # 1. 标题必须像个气温市场 - if not any(k in t_lower for k in ["highest temperature", "high temperature", "will temperature", "daily temperature"]): - continue - - # 2. 排除干扰项 - if "climate" in t_lower or "rain" in t_lower or "snow" in t_lower: - continue - - # 3. 确保这个具体的 market (bracket) 是我们想要的 - if not any(k in q_lower for k in ["temperature", "be", "highest", "range"]): - # 补充:如果是多选一市场的子项,question 可能只是一个数字或范围,此时看 title - if not any(k in t_lower for k in ["temperature", "highest"]): - continue - - c_id = m.get("conditionId") - # 识别 outcome_index - t_ids = m.get("clobTokenIds", []) - active_id = m.get("activeTokenId") - idx = 0 - if isinstance(t_ids, list) and active_id in t_ids: - idx = t_ids.index(active_id) - - # 对于多选一市场,不同档位共享 conditionId,但 tokenId 不同 - unique_key = f"{c_id}_{active_id}" - if c_id and unique_key not in seen_condition_ids: - all_weather_markets.append( - { - "condition_id": c_id, - "question": question, - "active_token_id": active_id, - "outcome_index": idx, - "tokens": t_ids, - "prices": m.get("outcomePrices"), - "event_title": title, - "slug": event_slug, - } - ) - seen_condition_ids.add(unique_key) - new_markets_count += 1 - if new_markets_count > 0: - logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约") - + seen_keys = set() + + # 1. Target newest markets by query and ID sorting + search_queries = ["highest temperature", "temperature in", "daily weather"] + try: - # 1. 扫描活跃且未合并的 (全量) - for offset in range(0, 20000, 1000): - params = { - "active": "true", - "closed": "false", - "limit": 1000, - "offset": offset, - } - - # 增加重试机制 - 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 + # Use multiple offsets to find more historical/diverse markets + for offset in [0, 500, 1000]: + for query in search_queries: + logger.debug(f"Searching with query: {query} (offset {offset})") + params = { + "query": query, + "active": "true", + "limit": 500, + "offset": offset, + "order": "id", + "ascending": "false" + } + resp = self.session.get(f"{self.gamma_url}/markets", params=params, timeout=self.timeout) + if resp.status_code == 200: + markets = resp.json() + logger.debug(f"Query '{query}' returned {len(markets)} markets") + for m in markets: + q = m.get("question", "").lower() + slug = m.get("slug", "").lower() + + # Filter for weather markets (Broadened) + is_weather = any(k in q or k in slug for k in [ + "highest temperature", "highest-temperature", + "temperature in", "temperature-in", + "daily weather", "daily-weather", + "weather", "气温", "温度" + ]) + if is_weather: + c_id = m.get("conditionId") + t_ids = m.get("clobTokenIds") + active_id = m.get("activeTokenId") + + # Robust JSON parsing for clobTokenIds string + if isinstance(t_ids, str) and t_ids.startswith("["): + try: + import json + t_ids = json.loads(t_ids) + except: + pass + + # For Neg Risk markets, activeTokenId might be missing in list view + # If we have clobTokenIds, we can work with it + if not t_ids: + continue + + if not active_id and isinstance(t_ids, list) and len(t_ids) > 0: + active_id = t_ids[0] # Assume first is YES - # 2. 扫描活跃但已关闭的 - for offset in range(0, 20000, 1000): - params = { - "active": "true", - "closed": "true", - "limit": 1000, - "offset": offset, - } - - 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. 扫描非活跃但未关闭的市场 - for offset in range(0, 10000, 1000): - params = { - "active": "false", - "closed": "false", - "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"Inactive-O{offset}") + if not active_id: + continue + + unique_key = f"{c_id}_{active_id}" + if unique_key not in seen_keys: + logger.debug(f"Found weather segment: {q}") + all_weather_markets.append({ + "condition_id": c_id, + "question": m.get("question"), + "active_token_id": active_id, + "outcome_index": t_ids.index(active_id) if isinstance(t_ids, list) and active_id in t_ids else 0, + "tokens": t_ids, + "prices": m.get("outcomePrices"), + "event_title": m.get("description", "")[:100], + "slug": m.get("slug"), + "group_id": m.get("negRiskMarketID") + }) + seen_keys.add(unique_key) else: + logger.debug(f"Query '{query}' failed with status {resp.status_code}") + + if len(all_weather_markets) > 50: break - # 4. 扫描非活跃且已关闭的市场(某些即将结算的市场可能在这里) - for offset in range(0, 10000, 1000): - params = { - "active": "false", - "closed": "true", - "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"InactiveClosed-O{offset}") - else: - break - - logger.info( - f"全量发现结束,共获取 {len(all_weather_markets)} 个天气档位合约" - ) - - # 更新缓存 + logger.info(f"Discovery complete: Found {len(all_weather_markets)} weather segments.") self._weather_markets_cache = all_weather_markets self._last_discovery_time = current_time - return all_weather_markets except Exception as e: - logger.error(f"全量发现天气市场失败: {e}") + logger.error(f"Market discovery failed: {e}") + return [] + + except Exception as e: + logger.error(f"Market discovery failed: {e}") + return [] + + except Exception as e: + logger.error(f"Market discovery failed: {e}") return [] def get_weather_markets(self) -> list: - """ - 获取全量活跃天气市场 - """ return self.discover_weather_markets() - def get_event_by_slug(self, slug: str) -> Optional[Dict]: - """ - 通过slug直接获取特定事件(用于捕获部分结算等特殊状态的市场) - """ - try: - url = f"{self.base_url.replace('clob', 'gamma-api')}/events" - params = {"slug": slug} - response = self.session.get(url, params=params, timeout=self.timeout) - - if response.status_code == 200: - events = response.json() - if events and len(events) > 0: - return events[0] - except Exception as e: - logger.debug(f"通过slug获取事件失败 ({slug}): {e}") - return None - def find_weather_market(self, city: str, date_str: str = None) -> Optional[Dict]: - """ - 根据城市和日期精准查找 - """ - weather_markets = self.get_weather_markets() - for m in weather_markets: - content = ( - str(m.get("question", "")) + str(m.get("event_title", "")) - ).lower() + markets = self.get_weather_markets() + for m in markets: + # Match against question, title AND slug + content = (str(m.get("question", "")) + str(m.get("event_title", "")) + str(m.get("slug", ""))).lower() if city.lower() in content: if date_str: - if date_str.lower() in content: - return m + if date_str.lower() in content: return m else: return m return None def get_weather_event_markets(self, city: str) -> list: - """ - 获取某个城市相关的所有区间市场 - """ all_markets = self.get_weather_markets() return [ - m - for m in all_markets - if city.lower() - in (str(m.get("question", "")) + str(m.get("event_title", ""))).lower() + m for m in all_markets + if city.lower() in (str(m.get("question", "")) + str(m.get("event_title", "")) + str(m.get("slug", ""))).lower() ] + + # --- Trading Stubs (Real trading requires signing, which is disabled in pure REST mode) --- + def create_order(self, *args, **kwargs) -> Optional[Dict]: + logger.warning("create_order: Real trading is disabled in pure REST mode. Please use paper trading.") + return None + + def cancel_order(self, *args, **kwargs) -> Optional[Dict]: + logger.warning("cancel_order: Real trading is disabled in pure REST mode.") + return None + + def get_orders(self, *args, **kwargs) -> Optional[Dict]: + logger.warning("get_orders: Real trading is disabled in pure REST mode.") + return None diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index c6432302..1bfc8fac 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -13,8 +13,27 @@ class WeatherDataCollector: - OpenWeatherMap (free, fast updates) - Weather Underground (Polymarket settlement source) - Visual Crossing (rich historical data) + - NOAA Aviation Weather (METAR - airport observations) """ + # Polymarket 12 个天气市场对应的 ICAO 机场代码 + # 这些是 Weather Underground 结算源使用的气象站 + CITY_TO_ICAO = { + "seattle": "KSEA", # Seattle-Tacoma Airport + "london": "EGLC", # London City Airport + "dallas": "KDAL", # Dallas Love Field + "miami": "KMIA", # Miami International + "atlanta": "KATL", # Hartsfield-Jackson + "chicago": "KORD", # O'Hare International + "new york": "KLGA", # LaGuardia Airport + "nyc": "KLGA", # Alias + "seoul": "RKSI", # Incheon International + "ankara": "LTAC", # Esenboğa International + "toronto": "CYYZ", # Toronto Pearson + "wellington": "NZWN", # Wellington International + "buenos aires": "SAEZ", # Ezeiza International + } + def __init__(self, config: dict): self.config = config self.wunderground_key = config.get("wunderground_api_key") @@ -167,6 +186,113 @@ class WeatherDataCollector: logger.error(f"Visual Crossing request failed: {e}") return None + def get_icao_code(self, city: str) -> Optional[str]: + """ + 根据城市名获取对应的 ICAO 机场代码 + """ + normalized = city.lower().strip() + + # 直接匹配 + if normalized in self.CITY_TO_ICAO: + return self.CITY_TO_ICAO[normalized] + + # 模糊匹配 + for key, icao in self.CITY_TO_ICAO.items(): + if key in normalized or normalized in key: + return icao + + return None + + def fetch_metar(self, city: str, use_fahrenheit: bool = False) -> Optional[Dict]: + """ + 从 NOAA Aviation Weather Center 获取 METAR 航空气象数据 + + 这是 Polymarket 天气市场的结算数据源 (Weather Underground) 使用的相同气象站 + + Args: + city: 城市名称 + use_fahrenheit: 是否转换为华氏度 + + Returns: + dict: METAR 数据,包含温度、露点、风速等 + """ + icao = self.get_icao_code(city) + if not icao: + logger.warning(f"未找到城市 {city} 对应的 ICAO 代码") + return None + + try: + # NOAA Aviation Weather API (免费,无需 Key) + url = "https://aviationweather.gov/api/data/metar" + params = { + "ids": icao, + "format": "json", + "hours": 3, # 获取最近3小时的观测 + } + + response = self.session.get(url, params=params, timeout=self.timeout) + response.raise_for_status() + + data = response.json() + if not data: + logger.warning(f"METAR 数据为空: {icao}") + return None + + # 取最新的观测记录 + latest = data[0] + + # 提取温度 (METAR 原始单位是摄氏度) + temp_c = latest.get("temp") + dewp_c = latest.get("dewp") + + # 转换为华氏度(如果需要) + if use_fahrenheit and temp_c is not None: + temp = temp_c * 9 / 5 + 32 + dewp = dewp_c * 9 / 5 + 32 if dewp_c is not None else None + unit = "fahrenheit" + else: + temp = temp_c + dewp = dewp_c + unit = "celsius" + + # 解析观测时间 + obs_time = latest.get("reportTime", "") + + result = { + "source": "metar", + "icao": icao, + "station_name": latest.get("name", icao), + "timestamp": datetime.utcnow().isoformat(), + "observation_time": obs_time, + "raw_metar": latest.get("rawOb", ""), + "current": { + "temp": round(temp, 1) if temp is not None else None, + "dewpoint": round(dewp, 1) if dewp is not None else None, + "humidity": latest.get("rh"), # 相对湿度 + "wind_speed_kt": latest.get("wspd"), # 风速 (knots) + "wind_dir": latest.get("wdir"), # 风向 (度) + "visibility_miles": latest.get("visib"), # 能见度 (英里) + "altimeter": latest.get("altim"), # 气压 + "flight_category": latest.get("fltcat"), # VFR/IFR 等 + "clouds": latest.get("clouds", []), + }, + "unit": unit, + } + + logger.info( + f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} " + f"(obs: {obs_time})" + ) + + return result + + except requests.exceptions.RequestException as e: + logger.error(f"METAR 请求失败 ({icao}): {e}") + return None + except (KeyError, IndexError, TypeError) as e: + logger.error(f"METAR 数据解析失败 ({icao}): {e}") + return None + def fetch_from_open_meteo( self, lat: float, @@ -234,32 +360,35 @@ class WeatherDataCollector: def extract_date_from_title(self, title: str) -> Optional[str]: """ 从标题中提取日期并标准化为 YYYY-MM-DD - 例如: "Highest temperature in Seattle on February 6?" -> "2026-02-06" + 支持: "February 6", "2月6日", "2-6" 等 """ + # 1. 尝试英文月份 months = { - "January": "01", - "February": "02", - "March": "03", - "April": "04", - "May": "05", - "June": "06", - "July": "07", - "August": "08", - "September": "09", - "October": "10", - "November": "11", - "December": "12", + "January": "01", "February": "02", "March": "03", "April": "04", + "May": "05", "June": "06", "July": "07", "August": "08", + "September": "09", "October": "10", "November": "11", "December": "12", } - for month_name, month_val in months.items(): if month_name in title: match = re.search(f"{month_name}\\s+(\\d+)", title) if match: day = int(match.group(1)) year = datetime.now().year - # 简单处理跨年逻辑:如果提取到的月份小于当前月份太多,可能是指明年 - # 但对于天气预报通常只看近期几天 return f"{year}-{month_val}-{day:02d}" + + # 2. 尝试中文格式 "2月7日" 或 "02月07日" + zh_match = re.search(r"(\d{1,2})月(\d{1,2})日", title) + if zh_match: + month = int(zh_match.group(1)) + day = int(zh_match.group(2)) + year = datetime.now().year + return f"{year}-{month:02d}-{day:02d}" + + # 3. 尝试 ISO 格式 YYYY-MM-DD + iso_match = re.search(r"(\d{4})-(\d{2})-(\d{2})", title) + if iso_match: + return iso_match.group(0) + return None def get_coordinates(self, city: str) -> Optional[Dict[str, float]]: @@ -317,40 +446,36 @@ class WeatherDataCollector: def extract_city_from_question(self, question: str) -> Optional[str]: """ - 从 Polymarket 问题描述中提取城市名称 - 支持多种描述方式: - - "Highest temperature in Ankara on February 5?" - - "Will the temperature in London be..." - - "Temp in New York..." + 从 Polymarket 问题描述或 Slug 中提取城市名称 """ q = question.lower() - # 移除常见的干扰词 - for noise in ["highest ", "the ", "will ", "lowest "]: - if q.startswith(noise): - q = q[len(noise) :] + # 1. 优先尝试已知城市列表 (硬编码匹配) + known_cities = { + "london": "London", "伦敦": "London", + "new york": "New York", "new york's central park": "New York", "nyc": "New York", "纽约": "New York", + "seattle": "Seattle", "西雅图": "Seattle", + "chicago": "Chicago", "芝加哥": "Chicago", + "dallas": "Dallas", "达拉斯": "Dallas", + "miami": "Miami", "迈阿密": "Miami", + "atlanta": "Atlanta", "亚特兰大": "Atlanta", + "seoul": "Seoul", "首尔": "Seoul", + "toronto": "Toronto", "多伦多": "Toronto", + "ankara": "Ankara", "安卡拉": "Ankara", + "wellington": "Wellington", "惠灵顿": "Wellington", + "buenos aires": "Buenos Aires", "布宜诺斯艾利斯": "Buenos Aires" + } + + for key, val in known_cities.items(): + if key in q: + return val - # 处理 "temperature in [City]" | "temp in [City]" - triggers = ["temperature in ", "temp in ", "weather in "] + # 2. 从英文模板中提取 + triggers = ["temperature in ", "temp in ", "weather in ", "highest-temperature-in-", "temperature-in-"] for trigger in triggers: if trigger in q: part = q.split(trigger)[1] - # 截断日期和其他后缀 - # 按照 "on", "at", "above", "below", "?", " ", "be", "is" 分割 - delimiters = [ - " on ", - " at ", - " above ", - " below ", - " be ", - " is ", - " will ", - " has ", - " reached ", - "?", - " (", - ", ", - ] + delimiters = [" on ", " at ", " above ", " below ", " be ", " is ", " will ", " has ", " reached ", "?", " (", ", ", "-"] city = part for d in delimiters: if d in city: @@ -404,6 +529,11 @@ class WeatherDataCollector: else: logger.info(f"🌡️ {city} 使用摄氏度 (°C)") + # METAR (Airport Weather - Same source as Weather Underground settlement) + metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit) + if metar_data: + results["metar"] = metar_data + # Open-Meteo (Primary Free Source - No Key) if lat and lon: open_meteo = self.fetch_from_open_meteo( diff --git a/src/strategy/risk_manager.py b/src/strategy/risk_manager.py index 967960e3..2feca543 100644 --- a/src/strategy/risk_manager.py +++ b/src/strategy/risk_manager.py @@ -1,14 +1,17 @@ from loguru import logger + class RiskManager: """ 风险控制系统 """ - + def __init__(self, config=None): self.config = config or {} # 基础风控参数 - self.max_single_trade = self.config.get("max_single_trade", 50.0) # 最大单笔调整为 $50 + self.max_single_trade = self.config.get( + "max_single_trade", 50.0 + ) # 最大单笔调整为 $50 self.max_daily_exposure = 50.0 # 每日最高投入上限 self.daily_used_exposure = 0.0 self.last_reset_date = "" @@ -16,82 +19,81 @@ class RiskManager: self.min_confidence = 0.5 self.peak_capital = 0 self.is_trading_paused = False - + logger.info("Initializing Pro Risk Manager...") def _reset_daily_exposure(self): """每日重置额度""" from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") if self.last_reset_date != today: self.daily_used_exposure = 0.0 self.last_reset_date = today logger.info(f"Daily exposure reset for {today}") - def calculate_position_size(self, - base_confidence_usd: float, - depth: float, - hours_to_settle: float, - is_high_relative_volume: bool) -> tuple[float, str]: + def calculate_position_size( + self, + base_confidence_usd: float, + depth: float = 0, + hours_to_settle: float = 24, + is_high_relative_volume: bool = False, + ) -> tuple[float, str]: """ - 四层过滤仓位计算方法: - 仓位 = base_position(置信度) - × liquidity_factor(深度/仓位 >= 5x) - × time_decay(离结算衰减) + 仓位计算方法 (简化版,移除流动性过滤): + 仓位 = base_position(置信度) + × time_decay(离结算衰减) × budget_limit """ self._reset_daily_exposure() - + final_pos = base_confidence_usd reason = "Normal" - # 1. 流动性过滤: 深度 < $50 强制跳过; 深度 < 仓位的 5 倍则缩减 - if depth < 50: - return 0.0, "🚫深度不足 (min $50)" - - if depth < final_pos * 5: - # 如果深度不足以承载期望仓位,按比例缩减至深度的 1/5 - final_pos = depth / 5.0 - reason = "⚠️深度限流" - - # 2. 时间衰减因子 + # 1. 时间衰减因子 # 离结算时间越近,预测越准但也存在剧烈博弈风险 time_factor = 1.0 if hours_to_settle <= 1.0: - time_factor = 0.0 # 最后 1 小时停止建仓 + time_factor = 0.0 # 最后 1 小时停止建仓 reason = "🚫临近结算" elif hours_to_settle <= 4.0: - time_factor = 0.4 # 1-4小时:缩小 60% + time_factor = 0.4 # 1-4小时:缩小 60% reason = "⏱️结算冲刺 (40%)" elif hours_to_settle <= 12.0: - time_factor = 0.7 # 4-12小时:缩小 30% + time_factor = 0.7 # 4-12小时:缩小 30% reason = "⏳接近结算 (70%)" - - final_pos *= time_factor - if final_pos <= 0: return 0.0, reason - # 3. 预算上限过滤 + final_pos *= time_factor + if final_pos <= 0: + return 0.0, reason + + # 2. 预算上限过滤 remaining_daily = self.max_daily_exposure - self.daily_used_exposure if remaining_daily <= 0: return 0.0, "🚫今日总额度已满 ($50)" - + if final_pos > remaining_daily: final_pos = remaining_daily reason = "🛑触及日风控上限" - # 4. 高相对成交量加权 (如果是高成交量市场,且逻辑支持,可保持原状或微增) + # 3. 高相对成交量加权 (如果是高成交量市场,且逻辑支持,可保持原状或微增) # 这里逻辑设定为:如果不是高成交量,再次缩减 20% 防御 if not is_high_relative_volume: final_pos *= 0.8 - if reason == "Normal": reason = "📉低活缩减" + if reason == "Normal": + reason = "📉低活缩减" return round(final_pos, 2), reason def record_trade(self, amount: float): """记录成交额以扣除额度""" self.daily_used_exposure += amount - logger.debug(f"Applied exposure: ${amount}. Daily Total: ${self.daily_used_exposure}") + logger.debug( + f"Applied exposure: ${amount}. Daily Total: ${self.daily_used_exposure}" + ) - def check_trade_risk(self, trade_size: float, market_data: dict, model_confidence: float) -> dict: + def check_trade_risk( + self, trade_size: float, market_data: dict, model_confidence: float + ) -> dict: """保持基础接口兼容""" return {"passed": True, "risks": []} diff --git a/src/utils/notifier.py b/src/utils/notifier.py index 5fdc7136..be0d733d 100644 --- a/src/utils/notifier.py +++ b/src/utils/notifier.py @@ -134,8 +134,9 @@ class TelegramNotifier: total_volume: float = 0, brackets_count: int = 0, strategy_tips: list = None, + metar_data: dict = None, ): - """发送简约版合并预警""" + """发送简约版合并预警 (含 METAR 航空气象数据)""" if not alerts: return @@ -143,16 +144,38 @@ class TelegramNotifier: # UTC+8 北京时间 now_bj = datetime.utcnow() + timedelta(hours=8) - timestamp_bj = now_bj.strftime( - "%H:%M" - ) # 简化为仅显示时间,日期通常与当地一致或不重要 + timestamp_bj = now_bj.strftime("%H:%M") - # 1. 信号详情构建 + # 1. METAR 航空气象数据区块 + metar_text = "" + if metar_data and metar_data.get("current", {}).get("temp") is not None: + icao = metar_data.get("icao", "N/A") + temp = metar_data["current"]["temp"] + unit = "°F" if metar_data.get("unit") == "fahrenheit" else "°C" + + # 解析观测时间 (格式: 2026-02-07T11:00:00.000Z) + obs_time_raw = metar_data.get("observation_time", "") + if "T" in obs_time_raw: + obs_time = obs_time_raw.split("T")[1][:5] + " UTC" + else: + obs_time = obs_time_raw or "N/A" + + # 可选:风速信息 + wind_kt = metar_data["current"].get("wind_speed_kt") + wind_text = f" | 风速:{wind_kt}kt" if wind_kt else "" + + metar_text = ( + f"✈️ 机场实测 ({icao}):\n" + f" 🌡️ {temp:.1f}{unit}{wind_text}\n" + f" 🕐 观测: {obs_time}\n\n" + ) + + # 2. 信号详情构建 items_text = "" for a in alerts: items_text += f"{a['msg']}\n\n" - # 2. 策略建议(如果有) + # 3. 策略建议(如果有) tips_text = "" if strategy_tips: tips_text = ( @@ -161,10 +184,11 @@ class TelegramNotifier: + "\n\n" ) - # 3. 总体布局 (回归清爽风格) + # 4. 总体布局 text = ( f"🔔 城市监控报告 #{self._escape_html(city)}\n\n" f"📍 城市: {self._escape_html(city)}\n" + f"{metar_text}" f"📊 实时异动:\n" f"{items_text}" f"{tips_text}" diff --git a/test_price.py b/test_price.py deleted file mode 100644 index ddd6f9a7..00000000 --- a/test_price.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Polymarket 天气市场价格查询 -直接获取 YES/NO 的真实买入价格(Ask) -""" -import requests - -# ============ 配置 ============ -# 替换成你要查的 token_id(从 Gamma API 获取) -YES_TOKEN_ID = "你的YES_token_id" -NO_TOKEN_ID = "你的NO_token_id" - -CLOB_BASE = "https://clob.polymarket.com" -GAMMA_BASE = "https://gamma-api.polymarket.com" -# ============ 方法1: 快速查价格 ============ -def get_price(token_id, side="buy"): -"""获取单个 token 的买入/卖出价格""" -resp = requests.get(f"{CLOB_BASE}/price", params={ -"token_id": token_id, -"side": side.upper() -}) -data = resp.json() -return float(data.get("price", 0)) - -# ============ 方法2: 查完整盘口 ============ -def get_orderbook(token_id): -"""获取完整 orderbook,含深度""" -resp = requests.get(f"{CLOB_BASE}/book", params={ -"token_id": token_id -}) -return resp.json() - -# ============ 方法3: 从 Gamma 发现天气市场 ============ -def discover_weather_markets(city="New York"): -"""自动发现天气市场,获取 token_id""" -resp = requests.get(f"{GAMMA_BASE}/markets", params={ -"tag": "weather", -"closed": "false", -"limit": 50 -}) -markets = resp.json() -results = [] -for m in markets: -if city.lower() in m.get("question", "").lower(): -tokens = m.get("tokens", []) -if len(tokens) >= 2: - yes_token = None - no_token = None - for t in tokens: - if t.get("outcome") == "Yes": - yes_token = t["token_id"] - else: - no_token = t["token_id"] - if yes_token and no_token: - results.append({ - "question": m["question"], - "yes_token": yes_token, - "no_token": no_token, - "slug": m.get("market_slug", "") - }) -return results - - -# ============ 主流程 ============ -def main(): -print("📡 正在发现 NYC 天气市场...\n") -markets = discover_weather_markets("New York") -if not markets: -print("❌ 未找到天气市场,检查 Gamma API") -return - -print(f"✅ 找到 {len(markets)} 个市场\n") -print("=" * 55) - -for m in markets[:10]: -q = m["question"] -yes_id = m["yes_token"] -no_id = m["no_token"] - -# 获取真实 Ask 价格 -yes_ask = get_price(yes_id, "buy") -no_ask = get_price(no_id, "buy") - -# 获取 Bid -yes_bid = get_price(yes_id, "sell") - -spread = yes_ask - yes_bid if yes_ask and yes_bid else None - -# 获取盘口深度 -book = get_orderbook(yes_id) -ask_depth = sum(float(o.get("size", 0)) for o in book.get("asks", [])[:3]) -bid_depth = sum(float(o.get("size", 0)) for o in book.get("bids", [])[:3]) - -# 流动性判断 -if ask_depth < 50: -liq = "🔴枯竭" -elif ask_depth < 500: -liq = "🟡正常" -else: -liq = "🟢充裕" - -print(f"\n📊 {q}") -print(f" YES Ask: {yes_ask*100:.1f}¢ | NO Ask: {no_ask*100:.1f}¢") -print(f" YES Bid: {yes_bid*100:.1f}¢ | Spread: {spread*100:.1f}¢" if spread else " YES Bid: --") -print(f" 深度: Ask ${ask_depth:.0f} | Bid ${bid_depth:.0f} | {liq}") -print(f" 🔗 https://polymarket.com/event/{m['slug']}") - -print("\n" + "=" * 55) -print("💡 价格单位: Ask = 你买入要付的价格") - -if __name__ == "__main__": -main() \ No newline at end of file diff --git a/test_template.py b/test_template.py deleted file mode 100644 index 186e1caf..00000000 --- a/test_template.py +++ /dev/null @@ -1,65 +0,0 @@ - -import os -import json -from src.utils.config_loader import load_config -from src.utils.notifier import TelegramNotifier - -def send_test_template(): - config_data = load_config() - notifier = TelegramNotifier(config_data["telegram"]) - - city = "Nyc" - target_date = "2026-02-07" - - # 模拟异动信号数据 - alerts = [ - { - "market": target_date, - "msg": ( - "🟢 26°F+\n" - "执行动作: BUY YES ⬆️\n" - "Ask: 80¢ | Bid: -- | Mid: 79.5¢\n" - "Spread: 1.2¢ | 深度: $1,847\n" - "流动性: ✅ 充裕 | 可交易: ✅\n" - "📐 预测偏差: -3.0°F (预测 23.0°F)" - ), - "bought": True, - "amount": 7.0, - "confidence": "⭐中置信" - }, - { - "market": target_date, - "msg": ( - "🔴 18-19°F\n" - "执行动作: SELL YES ⬇️\n" - "Ask: -- | Bid: 5.0¢ | Mid: 4.5¢\n" - "Spread: 0.5¢ | 深度: $312\n" - "流动性: ✅ 正常 | 可交易: ✅\n" - "📐 预测偏差: -4.0°F (预测 23.0°F)" - ), - "bought": False, - "amount": 0.0, - "confidence": "" - } - ] - - strategy_tips = [ - "预测温度 23.0°F 落在 22-23°F 区间,市场与模型一致", - "26°F+ 区间出现主力大额买入,建议跟随", - "18-19°F 流动性正常但偏差过大,已执行调仓" - ] - - print("🚀 正在发送测试模板到 Telegram...") - notifier.send_combined_alert( - city=city, - alerts=alerts, - local_time="10:56 EST", - forecast_temp="23.0°F", - total_volume=40113, - brackets_count=7, - strategy_tips=strategy_tips - ) - print("✅ 发送成功!请检查手机。") - -if __name__ == "__main__": - send_test_template()