From 9617425c6e0b2b5e01898348f1f0e026cf0ceb9f Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Thu, 5 Feb 2026 19:52:02 +0800
Subject: [PATCH] feat: Initialize PolyWeather project structure including
modules for data collection, analysis, strategy, trading, utilities,
configuration, and a Streamlit dashboard.
---
.env.example | 14 +
.gitignore | 27 ++
README.md | 78 ++++
README_EN.md | 79 ++++
bot_listener.py | 103 +++++
check_tg.py | 21 +
config/config.yaml | 87 ++++
dashboard/streamlit_app.py | 200 +++++++++
main.py | 434 ++++++++++++++++++
run.py | 47 ++
src/__init__.py | 0
src/analysis/__init__.py | 0
src/analysis/orderbook_analyzer.py | 77 ++++
src/analysis/technical_indicators.py | 146 +++++++
src/analysis/volume_analyzer.py | 135 ++++++
src/analysis/whale_tracker.py | 59 +++
src/data_collection/__init__.py | 0
src/data_collection/onchain_tracker.py | 56 +++
src/data_collection/polymarket_api.py | 579 +++++++++++++++++++++++++
src/data_collection/weather_sources.py | 436 +++++++++++++++++++
src/models/__init__.py | 0
src/models/statistical_model.py | 285 ++++++++++++
src/strategy/__init__.py | 0
src/strategy/decision_engine.py | 198 +++++++++
src/strategy/position_manager.py | 153 +++++++
src/strategy/risk_manager.py | 155 +++++++
src/trading/__init__.py | 0
src/trading/order_executor.py | 219 ++++++++++
src/utils/__init__.py | 0
src/utils/config_loader.py | 52 +++
src/utils/logger.py | 27 ++
src/utils/notifier.py | 232 ++++++++++
tests/test_models.py | 29 ++
33 files changed, 3928 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 README_EN.md
create mode 100644 bot_listener.py
create mode 100644 check_tg.py
create mode 100644 config/config.yaml
create mode 100644 dashboard/streamlit_app.py
create mode 100644 main.py
create mode 100644 run.py
create mode 100644 src/__init__.py
create mode 100644 src/analysis/__init__.py
create mode 100644 src/analysis/orderbook_analyzer.py
create mode 100644 src/analysis/technical_indicators.py
create mode 100644 src/analysis/volume_analyzer.py
create mode 100644 src/analysis/whale_tracker.py
create mode 100644 src/data_collection/__init__.py
create mode 100644 src/data_collection/onchain_tracker.py
create mode 100644 src/data_collection/polymarket_api.py
create mode 100644 src/data_collection/weather_sources.py
create mode 100644 src/models/__init__.py
create mode 100644 src/models/statistical_model.py
create mode 100644 src/strategy/__init__.py
create mode 100644 src/strategy/decision_engine.py
create mode 100644 src/strategy/position_manager.py
create mode 100644 src/strategy/risk_manager.py
create mode 100644 src/trading/__init__.py
create mode 100644 src/trading/order_executor.py
create mode 100644 src/utils/__init__.py
create mode 100644 src/utils/config_loader.py
create mode 100644 src/utils/logger.py
create mode 100644 src/utils/notifier.py
create mode 100644 tests/test_models.py
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..198c1099
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+# Polymarket API Credentials
+POLYMARKET_API_KEY=your_api_key_here
+
+# Telegram Bot
+TELEGRAM_BOT_TOKEN=your_bot_token_here
+TELEGRAM_CHAT_ID=your_chat_id_here
+
+# Proxy Setting (optional)
+HTTPS_PROXY=http://127.0.0.1:7890
+HTTP_PROXY=http://127.0.0.1:7890
+
+# Other Settings
+LOG_LEVEL=INFO
+ENV=production
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..e138d79f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# Secrets
+.env
+
+# Data and Logs
+data/*.json
+data/logs/
+data/historical/
+data/cache/
+data/models/
+logs/
+
+# Python
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+env/
+venv/
+.venv/
+pip-log.txt
+pip-delete-this-directory.txt
+.history/
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..511822a8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+# 🌡️ PolyWeather: Polymarket 天气交易监控系统
+
+基于多源实时气象数据与 Polymarket 市场定价偏差分析的智能监控与指令系统。
+
+## 🚀 运行指令 (一键启动)
+
+```powershell
+python run.py
+```
+
+该命令会同时启动:
+
+1. **监控引擎**: 负责 7x24h 扫描并推送 **85¢-95¢ 价格预警** 与 **市场异常**。
+2. **指令监听器**: 监听电报指令并返回实时信号。
+
+---
+
+## 🤖 电报机器人指令集
+
+| 指令 | 描述 | 用法 |
+| :-------- | :--------------- | :---------------------------- |
+| `/signal` | **获取交易信号** | 返回当前最值得关注的 3 个档位 |
+| `/status` | **检查系统状态** | 确认监控引擎是否在线 |
+| `/help` | **指令帮助** | 显示所有可用指令 |
+
+---
+
+## 📢 推送维度说明
+
+### 1. 📂 城市预警汇总 (主动推送)
+
+- **优化机制**: 同一轮扫描中,同一城市的所有异动将**合并为一条消息**发送,拒绝刷屏。
+- **触发内容**: 包含该城市下所有符合条件的“价格预警”与“市场异常”。
+
+### 2. ⚡ 价格预警
+
+- **触发条件**: Buy Yes 或 Buy No 价格在 **85¢-95¢** 区间。
+- **用途**: 高胜率/即将锁定区间提醒,适合平仓或收割。
+
+### 3. 👀 市场异常
+
+- **大户入场**: 检测到单笔 >$5000 的大额交易且买卖比失衡。
+- **异常交易流**: 成交量突然放大 (>2倍历史标准差)。
+
+### 4. 🎯 交易信号 (指令查询)
+
+- 对比气象预报与市场价格偏差。
+- 包含:城市、档位、当地时间、预期温度(含单位自适应)、偏差评分。
+
+---
+
+## 🛠️ 环境配置 (.env)
+
+```bash
+# Telegram 机器人
+TELEGRAM_BOT_TOKEN=your_bot_token
+TELEGRAM_CHAT_ID=your_chat_id
+
+# Polymarket API (用于批量获取实时盘口价格与交易历史)
+POLYMARKET_API_KEY=019c2d40-5d23-75a6-ab33-02ae5d2a033e
+
+# 代理设置 (如需要)
+HTTPS_PROXY=http://127.0.0.1:7890
+HTTP_PROXY=http://127.0.0.1:7890
+```
+
+---
+
+## 📋 核心功能特性
+
+- ✅ **智能合并推送**: 按城市汇总预警,界面整洁不刷屏。
+- ✅ **极速价格同步**: 采用批量 API 接口,一次请求同步全量城市盘口价,无延迟、无 404。
+- ✅ **北京时间适配**: 所有推送时间戳已自动转换为北京时间 (UTC+8)。
+- ✅ **智能日期选择**: 自动定位最早的活跃市场日期,结算后自动顺延。
+- ✅ **温度单位自适应**: 美国市场自动切换华氏度 (°F),其他地区显示摄氏度 (°C)。
+- ✅ **全量数据持久化**: 信号记录与推送历史保存至本地 JSON,重启不丢失,不重复。
+
+---
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 00000000..e22b6330
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,79 @@
+# 🌡️ PolyWeather: Polymarket Weather Trading Monitor
+
+An intelligent monitoring and alerting system based on multi-source real-time meteorological data and Polymarket market pricing deviation analysis.
+
+## 🚀 Quick Start
+
+```bash
+python run.py
+```
+
+This command launches:
+
+1. **Monitoring Engine**: Scans markets 24/7, providing **85¢-95¢ Price Alerts** and **Market Anomalies**.
+2. **Command Listener**: Handles Telegram commands and returns real-time signals.
+
+---
+
+## 🤖 Telegram Bot Commands
+
+| Command | Description | Usage |
+| :-------- | :---------------------- | :------------------------------------------- |
+| `/signal` | **Get Trading Signals** | Returns top 3 markets with highest deviation |
+| `/status` | **Check Status** | Confirm if the monitoring engine is online |
+| `/help` | **Help** | Display all available commands |
+
+---
+
+## 📢 Alerting Dimensions
+
+### 1. 📂 City Alert Summaries (Push)
+
+- **Optimization**: All anomalies for the same city are merged into a **single report** per scan cycle to prevent spamming.
+- **Content**: Includes Price Alerts and Market Anomalies (Whales/Volume).
+
+### 2. ⚡ Price Alerts
+
+- **Trigger**: Buy Yes or Buy No price enters the **85¢-95¢** range.
+- **Purpose**: High-probability / Near-settlement reminders, ideal for closing or reaping positions.
+
+### 3. 👀 Market Anomalies
+
+- **Whale Inflow**: Detection of large single trades (>$5,000) with imbalanced buy/sell ratios.
+- **Volume Spikes**: Sudden increase in trading volume (>2x historical standard deviation).
+
+### 4. 🎯 Trading Signals (Query)
+
+- Comparison between weather forecasts and market pricing.
+- Includes: City, bucket, local time, expected temperature (unit-aware), and deviation score.
+
+---
+
+## 🛠️ Configuration (.env)
+
+Duplicate `.env.example` to `.env` and fill in your credentials:
+
+```bash
+# Telegram Bot
+TELEGRAM_BOT_TOKEN=your_bot_token
+TELEGRAM_CHAT_ID=your_chat_id
+
+# Polymarket API (Used for real-time prices & trade history)
+POLYMARKET_API_KEY=019c2d40-5d23-75a6-ab33-02ae5d2a033e
+
+# Proxy (Optional)
+HTTPS_PROXY=http://127.0.0.1:7890
+```
+
+---
+
+## 📋 Core Features
+
+- ✅ **Smart Merged Push**: City-based alert aggregation for a clean interface.
+- ✅ **High-Speed Price Sync**: Utilizes CLOB batch API for instant price updates without 404s.
+- ✅ **Timezone Adaptation**: All timestamps are automatically adjusted to Beijing Time (UTC+8).
+- ✅ **Smart Date Selection**: Automatically targets the earliest active market date and rolls over after settlement.
+- ✅ **Unit Sensitivity**: US markets display Fahrenheit (°F), while others use Celsius (°C).
+- ✅ **Data Persistence**: Local JSON storage for signals and push history to ensure no duplicates after restart.
+
+---
diff --git a/bot_listener.py b/bot_listener.py
new file mode 100644
index 00000000..5f85ac71
--- /dev/null
+++ b/bot_listener.py
@@ -0,0 +1,103 @@
+import telebot
+import json
+import os
+import time
+import re
+from datetime import datetime
+from src.utils.config_loader import load_config
+from src.utils.notifier import TelegramNotifier
+
+
+def start_bot():
+ config = load_config()
+ bot_token = config["telegram"]["bot_token"]
+ chat_id = config["telegram"]["chat_id"]
+
+ if not bot_token:
+ print("Error: TELEGRAM_BOT_TOKEN not found.")
+ return
+
+ bot = telebot.TeleBot(bot_token)
+ notifier = TelegramNotifier(config["telegram"])
+
+ print(f"Bot is starting and listening for commands...")
+
+ @bot.message_handler(commands=["start", "help"])
+ def send_welcome(message):
+ welcome_text = (
+ "🌡️ PolyWeather 监控机器人\n\n"
+ "可用指令:\n"
+ "/signal - 获取当前高置信度交易信号\n"
+ "/status - 检查监控系统状态\n"
+ "/id - 获取当前聊天的 Chat ID\n\n"
+ "💡 直接输入城市名称 (如: Seattle 或 London) 即可查询该城市当天的最高温市场报价。"
+ )
+ bot.reply_to(message, welcome_text, parse_mode="HTML")
+
+ @bot.message_handler(commands=["id"])
+ def get_chat_id(message):
+ bot.reply_to(
+ message,
+ f"🎯 当前聊天的 Chat ID 是: {message.chat.id}",
+ parse_mode="HTML",
+ )
+ print(f"USER REQUEST IDENTIFIER: Chat ID found: {message.chat.id}")
+
+ @bot.message_handler(commands=["signal"])
+ def get_signals(message):
+ # 仅响应授权的 Chat ID (可选)
+ # if str(message.chat.id) != str(chat_id): return
+
+ bot.send_message(message.chat.id, "🔍 正在检索当前最值得关注的天气信号...")
+
+ try:
+ if not os.path.exists("data/active_signals.json"):
+ bot.send_message(
+ message.chat.id, "📭 目前暂无活跃信号,请等待系统完成下一轮扫描。"
+ )
+ return
+
+ with open("data/active_signals.json", "r", encoding="utf-8") as f:
+ signals = json.load(f)
+
+ if not signals:
+ bot.send_message(
+ message.chat.id, "📭 当前市场定价较为合理,暂无高偏差机会。"
+ )
+ return
+
+ # 按分数排序并取前 3 个
+ sorted_signals = sorted(
+ signals.values(), key=lambda x: x["score"], reverse=True
+ )[:3]
+
+ for s in sorted_signals:
+ notifier.send_signal(
+ market_name=s["city"],
+ full_title=s["full_title"],
+ option=s["option"],
+ score=round(s["score"] * 5, 1),
+ prediction=s["prediction"],
+ confidence=int(s["score"] * 100),
+ analysis_list=[f"偏差解析: {s['rationale']}"],
+ price=s["price"],
+ market_url=s["url"],
+ local_time=s["local_time"],
+ target_date=s["target_date"],
+ )
+ time.sleep(0.5)
+
+ except Exception as e:
+ bot.send_message(message.chat.id, f"❌ 获取信号时出错: {e}")
+
+ @bot.message_handler(commands=["status"])
+ def get_status(message):
+ bot.reply_to(
+ message, "✅ 监控引擎正在运行中...\n7x24h 实时扫码 Polymarket 气温市场。"
+ )
+
+ bot.infinity_polling()
+
+
+if __name__ == "__main__":
+ start_bot()
diff --git a/check_tg.py b/check_tg.py
new file mode 100644
index 00000000..78377fa0
--- /dev/null
+++ b/check_tg.py
@@ -0,0 +1,21 @@
+import requests
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+def get_updates():
+ token = os.getenv("TELEGRAM_BOT_TOKEN")
+ proxy = os.getenv("HTTPS_PROXY")
+ proxies = {"http": proxy, "https": proxy} if proxy else None
+
+ url = f"https://api.telegram.org/bot{token}/getUpdates"
+ try:
+ resp = requests.get(url, proxies=proxies)
+ data = resp.json()
+ print(f"Updates: {data}")
+ except Exception as e:
+ print(f"Error: {e}")
+
+if __name__ == "__main__":
+ get_updates()
diff --git a/config/config.yaml b/config/config.yaml
new file mode 100644
index 00000000..400877f4
--- /dev/null
+++ b/config/config.yaml
@@ -0,0 +1,87 @@
+# 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:
+ 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
+ - id: "london"
+ city: "London"
+ country: "UK"
+ latitude: 51.5074
+ longitude: -0.1278
+ - id: "new_york"
+ city: "New York"
+ country: "USA"
+ latitude: 40.7128
+ longitude: -74.0060
+ - id: "chicago"
+ city: "Chicago"
+ country: "USA"
+ latitude: 41.8781
+ longitude: -87.6298
+
+# Logging
+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
diff --git a/dashboard/streamlit_app.py b/dashboard/streamlit_app.py
new file mode 100644
index 00000000..5156dd94
--- /dev/null
+++ b/dashboard/streamlit_app.py
@@ -0,0 +1,200 @@
+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("""
+
+""", 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") + "*")
diff --git a/main.py b/main.py
new file mode 100644
index 00000000..dae7e6ea
--- /dev/null
+++ b/main.py
@@ -0,0 +1,434 @@
+import sys
+import time
+import os
+import json
+from datetime import datetime
+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.volume_analyzer import VolumeAnalyzer
+from src.analysis.orderbook_analyzer import OrderbookAnalyzer
+from src.analysis.technical_indicators import TechnicalIndicators
+from src.analysis.whale_tracker import WhaleTracker
+from src.strategy.decision_engine import DecisionEngine
+from src.strategy.risk_manager import RiskManager
+from src.utils.notifier import TelegramNotifier
+
+
+def main():
+ """
+ Polymarket 交易系统主循环 - 监控与推送模式
+ """
+ # 1. 设置日志
+ setup_logger()
+ logger.info("正在启动 Polymarket 天气交易信号监控系统...")
+
+ # 2. 加载配置
+ try:
+ config_data = load_config()
+ logger.info("配置加载成功。")
+ except Exception as e:
+ logger.error(f"配置加载失败: {e}")
+ sys.exit(1)
+
+ # 3. 初始化组件
+ polymarket = PolymarketClient(config_data["polymarket"])
+ weather = WeatherDataCollector(config_data["weather"])
+ onchain = OnchainTracker(config_data["polymarket"], polymarket)
+ notifier = TelegramNotifier(config_data["telegram"])
+
+ predictor = TemperaturePredictor()
+ volume_analyzer = VolumeAnalyzer()
+ orderbook_analyzer = OrderbookAnalyzer()
+ tech_indicators = TechnicalIndicators()
+ whale_tracker = WhaleTracker(config_data, onchain)
+
+ decision_engine = DecisionEngine(config_data)
+ risk_manager = RiskManager(config_data)
+
+ # 发送启动通知
+ notifier._send_message(
+ "🚀 Polymarket 天气监控系统启动成功\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 = {}
+
+ 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. 批量同步盘口价格 (优化:一次请求获取所有市场的 Buy Yes/No)
+ 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 = []
+ if ts and len(ts) >= 2:
+ price_requests.append({"token_id": ts[0], "side": "ask"}) # Buy Yes
+ price_requests.append({"token_id": ts[1], "side": "ask"}) # Buy No
+
+ if 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()
+
+ for i, m in enumerate(all_weather_markets):
+ c_id = m.get("condition_id")
+ if c_id in seen_condition_ids:
+ continue # 跳过重复
+ seen_condition_ids.add(c_id)
+
+ # 注入实时批量价格
+ ts = m.get("tokens", [])
+ if isinstance(ts, str):
+ try:
+ ts = json.loads(ts)
+ except:
+ ts = []
+ if ts and len(ts) >= 2:
+ m["buy_yes_live"] = token_price_map.get(ts[0])
+ m["buy_no_live"] = token_price_map.get(ts[1])
+
+ # 优先使用发现阶段已经识别出的城市名
+ city = m.get("city")
+
+ # 如果发现阶段没识别出,再尝试从问题文本提取
+ if not city or city == "Unknown":
+ full_context = f"{m.get('event_title', '')} {m.get('question', '')}"
+ 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
+
+ logger.info(
+ f"☁️ {city} 当前气温: {consensus['average_temp']}°C | 监控合约: {len(city_markets)}"
+ )
+
+ # --- 本城市汇总预警缓存 ---
+ city_alerts = []
+ city_local_time = None
+
+ # B. 遍历该城市所有合约
+ for market in city_markets:
+ market_id = market.get("condition_id")
+ question = market.get("question", "未知市场")
+ event_title = market.get("event_title", "")
+
+ # (日期处理逻辑保持不变...)
+ 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
+
+ # --- 价格获取逻辑 ---
+ buy_yes_price = market.get("buy_yes_live")
+ buy_no_price = market.get("buy_no_live")
+ current_price = 0.5
+ gamma_prices = market.get("prices", [])
+ if isinstance(gamma_prices, str):
+ try:
+ gamma_prices = json.loads(gamma_prices)
+ except:
+ gamma_prices = []
+ if gamma_prices and len(gamma_prices) > 0:
+ current_price = float(gamma_prices[0])
+
+ if buy_yes_price is None:
+ buy_yes_price = current_price
+ if buy_no_price is None:
+ buy_no_price = 1.0 - current_price
+
+ # 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")
+ )
+
+ 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),
+ "buy_no": int(buy_no_price * 100),
+ "url": f"https://polymarket.com/event/{market.get('slug')}",
+ "local_time": city_local_time,
+ "target_date": target_date,
+ "score": 0,
+ "rationale": "ACTIVE",
+ }
+
+ if buy_yes_price <= 0.01 or buy_yes_price >= 0.99:
+ cache_entry["rationale"] = "ENDED"
+ all_markets_cache[market_id] = cache_entry
+ continue
+
+ # D. 评分
+ 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=whale_tracker.analyze_market_whales(
+ market_id
+ ),
+ )
+ cache_entry["score"] = signal["final_score"]
+ cache_entry["rationale"] = signal.get("recommendation", "N/A")
+ all_markets_cache[market_id] = cache_entry
+
+ # --- 预警收集 ---
+ # 1. 价格预警
+ if (0.85 <= buy_yes_price <= 0.95) or (
+ 0.85 <= buy_no_price <= 0.95
+ ):
+ alert_key = f"alert_{market_id}_range_85_95"
+ if alert_key not in pushed_signals:
+ 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)
+ )
+ city_alerts.append(
+ {
+ "type": "price",
+ "market": question,
+ "msg": f"{trigger_side}进入锁定区间 {trigger_price}¢",
+ }
+ )
+ pushed_signals[alert_key] = time.time()
+
+ # 2. 市场异常
+ whale_sig = signal["factor_details"].get("whale", {})
+ volume_sig = signal["factor_details"].get("volume", {})
+ if (
+ whale_sig.get("signal")
+ in ["STRONG_ACCUMULATION", "STRONG_DISTRIBUTION"]
+ or volume_sig.get("volume_signal", {}).get("signal")
+ == "VOLUME_SPIKE"
+ ):
+ anomaly_key = f"anomaly_{market_id}"
+ if anomaly_key not in pushed_signals:
+ msg = (
+ "检测到异常交易流"
+ if volume_sig.get("score", 0) > 0.7
+ else "大户入场"
+ )
+ city_alerts.append(
+ {
+ "type": "anomaly",
+ "market": question,
+ "msg": f"{msg} (当前 {int(buy_yes_price * 100)}¢)",
+ }
+ )
+ pushed_signals[anomaly_key] = time.time()
+
+ # 3. 信号暂存
+ cached_signals[market_id] = cache_entry
+
+ # --- 循环结束后统一推送本城市汇总 ---
+ if city_alerts:
+ notifier.send_combined_alert(
+ city, city_alerts, local_time=city_local_time
+ )
+
+ except Exception as e:
+ logger.error(f"分析城市 {city} 时出错: {e}")
+ continue
+ except Exception as e:
+ logger.error(f"分析城市 {city} 时出错: {e}")
+ continue
+
+ # --- 每处理完一个城市,立即更新 JSON 文件 ---
+ try:
+ # 1. 更新活跃信号缓存 (合并旧数据避免扫描中途变空)
+ final_signals = {}
+ if os.path.exists("data/active_signals.json"):
+ try:
+ with open(
+ "data/active_signals.json", "r", encoding="utf-8"
+ ) as f:
+ final_signals = json.load(f)
+ except:
+ pass
+
+ final_signals.update(cached_signals)
+
+ with open("data/active_signals.json", "w", encoding="utf-8") as f:
+ json.dump(final_signals, f, ensure_ascii=False, indent=2)
+
+ # 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)
+
+ except Exception as e:
+ logger.error(f"即时保存数据失败: {e}")
+
+ # 4. 每日概览已移除
+
+ logger.info("本轮扫描结束。等待 5 分钟...")
+ time.sleep(300)
+
+ except KeyboardInterrupt:
+ logger.info("收到关机指令,正在退出...")
+ except Exception as e:
+ logger.exception(f"系统运行出错: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/run.py b/run.py
new file mode 100644
index 00000000..e4d200f9
--- /dev/null
+++ b/run.py
@@ -0,0 +1,47 @@
+import threading
+import time
+import sys
+import subprocess
+import os
+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)
+
+def main():
+ logger.info("🌟 PolyWeather 全功能系统正在初始化...")
+
+ # 创建共享文件夹 (如果不存在)
+ if not os.path.exists("data"):
+ os.makedirs("data")
+
+ # 创建两个线程并行运行
+ monitor_thread = threading.Thread(target=run_monitor, daemon=True)
+ bot_thread = threading.Thread(target=run_bot, daemon=True)
+
+ # 启动线程
+ monitor_thread.start()
+ bot_thread.start()
+
+ logger.success("🚀 系统已全面上线!")
+ logger.info("您可以现在去电报发送 /signal 指令测试。")
+ logger.info("监控引擎将在后台持续运行,发现 85¢-95¢ 价格将自动推送。")
+
+ try:
+ # 保持主进程运行
+ while True:
+ time.sleep(1)
+ except KeyboardInterrupt:
+ logger.warning("停止运行...")
+
+if __name__ == "__main__":
+ main()
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/analysis/__init__.py b/src/analysis/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/analysis/orderbook_analyzer.py b/src/analysis/orderbook_analyzer.py
new file mode 100644
index 00000000..d0abf2b2
--- /dev/null
+++ b/src/analysis/orderbook_analyzer.py
@@ -0,0 +1,77 @@
+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 analyze(self, orderbook):
+ """
+ 订单簿分析决策
+
+ Args:
+ orderbook: dict 包含 'bids' 和 'asks' 列表
+ """
+ bids = orderbook.get('bids', [])
+ asks = orderbook.get('asks', [])
+
+ if not bids or not asks:
+ return {"signal": "NEUTRAL", "confidence": 0.5, "reason": "Empty orderbook"}
+
+ # 1. 计算买卖力量对比 (Imbalance)
+ # Polymarket API 返回的通常是 [{"price": "0.90", "size": "100"}, ...]
+ 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
+
+ # 2. 识别墙单
+ max_bid = max([float(b.get('size', 0)) for b in bids]) if bids else 0
+ max_ask = max([float(a.get('size', 0)) for a in asks]) if asks else 0
+
+ # 3. 计算价差 (Spread)
+ best_bid = float(bids[0].get('price', 0))
+ best_ask = float(asks[0].get('price', 0))
+ spread = (best_ask - best_bid) / best_ask if best_ask > 0 else 0
+
+ result = {
+ "imbalance": imbalance,
+ "bid_volume": bid_volume,
+ "ask_volume": ask_volume,
+ "max_bid_wall": max_bid,
+ "max_ask_wall": max_ask,
+ "spread": spread,
+ "signal": "NEUTRAL",
+ "confidence": 0.5
+ }
+
+ # 4. 决策逻辑
+ if imbalance > 2.0:
+ result["signal"] = "BULLISH"
+ result["confidence"] = min(0.9, 0.5 + (imbalance - 1) / 4)
+ elif imbalance < 0.5:
+ result["signal"] = "BEARISH"
+ result["confidence"] = min(0.9, 0.5 + (1 / imbalance - 1) / 4)
+
+ if max_bid > self.wall_threshold and bid_volume > ask_volume:
+ result["signal"] = "STRONG_BUY"
+ result["confidence"] = 0.85
+ elif max_ask > self.wall_threshold and ask_volume > bid_volume:
+ result["signal"] = "STRONG_SELL"
+ result["confidence"] = 0.85
+
+ # 5. 流动性警告
+ if spread > 0.05: # 价差超过5%
+ result["warning"] = "LOW_LIQUIDITY"
+ result["confidence"] *= 0.8 # 降低置信度
+
+ return result
+
+def analyze_orderbook(orderbook):
+ """兼容旧接口的便捷函数"""
+ analyzer = OrderbookAnalyzer()
+ return analyzer.analyze(orderbook)
diff --git a/src/analysis/technical_indicators.py b/src/analysis/technical_indicators.py
new file mode 100644
index 00000000..85ce762b
--- /dev/null
+++ b/src/analysis/technical_indicators.py
@@ -0,0 +1,146 @@
+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.warning("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.warning("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
+ }
diff --git a/src/analysis/volume_analyzer.py b/src/analysis/volume_analyzer.py
new file mode 100644
index 00000000..5d12e4b2
--- /dev/null
+++ b/src/analysis/volume_analyzer.py
@@ -0,0 +1,135 @@
+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
+ }
diff --git a/src/analysis/whale_tracker.py b/src/analysis/whale_tracker.py
new file mode 100644
index 00000000..9238f156
--- /dev/null
+++ b/src/analysis/whale_tracker.py
@@ -0,0 +1,59 @@
+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"
+ }
diff --git a/src/data_collection/__init__.py b/src/data_collection/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/data_collection/onchain_tracker.py b/src/data_collection/onchain_tracker.py
new file mode 100644
index 00000000..4825950a
--- /dev/null
+++ b/src/data_collection/onchain_tracker.py
@@ -0,0 +1,56 @@
+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
diff --git a/src/data_collection/polymarket_api.py b/src/data_collection/polymarket_api.py
new file mode 100644
index 00000000..99c4e6f2
--- /dev/null
+++ b/src/data_collection/polymarket_api.py
@@ -0,0 +1,579 @@
+import os
+import requests
+import time
+import re
+from typing import Dict, List, Optional
+from loguru import logger
+from datetime import datetime
+
+
+class PolymarketClient:
+ """
+ Polymarket API Client for market data and trading
+ """
+
+ def __init__(self, config: Dict):
+ self.base_url = config.get("base_url", "https://clob.polymarket.com")
+ self.timeout = config.get("timeout", 10)
+ self.session = requests.Session()
+
+ # 统一代理设置
+ proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
+ if proxy:
+ self.session.proxies = {"http": proxy, "https": proxy}
+ logger.info(f"正在使用代理: {proxy}") # Added this line for logging
+
+ # 设置公开接口通用的 User-Agent
+ self.session.headers.update(
+ {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ "Accept": "application/json",
+ }
+ )
+
+ # 只有在明确需要签名交易时才注入私钥相关头 (目前我们只拉取报价)
+ self.api_key = config.get("api_key")
+ self.api_secret = config.get("api_secret")
+ self.api_passphrase = config.get("api_passphrase")
+ self._setup_headers()
+ logger.info(f"Polymarket 客户端初始化完成。Base URL: {self.base_url}")
+
+ def _setup_headers(self):
+ """Setup default headers for API requests"""
+ self.session.headers.update(
+ {"Content-Type": "application/json", "Accept": "application/json"}
+ )
+ if self.api_key:
+ self.session.headers.update({"POLY_API_KEY": self.api_key})
+
+ def _request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict]:
+ """Make HTTP request with error handling"""
+ url = f"{self.base_url}{endpoint}"
+
+ try:
+ response = self.session.request(
+ method=method, url=url, timeout=self.timeout, **kwargs
+ )
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.Timeout:
+ logger.error(f"Request timeout: {url}")
+ return None
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code == 404:
+ logger.debug(f"Resource not found (404): {url}")
+ else:
+ logger.error(f"HTTP error: {e}")
+ return None
+ except Exception as e:
+ logger.error(f"Request failed: {e}")
+ return None
+
+ def get_markets(self, next_cursor: str = None) -> Optional[Dict]:
+ """
+ Get list of all markets
+
+ Returns:
+ dict: Market list with pagination info
+ """
+ params = {}
+ if next_cursor:
+ params["next_cursor"] = next_cursor
+
+ return self._request("GET", "/markets", params=params)
+
+ def get_market(self, market_id: str) -> Optional[Dict]:
+ """
+ Get specific market details
+
+ Args:
+ market_id: The market condition ID
+
+ Returns:
+ dict: Market details
+ """
+ return self._request("GET", f"/markets/{market_id}")
+
+ def get_price(self, token_id: str, side: str = "ask") -> Optional[float]:
+ """
+ 获取 Token 的实时盘口价格 (CLOB API)
+ """
+ try:
+ book = self.get_orderbook(token_id)
+ if book and isinstance(book, dict):
+ if side == "ask" and book.get("asks"):
+ return float(book["asks"][0].get("price"))
+ elif side == "bid" and book.get("bids"):
+ return float(book["bids"][0].get("price"))
+
+ # 如果 orderbook 拿不到,尝试直接查 price 接口
+ res = self._request("GET", "/price", params={"token_id": token_id})
+ if res and isinstance(res, dict) and "price" in res:
+ return float(res["price"])
+ except Exception as e:
+ # 这里的 400 通常是由于该 token 暂时没有挂单深度
+ logger.debug(f"抓取 CLOB 价格失败 ({token_id}): {e}")
+
+ return None
+
+ def get_orderbook(self, token_id: str) -> Optional[Dict]:
+ """
+ 获取订单簿深度 (CLOB API)
+ """
+ # 尝试新端点 /orderbook
+ try:
+ url = f"{self.base_url}/orderbook"
+ response = self.session.get(url, params={"token_id": token_id}, timeout=15)
+
+ if response.status_code == 200:
+ return response.json()
+ elif response.status_code == 404:
+ # 回退到旧端点 /book
+ url = f"{self.base_url}/book"
+ response = self.session.get(
+ url, params={"token_id": token_id}, timeout=15
+ )
+ if response.status_code == 200:
+ return response.json()
+ except requests.exceptions.Timeout:
+ logger.debug(f"订单簿请求超时: {token_id[:20]}...")
+ except Exception as e:
+ logger.debug(f"获取订单簿失败: {e}")
+ return None
+
+ def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]:
+ """
+ 获取买入价格 (Buy Yes 和 Buy No)
+
+ Args:
+ yes_token_id: Yes token ID
+ no_token_id: No token ID
+
+ Returns:
+ dict: {"buy_yes": float, "buy_no": float} 或 None
+ """
+ try:
+ # Buy Yes = Yes token 的最佳卖单 (asks)
+ yes_book = self.get_orderbook(yes_token_id)
+ buy_yes = None
+ if (
+ yes_book
+ and isinstance(yes_book, dict)
+ and yes_book.get("asks")
+ and len(yes_book["asks"]) > 0
+ ):
+ buy_yes = float(yes_book["asks"][0].get("price", 0))
+
+ # Buy No = No token 的最佳卖单 (asks)
+ no_book = self.get_orderbook(no_token_id)
+ buy_no = None
+ if (
+ no_book
+ and isinstance(no_book, dict)
+ and no_book.get("asks")
+ and len(no_book["asks"]) > 0
+ ):
+ buy_no = float(no_book["asks"][0].get("price", 0))
+
+ if buy_yes is not None and buy_no is not None:
+ return {"buy_yes": buy_yes, "buy_no": buy_no}
+
+ except Exception as e:
+ logger.debug(f"获取买入价格失败: {e}")
+
+ return None
+
+ def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]:
+ """
+ 获取买入价格 (Buy Yes 和 Buy No)
+
+ Args:
+ yes_token_id: Yes token ID
+ no_token_id: No token ID
+
+ Returns:
+ dict: {"buy_yes": float, "buy_no": float} 或 None
+ """
+ try:
+ # Buy Yes = Yes token 的最佳卖单 (asks)
+ yes_book = self.get_orderbook(yes_token_id)
+ buy_yes = None
+ if yes_book and isinstance(yes_book, dict) and yes_book.get("asks"):
+ buy_yes = float(yes_book["asks"][0].get("price", 0))
+
+ # Buy No = No token 的最佳卖单 (asks)
+ no_book = self.get_orderbook(no_token_id)
+ buy_no = None
+ if no_book and isinstance(no_book, dict) and no_book.get("asks"):
+ buy_no = float(no_book["asks"][0].get("price", 0))
+
+ if buy_yes is not None and buy_no is not None:
+ return {"buy_yes": buy_yes, "buy_no": buy_no}
+
+ except Exception as e:
+ logger.debug(f"获取买入价格失败: {e}")
+
+ return None
+
+ def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]:
+ """
+ 批量获取多个 token 的价格 (使用 Polymarket 批量接口)
+ """
+ if not token_requests:
+ return {}
+
+ try:
+ # 批量获取价格端点
+ url = f"{self.base_url}/prices"
+
+ # Polymarket 期望的查询参数格式
+ # 我们需要获取可买入的价格,所以 side 应该是 "buy"
+ all_prices = {}
+
+ # 分批处理以提高稳定性
+ for i in range(0, len(token_requests), 50):
+ batch = token_requests[i : i + 50]
+ # 构建用于请求的 json 对象
+ payload = [{"token_id": r["token_id"], "side": "buy"} for r in batch]
+
+ response = self.session.post(url, json=payload, timeout=20)
+ if response.status_code == 200:
+ results = response.json()
+ # 结果通常是 { "token_id": "price", ... }
+ if isinstance(results, dict):
+ for tid, p in results.items():
+ all_prices[tid] = float(p)
+
+ return all_prices
+ except Exception as e:
+ logger.debug(f"批量获取盘口价格失败: {e}")
+ return {}
+
+ try:
+ url = f"{self.base_url}/prices"
+ # 这里的价格接口通常返回最佳买入/卖出价
+ # 构造请求体:Polymarket 期望的格式
+ payload = []
+ for req in token_requests:
+ payload.append(
+ {
+ "token_id": req["token_id"],
+ "side": "buy"
+ if req["side"] == "ask"
+ else "sell", # 映射:我们要买,所以查盘口的 sell side (ask)
+ }
+ )
+
+ # 分批处理,每批 50 个,避免请求过大
+ all_prices = {}
+ for i in range(0, len(payload), 50):
+ batch = payload[i : i + 50]
+ response = self.session.post(url, json=batch, timeout=20)
+ if response.status_code == 200:
+ results = response.json()
+ # 结果通常是一个字典 {token_id: price}
+ if isinstance(results, dict):
+ all_prices.update(results)
+ return all_prices
+ except Exception as e:
+ logger.debug(f"批量获取价格失败: {e}")
+ return {}
+
+ def get_trades(self, market_id: str = None, limit: int = 100) -> Optional[Dict]:
+ """
+ 获取成交历史 (使用 CLOB 专业接口 + Builder Key)
+ """
+ try:
+ url = f"{self.base_url}/trades"
+ params = {"limit": limit}
+ if market_id:
+ params["market"] = market_id
+
+ # 关键:带上你的 Builder Key
+ headers = {}
+ if self.api_key:
+ headers["x-api-key"] = self.api_key
+
+ response = self.session.get(
+ url, params=params, headers=headers, timeout=self.timeout
+ )
+
+ if response.status_code == 200:
+ return response.json()
+ elif response.status_code == 401:
+ logger.debug(
+ f"CLOB Trades 依然返回 401 (权限受限): {market_id[:20]}..."
+ )
+ else:
+ logger.debug(f"CLOB Trades 接口返回状态码: {response.status_code}")
+
+ except Exception as e:
+ logger.debug(f"获取成交历史失败: {e}")
+ return None
+
+ def get_midpoint(self, token_id: str) -> Optional[float]:
+ """
+ Get midpoint price for a token
+
+ Args:
+ token_id: The token ID
+
+ Returns:
+ float: Midpoint price
+ """
+ result = self._request("GET", f"/midpoint", params={"token_id": token_id})
+ if result and "mid" in result:
+ return float(result["mid"])
+ return None
+
+ def search_markets(self, query: str) -> Optional[Dict]:
+ """
+ Search markets by query
+
+ Args:
+ query: Search query string
+
+ Returns:
+ dict: Search results
+ """
+ return self._request("GET", "/markets", params={"tag": query})
+
+ # Trading methods (require authentication)
+ def create_order(
+ self,
+ token_id: str,
+ side: str,
+ price: float,
+ size: float,
+ order_type: str = "GTC",
+ ) -> Optional[Dict]:
+ """
+ Create a new order (requires API key)
+
+ Args:
+ token_id: Token to trade
+ side: "BUY" or "SELL"
+ price: Order price
+ size: Order size
+ order_type: Order type (GTC, GTD, FOK)
+
+ Returns:
+ dict: Order confirmation
+ """
+ if not self.api_key:
+ logger.error("API key required for trading")
+ return None
+
+ order_data = {
+ "tokenID": token_id,
+ "side": side.upper(),
+ "price": str(price),
+ "size": str(size),
+ "type": order_type,
+ }
+
+ logger.info(f"Creating order: {side} {size} @ {price}")
+ return self._request("POST", "/order", json=order_data)
+
+ def cancel_order(self, order_id: str) -> Optional[Dict]:
+ """
+ Cancel an existing order
+
+ Args:
+ order_id: Order ID to cancel
+
+ Returns:
+ dict: Cancellation confirmation
+ """
+ if not self.api_key:
+ logger.error("API key required for trading")
+ return None
+
+ return self._request("DELETE", f"/order/{order_id}")
+
+ def discover_weather_markets(self) -> list:
+ """
+ 通过全量扫描活跃事件发现最高温天气市场。
+ """
+ gamma_url = "https://gamma-api.polymarket.com/events"
+ all_weather_markets = []
+ seen_condition_ids = set()
+
+ def process_events(events, source_label):
+ if not isinstance(events, list):
+ return
+
+ new_markets_count = 0
+ for event in events:
+ title = event.get("title", "")
+ is_weather_event = (
+ "Highest temperature" in title or "temperature in" in title.lower()
+ )
+
+ event_slug = event.get("slug", "")
+ for m in event.get("markets", []):
+ question = m.get("groupItemTitle") or m.get("question") or ""
+
+ # 关键词匹配
+ if not (
+ is_weather_event
+ or "Highest temperature" in question
+ or "temperature in" in question.lower()
+ ):
+ continue
+
+ c_id = m.get("conditionId")
+ if c_id and c_id not in seen_condition_ids:
+ all_weather_markets.append(
+ {
+ "condition_id": c_id,
+ "question": question,
+ "active_token_id": m.get("activeTokenId"),
+ "tokens": m.get("clobTokenIds"),
+ "prices": m.get("outcomePrices"),
+ "event_title": title,
+ "slug": event_slug,
+ }
+ )
+ seen_condition_ids.add(c_id)
+ new_markets_count += 1
+ if new_markets_count > 0:
+ logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约")
+
+ try:
+ # 1. 扫描活跃且未合并的 (全量) - 增加到20000以确保抓取所有天气市场
+ for offset in range(0, 20000, 1000):
+ params = {
+ "active": "true",
+ "closed": "false",
+ "limit": 1000,
+ "offset": offset,
+ }
+ response = self.session.get(
+ gamma_url, params=params, timeout=self.timeout
+ )
+ if response.status_code == 200:
+ events = response.json()
+ if not events:
+ break
+ process_events(events, f"Open-O{offset}")
+ else:
+ break
+
+ # 2. 扫描活跃但已关闭的 - 增加到20000以覆盖更多历史
+ for offset in range(0, 20000, 1000):
+ params = {
+ "active": "true",
+ "closed": "true",
+ "limit": 1000,
+ "offset": offset,
+ }
+ response = self.session.get(
+ gamma_url, params=params, timeout=self.timeout
+ )
+ if response.status_code == 200:
+ events = response.json()
+ if not events:
+ break
+ process_events(events, f"Closed-O{offset}")
+ else:
+ break
+
+ # 3. 扫描非活跃但未关闭的市场
+ for offset in range(0, 10000, 1000):
+ params = {
+ "active": "false",
+ "closed": "false",
+ "limit": 1000,
+ "offset": offset,
+ }
+ response = self.session.get(
+ gamma_url, params=params, timeout=self.timeout
+ )
+ if response.status_code == 200:
+ events = response.json()
+ if not events:
+ break
+ process_events(events, f"Inactive-O{offset}")
+ else:
+ break
+
+ # 4. 扫描非活跃且已关闭的市场(某些即将结算的市场可能在这里)
+ for offset in range(0, 10000, 1000):
+ params = {
+ "active": "false",
+ "closed": "true",
+ "limit": 1000,
+ "offset": offset,
+ }
+ response = self.session.get(
+ gamma_url, params=params, timeout=self.timeout
+ )
+ if response.status_code == 200:
+ events = response.json()
+ if not events:
+ break
+ process_events(events, f"InactiveClosed-O{offset}")
+ else:
+ break
+
+ logger.info(
+ f"全量发现结束,共获取 {len(all_weather_markets)} 个天气档位合约"
+ )
+ return all_weather_markets
+
+ except Exception as e:
+ logger.error(f"全量发现天气市场失败: {e}")
+ return []
+
+ def get_weather_markets(self) -> list:
+ """
+ 获取全量活跃天气市场
+ """
+ return self.discover_weather_markets()
+
+ def get_event_by_slug(self, slug: str) -> Optional[Dict]:
+ """
+ 通过slug直接获取特定事件(用于捕获部分结算等特殊状态的市场)
+ """
+ try:
+ url = f"{self.base_url.replace('clob', 'gamma-api')}/events"
+ params = {"slug": slug}
+ response = self.session.get(url, params=params, timeout=self.timeout)
+
+ if response.status_code == 200:
+ events = response.json()
+ if events and len(events) > 0:
+ return events[0]
+ except Exception as e:
+ logger.debug(f"通过slug获取事件失败 ({slug}): {e}")
+ return None
+
+ def find_weather_market(self, city: str, date_str: str = None) -> Optional[Dict]:
+ """
+ 根据城市和日期精准查找
+ """
+ weather_markets = self.get_weather_markets()
+ for m in weather_markets:
+ content = (
+ str(m.get("question", "")) + str(m.get("event_title", ""))
+ ).lower()
+ 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", ""))).lower()
+ ]
diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py
new file mode 100644
index 00000000..75097acb
--- /dev/null
+++ b/src/data_collection/weather_sources.py
@@ -0,0 +1,436 @@
+import requests
+import re
+from typing import Optional, Dict, List
+from datetime import datetime, timedelta
+from loguru import logger
+
+
+class WeatherDataCollector:
+ """
+ Multi-source weather data collector
+
+ Supports:
+ - OpenWeatherMap (free, fast updates)
+ - Weather Underground (Polymarket settlement source)
+ - Visual Crossing (rich historical data)
+ """
+
+ def __init__(self, config: dict):
+ self.config = config
+ self.openweather_key = config.get("openweather_api_key")
+ self.wunderground_key = config.get("wunderground_api_key")
+ self.visualcrossing_key = config.get("visualcrossing_api_key")
+
+ self.timeout = 10
+ self.session = requests.Session()
+
+ # 设置代理
+ proxy = config.get("proxy")
+ if proxy:
+ if not proxy.startswith("http"):
+ proxy = f"http://{proxy}"
+ self.session.proxies = {"http": proxy, "https": proxy}
+ logger.info(f"正在使用天气数据代理: {proxy}")
+
+ logger.info("天气数据采集器初始化完成。")
+
+ def fetch_from_openweather(self, city: str, country: str = None) -> Optional[Dict]:
+ """
+ Fetch current weather and forecast from OpenWeatherMap
+
+ Args:
+ city: City name
+ country: Country code (optional)
+
+ Returns:
+ dict: Weather data
+ """
+ if not self.openweather_key:
+ logger.warning("OpenWeatherMap API key not configured")
+ return None
+
+ query = f"{city},{country}" if country else city
+
+ try:
+ # Current weather
+ current_url = "https://api.openweathermap.org/data/2.5/weather"
+ current_response = self.session.get(
+ current_url,
+ params={"q": query, "appid": self.openweather_key, "units": "metric"},
+ timeout=self.timeout,
+ )
+ current_response.raise_for_status()
+ current_data = current_response.json()
+
+ # 5-day forecast
+ forecast_url = "https://api.openweathermap.org/data/2.5/forecast"
+ forecast_response = self.session.get(
+ forecast_url,
+ params={"q": query, "appid": self.openweather_key, "units": "metric"},
+ timeout=self.timeout,
+ )
+ forecast_response.raise_for_status()
+ forecast_data = forecast_response.json()
+
+ return {
+ "source": "openweathermap",
+ "timestamp": datetime.utcnow().isoformat(),
+ "current": {
+ "temp": current_data["main"]["temp"],
+ "feels_like": current_data["main"]["feels_like"],
+ "temp_min": current_data["main"]["temp_min"],
+ "temp_max": current_data["main"]["temp_max"],
+ "humidity": current_data["main"]["humidity"],
+ "pressure": current_data["main"]["pressure"],
+ "wind_speed": current_data["wind"]["speed"],
+ "clouds": current_data["clouds"]["all"],
+ "description": current_data["weather"][0]["description"],
+ },
+ "forecast": self._parse_openweather_forecast(forecast_data),
+ }
+
+ except requests.exceptions.RequestException as e:
+ logger.error(f"OpenWeatherMap request failed: {e}")
+ return None
+
+ def _parse_openweather_forecast(self, data: dict) -> List[Dict]:
+ """Parse OpenWeatherMap forecast data"""
+ forecasts = []
+ for item in data.get("list", []):
+ forecasts.append(
+ {
+ "datetime": item["dt_txt"],
+ "temp": item["main"]["temp"],
+ "temp_min": item["main"]["temp_min"],
+ "temp_max": item["main"]["temp_max"],
+ "humidity": item["main"]["humidity"],
+ "description": item["weather"][0]["description"],
+ }
+ )
+ return forecasts
+
+ def fetch_from_visualcrossing(
+ self, city: str, start_date: str = None, end_date: str = None
+ ) -> Optional[Dict]:
+ """
+ Fetch historical weather data from Visual Crossing
+
+ Args:
+ city: City name
+ start_date: Start date (YYYY-MM-DD)
+ end_date: End date (YYYY-MM-DD)
+
+ Returns:
+ dict: Historical weather data
+ """
+ if not self.visualcrossing_key:
+ logger.warning("Visual Crossing API key not configured")
+ return None
+
+ # Default to last 30 days if no dates provided
+ if not end_date:
+ end_date = datetime.now().strftime("%Y-%m-%d")
+ if not start_date:
+ start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
+
+ try:
+ url = f"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{city}/{start_date}/{end_date}"
+ response = self.session.get(
+ url,
+ params={
+ "unitGroup": "metric",
+ "key": self.visualcrossing_key,
+ "contentType": "json",
+ "include": "days",
+ },
+ timeout=self.timeout,
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ return {
+ "source": "visualcrossing",
+ "timestamp": datetime.utcnow().isoformat(),
+ "location": data.get("resolvedAddress"),
+ "timezone": data.get("timezone"),
+ "days": [
+ {
+ "date": day["datetime"],
+ "temp_max": day.get("tempmax"),
+ "temp_min": day.get("tempmin"),
+ "temp_avg": day.get("temp"),
+ "humidity": day.get("humidity"),
+ "precip": day.get("precip"),
+ "conditions": day.get("conditions"),
+ }
+ for day in data.get("days", [])
+ ],
+ }
+
+ except requests.exceptions.RequestException as e:
+ logger.error(f"Visual Crossing request failed: {e}")
+ return None
+
+ def fetch_from_open_meteo(
+ self,
+ lat: float,
+ lon: float,
+ forecast_days: int = 14,
+ use_fahrenheit: bool = False,
+ ) -> Optional[Dict]:
+ """
+ Fetch weather from Open-Meteo with forecast data
+
+ Args:
+ lat: Latitude
+ lon: Longitude
+ forecast_days: Number of forecast days to fetch (default 14 to cover all market dates)
+ use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets)
+ """
+ try:
+ url = "https://api.open-meteo.com/v1/forecast"
+ params = {
+ "latitude": lat,
+ "longitude": lon,
+ "current_weather": "true",
+ "daily": "temperature_2m_max,apparent_temperature_max",
+ "timezone": "auto",
+ "forecast_days": forecast_days,
+ }
+
+ # 对于美国市场,使用华氏度
+ if use_fahrenheit:
+ params["temperature_unit"] = "fahrenheit"
+
+ response = self.session.get(
+ url,
+ params=params,
+ timeout=self.timeout,
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ current = data.get("current_weather", {})
+ return {
+ "source": "open-meteo",
+ "timestamp": datetime.utcnow().isoformat(),
+ "current": {
+ "temp": current.get("temperature"),
+ "local_time": current.get("time", "").replace("T", " "),
+ },
+ "daily": data.get("daily", {}),
+ "unit": "fahrenheit" if use_fahrenheit else "celsius",
+ }
+ except Exception as e:
+ logger.error(f"Open-Meteo forecast failed: {e}")
+ return None
+
+ def extract_date_from_title(self, title: str) -> Optional[str]:
+ """
+ 从标题中提取日期并标准化为 YYYY-MM-DD
+ 例如: "Highest temperature in Seattle on February 6?" -> "2026-02-06"
+ """
+ months = {
+ "January": "01",
+ "February": "02",
+ "March": "03",
+ "April": "04",
+ "May": "05",
+ "June": "06",
+ "July": "07",
+ "August": "08",
+ "September": "09",
+ "October": "10",
+ "November": "11",
+ "December": "12",
+ }
+
+ for month_name, month_val in months.items():
+ if month_name in title:
+ match = re.search(f"{month_name}\\s+(\\d+)", title)
+ if match:
+ day = int(match.group(1))
+ year = datetime.now().year
+ # 简单处理跨年逻辑:如果提取到的月份小于当前月份太多,可能是指明年
+ # 但对于天气预报通常只看近期几天
+ return f"{year}-{month_val}-{day:02d}"
+ return None
+
+ def get_coordinates(self, city: str) -> Optional[Dict[str, float]]:
+ """
+ 使用 Open-Meteo Geocoding API 获取城市坐标 (免费, 无需 Key)
+ """
+ # 预设常用城市坐标,避免网络波动导致启动失败
+ static_coords = {
+ "london": {"lat": 51.5074, "lon": -0.1278},
+ "new york": {"lat": 40.7128, "lon": -74.0060},
+ "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},
+ }
+
+ normalized_city = city.lower().strip()
+ if normalized_city in static_coords:
+ return static_coords[normalized_city]
+
+ try:
+ url = "https://geocoding-api.open-meteo.com/v1/search"
+ response = self.session.get(
+ url,
+ params={"name": city, "count": 1, "language": "en", "format": "json"},
+ timeout=15, # 增加超时时间到 15s
+ )
+ response.raise_for_status()
+ results = response.json().get("results", [])
+ if results:
+ res = results[0]
+ return {
+ "lat": res.get("latitude"),
+ "lon": res.get("longitude"),
+ "name": res.get("name"),
+ "country": res.get("country"),
+ }
+ except Exception as e:
+ logger.error(f"地理编码失败 ({city}): {e}")
+ return None
+
+ def extract_city_from_question(self, question: str) -> Optional[str]:
+ """
+ 从 Polymarket 问题描述中提取城市名称
+ 支持多种描述方式:
+ - "Highest temperature in Ankara on February 5?"
+ - "Will the temperature in London be..."
+ - "Temp in New York..."
+ """
+ q = question.lower()
+
+ # 移除常见的干扰词
+ for noise in ["highest ", "the ", "will ", "lowest "]:
+ if q.startswith(noise):
+ q = q[len(noise) :]
+
+ # 处理 "temperature in [City]" | "temp in [City]"
+ triggers = ["temperature in ", "temp in ", "weather in "]
+ for trigger in triggers:
+ if trigger in q:
+ part = q.split(trigger)[1]
+ # 截断日期和其他后缀
+ # 按照 "on", "at", "above", "below", "?", " ", "be", "is" 分割
+ delimiters = [
+ " on ",
+ " at ",
+ " above ",
+ " below ",
+ " be ",
+ " is ",
+ " will ",
+ " has ",
+ " reached ",
+ "?",
+ " (",
+ ", ",
+ ]
+ city = part
+ for d in delimiters:
+ if d in city:
+ city = city.split(d)[0]
+ return city.strip().title()
+
+ return None
+
+ def fetch_all_sources(
+ self, city: str, lat: float = None, lon: float = None, country: str = None
+ ) -> Dict:
+ """
+ Fetch weather data from all available sources
+ """
+ results = {}
+
+ # 判断是否为美国市场(使用华氏度)
+ us_cities = [
+ "dallas",
+ "nyc",
+ "new york",
+ "seattle",
+ "miami",
+ "atlanta",
+ "chicago",
+ "los angeles",
+ "san francisco",
+ "washington",
+ "boston",
+ "houston",
+ "phoenix",
+ "philadelphia",
+ ]
+ use_fahrenheit = city.lower() in us_cities
+
+ # Open-Meteo (Primary Free Source - No Key)
+ if lat and lon:
+ open_meteo = self.fetch_from_open_meteo(
+ lat, lon, use_fahrenheit=use_fahrenheit
+ )
+ if open_meteo:
+ results["open-meteo"] = open_meteo
+
+ # OpenWeatherMap (Requires Key)
+ openweather = self.fetch_from_openweather(city, country)
+ if openweather:
+ results["openweathermap"] = openweather
+
+ # Visual Crossing (Requires Key)
+ visualcrossing = self.fetch_from_visualcrossing(city)
+ if visualcrossing:
+ results["visualcrossing"] = visualcrossing
+
+ return results
+
+ def check_consensus(self, forecasts: Dict) -> Dict:
+ """
+ Check consensus across multiple weather sources
+
+ Args:
+ forecasts: Dict of forecasts from different sources
+
+ Returns:
+ dict: Consensus analysis
+ """
+ predictions = []
+ for source, data in forecasts.items():
+ if data and "current" in data:
+ predictions.append({"source": source, "temp": data["current"]["temp"]})
+
+ if len(predictions) == 0:
+ return {"consensus": False, "reason": "No weather data available"}
+
+ temps = [p["temp"] for p in predictions]
+ avg_temp = sum(temps) / len(temps)
+
+ # If only one source, consensus is implicitly true
+ if len(predictions) == 1:
+ return {
+ "consensus": True,
+ "average_temp": avg_temp,
+ "max_difference": 0.0,
+ "predictions": predictions,
+ "note": "Single source only",
+ }
+
+ max_diff = max(abs(t - avg_temp) for t in temps)
+ # Consensus if all predictions within 2.5°C
+ is_consensus = max_diff <= 2.5
+
+ return {
+ "consensus": is_consensus,
+ "average_temp": avg_temp,
+ "max_difference": max_diff,
+ "predictions": predictions,
+ }
diff --git a/src/models/__init__.py b/src/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/models/statistical_model.py b/src/models/statistical_model.py
new file mode 100644
index 00000000..b7df5ac8
--- /dev/null
+++ b/src/models/statistical_model.py
@@ -0,0 +1,285 @@
+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.warning("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.warning("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.warning("No predictions available")
+ return {
+ "predicted_temp": None,
+ "confidence": 0.0,
+ "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()
+ }
diff --git a/src/strategy/__init__.py b/src/strategy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/strategy/decision_engine.py b/src/strategy/decision_engine.py
new file mode 100644
index 00000000..19db1c4d
--- /dev/null
+++ b/src/strategy/decision_engine.py
@@ -0,0 +1,198 @@
+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"
+ }
diff --git a/src/strategy/position_manager.py b/src/strategy/position_manager.py
new file mode 100644
index 00000000..0f0af2c1
--- /dev/null
+++ b/src/strategy/position_manager.py
@@ -0,0 +1,153 @@
+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
+ }
diff --git a/src/strategy/risk_manager.py b/src/strategy/risk_manager.py
new file mode 100644
index 00000000..29169817
--- /dev/null
+++ b/src/strategy/risk_manager.py
@@ -0,0 +1,155 @@
+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", 500) # 最大单笔$500
+ self.max_drawdown = self.config.get("max_drawdown", 0.10) # 最大回撤10%
+ self.min_liquidity = self.config.get("min_liquidity", 1000) # 最小流动性$1000
+ self.max_slippage = self.config.get("max_slippage", 0.02) # 最大滑点2%
+ self.min_confidence = self.config.get("min_confidence", 0.65) # 最小置信度65%
+
+ self.peak_capital = 0
+ self.current_drawdown = 0
+ self.is_trading_paused = False
+
+ logger.info("Initializing Risk Manager...")
+
+ def check_trade_risk(self,
+ trade_size: float,
+ market_data: dict,
+ model_confidence: float) -> dict:
+ """
+ 检查单笔交易风险
+
+ Args:
+ trade_size: 交易金额
+ market_data: 市场数据 (包含订单簿等)
+ model_confidence: 模型置信度
+
+ Returns:
+ dict: 风险检查结果
+ """
+ risks = []
+ passed = True
+
+ # 1. 检查交易金额
+ if trade_size > self.max_single_trade:
+ risks.append({
+ "type": "TRADE_SIZE",
+ "message": f"Trade size ${trade_size:.2f} exceeds max ${self.max_single_trade}"
+ })
+ passed = False
+
+ # 2. 检查置信度
+ if model_confidence < self.min_confidence:
+ risks.append({
+ "type": "LOW_CONFIDENCE",
+ "message": f"Model confidence {model_confidence:.2f} below threshold {self.min_confidence}"
+ })
+ passed = False
+
+ # 3. 检查流动性
+ orderbook = market_data.get("orderbook", {})
+ total_liquidity = self._calculate_liquidity(orderbook)
+ if total_liquidity < self.min_liquidity:
+ risks.append({
+ "type": "LOW_LIQUIDITY",
+ "message": f"Market liquidity ${total_liquidity:.2f} below threshold ${self.min_liquidity}"
+ })
+ passed = False
+
+ # 4. 检查滑点
+ expected_slippage = self._estimate_slippage(trade_size, orderbook)
+ if expected_slippage > self.max_slippage:
+ risks.append({
+ "type": "HIGH_SLIPPAGE",
+ "message": f"Expected slippage {expected_slippage:.2%} exceeds max {self.max_slippage:.2%}"
+ })
+ passed = False
+
+ # 5. 检查是否暂停交易
+ if self.is_trading_paused:
+ risks.append({
+ "type": "TRADING_PAUSED",
+ "message": "Trading is paused due to drawdown limit"
+ })
+ passed = False
+
+ return {
+ "passed": passed,
+ "risks": risks,
+ "liquidity": total_liquidity,
+ "expected_slippage": expected_slippage
+ }
+
+ def _calculate_liquidity(self, orderbook: dict) -> float:
+ """计算订单簿总流动性"""
+ bids = orderbook.get("bids", [])
+ asks = orderbook.get("asks", [])
+
+ bid_liquidity = sum(float(b.get("size", 0)) for b in bids)
+ ask_liquidity = sum(float(a.get("size", 0)) for a in asks)
+
+ return bid_liquidity + ask_liquidity
+
+ def _estimate_slippage(self, trade_size: float, orderbook: dict) -> float:
+ """估算滑点"""
+ asks = orderbook.get("asks", [])
+ if not asks:
+ return 0.05 # 无数据时假设5%滑点
+
+ best_ask = float(asks[0].get("price", 0)) if asks else 0
+ if best_ask == 0:
+ return 0.05
+
+ # 简单估算:交易额 / 流动性 * 基础滑点
+ ask_liquidity = sum(float(a.get("size", 0)) for a in asks)
+ if ask_liquidity == 0:
+ return 0.05
+
+ impact_ratio = trade_size / ask_liquidity
+ estimated_slippage = impact_ratio * 0.1 # 假设10%的市场冲击系数
+
+ return min(estimated_slippage, 0.1) # 最大10%
+
+ def update_drawdown(self, current_capital: float) -> dict:
+ """
+ 更新回撤状态
+
+ Args:
+ current_capital: 当前资金
+
+ Returns:
+ dict: 回撤状态
+ """
+ # 更新峰值
+ if current_capital > self.peak_capital:
+ self.peak_capital = current_capital
+
+ # 计算回撤
+ if self.peak_capital > 0:
+ self.current_drawdown = (self.peak_capital - current_capital) / self.peak_capital
+ else:
+ self.current_drawdown = 0
+
+ # 检查是否需要暂停交易
+ if self.current_drawdown >= self.max_drawdown:
+ self.is_trading_paused = True
+ logger.warning(f"Trading PAUSED! Drawdown {self.current_drawdown:.2%} exceeds limit {self.max_drawdown:.2%}")
+
+ return {
+ "peak_capital": self.peak_capital,
+ "current_capital": current_capital,
+ "drawdown": self.current_drawdown,
+ "is_paused": self.is_trading_paused
+ }
+
+ def resume_trading(self):
+ """手动恢复交易"""
+ self.is_trading_paused = False
+ logger.info("Trading resumed manually")
diff --git a/src/trading/__init__.py b/src/trading/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/trading/order_executor.py b/src/trading/order_executor.py
new file mode 100644
index 00000000..da0efe03
--- /dev/null
+++ b/src/trading/order_executor.py
@@ -0,0 +1,219 @@
+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
+ }
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/utils/config_loader.py b/src/utils/config_loader.py
new file mode 100644
index 00000000..6abaab9f
--- /dev/null
+++ b/src/utils/config_loader.py
@@ -0,0 +1,52 @@
+import os
+from dotenv import load_dotenv
+
+def load_config():
+ """
+ Load configuration from environment variables and config files
+ """
+ load_dotenv()
+
+ def get_env_or_none(key):
+ val = os.getenv(key)
+ if not val or "your_" in val.lower() or val.strip() == "":
+ return None
+ return val
+
+ config = {
+ "polymarket": {
+ "api_key": get_env_or_none("POLYMARKET_API_KEY"),
+ "secret_key": get_env_or_none("POLYMARKET_SECRET_KEY"),
+ "passphrase": get_env_or_none("POLYMARKET_PASSPHRASE"),
+ "wallet_address": get_env_or_none("POLYMARKET_WALLET_ADDRESS"),
+ "proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
+ },
+ "weather": {
+ "openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"),
+ "wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"),
+ "visualcrossing_api_key": get_env_or_none("VISUALCROSSING_API_KEY"),
+ "proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
+ },
+ "telegram": {
+ "bot_token": os.getenv("TELEGRAM_BOT_TOKEN"),
+ "chat_id": os.getenv("TELEGRAM_CHAT_ID"),
+ "proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
+ },
+ "config": {
+ "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
+ }
+ },
+ "app": {
+ "log_level": os.getenv("LOG_LEVEL", "INFO"),
+ "env": os.getenv("ENV", "development"),
+ "proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
+ }
+ }
+
+ return config
diff --git a/src/utils/logger.py b/src/utils/logger.py
new file mode 100644
index 00000000..a1122e0c
--- /dev/null
+++ b/src/utils/logger.py
@@ -0,0 +1,27 @@
+import sys
+from loguru import logger
+
+def setup_logger():
+ """
+ Configure loguru logger
+ """
+ logger.remove() # Remove default handler
+
+ # 控制台输出 - 使用支持中文的格式
+ logger.add(
+ sys.stderr,
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ # 文件输出
+ logger.add(
+ "data/logs/trading_system.log",
+ rotation="10 MB",
+ retention="10 days",
+ level="DEBUG",
+ encoding="utf-8",
+ compression="zip"
+ )
+
+ logger.info("日志系统初始化完成。")
diff --git a/src/utils/notifier.py b/src/utils/notifier.py
new file mode 100644
index 00000000..5090bd74
--- /dev/null
+++ b/src/utils/notifier.py
@@ -0,0 +1,232 @@
+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 消息的主函数"""
+ if not self.token or not self.chat_id:
+ logger.warning("未配置 Telegram Token 或 Chat ID,无法发送消息。")
+ return
+
+ url = f"https://api.telegram.org/bot{self.token}/sendMessage"
+ # 调试输出:确保 ID 正确读取
+ logger.debug(f"DEBUG: Tnotifier using ChatID={self.chat_id}")
+
+ payload = {
+ "chat_id": self.chat_id,
+ "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 消息发送失败 (400): Chat ID {self.chat_id} 无效或机器人尚未被加入该聊天。请在 Telegram 中发送 /id 给机器人确认正确的 Chat ID。"
+ )
+ else:
+ logger.error(
+ f"Telegram 消息发送失败 ({response.status_code}): {error_msg}"
+ )
+ return False
+ logger.info("Telegram 消息发送成功。")
+ return True
+ except Exception as e:
+ logger.error(f"Telegram 请求异常: {e}")
+ return False
+
+ 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"🕒 当地时间: {self._escape_html(local_time)}\n"
+ if local_time
+ else ""
+ )
+ target_date_text = self._escape_html(target_date) if target_date else "待定"
+
+ text = (
+ f"🎯 交易信号 #{self._escape_html(market_name.split(' ')[0])}\n\n"
+ f"📍 城市: {self._escape_html(market_name)}\n"
+ f"🏆 市场: {self._escape_html(full_title)}\n"
+ f"📝 选项: {self._escape_html(option)}\n"
+ f"💰 当前价格: {price}¢\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"📅 结算日期: {target_date_text}\n"
+ f"🔗 点击进入市场\n\n"
+ f"⏰ 信号时间: {timestamp_utc} UTC"
+ )
+ return self._send_message(text)
+
+ def send_combined_alert(self, city: str, alerts: list, local_time: str = None):
+ """发送合并后的城市预警"""
+ if not alerts:
+ return
+
+ from datetime import datetime, timedelta
+
+ # UTC+8 北京时间
+ timestamp_bj = (datetime.utcnow() + timedelta(hours=8)).strftime("%H:%M")
+
+ items_text = ""
+ for a in alerts:
+ type_icon = "⚡" if a["type"] == "price" else "🐋"
+ items_text += f"{type_icon} {a['market']}: {a['msg']}\n"
+
+ text = (
+ f"🔔 城市监控报告 #{self._escape_html(city)}\n\n"
+ f"📍 城市: {self._escape_html(city)}\n"
+ f"📊 实时异动:\n"
+ f"{items_text}\n"
+ 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"🕒 当地时间: {self._escape_html(local_time)}\n"
+ if local_time
+ else ""
+ )
+
+ text = (
+ f"👀 市场异常 #{self._escape_html(city_tag)}\n\n"
+ f"📍 城市: {self._escape_html(city_tag)}\n"
+ f"🏆 市场: {self._escape_html(market_name)}\n\n"
+ f"🚨 检测到异常:\n"
+ f"{self._escape_html(detected_anomaly)}\n"
+ f"{stats_text}\n\n"
+ f"🐋 大户动向:\n"
+ f"{whale_text}\n\n"
+ f"💰 当前价格: {current_price}¢\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"🕒 当地时间: {self._escape_html(local_time)}\n"
+ if local_time
+ else ""
+ )
+
+ text = (
+ f"⚡ 价格预警 #{self._escape_html(city_tag)}\n\n"
+ f"📍 城市: {self._escape_html(city_tag)}\n"
+ f"🏆 市场: {self._escape_html(market_name)}\n"
+ f"💰 报价: {price}¢ ↗️\n\n"
+ f"触发条件: {self._escape_html(trigger)}\n"
+ f"变动详情: {prev_price}¢ -> {price}¢ ({self._escape_html(change)})\n\n"
+ f"📊 快速分析:\n"
+ f"{analysis_text}\n\n"
+ f"═══════════════════\n"
+ f"{local_time_text}"
+ f"⏰ 预警时间: {timestamp_bj} (北京时间)"
+ )
+ return self._send_message(text)
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 00000000..0c932f31
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,29 @@
+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()