Coverage for src/mls/application/service.py: 100%
69 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 ResultsByOpponentTier,
15 Standing,
16 StrengthOfSchedule,
17 Team,
18)
19from ..ports.outbound import MLSAPIPort
20from ._analytics_helpers import (
21 _build_ppg_index,
22 _build_tier_record,
23 _build_tier_specs,
24 _league_average_ppg,
25 _mean,
26 _opponent_ppgs,
27 _played_opponents,
28 _resolve_team,
29 _safe_ratio,
30 _self_record,
31 _tally_tier_results,
32 _validate_team_id,
33 _validate_tier_size,
34)
35from ._helpers import _validate_yyyymmdd
38class MLSService:
39 """Coordinates MLS data lookups through the outbound ports.
41 One driven port is injected: the ESPN-backed ``repo`` (read-only league
42 feeds). Additional data sources (SDP/Opta, CMS) can be added as separate
43 ports as they come online.
44 """
46 def __init__(self, repo: MLSAPIPort) -> None:
47 self._repo = repo
49 async def get_teams(self) -> list[Team]:
50 """Return all active MLS teams."""
51 return await self._repo.get_teams()
53 async def get_team(self, team_id: str) -> Team:
54 """Return a single team by its ESPN team ID.
56 Args:
57 team_id: ESPN numeric team ID or team abbreviation.
59 Raises:
60 ValueError: If team_id is empty.
61 MLSNotFoundError: If no team with that ID exists.
62 """
63 if not team_id or not team_id.strip():
64 raise ValueError("team_id must not be empty")
65 return await self._repo.get_team(team_id.strip())
67 async def get_scoreboard(self, date: str | None = None, end_date: str | None = None) -> list[Match]:
68 """Return matches for a date, a date range, or today if date is None.
70 Args:
71 date: Optional date string in YYYYMMDD format (e.g. "20260601").
72 end_date: Optional end of a range in YYYYMMDD format. Requires ``date``
73 to be provided as the start of the range.
75 Raises:
76 ValueError: If either argument is malformed, or if end_date is given
77 without a starting date.
78 """
79 if end_date is not None and date is None:
80 raise ValueError("end_date requires a starting date")
81 if date is not None:
82 date = _validate_yyyymmdd(date.strip(), "date")
83 if end_date is not None:
84 end_date = _validate_yyyymmdd(end_date.strip(), "end_date")
85 return await self._repo.get_scoreboard(date, end_date)
87 async def get_news(self, limit: int = 10) -> list[NewsArticle]:
88 """Return up to ``limit`` recent MLS news articles.
90 Args:
91 limit: Maximum number of articles to return (must be positive).
93 Raises:
94 ValueError: If limit is not a positive integer.
95 """
96 if limit <= 0:
97 raise ValueError("limit must be positive")
98 return await self._repo.get_news(limit)
100 async def get_roster(self, team_id: str) -> list[Player]:
101 """Return the active roster for a team.
103 Args:
104 team_id: ESPN numeric team ID.
106 Raises:
107 ValueError: If team_id is empty.
108 MLSNotFoundError: If no team with that ID exists.
109 """
110 if not team_id or not team_id.strip():
111 raise ValueError("team_id must not be empty")
112 return await self._repo.get_roster(team_id.strip())
114 async def get_match_details(self, match_id: str) -> MatchDetails:
115 """Return detailed information for a single match.
117 Args:
118 match_id: ESPN numeric event ID.
120 Raises:
121 ValueError: If match_id is empty.
122 MLSNotFoundError: If no match with that ID exists.
123 """
124 if not match_id or not match_id.strip():
125 raise ValueError("match_id must not be empty")
126 return await self._repo.get_match_details(match_id.strip())
128 async def get_team_schedule(self, team_id: str) -> list[Match]:
129 """Return all scheduled and completed matches for a team in the current season.
131 Args:
132 team_id: ESPN numeric team ID.
134 Raises:
135 ValueError: If team_id is empty.
136 MLSNotFoundError: If no team with that ID exists.
137 """
138 if not team_id or not team_id.strip():
139 raise ValueError("team_id must not be empty")
140 return await self._repo.get_team_schedule(team_id.strip())
142 async def get_standings(self) -> list[Standing]:
143 """Return the current MLS standings ordered by points descending.
145 Each row carries a ``conference`` label so callers can group Eastern vs
146 Western. The list itself is flat across both conferences, sorted by
147 overall points — Supporters' Shield ordering.
148 """
149 return await self._repo.get_standings()
151 async def get_strength_of_schedule(self, team_id: str) -> StrengthOfSchedule:
152 """Return the average current PPG of opponents this team has faced.
154 Walks the team's completed matches and aggregates each opponent's
155 league-table points-per-game (no self-exclusion). Useful for "who has
156 played the tougher schedule so far?" questions.
158 Args:
159 team_id: ESPN numeric team ID.
161 Raises:
162 ValueError: If team_id is empty.
163 MLSNotFoundError: If no team with that ID exists.
164 """
165 team_id = _validate_team_id(team_id)
166 standings = await self._repo.get_standings()
167 schedule = await self._repo.get_team_schedule(team_id)
168 ppg_index = _build_ppg_index(standings)
169 team = _resolve_team(standings, schedule, team_id)
170 opponents = [
171 OpponentPPG(
172 team=opp,
173 matches_played=ppg_index[opp.id].matches_played,
174 points=ppg_index[opp.id].points,
175 points_per_game=ppg_index[opp.id].ppg,
176 )
177 for opp in _played_opponents(schedule, team_id)
178 if opp.id in ppg_index
179 ]
180 return StrengthOfSchedule(
181 team=team,
182 matches_played=len(opponents),
183 opponents=opponents,
184 average_opponent_ppg=_mean([o.points_per_game for o in opponents]),
185 )
187 async def get_results_by_opponent_tier(self, team_id: str, tier_size: int = 5) -> ResultsByOpponentTier:
188 """Return W-L-T splits against current top-tier, middle, and bottom-tier teams.
190 Tiers are derived from the current league standings: the top ``tier_size``,
191 the bottom ``tier_size``, and everyone in between. Draws (no declared winner)
192 count as ties; matches against teams not in the current standings are
193 skipped.
195 Args:
196 team_id: ESPN numeric team ID.
197 tier_size: Number of teams in each of the top and bottom tiers.
198 Defaults to 5. Must be at least 1, and 2*tier_size must not
199 exceed the league size.
201 Raises:
202 ValueError: If team_id is empty or tier_size is invalid.
203 MLSNotFoundError: If no team with that ID exists.
204 """
205 team_id = _validate_team_id(team_id)
206 standings = await self._repo.get_standings()
207 _validate_tier_size(tier_size, len(standings))
208 schedule = await self._repo.get_team_schedule(team_id)
209 rank_by_id = {s.team.id: i + 1 for i, s in enumerate(standings)}
210 team = _resolve_team(standings, schedule, team_id)
211 tier_specs = _build_tier_specs(tier_size, len(standings))
212 tally = _tally_tier_results(schedule, team_id, rank_by_id, tier_specs)
213 tiers = [_build_tier_record(name, low, high, tally) for name, low, high in tier_specs if high >= low]
214 return ResultsByOpponentTier(team=team, tier_size=tier_size, tiers=tiers)
216 async def get_adjusted_points_per_game(self, team_id: str) -> AdjustedPointsPerGame:
217 """Return raw PPG plus a schedule-strength-adjusted PPG.
219 Adjusted PPG = raw_ppg * (avg_opponent_ppg / league_average_ppg). Values
220 above raw_ppg mean the team has earned points against a tougher
221 schedule than league average.
223 Args:
224 team_id: ESPN numeric team ID.
226 Raises:
227 ValueError: If team_id is empty.
228 MLSNotFoundError: If no team with that ID exists.
229 """
230 team_id = _validate_team_id(team_id)
231 standings = await self._repo.get_standings()
232 schedule = await self._repo.get_team_schedule(team_id)
233 ppg_index = _build_ppg_index(standings)
234 team = _resolve_team(standings, schedule, team_id)
235 matches_played, points, raw_ppg = _self_record(ppg_index, team_id)
236 opp_entries = _opponent_ppgs(schedule, team_id, ppg_index)
237 avg_opp_ppg = _mean([e.ppg for e in opp_entries])
238 league_avg = _league_average_ppg(standings)
239 return AdjustedPointsPerGame(
240 team=team,
241 matches_played=matches_played,
242 points=points,
243 raw_ppg=raw_ppg,
244 average_opponent_ppg=avg_opp_ppg,
245 league_average_ppg=league_avg,
246 adjusted_ppg=raw_ppg * _safe_ratio(avg_opp_ppg, league_avg),
247 )