|
|
import os |
|
|
import math |
|
|
import asyncio |
|
|
from datetime import date, timedelta |
|
|
from typing import List, Optional |
|
|
|
|
|
from fastapi import FastAPI, Query |
|
|
from pydantic import BaseModel |
|
|
import httpx |
|
|
from bs4 import BeautifulSoup |
|
|
from dotenv import load_dotenv |
|
|
|
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
|
|
|
HF_CACHE = os.getenv("HF_HOME", os.path.expanduser("~/.cache/huggingface")) |
|
|
os.environ["HF_HOME"] = HF_CACHE |
|
|
|
|
|
FOOTBALL_API_KEY = os.getenv("FOOTBALL_API_KEY", "") |
|
|
FOOTBALL_ENDPOINT = "https://api.football-data.org/v4/matches" |
|
|
SOCCER_LEAGUES = {"EPL": 2021, "LaLiga": 2014, "Bundesliga": 2002} |
|
|
|
|
|
BALLDONTLIE_ENDPOINT = "https://www.balldontlie.io/api/v1/games" |
|
|
ESPN_SCOREBOARD_JSON = "https://site.web.api.espn.com/apis/v2/sports/basketball/nba/scoreboard?dates={ymd}" |
|
|
|
|
|
|
|
|
MAX_CONCURRENT_REQUESTS = int(os.getenv("MAX_CONCURRENCY", "8")) |
|
|
REQUEST_TIMEOUT = 12.0 |
|
|
|
|
|
|
|
|
app = FastAPI(title="SafeBet AI v2", description="Soccer + NBA predictions (safe/aggressive) β async & robust") |
|
|
|
|
|
|
|
|
_sentiment_model = None |
|
|
_reasoning_model = None |
|
|
_similarity_model = None |
|
|
_model_lock = asyncio.Lock() |
|
|
|
|
|
|
|
|
def _load_models_blocking(): |
|
|
"""Blocking load of models. Called within threadpool.""" |
|
|
global _sentiment_model, _reasoning_model, _similarity_model |
|
|
from transformers import pipeline |
|
|
from sentence_transformers import SentenceTransformer |
|
|
|
|
|
if _sentiment_model is None: |
|
|
_sentiment_model = pipeline( |
|
|
"zero-shot-classification", |
|
|
model="valhalla/distilbart-mnli-12-1", |
|
|
device=-1 |
|
|
) |
|
|
if _reasoning_model is None: |
|
|
_reasoning_model = pipeline( |
|
|
"text2text-generation", |
|
|
model="google/flan-t5-base", |
|
|
device=-1 |
|
|
) |
|
|
if _similarity_model is None: |
|
|
_similarity_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") |
|
|
|
|
|
|
|
|
async def ensure_models(): |
|
|
async with _model_lock: |
|
|
if _sentiment_model is None or _reasoning_model is None or _similarity_model is None: |
|
|
await asyncio.to_thread(_load_models_blocking) |
|
|
|
|
|
|
|
|
|
|
|
sem = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS) |
|
|
|
|
|
|
|
|
async def fetch_text(url: str, client: httpx.AsyncClient, params=None, headers=None) -> Optional[str]: |
|
|
try: |
|
|
async with sem: |
|
|
r = await client.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT) |
|
|
r.raise_for_status() |
|
|
return r.text |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
|
|
|
async def fetch_json(url: str, client: httpx.AsyncClient, params=None, headers=None): |
|
|
try: |
|
|
async with sem: |
|
|
r = await client.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT) |
|
|
r.raise_for_status() |
|
|
return r.json() |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
|
|
|
async def parse_google_news_headlines(html_text: str, max_h=5) -> str: |
|
|
|
|
|
def _parse(html): |
|
|
soup = BeautifulSoup(html, "html.parser") |
|
|
hs = [h.get_text(separator=" ", strip=True) for h in soup.select("h3")[:max_h]] |
|
|
return " ".join(hs) |
|
|
return await asyncio.to_thread(_parse, html_text) if html_text else "" |
|
|
|
|
|
|
|
|
|
|
|
async def aggressive_team_news(team: str, sport: str = "football") -> str: |
|
|
""" |
|
|
Best procedures: |
|
|
1. Google News search |
|
|
2. Bing News search |
|
|
3. ESPN (if sport-specific) |
|
|
4. Fallback: short default text |
|
|
""" |
|
|
async with httpx.AsyncClient() as client: |
|
|
|
|
|
q = f"{team} {sport}" |
|
|
url = f"https://news.google.com/search" |
|
|
params = {"q": q, "hl": "en"} |
|
|
html = await fetch_text(url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) |
|
|
text = await parse_google_news_headlines(html, max_h=6) |
|
|
if text and len(text.strip()) >= 20: |
|
|
return text |
|
|
|
|
|
|
|
|
try: |
|
|
bing_url = "https://www.bing.com/news/search" |
|
|
params = {"q": q} |
|
|
html = await fetch_text(bing_url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) |
|
|
if html: |
|
|
parsed = await parse_google_news_headlines(html, max_h=6) |
|
|
if parsed and len(parsed.strip()) >= 20: |
|
|
return parsed |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
try: |
|
|
if sport.lower() in ("football", "soccer"): |
|
|
|
|
|
search_url = f"https://www.espn.com/search/results" |
|
|
params = {"search": q} |
|
|
html = await fetch_text(search_url, client, params=params, headers={"User-Agent": "Mozilla/5.0"}) |
|
|
parsed = await parse_google_news_headlines(html, max_h=6) |
|
|
if parsed and len(parsed.strip()) >= 20: |
|
|
return parsed |
|
|
else: |
|
|
|
|
|
espn_search = f"https://www.espn.com/search/results?q={q}" |
|
|
html = await fetch_text(espn_search, client, headers={"User-Agent": "Mozilla/5.0"}) |
|
|
parsed = await parse_google_news_headlines(html, max_h=6) |
|
|
if parsed and len(parsed.strip()) >= 20: |
|
|
return parsed |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
|
|
|
return f"No recent updates found for {team} ({sport})." |
|
|
|
|
|
|
|
|
|
|
|
async def sentiment_and_signal(text: str): |
|
|
await ensure_models() |
|
|
labels = ["positive", "negative", "injury", "motivation", "transfer"] |
|
|
|
|
|
def _call(): |
|
|
try: |
|
|
res = _sentiment_model(text, labels) |
|
|
|
|
|
labels_out = res.get("labels", []) |
|
|
scores_out = res.get("scores", []) |
|
|
table = {lbl: float(scores_out[i]) for i, lbl in enumerate(labels_out)} |
|
|
net = table.get("positive", 0.0) - table.get("negative", 0.0) - 0.4 * table.get("injury", 0.0) + 0.2 * table.get("motivation", 0.0) |
|
|
return {**table, "net": net} |
|
|
except Exception: |
|
|
return {"positive": 0.0, "negative": 0.0, "injury": 0.0, "motivation": 0.0, "transfer": 0.0, "net": 0.0} |
|
|
return await asyncio.to_thread(_call) |
|
|
|
|
|
|
|
|
async def similarity_score(a: str, b: str) -> float: |
|
|
await ensure_models() |
|
|
|
|
|
def _call(): |
|
|
try: |
|
|
emb_a = _similarity_model.encode(a, convert_to_tensor=True) |
|
|
emb_b = _similarity_model.encode(b, convert_to_tensor=True) |
|
|
from sentence_transformers import util as st_util |
|
|
return float(st_util.pytorch_cos_sim(emb_a, emb_b).item()) |
|
|
except Exception: |
|
|
return 0.0 |
|
|
return await asyncio.to_thread(_call) |
|
|
|
|
|
|
|
|
async def reasoning_text(context: str, sport: str, mode: str) -> str: |
|
|
await ensure_models() |
|
|
|
|
|
def _call(): |
|
|
try: |
|
|
prompt = ( |
|
|
f"Mode: {mode}. Sport: {sport}.\n" |
|
|
f"Context:\n{context}\n\n" |
|
|
"Provide a concise betting-style verdict and short safe reasoning." |
|
|
) |
|
|
out = _reasoning_model(prompt, max_new_tokens=120) |
|
|
return out[0].get("generated_text", "").strip() |
|
|
except Exception: |
|
|
return "" |
|
|
return await asyncio.to_thread(_call) |
|
|
|
|
|
|
|
|
|
|
|
def softmax_pair(h: float, a: float): |
|
|
"""Simple softmax over two values to get probabilities.""" |
|
|
exph = math.exp(h) |
|
|
expa = math.exp(a) |
|
|
s = exph + expa |
|
|
if s == 0: |
|
|
return 0.5, 0.5 |
|
|
return exph / s, expa / s |
|
|
|
|
|
|
|
|
def apply_bet_bias(p_home: float, p_draw: float, p_away: float, mode: str): |
|
|
"""Apply bias: safe increases draw/home; aggressive favors wins.""" |
|
|
if mode == "safe": |
|
|
p_draw += 0.15 |
|
|
p_home += 0.10 |
|
|
else: |
|
|
p_home += 0.20 |
|
|
p_away += 0.05 |
|
|
total = max(1e-6, p_home + p_draw + p_away) |
|
|
return p_home / total, p_draw / total, p_away / total |
|
|
|
|
|
|
|
|
def confidence_to_decimal_odds(conf: float, cap_min: float = 0.02): |
|
|
|
|
|
c = max(conf, cap_min) |
|
|
odds = 1.0 / c |
|
|
|
|
|
if odds < 1.01: |
|
|
odds = 1.01 |
|
|
if odds > 1000: |
|
|
odds = 1000.0 |
|
|
return round(odds, 2) |
|
|
|
|
|
|
|
|
|
|
|
async def predict_soccer(home: str, away: str, mode: str): |
|
|
|
|
|
home_news_task = asyncio.create_task(aggressive_team_news(home, "football")) |
|
|
away_news_task = asyncio.create_task(aggressive_team_news(away, "football")) |
|
|
home_news, away_news = await asyncio.gather(home_news_task, away_news_task) |
|
|
|
|
|
home_sig_task = asyncio.create_task(sentiment_and_signal(home_news)) |
|
|
away_sig_task = asyncio.create_task(sentiment_and_signal(away_news)) |
|
|
sim_task = asyncio.create_task(similarity_score(home + " " + home_news, away + " " + away_news)) |
|
|
home_sig, away_sig, sim = await asyncio.gather(home_sig_task, away_sig_task, sim_task) |
|
|
|
|
|
|
|
|
diff = home_sig.get("net", 0.0) - away_sig.get("net", 0.0) |
|
|
p_home = 1 / (1 + math.exp(-3 * diff)) |
|
|
p_away = 1 - p_home |
|
|
p_draw = max(0.05, 1 - abs(p_home - p_away)) |
|
|
|
|
|
p_home, p_draw, p_away = apply_bet_bias(p_home, p_draw, p_away, mode) |
|
|
|
|
|
|
|
|
if mode == "safe": |
|
|
if p_draw >= max(p_home, p_away) and p_draw >= 0.35: |
|
|
pick = "Draw / Under 2.5 (safe)" |
|
|
conf = p_draw |
|
|
elif p_home >= 0.45: |
|
|
pick = f"{home} or Draw (1X)" |
|
|
conf = p_home |
|
|
else: |
|
|
pick = f"Draw or {away} (X2)" |
|
|
conf = max(p_draw, p_away) |
|
|
else: |
|
|
|
|
|
if abs(p_home - p_away) < 0.07: |
|
|
pick = "Small margin β consider Double Chance" |
|
|
conf = max(p_home, p_away) |
|
|
else: |
|
|
pick = home if p_home > p_away else away |
|
|
conf = max(p_home, p_away) |
|
|
|
|
|
|
|
|
context = ( |
|
|
f"{home} vs {away}\nhome_net={home_sig.get('net'):.3f} away_net={away_sig.get('net'):.3f}\n" |
|
|
f"similarity={sim:.3f}\nhome_news_excerpt={home_news[:200]}\naway_news_excerpt={away_news[:200]}" |
|
|
) |
|
|
reasoning = await reasoning_text(context, "football", mode) |
|
|
|
|
|
|
|
|
decimal_odds = confidence_to_decimal_odds(conf) |
|
|
|
|
|
return { |
|
|
"match": f"{home} vs {away}", |
|
|
"pick": pick, |
|
|
"confidence": round(conf, 3), |
|
|
"decimal_odds": decimal_odds, |
|
|
"similarity": round(sim, 3), |
|
|
"reasoning": reasoning, |
|
|
"home_news": home_news[:500], |
|
|
"away_news": away_news[:500], |
|
|
"home_signal": home_sig, |
|
|
"away_signal": away_sig, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async def fetch_nba_fixtures_for_date(ymd: str): |
|
|
"""Try: balldontlie -> ESPN JSON scoreboard -> Google search fallback (light).""" |
|
|
fixtures = [] |
|
|
|
|
|
async with httpx.AsyncClient() as client: |
|
|
|
|
|
try: |
|
|
params = {"dates[]": ymd} |
|
|
r = await client.get(BALLDONTLIE_ENDPOINT, params=params, timeout=REQUEST_TIMEOUT) |
|
|
if r.status_code == 200: |
|
|
j = r.json() |
|
|
data = j.get("data", []) |
|
|
for g in data: |
|
|
home = g.get("home_team", {}).get("full_name") |
|
|
away = g.get("visitor_team", {}).get("full_name") |
|
|
if home and away: |
|
|
fixtures.append({"home": home, "away": away}) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
if fixtures: |
|
|
return fixtures |
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
ymd_es = ymd.replace("-", "") |
|
|
url = ESPN_SCOREBOARD_JSON.format(ymd=ymd_es) |
|
|
j = await fetch_json(url, client) |
|
|
events = (j or {}).get("events", []) if isinstance(j, dict) else [] |
|
|
for ev in events: |
|
|
comps = (ev.get("competitions"), None) or ev.get("competitions") or [] |
|
|
if comps: |
|
|
comps0 = comps[0] |
|
|
comps_list = comps0.get("competitors") or [] |
|
|
home = next((c for c in comps_list if c.get("homeAway") == "home"), None) |
|
|
away = next((c for c in comps_list if c.get("homeAway") == "away"), None) |
|
|
if home and away: |
|
|
fixtures.append({ |
|
|
"home": home.get("team", {}).get("displayName") or home.get("team", {}).get("name"), |
|
|
"away": away.get("team", {}).get("displayName") or away.get("team", {}).get("name"), |
|
|
}) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
if fixtures: |
|
|
return fixtures |
|
|
|
|
|
|
|
|
try: |
|
|
search_q = f"NBA schedule {ymd}" |
|
|
google = await fetch_text("https://www.google.com/search", client, params={"q": search_q}, headers={"User-Agent": "Mozilla/5.0"}) |
|
|
parsed = await parse_google_news_headlines(google, max_h=30) |
|
|
|
|
|
lines = parsed.split(" ") |
|
|
from re import findall |
|
|
for ln in lines: |
|
|
matches = findall(r"([A-Za-z .&]+) (?:v|vs|vs.|at) ([A-Za-z .&]+)", ln, flags=0) |
|
|
for m in matches: |
|
|
fixtures.append({"home": m[0].strip(), "away": m[1].strip()}) |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
return fixtures |
|
|
|
|
|
|
|
|
async def predict_nba(home: str, away: str, mode: str): |
|
|
|
|
|
home_news_task = asyncio.create_task(aggressive_team_news(home, "NBA")) |
|
|
away_news_task = asyncio.create_task(aggressive_team_news(away, "NBA")) |
|
|
home_news, away_news = await asyncio.gather(home_news_task, away_news_task) |
|
|
|
|
|
home_sig_task = asyncio.create_task(sentiment_and_signal(home_news)) |
|
|
away_sig_task = asyncio.create_task(sentiment_and_signal(away_news)) |
|
|
home_sig, away_sig = await asyncio.gather(home_sig_task, away_sig_task) |
|
|
|
|
|
|
|
|
diff = home_sig.get("net", 0.0) - away_sig.get("net", 0.0) |
|
|
p_home = 1 / (1 + math.exp(-3 * diff)) |
|
|
p_away = 1 - p_home |
|
|
p_draw = 0.02 |
|
|
|
|
|
p_home, p_draw, p_away = apply_bet_bias(p_home, p_draw, p_away, mode) |
|
|
|
|
|
|
|
|
if mode == "safe": |
|
|
|
|
|
pick = f"{home} Win (safe margin < 10 pts)" |
|
|
conf = p_home if p_home > p_away else p_away |
|
|
else: |
|
|
pick = home if p_home > p_away else away |
|
|
conf = max(p_home, p_away) |
|
|
|
|
|
context = ( |
|
|
f"{home} vs {away}\nhome_net={home_sig.get('net'):.3f} away_net={away_sig.get('net'):.3f}\n" |
|
|
f"home_news_excerpt={home_news[:200]}\naway_news_excerpt={away_news[:200]}" |
|
|
) |
|
|
reasoning = await reasoning_text(context, "NBA", mode) |
|
|
decimal_odds = confidence_to_decimal_odds(conf) |
|
|
return { |
|
|
"match": f"{home} vs {away}", |
|
|
"pick": pick, |
|
|
"confidence": round(conf, 3), |
|
|
"decimal_odds": decimal_odds, |
|
|
"reasoning": reasoning, |
|
|
"home_news": home_news[:500], |
|
|
"away_news": away_news[:500], |
|
|
"home_signal": home_sig, |
|
|
"away_signal": away_sig, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
class PredictionsResponse(BaseModel): |
|
|
date: str |
|
|
mode: str |
|
|
sport: str |
|
|
predictions: List[dict] |
|
|
count: int |
|
|
|
|
|
|
|
|
@app.get("/predictions", response_model=PredictionsResponse) |
|
|
async def predictions( |
|
|
mode: str = Query("safe", enum=["safe", "aggressive"]), |
|
|
sport: str = Query("both", enum=["soccer", "nba", "both"]), |
|
|
): |
|
|
await ensure_models() |
|
|
|
|
|
today = date.today() |
|
|
results = [] |
|
|
|
|
|
|
|
|
if sport in ("soccer", "both"): |
|
|
date_from = (today - timedelta(days=1)).isoformat() |
|
|
date_to = (today + timedelta(days=1)).isoformat() |
|
|
headers = {"X-Auth-Token": FOOTBALL_API_KEY} if FOOTBALL_API_KEY else {} |
|
|
async with httpx.AsyncClient() as client: |
|
|
for league, code in SOCCER_LEAGUES.items(): |
|
|
try: |
|
|
params = {"competitions": code, "dateFrom": date_from, "dateTo": date_to} |
|
|
r = await client.get(FOOTBALL_ENDPOINT, params=params, headers=headers, timeout=REQUEST_TIMEOUT) |
|
|
if r.status_code == 200: |
|
|
j = r.json() |
|
|
matches = j.get("matches", []) |
|
|
|
|
|
tasks = [] |
|
|
for m in matches[:3]: |
|
|
home = m.get("homeTeam", {}).get("name") |
|
|
away = m.get("awayTeam", {}).get("name") |
|
|
if home and away: |
|
|
tasks.append(asyncio.create_task(predict_soccer(home, away, mode))) |
|
|
if tasks: |
|
|
res = await asyncio.gather(*tasks) |
|
|
results.extend(res) |
|
|
except Exception as e: |
|
|
results.append({"league": league, "error": str(e)}) |
|
|
|
|
|
|
|
|
if sport in ("nba", "both"): |
|
|
|
|
|
ymd = today.isoformat() |
|
|
fixtures = await fetch_nba_fixtures_for_date(ymd) |
|
|
if fixtures: |
|
|
|
|
|
tasks = [] |
|
|
for f in fixtures[:6]: |
|
|
tasks.append(asyncio.create_task(predict_nba(f["home"], f["away"], mode))) |
|
|
res = await asyncio.gather(*tasks) |
|
|
results.extend(res) |
|
|
else: |
|
|
|
|
|
results.append({"league": "NBA", "notice": "No fixtures found for today via API or fallbacks."}) |
|
|
|
|
|
return PredictionsResponse( |
|
|
date=today.isoformat(), |
|
|
mode=mode, |
|
|
sport=sport, |
|
|
predictions=results, |
|
|
count=len(results) |
|
|
) |
|
|
|
|
|
|
|
|
@app.get("/") |
|
|
def root(): |
|
|
return { |
|
|
"message": "SafeBet AI v2 β async, aggressive news, NBA fallback, safe/aggressive modes", |
|
|
"endpoints": { |
|
|
"/predictions?mode=safe|aggressive&sport=soccer|nba|both": "Main predictions endpoint" |
|
|
}, |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
uvicorn.run("safebet_v2:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")), reload=False) |