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