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" + "💡 直接输入城市名称 (如: SeattleLondon) 即可查询该城市当天的最高温市场报价。" + ) + 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()