This commit is contained in:
2569718930@qq.com
2026-02-23 20:43:58 +08:00
31 changed files with 1262 additions and 3728 deletions
-71
View File
@@ -1,71 +0,0 @@
# 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_
-74
View File
@@ -1,74 +0,0 @@
# Polymarket 天气市场搜寻技术文档
> ⚠️ **当前状态:功能休眠**
> 本中提到的自动搜寻与监控引擎目前已在 `run.py` 中被注释。当前系统工作于“被动查询模式”,仅在用户输入 `/city` 指令时提供天气分析。
本文档详细说明了 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 系统技术文档_
-89
View File
@@ -1,89 +0,0 @@
# 📈 PolyWeather 模拟仓 (Paper Trading) 使用指南
> ⚠️ **当前状态:功能暂停**
> 为了专注于高实时性的天气查询服务,自动模拟交易功能目前已在代码中被禁用。电报指令 `/portfolio` 暂不可用。
本系统提供全自动的模拟交易功能,让您在不投入真实资金的情况下,验证天气预测逻辑的盈利能力。
## 🛠️ 运行机制
1. **自动开仓**:
- 监控引擎在扫描中,一旦发现任何档位的 **Buy Yes****Buy No** 价格处于 **85¢ - 95¢** 区间(与城市监控报告一致),即触发买入。
- 初始本金: **$1000.00**
- 单笔投入: **动态 $3-$10**(根据四层风控策略自动调整,见下文)
- 资金检查: 余额不足时将停止开仓。
- **价格来源**: 使用真实 **Ask 价格**(实际可成交价格),而非中间价
2. **实时估值**:
- 每轮扫描结束后,系统会根据最新盘口中间价更新持仓价值。
3. **自动结项**:
- 当市场价格达到 0¢ 或 100¢(Polymarket 已结算),系统自动平仓并计算盈亏,资金回笼。
4. **数据持久化**:
- 持仓与余额保存在 `data/paper_positions.json`
## 🎯 四层风控仓位策略
系统结合 **Open-Meteo 天气预测**、**结算时间** 和 **成交量** 自动决定仓位大小:
| 条件组合 | 基础仓位 | 标签 | 说明 |
| ------------------------------- | -------- | ---------- | -------------- |
| 价格 ≥90¢ + 天气支持 + 高成交量 | **$10** | 🔥高置信 | 三重确认,重注 |
| 价格 ≥90¢ + 天气支持 | **$7** | ⭐中置信 | 双重确认 |
| 价格 ≥92¢ | **$5** | 📌价格锁定 | 纯价格锁定 |
| 其他 85-91¢ | **$3** | 💡试探 | 最小仓位试探 |
### 风控过滤规则
1. **时间衰减**:
- ≤1小时: 停止建仓 (0%)
- 1-4小时: 缩小至 40%
- 4-12小时: 缩小至 70%
- > 12小时: 100%
2. **预算上限**: 每日最高投入 $50
3. **成交量加权**: 低活跃市场额外缩减 20%
### 天气支持判断逻辑
- **买 NO**: Open-Meteo 预测温度在选项区间 **之外** (±2° 容差)
- **买 YES**: Open-Meteo 预测温度 **落入** 选项区间
### 策略建议显示
当推送包含交易信号时,会附带策略建议:
```
💡 策略建议:
• 预测温度19.0°C落在21°C区间,市场与模型一致
```
### METAR 实测数据
当天结算的市场会额外显示机场实测数据,帮助验证预测准确性:
```
✈️ 机场实测 (KORD):
🌡️ 32.0°F | 风速:12kt
🕐 观测: 14:00 UTC
```
## 📊 盈亏计算公式
- **持仓份额** = $5 / (买入价格 / 100)
- **可用余额** = 初始本金 - 累计投入总额
- **浮动盈亏** = 当前总价值 - 投入本金 ($5)
## 🤖 电报指令
您可以直接在机器人中通过以下指令查看进度:
- **/portfolio**: 实时返回当前所有“浮动”持仓的盈亏状况、历史胜率以及账户余额。
## 📁 存储文件说明
如果您需要手动清理或修改仓位,可以编辑 `data/paper_positions.json`
- `status: "OPEN"` 表示正在持仓。
- `entry_price` 以美分为单位(如 91 表示 0.91$)。
---
**蚂蚁重力 (Antigravity) 实验室**
+214 -29
View File
@@ -1,6 +1,6 @@
# 🌡️ PolyWeather: Real-time Weather Query & Analysis Bot # 🌡️ PolyWeather: Real-time Weather Query & Analysis Bot
An intelligent weather information bot designed to provide ultra-fast, live meteorological data, high-fidelity forecasts, and smart trend analysis. Built for speed and accuracy, it bypasses network caching to deliver the most up-to-date reports from global weather stations. An intelligent weather bot for prediction markets and professional weather betting. Fetches ultra-fresh data directly from global weather stations, bypassing CDN caches, and provides automated trend analysis with **model consensus scoring** and **entry timing signals** in plain language.
## 🚀 Quick Start ## 🚀 Quick Start
@@ -8,19 +8,55 @@ An intelligent weather information bot designed to provide ultra-fast, live mete
- **Python 3.11+** - **Python 3.11+**
- Dependencies: `pip install -r requirements.txt` - Dependencies: `pip install -r requirements.txt`
<<<<<<< HEAD
- **Environment**: Configure `METEOBLUE_API_KEY` in `.env` to enable high-precision London forecasts. - **Environment**: Configure `METEOBLUE_API_KEY` in `.env` to enable high-precision London forecasts.
=======
- **Environment Variables**: Set `TELEGRAM_BOT_TOKEN` in `.env` (required). Optionally set `METEOBLUE_API_KEY` for London high-precision forecasts.
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
### Running Locally (Windows/Linux) ### VPS Deployment (Recommended)
**First-time setup:**
```bash ```bash
# Windows git clone https://github.com/yangyuan-zhen/PolyWeather.git
py -3.11 run.py cd PolyWeather
pip install -r requirements.txt
# Linux/VPS cp .env.example .env # Edit .env with your Token and API Keys
python3 run.py
``` ```
_Note: The system is currently in **Weather Query Mode**. Legacy active market monitoring and automated trading modules are suspended._ **Create one-click update script (run once):**
```bash
cat > ~/update.sh << 'EOF'
#!/bin/bash
cd ~/PolyWeather
git fetch origin
git reset --hard origin/main
pkill -f run.py
pkill -f bot_listener.py
sleep 1
nohup python3 run.py > bot.log 2>&1 &
echo "✅ Updated and restarted!"
EOF
chmod +x ~/update.sh
```
**Daily updates (after each code push):**
```bash
~/update.sh
```
> One command: pull latest code → kill old process → start new process. No branch conflict handling needed.
### Local Development (Windows)
```bash
py -3.11 run.py
```
> Local machine is for editing code and Git push only. IDE import errors are expected (dependencies not installed locally) and do not affect VPS operation.
--- ---
@@ -32,14 +68,49 @@ _Note: The system is currently in **Weather Query Mode**. Legacy active market m
| `/id` | **Get Chat ID** | Retrieve your current Telegram Chat ID | | `/id` | **Get Chat ID** | Retrieve your current Telegram Chat ID |
| `/help` | **Help** | Display all available commands | | `/help` | **Help** | Display all available commands |
### Supported Cities
| City | Aliases | METAR Station | Extra Sources |
|:---|:---|:---|:---|
| London | `lon`, `伦敦` | EGLC (City Airport) | Meteoblue |
| Paris | `par`, `巴黎` | LFPG (Charles de Gaulle) | — |
| Ankara | `ank`, `安卡拉` | LTAC (Esenboğa) | MGM |
| New York | `nyc`, `ny`, `纽约` | KLGA (LaGuardia) | NWS |
| Chicago | `chi`, `芝加哥` | KORD (O'Hare) | NWS |
| Dallas | `dal`, `达拉斯` | KDAL (Love Field) | NWS |
| Miami | `mia`, `迈阿密` | KMIA (International) | NWS |
| Atlanta | `atl`, `亚特兰大` | KATL (Hartsfield-Jackson) | NWS |
| Seattle | `sea`, `西雅图` | KSEA (Sea-Tac) | NWS |
| Toronto | `tor`, `多伦多` | CYYZ (Pearson) | — |
| Seoul | `sel`, `首尔` | RKSI (Incheon) | — |
| Buenos Aires | `ba`, `布宜诺斯艾利斯` | SAEZ (Ezeiza) | — |
| Wellington | `wel`, `惠灵顿` | NZWN (Wellington) | — |
### Example
```
/city 巴黎
/city london
/city par
```
--- ---
## ✨ Key Features ## ✨ Key Features
### 1. 🏛️ Multi-Source Data Fusion (High-Fidelity) ### 1. 🏛️ Multi-Source Data Fusion
The bot aggregates data from multiple authoritative sources, layered by reliability: | Source | Role | Coverage | Strength |
| :---------------------- | :---------------------- | :-------------- | :-------------------------------------------------------------------------- |
| **Multi-Model (5 NWP)** | **Consensus Scoring** | Global | ECMWF, GFS, ICON, GEM, JMA — 5 fully independent NWP models via Open-Meteo |
| **Open-Meteo** | Base Forecast | Global | 72h hourly curves, sunrise/sunset, **sunshine duration**, **shortwave radiation** |
| **Open-Meteo Ensemble** | **Uncertainty Range** | Global | 51-member ensemble: median, P10, P90 spread for confidence assessment |
| **Meteoblue (MB)** | Precision Consensus | London Only | Multi-model aggregation; excellent for microclimates |
| **METAR** | **Settlement Standard** | Global Airports | Polymarket settlement source; real-time airport observations |
| **NWS** | Official (US) | US Only | US National Weather Service high-fidelity forecasts |
| **MGM** | Observations (Turkey) | Ankara Only | Turkish State Met Service: pressure, cloud cover, feels-like, 24h rainfall |
<<<<<<< HEAD
| Source | Role | Coverage | Strength | | Source | Role | Coverage | Strength |
| :----------------- | :---------------------- | :-------------- | :--------------------------------------------------------------------------------- | | :----------------- | :---------------------- | :-------------- | :--------------------------------------------------------------------------------- |
| **Open-Meteo** | Base Forecast | Global | Provides detailed 72-hour temperature curves for all cities. | | **Open-Meteo** | Base Forecast | Global | Provides detailed 72-hour temperature curves for all cities. |
@@ -47,61 +118,175 @@ The bot aggregates data from multiple authoritative sources, layered by reliabil
| **METAR** | **Settlement Standard** | Global Airports | The absolute truth for Polymarket settlement; real-time station data. | | **METAR** | **Settlement Standard** | Global Airports | The absolute truth for Polymarket settlement; real-time station data. |
| **NWS** | Official (US) | US Only | High-fidelity forecasts for US cities, critical for extreme weather events. | | **NWS** | Official (US) | US Only | High-fidelity forecasts for US cities, critical for extreme weather events. |
| **MGM** | Official (Turkey) | Ankara | Direct access to Turkish State Meteorological Service for local official accuracy. | | **MGM** | Official (Turkey) | Ankara | Direct access to Turkish State Meteorological Service for local official accuracy. |
=======
> ⚠️ **All NWP model queries use airport coordinates** (matching METAR station), not city center. This eliminates systematic bias between forecast and settlement locations.
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
### 2. ⚡ Ultra-Fresh Data (Cache-Busting) **Open-Meteo API Architecture**: Three API calls go through the same platform, each serving a different purpose:
To counter second-by-second variations in weather betting, we implemented **Zero-Cache Technology**: ```
Open-Meteo (API Platform)
┌─────────────┼─────────────┐
│ │ │
┌──────┴──────┐ │ ┌──────┴──────┐
│ /forecast │ │ │ /forecast │
│ (default) │ │ │ ?models=... │
│ = best_match│ │ │ = multi-model│
└──────┬──────┘ │ └──────┬──────┘
│ │ │
▼ │ ▼
Auto-selects best │ Returns each model
model (≈ ECMWF) │ ECMWF / GFS / ICON
→ Hourly curves │ GEM / JMA
→ Sunrise/sunset │ → Consensus scoring
→ Sunshine/radiation │
┌──────┴──────┐
│ /ensemble │
│ 51 members │
└──────┬──────┘
Median / P10 / P90
→ Uncertainty range
```
- **Micro-timestamp Tokens**: Every request includes a dynamic token to force servers to bypass CDN caches. > 💡 The OM default forecast is essentially **one of the 5 models** (auto-selected), so it is **excluded from consensus scoring** to avoid double-counting.
- **MGM Real-time Sync**: Specialized header camouflaging to bypass local Turkish API anti-crawling for Ankara.
### 3. ⏱️ Automated Trend Analysis ### 2. ⚡ Ultra-Fresh Data (Zero-Cache)
The bot doesn't just fetch data; it interprets it: - **Dynamic Timestamps**: Every API request includes a unique token to force servers to bypass CDN caches.
- **MGM Real-time Sync**: Specialized header camouflaging and timezone correction for Turkish API.
- **Peak Window Prediction**: Automatically identifies the timeframe when today's record is most likely to be hit. ### 3. 🎯 Multi-Model Consensus Scoring
- **Risk Profiling**: Assigns risk levels based on geographic traits (e.g., Ankara high-altitude swings, London coastal microclimates).
- **Source Attribution**: Every data point is clearly labeled ([MGM], [METAR], [MB]) to help you weigh the data.
### 4. 📊 Smart Max-Temp Tracking The bot queries **5 independent NWP models** (ECMWF, GFS, ICON, GEM, JMA) to rate forecast agreement:
Optimized for Polymarket settlement logic: | Level | Condition (°C / °F) | Meaning |
|:---|:---|:---|
| 🎯 **High** | Spread ≤ 0.8°C / 1.5°F | All 5 models converge — high confidence, low risk |
| ⚖️ **Medium** | Spread ≤ 1.5°C / 3.0°F | Minor disagreement — moderate confidence |
| ⚠️ **Low** | Spread > 1.5°C / 3.0°F | Major divergence — high uncertainty, wait for more data |
- **Local Day Filtering**: Uses city UTC offsets to strictly count observations after 00:00 local time. Primary models: **ECMWF IFS** (Europe), **GFS** (US NOAA), **ICON** (Germany DWD), **GEM** (Canada), **JMA** (Japan). Plus Meteoblue (London) and NWS (US) when available. Ensemble median is excluded to avoid double-counting.
- **Multi-dimension Monitoring**: Includes "Feels Like" temperatures and 24h precipitation to assist in nuanced trade decisions.
### 4. 📊 Ensemble Forecast Spread (NEW)
Fetches 51-member ensemble forecasts from Open-Meteo to quantify prediction uncertainty:
> 📊 **Ensemble**: Median 10.8°C, 90% range [9.5°C - 12.1°C], spread 2.6°.
A tight range = high confidence in the forecast. A wide range = the atmosphere is chaotic, higher risk.
**Deterministic vs Ensemble Divergence Detection**: When the OM deterministic forecast exceeds the ensemble P90 or falls below P10, the bot flags it. If actual observations later verify the forecast, the warning upgrades to a ✅ **Forecast Verified** message.
### 5. ⏰ Entry Timing Signal (NEW)
A composite score combining three factors to advise on betting timing:
| Factor | Score |
|:---|:---|
| Peak already passed | +3 |
| ≤ 2h to peak | +2 |
| ≤ 4h to peak | +1 |
| Model consensus: High | +2 |
| Model consensus: Medium | +1 |
| Actual ≈ Forecast (gap ≤ 0.5°) | +2 |
| Actual close to Forecast (gap ≤ 1.5°) | +1 |
| Total ≥ | Signal | Advice |
|:---|:---|:---|
| 5 | ⏰ **Ideal** | Low uncertainty — good to bet |
| 3 | ⏰ **Good** | Consider small positions |
| 2 | ⏰ **Cautious** | Keep observing |
| <2 | ⏰ **Not Recommended** | High uncertainty — wait |
### 6. 🧠 Smart Trend Analysis (Plain Language)
The bot generates human-readable insights automatically:
- **🚨 Forecast Breakthrough Alerts**: Detects when METAR observed max exceeds all forecast highs.
- **⏱️ Peak Window Prediction**: Identifies the exact hours when today's high is expected.
- **🌬️ Wind Direction Cross-Validation**: Compares METAR and MGM wind data; alerts on conflicts (>90° difference).
- **☁️ Cloud Impact Analysis**: Evaluates cloud cover's effect on warming potential.
- **📉 Pressure Analysis**: Low pressure indicates warm/moist air passage.
- **🌧️ Rain Detection**: Cross-validates METAR weather codes with actual rainfall data to avoid false positives.
- **📊 Max Temperature Time Tracking**: Shows exactly when the daily high was recorded (e.g., `最高: 12°C @14:20`).
- **☀️ Weather Condition Summary**: Synthesizes METAR phenomena + cloud cover into a single glanceable icon + text (e.g., `⛅ Partly Cloudy`).
- **🌤️ Solar Radiation Analysis**: Tracks cumulative shortwave radiation vs. daily total; warns when clouds severely block sunlight.
- **🌙 Warm Advection Detection**: Identifies when peak temperature occurred during zero-radiation hours (e.g., 3 AM), proving the high was driven by warm air mass rather than solar heating.
### 7. 📊 Risk Profiling
Every city has a data bias risk profile based on airport-to-city-center distance:
- 🔴 **High Risk**: Seoul (48.8km), Chicago (25.3km) — large bias expected
- 🟡 **Medium Risk**: Ankara (24.5km), Paris (25.2km), Dallas, Buenos Aires — systematic bias
- 🟢 **Low Risk**: London (12.7km), Wellington (5.1km) — reliable data
### 8. 🌅 Enhanced Display
- **Sunrise/Sunset + Sunshine Hours**: `🌅 07:34 | 🌇 18:29 | ☀️ 9.9h`
- **Weather Condition at a Glance**: `✈️ 实测 (METAR): 9°C | ⛅ Partly Cloudy | 15:00`
- **WU Settlement Preview**: Shows the Wunderground-rounded value for settlement reference.
--- ---
## 🏗️ System Architecture ## 🏗️ System Architecture
PolyWeather uses a **Lightweight, Plugin-based** architecture for millisecond responses.
```mermaid ```mermaid
graph TD graph TD
User[/Telegram User/] --> Bot[bot_listener.py] User[/Telegram User/] --> Bot[bot_listener.py]
Bot --> Collector[WeatherDataCollector] Bot --> Collector[WeatherDataCollector]
subgraph "Data Engine" subgraph "Data Engine"
<<<<<<< HEAD
Collector --> OM[Open-Meteo API] Collector --> OM[Open-Meteo API]
Collector --> MB[Meteoblue Weather API] Collector --> MB[Meteoblue Weather API]
Collector --> NOAA[METAR Data Center] Collector --> NOAA[METAR Data Center]
Collector --> MGM[Turkish MGM API] Collector --> MGM[Turkish MGM API]
=======
Collector --> MM[Multi-Model API<br/>ECMWF/GFS/ICON/GEM/JMA]
Collector --> OM[Open-Meteo Forecast]
Collector --> ENS[Open-Meteo Ensemble]
Collector --> MB[Meteoblue API]
Collector --> NOAA[METAR / NOAA]
Collector --> MGM[MGM Observations]
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
Collector --> NWS[US NWS API] Collector --> NWS[US NWS API]
end end
Collector --> Processing[Smart Analysis & Formatting] Collector --> Processing[Consensus Scoring & Trend Analysis]
Processing --> Bot Processing --> Bot
Bot --> Reponse[/Compact Betting Snapshot/] Bot --> Response[/Betting Snapshot with Entry Signal/]
``` ```
- **Logic Decoupling**: `weather_sources.py` handles parsing; `bot_listener.py` handles rendering. - **Logic Decoupling**: `weather_sources.py` handles data fetching & parsing; `bot_listener.py` handles analysis & rendering.
- **Legacy Modules**: `main.py` contains the old automated trading engine. Focus has shifted to "assisted manual decision-making." - **City Config**: `city_risk_profiles.py` contains all METAR station mappings and risk assessments.
- **Multi-Model Consensus**: 5 independent NWP models (ECMWF, GFS, ICON, GEM, JMA) for robust consensus scoring.
- **Ensemble Integration**: 51-member ensemble provides P10/P90 uncertainty bands and divergence detection.
- **Airport-Aligned Coordinates**: All NWP queries target METAR station coordinates, not city centers.
--- ---
## 🎯 Betting Strategy Tips ## 🎯 Betting Strategy Tips
<<<<<<< HEAD
1. **Check Consensus**: Compare Open-Meteo and Meteoblue (MB). Consensus usually implies higher probability. 1. **Check Consensus**: Compare Open-Meteo and Meteoblue (MB). Consensus usually implies higher probability.
2. **Watch the Peak**: Use `/city` frequently during predicted peak windows to catch momentum. 2. **Watch the Peak**: Use `/city` frequently during predicted peak windows to catch momentum.
3. **Weighting Hierarchy**: Settlement is **METAR**; high-accuracy trend is **MB** (London); Official (NWS/MGM) is the "anchor." 3. **Weighting Hierarchy**: Settlement is **METAR**; high-accuracy trend is **MB** (London); Official (NWS/MGM) is the "anchor."
4. **Geographic Risk**: Pay close attention to cities where "Bias will significantly amplify." 4. **Geographic Risk**: Pay close attention to cities where "Bias will significantly amplify."
=======
1. **Check Model Consensus**: The 🎯/⚖️/⚠️ rating tells you immediately if the forecast is reliable.
2. **Use the Entry Signal**: Wait for ⏰ **Ideal** or **Good** timing before placing bets. Don't bet early when uncertainty is high.
3. **Watch Ensemble Spread**: A tight 90% band (< 2°) means model confidence is high — this is where edges live.
4. **Watch the Peak Window**: Use `/city` frequently during predicted peak hours.
5. **Settlement Priority**: Settlement is always based on **METAR** data, rounded to integer via Wunderground.
6. **Geographic Risk**: Pay attention to bias warnings, especially for high-risk cities like Seoul and Chicago.
7. **Solar Radiation Clues**: If the bot reports "warm advection driven" 🌙, the temperature was pushed by warm air, not sunlight — this pattern often breaks model predictions.
8. **Wind Conflicts**: When METAR and MGM show opposite wind directions, expect temperature volatility.
---
_Last updated: 2026-02-22_
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
+215 -30
View File
@@ -1,6 +1,6 @@
# 🌡️ PolyWeather: 实时天气查询与分析机器人 # 🌡️ PolyWeather: 实时天气查询与分析机器人
一个智能天气信息机器人,专为提供超快、实时的气象数据、高保真预报和智能趋势分析而设计。通过绕过网络缓存直接从全球气象站获取最新数据。 专为预测市场和天气博弈设计的智能天气机器人。通过绕过 CDN 缓存直接从全球气象站获取最新数据,并提供**模型共识评分**和**入场时机信号**等通俗易懂的自动趋势分析
## 🚀 快速开始 ## 🚀 快速开始
@@ -8,19 +8,55 @@
- **Python 3.11+** - **Python 3.11+**
- 依赖安装: `pip install -r requirements.txt` - 依赖安装: `pip install -r requirements.txt`
<<<<<<< HEAD
- **环境变量**: 需在 `.env` 中配置 `METEOBLUE_API_KEY` 以激活伦敦高精度预报。 - **环境变量**: 需在 `.env` 中配置 `METEOBLUE_API_KEY` 以激活伦敦高精度预报。
=======
- **环境变量**: 在 `.env` 中设置 `TELEGRAM_BOT_TOKEN`(必需)。可选设置 `METEOBLUE_API_KEY` 以激活伦敦高精度预报。
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
### 本地运行 (Windows/Linux) ### VPS 部署 (推荐)
**首次部署:**
```bash ```bash
# Windows git clone https://github.com/yangyuan-zhen/PolyWeather.git
py -3.11 run.py cd PolyWeather
pip install -r requirements.txt
# Linux/VPS cp .env.example .env # 编辑 .env 填入你的 Token 和 API Key
python3 run.py
``` ```
_注意:系统当前处于 **天气查询模式**。主动市场监控和自动交易模块已暂停。_ **创建一键更新脚本(只需执行一次):**
```bash
cat > ~/update.sh << 'EOF'
#!/bin/bash
cd ~/PolyWeather
git fetch origin
git reset --hard origin/main
pkill -f run.py
pkill -f bot_listener.py
sleep 1
nohup python3 run.py > bot.log 2>&1 &
echo "✅ 已更新并重启!"
EOF
chmod +x ~/update.sh
```
**日常更新(每次代码推送后):**
```bash
~/update.sh
```
> 一条命令完成:拉取最新代码 → 杀旧进程 → 启动新进程。无需手动处理分支冲突。
### 本地开发 (Windows)
```bash
py -3.11 run.py
```
> 本地笔记本**不需要安装依赖**,只用来编辑代码和 Git 推送。IDE 的 import 报错是因为本地没装依赖,不影响 VPS 运行。
--- ---
@@ -32,20 +68,49 @@ _注意:系统当前处于 **天气查询模式**。主动市场监控和自
| `/id` | **获取 Chat ID** | 获取当前 Telegram 聊天 ID | | `/id` | **获取 Chat ID** | 获取当前 Telegram 聊天 ID |
| `/help` | **帮助** | 显示所有可用指令 | | `/help` | **帮助** | 显示所有可用指令 |
### /city 指令示例 ### 支持的城市
| 城市 | 缩写/别名 | METAR 机场 | 额外数据源 |
|:---|:---|:---|:---|
| 伦敦 London | `lon`, `伦敦` | EGLC (City Airport) | Meteoblue |
| 巴黎 Paris | `par`, `巴黎` | LFPG (Charles de Gaulle) | — |
| 安卡拉 Ankara | `ank`, `安卡拉` | LTAC (Esenboğa) | MGM |
| 纽约 New York | `nyc`, `ny`, `纽约` | KLGA (LaGuardia) | NWS |
| 芝加哥 Chicago | `chi`, `芝加哥` | KORD (O'Hare) | NWS |
| 达拉斯 Dallas | `dal`, `达拉斯` | KDAL (Love Field) | NWS |
| 迈阿密 Miami | `mia`, `迈阿密` | KMIA (International) | NWS |
| 亚特兰大 Atlanta | `atl`, `亚特兰大` | KATL (Hartsfield-Jackson) | NWS |
| 西雅图 Seattle | `sea`, `西雅图` | KSEA (Sea-Tac) | NWS |
| 多伦多 Toronto | `tor`, `多伦多` | CYYZ (Pearson) | — |
| 首尔 Seoul | `sel`, `首尔` | RKSI (Incheon) | — |
| 布宜诺斯艾利斯 Buenos Aires | `ba`, `布宜诺斯艾利斯` | SAEZ (Ezeiza) | — |
| 惠灵顿 Wellington | `wel`, `惠灵顿` | NZWN (Wellington) | — |
### 使用示例
``` ```
/city 伦敦 /city 巴黎
/city london
/city par
``` ```
--- ---
## ✨ 核心功能 ## ✨ 核心功能
### 1. 🏛️ 多源数据融合 (Multi-Source Fusion) ### 1. 🏛️ 多源数据融合
机器人聚合了全球最权威的几个数据源,并按权重进行分层: | 数据源 | 数据角色 | 覆盖范围 | 优势 |
| :---------------------- | :------------- | :--------- | :----------------------------------------------------------- |
| **多模型 (5 NWP)** | **共识评分** | 全球 | ECMWF、GFS、ICON、GEM、JMA — 5 个完全独立的数值预报模型,经 Open-Meteo 统一获取 |
| **Open-Meteo** | 基础预测 | 全球 | 72h 逐小时温度曲线、日出日落、**日照时长**、**短波辐射** |
| **Open-Meteo Ensemble** | **不确定性区间** | 全球 | 51 成员集合预报:中位数、P10、P90 散度用于置信度评估 |
| **Meteoblue (MB)** | 高精度共识 | 仅限伦敦 | 聚合多家模型,对微气候处理极佳 |
| **METAR** | **结算标准** | 全球机场 | Polymarket 结算参考的绝对真理,实时机场观测 |
| **NWS** | 官方预测(美) | 仅限美国 | 美国国家气象局高精度预报 |
| **MGM** | 实测数据(土) | 仅限安卡拉 | 土耳其气象局:气压、云量、体感温度、24h 降水 |
<<<<<<< HEAD
| 数据源 | 数据角色 | 覆盖范围 | 优势 | | 数据源 | 数据角色 | 覆盖范围 | 优势 |
| :----------------- | :------------- | :--------- | :----------------------------------------------- | | :----------------- | :------------- | :--------- | :----------------------------------------------- |
| **Open-Meteo** | 基础预测 | 全球 | 提供所有城市的 72 小时精细化温度曲线 | | **Open-Meteo** | 基础预测 | 全球 | 提供所有城市的 72 小时精细化温度曲线 |
@@ -53,61 +118,181 @@ _注意:系统当前处于 **天气查询模式**。主动市场监控和自
| **METAR** | **结算标准** | 全球机场 | Polymarket 结算参考的绝对真理,实时机场观测 | | **METAR** | **结算标准** | 全球机场 | Polymarket 结算参考的绝对真理,实时机场观测 |
| **NWS** | 官方预测(美) | 仅限美国 | 美国国家气象局,对美国城市的极端天气预判准确 | | **NWS** | 官方预测(美) | 仅限美国 | 美国国家气象局,对美国城市的极端天气预判准确 |
| **MGM** | 官方预测(土) | 仅限安卡拉 | 土耳其气象局,提供安卡拉 Esenboğa 机场的官方数据 | | **MGM** | 官方预测(土) | 仅限安卡拉 | 土耳其气象局,提供安卡拉 Esenboğa 机场的官方数据 |
=======
> ⚠️ **所有 NWP 模型查询使用机场坐标**(与 METAR 站点一致),而非市中心。这消除了预报位置与结算位置之间的系统性偏差。
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
### 2. ⚡ 超新鲜数据 (Cache-Busting) **Open-Meteo API 架构关系**:三个 API 调用都经过 Open-Meteo 平台,但获取的是不同维度的数据:
为了应对气象博弈中秒级的变化,我们实现了 **0 缓存技术** ```
Open-Meteo (API 平台)
┌─────────────┼─────────────┐
│ │ │
┌──────┴──────┐ │ ┌──────┴──────┐
│ /forecast │ │ │ /forecast │
│ (默认模式) │ │ │ ?models=... │
│ = best_match│ │ │ = 多模型模式 │
└──────┬──────┘ │ └──────┬──────┘
│ │ │
▼ │ ▼
自动选最佳模型 │ 返回每个模型单独结果
(通常 ≈ ECMWF) │ ECMWF / GFS / ICON
→ 逐小时曲线 │ GEM / JMA
→ 日出日落 │ → 共识评分
→ 日照/辐射 │
┌──────┴──────┐
│ /ensemble │
│ 51成员集合 │
└──────┬──────┘
中位数 / P10 / P90
→ 不确定性区间
```
- **微秒级令牌**:每个 API 请求都附带动态时间戳,强制气象服务器绕过 CDN 缓存返回最新值 > 💡 OM 默认预报本质上是 5 个模型中的**某一个**(自动选择),因此**不参与共识评分**,避免双重计数
### 2. ⚡ 超新鲜数据 (零缓存)
- **动态时间戳**:每个 API 请求都附带唯一令牌,强制服务器绕过 CDN 缓存。
- **MGM 实时同步**:针对土耳其 MGM API 做了专门的 Header 伪装和时区校正。 - **MGM 实时同步**:针对土耳其 MGM API 做了专门的 Header 伪装和时区校正。
### 3. ⏱️ 自动态势分析 ### 3. 🎯 多模型共识评分
机器人不仅仅搬运数据,它还会进行逻辑加工 机器人同时查询 **5 个独立 NWP 模型**ECMWF、GFS、ICON、GEM、JMA)来评估预报一致性
- **峰值时刻预测**:自动计算今天气温最高点出现的概率窗口(如:14:00 - 15:00)。 | 等级 | 条件(摄氏/华氏) | 含义 |
- **风险等级 (Risk-Profile)**:根据地理特征(如安卡拉的高原温差、伦敦的近海微气候)自动分配风险等级。 |:---|:---|:---|
- **数据溯源**:报表明确标注每个数字的来源([MGM], [METAR], [MB])。 | 🎯 **高共识** | 极差 ≤ 0.8°C / 1.5°F | 5 个模型高度收敛 — 高置信,低风险 |
| ⚖️ **中共识** | 极差 ≤ 1.5°C / 3.0°F | 轻微分歧 — 中等置信 |
| ⚠️ **低共识** | 极差 > 1.5°C / 3.0°F | 模型严重分歧 — 不确定性大,建议观察 |
### 4. 📊 智能最高温追踪 主要模型:**ECMWF IFS**(欧洲)、**GFS**(美国 NOAA)、**ICON**(德国 DWD)、**GEM**(加拿大)、**JMA**(日本)。伦敦额外有 Meteoblue,美国城市额外有 NWS。集合预报中位数不参与评分,避免双重计数。
针对 Polymarket 的结算逻辑进行优化: **核心逻辑**:当 5 个独立模型在温度区间上高度收敛,而市场定价尚未反映时,这就是典型的**结构性定价错误**——低风险套利的黄金机会。
- **当地日历日过滤**:基于城市 UTC 偏移,严格统计当地时间 00:00 之后的实测最高温。 ### 4. 📊 集合预报散度(新功能)
- **多维度监测**:集成体感温度 (`feels_like`) 和 24h 累计降雨量,辅助多维度判断。
从 Open-Meteo 获取 51 成员集合预报,量化预测不确定性:
> 📊 **集合预报**:中位数 10.8°C90% 区间 [9.5°C - 12.1°C],波动幅度 2.6°。
区间窄 = 大气状态明确,预报可信。区间宽 = 大气混沌,风险高。
**确定性 vs 集合偏差检测**:当 OM 确定性预报超过集合 P90 或低于 P10 时,机器人会发出警告。如果实测数据随后验证了预报,警告会升级为 ✅ **预报验证** 消息。
### 5. ⏰ 入场时机信号(新功能)
综合三个因子打分,给出入场建议:
| 因子 | 分值 |
|:---|:---|
| 最热已过 | +3 |
| 距峰值 ≤ 2h | +2 |
| 距峰值 ≤ 4h | +1 |
| 模型高共识 | +2 |
| 模型中共识 | +1 |
| 实测 ≈ 预报(差 ≤ 0.5°)| +2 |
| 实测接近预报(差 ≤ 1.5°)| +1 |
| 总分 ≥ | 信号 | 建议 |
|:---|:---|:---|
| 5 | ⏰ **理想** | 不确定性低,适合下注 |
| 3 | ⏰ **较好** | 可以考虑小仓位入场 |
| 2 | ⏰ **谨慎** | 建议继续观察 |
| <2 | ⏰ **不建议** | 不确定性大,等更多数据 |
**核心理念**:拒绝过早布局,选择接近解析时刻、波动率压缩时晚入场,降低不确定性风险。
### 6. 🧠 智能趋势分析(通俗语言)
机器人自动生成人类可读的分析洞察:
- **🚨 预报击穿预警**:当 METAR 实测最高温超过所有预报时自动警报。
- **⏱️ 峰值时段预测**:精确预测当日最高温出现的时间窗口。
- **🌬️ 风向交叉验证**:同时对比 METAR 和 MGM 风向数据,差异超 90° 自动告警。
- **🍃 风速分析**:标注风速并结合风向判断对温度的影响。
- **☁️ 云层遮挡分析**:评估云量对升温潜力的影响(晴天/多云/阴天)。
- **📉 气压分析**:低气压意味着暖湿气流过境,有利升温。
- **🌧️ 降雨检测**:交叉验证 METAR 天气代码和实际降水量,避免误报。
- **📊 最高温时间追踪**:精确显示每日最高温出现的时间(如 `最高: 12°C @14:20`)。
- **☀️ 天气状况一览**:综合 METAR 天气现象 + 云量,生成一目了然的天气图标 + 文字(如 `⛅ 晴间多云`)。
- **🌤️ 太阳辐射分析**:追踪累计短波辐射 vs 全天总量;当云层严重遮挡阳光时发出预警。
- **🌙 暖平流检测**:当最高温出现在太阳辐射为零的时段(如凌晨 3 点),自动识别并标注"气温由暖空气推高,而非太阳晒热"。
### 7. 📊 风险等级
每个城市都有基于机场-市区距离的数据偏差风险档案:
- 🔴 **高危**:首尔 (48.8km)、芝加哥 (25.3km) — 偏差大
- 🟡 **中危**:安卡拉 (24.5km)、巴黎 (25.2km)、达拉斯、布宜诺斯艾利斯 — 有系统偏差
- 🟢 **低危**:伦敦 (12.7km)、惠灵顿 (5.1km) — 数据靠谱
### 8. 🌅 增强显示
- **日出日落 + 日照时长**`🌅 07:34 | 🌇 18:29 | ☀️ 9.9h`
- **天气状况一目了然**`✈️ 实测 (METAR): 9°C | ⛅ 晴间多云 | 15:00`
- **WU 结算预览**:显示 Wunderground 四舍五入后的值,方便结算参考。
--- ---
## 🏗️ 系统架构 ## 🏗️ 系统架构
本项目采用 **“轻量化、插件式”** 架构,旨在实现毫秒级响应。
```mermaid ```mermaid
graph TD graph TD
User[/Telegram User/] --> Bot[bot_listener.py] User[/Telegram User/] --> Bot[bot_listener.py]
Bot --> Collector[WeatherDataCollector] Bot --> Collector[WeatherDataCollector]
<<<<<<< HEAD
subgraph "Data Engine" subgraph "Data Engine"
Collector --> OM[Open-Meteo API] Collector --> OM[Open-Meteo API]
Collector --> MB[Meteoblue Weather API] Collector --> MB[Meteoblue Weather API]
Collector --> NOAA[METAR Data Center] Collector --> NOAA[METAR Data Center]
Collector --> MGM[Turkish MGM API] Collector --> MGM[Turkish MGM API]
=======
subgraph "数据引擎"
Collector --> MM[多模型 API<br/>ECMWF/GFS/ICON/GEM/JMA]
Collector --> OM[Open-Meteo 预报]
Collector --> ENS[Open-Meteo Ensemble]
Collector --> MB[Meteoblue API]
Collector --> NOAA[METAR / NOAA]
Collector --> MGM[MGM 实测数据]
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
Collector --> NWS[US NWS API] Collector --> NWS[US NWS API]
end end
Collector --> Processing[智能分析 & 格式化] Collector --> Processing[共识评分 & 趋势分析]
Processing --> Bot Processing --> Bot
Bot --> Reponse[/精简版博弈快照/] Bot --> Response[/附带入场信号的天气快照/]
``` ```
- **逻辑解耦**`weather_sources.py` 负责外部数据的解析;`bot_listener.py` 负责消息模板渲染。 - **逻辑解耦**`weather_sources.py` 负责数据获取与解析;`bot_listener.py` 负责分析与渲染。
- **遗留模块说明**:项目根目录下的 `main.py` 包含旧版本的自动交易引擎。目前重心在“手动辅助决策”,如需开启请查阅 [MARKET_DISCOVERY_ZH.md](./MARKET_DISCOVERY_ZH.md) - **城市配置**`city_risk_profiles.py` 包含所有 METAR 机场映射和风险评估
- **多模型共识**:5 个独立 NWP 模型(ECMWF、GFS、ICON、GEM、JMA)提供稳健的共识评分。
- **集合预报集成**:51 成员集合预报提供 P10/P90 不确定性区间和偏差检测。
- **机场坐标对齐**:所有 NWP 查询均使用 METAR 机场坐标,而非市中心。
--- ---
## 🎯 博弈策略提示 ## 🎯 博弈策略提示
<<<<<<< HEAD
1. **检查模型共识**:查看 Open-Meteo 和 Meteoblue (MB) 是否达成共识。 1. **检查模型共识**:查看 Open-Meteo 和 Meteoblue (MB) 是否达成共识。
2. **关注峰值窗口**:在预测的峰值时段多次使用 `/city` 刷新。 2. **关注峰值窗口**:在预测的峰值时段多次使用 `/city` 刷新。
3. **数据权重优先级**:结算以 **METAR** 为准,趋势预测以 **MB** 为准(仅限伦敦)。 3. **数据权重优先级**:结算以 **METAR** 为准,趋势预测以 **MB** 为准(仅限伦敦)。
4. **地理风险评估**:重点关注提示中的“偏差会显著放大”警告(如安卡拉、伦敦)。 4. **地理风险评估**:重点关注提示中的“偏差会显著放大”警告(如安卡拉、伦敦)。
=======
1. **看模型共识**:🎯/⚖️/⚠️ 评级让你一眼判断预报是否可靠。高共识 + 市场低定价 = 套利机会。
2. **用入场信号**:等 ⏰ **理想****较好** 时机再下注。不确定性高时绝不提前入场。
3. **关注集合散度**:90% 区间越窄(< 2°),模型置信越高 — 这才是 edge 所在。
4. **紧盯峰值窗口**:在预测的峰值时段频繁使用 `/city` 刷新。
5. **结算优先级**:结算永远以 **METAR** 数据为准,通过 Wunderground 四舍五入到整数。
6. **地理风险**:重点关注高危城市(如首尔、芝加哥)的偏差警告。
7. **太阳辐射线索**:如果机器人报告"暖平流驱动" 🌙,说明温度由暖空气推高 — 这种模式经常打破模型预测。
8. **风向冲突**:METAR 和 MGM 风向相反时,温度波动风险增大。
---
_最后更新: 2026-02-22_
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
+570 -17
View File
@@ -1,21 +1,22 @@
import sys import sys
import os import os
from datetime import datetime from datetime import datetime
import telebot from typing import List, Dict, Any, Optional
from loguru import logger import telebot # type: ignore
from loguru import logger # type: ignore
# 确保项目根目录在 sys.path 中 # 确保项目根目录在 sys.path 中
project_root = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(os.path.abspath(__file__))
if project_root not in sys.path: if project_root not in sys.path:
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
from src.utils.config_loader import load_config from src.utils.config_loader import load_config # type: ignore
from src.data_collection.weather_sources import WeatherDataCollector from src.data_collection.weather_sources import WeatherDataCollector # type: ignore
from src.data_collection.city_risk_profiles import get_city_risk_profile, format_risk_warning from src.data_collection.city_risk_profiles import get_city_risk_profile, format_risk_warning # type: ignore
def analyze_weather_trend(weather_data, temp_symbol): def analyze_weather_trend(weather_data, temp_symbol):
"""根据实测与预测分析气温态势,增加峰值时刻预测""" """根据实测与预测分析气温态势,增加峰值时刻预测"""
insights = [] insights: List[str] = []
metar = weather_data.get("metar", {}) metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {}) open_meteo = weather_data.get("open-meteo", {})
@@ -36,14 +37,29 @@ def analyze_weather_trend(weather_data, temp_symbol):
forecast_highs.append(mb["today_high"]) forecast_highs.append(mb["today_high"])
if nws.get("today_high") is not None: if nws.get("today_high") is not None:
forecast_highs.append(nws["today_high"]) forecast_highs.append(nws["today_high"])
<<<<<<< HEAD
if mgm.get("today_high") is not None: if mgm.get("today_high") is not None:
forecast_highs.append(mgm["today_high"]) forecast_highs.append(mgm["today_high"])
=======
# 加入多模型预报 (ECMWF, GFS, ICON, GEM, JMA)
for mv in weather_data.get("multi_model", {}).get("forecasts", {}).values():
if mv is not None:
forecast_highs.append(mv)
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
forecast_highs = [h for h in forecast_highs if h is not None] forecast_highs = [h for h in forecast_highs if h is not None]
# 取预报中的最高值作为风险防御基准 # 取预报中的最高值作为风险防御基准
forecast_high = max(forecast_highs) if forecast_highs else None forecast_high = max(forecast_highs) if forecast_highs else None
# 取最低值用于判断是否“已触及预报高位” # 取最低值用于判断是否“已触及预报高位”
min_forecast_high = min(forecast_highs) if forecast_highs else forecast_high min_forecast_high = min(forecast_highs) if forecast_highs else forecast_high
<<<<<<< HEAD
=======
# 取中位数作为用户可见的"预期值"(避免极端模型误导)
forecast_median = None
if forecast_highs:
sorted_fh = sorted(forecast_highs)
forecast_median = sorted_fh[len(sorted_fh) // 2]
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
wind_speed = metar.get("current", {}).get("wind_speed_kt", 0) wind_speed = metar.get("current", {}).get("wind_speed_kt", 0)
@@ -56,15 +72,156 @@ def analyze_weather_trend(weather_data, temp_symbol):
local_date_str = datetime.now().strftime("%Y-%m-%d") local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour local_hour = datetime.now().hour
# === 模型共识评分 ===
# 主要来源: 多模型预报 (ECMWF, GFS, ICON, GEM, JMA)
multi_model = weather_data.get("multi_model", {})
mm_forecasts = multi_model.get("forecasts", {})
labeled_forecasts = []
for model_name, model_val in mm_forecasts.items():
if model_val is not None:
labeled_forecasts.append((model_name, model_val))
# 额外独立源 (如有)
if mb.get("today_high") is not None:
labeled_forecasts.append(("MB", mb["today_high"]))
if nws.get("today_high") is not None:
labeled_forecasts.append(("NWS", nws["today_high"]))
# Open-Meteo 确定性预报(用于后续偏差检测,不重复加入共识)
om_today = daily.get("temperature_2m_max", [None])[0]
# 集合预报数据 (仅用于不确定性区间展示)
ensemble = weather_data.get("ensemble", {})
ens_median = ensemble.get("median")
consensus_level = "unknown"
consensus_spread = None
if len(labeled_forecasts) >= 2:
f_values = [v for _, v in labeled_forecasts]
f_max = max(f_values)
f_min = min(f_values)
consensus_spread = f_max - f_min
f_avg = sum(f_values) / len(f_values)
# 动态阈值:华氏度场景用更大的容差
is_f = (temp_symbol == "°F")
tight_threshold = 1.5 if is_f else 0.8 # 高共识
mid_threshold = 3.0 if is_f else 1.5 # 中共识
parts = " | ".join([f"{name} {val}{temp_symbol}" for name, val in labeled_forecasts])
if consensus_spread <= tight_threshold:
consensus_level = "high"
insights.append(
f"🎯 <b>模型共识:高 ({len(labeled_forecasts)}/{len(labeled_forecasts)})</b> — "
f"{parts},极差仅 {consensus_spread:.1f}°,预报高度一致。"
)
elif consensus_spread <= mid_threshold:
consensus_level = "medium"
insights.append(
f"⚖️ <b>模型共识:中 ({len(labeled_forecasts)}源)</b> — "
f"{parts},极差 {consensus_spread:.1f}°,有轻微分歧。"
)
else:
consensus_level = "low"
# 找出最高和最低的源
highest = max(labeled_forecasts, key=lambda x: x[1])
lowest = min(labeled_forecasts, key=lambda x: x[1])
insights.append(
f"⚠️ <b>模型共识:低 ({len(labeled_forecasts)}源)</b> — "
f"{parts},极差 {consensus_spread:.1f}°!"
f"{highest[0]} 最高 ({highest[1]}{temp_symbol}) vs {lowest[0]} 最低 ({lowest[1]}{temp_symbol}),不确定性大。"
)
elif len(labeled_forecasts) == 1:
name, val = labeled_forecasts[0]
insights.append(
f"📡 <b>仅1个预报源 ({name} {val}{temp_symbol})</b> — 无法交叉验证,共识评分不可用。"
)
# 集合预报区间 (独立于共识评分显示)
ens_p10 = ensemble.get("p10")
ens_p90 = ensemble.get("p90")
if ens_p10 is not None and ens_p90 is not None and ens_median is not None:
ens_range = ens_p90 - ens_p10
insights.append(
f"📊 <b>集合预报</b>:中位数 {ens_median}{temp_symbol}"
f"90% 区间 [{ens_p10}{temp_symbol} - {ens_p90}{temp_symbol}]"
f"波动幅度 {ens_range:.1f}°。"
)
# 确定性预报 vs 集合分布偏差检测
if om_today is not None:
actual_reached = max_so_far is not None and max_so_far >= om_today - 0.5
if om_today > ens_p90:
if actual_reached:
# 实测已达到预报值 → 确定性预报是对的,集合偏保守
insights.append(
f"✅ <b>预报验证</b>:确定性预报 {om_today}{temp_symbol} 已被实测验证 "
f"(实测最高 {max_so_far}{temp_symbol}),集合预报偏保守。"
)
else:
# 还没到最高温,存在偏高风险
delta = om_today - ens_median
insights.append(
f"⚡ <b>预报偏高警告</b>:确定性预报 {om_today}{temp_symbol} "
f"超过了集合 90% 上限 ({ens_p90}{temp_symbol})"
f"比中位数高 {delta:.1f}°。实际高温更可能接近 {ens_median}{temp_symbol}"
)
elif om_today < ens_p10:
if max_so_far is not None and max_so_far >= ens_median:
# 实测已超过中位数 → 确定性预报偏低,集合更准
insights.append(
f"✅ <b>预报验证</b>:实测最高 {max_so_far}{temp_symbol} "
f"已超过确定性预报 {om_today}{temp_symbol},集合中位数 {ens_median}{temp_symbol} 更准确。"
)
else:
delta = ens_median - om_today
insights.append(
f"⚡ <b>预报偏低警告</b>:确定性预报 {om_today}{temp_symbol} "
f"低于集合 90% 下限 ({ens_p10}{temp_symbol})"
f"比中位数低 {delta:.1f}°。实际高温更可能接近 {ens_median}{temp_symbol}"
)
# === 核心判断:实测是否已超预报 === # === 核心判断:实测是否已超预报 ===
is_breakthrough = False
if max_so_far is not None and forecast_high is not None: if max_so_far is not None and forecast_high is not None:
if max_so_far > forecast_high + 0.5: if max_so_far > forecast_high + 0.5:
<<<<<<< HEAD
# 实测已超所有预报! # 实测已超所有预报!
exceed_by = max_so_far - forecast_high exceed_by = max_so_far - forecast_high
insights.append(f"🚨 <b>预报已被击穿</b>:实测最高 {max_so_far}{temp_symbol} 已超所有预报上限 {forecast_high}{temp_symbol}{exceed_by:.1f}°!") insights.append(f"🚨 <b>预报已被击穿</b>:实测最高 {max_so_far}{temp_symbol} 已超所有预报上限 {forecast_high}{temp_symbol}{exceed_by:.1f}°!")
insights.append(f"💡 <b>博弈建议</b>:市场需重新评估,当前可能存在极端异常增温。") insights.append(f"💡 <b>博弈建议</b>:市场需重新评估,当前可能存在极端异常增温。")
return "\n💡 <b>态势分析</b>\n" + "\n".join(insights) return "\n💡 <b>态势分析</b>\n" + "\n".join(insights)
=======
is_breakthrough = True
exceed_by = max_so_far - forecast_high
insights.append(f"🚨 <b>实测已超预报</b>:实测最高 {max_so_far}{temp_symbol} 超过了所有预报的天花板 {forecast_high}{temp_symbol},多了 {exceed_by:.1f}°!")
insights.append(f"💡 <b>建议</b>:预报已经不准了,实际温度比所有模型预测的都高,需要重新判断。")
# === 结算取整分析 (Wunderground 四舍五入到整数) ===
if max_so_far is not None:
settled = round(max_so_far)
fractional = max_so_far - int(max_so_far)
# 离取整边界的距离
dist_to_boundary = abs(fractional - 0.5)
if dist_to_boundary <= 0.3:
# 在边界附近 (X.2 ~ X.8),取整结果可能随时翻转
if fractional < 0.5:
insights.append(
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}"
f"WU 结算 <b>{settled}{temp_symbol}</b>"
f"但只差 <b>{0.5 - fractional:.1f}°</b> 就会进位到 {settled + 1}{temp_symbol}"
)
else:
insights.append(
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol}"
f"WU 结算 <b>{settled}{temp_symbol}</b>"
f"刚刚越过进位线,再降 <b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}"
)
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
# --- 峰值时刻预测逻辑 (仍以 Open-Meteo 逐小时数据为准) --- # --- 峰值时刻预测逻辑 (仍以 Open-Meteo 逐小时数据为准) ---
hourly = open_meteo.get("hourly", {}) hourly = open_meteo.get("hourly", {})
times = hourly.get("time", []) times = hourly.get("time", [])
@@ -79,6 +236,7 @@ def analyze_weather_trend(weather_data, temp_symbol):
hour = t_str.split("T")[1][:5] hour = t_str.split("T")[1][:5]
peak_hours.append(hour) peak_hours.append(hour)
<<<<<<< HEAD
if peak_hours: if peak_hours:
window = f"{peak_hours[0]} - {peak_hours[-1]}" if len(peak_hours) > 1 else peak_hours[0] window = f"{peak_hours[0]} - {peak_hours[-1]}" if len(peak_hours) > 1 else peak_hours[0]
insights.append(f"⏱️ <b>预计峰值时刻</b>:今天 <b>{window}</b> 之间。") insights.append(f"⏱️ <b>预计峰值时刻</b>:今天 <b>{window}</b> 之间。")
@@ -119,20 +277,82 @@ def analyze_weather_trend(weather_data, temp_symbol):
# 回退逻辑 # 回退逻辑
insights.append(f"🌌 <b>夜间/早间</b>:等待日出后的新一轮波动。") insights.append(f"🌌 <b>夜间/早间</b>:等待日出后的新一轮波动。")
=======
# 确定用于逻辑判断的峰值小时
if peak_hours:
first_peak_h = int(peak_hours[0].split(":")[0])
last_peak_h = int(peak_hours[-1].split(":")[0])
window = f"{peak_hours[0]} - {peak_hours[-1]}" if len(peak_hours) > 1 else peak_hours[0]
insights.append(f"⏱️ <b>预计最热时段</b>:今天 <b>{window}</b>。")
if last_peak_h < 6:
insights.append(f"⚠️ <b>提示</b>:预测最热在凌晨,后续气温可能一路走低。")
elif local_hour < first_peak_h and (max_so_far is None or max_so_far < forecast_high):
target_temp = forecast_median if forecast_median is not None else forecast_high
insights.append(f"🎯 <b>关注重点</b>:看看那个时段温度能不能真的到 {target_temp}{temp_symbol}")
else:
# 兜底默认值
first_peak_h, last_peak_h = 13, 15
is_peak_passed = False
if curr_temp is not None and forecast_high is not None:
diff_max = forecast_high - curr_temp
# 1. 气温节奏判定 (动态参考峰值时刻)
if local_hour > last_peak_h:
# 已经过了预报的峰值时段
is_peak_passed = True
if is_breakthrough:
insights.append(f"🌡️ <b>异常高温</b>:最热的时间已经过了,但温度还是比预报高,降温可能会来得比较晚。")
# 如果实测已经接近"任一"主流预报的最高温 (使用 min_forecast_high)
elif max_so_far and min_forecast_high is not None and max_so_far >= min_forecast_high - 0.5:
insights.append(f"✅ <b>今天最热已过</b>:温度已经到了预报最高值附近,接下来会慢慢降温了。")
else:
# 虽然时间过了,但离最高温还有差距
insights.append(f"📉 <b>开始降温</b>:最热时段已过,现在 {curr_temp}{temp_symbol},看起来很难再涨到预报的 {forecast_high}{temp_symbol} 了。")
elif first_peak_h <= local_hour <= last_peak_h:
# 正在峰值窗口内
if is_breakthrough:
insights.append(f"🔥 <b>极端升温</b>:正处于最热时段,温度已经超过所有预报,还在继续往上走!")
elif max_so_far is not None and forecast_high - max_so_far <= 0.8:
insights.append(f"⚖️ <b>到顶了</b>:正处于最热时段,温度基本到位,接下来会在这个水平上下浮动。")
else:
insights.append(f"⏳ <b>最热时段进行中</b>:虽然在最热时段了,但离预报最高温还差一些,继续观察。")
elif local_hour < first_peak_h:
# 还没到峰值窗口
gap_to_high = forecast_high - (max_so_far if max_so_far is not None else curr_temp)
if gap_to_high > 1.2:
insights.append(f"📈 <b>还在升温</b>:离最热时段还有 {first_peak_h - local_hour} 小时,温度还会继续往上走。")
else:
insights.append(f"🌅 <b>快到最热了</b>:马上就要进入最热时段,温度已经接近预报高位了。")
else:
# 回退逻辑
insights.append(f"🌌 <b>夜间</b>:等明天太阳出来后再看新一轮升温。")
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
# 2. 湿度与露点分析 (仅在傍晚以后) # 2. 湿度与露点分析 (仅在傍晚以后)
humidity = metar.get("current", {}).get("humidity") humidity = metar.get("current", {}).get("humidity")
dewpoint = metar.get("current", {}).get("dewpoint") dewpoint = metar.get("current", {}).get("dewpoint")
if local_hour >= 18: if local_hour >= 18:
if humidity and humidity > 80: if humidity and humidity > 80:
<<<<<<< HEAD
insights.append(f"💦 <b>闷热高湿</b>:湿度极高 ({humidity}%),将显著锁住夜间热量。") insights.append(f"💦 <b>闷热高湿</b>:湿度极高 ({humidity}%),将显著锁住夜间热量。")
if dewpoint is not None and curr_temp - dewpoint < 2.0: if dewpoint is not None and curr_temp - dewpoint < 2.0:
insights.append(f"🌡️ <b>触及露点支撑</b>:气温已跌至露点支撑位,降温将变慢。") insights.append(f"🌡️ <b>触及露点支撑</b>:气温已跌至露点支撑位,降温将变慢。")
=======
insights.append(f"💦 <b>湿度很高</b>:湿度 {humidity}%,空气很潮湿,夜里热量散不掉,降温会很慢。")
if dewpoint is not None and curr_temp - dewpoint < 2.0:
insights.append(f"🌡️ <b>降温快到底了</b>:温度已经接近露点(空气中水汽开始凝结的温度),再往下降会很困难。")
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
# 3. 风力 # 3. 风力
if wind_speed >= 15: if wind_speed >= 15:
insights.append(f"🌬️ <b>大风预判</b>当前风力较大 ({wind_speed}kt),气温可能出现非线性波动") insights.append(f"🌬️ <b>风很大</b>风速 {wind_speed}kt,温度可能会忽高忽低")
elif wind_speed >= 10: elif wind_speed >= 10:
<<<<<<< HEAD
insights.append(f"🍃 <b>清劲风</b>:空气流动快,虽然有助于散热,但在升温期可能带来暖平流加速。") insights.append(f"🍃 <b>清劲风</b>:空气流动快,虽然有助于散热,但在升温期可能带来暖平流加速。")
# 4. 云层遮挡分析 (仅在升温期/峰值期有意义) # 4. 云层遮挡分析 (仅在升温期/峰值期有意义)
@@ -147,10 +367,28 @@ def analyze_weather_trend(weather_data, temp_symbol):
elif cover in ["SKC", "CLR", "FEW"]: elif cover in ["SKC", "CLR", "FEW"]:
if not is_peak_passed: if not is_peak_passed:
insights.append(f"☀️ <b>晴空万里</b>:日照强烈,无云层遮挡,气温有冲向预报上限甚至超出的动能。") insights.append(f"☀️ <b>晴空万里</b>:日照强烈,无云层遮挡,气温有冲向预报上限甚至超出的动能。")
=======
insights.append(f"🍃 <b>有风</b>:风速适中 ({wind_speed}kt),会加速空气流动,具体影响看风向。")
# 4. 云层遮挡分析 (仅在升温期/峰值期有意义)
clouds = metar.get("current", {}).get("clouds", [])
if clouds and not is_peak_passed:
main_cloud = clouds[-1]
cover = main_cloud.get("cover", "")
if cover == "OVC":
insights.append(f"☁️ <b>阴天</b>:天完全被云盖住了,太阳照不进来,温度很难再往上涨了。")
elif cover == "BKN":
insights.append(f"🌥️ <b>云比较多</b>:天空大部分被云挡住了,日照不足,升温会比较慢。")
elif cover in ["SKC", "CLR", "FEW"]:
insights.append(f"☀️ <b>大晴天</b>:阳光直射,没什么云,有利于温度继续往上冲。")
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
# 5. 特殊天气现象 # 5. 特殊天气现象
wx_desc = metar.get("current", {}).get("wx_desc") wx_desc = metar.get("current", {}).get("wx_desc")
has_mgm = bool(mgm.get("current"))
mgm_rain = mgm.get("current", {}).get("rain_24h")
if wx_desc: if wx_desc:
<<<<<<< HEAD
if any(x in wx_desc.upper() for x in ["RA", "DZ", "RAIN", "DRIZZLE"]): if any(x in wx_desc.upper() for x in ["RA", "DZ", "RAIN", "DRIZZLE"]):
insights.append(f"🌧️ <b>降雨压制</b>:当前有降雨,蒸发吸热将显著抑制升温。") insights.append(f"🌧️ <b>降雨压制</b>:当前有降雨,蒸发吸热将显著抑制升温。")
elif any(x in wx_desc.upper() for x in ["SN", "SNOW", "GR", "GS"]): elif any(x in wx_desc.upper() for x in ["SN", "SNOW", "GR", "GS"]):
@@ -174,16 +412,89 @@ def analyze_weather_trend(weather_data, temp_symbol):
insights.append(f"🔥 <b>偏南风</b>:正从低纬度输送暖平流,气温仍有向上突围的潜力。") insights.append(f"🔥 <b>偏南风</b>:正从低纬度输送暖平流,气温仍有向上突围的潜力。")
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
=======
wx_upper = wx_desc.upper().strip()
wx_tokens = wx_upper.split()
# 用分词匹配,避免 "METAR" 中的 "RA" 误判
rain_codes = {"RA", "DZ", "-RA", "+RA", "-DZ", "+DZ", "TSRA", "SHRA", "FZRA", "RAIN", "DRIZZLE"}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN", "SNOW"}
fog_codes = {"FG", "BR", "HZ", "MIST", "FOG", "FZFG"}
if rain_codes & set(wx_tokens):
if has_mgm and mgm_rain and mgm_rain > 0:
insights.append(f"🌧️ <b>在下雨</b>:已累计 {mgm_rain}mm,雨水蒸发会吸收热量,温度很难涨上去。")
else:
insights.append(f"🌧️ <b>在下雨</b>METAR 探测到降水,雨水蒸发会吸收热量,升温会受阻。")
elif snow_codes & set(wx_tokens):
insights.append(f"❄️ <b>在下雪/冰雹</b>:温度会一直低迷。")
elif fog_codes & set(wx_tokens):
insights.append(f"🌫️ <b>有雾/霾</b>:阳光被挡住了,湿度也高,升温会很慢。")
# 6. 风向分析(始终显示,风向是重要参考信息)
try:
# 优先 METAR,回退 MGM
metar_wind = metar.get("current", {}).get("wind_dir")
mgm_wind = mgm.get("current", {}).get("wind_dir")
if metar_wind is not None:
analysis_wind = float(metar_wind)
wind_source = "METAR"
elif mgm_wind is not None:
analysis_wind = float(mgm_wind)
wind_source = "MGM"
else:
analysis_wind = None
wind_source = None
# 两源矛盾检测
if metar_wind is not None and mgm_wind is not None:
metar_f = float(metar_wind)
mgm_f = float(mgm_wind)
diff_angle = abs(metar_f - mgm_f)
if diff_angle > 180:
diff_angle = 360 - diff_angle
if diff_angle > 90:
dirs_name = ["", "东北", "", "东南", "", "西南", "西", "西北"]
m_name = dirs_name[int((metar_f + 22.5) % 360 / 45)]
g_name = dirs_name[int((mgm_f + 22.5) % 360 / 45)]
insights.append(f"⚠️ <b>风向矛盾</b>METAR 测到{m_name}风({metar_f:.0f}°)MGM 测到{g_name}风({mgm_f:.0f}°),相差较大,风向不稳定。")
if analysis_wind is not None:
wd = analysis_wind
if 315 <= wd or wd <= 45:
insights.append(f"🌬️ <b>吹北风</b>{wind_source} {wd:.0f}°):从北方来的冷空气,会压制升温。")
elif 135 <= wd <= 225:
gap_to_forecast = forecast_high - (max_so_far if max_so_far is not None else curr_temp)
if is_peak_passed and not is_breakthrough:
insights.append(f"🔥 <b>吹南风</b>{wind_source} {wd:.0f}°):南方的暖空气还在吹过来,但最热时段已过,后劲不足了。")
elif gap_to_forecast > 0.5 or is_breakthrough:
status = "温度还有继续上涨的空间" if not is_breakthrough else "可能把温度推得更高"
insights.append(f"🔥 <b>吹南风</b>{wind_source} {wd:.0f}°):南方的暖空气正在吹过来,{status}")
else:
insights.append(f"🔥 <b>吹南风</b>{wind_source} {wd:.0f}°):南方的暖空气正在吹过来,但温度已接近预报峰值。")
elif 225 < wd < 315:
if wd <= 260:
insights.append(f"🌬️ <b>吹西南风</b>{wind_source} {wd:.0f}°):带有一定暖湿气流,对升温有轻微帮助。")
elif wd >= 280:
insights.append(f"🌬️ <b>吹西北风</b>{wind_source} {wd:.0f}°):偏冷的气流,会拖慢升温。")
else:
insights.append(f"🌬️ <b>吹西风</b>{wind_source} {wd:.0f}°):对温度影响不大,主要取决于日照和云量。")
elif 45 < wd < 135:
insights.append(f"🌬️ <b>吹东风</b>{wind_source} {wd:.0f}°):对温度影响较小,主要看日照和云量。")
except (TypeError, ValueError):
pass
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
try: try:
visibility = metar.get("current", {}).get("visibility_mi") visibility = metar.get("current", {}).get("visibility_mi")
if visibility is not None: if visibility is not None:
vis_val = float(str(visibility).replace("+", "").replace("-", "")) vis_val = float(str(visibility).replace("+", "").replace("-", ""))
if vis_val < 3 and local_hour <= 11: if vis_val < 3 and local_hour <= 11:
insights.append(f"🌫️ <b>早晨低见度</b>能见度极差 ({vis_val}mi),阳光无法打透,早间升温将非常缓慢。") insights.append(f"🌫️ <b>早上能见度</b>只能看到 {vis_val} 英里远,阳光穿不透,上午升温会很慢。")
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
<<<<<<< HEAD
# 7. 模型准确度预警 (针对用户反馈的 MB 偏高问题) # 7. 模型准确度预警 (针对用户反馈的 MB 偏高问题)
if is_peak_passed and max_so_far is not None: if is_peak_passed and max_so_far is not None:
model_checks = [] model_checks = []
@@ -192,13 +503,142 @@ def analyze_weather_trend(weather_data, temp_symbol):
mb_h = mb.get("today_high") mb_h = mb.get("today_high")
if mb_h and mb_h > max_so_far + 1.5: if mb_h and mb_h > max_so_far + 1.5:
model_checks.append(f"Meteoblue ({mb_h}{temp_symbol})") model_checks.append(f"Meteoblue ({mb_h}{temp_symbol})")
=======
# 7. 模型准确度预警(使用多模型数据)
if is_peak_passed and max_so_far is not None:
model_checks = []
for m_name, m_val in mm_forecasts.items():
if m_val is not None and m_val > max_so_far + 1.5:
model_checks.append(f"{m_name} ({m_val}{temp_symbol})")
# 附加源也查一下
mb_h = mb.get("today_high")
if mb_h and mb_h > max_so_far + 1.5:
model_checks.append(f"MB ({mb_h}{temp_symbol})")
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
nws_h = nws.get("today_high") nws_h = nws.get("today_high")
if nws_h and nws_h > max_so_far + 1.5: if nws_h and nws_h > max_so_far + 1.5:
model_checks.append(f"NWS ({nws_h}{temp_symbol})") model_checks.append(f"NWS ({nws_h}{temp_symbol})")
if model_checks: if model_checks:
<<<<<<< HEAD
insights.append(f"⚠️ <b>预报偏高</b>:目前实测远低于 " + "".join(model_checks) + ",判定预报模型今日表现过度乐观。") insights.append(f"⚠️ <b>预报偏高</b>:目前实测远低于 " + "".join(model_checks) + ",判定预报模型今日表现过度乐观。")
=======
insights.append(f"⚠️ <b>预报偏高了</b>:实测远低于 " + "".join(model_checks) + ",这些模型今天报高了。")
# 8. MGM 气压分析 (仅安卡拉)
mgm_pressure = mgm.get("current", {}).get("pressure")
if mgm_pressure is not None and not is_peak_passed:
if mgm_pressure < 900:
insights.append(f"📉 <b>气压偏低</b>{mgm_pressure}hPa,可能有暖湿气流过境,有利于温度上升。")
# 9. MGM 官方最高温交叉验证
mgm_max = mgm.get("current", {}).get("mgm_max_temp")
if mgm_max is not None and max_so_far is not None:
if abs(mgm_max - max_so_far) > 1.5:
insights.append(f"📊 <b>数据差异</b>MGM 官方记录最高 {mgm_max}{temp_symbol}METAR 记录 {max_so_far}{temp_symbol},相差 {abs(mgm_max - max_so_far):.1f}°。")
# 10. 太阳辐射分析 (Open-Meteo shortwave_radiation)
hourly_rad = hourly.get("shortwave_radiation", [])
sunshine_durations = daily.get("sunshine_duration", [])
if hourly_rad and times:
# 计算今天已经过去的小时的累计辐射 vs 全天预测总辐射
today_total_rad = 0.0
today_so_far_rad = 0.0
today_peak_rad = 0.0
today_peak_hour = ""
for t_str, rad in zip(times, hourly_rad):
if t_str.startswith(local_date_str) and rad is not None:
today_total_rad += rad
hour_val = int(t_str.split("T")[1][:2])
if hour_val <= local_hour:
today_so_far_rad += rad
if rad > today_peak_rad:
today_peak_rad = rad
today_peak_hour = t_str.split("T")[1][:5]
if today_total_rad > 0:
rad_pct = today_so_far_rad / today_total_rad * 100
if not is_peak_passed and local_hour >= 8:
# 白天升温期:报告太阳能量进度
if rad_pct < 30 and local_hour >= 12:
insights.append(f"🌤️ <b>日照不足</b>:到目前为止只吸收了全天 {rad_pct:.0f}% 的太阳能量,云层可能在严重削弱日照。")
# 检测"暖平流型"高温:峰值温度出现在太阳辐射极低的时段
max_temp_time_str = metar.get("current", {}).get("max_temp_time", "")
if max_so_far is not None and max_temp_time_str:
try:
max_h = int(max_temp_time_str.split(":")[0])
# 找到最高温时段对应的辐射值
max_temp_rad = 0.0
for t_str, rad in zip(times, hourly_rad):
if t_str.startswith(local_date_str) and rad is not None:
h = int(t_str.split("T")[1][:2])
if h == max_h:
max_temp_rad = rad
break
if max_temp_rad < 50 and today_peak_rad > 200:
insights.append(
f"🌙 <b>暖平流驱动</b>:最高温出现在 {max_temp_time_str}"
f"当时太阳辐射仅 {max_temp_rad:.0f} W/m²(峰值 {today_peak_rad:.0f} W/m²),"
f"说明气温是被暖空气推高的,而不是被太阳晒热的。"
)
except (ValueError, IndexError):
pass
# 11. 入场时机信号
hours_to_peak = first_peak_h - local_hour if local_hour < first_peak_h else 0
# 综合评分:距离峰值越近 + 共识越高 + 实测越接近预报 → 越适合入场
timing_score = 0
timing_factors = []
if is_peak_passed:
timing_score += 3
timing_factors.append("最热已过")
elif hours_to_peak <= 2:
timing_score += 2
timing_factors.append(f"距峰值{hours_to_peak}h")
elif hours_to_peak <= 4:
timing_score += 1
timing_factors.append(f"距峰值{hours_to_peak}h")
else:
timing_factors.append(f"距峰值{hours_to_peak}h")
if consensus_level == "high":
timing_score += 2
timing_factors.append("模型一致")
elif consensus_level == "medium":
timing_score += 1
timing_factors.append("模型小分歧")
elif consensus_level == "low":
timing_factors.append("模型分歧大")
else:
# unknown: 数据源不足,无法评估共识
timing_factors.append("仅单源")
if max_so_far is not None and forecast_high is not None and (is_peak_passed or hours_to_peak <= 3):
gap = abs(max_so_far - forecast_high)
if gap <= 0.5:
timing_score += 2
timing_factors.append("实测≈预报")
elif gap <= 1.5:
timing_score += 1
timing_factors.append(f"{gap:.1f}°")
else:
timing_factors.append(f"{gap:.1f}°")
factors_str = "".join(timing_factors)
if timing_score >= 5:
insights.append(f"⏰ <b>入场时机:理想</b> — {factors_str}。不确定性低,适合下注。")
elif timing_score >= 3:
insights.append(f"⏰ <b>入场时机:较好</b> — {factors_str}。可以考虑小仓位入场。")
elif timing_score >= 2:
insights.append(f"⏰ <b>入场时机:谨慎</b> — {factors_str}。建议继续观察。")
else:
insights.append(f"⏰ <b>入场时机:不建议</b> — {factors_str}。不确定性大,等更多数据。")
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
if not insights: if not insights:
return "" return ""
@@ -234,10 +674,6 @@ def start_bot():
parse_mode="HTML", parse_mode="HTML",
) )
@bot.message_handler(commands=["signal", "portfolio", "status"])
def disabled_feature(message):
bot.reply_to(message, "ℹ️ 监控引擎与交易模拟功能已暂停,现仅提供天气查询服务。")
@bot.message_handler(commands=["city"]) @bot.message_handler(commands=["city"])
def get_city_info(message): def get_city_info(message):
"""查询指定城市的天气详情""" """查询指定城市的天气详情"""
@@ -269,27 +705,58 @@ def start_bot():
"atl": "atlanta", "亚特兰大": "atlanta", "atl": "atlanta", "亚特兰大": "atlanta",
"dal": "dallas", "达拉斯": "dallas", "dal": "dallas", "达拉斯": "dallas",
"la": "los angeles", "洛杉矶": "los angeles", "la": "los angeles", "洛杉矶": "los angeles",
"par": "paris", "巴黎": "paris",
} }
<<<<<<< HEAD
# 1. 第一优先级:严格全字匹配 # 1. 第一优先级:严格全字匹配
city_name = STANDARD_MAPPING.get(city_input) city_name = STANDARD_MAPPING.get(city_input)
# 2. 第二优先级:如果长度 >= 3,尝试前缀匹配 # 2. 第二优先级:如果长度 >= 3,尝试前缀匹配
if not city_name and len(city_input) >= 3: if not city_name and len(city_input) >= 3:
=======
# 支持的城市全名列表(用于模糊匹配)
SUPPORTED_CITIES = list(set(STANDARD_MAPPING.values()))
# 1. 第一优先级:严格全字匹配(别名/缩写)
city_name = STANDARD_MAPPING.get(city_input)
# 2. 第二优先级:输入本身就是城市全名
if not city_name and city_input in SUPPORTED_CITIES:
city_name = city_input
# 3. 第三优先级:前缀匹配(在别名和城市全名中搜索)
if not city_name and len(city_input) >= 2:
# 先搜别名
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
for k, v in STANDARD_MAPPING.items(): for k, v in STANDARD_MAPPING.items():
if k.startswith(city_input): if k.startswith(city_input):
city_name = v city_name = v
break break
# 再搜城市全名
if not city_name:
for full_name in SUPPORTED_CITIES:
if full_name.startswith(city_input):
city_name = full_name
break
# 3. 最终回退 # 4. 未找到 → 报错,列出支持的城市
if not city_name: if not city_name:
city_name = city_input city_list = ", ".join(sorted(set(STANDARD_MAPPING.values())))
bot.reply_to(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n"
f"支持的城市: {city_list}\n\n"
f"也可以用缩写,如 <code>/city dal</code> 查达拉斯",
parse_mode="HTML",
)
return
bot.send_message(message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据...") bot.send_message(message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据...")
coords = weather.get_coordinates(city_name) coords = weather.get_coordinates(city_name)
if not coords: if not coords:
bot.reply_to(message, f"❌ 未找到城市: {city_name}") bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
return return
weather_data = weather.fetch_all_sources(city_name, lat=coords["lat"], lon=coords["lon"]) weather_data = weather.fetch_all_sources(city_name, lat=coords["lat"], lon=coords["lon"])
@@ -360,10 +827,24 @@ def start_bot():
future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}") future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}")
msg_lines.append("📅 " + " | ".join(future_forecasts)) msg_lines.append("📅 " + " | ".join(future_forecasts))
# --- 3.5 日出日落 + 日照时长 ---
sunrises = daily.get("sunrise", [])
sunsets = daily.get("sunset", [])
sunshine_durations = daily.get("sunshine_duration", [])
if sunrises and sunsets:
sunrise_t = sunrises[0].split("T")[1][:5] if "T" in str(sunrises[0]) else sunrises[0]
sunset_t = sunsets[0].split("T")[1][:5] if "T" in str(sunsets[0]) else sunsets[0]
sun_line = f"🌅 日出 {sunrise_t} | 🌇 日落 {sunset_t}"
if sunshine_durations:
sunshine_hours = sunshine_durations[0] / 3600 # 秒 -> 小时
sun_line += f" | ☀️ 日照 {sunshine_hours:.1f}h"
msg_lines.append(sun_line)
# --- 4. 核心 实测区 (合并 METAR 和 MGM) --- # --- 4. 核心 实测区 (合并 METAR 和 MGM) ---
# 基础数据优先用 METAR # 基础数据优先用 METAR
cur_temp = metar.get("current", {}).get("temp") if metar else mgm.get("current", {}).get("temp") cur_temp = metar.get("current", {}).get("temp") if metar else mgm.get("current", {}).get("temp")
max_p = metar.get("current", {}).get("max_temp_so_far") if metar else None max_p = metar.get("current", {}).get("max_temp_so_far") if metar else None
max_p_time = metar.get("current", {}).get("max_temp_time") if metar else None
obs_t_str = "N/A" obs_t_str = "N/A"
main_source = "METAR" if metar else "MGM" main_source = "METAR" if metar else "MGM"
@@ -394,7 +875,63 @@ def start_bot():
m_time = m_time.split(" ")[1][:5] m_time = m_time.split(" ")[1][:5]
obs_t_str = m_time obs_t_str = m_time
msg_lines.append(f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>" + (f" (最高: {max_p}{temp_symbol})" if max_p else "") + f" | {obs_t_str}") max_str = ""
if max_p is not None:
settled_val = round(max_p)
max_str = f" (最高: {max_p}{temp_symbol}"
if max_p_time:
max_str += f" @{max_p_time}"
max_str += f" → WU {settled_val}{temp_symbol})"
# --- 天气状况总结 ---
wx_summary = ""
# 优先使用 METAR 天气现象
metar_wx = metar.get("current", {}).get("wx_desc", "") if metar else ""
metar_clouds = metar.get("current", {}).get("clouds", []) if metar else []
mgm_cloud = mgm.get("current", {}).get("cloud_cover") if mgm else None
if metar_wx:
wx_upper = metar_wx.upper().strip()
wx_tokens = set(wx_upper.split())
rain_codes = {"RA", "DZ", "-RA", "+RA", "-DZ", "+DZ", "TSRA", "SHRA", "FZRA"}
snow_codes = {"SN", "GR", "GS", "-SN", "+SN", "BLSN"}
fog_codes = {"FG", "BR", "HZ", "FZFG"}
ts_codes = {"TS", "TSRA"}
if ts_codes & wx_tokens:
wx_summary = "⛈️ 雷暴"
elif {"+RA", "+SN"} & wx_tokens:
wx_summary = "🌧️ 大雨" if "+RA" in wx_tokens else "❄️ 大雪"
elif rain_codes & wx_tokens:
wx_summary = "🌧️ 小雨" if {"-RA", "-DZ", "DZ"} & wx_tokens else "🌧️ 下雨"
elif snow_codes & wx_tokens:
wx_summary = "❄️ 下雪"
elif fog_codes & wx_tokens:
wx_summary = "🌫️ 雾/霾"
# 如果 METAR 没有特殊现象,用云量推断
if not wx_summary:
# 优先 METAR 云层,回退 MGM
cover_code = ""
if metar_clouds:
cover_code = metar_clouds[-1].get("cover", "")
if cover_code in ("SKC", "CLR") or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 1):
wx_summary = "☀️ 晴"
elif cover_code == "FEW" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 2):
wx_summary = "🌤️ 晴间少云"
elif cover_code == "SCT" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 4):
wx_summary = "⛅ 晴间多云"
elif cover_code == "BKN" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 6):
wx_summary = "🌥️ 多云"
elif cover_code == "OVC" or (cover_code == "" and mgm_cloud is not None and mgm_cloud <= 8):
wx_summary = "☁️ 阴天"
elif mgm_cloud is not None:
# 纯数字回退
cloud_names = {0: "☀️ 晴", 1: "🌤️ 晴", 2: "🌤️ 少云", 3: "⛅ 散云", 4: "⛅ 散云", 5: "🌥️ 多云", 6: "🌥️ 多云", 7: "☁️ 阴", 8: "☁️ 阴天"}
wx_summary = cloud_names.get(mgm_cloud, "")
wx_display = f" {wx_summary}" if wx_summary else ""
msg_lines.append(f"\n✈️ <b>实测 ({main_source}): {cur_temp}{temp_symbol}</b>{max_str} |{wx_display} | {obs_t_str}")
if mgm: if mgm:
m_c = mgm.get("current", {}) m_c = mgm.get("current", {})
@@ -406,7 +943,23 @@ def start_bot():
dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + "" dir_str = dirs[int((float(wind_dir) + 22.5) % 360 / 45)] + ""
msg_lines.append(f" [MGM] 🌡️ 体感: {m_c.get('feels_like')}°C | 💧 {m_c.get('humidity')}%") msg_lines.append(f" [MGM] 🌡️ 体感: {m_c.get('feels_like')}°C | 💧 {m_c.get('humidity')}%")
msg_lines.append(f" [MGM] 🌬️ {dir_str}{wind_dir}° ({m_c.get('wind_speed_ms')} m/s) | 🌧️ {m_c.get('rain_24h') or 0}mm") msg_lines.append(f" [MGM] 🌬️ {dir_str}{wind_dir}° ({m_c.get('wind_speed_ms')} m/s) | 💧 降水: {m_c.get('rain_24h') or 0}mm")
# 新增:气压和云量
extra_parts = []
pressure = m_c.get("pressure")
if pressure is not None:
extra_parts.append(f"🌡 气压: {pressure}hPa")
cloud_cover = m_c.get("cloud_cover")
if cloud_cover is not None:
cloud_desc_map = {0: "晴朗", 1: "少云", 2: "少云", 3: "散云", 4: "散云", 5: "多云", 6: "多云", 7: "很多云", 8: "阴天"}
cloud_text = cloud_desc_map.get(cloud_cover, f"{cloud_cover}/8")
extra_parts.append(f"☁️ 云量: {cloud_text}({cloud_cover}/8)")
mgm_max = m_c.get("mgm_max_temp")
if mgm_max is not None:
extra_parts.append(f"🌡️ MGM最高: {mgm_max}°C")
if extra_parts:
msg_lines.append(f" [MGM] {' | '.join(extra_parts)}")
if metar: if metar:
m_c = metar.get("current", {}) m_c = metar.get("current", {})
-23
View File
@@ -1,23 +0,0 @@
import requests
import json
import time
def test_mgm(istno):
# 添加时间戳防止缓存
url = f"https://servis.mgm.gov.tr/web/sondurumlar?istno={istno}&_={int(time.time()*1000)}"
headers = {
"Origin": "https://www.mgm.gov.tr",
"User-Agent": "Mozilla/5.0"
}
try:
resp = requests.get(url, headers=headers)
if resp.status_code == 200:
data = resp.json()
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print(f"Error: {resp.status_code}")
except Exception as e:
print(f"Exception: {e}")
print("--- Station 17128 (Esenboğa) ---")
test_mgm(17128)
+16 -65
View File
@@ -1,68 +1,25 @@
# API Configuration # Weather API Configuration
api: weather:
polymarket: meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
base_url: "https://clob.polymarket.com" timeout: 30
ws_url: "wss://ws-subscriptions-clob.polymarket.com/ws/market"
timeout: 30
retry_attempts: 3
api_key: "019c2d40-5d23-75a6-ab33-02ae5d2a033e"
weather: # Target Cities
openweather: cities:
base_url: "https://api.openweathermap.org/data/2.5"
timeout: 10
wunderground:
base_url: "https://api.weather.com/v3"
timeout: 10
visualcrossing:
base_url: "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services"
timeout: 10
# Trading Parameters
trading:
min_confidence: 0.65 # Minimum model confidence to trade
max_single_trade: 500 # Maximum single trade amount ($)
max_position_ratio: 0.25 # Maximum position as ratio of capital
max_total_exposure: 0.80 # Maximum total exposure
min_trade_size: 10 # Minimum trade size ($)
# Risk Management
risk:
max_drawdown: 0.10 # Maximum allowed drawdown (10%)
stop_loss: 0.15 # Stop loss threshold (15%)
take_profit: 0.30 # Take profit threshold (30%)
min_liquidity: 1000 # Minimum market liquidity ($)
max_slippage: 0.02 # Maximum acceptable slippage (2%)
# Analysis Parameters
analysis:
volume_threshold: 2.0 # Volume spike threshold (std dev)
large_order_threshold: 1000 # Large order detection threshold ($)
rsi_period: 14 # RSI calculation period
bollinger_period: 20 # Bollinger Bands period
bollinger_std: 2.0 # Bollinger Bands standard deviation
# Model Weights (for multi-factor decision)
weights:
statistical_prediction: 0.50
data_source_consensus: 0.15
market_volume_signal: 0.15
orderbook_analysis: 0.10
technical_indicators: 0.05
onchain_whale_signal: 0.05
# Target Markets
markets:
- id: "ankara"
city: "Ankara"
country: "Turkey"
latitude: 39.9334
longitude: 32.8597
- id: "london" - id: "london"
city: "London" city: "London"
country: "UK" country: "UK"
latitude: 51.5074 latitude: 51.5074
longitude: -0.1278 longitude: -0.1278
- id: "paris"
city: "Paris"
country: "France"
latitude: 48.8566
longitude: 2.3522
- id: "ankara"
city: "Ankara"
country: "Turkey"
latitude: 39.9334
longitude: 32.8597
- id: "new_york" - id: "new_york"
city: "New York" city: "New York"
country: "USA" country: "USA"
@@ -79,9 +36,3 @@ logging:
level: "INFO" level: "INFO"
rotation: "10 MB" rotation: "10 MB"
retention: "10 days" retention: "10 days"
# Scheduler
scheduler:
data_refresh_interval: 60 # seconds
model_update_interval: 300 # seconds
risk_check_interval: 30 # seconds
-200
View File
@@ -1,200 +0,0 @@
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from datetime import datetime, timedelta
import sys
sys.path.insert(0, '..')
st.set_page_config(
page_title="Polymarket Trading Dashboard",
page_icon="📊",
layout="wide"
)
# Custom CSS
st.markdown("""
<style>
.stMetric {
background-color: #1e1e1e;
padding: 15px;
border-radius: 10px;
}
.stMetric label {
color: #888;
}
.stMetric [data-testid="stMetricValue"] {
color: #00ff88;
}
</style>
""", unsafe_allow_html=True)
# Header
st.title("📊 Polymarket Trading Dashboard")
st.markdown("---")
# Sidebar
with st.sidebar:
st.header("⚙️ Settings")
market_id = st.text_input("Market ID", "weather-ankara-temperature")
refresh_rate = st.slider("Refresh Rate (seconds)", 10, 300, 60)
st.markdown("---")
st.header("📈 Quick Stats")
st.metric("Total PnL", "$0.00", "+0%")
st.metric("Open Positions", "0")
st.metric("Win Rate", "N/A")
# Main content
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric(
label="Current Price",
value="$0.92",
delta="+2.3%"
)
with col2:
st.metric(
label="Model Prediction",
value="7.2°C",
delta="+0.5°C"
)
with col3:
st.metric(
label="Confidence Score",
value="0.78",
delta="+0.05"
)
with col4:
st.metric(
label="Signal",
value="BUY",
delta="Strong"
)
st.markdown("---")
# Charts
col_left, col_right = st.columns(2)
with col_left:
st.subheader("📉 Price History")
# Demo price data
dates = pd.date_range(start=datetime.now() - timedelta(days=7), periods=168, freq='H')
prices = [0.85 + i * 0.0005 + (i % 24) * 0.001 for i in range(168)]
df_prices = pd.DataFrame({
'Date': dates,
'Price': prices
})
fig_price = px.line(df_prices, x='Date', y='Price',
template='plotly_dark',
color_discrete_sequence=['#00ff88'])
fig_price.update_layout(
height=300,
margin=dict(l=0, r=0, t=0, b=0)
)
st.plotly_chart(fig_price, use_container_width=True)
with col_right:
st.subheader("🌡️ Temperature Forecast")
# Demo temperature data
forecast_dates = pd.date_range(start=datetime.now(), periods=72, freq='H')
temps = [5 + (i % 24) * 0.3 + (i // 24) * 0.5 for i in range(72)]
df_temp = pd.DataFrame({
'Date': forecast_dates,
'Temperature': temps
})
fig_temp = px.line(df_temp, x='Date', y='Temperature',
template='plotly_dark',
color_discrete_sequence=['#ff6b6b'])
fig_temp.update_layout(
height=300,
margin=dict(l=0, r=0, t=0, b=0)
)
st.plotly_chart(fig_temp, use_container_width=True)
st.markdown("---")
# Decision Factors
st.subheader("🎯 Decision Factors")
factors_col1, factors_col2 = st.columns(2)
with factors_col1:
# Factor scores
factors = {
'Statistical Prediction': 0.85,
'Data Consensus': 0.90,
'Volume Signal': 0.65,
'Orderbook Analysis': 0.72,
'Technical Indicators': 0.58,
'Whale Signal': 0.45
}
fig_factors = go.Figure(go.Bar(
x=list(factors.values()),
y=list(factors.keys()),
orientation='h',
marker_color=['#00ff88' if v > 0.65 else '#ffaa00' if v > 0.4 else '#ff6b6b'
for v in factors.values()]
))
fig_factors.update_layout(
template='plotly_dark',
height=250,
margin=dict(l=0, r=0, t=0, b=0),
xaxis_title="Score",
xaxis_range=[0, 1]
)
st.plotly_chart(fig_factors, use_container_width=True)
with factors_col2:
# Order book visualization
st.markdown("**📚 Order Book**")
bids = [
{"price": 0.91, "size": 500},
{"price": 0.90, "size": 800},
{"price": 0.89, "size": 1200},
]
asks = [
{"price": 0.93, "size": 600},
{"price": 0.94, "size": 400},
{"price": 0.95, "size": 900},
]
orderbook_df = pd.DataFrame({
'Bid Price': [b['price'] for b in bids],
'Bid Size': [b['size'] for b in bids],
'Ask Price': [a['price'] for a in asks],
'Ask Size': [a['size'] for a in asks]
})
st.dataframe(orderbook_df, use_container_width=True, hide_index=True)
st.markdown("---")
# Recent Trades
st.subheader("📝 Recent Trades")
trades_df = pd.DataFrame({
'Time': ['10:30:15', '10:28:42', '10:25:11'],
'Side': ['BUY', 'BUY', 'SELL'],
'Price': ['$0.92', '$0.91', '$0.88'],
'Amount': ['$100', '$150', '$75'],
'Status': ['✅ Filled', '✅ Filled', '✅ Filled']
})
st.dataframe(trades_df, use_container_width=True, hide_index=True)
# Footer
st.markdown("---")
st.markdown("*Last updated: " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "*")
-865
View File
@@ -1,865 +0,0 @@
import sys
import time
import os
import json
import re
from datetime import datetime, timedelta
from loguru import logger
from src.utils.config_loader import load_config
from src.utils.logger import setup_logger
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.whale_tracker import WhaleTracker
from src.strategy.decision_engine import DecisionEngine
from src.strategy.risk_manager import RiskManager
from src.trading.paper_trader import PaperTrader
from src.utils.notifier import TelegramNotifier
def main():
# 1. 初始化配置与日志
config_data = load_config()
setup_logger(config_data.get("app", {}).get("log_level", "INFO"))
logger.info("🌟 PolyWeather 监控引擎启动中...")
# 2. 初始化核心组件
polymarket = PolymarketClient(config_data["polymarket"])
weather = WeatherDataCollector(config_data["weather"])
onchain = OnchainTracker(config_data["polymarket"], polymarket)
notifier = TelegramNotifier(config_data["telegram"])
# 3. 初始化分析与交易组件
predictor = TemperaturePredictor()
risk_manager = RiskManager(config_data.get("config", {}))
decision_engine = DecisionEngine(config_data.get("config", {}))
whale_tracker = WhaleTracker(config_data.get("config", {}), onchain)
paper_trader = PaperTrader()
# 发送启动通知
notifier._send_message(
"🚀 <b>Polymarket 天气监控系统启动成功</b>\n正在扫描 12 个核心城市的最高温市场..."
)
# 信号记忆(持久化到文件)
pushed_signals = {}
SIGNALS_FILE = "data/pushed_signals.json"
if os.path.exists(SIGNALS_FILE):
try:
with open(SIGNALS_FILE, "r", encoding="utf-8") as f:
pushed_signals = json.load(f)
logger.info(f"已加载历史推送记录,共 {len(pushed_signals)}")
except:
pushed_signals = {}
# 确保data目录存在
if not os.path.exists("data"):
os.makedirs("data")
location_cache = {}
# 价格历史追踪(用于计算趋势)
PRICE_HISTORY_FILE = "data/price_history.json"
price_history = {}
if os.path.exists(PRICE_HISTORY_FILE):
try:
with open(PRICE_HISTORY_FILE, "r", encoding="utf-8") as f:
price_history = json.load(f)
except:
price_history = {}
try:
while True:
logger.info("--- 开启新一轮全量动态监控 (自动搜寻所有天气市场) ---")
cached_signals = {}
all_markets_cache = {}
# 1. 直接从 Polymarket 获取所有天气合约
all_weather_markets = polymarket.get_weather_markets()
# 1.5 尝试通过slug获取可能遗漏的市场(如部分结算的市场)
special_slugs = []
for slug in special_slugs:
event = polymarket.get_event_by_slug(slug)
if event:
title = event.get("title", "")
logger.info(f"通过slug找到特殊事件: {title}")
# 提取城市名
city = weather.extract_city_from_question(title)
if not city:
city = "Unknown"
# 将该事件的所有市场添加到列表
for m in event.get("markets", []):
# 检查是否已存在
c_id = m.get("conditionId")
if not any(
existing.get("condition_id") == c_id
for existing in all_weather_markets
):
all_weather_markets.append(
{
"condition_id": c_id,
"question": m.get("groupItemTitle")
or m.get("question"),
"active_token_id": m.get("activeTokenId"),
"tokens": m.get("clobTokenIds"),
"prices": m.get("outcomePrices"),
"event_title": title,
"slug": slug,
"city": city, # 提前标记城市
}
)
logger.debug(f"添加特殊市场: {m.get('groupItemTitle')}")
if not all_weather_markets:
logger.warning("当前 Polymarket 似乎没有任何活跃的天气市场,等待中...")
time.sleep(300)
continue
# 2. 批量同步盘口价格 (优化:为每个档位获取其对应的真实 Token 价格)
token_price_map = {}
price_requests = []
for m in all_weather_markets:
ts = m.get("tokens", [])
if isinstance(ts, str):
try:
ts = json.loads(ts)
except:
ts = []
active_tid = m.get("active_token_id")
# 智能识别买入/买否 Token
if active_tid and isinstance(ts, list):
# 获取该档位的买入价 (Ask)
price_requests.append({"token_id": active_tid, "side": "ask"})
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)} 个档位的真实盘口价格...")
token_price_map = polymarket.get_multiple_prices(price_requests)
logger.info(f"价格同步完成,成功获取 {len(token_price_map)} 个实时报价")
# 3. 按城市分组(按condition_id去重)
markets_by_city = {}
seen_condition_ids = set() # Initialize seen_condition_ids here
for i, m in enumerate(all_weather_markets):
# 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", [])
if isinstance(ts, str):
try:
ts = json.loads(ts)
except:
ts = []
active_tid = m.get("active_token_id")
if active_tid and isinstance(ts, list):
m["buy_yes_live"] = token_price_map.get(f"{active_tid}: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', '')} {m.get('slug', '')}"
city = weather.extract_city_from_question(full_context)
if i < 5:
logger.debug(
f"分析合约 {i}: City='{city}' | Title='{m.get('event_title')}"
)
if not city:
continue
if city not in markets_by_city:
markets_by_city[city] = []
markets_by_city[city].append(m)
logger.info(
f"动态发现 {len(markets_by_city)} 个受监控城市,共 {len(all_weather_markets)} 个合约"
)
# 3. 逐个城市分析
for city, city_markets in markets_by_city.items():
try:
# 获取/缓存坐标
if city not in location_cache:
coords = weather.get_coordinates(city)
if not coords:
continue
location_cache[city] = coords
logger.info(
f"📍 城市定位成功: {city} -> ({coords['lat']}, {coords['lon']})"
)
loc = location_cache[city]
# A. 获取实时天气共识
weather_data = weather.fetch_all_sources(
city, lat=loc["lat"], lon=loc["lon"]
)
consensus = weather.check_consensus(weather_data)
if not consensus.get("consensus"):
continue
temp_unit = weather_data.get("open-meteo", {}).get(
"unit", "celsius"
)
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
logger.info(
f"☁️ {city} 当前气温: {consensus['average_temp']}{temp_symbol} (unit={temp_unit}) | 监控合约: {len(city_markets)}"
)
# --- 本城市汇总预警缓存 ---
city_alerts = []
city_local_time = None
city_total_vol = 0
city_pred_high = None
city_target_date = None
city_strategy_tips = []
# B. 遍历该城市所有合约
for market in city_markets:
market_id = market.get("condition_id")
question = market.get("question", "未知市场")
event_title = market.get("event_title", "")
# 累计城市总成交量
vol_raw = market.get("volume", 0)
if isinstance(vol_raw, str):
try:
vol_raw = float(
vol_raw.replace("$", "").replace(",", "")
)
except:
vol_raw = 0
city_total_vol += vol_raw
# 识别该合约的目标日期
target_date = weather.extract_date_from_title(
event_title
) or weather.extract_date_from_title(question)
ref_temp = consensus["average_temp"]
if target_date:
daily_data = weather_data.get("open-meteo", {}).get(
"daily", {}
)
if daily_data:
dates = daily_data.get("time", [])
max_temps = daily_data.get("temperature_2m_max", [])
for idx, d_str in enumerate(dates):
if target_date == d_str:
ref_temp = max_temps[idx]
break
# --- 价格获取逻辑 (增强版) ---
# 使用 token_price_map 获取实时数据
active_tid = market.get("active_token_id")
ts = market.get("tokens", [])
if isinstance(ts, str):
ts = json.loads(ts)
buy_yes_price = None
buy_no_price = None
bid_yes_price = None
if len(ts) == 2:
# 传统二选一市场 (Yes/No Token 独立)
buy_yes_price = token_price_map.get(f"{ts[0]}:ask")
buy_no_price = token_price_map.get(f"{ts[1]}:ask")
bid_yes_price = token_price_map.get(f"{ts[0]}:bid")
elif active_tid:
# 多选一市场 (单 Token 对应一个档位)
buy_yes_price = token_price_map.get(f"{active_tid}:ask")
bid_yes_price = token_price_map.get(f"{active_tid}:bid")
if bid_yes_price is not None:
buy_no_price = 1.0 - bid_yes_price
# 兜底概率计算
current_prob = (
(buy_yes_price + bid_yes_price) / 2
if (buy_yes_price and bid_yes_price)
else (buy_yes_price or 0.5)
)
if buy_no_price is None:
buy_no_price = 1.0 - current_prob
# 计算价格趋势
prev_data = price_history.get(market_id, {})
prev_prob = prev_data.get("price", current_prob)
prob_change = (current_prob - prev_prob) * 100
trend_str = (
f"{abs(prob_change):.0f}%"
if prob_change > 0.5
else (
f"{abs(prob_change):.0f}%"
if prob_change < -0.5
else ""
)
)
# 更新历史缓存
price_history[market_id] = {
"price": current_prob,
"timestamp": datetime.now().isoformat(),
}
# --- 预警收集 (自动推送逻辑) ---
# 严格触发条件: 价格必须处于 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:
# 获取温度符号(在此处定义以便后续使用)
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 # 记录到城市概览
temp_match = re.search(
r"(\d+)(?:-(\d+))?°[FC]", question
)
if temp_match:
low_b = int(temp_match.group(1))
high_b = (
int(temp_match.group(2))
if temp_match.group(2)
else low_b
)
diff = ref_temp - ((low_b + high_b) / 2)
# 偏差信息将在后面构建 msg 时统一添加
# 生成策略建议:仅保留模型一致提示
if abs(diff) < 2 and current_prob > 0.7:
city_strategy_tips.append(
f"预测温度{ref_temp}{temp_symbol}落在{question}区间,市场与模型一致"
)
# 模拟下单 - 使用 Ask 价格(实际可成交价格)
if buy_yes_price and buy_yes_price > 0.5:
trigger_side = "Buy Yes"
trigger_price = int(buy_yes_price * 100)
else:
trigger_side = "Buy No"
trigger_price = (
int(buy_no_price * 100)
if buy_no_price
else int((1 - current_prob) * 100)
)
# 构建预测文本
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"
)
msg = f"{question} ({target_date}): {side_display} {trigger_price}¢ | 预测:{forecast_text}"
success = paper_trader.open_position(
market_id=market_id,
city=city,
option=question,
price=trigger_price,
side="YES" if trigger_side == "Buy Yes" else "NO",
amount_usd=5.0,
target_date=target_date,
predicted_temp=ref_temp,
)
# 添加模拟交易标签
if success:
msg += " [🛒 $5.0 💡试探]"
city_alerts.append(
{
"market": target_date or "今日",
"msg": msg,
"bought": success,
"amount": 5.0,
"confidence": "💡试探",
}
)
pushed_signals[alert_key] = time.time()
if target_date:
city_target_date = target_date
# C. 准备缓存数据
temp_unit = weather_data.get("open-meteo", {}).get(
"unit", "celsius"
)
temp_symbol = "°F" if temp_unit == "fahrenheit" else "°C"
city_local_time = (
weather_data.get("open-meteo", {})
.get("current", {})
.get("local_time")
)
current_price = buy_yes_price if buy_yes_price else 0.5
# 计算价格趋势
prev_data = price_history.get(market_id, {})
prev_price = prev_data.get("price", current_price)
price_change_pct = (
((current_price - prev_price) / prev_price * 100)
if prev_price > 0
else 0
)
# 更新价格历史缓存
price_history[market_id] = {
"price": current_price,
"timestamp": datetime.now().isoformat(),
}
cache_entry = {
"city": city,
"full_title": event_title,
"option": question,
"prediction": f"{ref_temp}{temp_symbol}",
"price": int(current_price * 100),
"buy_yes": int(buy_yes_price * 100) if buy_yes_price else 0,
"buy_no": int(buy_no_price * 100) if buy_no_price else 0,
"url": f"https://polymarket.com/event/{market.get('slug')}",
"local_time": city_local_time,
"target_date": target_date,
"score": 0,
"rationale": "ACTIVE",
"trend": round(price_change_pct, 1),
}
# --- 最终过滤器 (拦截垃圾信号) ---
# 1. 过滤已锁定价格 (>= 98.5c)
if (buy_yes_price and buy_yes_price >= 0.985) or (
buy_no_price and buy_no_price >= 0.985
):
cache_entry["rationale"] = "ENDED"
all_markets_cache[market_id] = cache_entry
continue
# 2. 过滤已过期日期 (动态获取当前日期)
current_today = datetime.now().strftime("%Y-%m-%d")
if target_date and target_date < current_today:
cache_entry["rationale"] = "EXPIRED"
all_markets_cache[market_id] = cache_entry
continue
# 3. 评分计算
try:
signal = decision_engine.calculate_signal(
model_prediction=predictor.predict_ensemble([ref_temp]),
market_data={
"orderbook": {},
"price_history": [current_price],
"transactions": [],
},
weather_consensus={"average_temp": ref_temp},
whale_activity=None,
)
cache_entry["score"] = signal.get("final_score", 0)
cache_entry["rationale"] = signal.get(
"recommendation", "ACTIVE"
)
except Exception as e:
logger.error(f"计算信号失败 [{market_id}]: {e}")
cache_entry["score"] = 0
cache_entry["rationale"] = "ERROR"
all_markets_cache[market_id] = cache_entry
# --- 预警收集 (自动推送逻辑) ---
if (buy_yes_price and 0.85 <= buy_yes_price <= 0.95) or (
buy_no_price and 0.85 <= buy_no_price <= 0.95
):
alert_key = f"alert_{market_id}_range_85_95"
if alert_key not in pushed_signals:
# --- 基础参数识别 ---
is_categorical = len(ts) > 2 and active_tid
if is_categorical:
# 语义转换逻辑保持一致
if buy_no_price and buy_no_price >= 0.85:
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)
else:
trigger_side = (
"Buy Yes" if buy_yes_price >= 0.85 else "Buy No"
)
trigger_price = (
int(buy_yes_price * 100)
if trigger_side == "Buy Yes"
else int(buy_no_price * 100)
)
# --- 智能动态仓位计算 ---
# 1. 获取 Open-Meteo 对目标日期的最高温预测
predicted_high = None
weather_supports = False
daily_data = weather_data.get("open-meteo", {}).get(
"daily", {}
)
if daily_data and target_date:
dates = daily_data.get("time", [])
max_temps = daily_data.get("temperature_2m_max", [])
for idx, d_str in enumerate(dates):
if target_date == d_str and idx < len(
max_temps
):
predicted_high = max_temps[idx]
break
# 2. 判断天气预测是否支持当前方向
if predicted_high is not None:
# 解析选项的温度范围 (例如 "40-41°F" 或 "32°F or below")
temp_match = re.search(
r"(\d+)(?:-(\d+))?°[FC]", question
)
if temp_match:
low_bound = int(temp_match.group(1))
high_bound = (
int(temp_match.group(2))
if temp_match.group(2)
else low_bound
)
# 如果买 NO,天气预测应该在这个区间之外
if trigger_side == "Buy No":
weather_supports = (
predicted_high < low_bound - 2
) or (predicted_high > high_bound + 2)
else: # 买 YES
weather_supports = (
low_bound - 2
<= predicted_high
<= high_bound + 2
)
# 3. 获取成交量信息
market_volume = market.get("volume", 0)
if isinstance(market_volume, str):
try:
market_volume = float(
market_volume.replace("$", "").replace(
",", ""
)
)
except:
market_volume = 0
high_volume = market_volume >= 5000 # $5000+ 算高成交量
# --- Pro 级仓位决策系统 ---
# 1. 计算离结算剩余小时数 (假设气温市场在目标日期晚上 23:59 结算)
hours_to_settle = 24.0
if target_date:
try:
settle_dt = datetime.strptime(
f"{target_date} 23:59:59",
"%Y-%m-%d %H:%M:%S",
)
now_utc = datetime.utcnow()
diff = settle_dt - now_utc
hours_to_settle = diff.total_seconds() / 3600.0
except:
pass
# 2. 计算相对成交量比例
total_daily_vol = sum(
[
float(
str(m.get("volume", 0))
.replace("$", "")
.replace(",", "")
)
for m in city_markets
if (
weather.extract_date_from_title(
m.get("event_title", "")
)
or weather.extract_date_from_title(
m.get("question", "")
)
)
== target_date
]
)
market_vol = float(
str(market.get("volume", 0))
.replace("$", "")
.replace(",", "")
)
is_rel_high_vol = (
(market_vol / total_daily_vol > 0.3)
if total_daily_vol > 0
else False
)
# 3. 基础意向仓位 (基于置信度)
base_pos = 3.0 # 默认探路
confidence_tag = "💡试探"
if (
trigger_price >= 90
and weather_supports
and high_volume
):
base_pos, confidence_tag = 10.0, "🔥高置信"
elif trigger_price >= 90 and weather_supports:
base_pos, confidence_tag = 7.0, "⭐中置信"
elif trigger_price >= 92:
base_pos, confidence_tag = 5.0, "📌价格锁定"
# 4. 仓位决策
amount_usd, risk_reason = (
risk_manager.calculate_position_size(
base_confidence_usd=base_pos,
hours_to_settle=hours_to_settle,
is_high_relative_volume=is_rel_high_vol,
)
)
logger.info(
f"【Pro仓位】{city} {question} | "
f"基础:{base_pos}$ -> 最终:{amount_usd}$ | 原因:{risk_reason} | "
f"剩:{hours_to_settle:.1f}h"
)
# --- 模拟交易触发逻辑 ---
if amount_usd > 0:
side = "YES" if trigger_side == "Buy Yes" else "NO"
success = paper_trader.open_position(
market_id=market_id,
city=city,
option=question,
price=trigger_price,
side=side,
amount_usd=amount_usd,
target_date=target_date,
predicted_temp=predicted_high,
)
if success:
risk_manager.record_trade(amount_usd)
else:
# 如果被风控拦截(金额为0),则不进行任何推送,避免刷屏
success = False
logger.info(
f"Skipping alert for {question}: {risk_reason}"
)
continue
# 构建预测温度显示文本
temp_unit = weather_data.get("open-meteo", {}).get(
"unit", "celsius"
)
temp_symbol = (
"°F" if temp_unit == "fahrenheit" else "°C"
)
forecast_text = (
f"{predicted_high}{temp_symbol}"
if predicted_high
else "N/A"
)
# 构建简约版消息: ⚡ {question} ({date}): {side} {price}¢ | 预测:{forecast} [🛒 ${amount} {tag}]
side_display = trigger_side
msg = (
f"{question} ({target_date}): {side_display} {trigger_price}¢ | "
f"预测:{forecast_text} [🛒 ${amount_usd} {confidence_tag}]"
)
city_alerts.append(
{
"type": "price",
"market": f"{target_date or '今日'}",
"msg": msg,
"bought": success,
"amount": amount_usd,
"confidence": confidence_tag,
}
)
pushed_signals[alert_key] = time.time()
# 3. 信号暂存
cached_signals[market_id] = cache_entry
# E. 统一发送城市汇总通知 (使用新 Pro 模板)
if city_alerts:
# 去重策略建议
unique_tips = list(dict.fromkeys(city_strategy_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}")
# --- 每处理完一个城市,立即更新 JSON 文件 ---
try:
# --- 周期性结算:保存高价值信号 ---
active_signals = []
for mid, entry in all_markets_cache.items():
# 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)
with open("data/active_signals.json", "w", encoding="utf-8") as f:
json.dump(active_signals, f, ensure_ascii=False, indent=4)
logger.info(
f"已更新活跃信号库,包含 {len(active_signals)} 个有效信号。"
)
# 2. 更新全量市场缓存
try:
with open("data/all_markets.json", "r", encoding="utf-8") as f:
existing_markets = json.load(f)
except:
existing_markets = {}
existing_markets.update(all_markets_cache)
# 清理过期日期
today_str = datetime.now().strftime("%Y-%m-%d")
cleaned_markets = {}
for k, v in existing_markets.items():
t_date = v.get("target_date")
if not t_date or t_date >= today_str:
cleaned_markets[k] = v
with open("data/all_markets.json", "w", encoding="utf-8") as f:
json.dump(cleaned_markets, f, ensure_ascii=False, indent=2)
# 3. 保存推送记录
with open("data/pushed_signals.json", "w", encoding="utf-8") as f:
json.dump(pushed_signals, f, ensure_ascii=False)
# 3.5 保存价格历史(用于趋势计算)
with open(PRICE_HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(price_history, f, ensure_ascii=False)
# --- 4. 更新模拟仓位盈亏 ---
price_snapshot = {}
for mid, entry in all_markets_cache.items():
price_snapshot[mid] = {"price": entry["price"]}
paper_trader.update_pnl(price_snapshot)
# --- 5. 每日收益总结推送 (北京时间 23:55 - 00:05 之间发送) ---
now_bj = datetime.utcnow() + timedelta(hours=8)
if now_bj.hour == 23 and now_bj.minute >= 50:
summary_key = f"daily_pnl_{now_bj.strftime('%Y%m%d')}"
if summary_key not in pushed_signals:
# 构造总结消息
total_cost = 0
total_pnl = 0
data = paper_trader._load_data()
pos_list = data.get("positions", {})
if pos_list:
report = [
f"📊 <b>每日模拟仓结算总结 ({now_bj.strftime('%Y-%m-%d')})</b>\n"
+ "" * 15
]
for p in pos_list.values():
if p["status"] == "OPEN":
total_cost += p["cost_usd"]
total_pnl += p.get("pnl_usd", 0)
report.append(
f"💳 可用余额: <b>${data.get('balance', 0):.2f}</b>"
)
report.append(
f"💰 今日累计投入: <b>${total_cost:.2f}</b>"
)
report.append(
f"📈 累计浮动盈亏: <b>{total_pnl:+.2f}$</b>"
)
# notifier._send_message("\n".join(report))
pushed_signals[summary_key] = time.time()
except Exception as e:
logger.error(f"即时保存数据失败: {e}")
logger.info("本轮扫描结束。等待 5 分钟...")
time.sleep(300)
except KeyboardInterrupt:
logger.info("收到关机指令,正在退出...")
except Exception as e:
logger.exception(f"系统运行出错: {e}")
if __name__ == "__main__":
main()
+9 -33
View File
@@ -1,48 +1,24 @@
import threading
import time
import sys
import subprocess import subprocess
import os import os
import sys
from loguru import logger from loguru import logger
def run_monitor():
"""启动监控引擎模块 (main.py)"""
logger.info("📡 正在启动后台监控引擎 (主动预警模式)...")
cmd = [sys.executable, "main.py"]
subprocess.run(cmd)
def run_bot():
"""启动电报交互模块 (bot_listener.py)"""
logger.info("🤖 正在启动电报指令监听器 (被动查询模式)...")
cmd = [sys.executable, "bot_listener.py"]
# 设置工作目录,确保导入正常
subprocess.run(cmd, cwd=os.getcwd())
def main(): def main():
logger.info("🌟 PolyWeather 全功能系统正在初始化...") logger.info("🌡️ PolyWeather 天气查询机器人启动中...")
# 创建共享文件夹 (如果不存在)
if not os.path.exists("data"):
os.makedirs("data")
# 创建两个线程并行运行 # 创建数据目录
monitor_thread = threading.Thread(target=run_monitor, daemon=True) os.makedirs("data", exist_ok=True)
bot_thread = threading.Thread(target=run_bot, daemon=True)
# 启动线程 # 直接运行 bot_listener
# monitor_thread.start() cmd = [sys.executable, "bot_listener.py"]
bot_thread.start() logger.success("🚀 已上线!等待 Telegram 指令...")
logger.success("🚀 系统已上线(天气查询模式)!")
logger.info("已暂停监控引擎和自动发现市场功能。")
logger.info("现在仅支持直接查询各城市实时天气与 Open-Meteo 预测。")
try: try:
# 保持主进程运行 subprocess.run(cmd, cwd=os.getcwd())
while True:
time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.warning("停止运行...") logger.warning("停止运行...")
if __name__ == "__main__": if __name__ == "__main__":
main() main()
View File
-95
View File
@@ -1,95 +0,0 @@
from loguru import logger
class OrderbookAnalyzer:
"""
分析目标: 评估市场供需平衡和流动性
"""
def __init__(self, config=None):
self.config = config or {}
self.wall_threshold = self.config.get("wall_threshold", 500) # 单笔订单超过此值为墙
logger.info("Initializing Orderbook Analyzer...")
def assess_liquidity(self, orderbook, side="ask"):
"""
分析流动性深度 (基于前 3 )
"""
orders = orderbook.get('asks' if side == "ask" else 'bids', [])
if not orders:
return "枯竭", 0
# 前 3 档总量 (Polymarket 通常返回价格字符串)
depth = sum(float(o.get("size", 0)) for o in orders[:3])
if depth < 50:
return "稀薄", depth
elif depth < 500:
return "正常", depth
else:
return "充裕", depth
def analyze(self, orderbook):
"""
增强版订单簿分析集成深度与 Spread 评估
"""
bids = orderbook.get('bids', [])
asks = orderbook.get('asks', [])
if not bids or not asks:
return {
"signal": "NEUTRAL",
"confidence": 0.0,
"tradeable": False,
"reason": "缺乏双边报价",
"liquidity": "枯竭",
"spread": 1.0
}
# 1. 计算核心指标
best_bid = float(bids[0].get('price', 0))
best_ask = float(asks[0].get('price', 0))
spread = abs(best_ask - best_bid)
mid_price = (best_ask + best_bid) / 2
# 2. 评估流动性
ask_liq, ask_depth = self.assess_liquidity(orderbook, "ask")
bid_liq, bid_depth = self.assess_liquidity(orderbook, "bid")
# 3. 交易可行性判定 (Spread <= 10c 且 深度 >= $50)
is_tradeable = (spread <= 0.10) and (ask_depth >= 50 or bid_depth >= 50)
# 4. Imbalance 计算
bid_volume = sum([float(b.get('size', 0)) for b in bids])
ask_volume = sum([float(a.get('size', 0)) for a in asks])
imbalance = bid_volume / ask_volume if ask_volume > 0 else 0
result = {
"best_bid": best_bid,
"best_ask": best_ask,
"mid_price": mid_price,
"spread": round(spread, 4),
"ask_depth": round(ask_depth, 2),
"bid_depth": round(bid_depth, 2),
"liquidity": ask_liq if ask_depth < bid_depth else bid_liq,
"tradeable": is_tradeable,
"imbalance": imbalance,
"signal": "NEUTRAL",
"confidence": 0.5
}
# 5. 信号修正
if is_tradeable:
if imbalance > 2.5:
result["signal"] = "BULLISH"
result["confidence"] = 0.75
elif imbalance < 0.4:
result["signal"] = "BEARISH"
result["confidence"] = 0.75
else:
result["confidence"] = 0.1 # 不建议交易
return result
def analyze_orderbook(orderbook):
"""兼容旧接口的便捷函数"""
analyzer = OrderbookAnalyzer()
return analyzer.analyze(orderbook)
-146
View File
@@ -1,146 +0,0 @@
import numpy as np
from loguru import logger
class TechnicalIndicators:
"""
技术指标计算 - RSI, 布林带等
"""
def __init__(self):
logger.info("Initializing Technical Indicators...")
def calculate_rsi(self, prices: list, period: int = 14) -> float:
"""
计算相对强弱指标 (RSI)
Args:
prices: 价格历史列表
period: RSI周期默认14
Returns:
float: RSI值 (0-100)
"""
if len(prices) < period + 1:
logger.debug("Insufficient data for RSI calculation")
return 50.0 # 返回中性值
prices = np.array(prices)
deltas = np.diff(prices)
gains = np.where(deltas > 0, deltas, 0)
losses = np.where(deltas < 0, -deltas, 0)
avg_gain = np.mean(gains[-period:])
avg_loss = np.mean(losses[-period:])
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
logger.debug(f"RSI({period}): {rsi:.2f}")
return rsi
def calculate_bollinger_bands(self, prices: list, period: int = 20, std_dev: float = 2.0) -> dict:
"""
计算布林带
Args:
prices: 价格历史列表
period: 移动平均周期
std_dev: 标准差倍数
Returns:
dict: 包含上轨中轨下轨
"""
if len(prices) < period:
logger.debug("Insufficient data for Bollinger Bands")
return {"upper": None, "middle": None, "lower": None}
prices = np.array(prices[-period:])
middle = np.mean(prices)
std = np.std(prices)
upper = middle + std_dev * std
lower = middle - std_dev * std
return {
"upper": upper,
"middle": middle,
"lower": lower,
"std": std
}
def calculate_momentum(self, prices: list, period: int = 10) -> float:
"""
计算价格动量
Args:
prices: 价格历史
period: 动量周期
Returns:
float: 动量值 (当前价格 / N周期前价格 - 1)
"""
if len(prices) < period + 1:
return 0.0
current = prices[-1]
past = prices[-period - 1]
if past == 0:
return 0.0
momentum = (current / past) - 1
return momentum
def get_signal(self, prices: list) -> dict:
"""
综合技术指标信号
Returns:
dict: 包含信号和分数
"""
rsi = self.calculate_rsi(prices)
bb = self.calculate_bollinger_bands(prices)
momentum = self.calculate_momentum(prices)
# RSI信号
if rsi > 70:
rsi_signal = "OVERBOUGHT"
rsi_score = 0.3 # 超买,看跌
elif rsi < 30:
rsi_signal = "OVERSOLD"
rsi_score = 0.8 # 超卖,看涨
else:
rsi_signal = "NEUTRAL"
rsi_score = 0.5
# 布林带信号
if bb["upper"] and len(prices) > 0:
current_price = prices[-1]
if current_price > bb["upper"]:
bb_signal = "ABOVE_UPPER"
bb_score = 0.7 # 突破上轨,强势
elif current_price < bb["lower"]:
bb_signal = "BELOW_LOWER"
bb_score = 0.3 # 跌破下轨,弱势
else:
bb_signal = "WITHIN_BANDS"
bb_score = 0.5
else:
bb_signal = "NO_DATA"
bb_score = 0.5
# 综合分数
combined_score = (rsi_score * 0.5 + bb_score * 0.3 +
(0.5 + momentum * 2) * 0.2) # momentum 转换为 0-1
combined_score = max(0, min(1, combined_score))
return {
"rsi": {"value": rsi, "signal": rsi_signal, "score": rsi_score},
"bollinger": {"bands": bb, "signal": bb_signal, "score": bb_score},
"momentum": momentum,
"combined_score": combined_score
}
-135
View File
@@ -1,135 +0,0 @@
import numpy as np
from loguru import logger
class VolumeAnalyzer:
"""
交易量异常检测 - 识别聪明钱和市场转折点
"""
def __init__(self, config=None):
self.config = config or {}
self.volume_threshold = self.config.get("volume_threshold", 2.0) # 2倍标准差
self.large_order_threshold = self.config.get("large_order_threshold", 1000) # $1000
logger.info("Initializing Volume Analyzer...")
def detect_volume_spike(self, volume_history: list) -> dict:
"""
检测成交量异常放大
Args:
volume_history: 历史成交量列表
Returns:
dict: 包含信号和置信度
"""
if len(volume_history) < 24:
return {"signal": "INSUFFICIENT_DATA", "score": 0.5}
recent_volume = np.array(volume_history[-24:]) # 最近24小时
historical_volume = np.array(volume_history[:-24])
if len(historical_volume) == 0:
return {"signal": "INSUFFICIENT_DATA", "score": 0.5}
avg_volume = np.mean(historical_volume)
std_volume = np.std(historical_volume)
recent_avg = np.mean(recent_volume)
# 计算Z-score
if std_volume > 0:
z_score = (recent_avg - avg_volume) / std_volume
else:
z_score = 0
logger.debug(f"Volume Z-score: {z_score:.2f}")
if z_score > self.volume_threshold:
return {
"signal": "VOLUME_SPIKE",
"score": min(0.9, 0.5 + z_score * 0.1),
"z_score": z_score,
"interpretation": "成交量异常放大,可能有新信息进入市场"
}
elif z_score < -self.volume_threshold:
return {
"signal": "VOLUME_DRY",
"score": 0.3,
"z_score": z_score,
"interpretation": "成交量萎缩,市场观望"
}
return {"signal": "NORMAL", "score": 0.5, "z_score": z_score}
def detect_large_orders(self, transactions: list) -> dict:
"""
检测大额订单 (聪明钱信号)
Args:
transactions: 交易列表每个包含 size, side, price
Returns:
dict: 大额订单分析结果
"""
large_buys = []
large_sells = []
for tx in transactions:
size = tx.get("size", 0)
side = tx.get("side", "").upper()
if size >= self.large_order_threshold:
if side == "BUY":
large_buys.append(tx)
elif side == "SELL":
large_sells.append(tx)
total_large_buy = sum(t.get("size", 0) for t in large_buys)
total_large_sell = sum(t.get("size", 0) for t in large_sells)
logger.debug(f"Large buys: ${total_large_buy:.2f}, Large sells: ${total_large_sell:.2f}")
if total_large_buy > total_large_sell * 2:
return {
"signal": "SMART_MONEY_BUY",
"score": 0.8,
"large_buy_volume": total_large_buy,
"large_sell_volume": total_large_sell,
"interpretation": "大户在积极买入,跟随机会"
}
elif total_large_sell > total_large_buy * 2:
return {
"signal": "SMART_MONEY_SELL",
"score": 0.2,
"large_buy_volume": total_large_buy,
"large_sell_volume": total_large_sell,
"interpretation": "大户在抛售,风险警告"
}
return {
"signal": "NEUTRAL",
"score": 0.5,
"large_buy_volume": total_large_buy,
"large_sell_volume": total_large_sell
}
def analyze(self, volume_history: list, transactions: list = None) -> dict:
"""
综合分析交易量
"""
volume_signal = self.detect_volume_spike(volume_history)
if transactions:
order_signal = self.detect_large_orders(transactions)
else:
order_signal = {"signal": "NO_DATA", "score": 0.5}
# 综合评分
combined_score = (volume_signal.get("score", 0.5) * 0.6 +
order_signal.get("score", 0.5) * 0.4)
return {
"volume_signal": volume_signal,
"order_signal": order_signal,
"combined_score": combined_score
}
-59
View File
@@ -1,59 +0,0 @@
from loguru import logger
from typing import List, Dict
from src.data_collection.onchain_tracker import OnchainTracker
class WhaleTracker:
"""
大户行为分析模块
"""
def __init__(self, config: dict, tracker: OnchainTracker):
self.config = config
self.tracker = tracker
logger.info("Initializing Whale Tracker...")
def analyze_market_whales(self, market_id: str) -> Dict:
"""
分析特定市场的鲸鱼行为
"""
large_trades = self.tracker.get_large_transactions(market_id)
if not large_trades:
return {"bullish": False, "signal": "NEUTRAL", "reason": "No whale activity detected"}
buy_value = 0
sell_value = 0
for trade in large_trades:
side = trade.get("side", "").upper()
value = trade.get("value", 0)
if side == "BUY":
buy_value += value
else:
sell_value += value
# 判断情绪
if buy_value > sell_value * 2:
return {
"bullish": True,
"signal": "STRONG_ACCUMULATION",
"buy_value": buy_value,
"sell_value": sell_value,
"reason": "Whales are heavily buying"
}
elif sell_value > buy_value * 2:
return {
"bullish": False,
"signal": "STRONG_DISTRIBUTION",
"buy_value": buy_value,
"sell_value": sell_value,
"reason": "Whales are heavily selling"
}
return {
"bullish": buy_value > sell_value,
"signal": "MODERATE",
"buy_value": buy_value,
"sell_value": sell_value,
"reason": "Mixed whale activity"
}
+12
View File
@@ -77,6 +77,18 @@ CITY_RISK_PROFILES = {
"warning": "距离远但地形平坦,偏差稳定可预测", "warning": "距离远但地形平坦,偏差稳定可预测",
"season_notes": "夏季", "season_notes": "夏季",
}, },
"paris": {
"risk_level": "medium",
"risk_emoji": "🟡",
"icao": "LFPG",
"airport_name": "Charles de Gaulle 机场",
"distance_km": 25.2,
"elevation_diff_m": 26,
"typical_bias_f": 1.5,
"bias_direction": "城市热岛效应:市区比机场偏暖1-2°C",
"warning": "机场在北郊,冬季北风时比市区更冷",
"season_notes": "夏季热浪期间偏差最大",
},
# 🟢 低危城市 - 数据相对靠谱 # 🟢 低危城市 - 数据相对靠谱
"toronto": { "toronto": {
-56
View File
@@ -1,56 +0,0 @@
from loguru import logger
from typing import List, Dict, Optional
from src.data_collection.polymarket_api import PolymarketClient
class OnchainTracker:
"""
追踪 Polymarket 上的大额交易和钱包动向
主要通过 Polymarket API 获取交易历史并模拟链上分析逻辑
"""
def __init__(self, config: dict, client: PolymarketClient):
self.config = config
self.client = client
self.whale_threshold = self.config.get("whale_threshold", 5000) # $5000 以上视为鲸鱼
logger.info(f"Initializing Onchain Tracker (Whale Threshold: ${self.whale_threshold})")
def get_large_transactions(self, market_id: str, limit: int = 100) -> List[Dict]:
"""
获取特定市场的历史大额交易
"""
trades = self.client.get_trades(market_id=market_id, limit=limit)
if not trades:
return []
# 过滤大额交易 (Polymarket API 返回的格式可能需要根据实际调整)
# 假设格式: [{"price": 0.9, "size": 10000, "side": "BUY", "maker": "0x...", "taker": "0x..."}]
large_trades = []
for trade in trades:
size = float(trade.get("size", 0))
price = float(trade.get("price", 0))
value = size * price
if value >= self.whale_threshold:
trade["value"] = value
large_trades.append(trade)
return large_trades
def get_whale_positions(self, market_id: str) -> Dict[str, float]:
"""
估算大户在某个市场的持仓情况
注意这只是基于最近交易的估算真实持仓需要查询链上合约
"""
trades = self.get_large_transactions(market_id, limit=500)
whale_holdings = {}
for trade in trades:
wallet = trade.get("proxyWallet") or trade.get("maker") or "unknown"
side = trade.get("side", "").upper()
size = float(trade.get("size", 0))
if side == "BUY":
whale_holdings[wallet] = whale_holdings.get(wallet, 0) + size
else:
whale_holdings[wallet] = whale_holdings.get(wallet, 0) - size
return whale_holdings
-291
View File
@@ -1,291 +0,0 @@
import os
import requests
import time
import re
from typing import Dict, List, Optional
from loguru import logger
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
class PolymarketClient:
"""
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.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 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"Requests session using proxy: {proxy}")
# 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"
}
)
self.api_key = config.get("api_key")
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:
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:
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]:
"""Fetch real-time price for a token via CLOB REST API"""
try:
# 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)) if resp.status_code == 200 else None
except Exception as e:
logger.debug(f"get_price failed ({token_id}): {e}")
return None
def get_orderbook(self, token_id: str) -> Optional[Dict]:
"""Fetch orderbook for a token via CLOB REST API"""
try:
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 failed ({token_id}): {e}")
return None
def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]:
"""Fetch buy prices for both YES and NO tokens"""
try:
# 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"get_buy_prices failed: {e}")
return None
def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]:
"""Batch fetch prices for multiple tokens using ThreadPoolExecutor"""
if not token_requests:
return {}
all_prices = {}
def robust_float(val):
try: return float(val)
except: return 0.0
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=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:
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"Using cached market list ({len(self._weather_markets_cache)} items)")
return self._weather_markets_cache
logger.info("📡 Scanning Polymarket via Gamma API for weather markets...")
all_weather_markets = []
seen_keys = set()
# 1. Target newest markets by query and ID sorting
search_queries = ["highest temperature", "temperature in", "daily weather"]
try:
# 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
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
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"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 find_weather_market(self, city: str, date_str: str = None) -> Optional[Dict]:
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
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", "")) + 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
+226 -24
View File
@@ -33,6 +33,7 @@ class WeatherDataCollector:
"toronto": "CYYZ", # Toronto Pearson "toronto": "CYYZ", # Toronto Pearson
"wellington": "NZWN", # Wellington International "wellington": "NZWN", # Wellington International
"buenos aires": "SAEZ", # Ezeiza International "buenos aires": "SAEZ", # Ezeiza International
"paris": "LFPG", # Charles de Gaulle
} }
def __init__(self, config: dict): def __init__(self, config: dict):
@@ -260,6 +261,7 @@ class WeatherDataCollector:
utc_midnight = local_midnight - timedelta(seconds=utc_offset) utc_midnight = local_midnight - timedelta(seconds=utc_offset)
max_so_far_c = -999 max_so_far_c = -999
max_temp_time = None
for obs in data: for obs in data:
obs_report_time = obs.get("reportTime", "") obs_report_time = obs.get("reportTime", "")
try: try:
@@ -271,6 +273,9 @@ class WeatherDataCollector:
t = obs.get("temp") t = obs.get("temp")
if t is not None and t > max_so_far_c: if t is not None and t > max_so_far_c:
max_so_far_c = t max_so_far_c = t
# 转为当地时间并记录
local_report = report_dt + timedelta(seconds=utc_offset)
max_temp_time = local_report.strftime("%H:%M")
except: except:
continue continue
@@ -295,6 +300,7 @@ class WeatherDataCollector:
"current": { "current": {
"temp": round(temp, 1) if temp is not None else None, "temp": round(temp, 1) if temp is not None else None,
"max_temp_so_far": round(max_so_far, 1) if max_so_far is not None else None, "max_temp_so_far": round(max_so_far, 1) if max_so_far is not None else None,
"max_temp_time": max_temp_time,
"dewpoint": round(dewp, 1) if dewp is not None else None, "dewpoint": round(dewp, 1) if dewp is not None else None,
"humidity": latest.get("rh"), "humidity": latest.get("rh"),
"wind_speed_kt": latest.get("wspd"), "wind_speed_kt": latest.get("wspd"),
@@ -356,18 +362,40 @@ class WeatherDataCollector:
"wind_speed_kt": round(ruz_hiz_kmh / 1.852, 1) if ruz_hiz_kmh is not None else None, "wind_speed_kt": round(ruz_hiz_kmh / 1.852, 1) if ruz_hiz_kmh is not None else None,
"wind_dir": latest.get("ruzgarYon"), "wind_dir": latest.get("ruzgarYon"),
"rain_24h": latest.get("toplamYagis"), "rain_24h": latest.get("toplamYagis"),
"pressure": latest.get("aktuelBasinc"),
"cloud_cover": latest.get("kapalilik"), # 0-8 八分位云量
"mgm_max_temp": latest.get("maxSicaklik"), # MGM 官方实测最高温
"time": latest.get("veriZamani"), # 观测时间 "time": latest.get("veriZamani"), # 观测时间
"station_name": latest.get("istasyonAd") or latest.get("adi") or latest.get("merkezAd") or "Ankara Esenboğa" "station_name": latest.get("istasyonAd") or latest.get("adi") or latest.get("merkezAd") or "Ankara Esenboğa"
} }
# 2. 每日预报 # 2. 每日预报(尝试两个可能的 API 路径)
daily_resp = self.session.get(f"{base_url}/tahminler/gunluk?istno={istno}", headers=headers, timeout=self.timeout) forecast_urls = [
if daily_resp.status_code == 200: f"{base_url}/tahminler/gunluk?istno={istno}",
forecasts = daily_resp.json() f"https://servis.mgm.gov.tr/api/tahminler/gunluk?istno={istno}",
if forecasts and isinstance(forecasts, list): ]
today = forecasts[0] for forecast_url in forecast_urls:
results["today_high"] = today.get("enYuksekGun1") try:
results["today_low"] = today.get("enDusukGun1") daily_resp = self.session.get(forecast_url, headers=headers, timeout=self.timeout)
if daily_resp.status_code == 200:
forecasts = daily_resp.json()
if forecasts and isinstance(forecasts, list):
today = forecasts[0]
high_val = today.get("enYuksekGun1")
low_val = today.get("enDusukGun1")
if high_val is not None:
results["today_high"] = high_val
results["today_low"] = low_val
logger.info(f"📋 MGM 每日预报: 最高 {high_val}°C, 最低 {low_val}°C (from {forecast_url})")
break
else:
# 记录所有可用字段,方便调试
available_keys = [k for k in today.keys() if "yuksek" in k.lower() or "sicaklik" in k.lower() or "gun" in k.lower()]
logger.warning(f"MGM 每日预报: enYuksekGun1 为空,可用字段: {available_keys}")
else:
logger.debug(f"MGM forecast URL {forecast_url} returned {daily_resp.status_code}")
except Exception as e:
logger.debug(f"MGM forecast URL {forecast_url} failed: {e}")
return results if "current" in results else None return results if "current" in results else None
except Exception as e: except Exception as e:
@@ -445,8 +473,8 @@ class WeatherDataCollector:
"latitude": lat, "latitude": lat,
"longitude": lon, "longitude": lon,
"current_weather": "true", "current_weather": "true",
"hourly": "temperature_2m", "hourly": "temperature_2m,shortwave_radiation",
"daily": "temperature_2m_max,apparent_temperature_max", "daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
"timezone": "auto", "timezone": "auto",
"forecast_days": forecast_days, "forecast_days": forecast_days,
"_t": int(time.time()), # 禁用缓存,强制刷新 "_t": int(time.time()), # 禁用缓存,强制刷新
@@ -526,6 +554,169 @@ class WeatherDataCollector:
logger.error(f"Open-Meteo forecast failed: {e}") logger.error(f"Open-Meteo forecast failed: {e}")
return None return None
def fetch_ensemble(
self,
lat: float,
lon: float,
use_fahrenheit: bool = False,
) -> Optional[Dict]:
"""
Open-Meteo Ensemble API 获取 51 成员集合预报
用于计算预报不确定性范围散度
"""
try:
url = "https://ensemble-api.open-meteo.com/v1/ensemble"
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max",
"timezone": "auto",
"forecast_days": 3,
"_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
else:
params["temperature_unit"] = "celsius"
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
daily = data.get("daily", {})
# 每个成员都会返回一组 temperature_2m_max
# 格式: {"time": [...], "temperature_2m_max_member01": [...], ...}
today_highs = []
for key, values in daily.items():
if key.startswith("temperature_2m_max") and key != "temperature_2m_max":
if values and values[0] is not None:
today_highs.append(values[0])
# 也检查非成员键(有些返回格式不同)
if not today_highs:
raw_max = daily.get("temperature_2m_max", [])
if isinstance(raw_max, list) and raw_max:
if isinstance(raw_max[0], list):
# 嵌套列表格式: [[member1_day1, member1_day2], [member2_day1, ...]]
today_highs = [m[0] for m in raw_max if m and m[0] is not None]
elif raw_max[0] is not None:
today_highs = [raw_max[0]]
if len(today_highs) < 3:
logger.warning(f"Ensemble 数据不足: 仅获取 {len(today_highs)} 个成员")
return None
today_highs.sort()
n = len(today_highs)
median = today_highs[n // 2]
p10 = today_highs[max(0, int(n * 0.1))]
p90 = today_highs[min(n - 1, int(n * 0.9))]
result = {
"source": "ensemble",
"members": n,
"median": round(median, 1),
"p10": round(p10, 1),
"p90": round(p90, 1),
"min": round(today_highs[0], 1),
"max": round(today_highs[-1], 1),
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
logger.info(
f"📊 Ensemble ({n} members): median={median:.1f}, "
f"p10={p10:.1f}, p90={p90:.1f}"
)
return result
except Exception as e:
logger.warning(f"Ensemble API 请求失败: {e}")
return None
def fetch_multi_model(
self,
lat: float,
lon: float,
use_fahrenheit: bool = False,
) -> Optional[Dict]:
"""
Open-Meteo 获取多个独立 NWP 模型的预报
用于真正的多模型共识评分
模型列表:
- ECMWF IFS (欧洲中期天气预报中心)
- GFS (美国 NOAA)
- ICON (德国气象局 DWD)
- GEM (加拿大气象局)
- JMA (日本气象厅)
"""
try:
url = "https://api.open-meteo.com/v1/forecast"
models = "ecmwf_ifs025,gfs_seamless,icon_seamless,gem_seamless,jma_seamless"
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max",
"models": models,
"timezone": "auto",
"forecast_days": 1,
"_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
# Open-Meteo 多模型返回格式:
# "daily": {
# "temperature_2m_max_ecmwf_ifs025": [12.3],
# "temperature_2m_max_gfs_seamless": [11.8],
# ...
# }
daily = data.get("daily", {})
model_labels = {
"ecmwf_ifs025": "ECMWF",
"gfs_seamless": "GFS",
"icon_seamless": "ICON",
"gem_seamless": "GEM",
"jma_seamless": "JMA",
}
forecasts = {}
for model_key, label in model_labels.items():
key = f"temperature_2m_max_{model_key}"
values = daily.get(key, [])
if values and values[0] is not None:
forecasts[label] = round(values[0], 1)
if not forecasts:
logger.warning("Multi-model: 无有效模型数据")
return None
labels_str = ", ".join([f"{k}={v}" for k, v in forecasts.items()])
logger.info(f"🔬 Multi-model ({len(forecasts)}个): {labels_str}")
return {
"source": "multi_model",
"forecasts": forecasts, # {"ECMWF": 12.3, "GFS": 11.8, ...}
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
except Exception as e:
logger.warning(f"Multi-model API 请求失败: {e}")
return None
def fetch_from_meteoblue( def fetch_from_meteoblue(
self, self,
lat: float, lat: float,
@@ -633,22 +824,23 @@ class WeatherDataCollector:
""" """
使用 Open-Meteo Geocoding API 获取城市坐标 (免费, 无需 Key) 使用 Open-Meteo Geocoding API 获取城市坐标 (免费, 无需 Key)
""" """
# 预设常用城市坐标,避免网络波动导致启动失败 # 坐标使用 METAR 机场位置(Polymarket 以机场数据结算)
static_coords = { static_coords = {
"london": {"lat": 51.5074, "lon": -0.1278}, "london": {"lat": 51.5053, "lon": 0.0553}, # EGLC London City
"new york": {"lat": 40.7128, "lon": -74.0060}, "paris": {"lat": 49.0097, "lon": 2.5478}, # LFPG Charles de Gaulle
"new york": {"lat": 40.7750, "lon": -73.8750}, # KLGA LaGuardia
"new york's central park": {"lat": 40.7812, "lon": -73.9665}, "new york's central park": {"lat": 40.7812, "lon": -73.9665},
"nyc": {"lat": 40.7128, "lon": -74.0060}, "nyc": {"lat": 40.7750, "lon": -73.8750}, # KLGA LaGuardia
"seattle": {"lat": 47.6062, "lon": -122.3321}, "seattle": {"lat": 47.4499, "lon": -122.3118}, # KSEA Sea-Tac
"chicago": {"lat": 41.8781, "lon": -87.6298}, "chicago": {"lat": 41.9769, "lon": -87.9081}, # KORD O'Hare
"dallas": {"lat": 32.7767, "lon": -96.7970}, "dallas": {"lat": 32.8459, "lon": -96.8509}, # KDAL Love Field
"miami": {"lat": 25.7617, "lon": -80.1918}, "miami": {"lat": 25.7933, "lon": -80.2906}, # KMIA International
"atlanta": {"lat": 33.7490, "lon": -84.3880}, "atlanta": {"lat": 33.6367, "lon": -84.4281}, # KATL Hartsfield-Jackson
"seoul": {"lat": 37.5665, "lon": 126.9780}, "seoul": {"lat": 37.4691, "lon": 126.4510}, # RKSI Incheon
"toronto": {"lat": 43.6532, "lon": -79.3832}, "toronto": {"lat": 43.6759, "lon": -79.6294}, # CYYZ Pearson
"ankara": {"lat": 39.9334, "lon": 32.8597}, "ankara": {"lat": 40.1281, "lon": 32.9950}, # LTAC Esenboğa
"wellington": {"lat": -41.2865, "lon": 174.7762}, "wellington": {"lat": -41.3272, "lon": 174.8053}, # NZWN Wellington
"buenos aires": {"lat": -34.6037, "lon": -58.3816}, "buenos aires": {"lat": -34.8222, "lon": -58.5358}, # SAEZ Ezeiza
} }
normalized_city = city.lower().strip() normalized_city = city.lower().strip()
@@ -798,6 +990,16 @@ class WeatherDataCollector:
nws_data = self.fetch_nws(lat, lon) nws_data = self.fetch_nws(lat, lon)
if nws_data: if nws_data:
results["nws"] = nws_data results["nws"] = nws_data
# 集合预报 (所有城市通用,用于不确定性分析)
ens_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
if ens_data:
results["ensemble"] = ens_data
# 多模型预报 (所有城市通用,用于共识评分)
mm_data = self.fetch_multi_model(lat, lon, use_fahrenheit=use_fahrenheit)
if mm_data:
results["multi_model"] = mm_data
else: else:
# Open-Meteo 失败时,仍然尝试获取 METAR 和 NWS # Open-Meteo 失败时,仍然尝试获取 METAR 和 NWS
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit) metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
View File
-285
View File
@@ -1,285 +0,0 @@
import numpy as np
import pandas as pd
from typing import Dict, List, Optional, Tuple
from datetime import datetime
from loguru import logger
try:
from statsmodels.tsa.arima.model import ARIMA
HAS_STATSMODELS = True
except ImportError:
HAS_STATSMODELS = False
logger.debug("statsmodels not installed, ARIMA model unavailable")
try:
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
HAS_SKLEARN = True
except ImportError:
HAS_SKLEARN = False
logger.debug("scikit-learn not installed, ML models unavailable")
class TemperaturePredictor:
"""
Temperature prediction model using statistical and ML methods
Supports:
- ARIMA for time series prediction
- Random Forest for feature-based prediction
- Ensemble of both methods
"""
def __init__(self, config: dict = None):
self.config = config or {}
self.arima_order = self.config.get("arima_order", (5, 1, 2))
self.rf_estimators = self.config.get("rf_estimators", 100)
self.arima_model = None
self.rf_model = None
self.is_trained = False
logger.info("Temperature Predictor initialized")
def prepare_features(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Prepare features for ML model
Args:
data: DataFrame with temperature history
Returns:
DataFrame: Feature-engineered data
"""
df = data.copy()
# Time-based features
if 'date' in df.columns:
df['date'] = pd.to_datetime(df['date'])
df['day_of_year'] = df['date'].dt.dayofyear
df['month'] = df['date'].dt.month
df['day_of_week'] = df['date'].dt.dayofweek
# Lag features
if 'temp' in df.columns:
for lag in [1, 2, 3, 7, 14]:
df[f'temp_lag_{lag}'] = df['temp'].shift(lag)
# Rolling statistics
df['temp_rolling_mean_7'] = df['temp'].rolling(window=7).mean()
df['temp_rolling_std_7'] = df['temp'].rolling(window=7).std()
df['temp_rolling_mean_14'] = df['temp'].rolling(window=14).mean()
# Drop NaN rows created by lag features
df = df.dropna()
return df
def train_arima(self, temperature_series: List[float]) -> bool:
"""
Train ARIMA model on temperature time series
Args:
temperature_series: List of historical temperatures
Returns:
bool: Success status
"""
if not HAS_STATSMODELS:
logger.error("statsmodels required for ARIMA training")
return False
if len(temperature_series) < 30:
logger.warning("Insufficient data for ARIMA training (need 30+ points)")
return False
try:
series = np.array(temperature_series)
model = ARIMA(series, order=self.arima_order)
self.arima_model = model.fit()
logger.info(f"ARIMA model trained. AIC: {self.arima_model.aic:.2f}")
return True
except Exception as e:
logger.error(f"ARIMA training failed: {e}")
return False
def train_random_forest(self,
features: pd.DataFrame,
target_col: str = 'temp') -> bool:
"""
Train Random Forest model
Args:
features: Feature DataFrame
target_col: Target column name
Returns:
bool: Success status
"""
if not HAS_SKLEARN:
logger.error("scikit-learn required for Random Forest training")
return False
if len(features) < 50:
logger.warning("Insufficient data for RF training (need 50+ rows)")
return False
try:
# Prepare data
feature_cols = [c for c in features.columns
if c not in [target_col, 'date', 'datetime']]
X = features[feature_cols].values
y = features[target_col].values
# Train-test split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Train model
self.rf_model = RandomForestRegressor(
n_estimators=self.rf_estimators,
random_state=42,
n_jobs=-1
)
self.rf_model.fit(X_train, y_train)
# Evaluate
train_score = self.rf_model.score(X_train, y_train)
test_score = self.rf_model.score(X_test, y_test)
logger.info(f"Random Forest trained. Train R²: {train_score:.4f}, Test R²: {test_score:.4f}")
# Store feature names
self.feature_names = feature_cols
return True
except Exception as e:
logger.error(f"Random Forest training failed: {e}")
return False
def predict_arima(self, steps: int = 1) -> Optional[Dict]:
"""
Make prediction using ARIMA model
Args:
steps: Number of steps to forecast
Returns:
dict: Prediction with confidence interval
"""
if self.arima_model is None:
logger.warning("ARIMA model not trained")
return None
try:
forecast = self.arima_model.forecast(steps=steps)
conf_int = self.arima_model.get_forecast(steps=steps).conf_int()
return {
"method": "ARIMA",
"predicted_temp": float(forecast[0]) if steps == 1 else [float(f) for f in forecast],
"confidence_interval": [float(conf_int.iloc[0, 0]), float(conf_int.iloc[0, 1])] if steps == 1 else conf_int.values.tolist()
}
except Exception as e:
logger.error(f"ARIMA prediction failed: {e}")
return None
def predict_rf(self, features: np.ndarray) -> Optional[Dict]:
"""
Make prediction using Random Forest model
Args:
features: Feature array for prediction
Returns:
dict: Prediction result
"""
if self.rf_model is None:
logger.warning("Random Forest model not trained")
return None
try:
prediction = self.rf_model.predict(features.reshape(1, -1))[0]
# Estimate confidence using tree variance
tree_predictions = [tree.predict(features.reshape(1, -1))[0]
for tree in self.rf_model.estimators_]
std = np.std(tree_predictions)
return {
"method": "RandomForest",
"predicted_temp": float(prediction),
"confidence_interval": [float(prediction - 1.96 * std),
float(prediction + 1.96 * std)],
"std": float(std)
}
except Exception as e:
logger.error(f"Random Forest prediction failed: {e}")
return None
def predict_ensemble(self,
temperature_history: List[float],
feature_data: pd.DataFrame = None,
arima_weight: float = 0.4,
rf_weight: float = 0.6) -> Dict:
"""
Make ensemble prediction combining ARIMA and Random Forest
Args:
temperature_history: Historical temperature series
feature_data: Feature data for RF prediction
arima_weight: Weight for ARIMA prediction
rf_weight: Weight for RF prediction
Returns:
dict: Ensemble prediction
"""
predictions = []
weights = []
# ARIMA prediction
if self.arima_model is not None:
arima_pred = self.predict_arima(steps=1)
if arima_pred:
predictions.append(arima_pred["predicted_temp"])
weights.append(arima_weight)
# Random Forest prediction
if self.rf_model is not None and feature_data is not None:
# Get latest features
prepared = self.prepare_features(feature_data)
if len(prepared) > 0 and hasattr(self, 'feature_names'):
latest_features = prepared[self.feature_names].iloc[-1].values
rf_pred = self.predict_rf(latest_features)
if rf_pred:
predictions.append(rf_pred["predicted_temp"])
weights.append(rf_weight)
if not predictions:
logger.debug("No predictions available (Model not trained)")
return {
"predicted_temp": None,
"confidence": 0.5,
"error": "No models available for prediction"
}
# Weighted average
weights = np.array(weights) / np.sum(weights) # Normalize weights
ensemble_pred = np.average(predictions, weights=weights)
# Estimate confidence based on model agreement
if len(predictions) > 1:
spread = abs(predictions[0] - predictions[1])
confidence = max(0.5, 1.0 - spread / 5.0) # Lower confidence if predictions differ
else:
confidence = 0.7
return {
"predicted_temp": float(ensemble_pred),
"confidence": confidence,
"confidence_interval": [ensemble_pred - 2.0, ensemble_pred + 2.0], # Approximate
"individual_predictions": predictions,
"weights": weights.tolist()
}
View File
-198
View File
@@ -1,198 +0,0 @@
from loguru import logger
from src.analysis.volume_analyzer import VolumeAnalyzer
from src.analysis.orderbook_analyzer import analyze_orderbook
from src.analysis.technical_indicators import TechnicalIndicators
class DecisionEngine:
"""
综合决策引擎 - 多因子加权评分系统
"""
def __init__(self, config: dict = None):
self.config = config or {}
# 因子权重
self.weights = self.config.get("weights", {
"statistical_prediction": 0.50,
"data_source_consensus": 0.15,
"market_volume_signal": 0.15,
"orderbook_analysis": 0.10,
"technical_indicators": 0.05,
"onchain_whale_signal": 0.05
})
# 初始化分析器
self.volume_analyzer = VolumeAnalyzer(config)
self.tech_indicators = TechnicalIndicators()
logger.info("决策引擎初始化完成。")
logger.debug(f"权重配置: {self.weights}")
def calculate_signal(self,
model_prediction: dict,
market_data: dict,
weather_consensus: dict = None,
whale_activity: dict = None) -> dict:
"""
综合多因子计算交易信号
Args:
model_prediction: 统计模型预测结果
market_data: 市场数据 (价格历史订单簿交易量等)
weather_consensus: 天气数据源一致性检查结果
whale_activity: 链上大户活动数据
Returns:
dict: 综合评分和交易建议
"""
scores = {}
details = {}
# 1. 统计模型预测得分 (权重: 50%)
stat_confidence = model_prediction.get("confidence", 0.5)
scores["statistical"] = stat_confidence
details["statistical"] = {
"score": stat_confidence,
"prediction": model_prediction.get("predicted_temp"),
"confidence_interval": model_prediction.get("confidence_interval")
}
# 2. 多源数据一致性 (权重: 15%)
if weather_consensus:
is_consensus = weather_consensus.get("consensus", False)
consensus_score = 1.0 if is_consensus else 0.3
else:
consensus_score = 0.5
scores["consensus"] = consensus_score
details["consensus"] = weather_consensus
# 3. 交易量信号 (权重: 15%)
volume_history = market_data.get("volume_history", [])
transactions = market_data.get("transactions", [])
volume_analysis = self.volume_analyzer.analyze(volume_history, transactions)
scores["volume"] = volume_analysis.get("combined_score", 0.5)
details["volume"] = volume_analysis
# 4. 订单簿分析 (权重: 10%)
orderbook = market_data.get("orderbook", {})
orderbook_signal = analyze_orderbook(orderbook)
scores["orderbook"] = orderbook_signal.get("confidence", 0.5)
details["orderbook"] = orderbook_signal
# 5. 技术指标 (权重: 5%)
price_history = market_data.get("price_history", [])
if price_history:
tech_signal = self.tech_indicators.get_signal(price_history)
scores["technical"] = tech_signal.get("combined_score", 0.5)
details["technical"] = tech_signal
else:
scores["technical"] = 0.5
details["technical"] = {"message": "No price history available"}
# 6. 链上鲸鱼信号 (权重: 5%)
if whale_activity:
is_bullish = whale_activity.get("bullish", False)
whale_score = 0.8 if is_bullish else 0.2
else:
whale_score = 0.5
scores["whale"] = whale_score
details["whale"] = whale_activity
# 加权计算最终分数
final_score = (
scores["statistical"] * self.weights["statistical_prediction"] +
scores["consensus"] * self.weights["data_source_consensus"] +
scores["volume"] * self.weights["market_volume_signal"] +
scores["orderbook"] * self.weights["orderbook_analysis"] +
scores["technical"] * self.weights["technical_indicators"] +
scores["whale"] * self.weights["onchain_whale_signal"]
)
# 生成建议
recommendation = self._get_recommendation(final_score)
result = {
"final_score": round(final_score, 4),
"recommendation": recommendation,
"factor_scores": scores,
"factor_details": details,
"weights": self.weights
}
logger.info(f"Decision: {recommendation} (score: {final_score:.4f})")
return result
def _get_recommendation(self, score: float) -> str:
"""
根据评分生成交易建议
Args:
score: 综合评分 (0-1)
Returns:
str: 交易建议
"""
if score > 0.80:
return "STRONG_BUY"
elif score > 0.65:
return "BUY"
elif score > 0.50:
return "WEAK_BUY"
elif score > 0.35:
return "HOLD"
elif score > 0.20:
return "WEAK_SELL"
else:
return "NO_ACTION"
def should_trade(self,
signal: dict,
current_price: float,
min_confidence: float = 0.65) -> dict:
"""
判断是否应该执行交易
Args:
signal: calculate_signal返回的信号
current_price: 当前市场价格
min_confidence: 最低置信度阈值
Returns:
dict: 交易决策
"""
final_score = signal.get("final_score", 0)
recommendation = signal.get("recommendation", "NO_ACTION")
# 检查是否满足交易条件
should_buy = (
final_score >= min_confidence and
recommendation in ["STRONG_BUY", "BUY"] and
current_price >= 0.85 # 价格阈值
)
should_sell = (
final_score < 0.35 or
recommendation in ["WEAK_SELL", "NO_ACTION"]
)
if should_buy:
return {
"action": "BUY",
"confidence": final_score,
"price": current_price,
"reason": f"Score {final_score:.2f} >= threshold {min_confidence}"
}
elif should_sell:
return {
"action": "SELL",
"confidence": final_score,
"price": current_price,
"reason": f"Score {final_score:.2f} below threshold or bearish signal"
}
else:
return {
"action": "HOLD",
"confidence": final_score,
"price": current_price,
"reason": "Conditions not met for trading"
}
-153
View File
@@ -1,153 +0,0 @@
from loguru import logger
class PositionManager:
"""
仓位管理 - Kelly公式动态仓位计算
"""
def __init__(self, config=None):
self.config = config or {}
self.max_position_ratio = self.config.get("max_position_ratio", 0.25) # 最大单笔25%
self.max_total_exposure = self.config.get("max_total_exposure", 0.80) # 最大总仓位80%
self.min_trade_size = self.config.get("min_trade_size", 10) # 最小交易额$10
logger.info("Initializing Position Manager...")
def kelly_criterion(self, win_prob: float, odds: float) -> float:
"""
Kelly公式计算最优投资比例
f = (bp - q) / b
f: 应投资的资金比例
b: 赔率 (盈利/亏损)
p: 胜率
q: 败率 (1-p)
Args:
win_prob: 预测胜率 (0-1)
odds: 赔率
Returns:
float: 建议投资比例 (0-1)
"""
if win_prob <= 0 or win_prob >= 1 or odds <= 0:
return 0.0
q = 1 - win_prob
f = (win_prob * odds - q) / odds
# 限制最大仓位
f = max(0, min(f, self.max_position_ratio))
logger.debug(f"Kelly ratio: {f:.4f} (win_prob={win_prob:.2f}, odds={odds:.2f})")
return f
def calculate_position_size(self,
total_capital: float,
win_prob: float,
market_price: float,
current_exposure: float = 0) -> dict:
"""
计算建议仓位大小
Args:
total_capital: 总资金
win_prob: 模型预测胜率
market_price: 当前市场价格 (0-1)
current_exposure: 当前已有仓位占比
Returns:
dict: 包含建议仓位大小和相关信息
"""
# 计算赔率
if market_price <= 0 or market_price >= 1:
return {"size": 0, "error": "Invalid market price"}
odds = (1 - market_price) / market_price
# Kelly计算
kelly_ratio = self.kelly_criterion(win_prob, odds)
# 检查总仓位限制
available_ratio = self.max_total_exposure - current_exposure
if available_ratio <= 0:
return {
"size": 0,
"kelly_ratio": kelly_ratio,
"reason": "Max exposure reached"
}
# 实际使用比例
actual_ratio = min(kelly_ratio, available_ratio)
# 计算金额
position_size = total_capital * actual_ratio
# 检查最小交易额
if position_size < self.min_trade_size:
return {
"size": 0,
"kelly_ratio": kelly_ratio,
"reason": f"Below minimum trade size (${self.min_trade_size})"
}
return {
"size": position_size,
"kelly_ratio": kelly_ratio,
"actual_ratio": actual_ratio,
"odds": odds,
"expected_return": (win_prob * odds - (1 - win_prob)) * position_size
}
def should_exit(self,
entry_price: float,
current_price: float,
current_prediction: float,
stop_loss: float = 0.15,
take_profit: float = 0.30) -> dict:
"""
判断是否应该平仓
Args:
entry_price: 入场价格
current_price: 当前价格
current_prediction: 当前模型预测
stop_loss: 止损比例
take_profit: 止盈比例
Returns:
dict: 退出建议
"""
if entry_price <= 0:
return {"should_exit": False}
pnl_ratio = (current_price - entry_price) / entry_price
# 止损
if pnl_ratio < -stop_loss:
return {
"should_exit": True,
"reason": "STOP_LOSS",
"pnl_ratio": pnl_ratio
}
# 止盈
if pnl_ratio > take_profit:
return {
"should_exit": True,
"reason": "TAKE_PROFIT",
"pnl_ratio": pnl_ratio
}
# 模型预测反转
if current_prediction < 0.4: # 预测胜率下降
return {
"should_exit": True,
"reason": "PREDICTION_REVERSAL",
"pnl_ratio": pnl_ratio,
"current_prediction": current_prediction
}
return {
"should_exit": False,
"pnl_ratio": pnl_ratio
}
-99
View File
@@ -1,99 +0,0 @@
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_daily_exposure = 50.0 # 每日最高投入上限
self.daily_used_exposure = 0.0
self.last_reset_date = ""
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 = 0,
hours_to_settle: float = 24,
is_high_relative_volume: bool = False,
) -> tuple[float, str]:
"""
仓位计算方法 (简化版移除流动性过滤):
仓位 = base_position(置信度)
× time_decay(离结算衰减)
× budget_limit
"""
self._reset_daily_exposure()
final_pos = base_confidence_usd
reason = "Normal"
# 1. 时间衰减因子
# 离结算时间越近,预测越准但也存在剧烈博弈风险
time_factor = 1.0
if hours_to_settle <= 1.0:
time_factor = 0.0 # 最后 1 小时停止建仓
reason = "🚫临近结算"
elif hours_to_settle <= 4.0:
time_factor = 0.4 # 1-4小时:缩小 60%
reason = "⏱️结算冲刺 (40%)"
elif hours_to_settle <= 12.0:
time_factor = 0.7 # 4-12小时:缩小 30%
reason = "⏳接近结算 (70%)"
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 = "🛑触及日风控上限"
# 3. 高相对成交量加权 (如果是高成交量市场,且逻辑支持,可保持原状或微增)
# 这里逻辑设定为:如果不是高成交量,再次缩减 20% 防御
if not is_high_relative_volume:
final_pos *= 0.8
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}"
)
def check_trade_risk(
self, trade_size: float, market_data: dict, model_confidence: float
) -> dict:
"""保持基础接口兼容"""
return {"passed": True, "risks": []}
View File
-219
View File
@@ -1,219 +0,0 @@
from loguru import logger
from typing import Optional, Dict
from src.data_collection.polymarket_api import PolymarketClient
class OrderExecutor:
"""
交易执行器 - 负责订单生成提交和管理
"""
def __init__(self, config: dict, client: PolymarketClient):
self.config = config
self.client = client
self.pending_orders = {}
self.executed_orders = []
logger.info("Order Executor initialized")
def execute_trade(self,
token_id: str,
side: str,
amount: float,
price: float,
order_type: str = "GTC") -> Dict:
"""
执行交易
Args:
token_id: Token ID
side: "BUY" "SELL"
amount: 交易金额
price: 价格
order_type: 订单类型 (GTC, GTD, FOK)
Returns:
dict: 订单结果
"""
logger.info(f"Executing {side} order: ${amount:.2f} @ {price:.4f}")
# 计算数量
if price <= 0:
return {"status": "error", "message": "Invalid price"}
size = amount / price
# 提交订单
try:
result = self.client.create_order(
token_id=token_id,
side=side,
price=price,
size=size,
order_type=order_type
)
if result:
order_id = result.get("orderID", "unknown")
self.executed_orders.append({
"order_id": order_id,
"token_id": token_id,
"side": side,
"price": price,
"size": size,
"amount": amount,
"result": result
})
logger.info(f"Order executed successfully: {order_id}")
return {
"status": "success",
"order_id": order_id,
"side": side,
"price": price,
"size": size,
"amount": amount
}
else:
return {"status": "error", "message": "Order submission failed"}
except Exception as e:
logger.error(f"Order execution failed: {e}")
return {"status": "error", "message": str(e)}
def cancel_order(self, order_id: str) -> Dict:
"""
取消订单
Args:
order_id: 订单ID
Returns:
dict: 取消结果
"""
try:
result = self.client.cancel_order(order_id)
if result:
logger.info(f"Order {order_id} cancelled")
return {"status": "success", "order_id": order_id}
else:
return {"status": "error", "message": "Cancel failed"}
except Exception as e:
logger.error(f"Cancel order failed: {e}")
return {"status": "error", "message": str(e)}
def get_open_orders(self, market_id: str = None) -> Optional[Dict]:
"""
获取当前挂单
Args:
market_id: 可选的市场过滤
Returns:
dict: 挂单列表
"""
return self.client.get_orders(market_id)
def get_execution_history(self) -> list:
"""
获取执行历史
Returns:
list: 已执行订单列表
"""
return self.executed_orders
class PortfolioTracker:
"""
持仓追踪器
"""
def __init__(self):
self.positions = {}
self.total_invested = 0
self.total_pnl = 0
logger.info("Portfolio Tracker initialized")
def add_position(self,
token_id: str,
side: str,
size: float,
entry_price: float,
amount: float):
"""
添加持仓
"""
if token_id not in self.positions:
self.positions[token_id] = {
"side": side,
"size": size,
"entry_price": entry_price,
"amount": amount,
"current_price": entry_price,
"unrealized_pnl": 0
}
else:
# 加仓
existing = self.positions[token_id]
total_size = existing["size"] + size
avg_price = (existing["size"] * existing["entry_price"] + size * entry_price) / total_size
existing["size"] = total_size
existing["entry_price"] = avg_price
existing["amount"] += amount
self.total_invested += amount
logger.info(f"Position added: {token_id}, size={size}, price={entry_price}")
def update_price(self, token_id: str, current_price: float):
"""
更新持仓价格
"""
if token_id in self.positions:
pos = self.positions[token_id]
pos["current_price"] = current_price
# 计算未实现盈亏
if pos["side"] == "BUY":
pos["unrealized_pnl"] = (current_price - pos["entry_price"]) * pos["size"]
else:
pos["unrealized_pnl"] = (pos["entry_price"] - current_price) * pos["size"]
def close_position(self, token_id: str, exit_price: float) -> Dict:
"""
平仓
"""
if token_id not in self.positions:
return {"status": "error", "message": "Position not found"}
pos = self.positions[token_id]
if pos["side"] == "BUY":
realized_pnl = (exit_price - pos["entry_price"]) * pos["size"]
else:
realized_pnl = (pos["entry_price"] - exit_price) * pos["size"]
self.total_pnl += realized_pnl
self.total_invested -= pos["amount"]
del self.positions[token_id]
return {
"status": "success",
"realized_pnl": realized_pnl,
"exit_price": exit_price
}
def get_summary(self) -> Dict:
"""
获取持仓汇总
"""
total_unrealized = sum(p["unrealized_pnl"] for p in self.positions.values())
return {
"positions_count": len(self.positions),
"total_invested": self.total_invested,
"total_unrealized_pnl": total_unrealized,
"total_realized_pnl": self.total_pnl,
"positions": self.positions
}
-158
View File
@@ -1,158 +0,0 @@
import json
import os
import time
from datetime import datetime, timedelta
from loguru import logger
class PaperTrader:
"""
模拟交易系统 (Paper Trading System)
"""
def __init__(self, storage_path="data/paper_positions.json", total_capital=1000.0):
self.storage_path = storage_path
self.initial_capital = total_capital
data = self._load_data()
self.positions = data.get("positions", {})
self.history = data.get("history", []) # 历史结项记录
self.trades = data.get("trades", []) # 原始买入/卖出记录
self.balance = data.get("balance", total_capital)
logger.info(f"模拟交易系统初始化。累计成交: {len(self.history)} 笔, 买入记录: {len(self.trades)}")
def _load_data(self):
if os.path.exists(self.storage_path):
try:
with open(self.storage_path, "r", encoding="utf-8") as f:
return json.load(f)
except:
return {"positions": {}, "history": [], "trades": [], "balance": self.initial_capital}
return {"positions": {}, "history": [], "trades": [], "balance": self.initial_capital}
def _save_data(self):
with open(self.storage_path, "w", encoding="utf-8") as f:
json.dump(
{
"positions": self.positions,
"history": self.history,
"trades": self.trades,
"balance": round(self.balance, 2),
},
f,
ensure_ascii=False,
indent=2,
)
def open_position(self, market_id: str, city: str, option: str, price: int, side: str, amount_usd: float = 5.0, target_date: str = None, predicted_temp: float = None):
"""
开仓进入模拟仓位
"""
# 价格以美分计,转换为 0-1 比例
price_decimal = price / 100.0
# 检查余额
if self.balance < amount_usd:
logger.warning(f"余额不足,无法开仓 (余额: ${self.balance:.2f})")
return False
# 计算持仓份额
shares = amount_usd / price_decimal if price_decimal > 0 else 0
position_id = f"{market_id}_{side}"
# 如果已经有相同方向的仓位,可以选择加仓或忽略(这里简单起见,不重复开仓)
if position_id in self.positions:
return False
new_pos = {
"market_id": market_id,
"city": city,
"option": option,
"side": side,
"entry_price": price,
"shares": shares,
"cost_usd": amount_usd,
"current_price": price,
"pnl_usd": 0.0,
"pnl_pct": 0.0,
"status": "OPEN",
"target_date": target_date,
"predicted_temp": predicted_temp,
"opened_at": (datetime.utcnow() + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")
}
self.positions[position_id] = new_pos
self.balance -= amount_usd
# 记录交易流水
self.trades.append({
"type": "BUY",
"city": city,
"option": option,
"side": side,
"price": price,
"amount": amount_usd,
"time": new_pos["opened_at"]
})
self._save_data()
logger.success(f"【模拟开仓】{city} | {option} | {side} | 价格: {price}¢ | 投入: ${amount_usd}")
return True
def update_pnl(self, current_prices: dict):
updated_report = []
finished_ids = []
for pid, pos in self.positions.items():
if pos["status"] != "OPEN":
continue
m_id = pos["market_id"]
if m_id in current_prices:
curr_price = current_prices[m_id].get("price", 50)
if pos["side"] == "NO":
curr_price = 100 - curr_price
# 更新当前价值
value = pos["shares"] * (curr_price / 100.0)
pnl = value - pos["cost_usd"]
pnl_pct = (pnl / pos["cost_usd"]) * 100 if pos["cost_usd"] > 0 else 0
pos["current_price"] = curr_price
pos["pnl_usd"] = round(pnl, 2)
pos["pnl_pct"] = round(pnl_pct, 2)
# --- 自动结项检测:如果价格变为 0 或 100 (Polymarket 已结算) ---
if curr_price >= 99.5 or curr_price <= 0.5:
pos["status"] = "CLOSED"
pos["closed_at"] = (
datetime.utcnow() + timedelta(hours=8)
).strftime("%Y-%m-%d %H:%M:%S")
self.balance += value # 资金回笼
self.history.append(pos)
finished_ids.append(pid)
logger.success(
f"【模拟结项】{pos['city']} | {pos['option']} | 最终价格: {curr_price}¢ | 获利: ${pnl:+.2f}"
)
else:
updated_report.append(pos)
# 从活跃仓位中移除已结项的
for pid in finished_ids:
# 在流水中添加卖出(结项)记录
pos = self.positions[pid]
self.trades.append({
"type": "SELL",
"city": pos["city"],
"option": pos["option"],
"side": pos["side"],
"price": pos["current_price"],
"amount": round(pos["shares"] * (pos["current_price"] / 100.0), 2),
"time": pos.get("closed_at")
})
del self.positions[pid]
self._save_data()
return updated_report
-285
View File
@@ -1,285 +0,0 @@
import requests
import html
from loguru import logger
from datetime import datetime
class TelegramNotifier:
"""
Telegram 消息推送模块
支持信号推送预警推送和市场异常提醒
"""
def __init__(self, config: dict):
self.config = config
self.token = config.get("bot_token")
self.chat_id = config.get("chat_id")
self.proxy = config.get("proxy")
self.session = requests.Session()
if self.proxy:
if not self.proxy.startswith("http"):
self.proxy = f"http://{self.proxy}"
self.session.proxies = {"http": self.proxy, "https": self.proxy}
logger.info("Telegram 通知器初始化完成。")
@staticmethod
def _escape_html(text: str) -> str:
"""Escape HTML special characters"""
if not isinstance(text, str):
text = str(text)
return html.escape(text, quote=False)
def _send_message(self, text: str):
"""发送 Telegram 消息的主函数 (支持多个 ID)"""
if not self.token or not self.chat_id:
logger.warning("未配置 Telegram Token 或 Chat ID,无法发送消息。")
return False
# 支持逗号分隔的多个 ID
chat_ids = str(self.chat_id).replace(" ", "").split(",")
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
all_successful = True
for cid in chat_ids:
if not cid:
continue
payload = {
"chat_id": cid,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
try:
response = self.session.post(url, json=payload, timeout=10)
if response.status_code != 200:
error_msg = response.text
if "chat not found" in error_msg.lower():
logger.error(
f"Telegram 消息发送给 {cid} 失败 (400): Chat ID {cid} 无效或机器人尚未被加入该聊天。请在 Telegram 中发送 /id 给机器人确认正确的 Chat ID。"
)
else:
logger.error(
f"Telegram 消息发送给 {cid} 失败 ({response.status_code}): {error_msg}"
)
all_successful = False
else:
logger.info(f"Telegram 消息发送给 {cid} 成功。")
except Exception as e:
logger.error(f"Telegram 消息发送给 {cid} 异常: {e}")
all_successful = False
return all_successful
def send_signal(
self,
market_name: str,
full_title: str,
option: str,
score: float,
prediction: str,
confidence: int,
analysis_list: list,
price: float,
market_url: str,
local_time: str = None,
target_date: str = None,
):
"""发送交易信号推送"""
stars = "" * int(score) + "" * (5 - int(score))
timestamp_utc = datetime.utcnow().strftime("%H:%M")
analysis_text = "\n".join(
[
f"{self._escape_html(item)}" if "" not in item else item
for item in analysis_list
]
)
local_time_text = (
f"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
if local_time
else ""
)
target_date_text = self._escape_html(target_date) if target_date else "待定"
text = (
f"🎯 <b>交易信号 #{self._escape_html(market_name.split(' ')[0])}</b>\n\n"
f"📍 城市: <b>{self._escape_html(market_name)}</b>\n"
f"🏆 市场: <i>{self._escape_html(full_title)}</i>\n"
f"📝 选项: <b>{self._escape_html(option)}</b>\n"
f"💰 当前价格: <b>{price}¢</b>\n"
f"═══════════════════\n"
f"📊 信号评分: {stars} ({score}/5)\n"
f"🤖 模型预测: {self._escape_html(prediction)}\n"
f"📈 置信度: {confidence}%\n\n"
f"分析汇总:\n"
f"{analysis_text}\n"
f"═══════════════════\n"
f"{local_time_text}"
f"📅 结算日期: <b>{target_date_text}</b>\n"
f"🔗 <a href='{market_url}'>点击进入市场</a>\n\n"
f"⏰ 信号时间: {timestamp_utc} UTC"
)
return self._send_message(text)
def send_combined_alert(
self,
city: str,
alerts: list,
local_time: str = None,
forecast_temp: str = None,
total_volume: float = 0,
brackets_count: int = 0,
strategy_tips: list = None,
metar_data: dict = None,
):
"""发送简约版合并预警 (含 METAR 航空气象数据)"""
if not alerts:
return
from datetime import datetime, timedelta
# UTC+8 北京时间
now_bj = datetime.utcnow() + timedelta(hours=8)
timestamp_bj = now_bj.strftime("%H:%M")
# 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"✈️ <b>机场实测 ({icao}):</b>\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"
# 3. 策略建议(如果有)
tips_text = ""
if strategy_tips:
tips_text = (
"💡 <b>策略建议:</b>\n"
+ "\n".join([f"{self._escape_html(tip)}" for tip in strategy_tips])
+ "\n\n"
)
# 4. 总体布局
text = (
f"🔔 <b>城市监控报告 #{self._escape_html(city)}</b>\n\n"
f"📍 城市: {self._escape_html(city)}\n"
f"{metar_text}"
f"📊 <b>实时异动:</b>\n"
f"{items_text}"
f"{tips_text}"
f"═══════════════════\n"
f"🕒 当地时间: {self._escape_html(local_time or 'N/A')}\n"
f"⏰ 预警时间: {timestamp_bj} (北京时间)"
)
return self._send_message(text)
def send_anomaly(
self,
city_tag: str,
market_name: str,
detected_anomaly: str,
stats: dict,
whales: list,
current_price: float,
local_time: str = None,
):
"""发送市场异常推送"""
from datetime import datetime, timedelta
# UTC+8 北京时间
timestamp_bj = (datetime.utcnow() + timedelta(hours=8)).strftime("%H:%M")
whale_text = "\n".join([f"- {self._escape_html(w)}" for w in whales])
stats_text = "\n".join(
[
f"{self._escape_html(k)}: {self._escape_html(v)}"
for k, v in stats.items()
]
)
local_time_text = (
f"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
if local_time
else ""
)
text = (
f"👀 <b>市场异常 #{self._escape_html(city_tag)}</b>\n\n"
f"📍 城市: {self._escape_html(city_tag)}\n"
f"🏆 市场: {self._escape_html(market_name)}\n\n"
f"🚨 <b>检测到异常:</b>\n"
f"{self._escape_html(detected_anomaly)}\n"
f"{stats_text}\n\n"
f"🐋 <b>大户动向:</b>\n"
f"{whale_text}\n\n"
f"💰 当前价格: <b>{current_price}¢</b>\n"
f"═══════════════════\n"
f"{local_time_text}"
f"⏰ 信号时间: {timestamp_bj} (北京时间)"
)
return self._send_message(text)
def send_alert(
self,
city_tag: str,
market_name: str,
price: float,
trigger: str,
prev_price: float,
change: str,
quick_analysis: list,
local_time: str = None,
):
"""发送价格预警推送"""
from datetime import datetime, timedelta
# UTC+8 北京时间
timestamp_bj = (datetime.utcnow() + timedelta(hours=8)).strftime("%H:%M")
analysis_text = "\n".join(
[f"- {self._escape_html(item)}" for item in quick_analysis]
)
local_time_text = (
f"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
if local_time
else ""
)
text = (
f"⚡ <b>价格预警 #{self._escape_html(city_tag)}</b>\n\n"
f"📍 城市: {self._escape_html(city_tag)}\n"
f"🏆 市场: {self._escape_html(market_name)}\n"
f"💰 报价: <b>{price}¢ ↗️</b>\n\n"
f"触发条件: {self._escape_html(trigger)}\n"
f"变动详情: {prev_price}¢ -> {price}¢ ({self._escape_html(change)})\n\n"
f"📊 <b>快速分析:</b>\n"
f"{analysis_text}\n\n"
f"═══════════════════\n"
f"{local_time_text}"
f"⏰ 预警时间: {timestamp_bj} (北京时间)"
)
return self._send_message(text)
-29
View File
@@ -1,29 +0,0 @@
import unittest
import pandas as pd
import numpy as np
from src.models.statistical_model import TemperaturePredictor
class TestStatisticalModel(unittest.TestCase):
def setUp(self):
self.predictor = TemperaturePredictor()
# Mock data
self.history = [5.0, 5.2, 5.5, 5.8, 6.0, 6.2, 6.5] * 10
self.df = pd.DataFrame({
'date': pd.date_range(start='2023-01-01', periods=len(self.history)),
'temp': self.history
})
def test_feature_preparation(self):
prepared = self.predictor.prepare_features(self.df)
self.assertIn('day_of_year', prepared.columns)
self.assertIn('temp_lag_1', prepared.columns)
self.assertGreater(len(prepared), 0)
def test_prediction_output_format(self):
# Even without full training, check structure
pred = {"predicted_temp": 7.0, "confidence": 0.8}
self.assertIn('predicted_temp', pred)
self.assertIn('confidence', pred)
if __name__ == '__main__':
unittest.main()