Files
PolyWeather/src/utils/telegram_push.py
T

1296 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import json
import os
import re
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
from src.database.runtime_state import (
STATE_STORAGE_SQLITE,
TelegramAlertStateRepository,
get_state_storage_mode,
)
from src.data_collection.city_registry import CITY_REGISTRY
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
SEVERITY_RANK = {
"none": 0,
"low": 1,
"medium": 2,
"high": 3,
}
_telegram_state_repo = TelegramAlertStateRepository()
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None:
return default
try:
return float(raw)
except Exception:
return default
def _norm_prob(v: Any) -> Optional[float]:
if v is None:
return None
try:
n = float(v)
except Exception:
return None
if n > 1.0:
n = n / 100.0
return max(0.0, min(1.0, n))
def _fmt_cents(value: Any) -> Optional[str]:
numeric = _norm_prob(value)
if numeric is None:
return None
cents = numeric * 100.0
rounded = round(cents, 1)
text = f"{rounded:.1f}".rstrip("0").rstrip(".")
return f"{text}c"
def _safe_float(v: Any) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except Exception:
return None
def _bucket_value(row: Dict[str, Any]) -> Optional[float]:
if not isinstance(row, dict):
return None
for key in ("value", "temp"):
n = _safe_float(row.get(key))
if n is not None:
return n
label = str(row.get("label") or "").strip()
m = re.search(r"(-?\d+(?:\.\d+)?)", label)
if not m:
return None
return _safe_float(m.group(1))
def _bucket_bounds(row: Dict[str, Any]) -> Optional[Tuple[Optional[float], Optional[float]]]:
value = _bucket_value(row)
if value is None:
return None
label = str(row.get("label") or "").strip().lower()
is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above"))
is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below"))
if is_upper_tail and not is_lower_tail:
return value, None
if is_lower_tail and not is_upper_tail:
return None, value
return value, value
def _observed_settlement_floor(alert_payload: Dict[str, Any]) -> Optional[float]:
evidence = alert_payload.get("evidence") or {}
if not isinstance(evidence, dict):
evidence = {}
inputs = evidence.get("inputs") or {}
if not isinstance(inputs, dict):
inputs = {}
suppression = alert_payload.get("suppression") or {}
if not isinstance(suppression, dict):
suppression = {}
rules = alert_payload.get("rules") or {}
if not isinstance(rules, dict):
rules = {}
breakthrough = rules.get("forecast_breakthrough") or {}
if not isinstance(breakthrough, dict):
breakthrough = {}
floor_candidates: List[float] = []
for raw in (
inputs.get("wu_settle"),
suppression.get("max_so_far"),
inputs.get("current_temp"),
suppression.get("current_temp"),
breakthrough.get("current_temp"),
):
n = _safe_float(raw)
if n is not None:
floor_candidates.append(n)
if not floor_candidates:
return None
return max(floor_candidates)
def _optional_bool(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _parse_iso_datetime_utc(value: Any) -> Optional[datetime]:
if not value:
return None
text = str(value).strip()
if not text:
return None
if "T" not in text:
return None
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _parse_city_list(raw: Optional[str]) -> List[str]:
if not raw:
return list(CITY_REGISTRY.keys())
out: List[str] = []
for part in raw.split(","):
city = part.strip().lower()
if city and city in CITY_REGISTRY:
out.append(city)
return out or list(CITY_REGISTRY.keys())
def _state_file() -> str:
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "telegram_alert_state.json")
def _load_state(path: str) -> Dict[str, Any]:
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
try:
return _telegram_state_repo.load_state()
except Exception as exc:
logger.error(f"failed to load telegram push state from sqlite: {exc}")
if not os.path.exists(path):
return {"last_by_city": {}, "by_signature": {}}
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
data.setdefault("last_by_city", {})
data.setdefault("by_signature", {})
return data
except Exception as exc:
logger.warning(f"failed to load telegram push state: {exc}")
return {"last_by_city": {}, "by_signature": {}}
def _save_state(path: str, state: Dict[str, Any]) -> None:
mode = get_state_storage_mode()
if mode == STATE_STORAGE_SQLITE:
_telegram_state_repo.save_state(state)
if mode == STATE_STORAGE_SQLITE:
return
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400) -> None:
for bucket_name in ("by_signature", "focus_digest_slots"):
bucket = state.get(bucket_name, {})
if not isinstance(bucket, dict):
state[bucket_name] = {}
continue
stale = [key for key, value in bucket.items() if now_ts - int(value or 0) > keep_sec]
for key in stale:
bucket.pop(key, None)
last_by_city = state.get("last_by_city", {})
if not isinstance(last_by_city, dict):
state["last_by_city"] = {}
return
stale_city = []
for city, row in last_by_city.items():
ts = int((row or {}).get("ts") or 0)
if now_ts - ts > keep_sec:
stale_city.append(city)
for city in stale_city:
last_by_city.pop(city, None)
def _cache_market_monitor_digest(
state: Dict[str, Any],
*,
message: str,
slot_label: str,
generated_at_ts: Optional[int] = None,
) -> None:
state["last_market_monitor_digest"] = {
"message": str(message or "").strip(),
"slot_label": str(slot_label or "").strip(),
"generated_at_ts": int(generated_at_ts or time.time()),
}
def load_cached_market_monitor_digest() -> str:
state = _load_state(_state_file())
cached = state.get("last_market_monitor_digest") or {}
if not isinstance(cached, dict):
return ""
return str(cached.get("message") or "").strip()
def _minute_of_day(text: Optional[str]) -> Optional[int]:
raw = str(text or "").strip()
if not raw or ":" not in raw:
return None
try:
hour_s, minute_s = raw.split(":", 1)
hour = int(hour_s)
minute = int(minute_s[:2])
except Exception:
return None
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return hour * 60 + minute
def _format_minutes_window(delta_minutes: int) -> str:
total = abs(int(delta_minutes))
hours = total // 60
minutes = total % 60
if hours > 0 and minutes > 0:
text = f"{hours}h{minutes:02d}m"
elif hours > 0:
text = f"{hours}h"
else:
text = f"{minutes}m"
return text
def _format_interval_brief(seconds: int) -> str:
total = max(1, int(seconds))
if total % 3600 == 0:
hours = total // 3600
return f"{hours}小时"
if total % 60 == 0:
minutes = total // 60
return f"{minutes}分钟"
return f"{total}秒"
def _focus_push_window_config() -> Dict[str, int]:
return {
"start_local_hour": min(
23,
max(0, _env_int("TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR", 8)),
),
"before_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN", 480),
),
"after_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", 300),
),
}
def _focus_push_window_label() -> str:
config = _focus_push_window_config()
return (
f"当地 {config['start_local_hour']:02d}:00 后;"
f"有日内高点参考时,保留高点前 {_format_minutes_window(config['before_peak_min'])} / "
f"高点后 {_format_minutes_window(config['after_peak_min'])} 内"
)
def _local_peak_context(alert_payload: Dict[str, Any]) -> Dict[str, Any]:
evidence = alert_payload.get("evidence") or {}
generated_local_time = str(evidence.get("generated_local_time") or "").strip()
trigger_summary = evidence.get("trigger_summary") or {}
suppression_snapshot = trigger_summary.get("suppression_snapshot") or {}
peak_time = str(suppression_snapshot.get("max_temp_time") or "").strip()
local_min = _minute_of_day(generated_local_time)
peak_min = _minute_of_day(peak_time)
if local_min is None or peak_min is None:
return {
"local_time": generated_local_time,
"peak_time": peak_time,
"minutes_to_peak": None,
"score_adjustment": 0.0,
"window_label": "",
}
delta = peak_min - local_min
score = 0.0
window_label = ""
if 0 <= delta <= 120:
score = 18.0
window_label = f"峰值前 {_format_minutes_window(delta)}"
elif 120 < delta <= 360:
score = 10.0
window_label = f"距峰值 {_format_minutes_window(delta)}"
elif -90 <= delta < 0:
score = 6.0
window_label = f"峰值后 {_format_minutes_window(delta)}"
elif delta < -90:
score = -8.0
window_label = "峰值已过较久"
return {
"local_time": generated_local_time,
"peak_time": peak_time,
"minutes_to_peak": delta,
"score_adjustment": score,
"window_label": window_label,
}
def _market_monitor_score(alert_payload: Dict[str, Any]) -> float:
severity = str(alert_payload.get("severity") or "none").lower()
severity_score = {"high": 36.0, "medium": 24.0, "none": 0.0}.get(severity, 0.0)
trigger_count = int(alert_payload.get("trigger_count") or 0)
trigger_score = min(18.0, float(trigger_count) * 9.0)
snapshot = alert_payload.get("market_snapshot") or {}
if not isinstance(snapshot, dict):
snapshot = {}
if not snapshot.get("available"):
return 0.0
edge_percent = abs(_safe_float(snapshot.get("edge_percent")) or 0.0)
edge_score = min(22.0, edge_percent * 2.5)
yes_buy = _norm_prob(snapshot.get("yes_buy"))
if yes_buy is None:
forecast_bucket = snapshot.get("forecast_bucket") or {}
if isinstance(forecast_bucket, dict):
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
pricing_score = 0.0
if yes_buy is not None:
if yes_buy < 0.10:
pricing_score = 14.0
elif yes_buy < 0.20:
pricing_score = 9.0
elif yes_buy < 0.35:
pricing_score = 5.0
confidence = str(snapshot.get("confidence") or "").strip().lower()
confidence_score = {"high": 10.0, "medium": 6.0, "low": 2.0}.get(confidence, 0.0)
suppression = alert_payload.get("suppression") or {}
suppressed_penalty = -20.0 if bool(suppression.get("suppressed")) else 0.0
peak_context = _local_peak_context(alert_payload)
return max(
0.0,
severity_score
+ trigger_score
+ edge_score
+ pricing_score
+ confidence_score
+ suppressed_penalty
+ float(peak_context.get("score_adjustment") or 0.0),
)
def _priority_label(score: float) -> str:
if score >= 72:
return "高优先级"
if score >= 48:
return "重点观察"
return "继续观察"
def _join_trigger_types_cn_local(rules: Dict[str, Dict[str, Any]]) -> str:
label_map = {
"ankara_center_deb_hit": "中心站触及 DEB",
"momentum_spike": "短时动量异动",
"forecast_breakthrough": "实测击穿模型",
"advection": "暖平流信号",
}
parts: List[str] = []
for key, label in label_map.items():
row = rules.get(key) or {}
if row.get("triggered"):
parts.append(label)
return " + ".join(parts)
def _focus_trigger_summary(alert_payload: Dict[str, Any]) -> str:
rules = alert_payload.get("rules") or {}
if not isinstance(rules, dict):
return "市场与天气分歧待观察"
return _join_trigger_types_cn_local(rules) or "市场与天气分歧待观察"
def _market_signal_brief(snapshot: Dict[str, Any]) -> str:
if not isinstance(snapshot, dict):
return ""
signal = str(snapshot.get("signal_label") or "").strip().upper()
signal_label = {
"BUY YES": "方向 BUY YES",
"BUY NO": "方向 BUY NO",
"MONITOR": "方向 MONITOR",
}.get(signal, f"方向 {signal}" if signal else "")
edge = _safe_float(snapshot.get("edge_percent"))
confidence = str(snapshot.get("confidence") or "").strip()
forecast_bucket = snapshot.get("forecast_bucket") or {}
if not isinstance(forecast_bucket, dict):
forecast_bucket = {}
yes_buy = _fmt_cents(forecast_bucket.get("yes_buy"))
yes_sell = _fmt_cents(forecast_bucket.get("yes_sell"))
parts: List[str] = []
if signal_label:
parts.append(signal_label)
if edge is not None:
parts.append(f"edge {edge:+.1f}%")
if confidence:
parts.append(f"置信 {confidence}")
if yes_buy:
parts.append(f"Yes买 {yes_buy}")
if yes_sell:
parts.append(f"Yes卖 {yes_sell}")
return " | ".join(parts)
def _shortlist_focus_payloads(
payloads: List[Dict[str, Any]],
*,
top_n: int,
for_push: bool = False,
) -> List[Dict[str, Any]]:
ranked = sorted(
payloads,
key=lambda item: _market_monitor_score(item),
reverse=True,
)
shortlisted: List[Dict[str, Any]] = []
for item in ranked:
if not bool((item.get("market_snapshot") or {}).get("available")):
continue
if not _market_price_cap_ok(item, require_actionable_quote=True):
continue
if for_push:
window_config = _focus_push_window_config()
peak_context = _local_peak_context(item)
local_min = _minute_of_day(peak_context.get("local_time"))
minutes_to_peak = peak_context.get("minutes_to_peak")
if local_min is not None and local_min < window_config["start_local_hour"] * 60:
continue
if isinstance(minutes_to_peak, (int, float)):
if minutes_to_peak > window_config["before_peak_min"]:
continue
if minutes_to_peak < -window_config["after_peak_min"]:
continue
shortlisted.append(item)
if len(shortlisted) >= top_n:
break
return shortlisted
def _build_focus_digest_message(
payloads: List[Dict[str, Any]],
*,
slot_label: str,
top_n: int,
) -> str:
scan_interval = _format_interval_brief(_env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
digest_interval = _format_interval_brief(
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 1800),
)
shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=True)
if not shortlisted:
return ""
lines = [
f"🌐 PolyWeather 市场监控 · {slot_label}",
"",
]
for idx, payload in enumerate(shortlisted, start=1):
city = str(payload.get("city") or "").strip().lower()
city_name = (CITY_REGISTRY.get(city) or {}).get("display_name") or city.title() or "--"
snapshot = payload.get("market_snapshot") or {}
evidence = payload.get("evidence") or {}
inputs = evidence.get("inputs") or {}
bucket = str(
(snapshot.get("forecast_bucket") or {}).get("label")
or snapshot.get("top_bucket")
or "--"
).strip()
current_temp = _safe_float(inputs.get("current_temp"))
deb_prediction = _safe_float(inputs.get("deb_prediction"))
market_url = str(snapshot.get("market_url") or snapshot.get("primary_market_url") or "").strip()
peak_context = _local_peak_context(payload)
score = _market_monitor_score(payload)
lines.append(f"{idx}. {city_name} | {_priority_label(score)}")
lines.append(" " + f"关注桶 {bucket}")
signal_brief = _market_signal_brief(snapshot)
if signal_brief:
lines.append(" " + signal_brief)
local_time = str(peak_context.get("local_time") or "").strip()
peak_time = str(peak_context.get("peak_time") or "").strip()
window_label = str(peak_context.get("window_label") or "").strip()
if local_time or peak_time or window_label:
context_parts: List[str] = []
if local_time:
context_parts.append(f"当地 {local_time}")
if peak_time:
context_parts.append(f"日内高点参考 {peak_time}")
if window_label:
context_parts.append(window_label)
lines.append(" " + " | ".join(context_parts))
if current_temp is not None or deb_prediction is not None:
lines.append(
" "
+ (f"实测 {current_temp:.1f}°C" if current_temp is not None else "实测 --")
+ " | "
+ (
f"DEB 预报 {deb_prediction:.1f}°C"
if deb_prediction is not None
else "DEB 预报 --"
)
)
lines.append(f" 触发:{_focus_trigger_summary(payload)}")
if market_url:
lines.append(f" 链接:{market_url}")
lines.append("")
frequency_parts = [
f"后台扫描:约每{scan_interval}一次",
f"主动推送:约每{digest_interval}一次",
f"观察窗口:{_focus_push_window_label()}",
"没有每日信号次数上限",
]
lines.append("更新频率:" + "".join(frequency_parts))
return "\n".join(lines).strip()
def _maybe_send_focus_digest(
bot: Any,
chat_ids: List[str],
payloads: List[Dict[str, Any]],
state: Dict[str, Any],
*,
digest_interval_sec: int,
top_n: int,
) -> bool:
if not chat_ids or not payloads or digest_interval_sec <= 0:
logger.info(
"market focus digest skipped reason=invalid_runtime chat_targets={} payloads={} interval_sec={}",
len(chat_ids),
len(payloads),
digest_interval_sec,
)
return False
now_ts = int(time.time())
last_digest_ts = int(state.get("last_focus_digest_ts") or 0)
available_count = sum(
1 for item in payloads
if bool((item.get("market_snapshot") or {}).get("available"))
)
actionable_count = sum(
1 for item in payloads
if bool((item.get("market_snapshot") or {}).get("available"))
and _market_price_cap_ok(item, require_actionable_quote=True)
)
shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=True)
reference_shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=False)
high_priority_count = sum(1 for item in shortlisted if _market_monitor_score(item) >= 72)
logger.info(
"market focus digest evaluate payloads={} available={} actionable={} shortlisted={} reference_shortlisted={} high_priority={} interval_sec={} top_n={} push_window={}",
len(payloads),
available_count,
actionable_count,
len(shortlisted),
len(reference_shortlisted),
high_priority_count,
digest_interval_sec,
top_n,
_focus_push_window_label(),
)
if last_digest_ts and now_ts - last_digest_ts < digest_interval_sec:
logger.info(
"market focus digest skipped reason=cooldown elapsed_sec={} required_sec={} shortlisted={} high_priority={}",
now_ts - last_digest_ts,
digest_interval_sec,
len(shortlisted),
high_priority_count,
)
return False
if not shortlisted:
logger.info(
"market focus digest skipped reason=no_candidates payloads={} available={} actionable={} top_n={}",
len(payloads),
available_count,
actionable_count,
top_n,
)
return False
# Tighten channel pushes: require either multiple candidates or one truly high-priority market.
if len(shortlisted) < 2 and high_priority_count < 1:
first_city = str(shortlisted[0].get("city") or "--") if shortlisted else "--"
first_score = _market_monitor_score(shortlisted[0]) if shortlisted else 0
logger.info(
"market focus digest skipped reason=too_few_candidates shortlisted={} high_priority={} first_city={} first_score={}",
len(shortlisted),
high_priority_count,
first_city,
first_score,
)
return False
local_now = datetime.now().astimezone()
hour = local_now.hour
if 6 <= hour < 15:
slot_label = "白天关注"
elif 15 <= hour < 23:
slot_label = "今晚关注"
else:
slot_label = "夜间关注"
message = _build_focus_digest_message(
shortlisted,
slot_label=slot_label,
top_n=top_n,
)
if not message:
logger.info(
"market focus digest skipped reason=empty_message shortlisted={} slot_label={}",
len(shortlisted),
slot_label,
)
return False
_cache_market_monitor_digest(
state,
message=message,
slot_label=slot_label,
)
sent_count = 0
for chat_id in chat_ids:
try:
bot.send_message(chat_id, message, disable_web_page_preview=True)
sent_count += 1
except Exception as exc:
logger.warning(
"market focus digest push failed interval_sec={} chat_id={} error={}",
digest_interval_sec,
chat_id,
exc,
)
if sent_count <= 0:
return False
state["last_focus_digest_ts"] = now_ts
logger.info(
"market focus digest pushed interval_sec={} shortlisted={} payloads={} chat_targets={} slot_label={}",
digest_interval_sec,
len(shortlisted),
len(payloads),
sent_count,
slot_label,
)
return True
def build_market_monitor_digest(
config: Dict[str, Any],
*,
slot_label: str = "当前概览",
top_n: Optional[int] = None,
force_refresh: bool = False,
) -> str:
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
digest_top_n = top_n if top_n is not None else max(
3,
min(8, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N", 5)),
)
payloads: List[Dict[str, Any]] = []
for city in cities:
try:
payloads.append(build_trade_alert_for_city(city, config, force_refresh=force_refresh))
except Exception as exc:
logger.warning("market monitor digest build skipped city={} error={}", city, exc)
message = _build_focus_digest_message(
payloads,
slot_label=slot_label,
top_n=digest_top_n,
)
if message:
state = _load_state(_state_file())
_cache_market_monitor_digest(
state,
message=message,
slot_label=slot_label,
)
_save_state(_state_file(), state)
return message
return "ℹ️ 当前没有可用的市场监控摘要。"
def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_count: int) -> bool:
triggered_alerts = alert_payload.get("triggered_alerts") or []
if any(alert.get("force_push") for alert in triggered_alerts):
return True
trigger_count = int(alert_payload.get("trigger_count") or 0)
if trigger_count < min_trigger_count:
return False
severity = str(alert_payload.get("severity") or "none").lower()
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
def _market_price_cap_ok(
alert_payload: Dict[str, Any],
require_actionable_quote: bool = False,
) -> bool:
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
primary_market = market.get("primary_market") or {}
if not isinstance(primary_market, dict):
primary_market = {}
market_slug = (
str(market.get("selected_slug") or "").strip()
or str(primary_market.get("slug") or "").strip()
or "--"
)
active = market.get("market_active")
if active is None:
active = primary_market.get("active")
active = _optional_bool(active)
closed = market.get("market_closed")
if closed is None:
closed = primary_market.get("closed")
closed = _optional_bool(closed)
accepting_orders = market.get("market_accepting_orders")
if accepting_orders is None:
accepting_orders = primary_market.get(
"accepting_orders",
primary_market.get("acceptingOrders"),
)
accepting_orders = _optional_bool(accepting_orders)
market_tradable = _optional_bool(market.get("market_tradable"))
tradable_reason = str(
market.get("market_tradable_reason")
or primary_market.get("tradable_reason")
or ""
).strip()
ended_at = str(
market.get("market_ended_at_utc")
or primary_market.get("ended_at_utc")
or ""
).strip()
ended_dt = _parse_iso_datetime_utc(ended_at)
is_past_end = ended_dt is not None and ended_dt <= datetime.now(timezone.utc)
if (
market_tradable is False
or closed is True
or active is False
or accepting_orders is False
or is_past_end
):
reason = tradable_reason or ("past_end_time" if is_past_end else "market_not_tradable")
logger.info(
"trade alert skipped: market not tradable city={} slug={} reason={} active={} closed={} accepting_orders={} ended_at={}".format(
alert_payload.get("city"),
market_slug,
reason,
active,
closed,
accepting_orders,
ended_at or "--",
)
)
return False
# Strict rule: use the bucket mapped from multi-model anchor settlement.
forecast_bucket = market.get("forecast_bucket") or {}
settle_ref = market.get("anchor_settlement")
if settle_ref is None:
settle_ref = market.get("open_meteo_settlement")
anchor_model = str(market.get("anchor_model") or "").strip() or "--"
yes_buy = None
bucket_label = None
if isinstance(forecast_bucket, dict):
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
bucket_label = str(forecast_bucket.get("label") or "").strip() or None
observed_floor = _observed_settlement_floor(alert_payload)
bucket_bounds = _bucket_bounds(forecast_bucket) if isinstance(forecast_bucket, dict) else None
if observed_floor is not None and bucket_bounds is not None:
_lower, upper = bucket_bounds
if upper is not None and observed_floor > upper + 1e-9:
logger.info(
"trade alert skipped: mapped bucket invalidated by observed high city={} bucket={} observed_floor={} upper_bound={} anchor_model={} anchor_settle={}".format(
alert_payload.get("city"),
bucket_label or "--",
round(observed_floor, 2),
round(upper, 2),
anchor_model,
settle_ref,
)
)
return False
if yes_buy is None or yes_buy <= 0.0:
logger.info(
"trade alert skipped: no actionable mapped bucket quote city={} bucket={} anchor_model={} anchor_settle={}".format(
alert_payload.get("city"),
bucket_label or "--",
anchor_model,
settle_ref,
)
)
return False
return True
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
trigger_types = sorted(
str(alert.get("type") or "").strip()
for alert in (alert_payload.get("triggered_alerts") or [])
if alert.get("type")
)
market = alert_payload.get("market_snapshot") or {}
if isinstance(market, dict) and market.get("available"):
signal = str(market.get("signal_label") or "").strip()
bucket = str(market.get("selected_bucket") or "").strip()
if signal:
trigger_types.append(f"mkt:{signal}:{bucket}")
return "|".join(trigger_types)
def _evidence_brief(alert_payload: Dict[str, Any]) -> str:
evidence = alert_payload.get("evidence") or {}
if not isinstance(evidence, dict):
return "--"
trigger_summary = evidence.get("trigger_summary") or {}
rules = evidence.get("rules") or {}
market = evidence.get("market") or {}
momentum = rules.get("momentum_spike") or {}
advection = rules.get("advection") or {}
breakthrough = rules.get("forecast_breakthrough") or {}
parts: List[str] = []
trigger_types = trigger_summary.get("trigger_types")
if isinstance(trigger_types, list) and trigger_types:
parts.append(f"triggers={','.join(str(t) for t in trigger_types)}")
slope = momentum.get("slope_30m")
if slope is not None:
parts.append(f"slope_30m={slope}")
lead_delta = advection.get("lead_delta")
if lead_delta is not None:
parts.append(f"lead_delta={lead_delta}")
margin = breakthrough.get("margin")
if margin is not None:
parts.append(f"break_margin={margin}")
edge = market.get("edge_percent")
if edge is not None:
parts.append(f"edge_pct={edge}")
forecast_bucket = market.get("forecast_bucket") or {}
if isinstance(forecast_bucket, dict):
label = str(forecast_bucket.get("label") or "").strip()
yes_buy = forecast_bucket.get("yes_buy")
if label:
parts.append(f"bucket={label}")
if yes_buy is not None:
parts.append(f"yes_buy={yes_buy}")
if not parts:
return "--"
return "; ".join(parts)
def _alert_signature(alert_payload: Dict[str, Any]) -> str:
rules = alert_payload.get("rules") or {}
center_deb = rules.get("ankara_center_deb_hit") or {}
momentum = rules.get("momentum_spike") or {}
breakthrough = rules.get("forecast_breakthrough") or {}
advection = rules.get("advection") or {}
suppression = alert_payload.get("suppression") or {}
market = alert_payload.get("market_snapshot") or {}
signature_payload = {
"city": alert_payload.get("city"),
"target_date": alert_payload.get("target_date"),
"severity": alert_payload.get("severity"),
"trigger_types": sorted(
alert.get("type")
for alert in (alert_payload.get("triggered_alerts") or [])
if alert.get("type")
),
"center_temp": round(float(((center_deb.get("center_station") or {}).get("temp")) or 0.0), 1),
"center_deb_prediction": round(float(center_deb.get("deb_prediction") or 0.0), 1),
"center_airport_gap": round(float(center_deb.get("center_lead_vs_airport") or 0.0), 1),
"momentum_direction": momentum.get("direction"),
"momentum_slope_30m": round(float(momentum.get("slope_30m") or 0.0), 1),
"breakthrough_margin": round(float(breakthrough.get("margin") or 0.0), 1),
"lead_station": (advection.get("lead_station") or {}).get("name"),
"lead_delta": round(float(advection.get("lead_delta") or 0.0), 1),
"suppressed": bool(suppression.get("suppressed")),
"suppression_reason": suppression.get("reason"),
"suppression_peak_time": suppression.get("max_temp_time"),
"suppression_rollback": round(float(suppression.get("rollback") or 0.0), 1),
"market_available": bool(market.get("available")),
"market_bucket": market.get("selected_bucket"),
"market_top_bucket": market.get("top_bucket"),
"market_top_bucket_prob": round(float(market.get("top_bucket_prob") or 0.0), 3),
"market_prob": round(float(market.get("market_prob") or 0.0), 3),
"model_prob": round(float(market.get("model_prob") or 0.0), 3),
"market_yes_buy": round(float(market.get("yes_buy") or 0.0), 3),
"market_yes_sell": round(float(market.get("yes_sell") or 0.0), 3),
"market_spread": round(float(market.get("spread") or 0.0), 3),
"market_edge_percent": round(float(market.get("edge_percent") or 0.0), 2),
"market_signal": market.get("signal_label"),
"market_confidence": market.get("confidence"),
}
raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
def build_trade_alert_for_city(
city: str,
config: Dict[str, Any],
force_refresh: bool = False,
target_date: Optional[str] = None,
) -> Dict[str, Any]:
from web.app import _analyze, _build_city_detail_payload
from src.analysis.market_alert_engine import build_trading_alerts
city_weather = _analyze(city, force_refresh=force_refresh)
try:
aggregate_detail = _build_city_detail_payload(
city_weather,
target_date=target_date,
)
market_scan = aggregate_detail.get("market_scan")
if isinstance(market_scan, dict):
city_weather = {**city_weather, "market_scan": market_scan}
except Exception as exc:
logger.debug(f"market scan attach skipped city={city}: {exc}")
resolved_target_date = target_date or city_weather.get("local_date")
if resolved_target_date:
datetime.strptime(resolved_target_date, "%Y-%m-%d")
map_url = os.getenv("POLYWEATHER_MAP_URL") or "https://polyweather-pro.vercel.app/"
alert_payload = build_trading_alerts(
city_weather=city_weather,
map_url=map_url,
)
alert_payload["target_date"] = resolved_target_date
return alert_payload
def _maybe_send_alert(
bot: Any,
chat_ids: List[str],
city: str,
alert_payload: Dict[str, Any],
state: Dict[str, Any],
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", {})
last_city = last_by_city.get(city) or {}
is_active = _severity_ok(alert_payload, min_severity, min_trigger_count)
if mispricing_only and not _is_market_mispricing_signal(alert_payload):
is_active = False
if not _market_price_cap_ok(
alert_payload,
require_actionable_quote=mispricing_only,
):
is_active = False
message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip()
if not is_active or not message:
if last_city.get("active"):
last_by_city[city] = {
**last_city,
"active": False,
"cleared_ts": now_ts,
}
logger.info(f"market monitor disarmed city={city}")
return True
return False
if not chat_ids:
return False
signature = _alert_signature(alert_payload)
trigger_key = _trigger_type_key(alert_payload)
last_city_sig = last_city.get("signature")
last_city_key = str(last_city.get("trigger_key") or "")
last_city_ts = int(last_city.get("ts") or 0)
last_sig_ts = int((state.get("by_signature") or {}).get(signature) or 0)
last_city_active = bool(last_city.get("active"))
if last_city_active and last_city_key == trigger_key and last_city_sig == signature:
return False
if last_city_ts and now_ts - last_city_ts < cooldown_sec:
return False
if last_sig_ts and now_ts - last_sig_ts < cooldown_sec:
return False
sent_count = 0
for chat_id in chat_ids:
try:
bot.send_message(chat_id, message)
sent_count += 1
except Exception as exc:
logger.warning("market monitor push failed city={} chat_id={} error={}", city, chat_id, exc)
if sent_count <= 0:
return False
last_by_city[city] = {
"signature": signature,
"trigger_key": trigger_key,
"severity": alert_payload.get("severity"),
"ts": now_ts,
"active": True,
"evidence": alert_payload.get("evidence"),
}
state.setdefault("by_signature", {})[signature] = now_ts
logger.info(
f"market monitor pushed city={city} severity={alert_payload.get('severity')} "
f"trigger_count={alert_payload.get('trigger_count')} trigger_key={trigger_key} "
f"evidence={_evidence_brief(alert_payload)} chat_targets={sent_count}"
)
return True
def _is_market_mispricing_signal(alert_payload: Dict[str, Any]) -> bool:
market = alert_payload.get("market_snapshot") or {}
if not isinstance(market, dict) or not market.get("available"):
return False
evidence = alert_payload.get("evidence") or {}
evidence_market = evidence.get("market") if isinstance(evidence, dict) else {}
low_yes_signal = (
evidence_market.get("low_yes_signal")
if isinstance(evidence_market, dict)
else None
)
if isinstance(low_yes_signal, dict) and bool(low_yes_signal.get("should_push")):
return True
signal = str(market.get("signal_label") or "").strip().upper()
if signal in {"BUY YES", "BUY NO"}:
return True
return False
def _run_market_monitor_cycle(
bot: Any,
config: Dict[str, Any],
*,
chat_ids: List[str],
cities: List[str],
state: Dict[str, Any],
focus_digest_enabled: bool,
focus_digest_interval_sec: int,
focus_digest_top_n: int,
alert_cooldown_sec: int,
mispricing_interval_sec: int,
min_severity: str,
min_trigger_count: int,
mispricing_only: bool,
sleep_between_cities_sec: float = 1.0,
) -> bool:
state_dirty = False
cycle_payloads: List[Dict[str, Any]] = []
critical_pushed_cities: set[str] = set()
for city in cities:
try:
alert_payload = build_trade_alert_for_city(city, config)
cycle_payloads.append(alert_payload)
is_mispricing = _is_market_mispricing_signal(alert_payload)
cooldown_sec = mispricing_interval_sec if is_mispricing else alert_cooldown_sec
if _maybe_send_alert(
bot=bot,
chat_ids=chat_ids,
city=city,
alert_payload=alert_payload,
state=state,
cooldown_sec=cooldown_sec,
min_severity=min_severity,
min_trigger_count=min_trigger_count,
mispricing_only=mispricing_only,
):
state_dirty = True
critical_pushed_cities.add(str(city).strip().lower())
except Exception:
logger.exception(f"telegram market monitor loop failed for city={city}")
if sleep_between_cities_sec > 0:
time.sleep(sleep_between_cities_sec)
if focus_digest_enabled:
try:
digest_payloads = [
payload
for payload in cycle_payloads
if str(payload.get("city") or "").strip().lower()
not in critical_pushed_cities
]
if _maybe_send_focus_digest(
bot=bot,
chat_ids=chat_ids,
payloads=digest_payloads,
state=state,
digest_interval_sec=focus_digest_interval_sec,
top_n=focus_digest_top_n,
):
state_dirty = True
except Exception:
logger.exception("failed to push market focus digest")
return state_dirty
def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[threading.Thread]:
enabled = _env_bool("TELEGRAM_ALERT_PUSH_ENABLED", True)
chat_ids = get_telegram_chat_ids_from_env()
if not enabled:
logger.info("telegram market monitor loop disabled")
return None
if not chat_ids:
logger.warning("telegram market monitor loop skipped: TELEGRAM_CHAT_IDS is not set")
return None
interval_sec = max(60, _env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
state_path = _state_file()
alert_cooldown_sec = max(60, _env_int("TELEGRAM_ALERT_PUSH_COOLDOWN_SEC", 1800))
mispricing_interval_sec = max(
alert_cooldown_sec,
_env_int("TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC", 7200),
)
min_trigger_count = max(1, _env_int("TELEGRAM_ALERT_MIN_TRIGGER_COUNT", 2))
min_severity = str(os.getenv("TELEGRAM_ALERT_MIN_SEVERITY") or "medium").strip().lower()
if min_severity not in SEVERITY_RANK:
min_severity = "medium"
mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
focus_digest_enabled = _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True)
focus_digest_top_n = max(3, min(8, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N", 5)))
focus_digest_interval_sec = max(
300,
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 1800),
)
def _runner() -> None:
try:
_save_state(state_path, _load_state(state_path))
except Exception:
logger.exception(f"failed to initialize market monitor state path={state_path}")
logger.info(
f"telegram market monitor loop started mode=critical-alerts-and-focus-digest "
f"cities={len(cities)} interval={interval_sec}s chat_targets={len(chat_ids)} "
f"alert_cooldown={alert_cooldown_sec}s mispricing_interval={mispricing_interval_sec}s "
f"min_severity={min_severity} min_trigger_count={min_trigger_count} "
f"mispricing_only={mispricing_only} "
f"focus_digest_enabled={focus_digest_enabled} focus_interval={focus_digest_interval_sec}s "
f"state_path={state_path}"
)
while True:
cycle_started = time.time()
state = _load_state(state_path)
_cleanup_state(state, int(cycle_started))
if _run_market_monitor_cycle(
bot=bot,
config=config,
chat_ids=chat_ids,
cities=cities,
state=state,
focus_digest_enabled=focus_digest_enabled,
focus_digest_interval_sec=focus_digest_interval_sec,
focus_digest_top_n=focus_digest_top_n,
alert_cooldown_sec=alert_cooldown_sec,
mispricing_interval_sec=mispricing_interval_sec,
min_severity=min_severity,
min_trigger_count=min_trigger_count,
mispricing_only=mispricing_only,
):
_save_state(state_path, state)
elapsed = time.time() - cycle_started
sleep_sec = max(5, interval_sec - int(elapsed))
time.sleep(sleep_sec)
thread = threading.Thread(
target=_runner,
name="telegram-market-monitor-pusher",
daemon=True,
)
thread.start()
return thread