"""DraftKings classic lineup solver (mixed-integer program, via SciPy's HiGHS). Used for two things: each source's same-engine lineup (the highest-projected legal lineup from its own projections) and the slate's perfect lineup (highest actual points in hindsight). Same engine for every source, so any difference in those lineups comes from the projections. """ from __future__ import annotations import numpy as np import pandas as pd from scipy.optimize import Bounds, LinearConstraint, milp from scipy.sparse import lil_matrix def best_lineup(sal: pd.DataFrame, values: pd.Series, cfg: dict) -> list[tuple[str, str]] | None: """Highest-value legal lineup. values: dk_id -> number (NaN / missing = not selectable). Returns [(slot, dk_id), ...] in slot order, or None if no legal lineup exists. """ slots: dict = cfg["slots"] vals = values.dropna() pl = sal[sal["dk_id"].isin(vals.index)].reset_index(drop=True) if pl.empty: return None var_p, var_s = [], [] for i, rec in enumerate(pl.itertuples()): for s in rec.roster: if s in slots: var_p.append(i) var_s.append(s) n = len(var_p) games = sorted(pl["game"].unique()) gidx = {g: k for k, g in enumerate(games)} G = len(games) obj_vals = pl["dk_id"].map(vals).to_numpy(dtype=float) c = np.concatenate([-obj_vals[var_p], np.zeros(G)]) rows = [] # (coeffs dict, lo, hi) n_rows = len(slots) + len(pl) + 1 + G + 1 A = lil_matrix((n_rows, n + G)) lo = np.zeros(n_rows) hi = np.zeros(n_rows) r = 0 for s, cnt in slots.items(): for j in range(n): if var_s[j] == s: A[r, j] = 1 lo[r] = hi[r] = cnt r += 1 player_vars: dict[int, list[int]] = {} for j, i in enumerate(var_p): player_vars.setdefault(i, []).append(j) for i in range(len(pl)): for j in player_vars.get(i, []): A[r, j] = 1 lo[r], hi[r] = 0, 1 r += 1 sal_arr = pl["salary"].to_numpy(dtype=float) for j in range(n): A[r, j] = sal_arr[var_p[j]] lo[r], hi[r] = 0, cfg["salary_cap"] r += 1 game_of_player = pl["game"].map(gidx).to_numpy() for g in range(G): A[r, n + g] = 1 for j in range(n): if game_of_player[var_p[j]] == g: A[r, j] = -1 lo[r], hi[r] = -np.inf, 0 r += 1 for g in range(G): A[r, n + g] = 1 lo[r], hi[r] = cfg.get("min_games", 1), np.inf r += 1 res = milp( c, constraints=LinearConstraint(A.tocsr(), lo, hi), integrality=np.ones(n + G), bounds=Bounds(0, 1), options={"time_limit": 60}, ) if res.x is None or res.status not in (0, 1): return None chosen = [j for j in range(n) if res.x[j] > 0.5] order = {s: k for k, s in enumerate(slots)} out = sorted(((var_s[j], pl.at[var_p[j], "dk_id"]) for j in chosen), key=lambda t: order[t[0]]) return out def assign_slots(ids: list[str], sal: pd.DataFrame, cfg: dict) -> list[tuple[str, str]] | None: """Can these players legally fill every slot? Returns an assignment or None.""" slots = [] for s, cnt in cfg["slots"].items(): slots += [s] * cnt if len(ids) != len(slots) or len(set(ids)) != len(ids): return None roster = {rec["dk_id"]: set(rec["roster"]) for rec in sal[sal["dk_id"].isin(ids)].to_dict("records")} if len(roster) != len(ids): return None # Fill the most restrictive slots first, backtracking. order = sorted(range(len(slots)), key=lambda k: sum(slots[k] in roster[p] for p in ids)) assign: dict[int, str] = {} def bt(k: int, used: set) -> bool: if k == len(order): return True slot = slots[order[k]] for p in ids: if p not in used and slot in roster[p]: assign[order[k]] = p if bt(k + 1, used | {p}): return True return False if not bt(0, set()): return None return [(slots[k], assign[k]) for k in range(len(slots))] def check_lineup(ids: list[str], sal: pd.DataFrame, cfg: dict) -> dict: """Validity checks used for the AI Division.""" problems = [] if len(set(ids)) != len(ids): problems.append("same player listed twice") on_slate = sal[sal["dk_id"].isin(ids)] if len(on_slate) != len(set(ids)): problems.append("player not on this slate") need = sum(cfg["slots"].values()) if len(ids) != need: problems.append(f"{len(ids)} players, needs {need}") if on_slate["salary"].sum() > cfg["salary_cap"]: problems.append(f"salary {int(on_slate['salary'].sum())} over the {cfg['salary_cap']} cap") if on_slate["game"].nunique() < cfg.get("min_games", 1): problems.append("players from too few games") if not problems and assign_slots(ids, sal, cfg) is None: problems.append("positions can't fill every slot") return {"valid": not problems, "problems": problems}