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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user