Refactor market analysis and price fetching logic, remove orderbook analysis from the main loop, add new data collection and strategy modules, and update documentation.

This commit is contained in:
2569718930@qq.com
2026-02-07 22:30:19 +08:00
parent 3deca01952
commit 1ec0d6eca8
15 changed files with 1043 additions and 1009 deletions
+184 -486
View File
@@ -7,587 +7,285 @@ from loguru import logger
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
from py_clob_client.client import ClobClient
from py_clob_client.constants import POLYGON
from py_clob_client.clob_types import ApiCreds, BookParams, OpenOrderParams
class PolymarketClient:
"""
Polymarket API Client for market data and trading (Exclusive py-clob-client mode)
Polymarket API Client (Pure REST API version)
Directly uses Gamma API and CLOB REST API without py-clob-client dependency.
"""
def __init__(self, config: Dict):
self.base_url = config.get("base_url", "https://clob.polymarket.com")
self.clob_url = config.get("base_url", "https://clob.polymarket.com")
self.gamma_url = "https://gamma-api.polymarket.com"
self.timeout = config.get("timeout", 20)
self.session = requests.Session()
# 缓存机制
# Cache mechanism
self._weather_markets_cache = []
self._last_discovery_time = 0
self._cache_ttl = 300 # 5 分钟缓存
self._cache_ttl = 300 # 5 minutes cache
# 统一代理设置
# Proxy settings (automatically read from environment)
proxy = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
if proxy:
self.session.proxies = {"http": proxy, "https": proxy}
logger.info(f"正在使用代理: {proxy}")
logger.info(f"Requests session using proxy: {proxy}")
# 设置公开接口通用的 User-Agent
# Set common User-Agent and headers
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
"Content-Type": "application/json"
}
)
# 初始化官方 CLOB 客户端
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
self.api_passphrase = config.get("api_passphrase")
try:
# 组装凭据对象 (如果提供)
creds = None
if self.api_key and self.api_secret:
creds = ApiCreds(
api_key=self.api_key,
api_secret=self.api_secret,
api_passphrase=self.api_passphrase
)
self.clob_client = ClobClient(
host=self.base_url,
key=None, # 除非有 0x 开头的私钥,否则传入 None 以避免报错
creds=creds,
chain_id=POLYGON
)
# 注入代理到官方客户端 (官方库使用 httpx 或 requests)
if proxy:
# 尝试给官方 client 的内部 session 设置代理 (取决于版本实现)
try:
if hasattr(self.clob_client, 'session'):
self.clob_client.session.proxies = {"http": proxy, "https": proxy}
except: pass
logger.info("✅ 官方 py-clob-client 已满血上线,Requests 模式已彻底退役。")
except Exception as e:
logger.error(f"官方客户端启动失败: {e}")
raise RuntimeError("必须安装并配置正确的 py-clob-client 才能运行。")
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})
logger.info(f"Polymarket REST Client initialized. CLOB: {self.clob_url}, Gamma: {self.gamma_url}")
def get_markets(self, next_cursor: str = None) -> Optional[Dict]:
"""
获取全量市场列表 (官方接口)
"""
"""Fetch markets list via CLOB REST API"""
try:
return self.clob_client.get_markets(next_cursor=next_cursor)
except: return None
params = {}
if next_cursor:
params["next_cursor"] = next_cursor
resp = self.session.get(f"{self.clob_url}/markets", params=params, timeout=self.timeout)
return resp.json() if resp.status_code == 200 else None
except Exception as e:
logger.debug(f"get_markets failed: {e}")
return None
def get_market(self, market_id: str) -> Optional[Dict]:
"""
获取特定市场详情 (官方接口)
"""
"""Fetch market details via CLOB REST API"""
try:
return self.clob_client.get_market(market_id=market_id)
except: return None
resp = self.session.get(f"{self.clob_url}/markets/{market_id}", timeout=self.timeout)
return resp.json() if resp.status_code == 200 else None
except Exception as e:
logger.debug(f"get_market failed: {e}")
return None
def get_price(self, token_id: str, side: str = "ask") -> Optional[float]:
"""
获取 Token 的实时盘口价格
优先使用官方库,失败时使用直接 REST API
"""
# 方法1: 尝试官方库
"""Fetch real-time price for a token via CLOB REST API"""
try:
sdk_side = "BUY" if side == "ask" else "SELL"
price_str = self.clob_client.get_price(token_id=token_id, side=sdk_side)
if price_str:
return float(price_str)
except Exception as e:
logger.debug(f"官方库 get_price 失败 ({token_id}): {e}")
# 方法2: 直接调用 REST API (与 test_price.py 一致)
try:
resp = self.session.get(f"{self.base_url}/price", params={
"token_id": token_id,
"side": side.upper()
}, timeout=10)
# Correct CLOB Mapping:
# 'sell' side price is the ASK (price you pay to BUY)
# 'buy' side price is the BID (price you get to SELL)
clob_side = "sell" if side.lower() in ["ask", "buy"] else "buy"
resp = self.session.get(
f"{self.clob_url}/price",
params={"token_id": token_id, "side": clob_side},
timeout=10
)
data = resp.json()
return float(data.get("price", 0))
return float(data.get("price", 0)) if resp.status_code == 200 else None
except Exception as e:
logger.debug(f"REST API get_price 失败 ({token_id}): {e}")
logger.debug(f"get_price failed ({token_id}): {e}")
return None
def get_orderbook(self, token_id: str) -> Optional[Dict]:
"""
获取订单簿深度
优先使用官方库,失败时使用直接 REST API
"""
# 方法1: 尝试官方库
"""Fetch orderbook for a token via CLOB REST API"""
try:
return self.clob_client.get_orderbook(token_id=token_id)
resp = self.session.get(
f"{self.clob_url}/book", params={"token_id": token_id}, timeout=10
)
return resp.json() if resp.status_code == 200 else None
except Exception as e:
logger.debug(f"官方库 get_orderbook 失败 ({token_id}): {e}")
# 方法2: 直接调用 REST API (与 test_price.py 一致)
try:
resp = self.session.get(f"{self.base_url}/book", params={
"token_id": token_id
}, timeout=10)
return resp.json()
except Exception as e:
logger.debug(f"REST API get_orderbook 失败 ({token_id}): {e}")
logger.debug(f"get_orderbook failed ({token_id}): {e}")
return None
def get_buy_prices(self, yes_token_id: str, no_token_id: str) -> Optional[Dict]:
"""
获取买入价格 (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
"""
"""Fetch buy prices for both YES and NO tokens"""
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))
# Buy Yes = Ask price of YES token
buy_yes = self.get_price(yes_token_id, "BUY")
# Buy No = Ask price of NO token
buy_no = self.get_price(no_token_id, "BUY")
if buy_yes is not None and buy_no is not None:
return {"buy_yes": buy_yes, "buy_no": buy_no}
except Exception as e:
logger.debug(f"获取买入价格失败: {e}")
logger.debug(f"get_buy_prices failed: {e}")
return None
def get_multiple_prices(self, token_requests: List[Dict]) -> Dict[str, float]:
"""
批量获取多个 token 的价格
优先使用官方库,失败时使用直接 REST API
"""
"""Batch fetch prices for multiple tokens using ThreadPoolExecutor"""
if not token_requests:
return {}
all_prices = {}
batch_size = 20
def robust_float(val):
if isinstance(val, (int, float)): return float(val)
if isinstance(val, str):
try: return float(val)
except: return 0.0
return 0.0
try: return float(val)
except: return 0.0
chunks = [token_requests[i : i + batch_size] for i in range(0, len(token_requests), batch_size)]
def fetch_chunk_sdk(chunk):
"""使用官方 SDK 批量获取"""
try:
batch_req = []
for r in chunk:
sdk_side = "BUY" if r.get("side") == "ask" else "SELL"
batch_req.append(BookParams(token_id=r["token_id"], side=sdk_side))
results = self.clob_client.get_prices(batch_req)
chunk_prices = {}
if isinstance(results, list):
for item in results:
tid = item.get("token_id")
price_raw = item.get("price")
res_side = item.get("side")
if tid and price_raw:
val = robust_float(price_raw)
key_side = "ask" if res_side == "BUY" else "bid"
chunk_prices[f"{tid}:{key_side}"] = val
return chunk_prices
except Exception as e:
logger.debug(f"SDK batch fetch failed: {e}")
return None
def fetch_chunk_rest(chunk):
"""使用 REST API 逐个获取 (备用方案)"""
chunk_prices = {}
for r in chunk:
try:
resp = self.session.get(f"{self.base_url}/price", params={
"token_id": r["token_id"],
"side": r.get("side", "ask").upper()
}, timeout=10)
data = resp.json()
price = robust_float(data.get("price", 0))
if price > 0:
chunk_prices[f"{r['token_id']}:{r.get('side', 'ask')}"] = price
except Exception as e:
logger.debug(f"REST API price fetch failed for {r['token_id'][:16]}...: {e}")
return chunk_prices
def fetch_single(req):
tid = req["token_id"]
side = req.get("side", "ask").lower()
# To get ASK (price to buy), request 'sell' side
# To get BID (price to sell), request 'buy' side
api_side = "sell" if side == "ask" else "buy"
val = self.get_price(tid, api_side)
if val:
return f"{tid}:{side.lower()}", val
return None
# 使用线程池并发抓取
with ThreadPoolExecutor(max_workers=3) as executor:
future_results = list(executor.map(fetch_chunk_sdk, chunks))
# 检查结果,如果 SDK 全部失败,使用 REST API 备用
sdk_success = False
for chunk_result in future_results:
if chunk_result:
all_prices.update(chunk_result)
sdk_success = True
# 如果 SDK 完全失败,使用 REST API
if not sdk_success and token_requests:
logger.info("SDK 批量获取失败,使用 REST API 逐个获取...")
with ThreadPoolExecutor(max_workers=5) as executor:
future_results = list(executor.map(fetch_chunk_rest, chunks))
for chunk_result in future_results:
all_prices.update(chunk_result)
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch_single, token_requests))
for res in results:
if res:
key, val = res
all_prices[key] = val
return all_prices
def get_midpoint(self, token_id: str) -> Optional[float]:
"""
获取中点价格 (官方接口)
"""
"""Fetch midpoint price via CLOB REST API"""
try:
res = self.clob_client.get_midpoint(token_id)
if res and "mid" in res:
return float(res["mid"])
except: pass
return None
def search_markets(self, query: str) -> Optional[Dict]:
"""
搜索市场
"""
try:
# Note: The py-clob-client's get_markets method does not directly support a 'query' parameter for searching.
# It primarily supports pagination (next_cursor).
# This implementation will fetch the first page of markets and return them.
# A more robust search would involve fetching all markets and filtering locally,
# or using a different API endpoint if available.
return self.clob_client.get_markets(next_cursor=None)
except Exception as e:
logger.error(f"搜索市场失败: {e}")
return None
# --- 交易指令 (强依赖官方库) ---
def create_order(
self,
token_id: str,
side: str,
price: float,
size: float,
order_type: str = "GTC",
) -> Optional[Dict]:
"""
创建新订单
"""
try:
# 转换方向
side_val = "BUY" if side.upper() == "BUY" else "SELL"
# 使用官方签名下单 (SDK 会自动处理签名)
return self.clob_client.create_order(
token_id=token_id,
price=price,
size=size,
side=side_val
)
except Exception as e:
logger.error(f"下单失败: {e}")
return None
def cancel_order(self, order_id: str) -> Optional[Dict]:
"""
取消订单
"""
try:
return self.clob_client.cancel_order(order_id)
except Exception as e:
logger.error(f"取消订单失败: {e}")
return None
def get_orders(self, market_id: str = None) -> Optional[Dict]:
"""
获取当前活跃挂单
"""
try:
params = OpenOrderParams(market=market_id) if market_id else None
return self.clob_client.get_orders(params=params)
except Exception as e:
logger.error(f"获取挂单失败: {e}")
resp = self.session.get(f"{self.clob_url}/midpoint", params={"token_id": token_id}, timeout=10)
data = resp.json()
return float(data.get("mid", 0)) if resp.status_code == 200 else None
except:
return None
def discover_weather_markets(self) -> list:
"""
通过全量扫描活跃事件发现最高温天气市场 (支持缓存机制)
"""
# 缓存检查
"""Scan Gamma API for all weather-related markets with prioritized search and city targeting"""
# Cache check
current_time = time.time()
if self._weather_markets_cache and (current_time - self._last_discovery_time < self._cache_ttl):
logger.debug(f"使用缓存的市场列表 (剩余寿命: {int(self._cache_ttl - (current_time - self._last_discovery_time))}s)")
logger.debug(f"Using cached market list ({len(self._weather_markets_cache)} items)")
return self._weather_markets_cache
logger.info("📡 正在全量扫描 Polymarket 发现天气市场...")
gamma_url = "https://gamma-api.polymarket.com/events"
logger.info("📡 Scanning Polymarket via Gamma API for weather markets...")
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 ""
# 强化过滤:必须在标题中包含明确的气温气象词,且排除非气温市场
t_lower = title.lower()
q_lower = question.lower()
# 1. 标题必须像个气温市场
if not any(k in t_lower for k in ["highest temperature", "high temperature", "will temperature", "daily temperature"]):
continue
# 2. 排除干扰项
if "climate" in t_lower or "rain" in t_lower or "snow" in t_lower:
continue
# 3. 确保这个具体的 market (bracket) 是我们想要的
if not any(k in q_lower for k in ["temperature", "be", "highest", "range"]):
# 补充:如果是多选一市场的子项,question 可能只是一个数字或范围,此时看 title
if not any(k in t_lower for k in ["temperature", "highest"]):
continue
c_id = m.get("conditionId")
# 识别 outcome_index
t_ids = m.get("clobTokenIds", [])
active_id = m.get("activeTokenId")
idx = 0
if isinstance(t_ids, list) and active_id in t_ids:
idx = t_ids.index(active_id)
# 对于多选一市场,不同档位共享 conditionId,但 tokenId 不同
unique_key = f"{c_id}_{active_id}"
if c_id and unique_key not in seen_condition_ids:
all_weather_markets.append(
{
"condition_id": c_id,
"question": question,
"active_token_id": active_id,
"outcome_index": idx,
"tokens": t_ids,
"prices": m.get("outcomePrices"),
"event_title": title,
"slug": event_slug,
}
)
seen_condition_ids.add(unique_key)
new_markets_count += 1
if new_markets_count > 0:
logger.debug(f"[{source_label}] 发现 {new_markets_count} 个新市场合约")
seen_keys = set()
# 1. Target newest markets by query and ID sorting
search_queries = ["highest temperature", "temperature in", "daily weather"]
try:
# 1. 扫描活跃且未合并的 (全量)
for offset in range(0, 20000, 1000):
params = {
"active": "true",
"closed": "false",
"limit": 1000,
"offset": offset,
}
# 增加重试机制
success = False
for retry in range(3):
try:
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}")
success = True
break
else:
logger.warning(f"Gamma API 状态码异常 ({response.status_code}),第 {retry+1} 次重试...")
except Exception as e:
logger.warning(f"发现市场请求出错: {e},第 {retry+1} 次重试...")
time.sleep(2)
if not success:
break
# Use multiple offsets to find more historical/diverse markets
for offset in [0, 500, 1000]:
for query in search_queries:
logger.debug(f"Searching with query: {query} (offset {offset})")
params = {
"query": query,
"active": "true",
"limit": 500,
"offset": offset,
"order": "id",
"ascending": "false"
}
resp = self.session.get(f"{self.gamma_url}/markets", params=params, timeout=self.timeout)
if resp.status_code == 200:
markets = resp.json()
logger.debug(f"Query '{query}' returned {len(markets)} markets")
for m in markets:
q = m.get("question", "").lower()
slug = m.get("slug", "").lower()
# Filter for weather markets (Broadened)
is_weather = any(k in q or k in slug for k in [
"highest temperature", "highest-temperature",
"temperature in", "temperature-in",
"daily weather", "daily-weather",
"weather", "气温", "温度"
])
if is_weather:
c_id = m.get("conditionId")
t_ids = m.get("clobTokenIds")
active_id = m.get("activeTokenId")
# Robust JSON parsing for clobTokenIds string
if isinstance(t_ids, str) and t_ids.startswith("["):
try:
import json
t_ids = json.loads(t_ids)
except:
pass
# For Neg Risk markets, activeTokenId might be missing in list view
# If we have clobTokenIds, we can work with it
if not t_ids:
continue
if not active_id and isinstance(t_ids, list) and len(t_ids) > 0:
active_id = t_ids[0] # Assume first is YES
# 2. 扫描活跃但已关闭的
for offset in range(0, 20000, 1000):
params = {
"active": "true",
"closed": "true",
"limit": 1000,
"offset": offset,
}
success = False
for retry in range(3):
try:
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}")
success = True
break
except Exception as e:
logger.warning(f"发现关闭市场请求出错: {e},第 {retry+1} 次重试...")
time.sleep(2)
if not success:
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}")
if not active_id:
continue
unique_key = f"{c_id}_{active_id}"
if unique_key not in seen_keys:
logger.debug(f"Found weather segment: {q}")
all_weather_markets.append({
"condition_id": c_id,
"question": m.get("question"),
"active_token_id": active_id,
"outcome_index": t_ids.index(active_id) if isinstance(t_ids, list) and active_id in t_ids else 0,
"tokens": t_ids,
"prices": m.get("outcomePrices"),
"event_title": m.get("description", "")[:100],
"slug": m.get("slug"),
"group_id": m.get("negRiskMarketID")
})
seen_keys.add(unique_key)
else:
logger.debug(f"Query '{query}' failed with status {resp.status_code}")
if len(all_weather_markets) > 50:
break
# 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)} 个天气档位合约"
)
# 更新缓存
logger.info(f"Discovery complete: Found {len(all_weather_markets)} weather segments.")
self._weather_markets_cache = all_weather_markets
self._last_discovery_time = current_time
return all_weather_markets
except Exception as e:
logger.error(f"全量发现天气市场失败: {e}")
logger.error(f"Market discovery failed: {e}")
return []
except Exception as e:
logger.error(f"Market discovery failed: {e}")
return []
except Exception as e:
logger.error(f"Market discovery failed: {e}")
return []
def get_weather_markets(self) -> list:
"""
获取全量活跃天气市场
"""
return self.discover_weather_markets()
def 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()
markets = self.get_weather_markets()
for m in markets:
# Match against question, title AND slug
content = (str(m.get("question", "")) + str(m.get("event_title", "")) + str(m.get("slug", ""))).lower()
if city.lower() in content:
if date_str:
if date_str.lower() in content:
return m
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()
m for m in all_markets
if city.lower() in (str(m.get("question", "")) + str(m.get("event_title", "")) + str(m.get("slug", ""))).lower()
]
# --- Trading Stubs (Real trading requires signing, which is disabled in pure REST mode) ---
def create_order(self, *args, **kwargs) -> Optional[Dict]:
logger.warning("create_order: Real trading is disabled in pure REST mode. Please use paper trading.")
return None
def cancel_order(self, *args, **kwargs) -> Optional[Dict]:
logger.warning("cancel_order: Real trading is disabled in pure REST mode.")
return None
def get_orders(self, *args, **kwargs) -> Optional[Dict]:
logger.warning("get_orders: Real trading is disabled in pure REST mode.")
return None
+173 -43
View File
@@ -13,8 +13,27 @@ class WeatherDataCollector:
- OpenWeatherMap (free, fast updates)
- Weather Underground (Polymarket settlement source)
- Visual Crossing (rich historical data)
- NOAA Aviation Weather (METAR - airport observations)
"""
# Polymarket 12 个天气市场对应的 ICAO 机场代码
# 这些是 Weather Underground 结算源使用的气象站
CITY_TO_ICAO = {
"seattle": "KSEA", # Seattle-Tacoma Airport
"london": "EGLC", # London City Airport
"dallas": "KDAL", # Dallas Love Field
"miami": "KMIA", # Miami International
"atlanta": "KATL", # Hartsfield-Jackson
"chicago": "KORD", # O'Hare International
"new york": "KLGA", # LaGuardia Airport
"nyc": "KLGA", # Alias
"seoul": "RKSI", # Incheon International
"ankara": "LTAC", # Esenboğa International
"toronto": "CYYZ", # Toronto Pearson
"wellington": "NZWN", # Wellington International
"buenos aires": "SAEZ", # Ezeiza International
}
def __init__(self, config: dict):
self.config = config
self.wunderground_key = config.get("wunderground_api_key")
@@ -167,6 +186,113 @@ class WeatherDataCollector:
logger.error(f"Visual Crossing request failed: {e}")
return None
def get_icao_code(self, city: str) -> Optional[str]:
"""
根据城市名获取对应的 ICAO 机场代码
"""
normalized = city.lower().strip()
# 直接匹配
if normalized in self.CITY_TO_ICAO:
return self.CITY_TO_ICAO[normalized]
# 模糊匹配
for key, icao in self.CITY_TO_ICAO.items():
if key in normalized or normalized in key:
return icao
return None
def fetch_metar(self, city: str, use_fahrenheit: bool = False) -> Optional[Dict]:
"""
从 NOAA Aviation Weather Center 获取 METAR 航空气象数据
这是 Polymarket 天气市场的结算数据源 (Weather Underground) 使用的相同气象站
Args:
city: 城市名称
use_fahrenheit: 是否转换为华氏度
Returns:
dict: METAR 数据,包含温度、露点、风速等
"""
icao = self.get_icao_code(city)
if not icao:
logger.warning(f"未找到城市 {city} 对应的 ICAO 代码")
return None
try:
# NOAA Aviation Weather API (免费,无需 Key)
url = "https://aviationweather.gov/api/data/metar"
params = {
"ids": icao,
"format": "json",
"hours": 3, # 获取最近3小时的观测
}
response = self.session.get(url, params=params, timeout=self.timeout)
response.raise_for_status()
data = response.json()
if not data:
logger.warning(f"METAR 数据为空: {icao}")
return None
# 取最新的观测记录
latest = data[0]
# 提取温度 (METAR 原始单位是摄氏度)
temp_c = latest.get("temp")
dewp_c = latest.get("dewp")
# 转换为华氏度(如果需要)
if use_fahrenheit and temp_c is not None:
temp = temp_c * 9 / 5 + 32
dewp = dewp_c * 9 / 5 + 32 if dewp_c is not None else None
unit = "fahrenheit"
else:
temp = temp_c
dewp = dewp_c
unit = "celsius"
# 解析观测时间
obs_time = latest.get("reportTime", "")
result = {
"source": "metar",
"icao": icao,
"station_name": latest.get("name", icao),
"timestamp": datetime.utcnow().isoformat(),
"observation_time": obs_time,
"raw_metar": latest.get("rawOb", ""),
"current": {
"temp": round(temp, 1) if temp is not None else None,
"dewpoint": round(dewp, 1) if dewp is not None else None,
"humidity": latest.get("rh"), # 相对湿度
"wind_speed_kt": latest.get("wspd"), # 风速 (knots)
"wind_dir": latest.get("wdir"), # 风向 (度)
"visibility_miles": latest.get("visib"), # 能见度 (英里)
"altimeter": latest.get("altim"), # 气压
"flight_category": latest.get("fltcat"), # VFR/IFR 等
"clouds": latest.get("clouds", []),
},
"unit": unit,
}
logger.info(
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} "
f"(obs: {obs_time})"
)
return result
except requests.exceptions.RequestException as e:
logger.error(f"METAR 请求失败 ({icao}): {e}")
return None
except (KeyError, IndexError, TypeError) as e:
logger.error(f"METAR 数据解析失败 ({icao}): {e}")
return None
def fetch_from_open_meteo(
self,
lat: float,
@@ -234,32 +360,35 @@ class WeatherDataCollector:
def extract_date_from_title(self, title: str) -> Optional[str]:
"""
从标题中提取日期并标准化为 YYYY-MM-DD
例如: "Highest temperature in Seattle on February 6?" -> "2026-02-06"
支持: "February 6", "2月6日", "2-6"
"""
# 1. 尝试英文月份
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",
"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}"
# 2. 尝试中文格式 "2月7日" 或 "02月07日"
zh_match = re.search(r"(\d{1,2})月(\d{1,2})日", title)
if zh_match:
month = int(zh_match.group(1))
day = int(zh_match.group(2))
year = datetime.now().year
return f"{year}-{month:02d}-{day:02d}"
# 3. 尝试 ISO 格式 YYYY-MM-DD
iso_match = re.search(r"(\d{4})-(\d{2})-(\d{2})", title)
if iso_match:
return iso_match.group(0)
return None
def get_coordinates(self, city: str) -> Optional[Dict[str, float]]:
@@ -317,40 +446,36 @@ class WeatherDataCollector:
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..."
从 Polymarket 问题描述或 Slug 中提取城市名称
"""
q = question.lower()
# 移除常见的干扰词
for noise in ["highest ", "the ", "will ", "lowest "]:
if q.startswith(noise):
q = q[len(noise) :]
# 1. 优先尝试已知城市列表 (硬编码匹配)
known_cities = {
"london": "London", "伦敦": "London",
"new york": "New York", "new york's central park": "New York", "nyc": "New York", "纽约": "New York",
"seattle": "Seattle", "西雅图": "Seattle",
"chicago": "Chicago", "芝加哥": "Chicago",
"dallas": "Dallas", "达拉斯": "Dallas",
"miami": "Miami", "迈阿密": "Miami",
"atlanta": "Atlanta", "亚特兰大": "Atlanta",
"seoul": "Seoul", "首尔": "Seoul",
"toronto": "Toronto", "多伦多": "Toronto",
"ankara": "Ankara", "安卡拉": "Ankara",
"wellington": "Wellington", "惠灵顿": "Wellington",
"buenos aires": "Buenos Aires", "布宜诺斯艾利斯": "Buenos Aires"
}
for key, val in known_cities.items():
if key in q:
return val
# 处理 "temperature in [City]" | "temp in [City]"
triggers = ["temperature in ", "temp in ", "weather in "]
# 2. 从英文模板中提取
triggers = ["temperature in ", "temp in ", "weather in ", "highest-temperature-in-", "temperature-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 ",
"?",
" (",
", ",
]
delimiters = [" on ", " at ", " above ", " below ", " be ", " is ", " will ", " has ", " reached ", "?", " (", ", ", "-"]
city = part
for d in delimiters:
if d in city:
@@ -404,6 +529,11 @@ class WeatherDataCollector:
else:
logger.info(f"🌡️ {city} 使用摄氏度 (°C)")
# METAR (Airport Weather - Same source as Weather Underground settlement)
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
if metar_data:
results["metar"] = metar_data
# Open-Meteo (Primary Free Source - No Key)
if lat and lon:
open_meteo = self.fetch_from_open_meteo(
+37 -35
View File
@@ -1,14 +1,17 @@
from loguru import logger
class RiskManager:
"""
风险控制系统
"""
def __init__(self, config=None):
self.config = config or {}
# 基础风控参数
self.max_single_trade = self.config.get("max_single_trade", 50.0) # 最大单笔调整为 $50
self.max_single_trade = self.config.get(
"max_single_trade", 50.0
) # 最大单笔调整为 $50
self.max_daily_exposure = 50.0 # 每日最高投入上限
self.daily_used_exposure = 0.0
self.last_reset_date = ""
@@ -16,82 +19,81 @@ class RiskManager:
self.min_confidence = 0.5
self.peak_capital = 0
self.is_trading_paused = False
logger.info("Initializing Pro Risk Manager...")
def _reset_daily_exposure(self):
"""每日重置额度"""
from datetime import datetime
today = datetime.now().strftime("%Y-%m-%d")
if self.last_reset_date != today:
self.daily_used_exposure = 0.0
self.last_reset_date = today
logger.info(f"Daily exposure reset for {today}")
def calculate_position_size(self,
base_confidence_usd: float,
depth: float,
hours_to_settle: float,
is_high_relative_volume: bool) -> tuple[float, str]:
def calculate_position_size(
self,
base_confidence_usd: float,
depth: float = 0,
hours_to_settle: float = 24,
is_high_relative_volume: bool = False,
) -> tuple[float, str]:
"""
四层过滤仓位计算方法:
仓位 = base_position(置信度)
× liquidity_factor(深度/仓位 >= 5x)
× time_decay(离结算衰减)
仓位计算方法 (简化版,移除流动性过滤):
仓位 = base_position(置信度)
× time_decay(离结算衰减)
× budget_limit
"""
self._reset_daily_exposure()
final_pos = base_confidence_usd
reason = "Normal"
# 1. 流动性过滤: 深度 < $50 强制跳过; 深度 < 仓位的 5 倍则缩减
if depth < 50:
return 0.0, "🚫深度不足 (min $50)"
if depth < final_pos * 5:
# 如果深度不足以承载期望仓位,按比例缩减至深度的 1/5
final_pos = depth / 5.0
reason = "⚠️深度限流"
# 2. 时间衰减因子
# 1. 时间衰减因子
# 离结算时间越近,预测越准但也存在剧烈博弈风险
time_factor = 1.0
if hours_to_settle <= 1.0:
time_factor = 0.0 # 最后 1 小时停止建仓
time_factor = 0.0 # 最后 1 小时停止建仓
reason = "🚫临近结算"
elif hours_to_settle <= 4.0:
time_factor = 0.4 # 1-4小时:缩小 60%
time_factor = 0.4 # 1-4小时:缩小 60%
reason = "⏱️结算冲刺 (40%)"
elif hours_to_settle <= 12.0:
time_factor = 0.7 # 4-12小时:缩小 30%
time_factor = 0.7 # 4-12小时:缩小 30%
reason = "⏳接近结算 (70%)"
final_pos *= time_factor
if final_pos <= 0: return 0.0, reason
# 3. 预算上限过滤
final_pos *= time_factor
if final_pos <= 0:
return 0.0, reason
# 2. 预算上限过滤
remaining_daily = self.max_daily_exposure - self.daily_used_exposure
if remaining_daily <= 0:
return 0.0, "🚫今日总额度已满 ($50)"
if final_pos > remaining_daily:
final_pos = remaining_daily
reason = "🛑触及日风控上限"
# 4. 高相对成交量加权 (如果是高成交量市场,且逻辑支持,可保持原状或微增)
# 3. 高相对成交量加权 (如果是高成交量市场,且逻辑支持,可保持原状或微增)
# 这里逻辑设定为:如果不是高成交量,再次缩减 20% 防御
if not is_high_relative_volume:
final_pos *= 0.8
if reason == "Normal": reason = "📉低活缩减"
if reason == "Normal":
reason = "📉低活缩减"
return round(final_pos, 2), reason
def record_trade(self, amount: float):
"""记录成交额以扣除额度"""
self.daily_used_exposure += amount
logger.debug(f"Applied exposure: ${amount}. Daily Total: ${self.daily_used_exposure}")
logger.debug(
f"Applied exposure: ${amount}. Daily Total: ${self.daily_used_exposure}"
)
def check_trade_risk(self, trade_size: float, market_data: dict, model_confidence: float) -> dict:
def check_trade_risk(
self, trade_size: float, market_data: dict, model_confidence: float
) -> dict:
"""保持基础接口兼容"""
return {"passed": True, "risks": []}
+31 -7
View File
@@ -134,8 +134,9 @@ class TelegramNotifier:
total_volume: float = 0,
brackets_count: int = 0,
strategy_tips: list = None,
metar_data: dict = None,
):
"""发送简约版合并预警"""
"""发送简约版合并预警 (含 METAR 航空气象数据)"""
if not alerts:
return
@@ -143,16 +144,38 @@ class TelegramNotifier:
# UTC+8 北京时间
now_bj = datetime.utcnow() + timedelta(hours=8)
timestamp_bj = now_bj.strftime(
"%H:%M"
) # 简化为仅显示时间,日期通常与当地一致或不重要
timestamp_bj = now_bj.strftime("%H:%M")
# 1. 信号详情构建
# 1. METAR 航空气象数据区块
metar_text = ""
if metar_data and metar_data.get("current", {}).get("temp") is not None:
icao = metar_data.get("icao", "N/A")
temp = metar_data["current"]["temp"]
unit = "°F" if metar_data.get("unit") == "fahrenheit" else "°C"
# 解析观测时间 (格式: 2026-02-07T11:00:00.000Z)
obs_time_raw = metar_data.get("observation_time", "")
if "T" in obs_time_raw:
obs_time = obs_time_raw.split("T")[1][:5] + " UTC"
else:
obs_time = obs_time_raw or "N/A"
# 可选:风速信息
wind_kt = metar_data["current"].get("wind_speed_kt")
wind_text = f" | 风速:{wind_kt}kt" if wind_kt else ""
metar_text = (
f"✈️ <b>机场实测 ({icao}):</b>\n"
f" 🌡️ {temp:.1f}{unit}{wind_text}\n"
f" 🕐 观测: {obs_time}\n\n"
)
# 2. 信号详情构建
items_text = ""
for a in alerts:
items_text += f"{a['msg']}\n\n"
# 2. 策略建议(如果有)
# 3. 策略建议(如果有)
tips_text = ""
if strategy_tips:
tips_text = (
@@ -161,10 +184,11 @@ class TelegramNotifier:
+ "\n\n"
)
# 3. 总体布局 (回归清爽风格)
# 4. 总体布局
text = (
f"🔔 <b>城市监控报告 #{self._escape_html(city)}</b>\n\n"
f"📍 城市: {self._escape_html(city)}\n"
f"{metar_text}"
f"📊 <b>实时异动:</b>\n"
f"{items_text}"
f"{tips_text}"