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 +0 -0
- app/algorithms/__init__.py +0 -0
- app/algorithms/captain.py +318 -0
- app/algorithms/chips.py +437 -0
- app/algorithms/compare.py +272 -0
- app/algorithms/differentials.py +138 -0
- app/algorithms/fixtures.py +129 -0
- app/algorithms/hit_analyzer.py +228 -0
- app/algorithms/live.py +127 -0
- app/algorithms/prices.py +79 -0
- app/algorithms/transfers.py +202 -0
- app/config.py +36 -0
- app/fpl_client.py +181 -0
- app/main.py +163 -0
- app/x402.py +253 -0
- fpl_intelligence-0.2.0.dist-info/METADATA +80 -0
- fpl_intelligence-0.2.0.dist-info/RECORD +21 -0
- fpl_intelligence-0.2.0.dist-info/WHEEL +4 -0
- fpl_intelligence-0.2.0.dist-info/entry_points.txt +2 -0
- fpl_intelligence-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server.py +488 -0
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())
|