import asyncio import json import os from dataclasses import dataclass import httpx from redis.asyncio import Redis def calc_expected_value(win_prob: float, odds: float, stake: float = 1.0) -> float: if odds <= 1 or not (0 <= win_prob <= 1): return 0.0 loss = (1 - win_prob) * stake return win_prob * (odds * stake - stake) - loss def is_ev_plus(ev: float, threshold: float = 0.05) -> bool: return ev > threshold def is_sharp_money_anomaly(handle_share: float, threshold: float = 0.8) -> bool: return handle_share >= threshold @dataclass(frozen=True) class AlertRecord: match_id: str market: str odds: float ev: float handle_share: float class TelegramNotifier: def __init__(self, token: str, chat_id: str, *, timeout: float = 10.0) -> None: self.token = token self.chat_id = chat_id self.client = httpx.AsyncClient(timeout=timeout) async def send_alert(self, message: str) -> bool: if not self.token or not self.chat_id: return False url = f'https://api.telegram.org/bot{self.token}/sendMessage' payload = { 'chat_id': self.chat_id, 'text': message, 'parse_mode': 'Markdown', } response = await self.client.post(url, json=payload) return response.is_success async def close(self) -> None: await self.client.aclose() class AlertService: def __init__( self, redis_url: str, notifier: TelegramNotifier, *, poll_interval_seconds: int = 10, sample_interval_seconds: int = 900, ev_threshold: float = 0.05, sharp_threshold: float = 0.80, ) -> None: self.redis = Redis.from_url(redis_url, decode_responses=True) self.notifier = notifier self.poll_interval_seconds = poll_interval_seconds self.sample_interval_seconds = sample_interval_seconds self.ev_threshold = ev_threshold self.sharp_threshold = sharp_threshold async def _format_alert(self, alert: AlertRecord) -> str: return ( '🚨 *高價值投注警報*\n' f'賽事:{alert.match_id}\n' f'市場:{alert.market}\n' f'目前賠率:{alert.odds:.2f}\n' f'EV:{alert.ev * 100:.2f}%\n' f'聰明錢資金占比:{alert.handle_share * 100:.1f}%\n' '建議:檢視盤口失衡與新聞情緒是否同步變動。' ) async def _should_send(self, match_id: str, market: str) -> bool: key = f'alert:sent:{match_id}:{market}' result = await self.redis.set(name=key, value='1', ex=self.sample_interval_seconds, nx=True) return bool(result) async def _scan_candidates(self) -> list[tuple[str, dict[str, object]]]: # 標準 key 形狀:odds:latest:{match_id}:{market} keys = await self.redis.keys('odds:latest:*') out: list[tuple[str, dict[str, object]]] = [] for key in keys: raw = await self.redis.get(key) if not raw: continue try: payload = json.loads(raw) except ValueError: continue out.append((key, payload)) return out async def evaluate_loop(self) -> None: def to_float(value: object, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def normalize_share(value: float) -> float: # 支援 0~1 或 0~100 兩種輸入格式 if value > 1: return value / 100 return value try: while True: candidates = await self._scan_candidates() for _, payload in candidates: match_id = payload.get('matchId') or payload.get('match_id') market = payload.get('market', 'unknown') if not match_id: continue odds = to_float(payload.get('odds', 0.0)) win_prob = to_float(payload.get('winProbability', payload.get('probability', 0.0))) handle_share = normalize_share( to_float(payload.get('handleShare', payload.get('handle_ratio', 0.0))), ) ev = calc_expected_value(win_prob, odds, stake=1.0) if not (is_ev_plus(ev, self.ev_threshold) or is_sharp_money_anomaly(handle_share, self.sharp_threshold)): continue should_send = await self._should_send(match_id, market) if not should_send: continue message = await self._format_alert(AlertRecord( match_id=match_id, market=market, odds=odds, ev=ev, handle_share=handle_share, )) await self.notifier.send_alert(message) await asyncio.sleep(self.poll_interval_seconds) finally: await self.notifier.close() await self.redis.close() async def main() -> None: redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') bot_token = os.environ.get('TELEGRAM_BOT_TOKEN', '') chat_id = os.environ.get('TELEGRAM_CHAT_ID', '') ev_threshold = float(os.environ.get('EV_THRESHOLD', '0.05')) sharp_threshold = float(os.environ.get('SHARP_THRESHOLD', '0.80')) poll_seconds = int(os.environ.get('ALERT_POLL_SECONDS', '10')) notifier = TelegramNotifier(bot_token, chat_id) svc = AlertService( redis_url, notifier, poll_interval_seconds=poll_seconds, ev_threshold=ev_threshold, sharp_threshold=sharp_threshold, ) await svc.evaluate_loop() if __name__ == '__main__': asyncio.run(main())