big-proppa / backend /cfb_data.py
AIBRUH's picture
Upload folder using huggingface_hub
1290dbb verified
Raw History Blame Contribute Delete
13.3 kB
"""
CFB data layer — thin wrapper around the CFBD API.
Auth: Bearer token from CFBD_API_KEY env var.
All calls return [] / {} / None on any error so callers degrade gracefully.
Signal inventory (2026):
sp_ratings() → SP+ overall/offense/defense ratings (Bill Connelly model)
advanced_stats() → PPA, success rate, explosiveness, havoc, line yards
elo_ratings() → Elo per team
talent_composite() → Recruiting talent composite
pregame_wp() → Model win probability per game_id
current_week_lines() → Spread, total, moneylines (consensus preferred)
team_recent_games() → Last-N completed game results
"""
from __future__ import annotations
import os
import time
from functools import lru_cache
from typing import Any
import urllib.request
import urllib.parse
import json
_BASE = "https://api.collegefootballdata.com"
_TIMEOUT = 12
# TTL cache for signal endpoints — avoids poisoning lru_cache with empty API failure results
_signal_cache: dict[str, tuple[Any, float]] = {}
_SIGNAL_TTL = 900 # 15 minutes
def _key() -> str | None:
return os.environ.get("CFBD_API_KEY")
def _get(path: str, params: dict | None = None) -> Any:
key = _key()
if not key:
return None
url = f"{_BASE}{path}"
if params:
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"})
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
return json.loads(r.read())
except Exception:
return None
def _get_cached(path: str, params: dict | None = None, ttl: int = _SIGNAL_TTL) -> Any:
"""Like _get() but uses a TTL dict cache — never caches None/empty results."""
cache_key = path + str(sorted((params or {}).items()))
entry = _signal_cache.get(cache_key)
if entry:
data, expires = entry
if time.time() < expires:
return data
result = _get(path, params)
if result: # only cache non-empty, non-None results
_signal_cache[cache_key] = (result, time.time() + ttl)
return result
def available() -> bool:
return bool(_key())
# ---------------------------------------------------------------------------
# Current season / week helpers
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _current_season() -> int:
"""
Latest season with real CFBD game data (team names populated).
Checks the current calendar year first, falls back until it finds data.
"""
import datetime
y = datetime.date.today().year
m = datetime.date.today().month
start = y if m >= 8 else y - 1
for candidate in (start, start - 1, start - 2):
sample = _get("/games", {"year": candidate, "week": 1, "seasonType": "regular"})
if sample and any(g.get("homeTeam") for g in sample):
return candidate
return start # best guess if API is down
@lru_cache(maxsize=1)
def current_week_games() -> list[dict]:
"""Games scheduled for the current (or next upcoming) week — real data only."""
year = _current_season()
raw = _get("/games", {"year": year, "seasonType": "regular"})
if not raw:
return []
# keep only FBS games with team names (guards against placeholder/empty rows)
real = [g for g in raw
if g.get("homeTeam") and g.get("awayTeam")
and g.get("homeClassification") == "fbs"
and g.get("awayClassification") == "fbs"]
# unfinished = not completed yet
unfinished = [g for g in real if not g.get("completed")]
if not unfinished:
# all games completed — show last completed week
completed = [g for g in real if g.get("completed")]
if not completed:
return []
max_week = max(g["week"] for g in completed)
return [g for g in completed if g["week"] == max_week]
min_week = min(g["week"] for g in unfinished)
return [g for g in unfinished if g["week"] == min_week]
@lru_cache(maxsize=1)
def current_week() -> int | None:
games = current_week_games()
return games[0]["week"] if games else None
# ---------------------------------------------------------------------------
# Rankings
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def ap_poll() -> dict[str, int]:
"""
Returns {team_name: rank} for the current AP top 25.
Team names match CFBD's school names (e.g. "Ohio State", "Georgia").
"""
year = _current_season()
week = current_week()
if week is None:
return {}
# try current week, fall back to week-1 if not published yet
for w in (week, max(1, week - 1)):
data = _get("/rankings", {"year": year, "week": w, "seasonType": "regular"})
if not data:
continue
for poll_week in data:
for poll in (poll_week.get("polls") or []):
if poll.get("poll") == "AP Top 25":
return {r["school"]: r["rank"] for r in poll.get("ranks", [])}
return {}
# ---------------------------------------------------------------------------
# Team game history (for performance comparison)
# ---------------------------------------------------------------------------
@lru_cache(maxsize=64)
def team_recent_games(team: str, n: int = 3) -> list[dict]:
"""
Last n completed games for a team this season (or last season if early).
Returns list of dicts with keys: week, points_for, points_against, win, opponent.
"""
year = _current_season()
week = current_week()
raw = _get("/games", {"year": year, "seasonType": "regular", "team": team})
if not raw:
# try prior season
raw = _get("/games", {"year": year - 1, "seasonType": "regular", "team": team}) or []
completed = []
for g in raw:
# only completed FBS games strictly before the current week
if not g.get("completed"):
continue
if g.get("homeClassification") != "fbs" or g.get("awayClassification") != "fbs":
continue
if week and g.get("week", 0) >= week:
continue
is_home = g.get("homeTeam") == team
pts_for = g.get("homePoints") if is_home else g.get("awayPoints")
pts_vs = g.get("awayPoints") if is_home else g.get("homePoints")
if pts_for is None or pts_vs is None:
continue
completed.append({
"week": g["week"],
"points_for": int(pts_for),
"points_against": int(pts_vs),
"win": int(pts_for) > int(pts_vs),
"opponent": g.get("awayTeam") if is_home else g.get("homeTeam"),
})
# sort descending (most recent first), take n
completed.sort(key=lambda g: g["week"], reverse=True)
return completed[:n]
# ---------------------------------------------------------------------------
# Betting lines
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def current_week_lines() -> dict[int, dict]:
"""
Returns {game_id: {spread, over_under, home_moneyline, away_moneyline, provider}}
for this week's games. Uses the first provider that has all fields.
"""
year = _current_season()
week = current_week()
if week is None:
return {}
raw = _get("/lines", {"year": year, "week": week, "seasonType": "regular"})
if not raw:
return {}
out: dict[int, dict] = {}
preferred = ("consensus", "DraftKings", "ESPN Bet", "FanDuel")
for game in raw:
gid = game.get("id")
lines_list = game.get("lines") or []
if not lines_list:
continue
# pick preferred provider, else first
line = None
for pref in preferred:
line = next((l for l in lines_list if l.get("provider") == pref), None)
if line:
break
if not line:
line = lines_list[0]
try:
out[gid] = {
"spread": float(line.get("spread") or 0),
"over_under": float(line.get("overUnder") or 0),
"home_moneyline": line.get("homeMoneyline"),
"away_moneyline": line.get("awayMoneyline"),
"provider": line.get("provider", "unknown"),
"formatted_spread": line.get("formattedSpread", ""),
}
except (TypeError, ValueError):
continue
return out
# ---------------------------------------------------------------------------
# SP+ ratings — Bill Connelly's most predictive CFB model
# ---------------------------------------------------------------------------
def sp_ratings() -> dict[str, dict]:
"""
{team: {overall, offense_rank, offense_rating, defense_rank, defense_rating}}
SP+ overall: positive = better, scale roughly -30 to +35.
"""
year = _current_season()
raw = _get_cached("/ratings/sp", {"year": year}) or []
out: dict[str, dict] = {}
for r in raw:
team = r.get("team", "")
if not team:
continue
off = r.get("offense") or {}
dfe = r.get("defense") or {}
out[team] = {
"overall": r.get("rating"),
"overall_rank": r.get("ranking"),
"offense_rank": off.get("ranking"),
"offense_rating": off.get("rating"),
"defense_rank": dfe.get("ranking"),
"defense_rating": dfe.get("rating"),
"sos": r.get("sos"),
}
return out
# ---------------------------------------------------------------------------
# Advanced stats — PPA, success rate, explosiveness, havoc
# ---------------------------------------------------------------------------
def advanced_stats() -> dict[str, dict]:
"""
{team: {off_ppa, def_ppa, off_success, def_success,
off_explosiveness, def_explosiveness,
off_havoc, def_havoc, off_stuff_rate, def_stuff_rate,
off_line_yards, off_open_field_yards}}
PPA > 0 on offense = efficient; PPA < 0 on defense = holding opponents.
"""
year = _current_season()
raw = _get_cached("/stats/season/advanced", {"year": year, "excludeGarbageTime": "true"}) or []
out: dict[str, dict] = {}
for r in raw:
team = r.get("team", "")
if not team:
continue
off = r.get("offense") or {}
dfe = r.get("defense") or {}
out[team] = {
"off_ppa": off.get("ppa"),
"off_success": off.get("successRate"),
"off_explosiveness": off.get("explosiveness"),
"off_havoc": (off.get("havoc") or {}).get("total"),
"off_stuff_rate": off.get("stuffRate"),
"off_line_yards": off.get("lineYards"),
"off_open_field_yards": off.get("openFieldYards"),
"def_ppa": dfe.get("ppa"),
"def_success": dfe.get("successRate"),
"def_explosiveness": dfe.get("explosiveness"),
"def_havoc": (dfe.get("havoc") or {}).get("total"),
"def_stuff_rate": dfe.get("stuffRate"),
}
return out
# ---------------------------------------------------------------------------
# Elo ratings
# ---------------------------------------------------------------------------
def elo_ratings() -> dict[str, float]:
"""{team: elo} — 1500 = average, higher = better."""
year = _current_season()
week = current_week()
params: dict = {"year": year}
if week:
params["week"] = week
raw = _get_cached("/ratings/elo", params) or []
return {r["team"]: r["elo"] for r in raw if r.get("team") and r.get("elo")}
# ---------------------------------------------------------------------------
# Talent composite
# ---------------------------------------------------------------------------
def talent_composite() -> dict[str, float]:
"""{team: talent_score} — composite recruiting talent (higher = better)."""
year = _current_season()
raw = _get_cached("/talent", {"year": year}) or []
return {r["team"]: r["talent"] for r in raw if r.get("team") and r.get("talent")}
# ---------------------------------------------------------------------------
# Pregame win probability (model-driven, per game_id)
# ---------------------------------------------------------------------------
def pregame_wp() -> dict[int, dict]:
"""
{game_id: {home_team, away_team, home_wp, spread}}
Model win probability from CFBD's SP+-calibrated model.
"""
year = _current_season()
week = current_week()
if week is None:
return {}
raw = _get_cached("/metrics/wp/pregame", {"year": year, "week": week, "seasonType": "regular"}) or []
out: dict[int, dict] = {}
for r in raw:
gid = r.get("gameId")
if gid:
out[gid] = {
"home_team": r.get("homeTeam", ""),
"away_team": r.get("awayTeam", ""),
"home_wp": r.get("homeWinProbability"),
"spread": r.get("spread"),
}
return out