James McCool commited on
Commit
21b08b7
·
1 Parent(s): bec9f9e

Adding optimization

Browse files
Files changed (3) hide show
  1. app.py +139 -0
  2. global_func/optimize_lineup.py +199 -67
  3. requirements.txt +2 -1
app.py CHANGED
@@ -28,6 +28,7 @@ from global_func.stratification_function import stratification_function
28
  from global_func.exposure_spread import exposure_spread
29
  from global_func.reassess_edge import reassess_edge
30
  from global_func.recalc_diversity import recalc_diversity
 
31
 
32
  from database_queries import *
33
  from database import *
@@ -3023,7 +3024,145 @@ if selected_tab == 'Manage Portfolio':
3023
  axis=1
3024
  )
3025
  st.session_state['export_merge'] = st.session_state['export_base'].copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3026
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3027
  with st.container():
3028
  if 'export_base' not in st.session_state:
3029
  st.session_state['export_base'] = pd.DataFrame(columns=st.session_state['working_frame'].columns)
 
28
  from global_func.exposure_spread import exposure_spread
29
  from global_func.reassess_edge import reassess_edge
30
  from global_func.recalc_diversity import recalc_diversity
31
+ from global_func.optimize_lineup import optimize_lineup
32
 
33
  from database_queries import *
34
  from database import *
 
3024
  axis=1
3025
  )
3026
  st.session_state['export_merge'] = st.session_state['export_base'].copy()
3027
+
3028
+ with st.expander('Lineup Reoptimization'):
3029
+ with st.form(key='Reoptimize'):
3030
+ optimize_by = st.selectbox("Optimize By", options=['median', 'ownership'], key='optimize_by')
3031
+ lock_teams_optimize = st.multiselect(
3032
+ "Locked Teams",
3033
+ options=sorted(list(set(st.session_state['projections_df']['team'].unique()))),
3034
+ default=[],
3035
+ key='lock_teams_optimize'
3036
+ )
3037
+ opt_submitted_col, opt_export_col = st.columns(2)
3038
+ st.info("Portfolio Button applies to your overall Portfolio, Export button applies to your Custom Export")
3039
+ with opt_submitted_col:
3040
+ opt_reg_submitted = st.form_submit_button("Portfolio")
3041
+ with opt_export_col:
3042
+ opt_exp_submitted = st.form_submit_button("Export")
3043
+
3044
+ if opt_reg_submitted:
3045
+ st.session_state['settings_base'] = False
3046
+ # Run optimization on working_frame
3047
+ optimized_frame = optimize_lineup(
3048
+ working_frame=st.session_state['working_frame'],
3049
+ projections_df=st.session_state['portfolio_inc_proj'],
3050
+ player_columns=st.session_state['player_columns'],
3051
+ map_dict=st.session_state['map_dict'],
3052
+ lock_teams=lock_teams_optimize,
3053
+ site_var=site_var,
3054
+ type_var=type_var,
3055
+ sport_var=sport_var,
3056
+ salary_max=salary_max,
3057
+ optimize_by=optimize_by
3058
+ )
3059
+
3060
+ # Recalculate lineup metrics
3061
+ optimized_frame = calculate_lineup_metrics(
3062
+ optimized_frame,
3063
+ st.session_state['player_columns'],
3064
+ st.session_state['map_dict'],
3065
+ type_var,
3066
+ sport_var,
3067
+ st.session_state['portfolio_inc_proj']
3068
+ )
3069
+
3070
+ st.session_state['working_frame'] = optimized_frame.reset_index(drop=True)
3071
+
3072
+ # Load Default base from compressed storage for reassess_edge
3073
+ default_base = load_base_frame('Default')
3074
+ st.session_state['working_frame'] = reassess_edge(
3075
+ st.session_state['working_frame'],
3076
+ default_base,
3077
+ st.session_state['map_dict'],
3078
+ site_var,
3079
+ type_var,
3080
+ Contest_Size,
3081
+ strength_var,
3082
+ sport_var,
3083
+ salary_max
3084
+ )
3085
 
3086
+ # Update Stack/Size columns if applicable
3087
+ team_dict = dict(zip(st.session_state['portfolio_inc_proj']['player_names'], st.session_state['portfolio_inc_proj']['team']))
3088
+ if 'Stack' in st.session_state['working_frame'].columns:
3089
+ st.session_state['working_frame']['Stack'] = st.session_state['working_frame'].apply(
3090
+ lambda row: Counter(
3091
+ team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]
3092
+ if team_dict.get(player, '') != ''
3093
+ ).most_common(1)[0][0] if any(team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]) else '',
3094
+ axis=1
3095
+ )
3096
+ st.session_state['working_frame']['Size'] = st.session_state['working_frame'].apply(
3097
+ lambda row: Counter(
3098
+ team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]
3099
+ if team_dict.get(player, '') != ''
3100
+ ).most_common(1)[0][1] if any(team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]) else 0,
3101
+ axis=1
3102
+ )
3103
+ st.session_state['export_merge'] = st.session_state['working_frame'].copy()
3104
+
3105
+ elif opt_exp_submitted:
3106
+ st.session_state['settings_base'] = False
3107
+ # Run optimization on export_base
3108
+ optimized_frame = optimize_lineup(
3109
+ working_frame=st.session_state['export_base'],
3110
+ projections_df=st.session_state['portfolio_inc_proj'],
3111
+ player_columns=st.session_state['player_columns'],
3112
+ map_dict=st.session_state['map_dict'],
3113
+ lock_teams=lock_teams_optimize,
3114
+ site_var=site_var,
3115
+ type_var=type_var,
3116
+ sport_var=sport_var,
3117
+ salary_max=salary_max,
3118
+ optimize_by=optimize_by
3119
+ )
3120
+
3121
+ # Recalculate lineup metrics for export
3122
+ optimized_frame = calculate_lineup_metrics(
3123
+ optimized_frame,
3124
+ st.session_state['player_columns'],
3125
+ st.session_state['map_dict'],
3126
+ type_var,
3127
+ sport_var,
3128
+ st.session_state['portfolio_inc_proj']
3129
+ )
3130
+
3131
+ st.session_state['export_base'] = optimized_frame.reset_index(drop=True)
3132
+
3133
+ # Load Default base from compressed storage for reassess_edge
3134
+ default_base = load_base_frame('Default')
3135
+ st.session_state['export_base'] = reassess_edge(
3136
+ st.session_state['export_base'],
3137
+ default_base,
3138
+ st.session_state['map_dict'],
3139
+ site_var,
3140
+ type_var,
3141
+ Contest_Size,
3142
+ strength_var,
3143
+ sport_var,
3144
+ salary_max
3145
+ )
3146
+
3147
+ # Update Stack/Size columns if applicable
3148
+ team_dict = dict(zip(st.session_state['portfolio_inc_proj']['player_names'], st.session_state['portfolio_inc_proj']['team']))
3149
+ if 'Stack' in st.session_state['export_base'].columns:
3150
+ st.session_state['export_base']['Stack'] = st.session_state['export_base'].apply(
3151
+ lambda row: Counter(
3152
+ team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]
3153
+ if team_dict.get(player, '') != ''
3154
+ ).most_common(1)[0][0] if any(team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]) else '',
3155
+ axis=1
3156
+ )
3157
+ st.session_state['export_base']['Size'] = st.session_state['export_base'].apply(
3158
+ lambda row: Counter(
3159
+ team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]
3160
+ if team_dict.get(player, '') != ''
3161
+ ).most_common(1)[0][1] if any(team_dict.get(player, '') for player in row[stack_column_dict[site_var][type_var][sport_var]]) else 0,
3162
+ axis=1
3163
+ )
3164
+ st.session_state['export_merge'] = st.session_state['export_base'].copy()
3165
+
3166
  with st.container():
3167
  if 'export_base' not in st.session_state:
3168
  st.session_state['export_base'] = pd.DataFrame(columns=st.session_state['working_frame'].columns)
global_func/optimize_lineup.py CHANGED
@@ -1,74 +1,206 @@
1
- import streamlit as st
2
- import numpy as np
3
  import pandas as pd
4
- import time
5
- from rapidfuzz import process
6
 
7
- def optimize_lineup(row):
8
- current_lineup = []
9
- total_salary = 0
10
- salary_cap = 50000
11
- used_players = set()
12
 
13
- # Convert row to dictionary with roster positions
14
- roster = {}
15
- for col, player in zip(row.index, row):
16
- if col not in ['salary', 'median', 'Own', 'Finish_percentile', 'Dupes', 'Lineup Edge']:
17
- roster[col] = {
18
- 'name': player,
19
- 'position': map_dict['pos_map'].get(player, '').split('/'),
20
- 'team': map_dict['team_map'].get(player, ''),
21
- 'salary': map_dict['salary_map'].get(player, 0),
22
- 'median': map_dict['proj_map'].get(player, 0),
23
- 'ownership': map_dict['own_map'].get(player, 0)
24
- }
25
- total_salary += roster[col]['salary']
26
- used_players.add(player)
27
 
28
- # Optimize each roster position in random order
29
- roster_positions = list(roster.items())
30
- random.shuffle(roster_positions)
31
-
32
- for roster_pos, current in roster_positions:
33
- # Skip optimization for players from removed teams
34
- if current['team'] in remove_teams_var:
35
- continue
36
-
37
- valid_positions = position_rules[roster_pos]
38
- better_options = []
39
 
40
- # Find valid replacements for this roster position
41
- for pos in valid_positions:
42
- if pos in position_groups:
43
- pos_options = [
44
- p for p in position_groups[pos]
45
- if p['median'] > current['median']
46
- and (total_salary - current['salary'] + p['salary']) <= salary_cap
47
- and p['player_names'] not in used_players
48
- and any(valid_pos in p['positions'] for valid_pos in valid_positions)
49
- and map_dict['team_map'].get(p['player_names']) not in remove_teams_var # Check team restriction
50
- ]
51
- better_options.extend(pos_options)
52
 
53
- if better_options:
54
- # Remove duplicates
55
- better_options = {opt['player_names']: opt for opt in better_options}.values()
56
-
57
- # Sort by median projection and take the best one
58
- best_replacement = max(better_options, key=lambda x: x['median'])
59
-
60
- # Update the lineup and tracking variables
61
- used_players.remove(current['name'])
62
- used_players.add(best_replacement['player_names'])
63
- total_salary = total_salary - current['salary'] + best_replacement['salary']
64
- roster[roster_pos] = {
65
- 'name': best_replacement['player_names'],
66
- 'position': map_dict['pos_map'][best_replacement['player_names']].split('/'),
67
- 'team': map_dict['team_map'][best_replacement['player_names']],
68
- 'salary': best_replacement['salary'],
69
- 'median': best_replacement['median'],
70
- 'ownership': best_replacement['ownership']
71
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- # Return optimized lineup maintaining original column order
74
- return [roster[pos]['name'] for pos in row.index if pos in roster]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pandas as pd
2
+ import numpy as np
3
+ from ortools.linear_solver import pywraplp
4
 
5
+ from global_func.exposure_spread import check_position_eligibility
 
 
 
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ def get_effective_salary(player_name: str, column_name: str, map_dict: dict, type_var: str) -> float:
9
+ """Calculate the effective salary for a player in a specific column (handles CPT multiplier)"""
10
+ base_salary = map_dict['salary_map'].get(player_name, 0)
11
+ if type_var != 'Classic' and column_name == 'CPT':
12
+ return base_salary * 1.5
13
+ return base_salary
 
 
 
 
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ def optimize_single_lineup(
17
+ row: pd.Series,
18
+ player_columns: list,
19
+ player_pool: pd.DataFrame,
20
+ map_dict: dict,
21
+ lock_teams: list,
22
+ type_var: str,
23
+ sport_var: str,
24
+ salary_max: int,
25
+ optimize_by: str = 'median'
26
+ ) -> pd.Series:
27
+ """
28
+ Optimize a single lineup row using linear programming.
29
+
30
+ Players from lock_teams are kept (locked), all other positions are cleared
31
+ and re-optimized using OR-Tools linear solver.
32
+
33
+ Args:
34
+ row: A single lineup row from the DataFrame
35
+ player_columns: List of column names containing player positions
36
+ player_pool: DataFrame of available players (projections_df)
37
+ map_dict: Dictionary containing player mappings
38
+ lock_teams: List of team names whose players should be KEPT (locked)
39
+ type_var: 'Classic' or 'Showdown'
40
+ sport_var: Sport identifier (NFL, NBA, MLB, etc.)
41
+ salary_max: Maximum salary cap for the lineup
42
+ optimize_by: 'median' or 'ownership' - which metric to optimize for
43
+
44
+ Returns:
45
+ Optimized row with potentially upgraded players
46
+ """
47
+ # Create a copy of the row to modify
48
+ optimized_row = row.copy()
49
+
50
+ # Identify locked players (from lock_teams) and open positions
51
+ locked_players = {} # {column: player_name}
52
+ open_columns = []
53
+ locked_salary = 0
54
+ locked_player_names = set()
55
+
56
+ for col in player_columns:
57
+ player_name = row[col]
58
+ player_team = map_dict['team_map'].get(player_name, '')
59
+
60
+ if player_team in lock_teams:
61
+ # Keep this player locked
62
+ locked_players[col] = player_name
63
+ locked_salary += get_effective_salary(player_name, col, map_dict, type_var)
64
+ locked_player_names.add(player_name)
65
+ else:
66
+ # This position is open for optimization
67
+ open_columns.append(col)
68
+
69
+ # If no open columns, nothing to optimize
70
+ if not open_columns:
71
+ return optimized_row
72
+
73
+ # Calculate remaining salary budget
74
+ remaining_salary = salary_max - locked_salary
75
+
76
+ # Filter player pool: exclude locked teams and already-locked players
77
+ available_players = player_pool[
78
+ (~player_pool['team'].isin(lock_teams)) &
79
+ (~player_pool['player_names'].isin(locked_player_names))
80
+ ].copy()
81
+
82
+ if available_players.empty:
83
+ return optimized_row
84
+
85
+ # Build the optimization model
86
+ solver = pywraplp.Solver.CreateSolver('CBC')
87
+ if not solver:
88
+ # Fallback if solver not available
89
+ return optimized_row
90
+
91
+ # Create decision variables: x[player_idx, col_idx] = 1 if player is assigned to column
92
+ player_list = available_players.to_dict('records')
93
+ num_players = len(player_list)
94
+ num_open_cols = len(open_columns)
95
+
96
+ # x[i][j] = 1 if player i is assigned to open column j
97
+ x = {}
98
+ for i in range(num_players):
99
+ for j in range(num_open_cols):
100
+ x[i, j] = solver.BoolVar(f'x_{i}_{j}')
101
+
102
+ # Constraint 1: Each open column gets exactly one player
103
+ for j in range(num_open_cols):
104
+ solver.Add(sum(x[i, j] for i in range(num_players)) == 1)
105
+
106
+ # Constraint 2: Each player can only be used once across all open columns
107
+ for i in range(num_players):
108
+ solver.Add(sum(x[i, j] for j in range(num_open_cols)) <= 1)
109
+
110
+ # Constraint 3: Position eligibility
111
+ for i, player in enumerate(player_list):
112
+ player_positions = player['position'].split('/')
113
+ for j, col in enumerate(open_columns):
114
+ if type_var == 'Classic':
115
+ if not check_position_eligibility(sport_var, col, player_positions):
116
+ solver.Add(x[i, j] == 0)
117
+ else:
118
+ # For Showdown, CPT and FLEX can take any player
119
+ pass
120
+
121
+ # Constraint 4: Total salary of selected players <= remaining_salary
122
+ salary_constraint = []
123
+ for i, player in enumerate(player_list):
124
+ for j, col in enumerate(open_columns):
125
+ effective_salary = get_effective_salary(player['player_names'], col, map_dict, type_var)
126
+ salary_constraint.append(x[i, j] * effective_salary)
127
+ solver.Add(sum(salary_constraint) <= remaining_salary)
128
+
129
+ # Objective: Maximize the sum of the optimization metric
130
+ objective_terms = []
131
+ for i, player in enumerate(player_list):
132
+ metric_value = player.get(optimize_by, player.get('median', 0))
133
+ for j in range(num_open_cols):
134
+ objective_terms.append(x[i, j] * metric_value)
135
+
136
+ solver.Maximize(sum(objective_terms))
137
+
138
+ # Solve
139
+ status = solver.Solve()
140
+
141
+ if status == pywraplp.Solver.OPTIMAL or status == pywraplp.Solver.FEASIBLE:
142
+ # Extract solution
143
+ for j, col in enumerate(open_columns):
144
+ for i, player in enumerate(player_list):
145
+ if x[i, j].solution_value() > 0.5:
146
+ optimized_row[col] = player['player_names']
147
+ break
148
+
149
+ return optimized_row
150
+
151
 
152
+ def optimize_lineup(
153
+ working_frame: pd.DataFrame,
154
+ projections_df: pd.DataFrame,
155
+ player_columns: list,
156
+ map_dict: dict,
157
+ lock_teams: list,
158
+ site_var: str,
159
+ type_var: str,
160
+ sport_var: str,
161
+ salary_max: int,
162
+ optimize_by: str = 'median'
163
+ ) -> pd.DataFrame:
164
+ """
165
+ Optimize all lineups in a portfolio using linear programming.
166
+
167
+ Players from lock_teams are kept (locked), all other positions are cleared
168
+ and re-optimized to find the best combination that fits the salary cap.
169
+
170
+ Args:
171
+ working_frame: DataFrame containing lineups to optimize
172
+ projections_df: DataFrame with player projections (must have columns:
173
+ player_names, team, position, salary, median, ownership)
174
+ player_columns: List of column names containing player positions
175
+ map_dict: Dictionary containing player mappings
176
+ lock_teams: List of team names whose players should be KEPT (locked).
177
+ All other players will be cleared and re-optimized.
178
+ site_var: 'Draftkings' or 'Fanduel'
179
+ type_var: 'Classic' or 'Showdown'
180
+ sport_var: Sport identifier (NFL, NBA, MLB, etc.)
181
+ salary_max: Maximum salary cap for lineups
182
+ optimize_by: 'median' or 'ownership' - which metric to optimize for (higher is better)
183
+
184
+ Returns:
185
+ DataFrame with optimized lineups
186
+ """
187
+ # Create a copy to avoid modifying the original
188
+ optimized_frame = working_frame.copy()
189
+
190
+ # Optimize each row
191
+ for idx in optimized_frame.index:
192
+ row = optimized_frame.loc[idx]
193
+ optimized_row = optimize_single_lineup(
194
+ row=row,
195
+ player_columns=player_columns,
196
+ player_pool=projections_df,
197
+ map_dict=map_dict,
198
+ lock_teams=lock_teams if lock_teams else [],
199
+ type_var=type_var,
200
+ sport_var=sport_var,
201
+ salary_max=salary_max,
202
+ optimize_by=optimize_by
203
+ )
204
+ optimized_frame.loc[idx] = optimized_row
205
+
206
+ return optimized_frame
requirements.txt CHANGED
@@ -5,4 +5,5 @@ numpy
5
  rapidfuzz
6
  matplotlib
7
  scipy
8
- pytz
 
 
5
  rapidfuzz
6
  matplotlib
7
  scipy
8
+ pytz
9
+ ortools