feat: Initialize PolyWeather project structure including modules for data collection, analysis, strategy, trading, utilities, configuration, and a Streamlit dashboard.
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
|
||||
level="DEBUG"
|
||||
)
|
||||
|
||||
# 文件输出
|
||||
logger.add(
|
||||
"data/logs/trading_system.log",
|
||||
rotation="10 MB",
|
||||
retention="10 days",
|
||||
level="DEBUG",
|
||||
encoding="utf-8",
|
||||
compression="zip"
|
||||
)
|
||||
|
||||
logger.info("日志系统初始化完成。")
|
||||
@@ -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"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
|
||||
if local_time
|
||||
else ""
|
||||
)
|
||||
target_date_text = self._escape_html(target_date) if target_date else "待定"
|
||||
|
||||
text = (
|
||||
f"🎯 <b>交易信号 #{self._escape_html(market_name.split(' ')[0])}</b>\n\n"
|
||||
f"📍 城市: <b>{self._escape_html(market_name)}</b>\n"
|
||||
f"🏆 市场: <i>{self._escape_html(full_title)}</i>\n"
|
||||
f"📝 选项: <b>{self._escape_html(option)}</b>\n"
|
||||
f"💰 当前价格: <b>{price}¢</b>\n"
|
||||
f"═══════════════════\n"
|
||||
f"📊 信号评分: {stars} ({score}/5)\n"
|
||||
f"🤖 模型预测: {self._escape_html(prediction)}\n"
|
||||
f"📈 置信度: {confidence}%\n\n"
|
||||
f"分析汇总:\n"
|
||||
f"{analysis_text}\n"
|
||||
f"═══════════════════\n"
|
||||
f"{local_time_text}"
|
||||
f"📅 结算日期: <b>{target_date_text}</b>\n"
|
||||
f"🔗 <a href='{market_url}'>点击进入市场</a>\n\n"
|
||||
f"⏰ 信号时间: {timestamp_utc} UTC"
|
||||
)
|
||||
return self._send_message(text)
|
||||
|
||||
def send_combined_alert(self, city: str, alerts: list, local_time: str = None):
|
||||
"""发送合并后的城市预警"""
|
||||
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} <b>{a['market']}</b>: {a['msg']}\n"
|
||||
|
||||
text = (
|
||||
f"🔔 <b>城市监控报告 #{self._escape_html(city)}</b>\n\n"
|
||||
f"📍 城市: {self._escape_html(city)}\n"
|
||||
f"📊 <b>实时异动:</b>\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"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
|
||||
if local_time
|
||||
else ""
|
||||
)
|
||||
|
||||
text = (
|
||||
f"👀 <b>市场异常 #{self._escape_html(city_tag)}</b>\n\n"
|
||||
f"📍 城市: {self._escape_html(city_tag)}\n"
|
||||
f"🏆 市场: {self._escape_html(market_name)}\n\n"
|
||||
f"🚨 <b>检测到异常:</b>\n"
|
||||
f"{self._escape_html(detected_anomaly)}\n"
|
||||
f"{stats_text}\n\n"
|
||||
f"🐋 <b>大户动向:</b>\n"
|
||||
f"{whale_text}\n\n"
|
||||
f"💰 当前价格: <b>{current_price}¢</b>\n"
|
||||
f"═══════════════════\n"
|
||||
f"{local_time_text}"
|
||||
f"⏰ 信号时间: {timestamp_bj} (北京时间)"
|
||||
)
|
||||
return self._send_message(text)
|
||||
|
||||
def send_alert(
|
||||
self,
|
||||
city_tag: str,
|
||||
market_name: str,
|
||||
price: float,
|
||||
trigger: str,
|
||||
prev_price: float,
|
||||
change: str,
|
||||
quick_analysis: list,
|
||||
local_time: str = None,
|
||||
):
|
||||
"""发送价格预警推送"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# UTC+8 北京时间
|
||||
timestamp_bj = (datetime.utcnow() + timedelta(hours=8)).strftime("%H:%M")
|
||||
|
||||
analysis_text = "\n".join(
|
||||
[f"- {self._escape_html(item)}" for item in quick_analysis]
|
||||
)
|
||||
local_time_text = (
|
||||
f"🕒 当地时间: <b>{self._escape_html(local_time)}</b>\n"
|
||||
if local_time
|
||||
else ""
|
||||
)
|
||||
|
||||
text = (
|
||||
f"⚡ <b>价格预警 #{self._escape_html(city_tag)}</b>\n\n"
|
||||
f"📍 城市: {self._escape_html(city_tag)}\n"
|
||||
f"🏆 市场: {self._escape_html(market_name)}\n"
|
||||
f"💰 报价: <b>{price}¢ ↗️</b>\n\n"
|
||||
f"触发条件: {self._escape_html(trigger)}\n"
|
||||
f"变动详情: {prev_price}¢ -> {price}¢ ({self._escape_html(change)})\n\n"
|
||||
f"📊 <b>快速分析:</b>\n"
|
||||
f"{analysis_text}\n\n"
|
||||
f"═══════════════════\n"
|
||||
f"{local_time_text}"
|
||||
f"⏰ 预警时间: {timestamp_bj} (北京时间)"
|
||||
)
|
||||
return self._send_message(text)
|
||||
Reference in New Issue
Block a user