fplkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. fpl_cli/__init__.py +6 -0
  2. fpl_cli/_version.py +24 -0
  3. fpl_cli/agents/__init__.py +5 -0
  4. fpl_cli/agents/action/__init__.py +1 -0
  5. fpl_cli/agents/action/waiver.py +530 -0
  6. fpl_cli/agents/analysis/__init__.py +1 -0
  7. fpl_cli/agents/analysis/bench_order.py +275 -0
  8. fpl_cli/agents/analysis/captain.py +243 -0
  9. fpl_cli/agents/analysis/squad_analyzer.py +370 -0
  10. fpl_cli/agents/analysis/starting_xi.py +191 -0
  11. fpl_cli/agents/analysis/stats.py +752 -0
  12. fpl_cli/agents/analysis/transfer_eval.py +281 -0
  13. fpl_cli/agents/base.py +164 -0
  14. fpl_cli/agents/common.py +224 -0
  15. fpl_cli/agents/data/__init__.py +1 -0
  16. fpl_cli/agents/data/fixture.py +517 -0
  17. fpl_cli/agents/data/price.py +216 -0
  18. fpl_cli/agents/data/scout.py +220 -0
  19. fpl_cli/agents/orchestration/__init__.py +1 -0
  20. fpl_cli/agents/orchestration/report.py +595 -0
  21. fpl_cli/api/__init__.py +1 -0
  22. fpl_cli/api/football_data.py +140 -0
  23. fpl_cli/api/fpl.py +306 -0
  24. fpl_cli/api/fpl_draft.py +392 -0
  25. fpl_cli/api/providers/__init__.py +152 -0
  26. fpl_cli/api/providers/_models.py +31 -0
  27. fpl_cli/api/providers/anthropic.py +136 -0
  28. fpl_cli/api/providers/openai_compat.py +149 -0
  29. fpl_cli/api/providers/perplexity.py +91 -0
  30. fpl_cli/api/understat.py +386 -0
  31. fpl_cli/api/vaastav.py +395 -0
  32. fpl_cli/cli/__init__.py +84 -0
  33. fpl_cli/cli/_banner.py +27 -0
  34. fpl_cli/cli/_context.py +170 -0
  35. fpl_cli/cli/_fines.py +221 -0
  36. fpl_cli/cli/_fines_config.py +76 -0
  37. fpl_cli/cli/_helpers.py +235 -0
  38. fpl_cli/cli/_json.py +92 -0
  39. fpl_cli/cli/_league_recap_data.py +751 -0
  40. fpl_cli/cli/_league_recap_types.py +111 -0
  41. fpl_cli/cli/_plan_grid.py +222 -0
  42. fpl_cli/cli/_review_analysis.py +341 -0
  43. fpl_cli/cli/_review_classic.py +494 -0
  44. fpl_cli/cli/_review_draft.py +430 -0
  45. fpl_cli/cli/_review_summarisation.py +666 -0
  46. fpl_cli/cli/allocate.py +304 -0
  47. fpl_cli/cli/captain.py +119 -0
  48. fpl_cli/cli/chips.py +487 -0
  49. fpl_cli/cli/credentials.py +46 -0
  50. fpl_cli/cli/differentials.py +164 -0
  51. fpl_cli/cli/fdr.py +497 -0
  52. fpl_cli/cli/fixtures.py +127 -0
  53. fpl_cli/cli/history.py +81 -0
  54. fpl_cli/cli/init.py +575 -0
  55. fpl_cli/cli/league.py +271 -0
  56. fpl_cli/cli/league_recap.py +292 -0
  57. fpl_cli/cli/player.py +863 -0
  58. fpl_cli/cli/preview.py +641 -0
  59. fpl_cli/cli/price_changes.py +107 -0
  60. fpl_cli/cli/price_history.py +226 -0
  61. fpl_cli/cli/ratings.py +229 -0
  62. fpl_cli/cli/review.py +371 -0
  63. fpl_cli/cli/sell_prices.py +206 -0
  64. fpl_cli/cli/squad.py +182 -0
  65. fpl_cli/cli/stats.py +356 -0
  66. fpl_cli/cli/status.py +689 -0
  67. fpl_cli/cli/targets.py +134 -0
  68. fpl_cli/cli/transfer_eval.py +287 -0
  69. fpl_cli/cli/waivers.py +154 -0
  70. fpl_cli/cli/xg.py +135 -0
  71. fpl_cli/config/defaults.yaml +51 -0
  72. fpl_cli/constants.py +3 -0
  73. fpl_cli/models/__init__.py +17 -0
  74. fpl_cli/models/chip_plan.py +106 -0
  75. fpl_cli/models/fixture.py +142 -0
  76. fpl_cli/models/player.py +216 -0
  77. fpl_cli/models/team.py +49 -0
  78. fpl_cli/models/types.py +139 -0
  79. fpl_cli/parsers/__init__.py +0 -0
  80. fpl_cli/parsers/recommendations.py +145 -0
  81. fpl_cli/paths.py +104 -0
  82. fpl_cli/prompts/__init__.py +1 -0
  83. fpl_cli/prompts/league_recap.py +134 -0
  84. fpl_cli/prompts/review.py +595 -0
  85. fpl_cli/prompts/scout.py +130 -0
  86. fpl_cli/scraper/__init__.py +1 -0
  87. fpl_cli/scraper/fpl_prices.py +519 -0
  88. fpl_cli/season.py +76 -0
  89. fpl_cli/services/__init__.py +1 -0
  90. fpl_cli/services/fixture_predictions.py +351 -0
  91. fpl_cli/services/matchup.py +254 -0
  92. fpl_cli/services/player_prior.py +233 -0
  93. fpl_cli/services/player_scoring.py +1742 -0
  94. fpl_cli/services/squad_allocator.py +667 -0
  95. fpl_cli/services/team_form.py +108 -0
  96. fpl_cli/services/team_ratings.py +681 -0
  97. fpl_cli/services/team_ratings_prior.py +291 -0
  98. fpl_cli/templates/gw_league_recap.md.j2 +67 -0
  99. fpl_cli/templates/gw_preview.md.j2 +128 -0
  100. fpl_cli/templates/gw_review.md.j2 +206 -0
  101. fpl_cli/utils/__init__.py +0 -0
  102. fpl_cli/utils/text.py +10 -0
  103. fplkit-1.0.0.dist-info/METADATA +312 -0
  104. fplkit-1.0.0.dist-info/RECORD +107 -0
  105. fplkit-1.0.0.dist-info/WHEEL +4 -0
  106. fplkit-1.0.0.dist-info/entry_points.txt +2 -0
  107. fplkit-1.0.0.dist-info/licenses/LICENSE +21 -0
fpl_cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """FPL Agents - Multi-agent system for Fantasy Premier League team management."""
2
+
3
+ try:
4
+ from fpl_cli._version import __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0+unknown"
fpl_cli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,5 @@
1
+ """Agent implementations for FPL automation."""
2
+
3
+ from fpl_cli.agents.base import Agent, AgentResult
4
+
5
+ __all__ = ["Agent", "AgentResult"]
@@ -0,0 +1 @@
1
+ """Action agents."""
@@ -0,0 +1,530 @@
1
+ """Waiver agent for recommending and managing draft waiver claims."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import Any, cast
7
+
8
+ from fpl_cli.agents.base import Agent, AgentResult, AgentStatus
9
+ from fpl_cli.agents.common import (
10
+ enrich_player,
11
+ fetch_understat_lookup,
12
+ )
13
+ from fpl_cli.api.fpl import FPLClient
14
+ from fpl_cli.api.fpl_draft import FPLDraftClient
15
+ from fpl_cli.models.types import EnrichedPlayer, WaiverTarget
16
+ from fpl_cli.services.player_scoring import (
17
+ apply_shrinkage,
18
+ build_player_evaluation,
19
+ calculate_waiver_score,
20
+ compute_aggregate_matchup,
21
+ compute_form_trajectory,
22
+ prepare_scoring_data,
23
+ )
24
+
25
+
26
+ class WaiverAgent(Agent):
27
+ """Agent for analyzing and recommending waiver claims.
28
+
29
+ Responsibilities:
30
+ - Analyze available players for waiver potential
31
+ - Compare against current team needs
32
+ - Rank waiver targets with reasoning
33
+ - Track waiver deadline
34
+ """
35
+
36
+ name = "WaiverAgent"
37
+ description = "Recommends waiver claims for draft leagues"
38
+
39
+ def __init__(self, config: dict[str, Any] | None = None):
40
+ super().__init__(config)
41
+ self.client = FPLDraftClient()
42
+ self.fpl_client = FPLClient()
43
+ self.league_id = config.get("draft_league_id") if config else None
44
+ self.entry_id = config.get("draft_entry_id") if config else None
45
+
46
+ async def close(self) -> None:
47
+ await self.client.close()
48
+ await self.fpl_client.close()
49
+
50
+ async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
51
+ """Analyze and recommend waiver targets.
52
+
53
+ Args:
54
+ context: Can contain:
55
+ - 'league_id': Draft league ID
56
+ - 'entry_id': Your draft entry ID
57
+ - 'available_players': Pre-fetched available players
58
+ - 'current_team': Your current squad
59
+
60
+ Returns:
61
+ AgentResult with waiver recommendations.
62
+ """
63
+ league_id = (context or {}).get("league_id") or self.league_id
64
+ entry_id = (context or {}).get("entry_id") or self.entry_id
65
+
66
+ if not league_id:
67
+ return self._create_result(
68
+ AgentStatus.FAILED,
69
+ message="No draft league ID provided",
70
+ errors=["Set draft_league_id in config or provide in context"],
71
+ )
72
+
73
+ self.log(f"Analyzing waiver options for league {league_id}...")
74
+
75
+ try:
76
+ # Fetch data
77
+ bootstrap = await self.client.get_bootstrap_static()
78
+ teams_data = bootstrap.get("teams", [])
79
+ team_map = {t["id"]: t for t in teams_data}
80
+
81
+ # Get available players
82
+ available = await self.client.get_available_players(league_id, bootstrap)
83
+
84
+ # Get recently released players (independent of entry_id)
85
+ recent_releases: list[dict[str, Any]] = []
86
+ league_details: dict[str, Any] = {}
87
+ try:
88
+ releases = await self.client.get_recent_releases(league_id, bootstrap)
89
+ # Build entry->name map to resolve who dropped each player
90
+ league_details = await self.client.get_league_details(league_id)
91
+ league_entries = league_details.get("league_entries", [])
92
+ entry_name_map = {
93
+ e.get("entry_id"): f"{e.get('player_first_name', '')} {e.get('player_last_name', '')}".strip()
94
+ for e in league_entries
95
+ }
96
+ recent_releases = [
97
+ {
98
+ **enrich_player(self.client.parse_player(r["player"]), team_map),
99
+ "dropped_by": entry_name_map.get(r["dropped_by"], str(r["dropped_by"])),
100
+ "gameweek": r["gameweek"],
101
+ }
102
+ for r in releases[:20]
103
+ ]
104
+ except Exception as e: # noqa: BLE001 — best-effort enrichment
105
+ self.log_warning(f"Could not fetch recent releases: {e}")
106
+
107
+ # Parse players
108
+ parsed_available = [
109
+ enrich_player(self.client.parse_player(p), team_map, include_availability=False)
110
+ for p in available
111
+ ]
112
+
113
+ # Enrich with Understat data
114
+ us_lookup = await fetch_understat_lookup(
115
+ cast(list[dict[str, Any]], parsed_available),
116
+ lambda p: p.get("team_name", ""),
117
+ )
118
+ for i, us_match in us_lookup.items():
119
+ parsed_available[i]["npxG_per_90"] = us_match.get("npxG_per_90")
120
+ parsed_available[i]["xGChain_per_90"] = us_match.get("xGChain_per_90")
121
+ parsed_available[i]["penalty_xG_per_90"] = us_match.get("penalty_xG_per_90")
122
+
123
+ # Get current squad if entry_id provided
124
+ current_squad = []
125
+ squad_by_position = {"GK": [], "DEF": [], "MID": [], "FWD": []}
126
+
127
+ if entry_id:
128
+ try:
129
+ # Reuse league_details from recent releases fetch, or fetch fresh
130
+ if not league_details:
131
+ league_details = await self.client.get_league_details(league_id)
132
+ league_entries = league_details.get("league_entries", [])
133
+
134
+ # Find matching entry - entry_id could be entry_id or id
135
+ # The API uses entry_id for fetching picks
136
+ draft_entry_id = None
137
+ for entry in league_entries:
138
+ if entry.get("entry_id") == entry_id or entry.get("id") == entry_id:
139
+ draft_entry_id = entry.get("entry_id") # Use entry_id for API calls
140
+ self.log(f"Found team: {entry.get('entry_name')}")
141
+ break
142
+
143
+ if draft_entry_id:
144
+ # Get current gameweek
145
+ game_data = await self.client.get_game_state()
146
+ current_gw = game_data.get("current_event", 1)
147
+
148
+ # Fetch picks for current gameweek
149
+ picks_data = await self.client.get_entry_picks(draft_entry_id, current_gw)
150
+ player_ids = [p.get("element") for p in picks_data.get("picks", [])]
151
+
152
+ player_map = {p["id"]: p for p in bootstrap.get("elements", [])}
153
+ for pid in player_ids:
154
+ if pid in player_map:
155
+ player = enrich_player(
156
+ self.client.parse_player(player_map[pid]),
157
+ team_map,
158
+ include_availability=False,
159
+ )
160
+ current_squad.append(player)
161
+ pos = player.get("position", "???")
162
+ if pos in squad_by_position:
163
+ squad_by_position[pos].append(player)
164
+ else:
165
+ self.log_warning(f"Entry ID {entry_id} not found in league entries")
166
+ except Exception as e: # noqa: BLE001 — best-effort enrichment
167
+ self.log_warning(f"Could not fetch current squad: {e}")
168
+
169
+ # Fetch fixture data and build shared scoring context
170
+ data = await prepare_scoring_data(
171
+ self.fpl_client, include_players=True,
172
+ include_history=True, include_prior=True,
173
+ )
174
+ next_gw_id = data.next_gw_id
175
+ self._player_histories = data.player_histories or {}
176
+ self._player_priors = data.player_priors
177
+ scoring_ctx = data.scoring_ctx
178
+
179
+ # Enrich available players with matchup and FDR
180
+ matchup_cache: dict[tuple[int, str], float] = {}
181
+ for player in parsed_available:
182
+ tid = player.get("team_id", 0)
183
+ pos = player.get("position", "MID")
184
+ matchup_avg_3gw, positional_fdr = compute_aggregate_matchup(
185
+ tid, pos, scoring_ctx, matchup_cache=matchup_cache,
186
+ )
187
+ if matchup_avg_3gw is not None:
188
+ player["matchup_avg_3gw"] = matchup_avg_3gw
189
+ if positional_fdr is not None:
190
+ player["positional_fdr"] = positional_fdr
191
+
192
+ # Score and rank waiver targets
193
+ waiver_targets = self._rank_waiver_targets(
194
+ parsed_available,
195
+ current_squad,
196
+ squad_by_position,
197
+ next_gw_id=next_gw_id,
198
+ )
199
+
200
+ # Get waiver priority
201
+ waiver_order = await self.client.get_waiver_order(league_id)
202
+
203
+ # Find our position in waiver order
204
+ our_waiver_position = None
205
+ if entry_id:
206
+ for i, team in enumerate(waiver_order, 1):
207
+ if team.get("entry_id") == entry_id:
208
+ our_waiver_position = i
209
+ break
210
+
211
+ self.log_success(f"Found {len(waiver_targets)} potential waiver targets")
212
+
213
+ return self._create_result(
214
+ AgentStatus.SUCCESS,
215
+ data={
216
+ "league_id": league_id,
217
+ "entry_id": entry_id,
218
+ "waiver_position": our_waiver_position,
219
+ "total_waiver_teams": len(waiver_order),
220
+ "top_targets": waiver_targets[:15],
221
+ "targets_by_position": self._group_by_position(waiver_targets[:30]),
222
+ "current_squad": current_squad,
223
+ "squad_weaknesses": self._identify_weaknesses(squad_by_position),
224
+ "recommendations": self._generate_recommendations(
225
+ waiver_targets,
226
+ squad_by_position,
227
+ ),
228
+ "recent_releases": recent_releases,
229
+ },
230
+ message=f"Top waiver target: {waiver_targets[0]['player_name'] if waiver_targets else 'None'}",
231
+ )
232
+
233
+ except Exception as e: # noqa: BLE001 — agent top-level handler
234
+ self.log_error(f"Failed to analyze waivers: {e}")
235
+ return self._create_result(
236
+ AgentStatus.FAILED,
237
+ message="Failed to analyze waiver options",
238
+ errors=[str(e)],
239
+ )
240
+
241
+ def _get_team_exposure(
242
+ self,
243
+ squad_by_position: dict[str, list],
244
+ ) -> dict[str, int]:
245
+ """Count players per team in current squad."""
246
+ team_counts: dict[str, int] = defaultdict(int)
247
+ for players in squad_by_position.values():
248
+ for p in players:
249
+ team_short = p.get("team_short", "???")
250
+ team_counts[team_short] += 1
251
+ return dict(team_counts)
252
+
253
+ def _check_team_exposure(
254
+ self,
255
+ target: WaiverTarget,
256
+ drop_candidate: dict[str, Any] | None,
257
+ team_counts: dict[str, int],
258
+ ) -> tuple[int, str | None]:
259
+ """Check resulting team exposure after a waiver.
260
+
261
+ Returns:
262
+ (new_count, warning_message or None)
263
+ """
264
+ target_team = target.get("team_short", "???")
265
+ drop_team = drop_candidate.get("team_short") if drop_candidate else None
266
+
267
+ current = team_counts.get(target_team, 0)
268
+
269
+ # If dropping from same team, net change is 0
270
+ if drop_team == target_team:
271
+ return current, None
272
+
273
+ new_count = current + 1
274
+
275
+ if new_count >= 4:
276
+ return new_count, f"Heavy exposure: {new_count} {target_team} players"
277
+ elif new_count == 3:
278
+ return new_count, f"Triple-up: 3 {target_team} players"
279
+
280
+ return new_count, None
281
+
282
+ def _rank_waiver_targets(
283
+ self,
284
+ available: list[EnrichedPlayer],
285
+ current_squad: list[dict[str, Any]],
286
+ squad_by_position: dict[str, list],
287
+ next_gw_id: int = 38,
288
+ ) -> list[WaiverTarget]:
289
+ """Rank available players as waiver targets."""
290
+ scored_players = []
291
+ team_counts = self._get_team_exposure(squad_by_position)
292
+
293
+ for player in available:
294
+ score = self._calculate_waiver_score(
295
+ player, squad_by_position, team_counts, next_gw_id=next_gw_id,
296
+ )
297
+ reasons = self._generate_target_reasons(player, squad_by_position)
298
+
299
+ scored_players.append({
300
+ **player, # superset of WaiverTarget keys via EnrichedPlayer
301
+ "waiver_score": score,
302
+ "reasons": reasons,
303
+ })
304
+
305
+ # Apply early-season shrinkage
306
+ apply_shrinkage(scored_players, "waiver_score", self._player_priors, next_gw_id)
307
+
308
+ # Sort by waiver score
309
+ scored_players.sort(key=lambda p: p["waiver_score"], reverse=True)
310
+ return scored_players
311
+
312
+ def _calculate_waiver_score(
313
+ self,
314
+ player: EnrichedPlayer,
315
+ squad_by_position: dict[str, list],
316
+ team_counts: dict[str, int] | None = None,
317
+ next_gw_id: int = 38,
318
+ ) -> int:
319
+ """Calculate a waiver priority score via the player scoring engine."""
320
+ enrichment: dict[str, Any] = {}
321
+ histories = getattr(self, "_player_histories", {})
322
+ history = histories.get(player.get("id", 0), [])
323
+ if history:
324
+ enrichment["form_trajectory"] = compute_form_trajectory(history, next_gw_id)
325
+ priors = getattr(self, "_player_priors", None)
326
+ if priors:
327
+ prior = priors.get(player.get("id", 0))
328
+ if prior:
329
+ enrichment["prior_confidence"] = prior.confidence
330
+ evaluation, _ = build_player_evaluation(
331
+ player,
332
+ enrichment=enrichment,
333
+ matchup_avg_3gw=player.get("matchup_avg_3gw"),
334
+ positional_fdr=player.get("positional_fdr"),
335
+ )
336
+ return calculate_waiver_score(
337
+ evaluation,
338
+ squad_by_position=squad_by_position,
339
+ team_counts=team_counts,
340
+ next_gw_id=next_gw_id,
341
+ )
342
+
343
+ def _generate_target_reasons(
344
+ self,
345
+ player: EnrichedPlayer,
346
+ squad_by_position: dict[str, list],
347
+ ) -> list[str]:
348
+ """Generate reasons why a player is a good waiver target."""
349
+ reasons = []
350
+
351
+ form = player.get("form", 0)
352
+ if form >= 6:
353
+ reasons.append(f"Excellent form ({form})")
354
+ elif form >= 4:
355
+ reasons.append(f"Good form ({form})")
356
+
357
+ ppg = player.get("ppg", 0)
358
+ if ppg >= 5:
359
+ reasons.append(f"Strong PPG ({ppg:.1f})")
360
+
361
+ xgi = player.get("xGI_per_90", 0)
362
+ if xgi >= 0.4:
363
+ reasons.append(f"High xGI ({xgi:.2f}/90)")
364
+
365
+ minutes = player.get("minutes", 0)
366
+ if minutes >= 1500:
367
+ reasons.append("Regular starter")
368
+
369
+ # Check if fills a need
370
+ pos_name = player.get("position", "???")
371
+ if pos_name in squad_by_position:
372
+ position_players = squad_by_position[pos_name]
373
+ if position_players:
374
+ avg_form = sum(p.get("form", 0) for p in position_players) / len(position_players)
375
+ if form > avg_form + 1:
376
+ reasons.append(f"Better than current {pos_name} options")
377
+
378
+ if not reasons:
379
+ reasons.append("Depth option")
380
+
381
+ return reasons
382
+
383
+ def _group_by_position(
384
+ self,
385
+ players: list[WaiverTarget],
386
+ ) -> dict[str, list[WaiverTarget]]:
387
+ """Group players by position."""
388
+ by_position = {"GK": [], "DEF": [], "MID": [], "FWD": []}
389
+
390
+ for player in players:
391
+ pos = player.get("position", "???")
392
+ if pos in by_position:
393
+ by_position[pos].append(player)
394
+
395
+ return by_position
396
+
397
+ def _identify_weaknesses(
398
+ self,
399
+ squad_by_position: dict[str, list],
400
+ ) -> list[dict[str, Any]]:
401
+ """Identify weak positions in the squad."""
402
+ weaknesses = []
403
+
404
+ for pos, players in squad_by_position.items():
405
+ if not players:
406
+ weaknesses.append({
407
+ "position": pos,
408
+ "severity": "high",
409
+ "reason": "No players at this position",
410
+ })
411
+ else:
412
+ avg_form = sum(p.get("form", 0) for p in players) / len(players)
413
+ if avg_form < 3:
414
+ weaknesses.append({
415
+ "position": pos,
416
+ "severity": "medium",
417
+ "reason": f"Low average form ({avg_form:.1f})",
418
+ "current_players": [p.get("player_name") for p in players],
419
+ })
420
+
421
+ return weaknesses
422
+
423
+ def _calculate_drop_priority(self, player: dict[str, Any]) -> float:
424
+ """Calculate drop priority score (higher = more droppable).
425
+
426
+ Priority order:
427
+ 1. Suspended players (status="s") - highest priority to drop
428
+ 2. Unavailable with 0% chance of playing
429
+ 3. Injured with low chance (<50%)
430
+ 4. Injured with medium chance (50-75%)
431
+ 5. Available players sorted by form (lowest form = higher priority)
432
+ """
433
+ status = player.get("status", "a")
434
+ chance = player.get("chance_of_playing_next_round")
435
+ form = player.get("form", 0)
436
+
437
+ # Suspended players are highest priority to drop
438
+ if status == "s":
439
+ return 1000
440
+
441
+ # Unavailable with 0% chance
442
+ if status != "a" and chance == 0:
443
+ return 500
444
+
445
+ # Injured with low chance (<50%)
446
+ if status != "a" and chance is not None and chance < 50:
447
+ return 200 + (50 - chance)
448
+
449
+ # Injured with medium chance (50-75%)
450
+ if status != "a" and chance is not None and chance < 75:
451
+ return 100 + (75 - chance)
452
+
453
+ # Available players - inverse form (lower form = higher drop score)
454
+ return 10 - min(form, 10)
455
+
456
+ def _generate_recommendations(
457
+ self,
458
+ waiver_targets: list[WaiverTarget],
459
+ squad_by_position: dict[str, list],
460
+ ) -> list[dict[str, Any]]:
461
+ """Generate specific waiver recommendations."""
462
+ recommendations = []
463
+ team_counts = self._get_team_exposure(squad_by_position)
464
+
465
+ # Find best target for each position
466
+ seen_positions = set()
467
+
468
+ for target in waiver_targets[:20]:
469
+ pos = target.get("position", "???")
470
+
471
+ if pos not in seen_positions and pos in squad_by_position:
472
+ squad_players = squad_by_position[pos]
473
+
474
+ # Find worst player to drop (highest drop priority)
475
+ drop_candidate = None
476
+ if squad_players:
477
+ drop_candidate = max(
478
+ squad_players, key=lambda p: self._calculate_drop_priority(p)
479
+ )
480
+
481
+ # Determine why this player is being dropped
482
+ drop_reason = None
483
+ if drop_candidate:
484
+ drop_status = drop_candidate.get("status", "a")
485
+ drop_chance = drop_candidate.get("chance_of_playing_next_round")
486
+ if drop_status == "s":
487
+ drop_reason = "Suspended"
488
+ elif drop_status != "a" and drop_chance == 0:
489
+ drop_reason = "Unavailable (0%)"
490
+ elif drop_status != "a" and drop_chance is not None and drop_chance < 75:
491
+ drop_reason = f"Doubtful ({drop_chance}%)"
492
+ else:
493
+ drop_reason = f"Low form ({drop_candidate.get('form', 0)})"
494
+
495
+ # Check team exposure
496
+ new_count, exposure_warning = self._check_team_exposure(
497
+ target, drop_candidate, team_counts
498
+ )
499
+
500
+ rec: dict[str, Any] = {
501
+ "priority": len(recommendations) + 1,
502
+ "target": {
503
+ "name": target.get("player_name"),
504
+ "team": target.get("team_short"),
505
+ "position": pos,
506
+ "form": target.get("form"),
507
+ "waiver_score": target.get("waiver_score"),
508
+ },
509
+ "drop": {
510
+ "name": drop_candidate.get("player_name") if drop_candidate else None,
511
+ "form": drop_candidate.get("form") if drop_candidate else None,
512
+ "reason": drop_reason,
513
+ } if drop_candidate else None,
514
+ "reasons": target.get("reasons", []),
515
+ }
516
+
517
+ if exposure_warning:
518
+ rec["exposure"] = {
519
+ "team": target.get("team_short"),
520
+ "count_after": new_count,
521
+ "warning": exposure_warning,
522
+ }
523
+
524
+ recommendations.append(rec)
525
+ seen_positions.add(pos)
526
+
527
+ if len(recommendations) >= 5:
528
+ break
529
+
530
+ return recommendations
@@ -0,0 +1 @@
1
+ """Analysis agents."""