Update app.py
Browse files
app.py
CHANGED
|
@@ -68,6 +68,177 @@ def get_team_news(team: str, sport: str = "football"):
|
|
| 68 |
headlines = [h.text for h in soup.select("h3")[:5]]
|
| 69 |
return " ".join(headlines)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
@app.get("/soccer-predictions")
|
| 72 |
def soccer_predictions():
|
| 73 |
from datetime import timedelta
|
|
@@ -110,6 +281,8 @@ def soccer_predictions():
|
|
| 110 |
|
| 111 |
for match in matches:
|
| 112 |
home, away = match["homeTeam"]["name"], match["awayTeam"]["name"]
|
|
|
|
|
|
|
| 113 |
|
| 114 |
home_news = get_team_news(home, "football")
|
| 115 |
away_news = get_team_news(away, "football")
|
|
@@ -134,24 +307,50 @@ def soccer_predictions():
|
|
| 134 |
emb_away = similarity_model.encode(away + " " + away_news, convert_to_tensor=True)
|
| 135 |
similarity = util.pytorch_cos_sim(emb_home, emb_away).item()
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
context = f"""
|
| 138 |
Match: {home} vs {away}.
|
| 139 |
Home news: {home_news}.
|
| 140 |
Away news: {away_news}.
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
"""
|
| 143 |
reasoning = reasoning_model(
|
| 144 |
-
f"
|
| 145 |
max_length=256
|
| 146 |
)[0]['generated_text']
|
| 147 |
|
| 148 |
predictions.append({
|
| 149 |
"league": league,
|
| 150 |
"match": f"{home} vs {away}",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
"reason": reasoning,
|
|
|
|
| 152 |
"news_summary": {
|
| 153 |
home: home_sent,
|
| 154 |
away: away_sent
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
})
|
| 157 |
|
|
@@ -281,6 +480,27 @@ def nba_predictions():
|
|
| 281 |
"error": f"NBA API error: {str(e)}"
|
| 282 |
}
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
for g in games:
|
| 285 |
home, away = g["home_team"]["full_name"], g["visitor_team"]["full_name"]
|
| 286 |
|
|
@@ -309,13 +529,14 @@ def nba_predictions():
|
|
| 309 |
Away news: {away_news}.
|
| 310 |
"""
|
| 311 |
reasoning = reasoning_model(
|
| 312 |
-
f"
|
| 313 |
max_length=256
|
| 314 |
)[0]['generated_text']
|
| 315 |
|
| 316 |
predictions.append({
|
| 317 |
"match": f"{home} vs {away}",
|
| 318 |
"reason": reasoning,
|
|
|
|
| 319 |
"news_summary": {
|
| 320 |
home: home_sent,
|
| 321 |
away: away_sent
|
|
|
|
| 68 |
headlines = [h.text for h in soup.select("h3")[:5]]
|
| 69 |
return " ".join(headlines)
|
| 70 |
|
| 71 |
+
# --- Helpers: NBA preseason scraping (very lightweight best-effort) ---
|
| 72 |
+
def _extract_pairs_from_texts(texts):
|
| 73 |
+
pairs = []
|
| 74 |
+
for t in texts:
|
| 75 |
+
s = t.strip()
|
| 76 |
+
if not s:
|
| 77 |
+
continue
|
| 78 |
+
lowered = s.lower()
|
| 79 |
+
if " vs " in lowered:
|
| 80 |
+
sep = " vs "
|
| 81 |
+
elif " v " in lowered:
|
| 82 |
+
sep = " v "
|
| 83 |
+
elif " at " in lowered:
|
| 84 |
+
sep = " at "
|
| 85 |
+
else:
|
| 86 |
+
continue
|
| 87 |
+
parts = s.split(sep, 1)
|
| 88 |
+
if len(parts) == 2:
|
| 89 |
+
home = parts[0].strip()
|
| 90 |
+
away = parts[1].strip()
|
| 91 |
+
if home and away:
|
| 92 |
+
pairs.append({"home": home, "away": away})
|
| 93 |
+
return pairs
|
| 94 |
+
|
| 95 |
+
def scrape_nba_preseason_pairs_for_dates(dates):
|
| 96 |
+
found = []
|
| 97 |
+
seen = set()
|
| 98 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 99 |
+
for d in dates:
|
| 100 |
+
q = f"NBA preseason {d}"
|
| 101 |
+
url = f"https://www.google.com/search?q={q}"
|
| 102 |
+
try:
|
| 103 |
+
r = requests.get(url, headers=headers, timeout=20)
|
| 104 |
+
soup = BeautifulSoup(r.text, "html.parser")
|
| 105 |
+
h3s = [h.text for h in soup.select("h3")[:20]]
|
| 106 |
+
pairs = _extract_pairs_from_texts(h3s)
|
| 107 |
+
for p in pairs:
|
| 108 |
+
key = (p["home"].lower(), p["away"].lower())
|
| 109 |
+
if key not in seen:
|
| 110 |
+
seen.add(key)
|
| 111 |
+
found.append(p)
|
| 112 |
+
except Exception:
|
| 113 |
+
continue
|
| 114 |
+
return found
|
| 115 |
+
|
| 116 |
+
# --- Verdict helpers ---
|
| 117 |
+
def build_soccer_verdict(home, away, agg):
|
| 118 |
+
oneXtwo = agg.get("oneXtwo", {})
|
| 119 |
+
over_under = agg.get("over_under", {})
|
| 120 |
+
pick_1x2 = max(oneXtwo, key=oneXtwo.get) if oneXtwo else "X"
|
| 121 |
+
conf_1x2 = oneXtwo.get(pick_1x2, 0.0)
|
| 122 |
+
ou_25 = over_under.get("2.5", {"over": 0.0, "under": 0.0})
|
| 123 |
+
pick_ou = "Over 2.5" if ou_25.get("over", 0.0) >= ou_25.get("under", 0.0) else "Under 2.5"
|
| 124 |
+
conf_ou = max(ou_25.get("over", 0.0), ou_25.get("under", 0.0))
|
| 125 |
+
return f"{home} vs {away}: {pick_1x2} ({conf_1x2:.2f}), {pick_ou} ({conf_ou:.2f})"
|
| 126 |
+
|
| 127 |
+
# --- Helpers: Football-Data last-5 and simple Poisson model ---
|
| 128 |
+
def football_headers():
|
| 129 |
+
return {"X-Auth-Token": FOOTBALL_API_KEY}
|
| 130 |
+
|
| 131 |
+
def fetch_last5_for_team(team_id: int):
|
| 132 |
+
try:
|
| 133 |
+
url = f"https://api.football-data.org/v4/teams/{team_id}/matches?status=FINISHED&limit=5"
|
| 134 |
+
r = requests.get(url, headers=football_headers(), timeout=20)
|
| 135 |
+
r.raise_for_status()
|
| 136 |
+
data = r.json()
|
| 137 |
+
return data.get("matches", [])
|
| 138 |
+
except Exception:
|
| 139 |
+
return []
|
| 140 |
+
|
| 141 |
+
def summarize_form(team_id: int, team_name: str):
|
| 142 |
+
matches = fetch_last5_for_team(team_id)
|
| 143 |
+
goals_for = 0
|
| 144 |
+
goals_against = 0
|
| 145 |
+
wins = 0
|
| 146 |
+
draws = 0
|
| 147 |
+
losses = 0
|
| 148 |
+
for m in matches:
|
| 149 |
+
ht = m.get("homeTeam", {})
|
| 150 |
+
at = m.get("awayTeam", {})
|
| 151 |
+
score = m.get("score", {}).get("fullTime", {})
|
| 152 |
+
hg = score.get("home") if score.get("home") is not None else 0
|
| 153 |
+
ag = score.get("away") if score.get("away") is not None else 0
|
| 154 |
+
is_home = (ht.get("id") == team_id)
|
| 155 |
+
gf = hg if is_home else ag
|
| 156 |
+
ga = ag if is_home else hg
|
| 157 |
+
goals_for += gf
|
| 158 |
+
goals_against += ga
|
| 159 |
+
if gf > ga:
|
| 160 |
+
wins += 1
|
| 161 |
+
elif gf == ga:
|
| 162 |
+
draws += 1
|
| 163 |
+
else:
|
| 164 |
+
losses += 1
|
| 165 |
+
games = max(1, len(matches))
|
| 166 |
+
avg_for = goals_for / games
|
| 167 |
+
avg_against = goals_against / games
|
| 168 |
+
return {
|
| 169 |
+
"team_id": team_id,
|
| 170 |
+
"team": team_name,
|
| 171 |
+
"matches_considered": len(matches),
|
| 172 |
+
"wins": wins,
|
| 173 |
+
"draws": draws,
|
| 174 |
+
"losses": losses,
|
| 175 |
+
"avg_goals_for": avg_for,
|
| 176 |
+
"avg_goals_against": avg_against,
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
def poisson_pmf(lmbda: float, k: int):
|
| 180 |
+
if lmbda <= 0:
|
| 181 |
+
lmbda = 0.0001
|
| 182 |
+
# simple Poisson PMF: e^-位 位^k / k!
|
| 183 |
+
from math import exp
|
| 184 |
+
# factorial up to 10 safely
|
| 185 |
+
fact = 1
|
| 186 |
+
for i in range(2, k + 1):
|
| 187 |
+
fact *= i
|
| 188 |
+
return (exp(-lmbda) * (lmbda ** k)) / fact
|
| 189 |
+
|
| 190 |
+
def match_score_prob(lambda_home: float, lambda_away: float, max_goals: int = 10):
|
| 191 |
+
matrix = []
|
| 192 |
+
for h in range(0, max_goals + 1):
|
| 193 |
+
row = []
|
| 194 |
+
for a in range(0, max_goals + 1):
|
| 195 |
+
row.append(poisson_pmf(lambda_home, h) * poisson_pmf(lambda_away, a))
|
| 196 |
+
matrix.append(row)
|
| 197 |
+
return matrix
|
| 198 |
+
|
| 199 |
+
def aggregate_outcomes(prob_matrix, thresholds=(0.5, 1.5, 2.5, 3.5)):
|
| 200 |
+
max_g = len(prob_matrix) - 1
|
| 201 |
+
p_home = 0.0
|
| 202 |
+
p_draw = 0.0
|
| 203 |
+
p_away = 0.0
|
| 204 |
+
ou = {t: {"over": 0.0, "under": 0.0} for t in thresholds}
|
| 205 |
+
total_prob = 0.0
|
| 206 |
+
for h in range(max_g + 1):
|
| 207 |
+
for a in range(max_g + 1):
|
| 208 |
+
p = prob_matrix[h][a]
|
| 209 |
+
total_prob += p
|
| 210 |
+
if h > a:
|
| 211 |
+
p_home += p
|
| 212 |
+
elif h == a:
|
| 213 |
+
p_draw += p
|
| 214 |
+
else:
|
| 215 |
+
p_away += p
|
| 216 |
+
s = h + a
|
| 217 |
+
for t in thresholds:
|
| 218 |
+
if s > t:
|
| 219 |
+
ou[t]["over"] += p
|
| 220 |
+
else:
|
| 221 |
+
ou[t]["under"] += p
|
| 222 |
+
# double chance
|
| 223 |
+
dc_1x = p_home + p_draw
|
| 224 |
+
dc_x2 = p_draw + p_away
|
| 225 |
+
# normalize tiny drift
|
| 226 |
+
if abs(total_prob - 1.0) > 1e-6:
|
| 227 |
+
scale = 1.0 / total_prob
|
| 228 |
+
p_home *= scale
|
| 229 |
+
p_draw *= scale
|
| 230 |
+
p_away *= scale
|
| 231 |
+
for t in thresholds:
|
| 232 |
+
ou[t]["over"] *= scale
|
| 233 |
+
ou[t]["under"] *= scale
|
| 234 |
+
dc_1x *= scale
|
| 235 |
+
dc_x2 *= scale
|
| 236 |
+
return {
|
| 237 |
+
"oneXtwo": {"1": p_home, "X": p_draw, "2": p_away},
|
| 238 |
+
"double_chance": {"1X": dc_1x, "X2": dc_x2},
|
| 239 |
+
"over_under": {str(t): ou[t] for t in thresholds}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
@app.get("/soccer-predictions")
|
| 243 |
def soccer_predictions():
|
| 244 |
from datetime import timedelta
|
|
|
|
| 281 |
|
| 282 |
for match in matches:
|
| 283 |
home, away = match["homeTeam"]["name"], match["awayTeam"]["name"]
|
| 284 |
+
home_id = match["homeTeam"].get("id")
|
| 285 |
+
away_id = match["awayTeam"].get("id")
|
| 286 |
|
| 287 |
home_news = get_team_news(home, "football")
|
| 288 |
away_news = get_team_news(away, "football")
|
|
|
|
| 307 |
emb_away = similarity_model.encode(away + " " + away_news, convert_to_tensor=True)
|
| 308 |
similarity = util.pytorch_cos_sim(emb_home, emb_away).item()
|
| 309 |
|
| 310 |
+
# Form summaries (last-5)
|
| 311 |
+
form_home = summarize_form(home_id, home) if home_id else None
|
| 312 |
+
form_away = summarize_form(away_id, away) if away_id else None
|
| 313 |
+
|
| 314 |
+
# Simple Poisson lambdas from form with home advantage factor
|
| 315 |
+
home_lambda = (form_home["avg_goals_for"] if form_home else 1.2) * 1.1
|
| 316 |
+
away_lambda = (form_away["avg_goals_for"] if form_away else 1.1) * 0.95
|
| 317 |
+
prob_matrix = match_score_prob(home_lambda, away_lambda)
|
| 318 |
+
agg = aggregate_outcomes(prob_matrix)
|
| 319 |
+
|
| 320 |
+
# LLM reasoning
|
| 321 |
context = f"""
|
| 322 |
Match: {home} vs {away}.
|
| 323 |
Home news: {home_news}.
|
| 324 |
Away news: {away_news}.
|
| 325 |
+
Similarity: {similarity:.3f}.
|
| 326 |
+
Home form (avg GF/GA): {(form_home['avg_goals_for'] if form_home else 'n/a')}/{(form_home['avg_goals_against'] if form_home else 'n/a')}.
|
| 327 |
+
Away form (avg GF/GA): {(form_away['avg_goals_for'] if form_away else 'n/a')}/{(form_away['avg_goals_against'] if form_away else 'n/a')}.
|
| 328 |
+
Model 1X2: {agg['oneXtwo']}.
|
| 329 |
+
O/U ladder: {agg['over_under']}.
|
| 330 |
+
Double chance: {agg['double_chance']}.
|
| 331 |
"""
|
| 332 |
reasoning = reasoning_model(
|
| 333 |
+
f"Provide a concise betting-style verdict (1X2, O/U, double chance) with rationale given the context. Context: {context}",
|
| 334 |
max_length=256
|
| 335 |
)[0]['generated_text']
|
| 336 |
|
| 337 |
predictions.append({
|
| 338 |
"league": league,
|
| 339 |
"match": f"{home} vs {away}",
|
| 340 |
+
"structured": {
|
| 341 |
+
"oneXtwo": agg["oneXtwo"],
|
| 342 |
+
"double_chance": agg["double_chance"],
|
| 343 |
+
"over_under": agg["over_under"],
|
| 344 |
+
},
|
| 345 |
"reason": reasoning,
|
| 346 |
+
"verdict": build_soccer_verdict(home, away, agg),
|
| 347 |
"news_summary": {
|
| 348 |
home: home_sent,
|
| 349 |
away: away_sent
|
| 350 |
+
},
|
| 351 |
+
"form": {
|
| 352 |
+
home: form_home,
|
| 353 |
+
away: form_away
|
| 354 |
}
|
| 355 |
})
|
| 356 |
|
|
|
|
| 480 |
"error": f"NBA API error: {str(e)}"
|
| 481 |
}
|
| 482 |
|
| 483 |
+
if not games:
|
| 484 |
+
# Try lightweight Google scraping for preseason fixtures
|
| 485 |
+
scraped_pairs = scrape_nba_preseason_pairs_for_dates(dates)
|
| 486 |
+
debug_info.append({
|
| 487 |
+
"note": "No NBA API games; attempted preseason scrape",
|
| 488 |
+
"dates_queried": dates,
|
| 489 |
+
"scraped_pairs": scraped_pairs
|
| 490 |
+
})
|
| 491 |
+
if not scraped_pairs:
|
| 492 |
+
return {
|
| 493 |
+
"date_range": f"{date_from} to {date_to}",
|
| 494 |
+
"predictions": [],
|
| 495 |
+
"debug_info": debug_info,
|
| 496 |
+
"total_predictions": 0
|
| 497 |
+
}
|
| 498 |
+
# Build stub games from scraped data
|
| 499 |
+
games = [{
|
| 500 |
+
"home_team": {"full_name": p["home"]},
|
| 501 |
+
"visitor_team": {"full_name": p["away"]}
|
| 502 |
+
} for p in scraped_pairs]
|
| 503 |
+
|
| 504 |
for g in games:
|
| 505 |
home, away = g["home_team"]["full_name"], g["visitor_team"]["full_name"]
|
| 506 |
|
|
|
|
| 529 |
Away news: {away_news}.
|
| 530 |
"""
|
| 531 |
reasoning = reasoning_model(
|
| 532 |
+
f"Provide a concise NBA verdict: likely winner and Over/Under guidance. Context: {context}",
|
| 533 |
max_length=256
|
| 534 |
)[0]['generated_text']
|
| 535 |
|
| 536 |
predictions.append({
|
| 537 |
"match": f"{home} vs {away}",
|
| 538 |
"reason": reasoning,
|
| 539 |
+
"verdict": f"{home} vs {away}: {reasoning[:140]}...",
|
| 540 |
"news_summary": {
|
| 541 |
home: home_sent,
|
| 542 |
away: away_sent
|