Coverage for src/epl/application/service.py: 92%
79 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-05 10:11 +0000
1"""Application service — the core of the hexagonal architecture.
3This layer orchestrates work by delegating to outbound ports. It knows nothing
4about MCP, HTTP, or JSON — those are adapter concerns.
5"""
7from ..domain.models import (
8 AdjustedPointsPerGame,
9 Match,
10 MatchDetails,
11 NewsArticle,
12 OpponentPPG,
13 Player,
14 PulselivePlayer,
15 ResultsByOpponentTier,
16 Standing,
17 StrengthOfSchedule,
18 Team,
19)
20from ..ports.outbound import EPLAPIPort, PulselivePort
21from ._analytics_helpers import (
22 _build_ppg_index,
23 _build_tier_record,
24 _build_tier_specs,
25 _league_average_ppg,
26 _mean,
27 _opponent_ppgs,
28 _played_opponents,
29 _resolve_team,
30 _safe_ratio,
31 _self_record,
32 _tally_tier_results,
33 _validate_team_id,
34 _validate_tier_size,
35)
36from ._helpers import _validate_yyyymmdd
38_DEFAULT_PLAYER_PAGE = 50
39_MAX_PLAYER_PAGE = 500
42class EPLService:
43 """Coordinates Premier League data lookups through the outbound ports.
45 Two driven ports are injected: the ESPN-backed ``repo`` for the normalized
46 league feeds, and the Pulselive-backed ``pulselive`` port for the richer
47 per-player data (Opta IDs, shirt numbers, positional detail, loan flag).
48 """
50 def __init__(self, repo: EPLAPIPort, pulselive: PulselivePort) -> None:
51 self._repo = repo
52 self._pulselive = pulselive
54 async def get_players(
55 self,
56 season_year: int | None = None,
57 limit: int = _DEFAULT_PLAYER_PAGE,
58 ) -> list[PulselivePlayer]:
59 """Return Pulselive-registered players for a season.
61 Args:
62 season_year: Deprecated placeholder — currently ignored. The Pulselive
63 season model does not match on year alone (labels like ``2024/25``
64 span two calendar years); when this is None the service defaults
65 to the most recent completed season. Pass a concrete season id
66 once season lookup is wired.
67 limit: Maximum number of player rows to return. Capped at
68 :data:`_MAX_PLAYER_PAGE` to prevent unbounded upstream fetches.
70 Raises:
71 ValueError: If ``limit`` is not positive.
72 """
73 _ = season_year
74 if limit <= 0:
75 raise ValueError("limit must be positive")
76 page = min(limit, _MAX_PLAYER_PAGE)
77 season_id = await self._pulselive.latest_completed_season_id()
78 return await self._pulselive.get_players(season_id, page)
80 async def get_teams(self) -> list[Team]:
81 """Return all active Premier League teams."""
82 return await self._repo.get_teams()
84 async def get_team(self, team_id: str) -> Team:
85 """Return a single team by its ESPN team ID.
87 Args:
88 team_id: ESPN numeric team ID or team abbreviation.
90 Raises:
91 ValueError: If team_id is empty.
92 EPLNotFoundError: If no team with that ID exists.
93 """
94 if not team_id or not team_id.strip():
95 raise ValueError("team_id must not be empty")
96 return await self._repo.get_team(team_id.strip())
98 async def get_scoreboard(self, date: str | None = None, end_date: str | None = None) -> list[Match]:
99 """Return matches for a date, a date range, or today if date is None.
101 Args:
102 date: Optional date string in YYYYMMDD format (e.g. "20260601").
103 end_date: Optional end of a range in YYYYMMDD format. Requires ``date``
104 to be provided as the start of the range.
106 Raises:
107 ValueError: If either argument is malformed, or if end_date is given
108 without a starting date.
109 """
110 if end_date is not None and date is None:
111 raise ValueError("end_date requires a starting date")
112 if date is not None:
113 date = _validate_yyyymmdd(date.strip(), "date")
114 if end_date is not None:
115 end_date = _validate_yyyymmdd(end_date.strip(), "end_date")
116 return await self._repo.get_scoreboard(date, end_date)
118 async def get_news(self, limit: int = 10) -> list[NewsArticle]:
119 """Return up to ``limit`` recent Premier League news articles.
121 Args:
122 limit: Maximum number of articles to return (must be positive).
124 Raises:
125 ValueError: If limit is not a positive integer.
126 """
127 if limit <= 0:
128 raise ValueError("limit must be positive")
129 return await self._repo.get_news(limit)
131 async def get_roster(self, team_id: str) -> list[Player]:
132 """Return the active roster for a team.
134 Args:
135 team_id: ESPN numeric team ID.
137 Raises:
138 ValueError: If team_id is empty.
139 EPLNotFoundError: If no team with that ID exists.
140 """
141 if not team_id or not team_id.strip():
142 raise ValueError("team_id must not be empty")
143 return await self._repo.get_roster(team_id.strip())
145 async def get_match_details(self, match_id: str) -> MatchDetails:
146 """Return detailed information for a single match.
148 Args:
149 match_id: ESPN numeric event ID.
151 Raises:
152 ValueError: If match_id is empty.
153 EPLNotFoundError: If no match with that ID exists.
154 """
155 if not match_id or not match_id.strip():
156 raise ValueError("match_id must not be empty")
157 return await self._repo.get_match_details(match_id.strip())
159 async def get_team_schedule(self, team_id: str) -> list[Match]:
160 """Return all scheduled and completed matches for a team in the current season.
162 Args:
163 team_id: ESPN numeric team ID.
165 Raises:
166 ValueError: If team_id is empty.
167 EPLNotFoundError: If no team with that ID exists.
168 """
169 if not team_id or not team_id.strip():
170 raise ValueError("team_id must not be empty")
171 return await self._repo.get_team_schedule(team_id.strip())
173 async def get_standings(self) -> list[Standing]:
174 """Return the current Premier League standings ordered by points descending."""
175 return await self._repo.get_standings()
177 async def get_strength_of_schedule(self, team_id: str) -> StrengthOfSchedule:
178 """Return the average current PPG of opponents this team has faced.
180 Walks the team's completed matches and aggregates each opponent's
181 league-table points-per-game (no self-exclusion). Useful for "who has
182 played the tougher schedule so far?" questions.
184 Args:
185 team_id: ESPN numeric team ID.
187 Raises:
188 ValueError: If team_id is empty.
189 EPLNotFoundError: If no team with that ID exists.
190 """
191 team_id = _validate_team_id(team_id)
192 standings = await self._repo.get_standings()
193 schedule = await self._repo.get_team_schedule(team_id)
194 ppg_index = _build_ppg_index(standings)
195 team = _resolve_team(standings, schedule, team_id)
196 opponents = [
197 OpponentPPG(
198 team=opp,
199 matches_played=ppg_index[opp.id].matches_played,
200 points=ppg_index[opp.id].points,
201 points_per_game=ppg_index[opp.id].ppg,
202 )
203 for opp in _played_opponents(schedule, team_id)
204 if opp.id in ppg_index
205 ]
206 return StrengthOfSchedule(
207 team=team,
208 matches_played=len(opponents),
209 opponents=opponents,
210 average_opponent_ppg=_mean([o.points_per_game for o in opponents]),
211 )
213 async def get_results_by_opponent_tier(self, team_id: str, tier_size: int = 5) -> ResultsByOpponentTier:
214 """Return W-L-T splits against current top-tier, middle, and bottom-tier teams.
216 Tiers are derived from the current league standings: the top ``tier_size``,
217 the bottom ``tier_size``, and everyone in between. Draws (no declared winner)
218 count as ties; matches against teams not in the current standings are
219 skipped.
221 Args:
222 team_id: ESPN numeric team ID.
223 tier_size: Number of teams in each of the top and bottom tiers.
224 Defaults to 5. Must be at least 1, and 2*tier_size must not
225 exceed the league size.
227 Raises:
228 ValueError: If team_id is empty or tier_size is invalid.
229 EPLNotFoundError: If no team with that ID exists.
230 """
231 team_id = _validate_team_id(team_id)
232 standings = await self._repo.get_standings()
233 _validate_tier_size(tier_size, len(standings))
234 schedule = await self._repo.get_team_schedule(team_id)
235 rank_by_id = {s.team.id: i + 1 for i, s in enumerate(standings)}
236 team = _resolve_team(standings, schedule, team_id)
237 tier_specs = _build_tier_specs(tier_size, len(standings))
238 tally = _tally_tier_results(schedule, team_id, rank_by_id, tier_specs)
239 tiers = [_build_tier_record(name, low, high, tally) for name, low, high in tier_specs if high >= low]
240 return ResultsByOpponentTier(team=team, tier_size=tier_size, tiers=tiers)
242 async def get_adjusted_points_per_game(self, team_id: str) -> AdjustedPointsPerGame:
243 """Return raw PPG plus a schedule-strength-adjusted PPG.
245 Adjusted PPG = raw_ppg * (avg_opponent_ppg / league_average_ppg). Values
246 above raw_ppg mean the team has earned points against a tougher
247 schedule than league average.
249 Args:
250 team_id: ESPN numeric team ID.
252 Raises:
253 ValueError: If team_id is empty.
254 EPLNotFoundError: If no team with that ID exists.
255 """
256 team_id = _validate_team_id(team_id)
257 standings = await self._repo.get_standings()
258 schedule = await self._repo.get_team_schedule(team_id)
259 ppg_index = _build_ppg_index(standings)
260 team = _resolve_team(standings, schedule, team_id)
261 matches_played, points, raw_ppg = _self_record(ppg_index, team_id)
262 opp_entries = _opponent_ppgs(schedule, team_id, ppg_index)
263 avg_opp_ppg = _mean([e.ppg for e in opp_entries])
264 league_avg = _league_average_ppg(standings)
265 return AdjustedPointsPerGame(
266 team=team,
267 matches_played=matches_played,
268 points=points,
269 raw_ppg=raw_ppg,
270 average_opponent_ppg=avg_opp_ppg,
271 league_average_ppg=league_avg,
272 adjusted_ppg=raw_ppg * _safe_ratio(avg_opp_ppg, league_avg),
273 )