fpl-intelligence 0.2.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.
app/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,318 @@
1
+ """
2
+ Captain Pick algorithm v2.1 — backtest-tuned weights.
3
+
4
+ captain_score =
5
+ points_per_game * 3.0 # highest single-GW correlation (0.42)
6
+ + form * 2.5 # strong correlation (0.26)
7
+ + xG_per_90 * 2.0 # reduced from 5.0 — low single-GW correlation
8
+ + xA_per_90 * 1.5 # reduced from 3.0 — low single-GW correlation
9
+ + home_bonus (1.5 if home)
10
+ - fixture_difficulty * 1.5 # increased — FDR matters
11
+ + ict_index * 0.01
12
+ + bonus_per_game * 1.0 # increased from 0.5
13
+ + penalty_taker_bonus * 2.0
14
+ + minutes_certainty * 1.0
15
+ - playing_chance_penalty
16
+
17
+ Weights tuned against GW1-29 actuals via scripts/backtest.py.
18
+ """
19
+
20
+ from app.fpl_client import get_bootstrap, get_next_gameweek, get_fixtures
21
+
22
+ # Statuses that warrant a full injury penalty
23
+ INJURY_STATUSES = {"i", "d", "s", "u"} # injured, doubtful, suspended, unavailable
24
+
25
+ WEIGHTS = {
26
+ "xg90": 2.0, # reduced — low single-GW correlation per backtest
27
+ "xa90": 1.5, # reduced — low single-GW correlation per backtest
28
+ "form": 2.5, # strongest predictor after PPG per backtest
29
+ "ppg": 3.0, # highest correlation with actual GW points (0.42)
30
+ "home": 2.0, # increased — home advantage is a key differentiator
31
+ "fdr": 2.0, # increased — fixture difficulty should drive pick variation
32
+ "ict": 0.01, # keep
33
+ "bonus_pg": 1.0, # increased — bonus correlates well
34
+ "penalty": 1.5, # reduced — less important than fixture context
35
+ "minutes_cert": 1.0, # keep
36
+ "playing_chance_max_penalty": -10.0,
37
+ }
38
+
39
+ POSITION_MAP = {1: "GKP", 2: "DEF", 3: "MID", 4: "FWD"}
40
+
41
+
42
+ def _playing_chance_penalty(player: dict) -> float:
43
+ """
44
+ Penalise players unlikely to play next round.
45
+
46
+ chance_of_playing_next_round:
47
+ 100 or None (fit) -> 0 penalty
48
+ 75 -> -2.5
49
+ 50 -> -5.0
50
+ 25 -> -7.5
51
+ 0 -> -10.0
52
+ """
53
+ chance = player.get("chance_of_playing_next_round")
54
+ if chance is None:
55
+ # No flag = assumed fit
56
+ status = player.get("status", "a")
57
+ if status in INJURY_STATUSES:
58
+ return WEIGHTS["playing_chance_max_penalty"]
59
+ return 0.0
60
+ chance = float(chance)
61
+ return WEIGHTS["playing_chance_max_penalty"] * (1.0 - chance / 100.0)
62
+
63
+
64
+ def _build_fixture_map(fixtures: list, gameweek: int) -> dict[int, list[dict]]:
65
+ """
66
+ Map team_id -> list of fixture details for the given gameweek.
67
+ Returns: { team_id: [ { fdr, is_home, opponent_team }, ... ] }
68
+
69
+ Supports double gameweeks (DGW) where a team plays multiple fixtures.
70
+ """
71
+ fixture_map: dict[int, list[dict]] = {}
72
+ gw_fixtures = [f for f in fixtures if f.get("event") == gameweek]
73
+
74
+ for fix in gw_fixtures:
75
+ home_id = fix["team_h"]
76
+ away_id = fix["team_a"]
77
+ home_fdr = fix["team_h_difficulty"]
78
+ away_fdr = fix["team_a_difficulty"]
79
+
80
+ # Home team
81
+ fixture_map.setdefault(home_id, []).append(
82
+ {"fdr": home_fdr, "is_home": True, "opponent": away_id}
83
+ )
84
+ # Away team
85
+ fixture_map.setdefault(away_id, []).append(
86
+ {"fdr": away_fdr, "is_home": False, "opponent": home_id}
87
+ )
88
+
89
+ return fixture_map
90
+
91
+
92
+ def _score_player(player: dict, fixtures: list[dict] | None) -> float:
93
+ """
94
+ Score a player for captaincy using v2 algorithm with xG/xA data.
95
+
96
+ fixtures: list of fixture dicts for the player's team this gameweek.
97
+ Supports DGWs (multiple fixtures) by summing fixture-dependent
98
+ components across all fixtures.
99
+ """
100
+ form = float(player.get("form") or 0)
101
+ ppg = float(player.get("points_per_game") or 0)
102
+ ict = float(player.get("ict_index") or 0)
103
+
104
+ # xG and xA per 90
105
+ minutes = player.get("minutes", 0)
106
+ nineties = minutes / 90.0 if minutes > 0 else 0
107
+
108
+ xg_per_90 = 0.0
109
+ xa_per_90 = 0.0
110
+ if nineties > 0:
111
+ xg = float(player.get("expected_goals") or 0)
112
+ xa = float(player.get("expected_assists") or 0)
113
+ xg_per_90 = xg / nineties
114
+ xa_per_90 = xa / nineties
115
+
116
+ # bonus_per_game approximation: total bonus / 90s played
117
+ gw_played = max(1, round(nineties)) if nineties > 0 else 1
118
+ bonus_pg = player.get("bonus", 0) / gw_played
119
+
120
+ # Penalty taker bonus
121
+ penalties_order = player.get("penalties_order")
122
+ penalty_bonus = WEIGHTS["penalty"] if penalties_order == 1 else 0.0
123
+
124
+ # Minutes certainty: starts / possible starts (approximate from GWs played)
125
+ starts = player.get("starts", 0)
126
+ # Count gameweeks where player could have started (events so far)
127
+ possible_starts = max(1, gw_played)
128
+ minutes_cert = starts / possible_starts if possible_starts > 0 else 0.0
129
+
130
+ # Playing chance penalty (uses chance_of_playing_next_round or status)
131
+ chance_penalty = _playing_chance_penalty(player)
132
+
133
+ # Base score (fixture-independent)
134
+ base_score = (
135
+ xg_per_90 * WEIGHTS["xg90"]
136
+ + xa_per_90 * WEIGHTS["xa90"]
137
+ + form * WEIGHTS["form"]
138
+ + ppg * WEIGHTS["ppg"]
139
+ + ict * WEIGHTS["ict"]
140
+ + bonus_pg * WEIGHTS["bonus_pg"]
141
+ + penalty_bonus
142
+ + minutes_cert * WEIGHTS["minutes_cert"]
143
+ + chance_penalty
144
+ )
145
+
146
+ # Fixture-dependent scoring -- sum across all fixtures (DGW support)
147
+ if fixtures:
148
+ fixture_score = 0.0
149
+ for fixture in fixtures:
150
+ fdr = fixture["fdr"]
151
+ is_home = fixture["is_home"]
152
+ home_bonus = WEIGHTS["home"] if is_home else 0.0
153
+ fixture_score += home_bonus - fdr * WEIGHTS["fdr"]
154
+ score = base_score + fixture_score
155
+ else:
156
+ # No fixture data -- assume average difficulty
157
+ score = base_score - 3 * WEIGHTS["fdr"]
158
+
159
+ return round(score, 3)
160
+
161
+
162
+ def _build_reasoning(player: dict, fixtures: list[dict] | None, score: float) -> str:
163
+ parts = []
164
+ form = float(player.get("form") or 0)
165
+ if form >= 7:
166
+ parts.append("exceptional form")
167
+ elif form >= 5:
168
+ parts.append("strong form")
169
+ elif form <= 2:
170
+ parts.append("poor form")
171
+
172
+ # xG/xA insight
173
+ minutes = player.get("minutes", 0)
174
+ if minutes > 0:
175
+ nineties = minutes / 90.0
176
+ xg = float(player.get("expected_goals") or 0)
177
+ xa = float(player.get("expected_assists") or 0)
178
+ xg90 = xg / nineties if nineties > 0 else 0
179
+ xa90 = xa / nineties if nineties > 0 else 0
180
+ if xg90 >= 0.5:
181
+ parts.append(f"elite xG/90 ({xg90:.2f})")
182
+ elif xg90 >= 0.3:
183
+ parts.append(f"strong xG/90 ({xg90:.2f})")
184
+ if xa90 >= 0.3:
185
+ parts.append(f"strong xA/90 ({xa90:.2f})")
186
+
187
+ if player.get("penalties_order") == 1:
188
+ parts.append("on penalties")
189
+
190
+ if fixtures:
191
+ if len(fixtures) > 1:
192
+ parts.append(f"double gameweek ({len(fixtures)} fixtures)")
193
+ for fixture in fixtures:
194
+ fdr = fixture["fdr"]
195
+ if fdr <= 2:
196
+ parts.append("easy fixture (FDR %d)" % fdr)
197
+ elif fdr >= 4:
198
+ parts.append("tough fixture (FDR %d)" % fdr)
199
+ if fixture["is_home"]:
200
+ parts.append("home advantage")
201
+
202
+ chance = player.get("chance_of_playing_next_round")
203
+ status = player.get("status", "a")
204
+ if status in INJURY_STATUSES:
205
+ if chance is not None:
206
+ parts.append(f"injury concern ({chance}% chance)")
207
+ else:
208
+ parts.append("injury concern")
209
+
210
+ ict = float(player.get("ict_index") or 0)
211
+ if ict >= 150:
212
+ parts.append("elite ICT index")
213
+
214
+ if not parts:
215
+ parts.append("solid all-round score")
216
+
217
+ return ", ".join(parts).capitalize() + f" (score: {score})"
218
+
219
+
220
+ async def get_captain_picks(gameweek: int | None = None, top_n: int = 5) -> dict:
221
+ """
222
+ Return top N captain recommendations for the given gameweek.
223
+ If gameweek is None, uses the next gameweek (what managers are prepping for).
224
+ """
225
+ bootstrap, fixtures = await _gather_data()
226
+
227
+ if gameweek is None:
228
+ gameweek = get_next_gameweek(bootstrap)
229
+
230
+ teams = {t["id"]: t for t in bootstrap["teams"]}
231
+ fixture_map = _build_fixture_map(fixtures, gameweek)
232
+
233
+ scored = []
234
+ for player in bootstrap["elements"]:
235
+ player_fixtures = fixture_map.get(player["team"])
236
+ score = _score_player(player, player_fixtures)
237
+ scored.append((score, player, player_fixtures))
238
+
239
+ scored.sort(key=lambda x: x[0], reverse=True)
240
+ top = scored[:top_n]
241
+
242
+ picks = []
243
+ for score, player, player_fixtures in top:
244
+ team = teams.get(player["team"], {})
245
+
246
+ # Build fixture info for all fixtures (DGW support)
247
+ fixture_info = None
248
+ if player_fixtures:
249
+ fixture_entries = []
250
+ for fix in player_fixtures:
251
+ opponent_id = fix["opponent"]
252
+ opponent = teams.get(opponent_id, {}).get("short_name", "?")
253
+ fixture_entries.append({
254
+ "opponent": opponent,
255
+ "venue": "Home" if fix["is_home"] else "Away",
256
+ "fdr": fix["fdr"],
257
+ })
258
+ fixture_info = {
259
+ "fixtures": fixture_entries,
260
+ "gameweek": gameweek,
261
+ "is_dgw": len(fixture_entries) > 1,
262
+ # Keep backward compat: top-level opponent/venue/fdr from first fixture
263
+ "opponent": fixture_entries[0]["opponent"],
264
+ "venue": fixture_entries[0]["venue"],
265
+ "fdr": fixture_entries[0]["fdr"],
266
+ }
267
+
268
+ # xG/xA stats
269
+ minutes = player.get("minutes", 0)
270
+ nineties = minutes / 90.0 if minutes > 0 else 0
271
+ xg = float(player.get("expected_goals") or 0)
272
+ xa = float(player.get("expected_assists") or 0)
273
+
274
+ picks.append(
275
+ {
276
+ "rank": len(picks) + 1,
277
+ "player": {
278
+ "id": player["id"],
279
+ "name": player["web_name"],
280
+ "team": team.get("short_name", "?"),
281
+ "position": POSITION_MAP.get(player["element_type"], "?"),
282
+ "cost": player["now_cost"] / 10,
283
+ "selected_by_pct": float(player.get("selected_by_percent") or 0),
284
+ "status": player.get("status", "a"),
285
+ },
286
+ "fixture": fixture_info,
287
+ "score": score,
288
+ "reasoning": _build_reasoning(player, player_fixtures, score),
289
+ "stats": {
290
+ "form": float(player.get("form") or 0),
291
+ "points_per_game": float(player.get("points_per_game") or 0),
292
+ "ict_index": float(player.get("ict_index") or 0),
293
+ "total_points": player.get("total_points", 0),
294
+ "bonus": player.get("bonus", 0),
295
+ "expected_goals": xg,
296
+ "expected_assists": xa,
297
+ "expected_goal_involvements": float(player.get("expected_goal_involvements") or 0),
298
+ "xg_per_90": round(xg / nineties, 3) if nineties > 0 else 0,
299
+ "xa_per_90": round(xa / nineties, 3) if nineties > 0 else 0,
300
+ "penalties_order": player.get("penalties_order"),
301
+ "starts": player.get("starts", 0),
302
+ "chance_of_playing": player.get("chance_of_playing_next_round"),
303
+ },
304
+ }
305
+ )
306
+
307
+ return {
308
+ "gameweek": gameweek,
309
+ "algorithm_version": "2.0",
310
+ "picks": picks,
311
+ }
312
+
313
+
314
+ async def _gather_data():
315
+ """Fetch bootstrap and fixtures concurrently."""
316
+ import asyncio
317
+
318
+ return await asyncio.gather(get_bootstrap(), get_fixtures())