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

1"""Application service — the core of the hexagonal architecture. 

2 

3This layer orchestrates work by delegating to outbound ports. It knows nothing 

4about MCP, HTTP, or JSON — those are adapter concerns. 

5""" 

6 

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 

37 

38_DEFAULT_PLAYER_PAGE = 50 

39_MAX_PLAYER_PAGE = 500 

40 

41 

42class EPLService: 

43 """Coordinates Premier League data lookups through the outbound ports. 

44 

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 """ 

49 

50 def __init__(self, repo: EPLAPIPort, pulselive: PulselivePort) -> None: 

51 self._repo = repo 

52 self._pulselive = pulselive 

53 

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. 

60 

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. 

69 

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) 

79 

80 async def get_teams(self) -> list[Team]: 

81 """Return all active Premier League teams.""" 

82 return await self._repo.get_teams() 

83 

84 async def get_team(self, team_id: str) -> Team: 

85 """Return a single team by its ESPN team ID. 

86 

87 Args: 

88 team_id: ESPN numeric team ID or team abbreviation. 

89 

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()) 

97 

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. 

100 

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. 

105 

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) 

117 

118 async def get_news(self, limit: int = 10) -> list[NewsArticle]: 

119 """Return up to ``limit`` recent Premier League news articles. 

120 

121 Args: 

122 limit: Maximum number of articles to return (must be positive). 

123 

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) 

130 

131 async def get_roster(self, team_id: str) -> list[Player]: 

132 """Return the active roster for a team. 

133 

134 Args: 

135 team_id: ESPN numeric team ID. 

136 

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()) 

144 

145 async def get_match_details(self, match_id: str) -> MatchDetails: 

146 """Return detailed information for a single match. 

147 

148 Args: 

149 match_id: ESPN numeric event ID. 

150 

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()) 

158 

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. 

161 

162 Args: 

163 team_id: ESPN numeric team ID. 

164 

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()) 

172 

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() 

176 

177 async def get_strength_of_schedule(self, team_id: str) -> StrengthOfSchedule: 

178 """Return the average current PPG of opponents this team has faced. 

179 

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. 

183 

184 Args: 

185 team_id: ESPN numeric team ID. 

186 

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 ) 

212 

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. 

215 

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. 

220 

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. 

226 

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) 

241 

242 async def get_adjusted_points_per_game(self, team_id: str) -> AdjustedPointsPerGame: 

243 """Return raw PPG plus a schedule-strength-adjusted PPG. 

244 

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. 

248 

249 Args: 

250 team_id: ESPN numeric team ID. 

251 

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 )