Files
2026FIFAWorldCup/platform/alerts/alert_worker.py

178 lines
5.2 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 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())