fpl-solver / solver_engine.py
AnayShukla's picture
updates
6e30e73
import requests
from typing import Any
_FIXTURE_CACHE = []
def _norm_id_list(raw) -> list[int]:
if not raw:
return []
out = []
for x in raw:
# THE FIX: Safely extract the ID from React's dictionary payload
if isinstance(x, dict):
pid = x.get("ID") or x.get("id", 0)
out.append(int(pid))
else:
out.append(int(x))
return out
def _norm_gw_list(raw) -> list[int]:
"""Normalise a chip GW list to a list of ints, handling None gracefully."""
if not raw:
return []
return [int(g) for g in raw if g is not None]
def prep_solver_data(payload_data: dict):
"""
Translates the React JSON payload into the exact mathematical dictionaries
the MILP engine requires, including chips and advanced constraints.
"""
print("Prepping data for the MILP Engine...")
horizon_gws = [int(g) for g in payload_data["horizon_gws"]]
raw_squad = payload_data["current_squad_ids"]
current_squad_ids = [
int(pid)
for pid in raw_squad
if isinstance(pid, (int, float)) or str(pid).isdigit()
]
settings_payload: dict[str, Any] = dict(payload_data.get("settings") or {})
comp_payload = dict(payload_data.get("comprehensive_settings") or {})
# Start with an empty dictionary.
# Python no longer guesses defaults. It completely trusts the React payload.
settings = {}
# 1. Apply basic settings
settings.update({k: v for k, v in settings_payload.items() if v not in (None, "")})
# 2. Apply comprehensive settings (Filters out None AND empty strings from UI)
settings.update({k: v for k, v in comp_payload.items() if v not in (None, "")})
# --- BAN / LOCK ---
banned_ids = _norm_id_list(settings.get("banned") or settings.get("banned_ids"))
locked_ids = _norm_id_list(settings.get("locked") or settings.get("locked_ids"))
settings["banned_ids"] = banned_ids
settings["locked_ids"] = locked_ids
# --- SCALAR SETTINGS ---
settings["iterations"] = max(
1,
min(5, int(settings.get("iterations", settings.get("num_iterations", 1)))),
)
settings["iteration_diff"] = int(settings.get("iteration_diff", 1))
settings["iteration_criteria"] = settings.get(
"iteration_criteria", "this_gw_transfer_in_out"
)
settings["hit_cost"] = int(settings.get("hit_cost", 4))
settings["max_ft"] = int(settings.get("max_ft", 5))
settings["itb_value"] = float(settings.get("itb_value", 0.08))
settings["time_limit_sec"] = int(settings.get("time_limit_sec", 3000))
settings["vice_weight"] = float(settings.get("vice_weight", 0.05))
settings["max_per_team"] = int(settings.get("max_per_team", 3))
settings["no_transfer_last_gws"] = int(settings.get("no_transfer_last_gws", 0))
settings["itb_loss_per_transfer"] = float(settings.get("itb_loss_per_transfer", 0))
settings["ft_value"] = float(
settings.get("ft_value_base", settings.get("ft_value", 0)) or 0
)
settings["ft_use_penalty"] = float(settings.get("ft_use_penalty", 0) or 0)
settings["future_transfer_limit"] = settings.get("future_transfer_limit")
if settings["future_transfer_limit"] is not None:
settings["future_transfer_limit"] = int(settings["future_transfer_limit"])
settings["hit_limit"] = settings.get("hit_limit")
if settings["hit_limit"] is not None:
settings["hit_limit"] = int(settings["hit_limit"])
settings["weekly_hit_limit"] = settings.get("weekly_hit_limit")
if settings["weekly_hit_limit"] is not None:
settings["weekly_hit_limit"] = int(settings["weekly_hit_limit"])
settings["no_transfer_gws"] = _norm_gw_list(settings.get("no_transfer_gws"))
settings["no_transfer_by_position"] = settings.get("no_transfer_by_position") or []
settings["no_trs_except_wc"] = bool(settings.get("no_trs_except_wc", False))
settings["max_defenders_per_team"] = int(settings.get("max_defenders_per_team", 3))
settings["double_defense_pick"] = bool(settings.get("double_defense_pick", False))
settings["transfer_itb_buffer"] = settings.get("transfer_itb_buffer")
if settings["transfer_itb_buffer"] is not None:
settings["transfer_itb_buffer"] = float(settings["transfer_itb_buffer"])
settings["no_gk_rotation_after"] = settings.get("no_gk_rotation_after")
settings["no_opposing_play"] = settings.get("no_opposing_play", False)
settings["opposing_play_group"] = settings.get("opposing_play_group", "all")
settings["opposing_play_penalty"] = float(
settings.get("opposing_play_penalty", 0.5)
)
settings["force_ft_state_lb"] = settings.get("force_ft_state_lb") or []
settings["force_ft_state_ub"] = settings.get("force_ft_state_ub") or []
settings["no_chip_gws"] = _norm_gw_list(settings.get("no_chip_gws"))
# Parse gw-specific lock/bans
def norm_temporal_list(raw):
if not raw:
return []
out = []
for x in raw:
if isinstance(x, list) and len(x) == 2:
out.append((int(x[0]), int(x[1])))
elif isinstance(x, dict):
# THE FIX: Extract the ID and strictly bind it to the FIRST gameweek of the horizon!
pid = x.get("ID") or x.get("id", 0)
out.append((int(pid), horizon_gws[0]))
else:
out.append((int(x), horizon_gws[0]))
return out
settings["banned_next_gw"] = norm_temporal_list(
settings.get("ban_this_gw") or settings.get("banned_next_gw")
)
settings["locked_next_gw"] = norm_temporal_list(
settings.get("lock_this_gw") or settings.get("locked_next_gw")
)
settings["booked_transfers"] = settings.get("booked_transfers") or []
settings["only_booked_transfers"] = bool(
settings.get("only_booked_transfers", False)
)
settings["no_future_transfer"] = bool(settings.get("no_future_transfer", False))
settings["num_transfers"] = settings.get("num_transfers")
settings["pick_prices"] = settings.get("pick_prices", {})
# --- CHIP GW LISTS ---
settings["use_wc"] = _norm_gw_list(settings.get("use_wc"))
settings["use_fh"] = _norm_gw_list(settings.get("use_fh"))
settings["use_bb"] = _norm_gw_list(settings.get("use_bb"))
settings["use_tc"] = _norm_gw_list(settings.get("use_tc"))
# --- BENCH WEIGHTS (dict with string keys "0"-"3") ---
raw_bw = settings.get("bench_weights")
if raw_bw and isinstance(raw_bw, dict):
settings["bench_weights"] = {str(k): float(v) for k, v in raw_bw.items()}
else:
settings["bench_weights"] = {"0": 0.03, "1": 0.18, "2": 0.06, "3": 0.002}
# --- FT VALUE LIST (dict with string keys "2"-"5") ---
raw_ftvl = settings.get("ft_value_list")
if raw_ftvl and isinstance(raw_ftvl, dict):
settings["ft_value_list"] = {str(k): float(v) for k, v in raw_ftvl.items()}
else:
settings["ft_value_list"] = {}
# --- DECAY ---
decay = float(settings.get("decay_base", settings.get("decay", 1.0)) or 1.0)
# --- BUILD PLAYER DICTS ---
buy_prices: dict[int, float] = {}
sell_prices: dict[int, float] = {}
positions: dict[int, str] = {}
teams: dict[int, str] = {}
ev_matrix: dict[int, dict[int, float]] = {}
raw_ev_matrix: dict[int, dict[int, float]] = {}
for idx, gw in enumerate(horizon_gws):
decay_factor = decay**idx if decay != 1.0 else 1.0
for p in payload_data["market_players"]:
pid = int(p["id"])
if pid not in buy_prices:
positions[pid] = p["pos"]
teams[pid] = p["team"]
buy_prices[pid] = float(p["now_cost"])
sell_prices[pid] = float(p.get("sell_price", p["now_cost"]))
ev_matrix[pid] = {}
raw_ev_matrix[pid] = {}
raw_ev = p["evs"].get(gw, p["evs"].get(str(gw), 0))
ev_matrix[pid][gw] = float(raw_ev) * decay_factor
raw_ev_matrix[pid][gw] = float(raw_ev)
fh_sell_price = {
pid: sell_prices[pid] if pid in current_squad_ids else buy_prices[pid]
for pid in buy_prices
}
# --- CROSS-PLAY FIXTURE PREP ---
global _FIXTURE_CACHE
gw_opp_teams = {w: [] for w in horizon_gws}
if settings.get("no_opposing_play"):
if not _FIXTURE_CACHE:
try:
# Same fast cache logic as solver_the_real_one
_FIXTURE_CACHE = requests.get(
"https://fantasy.premierleague.com/api/fixtures/"
).json()
except: # noqa: E722
pass
# Map the opponent matchups for the solver
team_mapping = {v: k for k, v in teams.items()} # Reverse lookup
for f in _FIXTURE_CACHE:
w = f.get("event")
if w in horizon_gws:
home_team = team_mapping.get(f.get("team_h"))
away_team = team_mapping.get(f.get("team_a"))
if home_team and away_team:
gw_opp_teams[w].extend(
[(home_team, away_team), (away_team, home_team)]
)
return {
"players": list(buy_prices.keys()),
"gws": horizon_gws,
"buy_prices": buy_prices,
"sell_prices": sell_prices,
"fh_sell_price": fh_sell_price,
"positions": positions,
"teams": teams,
"ev_matrix": ev_matrix,
"current_squad": current_squad_ids,
"itb": float(payload_data["in_the_bank"]),
"ft": int(payload_data["free_transfers"]),
"settings": settings,
"gw_opp_teams": gw_opp_teams,
}