"""
Livescore API client for fetching soccer matches and statistics.
Provides dataclasses for teams, odds, fixtures, and historical matches, plus
``LivescoreClient`` for authenticated requests with optional file-based caching.
"""
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any
import requests
from slickbet.cache import ApiCache
[docs]
@dataclass
class Team:
"""Represents a soccer team."""
id: str
name: str
country: str | None = None
[docs]
@dataclass
class Odds:
"""Represents betting odds for a match."""
home_win: float | None = None # "1" in API
draw: float | None = None # "X" in API
away_win: float | None = None # "2" in API
@property
def has_odds(self) -> bool:
"""Check if any odds are available."""
return any(o is not None for o in [self.home_win, self.draw, self.away_win])
[docs]
def implied_probability(self, outcome: str) -> float | None:
"""
Calculate implied probability from odds.
Parameters
----------
outcome : str
"home", "draw", or "away"
Returns
-------
float or None
Implied probability (0-1) or None if odds not available
"""
odds_map = {"home": self.home_win, "draw": self.draw, "away": self.away_win}
odds = odds_map.get(outcome)
if odds and odds > 0:
return 1 / odds
return None
[docs]
@dataclass
class MatchScores:
"""Match scores at different periods."""
final: str | None = None # e.g., "2 - 1"
half_time: str | None = None # e.g., "1 - 0"
full_time: str | None = None # e.g., "2 - 1"
extra_time: str | None = None # e.g., "3 - 2"
penalties: str | None = None # e.g., "5 - 4"
[docs]
def parse_score(self, score_str: str | None) -> tuple[int, int] | None:
"""
Parse score string to (home, away) tuple.
Parameters
----------
score_str : str or None
Score like ``"2 - 1"``.
Returns
-------
tuple[int, int] or None
``(home, away)`` goals, or None if unparseable.
"""
if not score_str or not isinstance(score_str, str):
return None
try:
parts = score_str.replace(" ", "").split("-")
if len(parts) == 2:
return (int(parts[0]), int(parts[1]))
except (ValueError, AttributeError):
pass
return None
@property
def final_tuple(self) -> tuple[int, int] | None:
"""Get final score as (home, away) tuple."""
return self.parse_score(self.final or self.full_time)
[docs]
@dataclass
class MatchOutcome:
"""
Match outcomes (betting results) at different periods.
Values are "1" (home win), "X" (draw), or "2" (away win).
"""
half_time: str | None = None
full_time: str | None = None
extra_time: str | None = None
penalties: str | None = None
[docs]
@dataclass
class HistoricalMatch:
"""
A completed historical match with scores and outcomes.
Per https://live-score-api.com/documentation/reference/15/football_data_history_matches
"""
id: str
fixture_id: str | None
home_team: Team
away_team: Team
competition: str
competition_id: str
country: str | None
date: datetime
status: str
scores: MatchScores
outcomes: MatchOutcome
pre_odds: Odds | None = None
location: str | None = None
round: str | None = None
@property
def home_score(self) -> int | None:
"""Get home team final score."""
result = self.scores.final_tuple
return result[0] if result else None
@property
def away_score(self) -> int | None:
"""Get away team final score."""
result = self.scores.final_tuple
return result[1] if result else None
@property
def result(self) -> str | None:
"""Get match result: 'H' (home win), 'A' (away win), 'D' (draw)."""
outcome = self.outcomes.full_time
if outcome == "1":
return "H"
elif outcome == "2":
return "A"
elif outcome == "X":
return "D"
return None
[docs]
@dataclass
class MatchStatistics:
"""
Match statistics from the API.
Per https://live-score-api.com/documentation/reference/23/match-statistics
Statistics are only available for live/completed matches, not fixtures.
Values are "home:away" format (e.g., "48:52" for possession).
"""
possession: tuple[int, int] | None = None # Ball possession %
shots_on_target: tuple[int, int] | None = None
shots_off_target: tuple[int, int] | None = None
corners: tuple[int, int] | None = None
fouls: tuple[int, int] | None = None
yellow_cards: tuple[int, int] | None = None
red_cards: tuple[int, int] | None = None
offsides: tuple[int, int] | None = None
saves: tuple[int, int] | None = None
attacks: tuple[int, int] | None = None
dangerous_attacks: tuple[int, int] | None = None
expected_goals: tuple[float, float] | None = None # xG (Expected Goals)
[docs]
def has_data(self) -> bool:
"""
Check if this MatchStatistics object has any meaningful data.
Returns
-------
bool
True if at least one statistic field has data
"""
return any(
[
self.possession is not None,
self.shots_on_target is not None,
self.shots_off_target is not None,
self.corners is not None,
self.attacks is not None,
self.dangerous_attacks is not None,
self.expected_goals is not None,
]
)
[docs]
@staticmethod
def parse_stat(value: str | None) -> tuple[int, int] | None:
"""
Parse ``home:away`` format to an integer tuple.
Parameters
----------
value : str or None
Stat string like ``"48:52"``.
Returns
-------
tuple[int, int] or None
``(home, away)`` values, or None if unparseable.
"""
if not value or not isinstance(value, str):
return None
try:
parts = value.split(":")
if len(parts) == 2:
return (int(parts[0]), int(parts[1]))
except (ValueError, AttributeError):
pass
return None
[docs]
@staticmethod
def parse_float_stat(value: str | None) -> tuple[float, float] | None:
"""
Parse ``home:away`` format to a float tuple (for xG).
Parameters
----------
value : str or None
Stat string like ``"1.2:0.8"``.
Returns
-------
tuple[float, float] or None
``(home, away)`` values, or None if unparseable.
"""
if not value or not isinstance(value, str):
return None
try:
parts = value.split(":")
if len(parts) == 2:
return (float(parts[0]), float(parts[1]))
except (ValueError, AttributeError):
pass
return None
[docs]
@staticmethod
def calculate_xg_estimate(
shots_on_target: int,
shots_off_target: int,
dangerous_attacks: int,
attacks: int,
possession: float | None = None,
) -> float:
"""
Calculate estimated xG (Expected Goals) using a model-based approach.
Based on Expected Goals principles from:
https://en.wikipedia.org/wiki/Expected_goals
The model assigns probabilities to goal-scoring opportunities based on:
1. Shot quality (on target vs off target)
2. Chance quality (dangerous attacks indicate high-probability chances)
3. Contextual factors (possession, total attacks)
Model inputs (as per Wikipedia):
- Shot location/quality: inferred from shots on target (higher xG)
- Phase of play: inferred from dangerous attacks (high-quality chances)
- Contextual factors: possession and attack volume
Parameters
----------
shots_on_target : int
Number of shots on target (higher probability of goal)
shots_off_target : int
Number of shots off target (lower probability)
dangerous_attacks : int
Number of dangerous attacks (high-quality chances)
attacks : int
Total number of attacks (context factor)
possession : float or None, optional
Possession percentage (0-100), used to adjust shot quality
Returns
-------
float
Estimated xG value (sum of shot probabilities)
"""
# Model-based xG calculation
# Each shot is assigned a probability based on its characteristics
# 1. Shots on target: Higher probability (0.15-0.35 range)
# Based on historical data: shots on target convert at ~25-30%
# We use 0.25 as base, adjusted by possession quality
base_sot_xg = 0.25
if possession is not None:
# Higher possession = better shot quality (more time in good areas)
possession_factor = 1.0 + (possession - 50) / 200.0 # 0.75-1.25 range
sot_xg_per_shot = base_sot_xg * possession_factor
else:
sot_xg_per_shot = base_sot_xg
xg_from_shots_on_target = shots_on_target * min(sot_xg_per_shot, 0.35)
# 2. Shots off target: Lower probability (0.02-0.08 range)
# These represent attempts from less favorable positions
base_sot_off_xg = 0.04
if possession is not None:
possession_factor = 1.0 + (possession - 50) / 300.0
sot_off_xg_per_shot = base_sot_off_xg * possession_factor
else:
sot_off_xg_per_shot = base_sot_off_xg
xg_from_shots_off_target = shots_off_target * min(sot_off_xg_per_shot, 0.08)
# 3. Dangerous attacks: High-quality chances that may not result in shots
# These represent opportunities in the penalty area (high xG locations)
# Per Wikipedia: dangerous attacks indicate shots from favorable positions
# We estimate: ~40% of dangerous attacks result in shots
# Average xG per dangerous attack: 0.10-0.15 (penalty area chances)
dangerous_attacks_with_shots = dangerous_attacks * 0.4
xg_from_dangerous_attacks = dangerous_attacks_with_shots * 0.12
# 4. Remaining dangerous attacks (60%) that didn't result in shots
# These still represent high-probability chances (assists, blocked shots)
dangerous_attacks_no_shots = dangerous_attacks * 0.6
xg_from_dangerous_no_shots = dangerous_attacks_no_shots * 0.05
# 5. Other attacks: Lower quality chances
# These represent build-up play and pressure
# Only a small fraction result in high-probability chances
other_attacks = max(0, attacks - dangerous_attacks)
xg_from_other_attacks = other_attacks * 0.002
# Sum all probabilities (as per Expected Goals definition)
total_xg = (
xg_from_shots_on_target
+ xg_from_shots_off_target
+ xg_from_dangerous_attacks
+ xg_from_dangerous_no_shots
+ xg_from_other_attacks
)
# Cap at reasonable maximum
# Top teams rarely exceed 3.5-4.0 xG per match
return min(total_xg, 4.0)
[docs]
@dataclass
class Match:
"""Represents a soccer match."""
id: str
home_team: Team
away_team: Team
competition: str
competition_id: str
country: str
kickoff_time: datetime
status: str
home_score: int | None = None
away_score: int | None = None
# Pre-match odds from bookmakers
pre_odds: Odds | None = None
# Statistics for betting analysis
home_form: str | None = None # e.g., "WWDLW"
away_form: str | None = None
home_position: int | None = None
away_position: int | None = None
head_to_head: dict | None = None
# NEW: Detailed performance statistics
home_performance: TeamPerformanceStats | None = None
away_performance: TeamPerformanceStats | None = None
# Live/post-match statistics (only available after match starts)
statistics: MatchStatistics | None = None
[docs]
class LivescoreAPIError(Exception):
"""Custom exception for Livescore API errors."""
pass
[docs]
class APIEndpoint(str, Enum):
"""Enumeration of Livescore API endpoints."""
FIXTURES_MATCHES = "fixtures/matches.json"
FIXTURES_LIST = "fixtures/list.json"
COMPETITIONS_LIST = "competitions/list.json"
COUNTRIES_LIST = "countries/list.json"
LEAGUES_TABLE = "leagues/table.json"
TEAMS_MATCHES = "teams/matches.json"
TEAMS_HEAD2HEAD = "teams/head2head.json"
MATCHES_STATS = "matches/stats.json"
MATCHES_HISTORY = "matches/history.json"
[docs]
class LivescoreClient:
"""
Client for interacting with the Livescore API.
Uses LIVESCORE_API_KEY and LIVESCORE_API_SECRET environment variables.
"""
BASE_URL = "https://livescore-api.com/api-client"
def __init__(
self,
api_key: str | None = None,
api_secret: str | None = None,
cache_dir: str | Path | None = None,
cache_only: bool = False,
):
"""
Initialize the Livescore API client.
Parameters
----------
api_key : str or None, optional
API key (defaults to LIVESCORE_API_KEY env var)
api_secret : str or None, optional
API secret (defaults to LIVESCORE_API_SECRET env var)
cache_dir : str or Path or None, optional
If set, cache API responses under this directory for fast
backtesting and hyperparameter optimization.
cache_only : bool, optional
If True and cache_dir is set, only read from cache; do not call
the API. Use for offline backtest runs. Raises on cache miss.
"""
self.api_key = api_key or os.environ.get("LIVESCORE_API_KEY")
self.api_secret = api_secret or os.environ.get("LIVESCORE_API_SECRET")
if not cache_only and (not self.api_key or not self.api_secret):
raise LivescoreAPIError(
"API credentials not found. Set LIVESCORE_API_KEY and "
"LIVESCORE_API_SECRET environment variables."
)
self.cache: ApiCache | None = ApiCache(Path(cache_dir)) if cache_dir else None
self.cache_only = bool(cache_only) and self.cache is not None
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json", "User-Agent": "SlickBet/1.0"})
def _make_request(self, endpoint: APIEndpoint | str, params: dict | None = None) -> dict:
"""
Make an authenticated request to the API.
If cache_dir is set, responses are read from / written to the cache.
With cache_only=True, only cache is used (no network calls).
Parameters
----------
endpoint : APIEndpoint or str
API endpoint (APIEndpoint enum or string)
params : dict or None, optional
Additional query parameters
Returns
-------
dict
JSON response as dictionary
"""
endpoint_str = endpoint.value if isinstance(endpoint, APIEndpoint) else endpoint
# Cache lookup (key uses only endpoint + params, not credentials)
if self.cache is not None:
cached = self.cache.get(endpoint_str, params)
if cached is not None:
return cached
if self.cache_only:
raise LivescoreAPIError(
f"Cache miss for {endpoint_str} (cache_only=True). "
"Run once without cache_only to populate the cache."
)
url = f"{self.BASE_URL}/{endpoint_str}"
request_params = {
"key": self.api_key,
"secret": self.api_secret,
}
if params:
request_params.update(params)
try:
response = self.session.get(url, params=request_params, timeout=30)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and not data.get("success", True):
error_obj = data.get("error", {})
if isinstance(error_obj, dict):
error_msg = error_obj.get("message", "Unknown API error")
elif isinstance(error_obj, str):
error_msg = error_obj
else:
error_msg = str(error_obj)
raise LivescoreAPIError(f"API error: {error_msg}")
if self.cache is not None:
self.cache.set(endpoint_str, params, data)
return dict(data)
except requests.exceptions.RequestException as e:
raise LivescoreAPIError(f"Request failed: {e}") from e
def _safe_get_nested(self, data: dict, *keys: str, default: Any = None) -> Any:
"""
Safely get nested values from a dictionary.
Handles cases where intermediate values are not dicts.
Parameters
----------
data : dict
Root dictionary.
*keys : str
Nested keys to traverse.
default : Any, optional
Value returned on missing key or non-dict intermediate.
Returns
-------
Any
Nested value or ``default``.
"""
result = data
for key in keys:
if isinstance(result, dict):
result = result.get(key, default)
else:
return default
return result
[docs]
def get_fixtures_by_date(
self,
date: datetime,
competition_id: str | None = None,
fetch_all_pages: bool = True,
) -> list[Match]:
"""
Get all fixtures for a specific date.
Parameters
----------
date : datetime
The date to fetch fixtures for
competition_id : str or None, optional
Optional competition ID to filter by
fetch_all_pages : bool, optional
Whether to fetch all pages (default: True)
Returns
-------
list[Match]
List of Match objects
"""
date_str = date.strftime("%Y-%m-%d")
all_matches = []
page = 1
max_pages = 10 # Safety limit
while page <= max_pages:
params = {"date": date_str, "page": page}
if competition_id:
params["competition_id"] = competition_id
data = self._make_request(APIEndpoint.FIXTURES_MATCHES, params=params)
fixtures = self._safe_get_nested(data, "data", "fixtures", default=[])
# Handle case where fixtures might be at different path
if not fixtures:
fixtures = self._safe_get_nested(data, "data", default=[])
if not isinstance(fixtures, list):
fixtures = []
if not fixtures:
break # No more fixtures
for fixture in fixtures:
if not isinstance(fixture, dict):
continue
try:
match = self._parse_fixture(fixture)
all_matches.append(match)
except (KeyError, ValueError, TypeError):
# Skip malformed fixtures
continue
# Check if there's a next page
next_page = self._safe_get_nested(data, "data", "next_page", default=False)
if not next_page or not fetch_all_pages:
break
page += 1
return all_matches
[docs]
def get_tomorrow_fixtures(self) -> list[Match]:
"""
Get all fixtures scheduled for tomorrow.
Returns
-------
list[Match]
List of Match objects for tomorrow's games
"""
tomorrow = datetime.now() + timedelta(days=1)
return self.get_fixtures_by_date(tomorrow)
[docs]
def get_fixtures_list(
self,
date: datetime | None = None,
competition_ids: list[str] | None = None,
country_id: str | None = None,
fetch_all_pages: bool = True,
) -> list[Match]:
"""
Get fixtures using the list endpoint with competition_id and country_id filtering.
This endpoint is more efficient for filtering by competition/country as it
provides these fields directly, avoiding the need for name-based heuristics.
Parameters
----------
date : datetime or None, optional
The date to fetch fixtures for (defaults to tomorrow)
competition_ids : list[str] or None, optional
List of competition IDs to filter by
country_id : str or None, optional
Country ID to filter by
fetch_all_pages : bool, optional
Whether to fetch all pages (default: True)
Returns
-------
list[Match]
List of Match objects
"""
if date is None:
date = datetime.now() + timedelta(days=1)
date_str = date.strftime("%Y-%m-%d")
all_matches = []
page = 1
max_pages = 10 # Safety limit
while page <= max_pages:
params: dict[str, Any] = {"date": date_str, "page": page}
if competition_ids:
# API accepts comma-separated competition IDs
params["competition_id"] = ",".join(competition_ids)
if country_id:
params["country_id"] = country_id
data = self._make_request(APIEndpoint.FIXTURES_LIST, params=params)
fixtures = self._safe_get_nested(data, "data", "fixtures", default=[])
# Handle case where fixtures might be at different path
if not fixtures:
fixtures = self._safe_get_nested(data, "data", default=[])
if not isinstance(fixtures, list):
fixtures = []
if not fixtures:
break # No more fixtures
for fixture in fixtures:
if not isinstance(fixture, dict):
continue
try:
match = self._parse_fixture(fixture)
all_matches.append(match)
except (KeyError, ValueError, TypeError):
# Skip malformed fixtures
continue
# Check if there's a next page
next_page = self._safe_get_nested(data, "data", "next_page", default=False)
if not next_page or not fetch_all_pages:
break
page += 1
return all_matches
[docs]
def get_competitions(self, country_id: str | None = None) -> list[dict[str, Any]]:
"""
Get list of available competitions.
Parameters
----------
country_id : str or None, optional
Optional country ID to filter competitions
Returns
-------
list[dict[str, Any]]
List of competition dictionaries with id, name, country
"""
params: dict[str, Any] = {}
if country_id:
params["country_id"] = country_id
data = self._make_request(APIEndpoint.COMPETITIONS_LIST, params=params)
competitions: list[dict[str, Any]] = []
comp_list = self._safe_get_nested(data, "data", "competition", default=[])
if not isinstance(comp_list, list):
return competitions
for comp in comp_list:
if not isinstance(comp, dict):
continue
competitions.append(
{
"id": str(comp.get("id", "")),
"name": comp.get("name", "Unknown"),
"country_id": str(comp.get("country_id", "")),
"country_name": comp.get("country_name", ""),
}
)
return competitions
[docs]
def get_countries(self) -> list[dict[str, Any]]:
"""
Get list of available countries.
Returns
-------
list[dict[str, Any]]
List of country dictionaries with id and name
"""
data = self._make_request(APIEndpoint.COUNTRIES_LIST)
countries: list[dict[str, Any]] = []
country_list = self._safe_get_nested(data, "data", "country", default=[])
if not isinstance(country_list, list):
return countries
for country in country_list:
if not isinstance(country, dict):
continue
countries.append(
{
"id": str(country.get("id", "")),
"name": country.get("name", "Unknown"),
}
)
return countries
[docs]
def get_team_standings(self, competition_id: str) -> dict[str, int]:
"""
Get team standings/positions for a competition.
Parameters
----------
competition_id : str
The competition/league ID
Returns
-------
dict[str, int]
Dictionary mapping team ID to position
"""
data = self._make_request(
APIEndpoint.LEAGUES_TABLE, params={"competition_id": competition_id}
)
standings: dict[str, int] = {}
table = self._safe_get_nested(data, "data", "table", default=[])
if not isinstance(table, list):
return standings
for entry in table:
if not isinstance(entry, dict):
continue
team_id = str(entry.get("team_id", ""))
position = entry.get("rank", 0)
# Ensure position is an integer
try:
position = int(position) if position else 0
except (ValueError, TypeError):
position = 0
if team_id:
standings[team_id] = position
return standings
[docs]
def get_head_to_head(self, home_team_id: str, away_team_id: str, limit: int = 5) -> dict:
"""
Get head-to-head statistics between two teams.
Parameters
----------
home_team_id : str
Home team ID
away_team_id : str
Away team ID
limit : int, optional
Number of recent H2H matches to consider
Returns
-------
dict
Dictionary with H2H statistics
"""
data = self._make_request(
APIEndpoint.TEAMS_HEAD2HEAD,
params={"team1_id": home_team_id, "team2_id": away_team_id},
)
h2h_data = self._safe_get_nested(data, "data", default={})
if not isinstance(h2h_data, dict):
h2h_data = {}
matches = h2h_data.get("matches", [])
if not isinstance(matches, list):
matches = []
home_wins = 0
away_wins = 0
draws = 0
for match in matches[:limit]:
if not isinstance(match, dict):
continue
try:
home_score = int(match.get("home_score", 0) or 0)
away_score = int(match.get("away_score", 0) or 0)
except (ValueError, TypeError):
continue
match_home_id = str(match.get("home_id", ""))
if home_score > away_score:
if match_home_id == home_team_id:
home_wins += 1
else:
away_wins += 1
elif home_score < away_score:
if match_home_id == home_team_id:
away_wins += 1
else:
home_wins += 1
else:
draws += 1
total = home_wins + away_wins + draws
return {
"matches_played": total,
"home_wins": home_wins,
"away_wins": away_wins,
"draws": draws,
"home_win_rate": home_wins / total if total > 0 else 0.0,
"away_win_rate": away_wins / total if total > 0 else 0.0,
}
[docs]
def get_match_statistics(self, match_id: str) -> MatchStatistics:
"""
Get statistics for a live or completed match.
Per https://live-score-api.com/documentation/reference/23/match-statistics
Statistics are only available after the match has started.
Parameters
----------
match_id : str
The match ID
Returns
-------
MatchStatistics
MatchStatistics object with available stats
"""
# Correct endpoint: matches/stats.json
# Per: https://livescore-api.com/api-client/matches/stats.json?match_id=172252
data = self._make_request(APIEndpoint.MATCHES_STATS, params={"match_id": match_id})
stats_data = self._safe_get_nested(data, "data", default={})
if not isinstance(stats_data, dict):
stats_data = {}
parse = MatchStatistics.parse_stat
parse_float = MatchStatistics.parse_float_stat
# Try to get xG from API first
api_xg = parse_float(stats_data.get("expected_goals") or stats_data.get("xg"))
# If API doesn't provide xG, calculate estimate from available stats
# Using model-based approach per Wikipedia Expected Goals principles
calculated_xg = None
if api_xg is None:
shots_on_target = parse(stats_data.get("shots_on_target"))
shots_off_target = parse(stats_data.get("shots_off_target"))
dangerous_attacks = parse(stats_data.get("dangerous_attacks"))
attacks = parse(stats_data.get("attacks"))
possession = parse(stats_data.get("possesion")) # API typo
if (
shots_on_target is not None
or shots_off_target is not None
or dangerous_attacks is not None
or attacks is not None
):
# Calculate xG for both teams
home_sot = shots_on_target[0] if shots_on_target else 0
away_sot = shots_on_target[1] if shots_on_target else 0
home_sot_off = shots_off_target[0] if shots_off_target else 0
away_sot_off = shots_off_target[1] if shots_off_target else 0
home_da = dangerous_attacks[0] if dangerous_attacks else 0
away_da = dangerous_attacks[1] if dangerous_attacks else 0
home_att = attacks[0] if attacks else 0
away_att = attacks[1] if attacks else 0
home_poss = float(possession[0]) if possession else None
away_poss = float(possession[1]) if possession else None
home_xg = MatchStatistics.calculate_xg_estimate(
home_sot, home_sot_off, home_da, home_att, home_poss
)
away_xg = MatchStatistics.calculate_xg_estimate(
away_sot, away_sot_off, away_da, away_att, away_poss
)
calculated_xg = (home_xg, away_xg)
return MatchStatistics(
possession=parse(stats_data.get("possesion")), # API typo: "possesion"
shots_on_target=parse(stats_data.get("shots_on_target")),
shots_off_target=parse(stats_data.get("shots_off_target")),
corners=parse(stats_data.get("corners")),
fouls=parse(stats_data.get("fauls")), # API typo: "fauls"
yellow_cards=parse(stats_data.get("yellow_cards")),
red_cards=parse(stats_data.get("red_cards")),
offsides=parse(stats_data.get("offsides")),
saves=parse(stats_data.get("saves")),
attacks=parse(stats_data.get("attacks")),
dangerous_attacks=parse(stats_data.get("dangerous_attacks")),
expected_goals=api_xg or calculated_xg, # Use API xG if available, else calculated
)
[docs]
def get_history(
self,
competition_id: str | None = None,
team_id: str | None = None,
from_date: datetime | None = None,
to_date: datetime | None = None,
page: int = 1,
) -> list[HistoricalMatch]:
"""
Get historical match data.
Per https://live-score-api.com/documentation/reference/15/football_data_history_matches
Parameters
----------
competition_id : str or None, optional
Filter by competition (comma-separated for multiple)
team_id : str or None, optional
Filter by team (comma-separated for multiple)
from_date : datetime or None, optional
Get matches from this date onwards
to_date : datetime or None, optional
Get matches until this date
page : int, optional
Page number for pagination
Returns
-------
list[HistoricalMatch]
List of HistoricalMatch objects
"""
params: dict[str, Any] = {"page": page}
if competition_id:
params["competition_id"] = competition_id
if team_id:
params["team_id"] = team_id
if from_date:
params["from"] = from_date.strftime("%Y-%m-%d")
if to_date:
params["to"] = to_date.strftime("%Y-%m-%d")
data = self._make_request(APIEndpoint.MATCHES_HISTORY, params=params)
matches: list[HistoricalMatch] = []
match_list = self._safe_get_nested(data, "data", "match", default=[])
if not isinstance(match_list, list):
return matches
for match_data in match_list:
if not isinstance(match_data, dict):
continue
try:
match = self._parse_historical_match(match_data)
matches.append(match)
except (KeyError, ValueError, TypeError):
continue
return matches
def _parse_historical_match(self, data: dict) -> HistoricalMatch:
"""
Parse a historical match from the API response.
Parameters
----------
data : dict
Raw match object from the history endpoint.
Returns
-------
HistoricalMatch
Parsed historical match.
"""
# Extract nested objects with type safety
home_data = data.get("home") or {}
if not isinstance(home_data, dict):
home_data = {}
away_data = data.get("away") or {}
if not isinstance(away_data, dict):
away_data = {}
country_data = data.get("country") or {}
if not isinstance(country_data, dict):
country_data = {}
competition_data = data.get("competition") or {}
if not isinstance(competition_data, dict):
competition_data = {}
scores_data = data.get("scores") or {}
if not isinstance(scores_data, dict):
scores_data = {}
outcomes_data = data.get("outcomes") or {}
if not isinstance(outcomes_data, dict):
outcomes_data = {}
odds_data = data.get("odds") or {}
if not isinstance(odds_data, dict):
odds_data = {}
# Parse date
date_str = data.get("date", "")
try:
match_date = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
match_date = datetime.now()
# Country name (can be None for international competitions)
country_name = None
if isinstance(country_data, dict) and country_data:
country_name = country_data.get("name")
# Parse pre-match odds
pre_odds_data = odds_data.get("pre", {}) if isinstance(odds_data, dict) else {}
pre_odds = None
if isinstance(pre_odds_data, dict) and pre_odds_data:
pre_odds = Odds(
home_win=pre_odds_data.get("1"),
draw=pre_odds_data.get("X"),
away_win=pre_odds_data.get("2"),
)
if not pre_odds.has_odds:
pre_odds = None
return HistoricalMatch(
id=str(data.get("id", "")),
fixture_id=str(data.get("fixture_id", "")) or None,
home_team=Team(
id=str(home_data.get("id", "")),
name=home_data.get("name", "Unknown"),
country=country_name,
),
away_team=Team(
id=str(away_data.get("id", "")),
name=away_data.get("name", "Unknown"),
country=country_name,
),
competition=competition_data.get("name", "Unknown"),
competition_id=str(competition_data.get("id", "")),
country=country_name,
date=match_date,
status=data.get("status", "FINISHED"),
scores=MatchScores(
final=scores_data.get("score"),
half_time=scores_data.get("ht_score"),
full_time=scores_data.get("ft_score"),
extra_time=scores_data.get("et_score") or None,
penalties=scores_data.get("ps_score") or None,
),
outcomes=MatchOutcome(
half_time=outcomes_data.get("half_time"),
full_time=outcomes_data.get("full_time"),
extra_time=outcomes_data.get("extra_time"),
penalties=outcomes_data.get("penalty_shootout"),
),
pre_odds=pre_odds,
location=data.get("location"),
round=data.get("round"),
)
[docs]
def get_team_history(self, team_id: str, limit: int = 10) -> list[HistoricalMatch]:
"""
Get recent match history for a specific team.
Parameters
----------
team_id : str
The team ID
limit : int, optional
Maximum number of matches to return
Returns
-------
list[HistoricalMatch]
List of HistoricalMatch objects (most recent first)
"""
# Get enough pages to hopefully get limit matches
matches: list[HistoricalMatch] = []
page = 1
max_pages = (limit // 30) + 2 # API returns max 30 per page
while len(matches) < limit and page <= max_pages:
try:
page_matches = self.get_history(team_id=team_id, page=page)
if not page_matches:
break
matches.extend(page_matches)
page += 1
except LivescoreAPIError:
break
return matches[:limit]
def _parse_fixture(self, fixture: dict) -> Match:
"""
Parse a fixture from the API response into a Match object.
Supports nested (``home``/``away`` objects) and flat
(``home_id``/``home_name``) response formats.
Parameters
----------
fixture : dict
Raw fixture object from the fixtures endpoint.
Returns
-------
Match
Parsed match (pre-match odds included when present).
"""
# TODO(amir): refactor this; API may return two formats:
# Format 1 (nested): home/away objects
# Format 2 (flat): home_id/home_name fields
# Try nested format first
home_data = fixture.get("home", {}) or {}
away_data = fixture.get("away", {}) or {}
country_data = fixture.get("country", {}) or {}
competition_data = fixture.get("competition", {}) or {}
# Ensure they are dicts
if not isinstance(home_data, dict):
home_data = {}
if not isinstance(away_data, dict):
away_data = {}
if not isinstance(country_data, dict):
country_data = {}
if not isinstance(competition_data, dict):
competition_data = {}
# Parse kickoff time (format: "HH:MM:SS")
date_str = fixture.get("date", "")
time_str = fixture.get("time", "00:00:00")
kickoff_str = f"{date_str} {time_str}"
try:
# Try HH:MM:SS format first
kickoff_time = datetime.strptime(kickoff_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
# Fallback to HH:MM format
kickoff_time = datetime.strptime(kickoff_str, "%Y-%m-%d %H:%M")
except ValueError:
kickoff_time = datetime.now()
# Get country name - try both nested and flat formats
country_name = (
country_data.get("name")
or fixture.get("country_name")
or fixture.get("location")
or "Unknown"
)
# Get competition name - try both nested and flat formats
competition_name = (
competition_data.get("name")
or fixture.get("competition_name")
or fixture.get("league_name")
or "Unknown"
)
competition_id = str(
competition_data.get("id")
or fixture.get("competition_id")
or fixture.get("league_id")
or ""
)
# Get team data - try both nested and flat formats
home_id = str(home_data.get("id") or fixture.get("home_id") or "")
home_name = (
home_data.get("name")
or fixture.get("home_name")
or fixture.get("home_team")
or "Unknown"
)
away_id = str(away_data.get("id") or fixture.get("away_id") or "")
away_name = (
away_data.get("name")
or fixture.get("away_name")
or fixture.get("away_team")
or "Unknown"
)
home_team = Team(
id=home_id,
name=home_name,
country=country_name,
)
away_team = Team(
id=away_id,
name=away_name,
country=country_name,
)
# Parse pre-match odds - try multiple formats
odds_data = fixture.get("odds", {}) or {}
if not isinstance(odds_data, dict):
odds_data = {}
pre_odds_data = odds_data.get("pre", {}) or {}
if not isinstance(pre_odds_data, dict):
pre_odds_data = {}
# Try to get odds from various locations
home_odds = pre_odds_data.get("1") or fixture.get("odds_home") or fixture.get("home_odds")
draw_odds = pre_odds_data.get("X") or fixture.get("odds_draw") or fixture.get("draw_odds")
away_odds = pre_odds_data.get("2") or fixture.get("odds_away") or fixture.get("away_odds")
pre_odds = Odds(
home_win=home_odds,
draw=draw_odds,
away_win=away_odds,
)
# Get score if available
home_score = fixture.get("home_score") or fixture.get("score_home")
away_score = fixture.get("away_score") or fixture.get("score_away")
return Match(
id=str(fixture.get("id", "")),
home_team=home_team,
away_team=away_team,
competition=competition_name,
competition_id=competition_id,
country=country_name,
kickoff_time=kickoff_time,
status=fixture.get("status", "NS"),
home_score=home_score,
away_score=away_score,
pre_odds=pre_odds if pre_odds.has_odds else None,
)
# TODO(amir): I did this in case we wanna bet on cup matches.
# Mapping from cup competitions to their respective leagues for standings lookup
# This allows us to show league positions even for cup matches
CUP_TO_LEAGUE_MAPPING = {
# UAE
"421": "354", # UAE Presidents Cup -> UAE Pro League
"356": "354", # UAE Cup -> UAE Pro League
"357": "354", # UAE League Cup -> UAE Pro League
# Saudi Arabia
"315": "313", # Saudi King's Cup -> Saudi Pro League
# Qatar
"307": "305", # Qatar Stars League Cup -> Qatar Stars League
# European
"152": "2", # FA Cup -> Premier League
"153": "2", # EFL Cup -> Premier League
"247": "1", # DFB Pokal -> Bundesliga
"150": "3", # Copa del Rey -> La Liga
"149": "4", # Coppa Italia -> Serie A
"148": "5", # Coupe de France -> Ligue 1
}
[docs]
def enrich_match_with_stats(self, match: Match) -> Match:
"""
Enrich a match object with additional statistics.
Parameters
----------
match : Match
The match to enrich
Returns
-------
Match
Match with additional stats (form, standings, H2H, performance)
"""
# Get IDs safely
home_id = ""
away_id = ""
comp_id = ""
if match.home_team and match.home_team.id:
home_id = match.home_team.id
if match.away_team and match.away_team.id:
away_id = match.away_team.id
if match.competition_id:
comp_id = match.competition_id
# Skip if we don't have valid IDs
if not home_id or not away_id:
return match
try:
# Get team forms
match.home_form = self.get_team_form(home_id)
match.away_form = self.get_team_form(away_id)
except Exception:
pass
# Determine which competition to use for standings
# For cup competitions, use the corresponding league
standings_comp_id = self.CUP_TO_LEAGUE_MAPPING.get(comp_id, comp_id)
try:
# Get standings (use league standings for cup competitions)
if standings_comp_id:
standings = self.get_team_standings(standings_comp_id)
if isinstance(standings, dict):
match.home_position = standings.get(home_id)
match.away_position = standings.get(away_id)
except Exception:
pass
try:
# Get head-to-head
match.head_to_head = self.get_head_to_head(home_id, away_id)
except Exception:
pass
try:
# NEW: Get detailed performance stats (goals, clean sheets, etc.)
# This looks back at last 10 matches and calculates averages
match.home_performance = self.get_team_performance_stats(home_id, num_matches=10)
match.away_performance = self.get_team_performance_stats(away_id, num_matches=10)
except Exception:
pass
return match