diff --git a/bot_listener.py b/bot_listener.py index 5c490d66..65976cb2 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -3,7 +3,7 @@ import os import telebot # type: ignore from loguru import logger # type: ignore -# 纭繚椤圭洰鏍圭洰褰曞湪 sys.path 涓? +# 确保项目根目录在 sys.path 中 project_root = os.path.dirname(os.path.abspath(__file__)) if project_root not in sys.path: sys.path.insert(0, project_root) @@ -339,7 +339,10 @@ def start_bot(): return weather_data = weather.fetch_all_sources( - city_name, lat=coords["lat"], lon=coords["lon"] + city_name, + lat=coords["lat"], + lon=coords["lon"], + force_refresh=True, ) city_report = build_city_query_report( city_name=city_name, diff --git a/src/analysis/city_query_service.py b/src/analysis/city_query_service.py index d7f215c3..751ac789 100644 --- a/src/analysis/city_query_service.py +++ b/src/analysis/city_query_service.py @@ -58,6 +58,17 @@ def _render_local_time( metar: Dict[str, Any], fallback_utc_offset: int, ) -> str: + utc_offset = open_meteo.get("utc_offset") + if utc_offset is None: + utc_offset = fallback_utc_offset + try: + local_now = datetime.now(timezone.utc).astimezone( + timezone(timedelta(seconds=int(utc_offset))) + ) + return local_now.strftime("%H:%M") + except Exception: + pass + local_time = (open_meteo.get("current") or {}).get("local_time", "") if " " in str(local_time): return str(local_time).split(" ")[1][:5] @@ -79,13 +90,49 @@ def _render_local_time( if metar_obs: return str(metar_obs)[:5] - try: - local_now = datetime.now(timezone.utc).astimezone( - timezone(timedelta(seconds=int(fallback_utc_offset))) - ) - return local_now.strftime("%H:%M") - except Exception: - return "N/A" + return "N/A" + + +def _derive_mgm_daily_highs_from_hourly( + mgm: Dict[str, Any], + fallback_utc_offset: int, +) -> Dict[str, float]: + if not isinstance(mgm, dict): + return {} + hourly = mgm.get("hourly") + if not isinstance(hourly, list) or not hourly: + return {} + + daily_highs: Dict[str, float] = {} + local_tz = timezone(timedelta(seconds=int(fallback_utc_offset))) + for row in hourly: + if not isinstance(row, dict): + continue + temp = _sf(row.get("temp")) + raw_time = str(row.get("time") or "").strip() + if temp is None or not raw_time: + continue + + date_key = None + if "T" in raw_time: + try: + dt = datetime.fromisoformat(raw_time.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.astimezone(local_tz) + date_key = dt.strftime("%Y-%m-%d") + except Exception: + if len(raw_time) >= 10 and raw_time[4] == "-" and raw_time[7] == "-": + date_key = raw_time[:10] + elif len(raw_time) >= 10 and raw_time[4] == "-" and raw_time[7] == "-": + date_key = raw_time[:10] + + if not date_key: + continue + + prev = daily_highs.get(date_key) + daily_highs[date_key] = temp if prev is None else max(prev, temp) + + return daily_highs def _append_future_forecast_lines( @@ -98,6 +145,12 @@ def _append_future_forecast_lines( ) -> None: mgm = weather_data.get("mgm") or {} mgm_daily = (mgm.get("daily_forecasts") or {}) if isinstance(mgm, dict) else {} + mgm_hourly_daily = _derive_mgm_daily_highs_from_hourly(mgm, fallback_utc_offset) + if not isinstance(mgm_daily, dict): + mgm_daily = {} + for date_key, day_high in mgm_hourly_daily.items(): + if date_key not in mgm_daily: + mgm_daily[date_key] = day_high mm_raw = weather_data.get("multi_model") or {} mm_daily = mm_raw.get("daily_forecasts", {}) if isinstance(mm_raw, dict) else {} mb_daily = (weather_data.get("meteoblue") or {}).get("daily_highs", []) or [] @@ -108,8 +161,9 @@ def _append_future_forecast_lines( for d, t in zip(dates[1:], max_temps[1:]): mgm_value = mgm_daily.get(d) if isinstance(mgm_daily, dict) else None if mgm_value is not None: + mgm_display = f"{float(mgm_value):.1f}" future_forecasts.append( - f"{d[5:]}: {t}{temp_symbol} | 🇺🇸 MGM: {mgm_value}{temp_symbol}" + f"{d[5:]}: {t}{temp_symbol} | 🇺🇸 MGM: {mgm_display}{temp_symbol}" ) else: future_forecasts.append(f"{d[5:]}: {t}{temp_symbol}") diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py index 83ec024a..4df0f810 100644 --- a/src/analysis/market_alert_engine.py +++ b/src/analysis/market_alert_engine.py @@ -473,6 +473,18 @@ def _bucket_label(bucket: Any) -> Optional[str]: return None +def _row_yes_buy_prob(row: Dict[str, Any]) -> Optional[float]: + if not isinstance(row, dict): + return None + return _norm_probability(row.get("yes_buy")) + + +def _has_actionable_yes_buy_quote(row: Dict[str, Any]) -> bool: + quote = _row_yes_buy_prob(row) + # 0 usually means no actionable orderbook bid, not a tradable quote. + return quote is not None and quote > 0.0 + + def _to_celsius(temp: Optional[float], temp_symbol: str) -> Optional[float]: if temp is None: return None @@ -555,6 +567,7 @@ def _pick_bucket_for_forecast( best_row: Optional[Dict[str, Any]] = None best_distance: Optional[float] = None + best_has_quote = False best_probability = -1.0 best_rank = 10**9 @@ -564,12 +577,14 @@ def _pick_bucket_for_forecast( continue distance = _distance_to_bucket(target, bounds) + has_quote = _has_actionable_yes_buy_quote(row) probability = _norm_probability(row.get("probability")) probability_rank = probability if probability is not None else -1.0 if best_row is None: best_row = row best_distance = distance + best_has_quote = has_quote best_probability = probability_rank best_rank = idx continue @@ -578,19 +593,32 @@ def _pick_bucket_for_forecast( if distance < best_distance: best_row = row best_distance = distance + best_has_quote = has_quote best_probability = probability_rank best_rank = idx continue if abs(distance - best_distance) <= 1e-9: - if probability_rank > best_probability: + if has_quote and not best_has_quote: best_row = row best_distance = distance + best_has_quote = has_quote best_probability = probability_rank best_rank = idx - elif abs(probability_rank - best_probability) <= 1e-9 and idx < best_rank: + elif has_quote == best_has_quote and probability_rank > best_probability: best_row = row best_distance = distance + best_has_quote = has_quote + best_probability = probability_rank + best_rank = idx + elif ( + has_quote == best_has_quote + and abs(probability_rank - best_probability) <= 1e-9 + and idx < best_rank + ): + best_row = row + best_distance = distance + best_has_quote = has_quote best_probability = probability_rank best_rank = idx @@ -972,7 +1000,12 @@ def _build_telegram_messages_mispricing( om_settle = snapshot.get("open_meteo_settlement") forecast_bucket = snapshot.get("forecast_bucket") or {} match_bucket_label = str(forecast_bucket.get("label") or "--").strip() or "--" - match_bucket_yes = _fmt_cents(forecast_bucket.get("yes_buy")) + match_bucket_yes_prob = _norm_probability(forecast_bucket.get("yes_buy")) + match_bucket_yes = ( + _fmt_cents(match_bucket_yes_prob) + if match_bucket_yes_prob is not None and match_bucket_yes_prob > 0.0 + else "--" + ) market_url = str( snapshot.get("market_url") or snapshot.get("primary_market_url") diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index d1c87e89..089947cd 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -1128,6 +1128,7 @@ class PolymarketReadOnlyLayer: ) orderbook_raw = self._safe_call(clob, "get_order_book", token_id) book, book_liquidity = self._normalize_orderbook(orderbook_raw) + buy, sell = self._resolve_trade_prices(buy=buy, sell=sell, book=book) return { "buy": buy, "sell": sell, @@ -1150,6 +1151,7 @@ class PolymarketReadOnlyLayer: ) orderbook_raw = self._clob_get("/book", {"token_id": token_id}) book, book_liquidity = self._normalize_orderbook(orderbook_raw) + buy, sell = self._resolve_trade_prices(buy=buy, sell=sell, book=book) return { "buy": buy, "sell": sell, @@ -1174,6 +1176,19 @@ class PolymarketReadOnlyLayer: except Exception: return None + def _resolve_trade_prices( + self, + buy: Optional[float], + sell: Optional[float], + book: Optional[Dict[str, Any]], + ) -> Tuple[Optional[float], Optional[float]]: + payload = book if isinstance(book, dict) else {} + best_bid = _extract_price(payload.get("best_bid")) + best_ask = _extract_price(payload.get("best_ask")) + resolved_buy = best_ask if best_ask is not None else buy + resolved_sell = best_bid if best_bid is not None else sell + return resolved_buy, resolved_sell + def _normalize_orderbook(self, orderbook_raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: payload = _to_plain_dict(orderbook_raw) if not payload and isinstance(orderbook_raw, dict): @@ -1210,6 +1225,8 @@ class PolymarketReadOnlyLayer: _parse_side(bids_raw, bid_levels) _parse_side(asks_raw, ask_levels) + bid_levels.sort(key=lambda level: level[0], reverse=True) + ask_levels.sort(key=lambda level: level[0]) best_bid = bid_levels[0][0] if bid_levels else None best_ask = ask_levels[0][0] if ask_levels else None normalized = { diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 8404d967..179a291d 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -1797,8 +1797,46 @@ class WeatherDataCollector: return None + def _evict_city_caches( + self, + city: str, + lat: Optional[float], + lon: Optional[float], + use_fahrenheit: bool, + ) -> None: + """Drop in-memory caches for one city before a force-refresh query.""" + if lat is not None and lon is not None: + base = f"{round(float(lat), 4)}:{round(float(lon), 4)}" + unit = "f" if use_fahrenheit else "c" + open_meteo_key = f"{base}:14:{unit}" + ensemble_key = f"{base}:{unit}" + meteoblue_key = ensemble_key + multi_model_key = ensemble_key + + with self._open_meteo_cache_lock: + self._open_meteo_cache.pop(open_meteo_key, None) + with self._ensemble_cache_lock: + self._ensemble_cache.pop(ensemble_key, None) + with self._multi_model_cache_lock: + self._multi_model_cache.pop(multi_model_key, None) + with self._meteoblue_cache_lock: + self._meteoblue_cache.pop(meteoblue_key, None) + + icao = self.get_icao_code(city) + if icao: + prefix = f"{icao}:" + with self._metar_cache_lock: + for key in list(self._metar_cache.keys()): + if key.startswith(prefix): + self._metar_cache.pop(key, None) + def fetch_all_sources( - self, city: str, lat: float = None, lon: float = None, country: str = None + self, + city: str, + lat: float = None, + lon: float = None, + country: str = None, + force_refresh: bool = False, ) -> Dict: """ Fetch weather data from all available sources @@ -1835,9 +1873,17 @@ class WeatherDataCollector: # 严格判断是否为美国市场(必须完全匹配列表或缩写) use_fahrenheit = city_lower in us_cities + if force_refresh: + self._evict_city_caches( + city=city, + lat=lat, + lon=lon, + use_fahrenheit=use_fahrenheit, + ) + # Turkish cities: keep MGM model fallback alive when Open-Meteo is rate-limited. turkish_provinces = { - "ankara": ("17128", "Ankara"), # settlement reference: Esenboğa airport + "ankara": ("17130", "Ankara"), # MGM center station "istanbul": ("17060", "Istanbul"), } @@ -1862,23 +1908,14 @@ class WeatherDataCollector: # 对土耳其城市,额外获取 MGM 官方数据与周边测站 turkish_provinces = { - "ankara": ("17128", "Ankara"), # 使用机场站 (Esenboğa Havalimanı) 作为结算参考主站 + "ankara": ("17130", "Ankara"), # use one MGM station consistently "istanbul": ("17060", "Istanbul"), } if city_lower in turkish_provinces: istno, province = turkish_provinces[city_lower] - # 核心逻辑:实测用 istno (17128), 预报强制去 17130 拿 + # Use one station for both current conditions and forecasts. mgm_data = self.fetch_from_mgm(istno) - - # 如果当前是机场站 (17128),我们额外去 17130 拿一次预报 - if istno == "17128": - mgm_city_center = self.fetch_from_mgm("17130") - if mgm_city_center and mgm_data: - # 用市中心的预报覆盖机场可能缺失的预报 - mgm_data["today_high"] = mgm_city_center.get("today_high") - mgm_data["daily_forecasts"] = mgm_city_center.get("daily_forecasts") - logger.info("⚡ 已同步 MGM 安卡拉总部 (17130) 的官方最高温预报") - + if mgm_data: results["mgm"] = mgm_data nearby = self.fetch_mgm_nearby_stations(province, root_ist_no=istno) @@ -1943,13 +1980,6 @@ class WeatherDataCollector: if city_lower in turkish_provinces: istno, province = turkish_provinces[city_lower] mgm_data = self.fetch_from_mgm(istno) - if istno == "17128": - mgm_city_center = self.fetch_from_mgm("17130") - if mgm_city_center and mgm_data: - mgm_data["today_high"] = mgm_city_center.get("today_high") - mgm_data["daily_forecasts"] = mgm_city_center.get( - "daily_forecasts" - ) if mgm_data: results["mgm"] = mgm_data nearby = self.fetch_mgm_nearby_stations( diff --git a/src/onchain/polymarket_wallet_activity_watcher.py b/src/onchain/polymarket_wallet_activity_watcher.py index 02746662..93e2f5f9 100644 --- a/src/onchain/polymarket_wallet_activity_watcher.py +++ b/src/onchain/polymarket_wallet_activity_watcher.py @@ -237,6 +237,109 @@ def _diff_positions( return changes +def _merge_pending_update( + pending_updates: Dict[str, Dict[str, Any]], + pos_key: str, + pos: Dict[str, Any], + now_ts: int, +) -> None: + entry = pending_updates.get(pos_key) + size_delta = _safe_float(pos.get("size_delta")) + old_size = _safe_float(pos.get("old_size")) + new_size = _safe_float(pos.get("size")) + old_avg = _safe_float(pos.get("old_avg_price")) + new_avg = _safe_float(pos.get("avg_price")) + + if entry is None: + pending_updates[pos_key] = { + "count": 1, + "first_ts": now_ts, + "last_ts": now_ts, + "title": pos.get("title"), + "slug": pos.get("slug"), + "event_slug": pos.get("event_slug"), + "outcome": pos.get("outcome"), + "asset": pos.get("asset"), + "condition_id": pos.get("condition_id"), + "old_size": old_size, + "size": new_size, + "size_delta": size_delta, + "old_avg_price": old_avg, + "avg_price": new_avg, + "position_value": _safe_float(pos.get("position_value")), + "cash_pnl": _safe_float(pos.get("cash_pnl")), + "percent_pnl": _safe_float(pos.get("percent_pnl")), + } + return + + entry["count"] = int(entry.get("count", 1)) + 1 + entry["last_ts"] = now_ts + entry["size_delta"] = _safe_float(entry.get("size_delta")) + size_delta + entry["size"] = new_size + entry["avg_price"] = new_avg + entry["position_value"] = _safe_float(pos.get("position_value")) + entry["cash_pnl"] = _safe_float(pos.get("cash_pnl")) + entry["percent_pnl"] = _safe_float(pos.get("percent_pnl")) + pending_updates[pos_key] = entry + + +def _finalize_pending_update( + pending_entry: Dict[str, Any], + now_ts: int, +) -> Dict[str, Any]: + first_ts = int(pending_entry.get("first_ts") or now_ts) + last_ts = int(pending_entry.get("last_ts") or now_ts) + return { + "title": pending_entry.get("title") or "", + "slug": pending_entry.get("slug") or "", + "event_slug": pending_entry.get("event_slug") or "", + "outcome": pending_entry.get("outcome") or "", + "asset": pending_entry.get("asset") or "", + "condition_id": pending_entry.get("condition_id") or "", + "old_size": _safe_float(pending_entry.get("old_size")), + "size": _safe_float(pending_entry.get("size")), + "size_delta": _safe_float(pending_entry.get("size_delta")), + "old_avg_price": _safe_float(pending_entry.get("old_avg_price")), + "avg_price": _safe_float(pending_entry.get("avg_price")), + "position_value": _safe_float(pending_entry.get("position_value")), + "cash_pnl": _safe_float(pending_entry.get("cash_pnl")), + "percent_pnl": _safe_float(pending_entry.get("percent_pnl")), + "agg_count": int(pending_entry.get("count") or 1), + "agg_span_sec": max(0, last_ts - first_ts), + } + + +def _flush_ready_pending_updates( + pending_updates: Dict[str, Dict[str, Any]], + now_ts: int, + debounce_sec: int, + max_hold_sec: int, + force_keys: Optional[set] = None, +) -> List[Tuple[str, Dict[str, Any]]]: + out: List[Tuple[str, Dict[str, Any]]] = [] + keys = list(pending_updates.keys()) + for key in keys: + entry = pending_updates.get(key) + if not isinstance(entry, dict): + pending_updates.pop(key, None) + continue + + if force_keys and key in force_keys: + out.append(("update", _finalize_pending_update(entry, now_ts))) + pending_updates.pop(key, None) + continue + + first_ts = int(entry.get("first_ts") or now_ts) + last_ts = int(entry.get("last_ts") or now_ts) + quiet_enough = (now_ts - last_ts) >= debounce_sec + held_too_long = (now_ts - first_ts) >= max_hold_sec + if quiet_enough or held_too_long: + out.append(("update", _finalize_pending_update(entry, now_ts))) + pending_updates.pop(key, None) + + return out + + def _fmt_pct(value: float) -> str: # Data API may return either ratio (0.12) or percent (12.0). display = value * 100.0 if abs(value) <= 1.5 else value @@ -279,8 +382,14 @@ def _format_change_block( lines: List[str] = [] if change_type == "new": lines.append("🆕 新开仓位") + elif change_type == "closed": + lines.append("❌ 仓位关闭") else: - lines.append("🔄 仓位更新") + agg_count = int(pos.get("agg_count") or 1) + if agg_count > 1: + lines.append("🔁 连续仓位变动汇总") + else: + lines.append("🔄 仓位更新") lines.append(f"钱包: {_short(wallet)}") if market_url: @@ -295,11 +404,20 @@ def _format_change_block( now_size = _safe_float(pos.get("size")) delta = _safe_float(pos.get("size_delta")) lines.append(f"持有数量: {old_size:.3f} -> {now_size:.3f} (Δ {delta:+.3f})") + agg_count = int(pos.get("agg_count") or 1) + if agg_count > 1: + span_sec = int(_safe_float(pos.get("agg_span_sec"))) + lines.append(f"变动次数: {agg_count} 次 | 聚合窗口: {span_sec}s") else: lines.append(f"持有数量: {_safe_float(pos.get('size')):.3f}") avg_price = _safe_float(pos.get("avg_price")) - if _should_show_avg_price(avg_price): + old_avg_price = _safe_float(pos.get("old_avg_price")) + agg_count = int(pos.get("agg_count") or 1) + if change_type == "update" and agg_count > 1: + if _should_show_avg_price(old_avg_price) or _should_show_avg_price(avg_price): + lines.append(f"建仓均价: {_fmt_price(old_avg_price)} -> {_fmt_price(avg_price)}") + elif _should_show_avg_price(avg_price): lines.append(f"建仓均价: {_fmt_price(avg_price)}") lines.append(f"当前价值: {_fmt_usd(_safe_float(pos.get('position_value')))}") @@ -360,6 +478,14 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5)) notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False) bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False) + update_debounce_sec = max( + poll_sec, + _env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC", 90), + ) + update_max_hold_sec = max( + update_debounce_sec, + _env_int("POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC", 240), + ) # 价格过滤范围配置 min_price = _env_float("POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN", 0.0) @@ -374,13 +500,15 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread logger.info( f"polymarket wallet activity watcher started users={len(users)} " - f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price}" + f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} " + f"update_debounce={update_debounce_sec}s update_max_hold={update_max_hold_sec}s" ) while True: touched = False for user in users: try: + now_ts = int(time.time()) rows = _fetch_positions( session=session, base_url=data_api_url, @@ -388,19 +516,30 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread timeout_sec=timeout_sec, ) current = _build_snapshot(rows, min_size_abs=min_size_abs) - prev = ( - (users_state.get(user) or {}).get("positions") - if isinstance(users_state.get(user), dict) - else {} - ) or {} - if not prev and not bootstrap_alert: - users_state[user] = { - "positions": current, - "updated_at": int(time.time()), - } - touched = True - continue + user_state = users_state.get(user) if isinstance(users_state.get(user), dict) else {} + prev = (user_state.get("positions") if isinstance(user_state, dict) else {}) or {} + if not isinstance(prev, dict): + prev = {} + pending_updates = ( + user_state.get("pending_updates") if isinstance(user_state, dict) else {} + ) or {} + if not isinstance(pending_updates, dict): + pending_updates = {} + initialized = bool(user_state.get("initialized")) if isinstance(user_state, dict) else False + + # First cycle for each wallet only initializes baseline unless bootstrap alert is enabled. + if not initialized: + if not bootstrap_alert: + users_state[user] = { + "positions": current, + "pending_updates": {}, + "initialized": True, + "updated_at": now_ts, + } + touched = True + continue + prev = {} changes = _diff_positions( previous=prev, @@ -411,16 +550,63 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread max_price=max_price, ) - if changes: - msg = _build_message(user, changes, max_changes=max_changes) + outgoing: List[Tuple[str, Dict[str, Any]]] = [] + for change_type, pos in changes: + pos_key = _position_key(pos) + if change_type == "update": + _merge_pending_update( + pending_updates=pending_updates, + pos_key=pos_key, + pos=pos, + now_ts=now_ts, + ) + continue + + outgoing.extend( + _flush_ready_pending_updates( + pending_updates=pending_updates, + now_ts=now_ts, + debounce_sec=update_debounce_sec, + max_hold_sec=update_max_hold_sec, + force_keys={pos_key}, + ) + ) + outgoing.append((change_type, pos)) + + # If a key disappeared from snapshot, flush pending summary now. + missing_keys = {k for k in pending_updates.keys() if k not in current} + if missing_keys: + outgoing.extend( + _flush_ready_pending_updates( + pending_updates=pending_updates, + now_ts=now_ts, + debounce_sec=update_debounce_sec, + max_hold_sec=update_max_hold_sec, + force_keys=missing_keys, + ) + ) + + outgoing.extend( + _flush_ready_pending_updates( + pending_updates=pending_updates, + now_ts=now_ts, + debounce_sec=update_debounce_sec, + max_hold_sec=update_max_hold_sec, + ) + ) + + if outgoing: + msg = _build_message(user, outgoing, max_changes=max_changes) bot.send_message(chat_id, msg, disable_web_page_preview=True) logger.info( - f"wallet activity pushed user={user} changes={len(changes)}" + f"wallet activity pushed user={user} changes={len(outgoing)}" ) users_state[user] = { "positions": current, - "updated_at": int(time.time()), + "pending_updates": pending_updates, + "initialized": True, + "updated_at": now_ts, } touched = True except Exception: @@ -444,7 +630,3 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread - - - - diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 237b036a..297a685e 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -133,12 +133,23 @@ def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_c return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0) -def _market_price_cap_ok(alert_payload: Dict[str, Any], max_yes_buy: float) -> bool: +def _market_price_cap_ok( + alert_payload: Dict[str, Any], + max_yes_buy: float, + require_actionable_quote: bool = False, +) -> bool: if max_yes_buy >= 1.0: return True market = alert_payload.get("market_snapshot") or {} if not isinstance(market, dict) or not market.get("available"): + if require_actionable_quote: + logger.info( + "trade alert skipped: market snapshot unavailable city={}".format( + alert_payload.get("city"), + ) + ) + return False return True # Strict rule: use the bucket mapped from Open-Meteo settlement. @@ -149,10 +160,11 @@ def _market_price_cap_ok(alert_payload: Dict[str, Any], max_yes_buy: float) -> b yes_buy = _norm_prob(forecast_bucket.get("yes_buy")) bucket_label = str(forecast_bucket.get("label") or "").strip() or None - if yes_buy is None: + if yes_buy is None or yes_buy <= 0.0: logger.info( - "trade alert skipped: no mapped forecast bucket city={} om_settle={}".format( + "trade alert skipped: no actionable mapped bucket quote city={} bucket={} om_settle={}".format( alert_payload.get("city"), + bucket_label or "--", market.get("open_meteo_settlement"), ) ) @@ -321,6 +333,7 @@ def _maybe_send_alert( cooldown_sec: int, min_severity: str, min_trigger_count: int, + mispricing_only: bool, ) -> bool: now_ts = int(time.time()) last_by_city = state.setdefault("last_by_city", {}) @@ -330,7 +343,11 @@ def _maybe_send_alert( 0.0, min(1.0, _env_float("TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY", 0.10)), ) - if not _market_price_cap_ok(alert_payload, max_yes_buy): + if not _market_price_cap_ok( + alert_payload, + max_yes_buy, + require_actionable_quote=mispricing_only, + ): is_active = False message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip() @@ -430,6 +447,7 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th cooldown_sec=cooldown_sec, min_severity=min_severity, min_trigger_count=min_trigger_count, + mispricing_only=mispricing_only, ): try: _save_state(state_path, state) diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py new file mode 100644 index 00000000..fdecde32 --- /dev/null +++ b/tests/test_polymarket_readonly.py @@ -0,0 +1,62 @@ +from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer + + +def test_normalize_orderbook_uses_sorted_best_prices(): + layer = PolymarketReadOnlyLayer() + raw = { + "bids": [ + {"price": "0.24", "size": "10"}, + {"price": "0.31", "size": "5"}, + {"price": "0.27", "size": "8"}, + ], + "asks": [ + {"price": "0.44", "size": "9"}, + {"price": "0.39", "size": "6"}, + {"price": "0.42", "size": "4"}, + ], + } + + book, _liquidity = layer._normalize_orderbook(raw) + + assert book is not None + assert book["best_bid"] == 0.31 + assert book["best_ask"] == 0.39 + assert book["bid_levels"][0][0] == 0.31 + assert book["ask_levels"][0][0] == 0.39 + + +def test_fetch_token_market_data_prefers_orderbook_executable_prices(): + class FakeClob: + @staticmethod + def get_price(_token_id: str, side: str): + if side == "BUY": + return {"price": "0.11"} + return {"price": "0.88"} + + @staticmethod + def get_midpoint(_token_id: str): + return {"midpoint": "0.50"} + + @staticmethod + def get_last_trade_price(_token_id: str): + return {"price": "0.49"} + + @staticmethod + def get_order_book(_token_id: str): + return { + "bids": [{"price": "0.24", "size": "10"}], + "asks": [{"price": "0.26", "size": "12"}], + } + + layer = PolymarketReadOnlyLayer() + layer._get_clob_client = lambda: FakeClob() + + data = layer._fetch_token_market_data("token-1") + + # Executable BUY should match best ask from the book. + assert data["buy"] == 0.26 + # Executable SELL should match best bid from the book. + assert data["sell"] == 0.24 + assert data["midpoint"] == 0.5 + assert data["last_trade_price"] == 0.49 + diff --git a/web/app.py b/web/app.py index adc51de6..a96f89d2 100644 --- a/web/app.py +++ b/web/app.py @@ -144,7 +144,12 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: sym = "°F" if is_f else "°C" # ── 1. Fetch raw data ── - raw = _weather.fetch_all_sources(city, lat=lat, lon=lon) + raw = _weather.fetch_all_sources( + city, + lat=lat, + lon=lon, + force_refresh=force_refresh, + ) om = raw.get("open-meteo", {}) metar = raw.get("metar", {}) mgm = raw.get("mgm") or {} @@ -786,7 +791,45 @@ def _build_city_detail_payload( market_slug: Optional[str] = None, ) -> Dict[str, Any]: distribution = data.get("probabilities", {}).get("distribution", []) or [] - primary_bucket = distribution[0] if distribution else None + city_name = str(data.get("name") or "").strip().lower() + model_map = data.get("multi_model") or {} + if not isinstance(model_map, dict): + model_map = {} + + # Mispricing anchor temperature: + # - Ankara: use MGM today-high forecast + # - Others: use Open-Meteo today-high forecast + anchor_temp = None + if city_name == "ankara": + anchor_temp = _sf(model_map.get("MGM")) + else: + anchor_temp = _sf(model_map.get("Open-Meteo")) + + if anchor_temp is None and city_name == "ankara": + # Keep radar available when MGM is missing unexpectedly. + anchor_temp = _sf(model_map.get("Open-Meteo")) + + primary_bucket = None + if isinstance(distribution, list) and distribution: + if anchor_temp is None: + primary_bucket = distribution[0] + else: + ranked_buckets = [] + for idx, row in enumerate(distribution): + if not isinstance(row, dict): + continue + bucket_temp = _sf(row.get("value")) + bucket_prob = _sf(row.get("probability")) + if bucket_temp is None: + continue + prob_rank = bucket_prob if bucket_prob is not None else -1.0 + ranked_buckets.append((abs(bucket_temp - anchor_temp), -prob_rank, idx, row)) + if ranked_buckets: + ranked_buckets.sort(key=lambda x: (x[0], x[1], x[2])) + primary_bucket = ranked_buckets[0][3] + else: + primary_bucket = distribution[0] + model_probability = None if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None: try: