Document market monitor bot settings and retire wallet activity
This commit is contained in:
+8
-1
@@ -76,7 +76,14 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
|||||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||||
|
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||||
|
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||||
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_HOURS=11,18
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES=180
|
||||||
|
TELEGRAM_MARKET_TIMEZONE=Asia/Shanghai
|
||||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||||
|
|
||||||
@@ -158,7 +165,7 @@ POLYGON_WALLET_WATCH_POLYMARKET_ONLY=true
|
|||||||
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
POLYGON_WALLET_WATCH_INCLUDE_DEFAULT_PM_CONTRACTS=true
|
||||||
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
|
||||||
|
|
||||||
# Polymarket wallet activity
|
# Polymarket wallet activity (retired; replaced by market monitor digests + critical alerts)
|
||||||
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||||
POLYMARKET_WALLET_ACTIVITY_USERS=
|
POLYMARKET_WALLET_ACTIVITY_USERS=
|
||||||
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
|||||||
- `POLYWEATHER_PAYMENT_ENABLED`
|
- `POLYWEATHER_PAYMENT_ENABLED`
|
||||||
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
- `POLYMARKET_MARKET_SCAN_ENABLED`
|
||||||
- `POLYGON_WALLET_WATCH_ENABLED`
|
- `POLYGON_WALLET_WATCH_ENABLED`
|
||||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`
|
- `TELEGRAM_ALERT_PUSH_ENABLED`
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED`
|
||||||
|
- `POLYMARKET_WALLET_ACTIVITY_ENABLED`(已退役,建议保持 `false`)
|
||||||
|
|
||||||
### 4.3 L3:运行调优项
|
### 4.3 L3:运行调优项
|
||||||
|
|
||||||
@@ -119,6 +121,15 @@ PolyWeather 的环境变量很多,但不是所有变量都属于同一层级
|
|||||||
- 各类 `*_TIMEOUT_SEC`
|
- 各类 `*_TIMEOUT_SEC`
|
||||||
- 各类 `*_COOLDOWN_SEC`
|
- 各类 `*_COOLDOWN_SEC`
|
||||||
- 各类 `*_INTERVAL_SEC`
|
- 各类 `*_INTERVAL_SEC`
|
||||||
|
- `TELEGRAM_ALERT_MIN_TRIGGER_COUNT`
|
||||||
|
- `TELEGRAM_ALERT_MIN_SEVERITY`
|
||||||
|
- `TELEGRAM_ALERT_MISPRICING_ONLY`
|
||||||
|
- `TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC`
|
||||||
|
- `TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY`
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_HOURS`
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N`
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES`
|
||||||
|
- `TELEGRAM_MARKET_TIMEZONE`
|
||||||
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
- `POLYWEATHER_PAYMENT_RPC_URLS`
|
||||||
- `TAF_CACHE_TTL_SEC`
|
- `TAF_CACHE_TTL_SEC`
|
||||||
|
|
||||||
@@ -204,7 +215,7 @@ TELEGRAM_BOT_TOKEN=...
|
|||||||
TELEGRAM_CHAT_ID=...
|
TELEGRAM_CHAT_ID=...
|
||||||
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather
|
||||||
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
POLYWEATHER_DB_PATH=/var/lib/polyweather/polyweather.db
|
||||||
POLYWEATHER_STATE_STORAGE_MODE=dual
|
POLYWEATHER_STATE_STORAGE_MODE=sqlite
|
||||||
UID=1000
|
UID=1000
|
||||||
GID=1000
|
GID=1000
|
||||||
POLYWEATHER_AUTH_ENABLED=true
|
POLYWEATHER_AUTH_ENABLED=true
|
||||||
@@ -215,6 +226,20 @@ SUPABASE_URL=https://your-project.supabase.co
|
|||||||
SUPABASE_ANON_KEY=...
|
SUPABASE_ANON_KEY=...
|
||||||
SUPABASE_SERVICE_ROLE_KEY=...
|
SUPABASE_SERVICE_ROLE_KEY=...
|
||||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
|
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
|
||||||
|
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||||
|
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||||
|
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||||
|
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||||
|
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||||
|
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||||
|
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||||
|
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_HOURS=11,18
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES=180
|
||||||
|
TELEGRAM_MARKET_TIMEZONE=Asia/Shanghai
|
||||||
|
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -223,8 +248,45 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=...
|
|||||||
- Windows / macOS 一般可以直接保留默认值。
|
- Windows / macOS 一般可以直接保留默认值。
|
||||||
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
- `POLYWEATHER_RUNTIME_DATA_DIR` 建议放在仓库外,例如 `/var/lib/polyweather`。
|
||||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前推荐先用 `dual`,验证后再切 `sqlite`。
|
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||||
|
- 机器人市场监控当前分成两类消息:`关键提醒` 与 `关注清单`。
|
||||||
|
- `TELEGRAM_MARKET_TIMEZONE` 建议保持 `Asia/Shanghai`,更适合亚洲时区盯盘节奏。
|
||||||
|
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||||
|
|
||||||
|
### 6.3 机器人市场监控建议配置
|
||||||
|
|
||||||
|
这套配置用于替代旧的钱包异动监听,围绕市场本身做两类推送:
|
||||||
|
|
||||||
|
- `关键提醒`:实时错价/触发条件满足时发送
|
||||||
|
- `关注清单`:按亚洲时区定时推送当日重点市场摘要
|
||||||
|
|
||||||
|
推荐值:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_ALERT_PUSH_ENABLED=true
|
||||||
|
TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||||
|
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||||
|
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||||
|
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||||
|
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||||
|
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||||
|
TELEGRAM_ALERT_MISPRICING_MAX_YES_BUY=0.10
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_HOURS=11,18
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||||
|
TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES=180
|
||||||
|
TELEGRAM_MARKET_TIMEZONE=Asia/Shanghai
|
||||||
|
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_HOURS=11,18` 对应亚洲白天与傍晚两个摘要窗口,适合提前筛出今晚值得关注的市场。
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||||
|
- `TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES=180` 用于容忍进程重启/部署后的补发窗口。
|
||||||
|
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||||
|
|
||||||
## 7. 当前建议的运维规则
|
## 7. 当前建议的运维规则
|
||||||
|
|
||||||
|
|||||||
@@ -1135,9 +1135,9 @@ def _build_telegram_messages_mispricing(
|
|||||||
center_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
|
center_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
|
||||||
|
|
||||||
if snapshot.get("available") and signal_state.get("should_push"):
|
if snapshot.get("available") and signal_state.get("should_push"):
|
||||||
title_zh = "🚨 PolyWeather 错价雷达"
|
title_zh = "🚨 PolyWeather 市场监控"
|
||||||
elif snapshot.get("available"):
|
elif snapshot.get("available"):
|
||||||
title_zh = "📍 PolyWeather 错价观察"
|
title_zh = "📍 PolyWeather 市场观察"
|
||||||
else:
|
else:
|
||||||
title_zh = "🚨 PolyWeather 异动预警" if (has_active_trigger or suppressed) else "📍 PolyWeather 状态快照"
|
title_zh = "🚨 PolyWeather 异动预警" if (has_active_trigger or suppressed) else "📍 PolyWeather 状态快照"
|
||||||
|
|
||||||
@@ -1175,9 +1175,9 @@ def _build_telegram_messages_mispricing(
|
|||||||
lines_zh.append(f"地图:{final_map}")
|
lines_zh.append(f"地图:{final_map}")
|
||||||
|
|
||||||
title_en = (
|
title_en = (
|
||||||
"🚨 PolyWeather Mispricing Radar"
|
"🚨 PolyWeather Market Monitor"
|
||||||
if snapshot.get("available") and signal_state.get("should_push")
|
if snapshot.get("available") and signal_state.get("should_push")
|
||||||
else "📍 PolyWeather Mispricing Watch"
|
else "📍 PolyWeather Market Watch"
|
||||||
if snapshot.get("available")
|
if snapshot.get("available")
|
||||||
else "🚨 PolyWeather Alert"
|
else "🚨 PolyWeather Alert"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ from datetime import datetime
|
|||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src.utils.telegram_chat_ids import (
|
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||||||
get_polymarket_wallet_activity_chat_ids_from_env,
|
|
||||||
get_telegram_chat_ids_from_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -168,11 +165,16 @@ class StartupCoordinator:
|
|||||||
"interval_sec": interval,
|
"interval_sec": interval,
|
||||||
"cities_count": cities_count,
|
"cities_count": cities_count,
|
||||||
"chat_targets": len(chat_ids),
|
"chat_targets": len(chat_ids),
|
||||||
|
"focus_digest_enabled": _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True),
|
||||||
|
"focus_digest_hours": str(
|
||||||
|
os.getenv("TELEGRAM_MARKET_FOCUS_DIGEST_HOURS") or "11,18"
|
||||||
|
).strip(),
|
||||||
|
"timezone": str(os.getenv("TELEGRAM_MARKET_TIMEZONE") or "Asia/Shanghai").strip(),
|
||||||
}
|
}
|
||||||
validation_error = None if chat_ids else "missing_TELEGRAM_CHAT_IDS"
|
validation_error = None if chat_ids else "missing_TELEGRAM_CHAT_IDS"
|
||||||
return self._start_with_validation(
|
return self._start_with_validation(
|
||||||
key="trade_alert_push",
|
key="trade_alert_push",
|
||||||
label="错价雷达推送",
|
label="市场监控推送",
|
||||||
configured_enabled=enabled,
|
configured_enabled=enabled,
|
||||||
details=details,
|
details=details,
|
||||||
validation_error=validation_error,
|
validation_error=validation_error,
|
||||||
@@ -213,32 +215,16 @@ class StartupCoordinator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _start_polymarket_wallet_activity_loop(self) -> LoopStatus:
|
def _start_polymarket_wallet_activity_loop(self) -> LoopStatus:
|
||||||
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
|
return LoopStatus(
|
||||||
chat_ids = get_polymarket_wallet_activity_chat_ids_from_env()
|
|
||||||
users_count = _parse_csv_count(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
|
|
||||||
poll = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20))
|
|
||||||
details = {
|
|
||||||
"poll_sec": poll,
|
|
||||||
"users_count": users_count,
|
|
||||||
"link_preview": _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True),
|
|
||||||
"chat_targets": len(chat_ids),
|
|
||||||
}
|
|
||||||
validation_error = None
|
|
||||||
if not chat_ids:
|
|
||||||
validation_error = (
|
|
||||||
"missing_POLYMARKET_WALLET_ACTIVITY_CHAT_IDS_or_TELEGRAM_CHAT_IDS"
|
|
||||||
)
|
|
||||||
elif users_count == 0:
|
|
||||||
validation_error = "missing_POLYMARKET_WALLET_ACTIVITY_USERS"
|
|
||||||
return self._start_with_validation(
|
|
||||||
key="polymarket_wallet_activity",
|
key="polymarket_wallet_activity",
|
||||||
label="Polymarket 钱包异动监听",
|
label="Polymarket 钱包异动监听(已停用)",
|
||||||
configured_enabled=enabled,
|
configured_enabled=False,
|
||||||
details=details,
|
started=False,
|
||||||
validation_error=validation_error,
|
reason="retired_replaced_by_market_monitor",
|
||||||
starter=lambda: import_module(
|
details={
|
||||||
"src.onchain.polymarket_wallet_activity_watcher"
|
"replacement": "trade_alert_push",
|
||||||
).start_polymarket_wallet_activity_loop(self.bot),
|
"note": "wallet activity watcher retired in favor of market monitor digests and critical alerts",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_weekly_reward_loop(self) -> LoopStatus:
|
def _start_weekly_reward_loop(self) -> LoopStatus:
|
||||||
|
|||||||
+296
-18
@@ -4,7 +4,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -67,6 +67,16 @@ def _norm_prob(v: Any) -> Optional[float]:
|
|||||||
return max(0.0, min(1.0, n))
|
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]:
|
def _safe_float(v: Any) -> Optional[float]:
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
@@ -230,7 +240,7 @@ def _save_state(path: str, state: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400) -> None:
|
def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400) -> None:
|
||||||
for bucket_name in ("by_signature",):
|
for bucket_name in ("by_signature", "focus_digest_slots"):
|
||||||
bucket = state.get(bucket_name, {})
|
bucket = state.get(bucket_name, {})
|
||||||
if not isinstance(bucket, dict):
|
if not isinstance(bucket, dict):
|
||||||
state[bucket_name] = {}
|
state[bucket_name] = {}
|
||||||
@@ -252,6 +262,249 @@ def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400
|
|||||||
last_by_city.pop(city, None)
|
last_by_city.pop(city, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hour_list(raw: Optional[str], default: List[int]) -> List[int]:
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
out: List[int] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for part in str(raw).replace(";", ",").split(","):
|
||||||
|
token = str(part).strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
hour = int(token)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not (0 <= hour <= 23) or hour in seen:
|
||||||
|
continue
|
||||||
|
seen.add(hour)
|
||||||
|
out.append(hour)
|
||||||
|
return sorted(out) or default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_market_timezone(timezone_name: str):
|
||||||
|
normalized = str(timezone_name or "").strip() or "Asia/Shanghai"
|
||||||
|
if normalized == "Asia/Shanghai":
|
||||||
|
return timezone(timedelta(hours=8), name="Asia/Shanghai")
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
return ZoneInfo(normalized)
|
||||||
|
except Exception:
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return max(0.0, severity_score + trigger_score + edge_score + pricing_score + confidence_score + suppressed_penalty)
|
||||||
|
|
||||||
|
|
||||||
|
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 _build_focus_digest_message(
|
||||||
|
payloads: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
slot_label: str,
|
||||||
|
top_n: int,
|
||||||
|
timezone_name: str,
|
||||||
|
) -> str:
|
||||||
|
ranked = sorted(
|
||||||
|
payloads,
|
||||||
|
key=lambda item: _market_monitor_score(item),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
shortlisted = [
|
||||||
|
item for item in ranked
|
||||||
|
if _market_monitor_score(item) > 0 and bool((item.get("market_snapshot") or {}).get("available"))
|
||||||
|
][:top_n]
|
||||||
|
if not shortlisted:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🌐 PolyWeather 市场监控 · {slot_label}",
|
||||||
|
f"时区:{timezone_name}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
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()
|
||||||
|
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"))
|
||||||
|
yes_buy_text = _fmt_cents(yes_buy) or "--"
|
||||||
|
edge_percent = _safe_float(snapshot.get("edge_percent"))
|
||||||
|
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()
|
||||||
|
|
||||||
|
score = _market_monitor_score(payload)
|
||||||
|
lines.append(f"{idx}. {city_name} | {_priority_label(score)}")
|
||||||
|
lines.append(
|
||||||
|
" "
|
||||||
|
+ f"桶 {bucket} | Yes {yes_buy_text} | 偏差 "
|
||||||
|
+ (f"{edge_percent:+.1f}%" if edge_percent is not None else "--")
|
||||||
|
)
|
||||||
|
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("")
|
||||||
|
|
||||||
|
lines.append("用途:先筛今晚值得盯的市场,真正进入关键窗口时仍会继续推送。")
|
||||||
|
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],
|
||||||
|
*,
|
||||||
|
timezone_name: str,
|
||||||
|
digest_hours: List[int],
|
||||||
|
top_n: int,
|
||||||
|
grace_minutes: int,
|
||||||
|
) -> bool:
|
||||||
|
if not chat_ids or not payloads or not digest_hours:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_now = datetime.now(_resolve_market_timezone(timezone_name))
|
||||||
|
except Exception:
|
||||||
|
local_now = datetime.now()
|
||||||
|
timezone_name = "local"
|
||||||
|
|
||||||
|
eligible_slot: Optional[datetime] = None
|
||||||
|
for hour in sorted(digest_hours, reverse=True):
|
||||||
|
slot_dt = local_now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||||
|
delta_minutes = int((local_now - slot_dt).total_seconds() // 60)
|
||||||
|
if 0 <= delta_minutes <= grace_minutes:
|
||||||
|
eligible_slot = slot_dt
|
||||||
|
break
|
||||||
|
|
||||||
|
if eligible_slot is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
slot_key = eligible_slot.strftime("%Y-%m-%d@%H")
|
||||||
|
digest_slots = state.setdefault("focus_digest_slots", {})
|
||||||
|
if digest_slots.get(slot_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
slot_label = "白天关注" if eligible_slot.hour < 15 else "今晚关注"
|
||||||
|
message = _build_focus_digest_message(
|
||||||
|
payloads,
|
||||||
|
slot_label=slot_label,
|
||||||
|
top_n=top_n,
|
||||||
|
timezone_name=timezone_name,
|
||||||
|
)
|
||||||
|
if not message:
|
||||||
|
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 focus digest push failed slot={} chat_id={} error={}",
|
||||||
|
slot_key,
|
||||||
|
chat_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if sent_count <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
digest_slots[slot_key] = int(time.time())
|
||||||
|
logger.info(
|
||||||
|
"market focus digest pushed slot={} timezone={} items={} chat_targets={}",
|
||||||
|
slot_key,
|
||||||
|
timezone_name,
|
||||||
|
min(top_n, len(payloads)),
|
||||||
|
sent_count,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_count: int) -> bool:
|
def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_count: int) -> bool:
|
||||||
triggered_alerts = alert_payload.get("triggered_alerts") or []
|
triggered_alerts = alert_payload.get("triggered_alerts") or []
|
||||||
if any(alert.get("force_push") for alert in triggered_alerts):
|
if any(alert.get("force_push") for alert in triggered_alerts):
|
||||||
@@ -572,7 +825,7 @@ def _maybe_send_alert(
|
|||||||
"active": False,
|
"active": False,
|
||||||
"cleared_ts": now_ts,
|
"cleared_ts": now_ts,
|
||||||
}
|
}
|
||||||
logger.info(f"trade alert disarmed city={city}")
|
logger.info(f"market monitor disarmed city={city}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -601,12 +854,7 @@ def _maybe_send_alert(
|
|||||||
bot.send_message(chat_id, message)
|
bot.send_message(chat_id, message)
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning("market monitor push failed city={} chat_id={} error={}", city, chat_id, exc)
|
||||||
"trade alert push failed city={} chat_id={} error={}",
|
|
||||||
city,
|
|
||||||
chat_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
if sent_count <= 0:
|
if sent_count <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -620,7 +868,7 @@ def _maybe_send_alert(
|
|||||||
}
|
}
|
||||||
state.setdefault("by_signature", {})[signature] = now_ts
|
state.setdefault("by_signature", {})[signature] = now_ts
|
||||||
logger.info(
|
logger.info(
|
||||||
f"trade alert pushed city={city} severity={alert_payload.get('severity')} "
|
f"market monitor pushed city={city} severity={alert_payload.get('severity')} "
|
||||||
f"trigger_count={alert_payload.get('trigger_count')} trigger_key={trigger_key} "
|
f"trigger_count={alert_payload.get('trigger_count')} trigger_key={trigger_key} "
|
||||||
f"evidence={_evidence_brief(alert_payload)} chat_targets={sent_count}"
|
f"evidence={_evidence_brief(alert_payload)} chat_targets={sent_count}"
|
||||||
)
|
)
|
||||||
@@ -631,10 +879,10 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
|
|||||||
enabled = _env_bool("TELEGRAM_ALERT_PUSH_ENABLED", True)
|
enabled = _env_bool("TELEGRAM_ALERT_PUSH_ENABLED", True)
|
||||||
chat_ids = get_telegram_chat_ids_from_env()
|
chat_ids = get_telegram_chat_ids_from_env()
|
||||||
if not enabled:
|
if not enabled:
|
||||||
logger.info("telegram alert push loop disabled")
|
logger.info("telegram market monitor loop disabled")
|
||||||
return None
|
return None
|
||||||
if not chat_ids:
|
if not chat_ids:
|
||||||
logger.warning("telegram alert push loop skipped: TELEGRAM_CHAT_IDS is not set")
|
logger.warning("telegram market monitor loop skipped: TELEGRAM_CHAT_IDS is not set")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
|
mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
|
||||||
@@ -649,26 +897,40 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
|
|||||||
min_severity = os.getenv("TELEGRAM_ALERT_MIN_SEVERITY", "medium").strip().lower()
|
min_severity = os.getenv("TELEGRAM_ALERT_MIN_SEVERITY", "medium").strip().lower()
|
||||||
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
|
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
|
||||||
state_path = _state_file()
|
state_path = _state_file()
|
||||||
|
focus_digest_enabled = _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True)
|
||||||
|
focus_digest_hours = _parse_hour_list(
|
||||||
|
os.getenv("TELEGRAM_MARKET_FOCUS_DIGEST_HOURS"),
|
||||||
|
[11, 18],
|
||||||
|
)
|
||||||
|
focus_digest_top_n = max(3, min(8, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N", 5)))
|
||||||
|
focus_digest_grace_minutes = max(
|
||||||
|
30,
|
||||||
|
min(240, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_GRACE_MINUTES", 180)),
|
||||||
|
)
|
||||||
|
market_timezone = str(os.getenv("TELEGRAM_MARKET_TIMEZONE") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||||
|
|
||||||
def _runner() -> None:
|
def _runner() -> None:
|
||||||
try:
|
try:
|
||||||
_save_state(state_path, _load_state(state_path))
|
_save_state(state_path, _load_state(state_path))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"failed to initialize telegram push state path={state_path}")
|
logger.exception(f"failed to initialize market monitor state path={state_path}")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"telegram alert push loop started mode={'mispricing-only' if mispricing_only else 'full'} "
|
f"telegram market monitor loop started mode={'mispricing-only' if mispricing_only else 'full'} "
|
||||||
f"cities={len(cities)} interval={interval_sec}s chat_targets={len(chat_ids)} "
|
f"cities={len(cities)} interval={interval_sec}s chat_targets={len(chat_ids)} "
|
||||||
f"cooldown={cooldown_sec}s min_triggers={min_trigger_count} min_severity={min_severity} "
|
f"cooldown={cooldown_sec}s min_triggers={min_trigger_count} min_severity={min_severity} "
|
||||||
f"state_path={state_path}"
|
f"focus_digest_enabled={focus_digest_enabled} focus_hours={focus_digest_hours} "
|
||||||
|
f"timezone={market_timezone} state_path={state_path}"
|
||||||
)
|
)
|
||||||
while True:
|
while True:
|
||||||
cycle_started = time.time()
|
cycle_started = time.time()
|
||||||
state = _load_state(state_path)
|
state = _load_state(state_path)
|
||||||
_cleanup_state(state, int(cycle_started))
|
_cleanup_state(state, int(cycle_started))
|
||||||
|
cycle_payloads: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for city in cities:
|
for city in cities:
|
||||||
try:
|
try:
|
||||||
alert_payload = build_trade_alert_for_city(city, config)
|
alert_payload = build_trade_alert_for_city(city, config)
|
||||||
|
cycle_payloads.append(alert_payload)
|
||||||
if _maybe_send_alert(
|
if _maybe_send_alert(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
chat_ids=chat_ids,
|
chat_ids=chat_ids,
|
||||||
@@ -683,18 +945,34 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
|
|||||||
try:
|
try:
|
||||||
_save_state(state_path, state)
|
_save_state(state_path, state)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"failed to save telegram push state city={city}")
|
logger.exception(f"failed to save market monitor state city={city}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"telegram alert push loop failed for city={city}")
|
logger.exception(f"telegram market monitor loop failed for city={city}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
if focus_digest_enabled:
|
||||||
|
try:
|
||||||
|
if _maybe_send_focus_digest(
|
||||||
|
bot=bot,
|
||||||
|
chat_ids=chat_ids,
|
||||||
|
payloads=cycle_payloads,
|
||||||
|
state=state,
|
||||||
|
timezone_name=market_timezone,
|
||||||
|
digest_hours=focus_digest_hours,
|
||||||
|
top_n=focus_digest_top_n,
|
||||||
|
grace_minutes=focus_digest_grace_minutes,
|
||||||
|
):
|
||||||
|
_save_state(state_path, state)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to push market focus digest")
|
||||||
|
|
||||||
elapsed = time.time() - cycle_started
|
elapsed = time.time() - cycle_started
|
||||||
sleep_sec = max(5, interval_sec - int(elapsed))
|
sleep_sec = max(5, interval_sec - int(elapsed))
|
||||||
time.sleep(sleep_sec)
|
time.sleep(sleep_sec)
|
||||||
|
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_runner,
|
target=_runner,
|
||||||
name="telegram-trade-alert-pusher",
|
name="telegram-market-monitor-pusher",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user