Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
7
platform/alerts/.env.example
Normal file
7
platform/alerts/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
TELEGRAM_BOT_TOKEN=replace-me
|
||||
TELEGRAM_CHAT_ID=replace-me
|
||||
EV_THRESHOLD=0.05
|
||||
SHARP_THRESHOLD=0.80
|
||||
ALERT_POLL_SECONDS=10
|
||||
|
||||
9
platform/alerts/Dockerfile
Normal file
9
platform/alerts/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY alert_worker.py ./
|
||||
CMD ["python", "alert_worker.py"]
|
||||
|
||||
177
platform/alerts/alert_worker.py
Normal file
177
platform/alerts/alert_worker.py
Normal file
@@ -0,0 +1,177 @@
|
||||
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())
|
||||
2
platform/alerts/requirements.txt
Normal file
2
platform/alerts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
httpx==0.28.1
|
||||
redis==5.2.1
|
||||
Reference in New Issue
Block a user