Merge branch 'main' of https://github.com/yangyuan-zhen/PolyWeather
This commit is contained in:
@@ -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_
|
||||
@@ -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 系统技术文档_
|
||||
@@ -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) 实验室**
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🌡️ 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
|
||||
|
||||
@@ -8,19 +8,55 @@ An intelligent weather information bot designed to provide ultra-fast, live mete
|
||||
|
||||
- **Python 3.11+**
|
||||
- Dependencies: `pip install -r requirements.txt`
|
||||
<<<<<<< HEAD
|
||||
- **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
|
||||
# Windows
|
||||
py -3.11 run.py
|
||||
|
||||
# Linux/VPS
|
||||
python3 run.py
|
||||
git clone https://github.com/yangyuan-zhen/PolyWeather.git
|
||||
cd PolyWeather
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # Edit .env with your Token and API Keys
|
||||
```
|
||||
|
||||
_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 |
|
||||
| `/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
|
||||
|
||||
### 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 |
|
||||
| :----------------- | :---------------------- | :-------------- | :--------------------------------------------------------------------------------- |
|
||||
| **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. |
|
||||
| **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. |
|
||||
=======
|
||||
> ⚠️ **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.
|
||||
- **MGM Real-time Sync**: Specialized header camouflaging to bypass local Turkish API anti-crawling for Ankara.
|
||||
> 💡 The OM default forecast is essentially **one of the 5 models** (auto-selected), so it is **excluded from consensus scoring** to avoid double-counting.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
### 3. 🎯 Multi-Model Consensus Scoring
|
||||
|
||||
### 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.
|
||||
- **Multi-dimension Monitoring**: Includes "Feels Like" temperatures and 24h precipitation to assist in nuanced trade decisions.
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
PolyWeather uses a **Lightweight, Plugin-based** architecture for millisecond responses.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[/Telegram User/] --> Bot[bot_listener.py]
|
||||
Bot --> Collector[WeatherDataCollector]
|
||||
|
||||
subgraph "Data Engine"
|
||||
<<<<<<< HEAD
|
||||
Collector --> OM[Open-Meteo API]
|
||||
Collector --> MB[Meteoblue Weather API]
|
||||
Collector --> NOAA[METAR Data Center]
|
||||
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]
|
||||
end
|
||||
|
||||
Collector --> Processing[Smart Analysis & Formatting]
|
||||
Collector --> Processing[Consensus Scoring & Trend Analysis]
|
||||
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.
|
||||
- **Legacy Modules**: `main.py` contains the old automated trading engine. Focus has shifted to "assisted manual decision-making."
|
||||
- **Logic Decoupling**: `weather_sources.py` handles data fetching & parsing; `bot_listener.py` handles analysis & rendering.
|
||||
- **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
|
||||
|
||||
<<<<<<< HEAD
|
||||
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.
|
||||
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."
|
||||
=======
|
||||
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
@@ -1,6 +1,6 @@
|
||||
# 🌡️ PolyWeather: 实时天气查询与分析机器人
|
||||
|
||||
一个智能天气信息机器人,专为提供超快、实时的气象数据、高保真预报和智能趋势分析而设计。通过绕过网络缓存,直接从全球气象站获取最新数据。
|
||||
专为预测市场和天气博弈设计的智能天气机器人。通过绕过 CDN 缓存直接从全球气象站获取最新数据,并提供**模型共识评分**和**入场时机信号**等通俗易懂的自动趋势分析。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -8,19 +8,55 @@
|
||||
|
||||
- **Python 3.11+**
|
||||
- 依赖安装: `pip install -r requirements.txt`
|
||||
<<<<<<< HEAD
|
||||
- **环境变量**: 需在 `.env` 中配置 `METEOBLUE_API_KEY` 以激活伦敦高精度预报。
|
||||
=======
|
||||
- **环境变量**: 在 `.env` 中设置 `TELEGRAM_BOT_TOKEN`(必需)。可选设置 `METEOBLUE_API_KEY` 以激活伦敦高精度预报。
|
||||
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
|
||||
|
||||
### 本地运行 (Windows/Linux)
|
||||
### VPS 部署 (推荐)
|
||||
|
||||
**首次部署:**
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
py -3.11 run.py
|
||||
|
||||
# Linux/VPS
|
||||
python3 run.py
|
||||
git clone https://github.com/yangyuan-zhen/PolyWeather.git
|
||||
cd PolyWeather
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # 编辑 .env 填入你的 Token 和 API Key
|
||||
```
|
||||
|
||||
_注意:系统当前处于 **天气查询模式**。主动市场监控和自动交易模块已暂停。_
|
||||
**创建一键更新脚本(只需执行一次):**
|
||||
|
||||
```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 |
|
||||
| `/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 小时精细化温度曲线 |
|
||||
@@ -53,61 +118,181 @@ _注意:系统当前处于 **天气查询模式**。主动市场监控和自
|
||||
| **METAR** | **结算标准** | 全球机场 | Polymarket 结算参考的绝对真理,实时机场观测 |
|
||||
| **NWS** | 官方预测(美) | 仅限美国 | 美国国家气象局,对美国城市的极端天气预判准确 |
|
||||
| **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 伪装和时区校正。
|
||||
|
||||
### 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 之后的实测最高温。
|
||||
- **多维度监测**:集成体感温度 (`feels_like`) 和 24h 累计降雨量,辅助多维度判断。
|
||||
### 4. 📊 集合预报散度(新功能)
|
||||
|
||||
从 Open-Meteo 获取 51 成员集合预报,量化预测不确定性:
|
||||
|
||||
> 📊 **集合预报**:中位数 10.8°C,90% 区间 [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
|
||||
graph TD
|
||||
User[/Telegram User/] --> Bot[bot_listener.py]
|
||||
Bot --> Collector[WeatherDataCollector]
|
||||
|
||||
<<<<<<< HEAD
|
||||
subgraph "Data Engine"
|
||||
Collector --> OM[Open-Meteo API]
|
||||
Collector --> MB[Meteoblue Weather API]
|
||||
Collector --> NOAA[METAR Data Center]
|
||||
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]
|
||||
end
|
||||
|
||||
Collector --> Processing[智能分析 & 格式化]
|
||||
Collector --> Processing[共识评分 & 趋势分析]
|
||||
Processing --> Bot
|
||||
Bot --> Reponse[/精简版博弈快照/]
|
||||
Bot --> Response[/附带入场信号的天气快照/]
|
||||
```
|
||||
|
||||
- **逻辑解耦**:`weather_sources.py` 负责外部数据的解析;`bot_listener.py` 负责消息模板渲染。
|
||||
- **遗留模块说明**:项目根目录下的 `main.py` 包含旧版本的自动交易引擎。目前重心在“手动辅助决策”,如需开启请查阅 [MARKET_DISCOVERY_ZH.md](./MARKET_DISCOVERY_ZH.md)。
|
||||
- **逻辑解耦**:`weather_sources.py` 负责数据获取与解析;`bot_listener.py` 负责分析与渲染。
|
||||
- **城市配置**:`city_risk_profiles.py` 包含所有 METAR 机场映射和风险评估。
|
||||
- **多模型共识**:5 个独立 NWP 模型(ECMWF、GFS、ICON、GEM、JMA)提供稳健的共识评分。
|
||||
- **集合预报集成**:51 成员集合预报提供 P10/P90 不确定性区间和偏差检测。
|
||||
- **机场坐标对齐**:所有 NWP 查询均使用 METAR 机场坐标,而非市中心。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 博弈策略提示
|
||||
|
||||
<<<<<<< HEAD
|
||||
1. **检查模型共识**:查看 Open-Meteo 和 Meteoblue (MB) 是否达成共识。
|
||||
2. **关注峰值窗口**:在预测的峰值时段多次使用 `/city` 刷新。
|
||||
3. **数据权重优先级**:结算以 **METAR** 为准,趋势预测以 **MB** 为准(仅限伦敦)。
|
||||
4. **地理风险评估**:重点关注提示中的“偏差会显著放大”警告(如安卡拉、伦敦)。
|
||||
=======
|
||||
1. **看模型共识**:🎯/⚖️/⚠️ 评级让你一眼判断预报是否可靠。高共识 + 市场低定价 = 套利机会。
|
||||
2. **用入场信号**:等 ⏰ **理想** 或 **较好** 时机再下注。不确定性高时绝不提前入场。
|
||||
3. **关注集合散度**:90% 区间越窄(< 2°),模型置信越高 — 这才是 edge 所在。
|
||||
4. **紧盯峰值窗口**:在预测的峰值时段频繁使用 `/city` 刷新。
|
||||
5. **结算优先级**:结算永远以 **METAR** 数据为准,通过 Wunderground 四舍五入到整数。
|
||||
6. **地理风险**:重点关注高危城市(如首尔、芝加哥)的偏差警告。
|
||||
7. **太阳辐射线索**:如果机器人报告"暖平流驱动" 🌙,说明温度由暖空气推高 — 这种模式经常打破模型预测。
|
||||
8. **风向冲突**:METAR 和 MGM 风向相反时,温度波动风险增大。
|
||||
|
||||
---
|
||||
|
||||
_最后更新: 2026-02-22_
|
||||
>>>>>>> e575440acfd8b5f1e8c30e83dfcb972d26175729
|
||||
|
||||
+570
-17
@@ -1,21 +1,22 @@
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
import telebot
|
||||
from loguru import logger
|
||||
from typing import List, Dict, Any, Optional
|
||||
import telebot # type: ignore
|
||||
from loguru import logger # type: ignore
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from src.utils.config_loader import load_config
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.data_collection.city_risk_profiles import get_city_risk_profile, format_risk_warning
|
||||
from src.utils.config_loader import load_config # type: ignore
|
||||
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 # type: ignore
|
||||
|
||||
def analyze_weather_trend(weather_data, temp_symbol):
|
||||
"""根据实测与预测分析气温态势,增加峰值时刻预测"""
|
||||
insights = []
|
||||
insights: List[str] = []
|
||||
|
||||
metar = weather_data.get("metar", {})
|
||||
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"])
|
||||
if nws.get("today_high") is not None:
|
||||
forecast_highs.append(nws["today_high"])
|
||||
<<<<<<< HEAD
|
||||
if mgm.get("today_high") is not None:
|
||||
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_high = max(forecast_highs) if forecast_highs else None
|
||||
# 取最低值用于判断是否“已触及预报高位”
|
||||
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)
|
||||
|
||||
@@ -56,15 +72,156 @@ def analyze_weather_trend(weather_data, temp_symbol):
|
||||
local_date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
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 > forecast_high + 0.5:
|
||||
<<<<<<< HEAD
|
||||
# 实测已超所有预报!
|
||||
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>:市场需重新评估,当前可能存在极端异常增温。")
|
||||
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 逐小时数据为准) ---
|
||||
hourly = open_meteo.get("hourly", {})
|
||||
times = hourly.get("time", [])
|
||||
@@ -79,6 +236,7 @@ def analyze_weather_trend(weather_data, temp_symbol):
|
||||
hour = t_str.split("T")[1][:5]
|
||||
peak_hours.append(hour)
|
||||
|
||||
<<<<<<< HEAD
|
||||
if peak_hours:
|
||||
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> 之间。")
|
||||
@@ -119,20 +277,82 @@ def analyze_weather_trend(weather_data, temp_symbol):
|
||||
# 回退逻辑
|
||||
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. 湿度与露点分析 (仅在傍晚以后)
|
||||
humidity = metar.get("current", {}).get("humidity")
|
||||
dewpoint = metar.get("current", {}).get("dewpoint")
|
||||
|
||||
if local_hour >= 18:
|
||||
if humidity and humidity > 80:
|
||||
<<<<<<< HEAD
|
||||
insights.append(f"💦 <b>闷热高湿</b>:湿度极高 ({humidity}%),将显著锁住夜间热量。")
|
||||
if dewpoint is not None and curr_temp - dewpoint < 2.0:
|
||||
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. 风力
|
||||
if wind_speed >= 15:
|
||||
insights.append(f"🌬️ <b>大风预判</b>:当前风力较大 ({wind_speed}kt),气温可能出现非线性波动。")
|
||||
insights.append(f"🌬️ <b>风很大</b>:风速 {wind_speed}kt,温度可能会忽高忽低。")
|
||||
elif wind_speed >= 10:
|
||||
<<<<<<< HEAD
|
||||
insights.append(f"🍃 <b>清劲风</b>:空气流动快,虽然有助于散热,但在升温期可能带来暖平流加速。")
|
||||
|
||||
# 4. 云层遮挡分析 (仅在升温期/峰值期有意义)
|
||||
@@ -147,10 +367,28 @@ def analyze_weather_trend(weather_data, temp_symbol):
|
||||
elif cover in ["SKC", "CLR", "FEW"]:
|
||||
if not is_peak_passed:
|
||||
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. 特殊天气现象
|
||||
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:
|
||||
<<<<<<< HEAD
|
||||
if any(x in wx_desc.upper() for x in ["RA", "DZ", "RAIN", "DRIZZLE"]):
|
||||
insights.append(f"🌧️ <b>降雨压制</b>:当前有降雨,蒸发吸热将显著抑制升温。")
|
||||
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>:正从低纬度输送暖平流,气温仍有向上突围的潜力。")
|
||||
except (TypeError, ValueError):
|
||||
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:
|
||||
visibility = metar.get("current", {}).get("visibility_mi")
|
||||
if visibility is not None:
|
||||
vis_val = float(str(visibility).replace("+", "").replace("-", ""))
|
||||
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):
|
||||
pass
|
||||
|
||||
<<<<<<< HEAD
|
||||
# 7. 模型准确度预警 (针对用户反馈的 MB 偏高问题)
|
||||
if is_peak_passed and max_so_far is not None:
|
||||
model_checks = []
|
||||
@@ -192,13 +503,142 @@ def analyze_weather_trend(weather_data, temp_symbol):
|
||||
mb_h = mb.get("today_high")
|
||||
if mb_h and mb_h > max_so_far + 1.5:
|
||||
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")
|
||||
if nws_h and nws_h > max_so_far + 1.5:
|
||||
model_checks.append(f"NWS ({nws_h}{temp_symbol})")
|
||||
|
||||
if model_checks:
|
||||
<<<<<<< HEAD
|
||||
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:
|
||||
return ""
|
||||
@@ -234,10 +674,6 @@ def start_bot():
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@bot.message_handler(commands=["signal", "portfolio", "status"])
|
||||
def disabled_feature(message):
|
||||
bot.reply_to(message, "ℹ️ 监控引擎与交易模拟功能已暂停,现仅提供天气查询服务。")
|
||||
|
||||
@bot.message_handler(commands=["city"])
|
||||
def get_city_info(message):
|
||||
"""查询指定城市的天气详情"""
|
||||
@@ -269,27 +705,58 @@ def start_bot():
|
||||
"atl": "atlanta", "亚特兰大": "atlanta",
|
||||
"dal": "dallas", "达拉斯": "dallas",
|
||||
"la": "los angeles", "洛杉矶": "los angeles",
|
||||
"par": "paris", "巴黎": "paris",
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
# 1. 第一优先级:严格全字匹配
|
||||
city_name = STANDARD_MAPPING.get(city_input)
|
||||
|
||||
# 2. 第二优先级:如果长度 >= 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():
|
||||
if k.startswith(city_input):
|
||||
city_name = v
|
||||
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:
|
||||
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()} 的天气数据...")
|
||||
|
||||
coords = weather.get_coordinates(city_name)
|
||||
if not coords:
|
||||
bot.reply_to(message, f"❌ 未找到城市: {city_name}")
|
||||
bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
|
||||
return
|
||||
|
||||
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}")
|
||||
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) ---
|
||||
# 基础数据优先用 METAR
|
||||
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_time = metar.get("current", {}).get("max_temp_time") if metar else None
|
||||
obs_t_str = "N/A"
|
||||
main_source = "METAR" if metar else "MGM"
|
||||
|
||||
@@ -394,7 +875,63 @@ def start_bot():
|
||||
m_time = m_time.split(" ")[1][:5]
|
||||
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:
|
||||
m_c = mgm.get("current", {})
|
||||
@@ -406,7 +943,23 @@ def start_bot():
|
||||
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] 🌬️ {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:
|
||||
m_c = metar.get("current", {})
|
||||
|
||||
@@ -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
@@ -1,68 +1,25 @@
|
||||
# API Configuration
|
||||
api:
|
||||
polymarket:
|
||||
base_url: "https://clob.polymarket.com"
|
||||
ws_url: "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
||||
timeout: 30
|
||||
retry_attempts: 3
|
||||
api_key: "019c2d40-5d23-75a6-ab33-02ae5d2a033e"
|
||||
# Weather API Configuration
|
||||
weather:
|
||||
meteoblue_api_key: null # Set via METEOBLUE_API_KEY env var
|
||||
timeout: 30
|
||||
|
||||
weather:
|
||||
openweather:
|
||||
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
|
||||
# Target Cities
|
||||
cities:
|
||||
- id: "london"
|
||||
city: "London"
|
||||
country: "UK"
|
||||
latitude: 51.5074
|
||||
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"
|
||||
city: "New York"
|
||||
country: "USA"
|
||||
@@ -79,9 +36,3 @@ logging:
|
||||
level: "INFO"
|
||||
rotation: "10 MB"
|
||||
retention: "10 days"
|
||||
|
||||
# Scheduler
|
||||
scheduler:
|
||||
data_refresh_interval: 60 # seconds
|
||||
model_update_interval: 300 # seconds
|
||||
risk_check_interval: 30 # seconds
|
||||
|
||||
@@ -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") + "*")
|
||||
@@ -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()
|
||||
@@ -1,48 +1,24 @@
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
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():
|
||||
logger.info("🌟 PolyWeather 全功能系统正在初始化...")
|
||||
|
||||
# 创建共享文件夹 (如果不存在)
|
||||
if not os.path.exists("data"):
|
||||
os.makedirs("data")
|
||||
logger.info("🌡️ PolyWeather 天气查询机器人启动中...")
|
||||
|
||||
# 创建两个线程并行运行
|
||||
monitor_thread = threading.Thread(target=run_monitor, daemon=True)
|
||||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||||
# 创建数据目录
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
# 启动线程
|
||||
# monitor_thread.start()
|
||||
bot_thread.start()
|
||||
|
||||
logger.success("🚀 系统已上线(天气查询模式)!")
|
||||
logger.info("已暂停监控引擎和自动发现市场功能。")
|
||||
logger.info("现在仅支持直接查询各城市实时天气与 Open-Meteo 预测。")
|
||||
# 直接运行 bot_listener
|
||||
cmd = [sys.executable, "bot_listener.py"]
|
||||
logger.success("🚀 已上线!等待 Telegram 指令...")
|
||||
|
||||
try:
|
||||
# 保持主进程运行
|
||||
while True:
|
||||
time.sleep(1)
|
||||
subprocess.run(cmd, cwd=os.getcwd())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("停止运行...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -77,6 +77,18 @@ CITY_RISK_PROFILES = {
|
||||
"warning": "距离远但地形平坦,偏差稳定可预测",
|
||||
"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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -33,6 +33,7 @@ class WeatherDataCollector:
|
||||
"toronto": "CYYZ", # Toronto Pearson
|
||||
"wellington": "NZWN", # Wellington International
|
||||
"buenos aires": "SAEZ", # Ezeiza International
|
||||
"paris": "LFPG", # Charles de Gaulle
|
||||
}
|
||||
|
||||
def __init__(self, config: dict):
|
||||
@@ -260,6 +261,7 @@ class WeatherDataCollector:
|
||||
utc_midnight = local_midnight - timedelta(seconds=utc_offset)
|
||||
|
||||
max_so_far_c = -999
|
||||
max_temp_time = None
|
||||
for obs in data:
|
||||
obs_report_time = obs.get("reportTime", "")
|
||||
try:
|
||||
@@ -271,6 +273,9 @@ class WeatherDataCollector:
|
||||
t = obs.get("temp")
|
||||
if t is not None and t > max_so_far_c:
|
||||
max_so_far_c = t
|
||||
# 转为当地时间并记录
|
||||
local_report = report_dt + timedelta(seconds=utc_offset)
|
||||
max_temp_time = local_report.strftime("%H:%M")
|
||||
except:
|
||||
continue
|
||||
|
||||
@@ -295,6 +300,7 @@ class WeatherDataCollector:
|
||||
"current": {
|
||||
"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_time": max_temp_time,
|
||||
"dewpoint": round(dewp, 1) if dewp is not None else None,
|
||||
"humidity": latest.get("rh"),
|
||||
"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_dir": latest.get("ruzgarYon"),
|
||||
"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"), # 观测时间
|
||||
"station_name": latest.get("istasyonAd") or latest.get("adi") or latest.get("merkezAd") or "Ankara Esenboğa"
|
||||
}
|
||||
|
||||
# 2. 每日预报
|
||||
daily_resp = self.session.get(f"{base_url}/tahminler/gunluk?istno={istno}", headers=headers, timeout=self.timeout)
|
||||
if daily_resp.status_code == 200:
|
||||
forecasts = daily_resp.json()
|
||||
if forecasts and isinstance(forecasts, list):
|
||||
today = forecasts[0]
|
||||
results["today_high"] = today.get("enYuksekGun1")
|
||||
results["today_low"] = today.get("enDusukGun1")
|
||||
# 2. 每日预报(尝试两个可能的 API 路径)
|
||||
forecast_urls = [
|
||||
f"{base_url}/tahminler/gunluk?istno={istno}",
|
||||
f"https://servis.mgm.gov.tr/api/tahminler/gunluk?istno={istno}",
|
||||
]
|
||||
for forecast_url in forecast_urls:
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
@@ -445,8 +473,8 @@ class WeatherDataCollector:
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current_weather": "true",
|
||||
"hourly": "temperature_2m",
|
||||
"daily": "temperature_2m_max,apparent_temperature_max",
|
||||
"hourly": "temperature_2m,shortwave_radiation",
|
||||
"daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
|
||||
"timezone": "auto",
|
||||
"forecast_days": forecast_days,
|
||||
"_t": int(time.time()), # 禁用缓存,强制刷新
|
||||
@@ -526,6 +554,169 @@ class WeatherDataCollector:
|
||||
logger.error(f"Open-Meteo forecast failed: {e}")
|
||||
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(
|
||||
self,
|
||||
lat: float,
|
||||
@@ -633,22 +824,23 @@ class WeatherDataCollector:
|
||||
"""
|
||||
使用 Open-Meteo Geocoding API 获取城市坐标 (免费, 无需 Key)
|
||||
"""
|
||||
# 预设常用城市坐标,避免网络波动导致启动失败
|
||||
# 坐标使用 METAR 机场位置(Polymarket 以机场数据结算)
|
||||
static_coords = {
|
||||
"london": {"lat": 51.5074, "lon": -0.1278},
|
||||
"new york": {"lat": 40.7128, "lon": -74.0060},
|
||||
"london": {"lat": 51.5053, "lon": 0.0553}, # EGLC London City
|
||||
"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},
|
||||
"nyc": {"lat": 40.7128, "lon": -74.0060},
|
||||
"seattle": {"lat": 47.6062, "lon": -122.3321},
|
||||
"chicago": {"lat": 41.8781, "lon": -87.6298},
|
||||
"dallas": {"lat": 32.7767, "lon": -96.7970},
|
||||
"miami": {"lat": 25.7617, "lon": -80.1918},
|
||||
"atlanta": {"lat": 33.7490, "lon": -84.3880},
|
||||
"seoul": {"lat": 37.5665, "lon": 126.9780},
|
||||
"toronto": {"lat": 43.6532, "lon": -79.3832},
|
||||
"ankara": {"lat": 39.9334, "lon": 32.8597},
|
||||
"wellington": {"lat": -41.2865, "lon": 174.7762},
|
||||
"buenos aires": {"lat": -34.6037, "lon": -58.3816},
|
||||
"nyc": {"lat": 40.7750, "lon": -73.8750}, # KLGA LaGuardia
|
||||
"seattle": {"lat": 47.4499, "lon": -122.3118}, # KSEA Sea-Tac
|
||||
"chicago": {"lat": 41.9769, "lon": -87.9081}, # KORD O'Hare
|
||||
"dallas": {"lat": 32.8459, "lon": -96.8509}, # KDAL Love Field
|
||||
"miami": {"lat": 25.7933, "lon": -80.2906}, # KMIA International
|
||||
"atlanta": {"lat": 33.6367, "lon": -84.4281}, # KATL Hartsfield-Jackson
|
||||
"seoul": {"lat": 37.4691, "lon": 126.4510}, # RKSI Incheon
|
||||
"toronto": {"lat": 43.6759, "lon": -79.6294}, # CYYZ Pearson
|
||||
"ankara": {"lat": 40.1281, "lon": 32.9950}, # LTAC Esenboğa
|
||||
"wellington": {"lat": -41.3272, "lon": 174.8053}, # NZWN Wellington
|
||||
"buenos aires": {"lat": -34.8222, "lon": -58.5358}, # SAEZ Ezeiza
|
||||
}
|
||||
|
||||
normalized_city = city.lower().strip()
|
||||
@@ -798,6 +990,16 @@ class WeatherDataCollector:
|
||||
nws_data = self.fetch_nws(lat, lon)
|
||||
if 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:
|
||||
# Open-Meteo 失败时,仍然尝试获取 METAR 和 NWS
|
||||
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": []}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user