Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

View 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

View 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"]

View 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())

View File

@@ -0,0 +1,2 @@
httpx==0.28.1
redis==5.2.1