File size: 10,340 Bytes
a35b524
21b08b7
 
a35b524
21b08b7
a35b524
 
21b08b7
 
 
 
 
 
a35b524
 
21b08b7
 
 
 
 
 
 
 
 
211cc48
 
 
21b08b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211cc48
21b08b7
 
211cc48
21b08b7
 
 
 
 
 
 
 
 
211cc48
21b08b7
 
 
 
 
 
47189b9
21b08b7
 
 
211cc48
 
 
 
 
21b08b7
47189b9
21b08b7
 
211cc48
21b08b7
211cc48
21b08b7
47189b9
21b08b7
ad7b76c
211cc48
 
 
 
 
 
 
 
ad7b76c
21b08b7
 
ad7b76c
21b08b7
87ed105
ad7b76c
 
 
21b08b7
211cc48
21b08b7
 
 
 
 
211cc48
21b08b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e112a6
 
21b08b7
8e112a6
47189b9
8e112a6
47189b9
 
211cc48
47189b9
 
8e112a6
47189b9
21b08b7
 
 
 
 
 
 
 
 
 
47189b9
21b08b7
 
 
 
 
 
 
211cc48
21b08b7
 
 
 
 
 
211cc48
 
 
 
21b08b7
 
 
 
 
211cc48
 
21b08b7
 
 
 
 
 
211cc48
21b08b7
 
211cc48
21b08b7
a35b524
21b08b7
 
 
 
 
 
 
 
 
 
211cc48
 
 
21b08b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211cc48
 
21b08b7
 
 
 
 
 
 
211cc48
 
 
21b08b7
 
 
211cc48
 
 
 
 
 
 
21b08b7
 
 
 
 
 
 
 
211cc48
 
21b08b7
 
211cc48
 
 
21b08b7
211cc48
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import pandas as pd
import numpy as np
from ortools.linear_solver import pywraplp

from global_func.exposure_spread import check_position_eligibility


def get_effective_salary(player_name: str, column_name: str, map_dict: dict, type_var: str) -> float:
    """Calculate the effective salary for a player in a specific column (handles CPT multiplier)"""
    base_salary = map_dict['salary_map'].get(player_name, 0)
    if type_var != 'Classic' and column_name == 'CPT':
        return base_salary * 1.5
    return base_salary


def optimize_single_lineup(
    row: pd.Series,
    player_columns: list,
    player_pool: pd.DataFrame,
    map_dict: dict,
    lock_teams: list,
    type_var: str,
    sport_var: str,
    salary_max: int,
    optimize_by: str = 'median',
    max_objective_value: float = None  # NEW PARAMETER
) -> tuple[pd.Series, float]:  # NOW RETURNS (row, objective_value)
    """
    Optimize a single lineup row using linear programming.
    
    Players from lock_teams are kept (locked), all other positions are cleared
    and re-optimized using OR-Tools linear solver.
    
    Args:
        row: A single lineup row from the DataFrame
        player_columns: List of column names containing player positions
        player_pool: DataFrame of available players (projections_df)
        map_dict: Dictionary containing player mappings
        lock_teams: List of team names whose players should be KEPT (locked)
        type_var: 'Classic' or 'Showdown'
        sport_var: Sport identifier (NFL, NBA, MLB, etc.)
        salary_max: Maximum salary cap for the lineup
        optimize_by: 'median' or 'ownership' - which metric to optimize for
        max_objective_value: Maximum allowed objective value (for cascading optimization)
        
    Returns:
        Tuple of (optimized_row, achieved_objective_value)
    """
    # Create a copy of the row to modify
    optimized_row = row.copy()
    
    # Identify locked players (from lock_teams) and open positions
    locked_players = {}  # {column: player_name}
    open_columns = []
    locked_salary = 0
    locked_player_names = set()
    locked_objective_value = 0  # Track locked player contribution to objective
    
    for col in player_columns:
        player_name = row[col]
        player_team = map_dict['team_map'].get(player_name, '')
        
        if player_team in lock_teams:
            # Keep this player locked
            locked_players[col] = player_name
            locked_salary += get_effective_salary(player_name, col, map_dict, type_var)
            locked_player_names.add(player_name)
            
            # Add locked player's contribution to objective
            player_data = player_pool[player_pool['player_names'] == player_name]
            if not player_data.empty:
                locked_objective_value += player_data.iloc[0].get(optimize_by, player_data.iloc[0].get('median', 0))
        else:
            # This position is open for optimization
            open_columns.append(col)
    
    # If no open columns, return with locked objective value
    if not open_columns:
        return optimized_row, locked_objective_value
    
    # Calculate remaining salary budget
    remaining_salary = salary_max - locked_salary
    
    # Calculate remaining objective budget (if max_objective_value is set)
    remaining_objective_budget = None
    if max_objective_value is not None:
        remaining_objective_budget = max_objective_value - locked_objective_value
        # If we can't meet the constraint, return original row
        if remaining_objective_budget < 0:
            return optimized_row, locked_objective_value
    
    # Filter player pool: exclude locked teams and already-locked players
    available_players = player_pool[
        (~player_pool['team'].isin(lock_teams)) & 
        (~player_pool['player_names'].isin(locked_player_names))
    ].copy()

    # CRITICAL: Remove duplicate players from available pool
    available_players = available_players.drop_duplicates(subset=['player_names'], keep='first')
    
    if available_players.empty:
        return optimized_row, locked_objective_value
    
    # Build the optimization model
    solver = pywraplp.Solver.CreateSolver('CBC')
    if not solver:
        # Fallback if solver not available
        return optimized_row, locked_objective_value
    
    # Create decision variables: x[player_idx, col_idx] = 1 if player is assigned to column
    player_list = available_players.to_dict('records')
    num_players = len(player_list)
    num_open_cols = len(open_columns)
    
    # x[i][j] = 1 if player i is assigned to open column j
    x = {}
    for i in range(num_players):
        for j in range(num_open_cols):
            x[i, j] = solver.BoolVar(f'x_{i}_{j}')
    
    # Constraint 1: Each open column gets exactly one player
    for j in range(num_open_cols):
        solver.Add(sum(x[i, j] for i in range(num_players)) == 1)

    # Constraint 2: Each player can only be used AT MOST once across all open columns
    for i in range(num_players):
        solver.Add(sum(x[i, j] for j in range(num_open_cols)) <= 1)

    # Constraint 3: Players already LOCKED in the row cannot be selected again
    for i, player in enumerate(player_list):
        player_name = player['player_names']
        if player_name in locked_player_names:
            for j in range(num_open_cols):
                solver.Add(x[i, j] == 0)

    # Constraint 4: Position eligibility
    for i, player in enumerate(player_list):
        player_positions = player['position'].split('/')
        for j, col in enumerate(open_columns):
            if type_var == 'Classic':
                if not check_position_eligibility(sport_var, col, player_positions):
                    solver.Add(x[i, j] == 0)
            else:
                # For Showdown, CPT and FLEX can take any player
                pass
    
    # Constraint 5: Total salary of selected players <= remaining_salary
    salary_constraint = []
    for i, player in enumerate(player_list):
        for j, col in enumerate(open_columns):
            effective_salary = get_effective_salary(player['player_names'], col, map_dict, type_var)
            salary_constraint.append(x[i, j] * effective_salary)
    solver.Add(sum(salary_constraint) <= remaining_salary)
    
    # NEW Constraint 6: Total objective value <= max_objective_value (if specified)
    objective_terms = []
    for i, player in enumerate(player_list):
        metric_value = player.get(optimize_by, player.get('median', 0))
        for j in range(num_open_cols):
            objective_terms.append(x[i, j] * metric_value)
    
    if remaining_objective_budget is not None:
        solver.Add(sum(objective_terms) <= remaining_objective_budget)
    
    # Objective: Maximize the sum of the optimization metric
    solver.Maximize(sum(objective_terms))
    
    # Solve
    status = solver.Solve()
    
    achieved_objective = locked_objective_value  # Start with locked contribution
    
    if status == pywraplp.Solver.OPTIMAL or status == pywraplp.Solver.FEASIBLE:
        # Extract solution
        for j, col in enumerate(open_columns):
            for i, player in enumerate(player_list):
                if x[i, j].solution_value() > 0.5:
                    optimized_row[col] = player['player_names']
                    achieved_objective += player.get(optimize_by, player.get('median', 0))
                    break
    
    return optimized_row, achieved_objective


def optimize_lineup(
    working_frame: pd.DataFrame,
    projections_df: pd.DataFrame,
    player_columns: list,
    map_dict: dict,
    lock_teams: list,
    site_var: str,
    type_var: str,
    sport_var: str,
    salary_max: int,
    optimize_by: str = 'median',
    use_cascading: bool = True,  # NEW PARAMETER
    cascade_decrement: float = 0.01  # NEW PARAMETER
) -> pd.DataFrame:
    """
    Optimize all lineups in a portfolio using linear programming.
    
    Players from lock_teams are kept (locked), all other positions are cleared
    and re-optimized to find the best combination that fits the salary cap.
    
    Args:
        working_frame: DataFrame containing lineups to optimize
        projections_df: DataFrame with player projections (must have columns: 
                       player_names, team, position, salary, median, ownership)
        player_columns: List of column names containing player positions
        map_dict: Dictionary containing player mappings
        lock_teams: List of team names whose players should be KEPT (locked).
                   All other players will be cleared and re-optimized.
        site_var: 'Draftkings' or 'Fanduel'
        type_var: 'Classic' or 'Showdown'
        sport_var: Sport identifier (NFL, NBA, MLB, etc.)
        salary_max: Maximum salary cap for lineups
        optimize_by: 'median' or 'ownership' - which metric to optimize for (higher is better)
        use_cascading: If True, each row's max objective is constrained by previous row
        cascade_decrement: Amount to subtract from previous row's objective (default 0.01)
        
    Returns:
        DataFrame with optimized lineups
    """
    # Create a copy to avoid modifying the original
    optimized_frame = working_frame.copy()
    
    # Track the previous row's objective value
    previous_objective = None
    
    # Optimize each row
    for idx in optimized_frame.index:
        row = optimized_frame.loc[idx]
        
        # Set max_objective_value based on cascading setting
        max_objective = None
        if use_cascading and previous_objective is not None:
            max_objective = previous_objective - cascade_decrement
        
        optimized_row, achieved_objective = optimize_single_lineup(
            row=row,
            player_columns=player_columns,
            player_pool=projections_df,
            map_dict=map_dict,
            lock_teams=lock_teams if lock_teams else [],
            type_var=type_var,
            sport_var=sport_var,
            salary_max=salary_max,
            optimize_by=optimize_by,
            max_objective_value=max_objective
        )
        optimized_frame.loc[idx] = optimized_row
        
        # Update previous_objective for next iteration
        previous_objective = achieved_objective
    
    return optimized_frame