diff --git a/pydfs_lineup_optimizer/constants.py b/pydfs_lineup_optimizer/constants.py index c6bb45a..46e2c57 100644 --- a/pydfs_lineup_optimizer/constants.py +++ b/pydfs_lineup_optimizer/constants.py @@ -1,6 +1,3 @@ -from enum import Enum - - class Site: DRAFTKINGS = 'DRAFTKINGS' FANDUEL = 'FANDUEL' diff --git a/pydfs_lineup_optimizer/exceptions.py b/pydfs_lineup_optimizer/exceptions.py index 0af4892..712f4f8 100644 --- a/pydfs_lineup_optimizer/exceptions.py +++ b/pydfs_lineup_optimizer/exceptions.py @@ -1,3 +1,6 @@ +from typing import List + + class LineupOptimizerException(Exception): def __init__(self, message: str): self.message = message @@ -17,3 +20,14 @@ class LineupOptimizerIncorrectPositionName(LineupOptimizerException): class LineupOptimizerIncorrectCSV(LineupOptimizerException): def __init__(self, message: str = 'Incorrect csv format!'): super(LineupOptimizerIncorrectCSV, self).__init__(message) + + +class GenerateLineupException(LineupOptimizerException): + def __init__(self, invalid_constraints: List[str]): + self.invalid_constraints = invalid_constraints + + def __str__(self): + msg = 'Can\'t generate lineups.' + if self.invalid_constraints: + msg += ' Following constraints are not valid: %s' % ','.join(self.invalid_constraints) + return msg diff --git a/pydfs_lineup_optimizer/lineup_optimizer.py b/pydfs_lineup_optimizer/lineup_optimizer.py index ff6eb97..d20a43f 100644 --- a/pydfs_lineup_optimizer/lineup_optimizer.py +++ b/pydfs_lineup_optimizer/lineup_optimizer.py @@ -1,18 +1,17 @@ -import warnings from collections import OrderedDict from itertools import chain from math import ceil -from typing import FrozenSet, Type, Generator, Tuple, Union, Optional, List, Dict, Set, cast +from typing import FrozenSet, Type, Generator, Tuple, Optional, List, Dict, Set from pydfs_lineup_optimizer.lineup import Lineup -from pydfs_lineup_optimizer.solvers import Solver, PuLPSolver, SolverException +from pydfs_lineup_optimizer.solvers import Solver, PuLPSolver, SolverInfeasibleSolutionException from pydfs_lineup_optimizer.exceptions import LineupOptimizerException, LineupOptimizerIncorrectTeamName, \ - LineupOptimizerIncorrectPositionName + LineupOptimizerIncorrectPositionName, GenerateLineupException from pydfs_lineup_optimizer.lineup_importer import CSVImporter from pydfs_lineup_optimizer.settings import BaseSettings from pydfs_lineup_optimizer.player import Player, LineupPlayer, GameInfo -from pydfs_lineup_optimizer.utils import ratio, link_players_with_positions, process_percents, get_remaining_positions +from pydfs_lineup_optimizer.utils import ratio, link_players_with_positions, get_remaining_positions from pydfs_lineup_optimizer.rules import * -from pydfs_lineup_optimizer.stacks import BaseGroup, TeamStack, PositionsStack, BaseStack, Stack +from pydfs_lineup_optimizer.stacks import BaseGroup, BaseStack, Stack from pydfs_lineup_optimizer.context import OptimizationContext from pydfs_lineup_optimizer.statistics import Statistic from pydfs_lineup_optimizer.exposure_strategy import BaseExposureStrategy, TotalExposureStrategy @@ -253,15 +252,6 @@ def set_players_with_same_position(self, positions: Dict[str, int]): self._check_position_constraint(pos) self.players_with_same_position = positions - # def set_positions_for_same_team(self, *positions_stacks: List[Union[str, Tuple[str, ...]]]): - # warnings.simplefilter('always', DeprecationWarning) - # warnings.warn('set_positions_for_same_team method will be removed in 3.3, use add_stack instead', DeprecationWarning) - # if positions_stacks and positions_stacks[0] is not None: - # team_stacks = [ - # PositionsStack(stack, max_exposure_per_team=self.teams_exposures) for stack in positions_stacks] - # for stack in team_stacks: - # self.add_stack(stack) - def set_max_repeating_players(self, max_repeating_players: int): if max_repeating_players >= self.total_players: raise LineupOptimizerException('Maximum repeating players should be smaller than %d' % self.total_players) @@ -286,14 +276,6 @@ def set_projected_ownership( else: self.remove_rule(ProjectedOwnershipRule) - # def set_team_stacking(self, stacks: Optional[List[int]], for_positions: Optional[List[str]] = None): - # warnings.simplefilter('always', DeprecationWarning) - # warnings.warn('set_team_stacking method will be removed in 3.3, use add_stack instead', DeprecationWarning) - # if stacks: - # team_stacks = [TeamStack(stack, for_positions=for_positions, max_exposure_per_team=self.teams_exposures) for stack in stacks] - # for stack in team_stacks: - # self.add_stack(stack) - def restrict_positions_for_opposing_team( self, first_team_positions: List[str], @@ -415,8 +397,8 @@ def optimize( return for constraint in constraints: constraint.post_optimize(variables_names) - except SolverException: - raise LineupOptimizerException('Can\'t generate lineups') + except SolverInfeasibleSolutionException as solver_exception: + raise GenerateLineupException(solver_exception.get_user_defined_constraints()) self.last_context = context def optimize_lineups(self, lineups: List[Lineup]): @@ -462,8 +444,8 @@ def optimize_lineups(self, lineups: List[Lineup]): return for constraint in constraints: constraint.post_optimize(variables_names) - except SolverException: - raise LineupOptimizerException('Can\'t generate lineups') + except SolverInfeasibleSolutionException as solver_exception: + raise GenerateLineupException(solver_exception.get_user_defined_constraints()) self.last_context = context def print_statistic(self) -> None: diff --git a/pydfs_lineup_optimizer/rules.py b/pydfs_lineup_optimizer/rules.py index efc17b8..f9aa472 100644 --- a/pydfs_lineup_optimizer/rules.py +++ b/pydfs_lineup_optimizer/rules.py @@ -94,7 +94,8 @@ class TotalPlayersRule(OptimizerRule): def apply(self, solver): if self.optimizer.total_players: variables = self.players_dict.values() - solver.add_constraint(variables, None, SolverSign.EQ, self.optimizer.total_players) + solver.add_constraint(variables, None, SolverSign.EQ, self.optimizer.total_players, + name='total_players') class LineupBudgetRule(OptimizerRule): @@ -106,7 +107,7 @@ def apply(self, solver): for player, variable in self.players_dict.items(): variables.append(variable) coefficients.append(player.salary) - solver.add_constraint(variables, coefficients, SolverSign.LTE, self.optimizer.budget) + solver.add_constraint(variables, coefficients, SolverSign.LTE, self.optimizer.budget, name='budget') class LockedPlayersRule(OptimizerRule): @@ -132,9 +133,9 @@ def apply_for_iteration(self, solver, result): if player not in removed_players: force_variables.append(self.players_dict[player]) if force_variables: - solver.add_constraint(force_variables, None, SolverSign.EQ, len(force_variables)) + solver.add_constraint(force_variables, None, SolverSign.EQ, len(force_variables), name='locked_players') if exclude_variables: - solver.add_constraint(exclude_variables, None, SolverSign.EQ, 0) + solver.add_constraint(exclude_variables, None, SolverSign.EQ, 0, name='exclude_players') def post_optimize(self, solved_variables: List[str]): self.max_exposure_strategy.set_used(solved_variables) @@ -161,14 +162,16 @@ def apply(self, solver): players_with_position = set() for pos in position: players_with_position.update(players_by_positions[pos]) - solver.add_constraint(players_with_position, None, SolverSign.GTE, places + extra) + solver.add_constraint(players_with_position, None, SolverSign.GTE, places + extra, + name='positions_%s' % '_'.join(position)) class TeamMatesRule(OptimizerRule): def apply(self, solver): for team, quantity in self.optimizer.players_from_one_team.items(): players_from_same_team = [variable for player, variable in self.players_dict.items() if player.team == team] - solver.add_constraint(players_from_same_team, None, SolverSign.EQ, quantity) + solver.add_constraint(players_from_same_team, None, SolverSign.EQ, quantity, + name='players_from_one_team_%s' % team) class MaxFromOneTeamRule(OptimizerRule): @@ -177,7 +180,8 @@ def apply(self, solver): return for team in self.optimizer.available_teams: players_from_team = [variable for player, variable in self.players_dict.items() if player.team == team] - solver.add_constraint(players_from_team, None, SolverSign.LTE, self.optimizer.max_from_one_team) + solver.add_constraint(players_from_team, None, SolverSign.LTE, self.optimizer.max_from_one_team, + name='max_from_one_team_%s' % team) class MinSalaryCapRule(OptimizerRule): @@ -188,13 +192,13 @@ def apply(self, solver): variables.append(variable) coefficients.append(player.salary) min_salary_cap = self.optimizer.min_salary_cap - solver.add_constraint(variables, coefficients, SolverSign.GTE, min_salary_cap) + solver.add_constraint(variables, coefficients, SolverSign.GTE, min_salary_cap, name='min_salary_cap') class RemoveInjuredRule(OptimizerRule): def apply(self, solver): injured_players_variables = [variable for player, variable in self.players_dict.items() if player.is_injured] - solver.add_constraint(injured_players_variables, None, SolverSign.EQ, 0) + solver.add_constraint(injured_players_variables, None, SolverSign.EQ, 0, name='exclude_injured') class MaxRepeatingPlayersRule(OptimizerRule): @@ -207,8 +211,9 @@ def apply_for_iteration(self, solver, result): if max_repeating_players is None or not result: return self.exclude_combinations.append([self.players_dict[player] for player in result]) - for players_combination in self.exclude_combinations: - solver.add_constraint(players_combination, None, SolverSign.LTE, max_repeating_players) + for i, players_combination in enumerate(self.exclude_combinations, start=1): + solver.add_constraint(players_combination, None, SolverSign.LTE, max_repeating_players, + name='max_repeating_lineup_%d' % i) class ProjectedOwnershipRule(OptimizerRule): @@ -229,9 +234,9 @@ def apply(self, solver): max_variables.append(variable) max_coefficients.append(player.projected_ownership - max_projected_ownership) if min_variables: - solver.add_constraint(min_variables, min_coefficients, SolverSign.GTE, 0) + solver.add_constraint(min_variables, min_coefficients, SolverSign.GTE, 0, name='ownership_min') if max_variables: - solver.add_constraint(max_variables, max_coefficients, SolverSign.LTE, 0) + solver.add_constraint(max_variables, max_coefficients, SolverSign.LTE, 0, name='ownership_max') class UniquePlayerRule(OptimizerRule): @@ -246,7 +251,7 @@ def apply(self, solver): if len(group) == 1: continue variables = [variable for player, variable in group] - solver.add_constraint(variables, None, SolverSign.LTE, 1) + solver.add_constraint(variables, None, SolverSign.LTE, 1, name='unique_player_%s' % player_id) class LateSwapRule(OptimizerRule): @@ -497,7 +502,8 @@ def apply(self, solver): list_intersection(player.positions, self.HITTERS)} for team in self.optimizer.available_teams: players_from_team = [variable for player, variable in players_dict.items() if player.team == team] - solver.add_constraint(players_from_team, None, SolverSign.LTE, self.MAXIMUM_HITTERS_FROM_ONE_TEAM) + solver.add_constraint(players_from_team, None, SolverSign.LTE, self.MAXIMUM_HITTERS_FROM_ONE_TEAM, + name='fanduel_max_hitters_%s' % team) class TotalTeamsRule(OptimizerRule): @@ -520,15 +526,15 @@ def apply(self, solver): solver.add_constraint(variables, None, SolverSign.LTE, variable * total_players) solver.add_constraint(variables, None, SolverSign.GTE, variable) if total_teams: - solver.add_constraint(teams_variables, None, SolverSign.EQ, total_teams) + solver.add_constraint(teams_variables, None, SolverSign.EQ, total_teams, name='total_teams') else: - solver.add_constraint(teams_variables, None, SolverSign.GTE, min_teams) + solver.add_constraint(teams_variables, None, SolverSign.GTE, min_teams, name='min_teams') class FanduelSingleGameMaxQBRule(OptimizerRule): def apply(self, solver): variables = [var for player, var in self.players_dict.items() if 'QB' in player.original_positions] - solver.add_constraint(variables, None, SolverSign.LTE, 2) + solver.add_constraint(variables, None, SolverSign.LTE, 2, name='fanduel_single_game_max_qb') class MinStartersRule(OptimizerRule): @@ -537,7 +543,7 @@ def apply(self, solver): if not min_starters: return variables = [variable for player, variable in self.players_dict.items() if player.is_confirmed_starter] - solver.add_constraint(variables, None, SolverSign.GTE, min_starters) + solver.add_constraint(variables, None, SolverSign.GTE, min_starters, name='min_starters') class MinGamesRule(OptimizerRule): @@ -558,7 +564,7 @@ def apply(self, solver): solver.add_constraint(variables, None, SolverSign.LTE, variable * total_players) solver.add_constraint(variables, None, SolverSign.GTE, variable) if len(game_variables) >= min_games: - solver.add_constraint(game_variables, None, SolverSign.GTE, min_games) + solver.add_constraint(game_variables, None, SolverSign.GTE, min_games, name='min_games') class DraftKingsBaseballRosterRule(OptimizerRule): @@ -570,7 +576,8 @@ def apply(self, solver): list_intersection(player.positions, self.HITTERS)} for team in self.optimizer.available_teams: players_from_team = [variable for player, variable in players_dict.items() if player.team == team] - solver.add_constraint(players_from_team, None, SolverSign.LTE, self.MAXIMUM_HITTERS_FROM_ONE_TEAM) + solver.add_constraint(players_from_team, None, SolverSign.LTE, self.MAXIMUM_HITTERS_FROM_ONE_TEAM, + name='dk_max_hitters_%s' % team) class DraftKingsTiersRule(OptimizerRule): @@ -581,4 +588,4 @@ def sort_player(player): def apply(self, solver): for tier, players in groupby(sorted(self.players_dict.keys(), key=self.sort_player), key=self.sort_player): variables = [self.players_dict[player] for player in players] - solver.add_constraint(variables, None, SolverSign.EQ, 1) + solver.add_constraint(variables, None, SolverSign.EQ, 1, name='dk_tier_%s' % tier) diff --git a/pydfs_lineup_optimizer/solvers/__init__.py b/pydfs_lineup_optimizer/solvers/__init__.py index 91ea204..891592d 100644 --- a/pydfs_lineup_optimizer/solvers/__init__.py +++ b/pydfs_lineup_optimizer/solvers/__init__.py @@ -1,6 +1,6 @@ from pydfs_lineup_optimizer.solvers.pulp_solver import Solver, PuLPSolver from pydfs_lineup_optimizer.solvers.constants import SolverSign -from pydfs_lineup_optimizer.solvers.exceptions import SolverException +from pydfs_lineup_optimizer.solvers.exceptions import SolverException, SolverInfeasibleSolutionException -__all__ = ['Solver', 'PuLPSolver', 'SolverSign', 'SolverException'] +__all__ = ['Solver', 'PuLPSolver', 'SolverSign', 'SolverException', 'SolverInfeasibleSolutionException'] diff --git a/pydfs_lineup_optimizer/solvers/base.py b/pydfs_lineup_optimizer/solvers/base.py index 7554bdc..34d7600 100644 --- a/pydfs_lineup_optimizer/solvers/base.py +++ b/pydfs_lineup_optimizer/solvers/base.py @@ -14,7 +14,8 @@ def set_objective(self, variables: Iterable[Any], coefficients: Iterable[float]) def add_variable(self, name: str, min_value: Optional[int] = None, max_value: Optional[int] = None) -> Any: raise NotImplementedError - def add_constraint(self, variables: Iterable[Any], coefficients: Optional[Iterable[float]], sign: str, rhs: float): + def add_constraint(self, variables: Iterable[Any], coefficients: Optional[Iterable[float]], sign: str, rhs: float, + name: Optional[str] = None): raise NotImplementedError def solve(self) -> List[Any]: diff --git a/pydfs_lineup_optimizer/solvers/exceptions.py b/pydfs_lineup_optimizer/solvers/exceptions.py index 4d91556..380f188 100644 --- a/pydfs_lineup_optimizer/solvers/exceptions.py +++ b/pydfs_lineup_optimizer/solvers/exceptions.py @@ -1,2 +1,13 @@ +from typing import List + + class SolverException(Exception): pass + + +class SolverInfeasibleSolutionException(SolverException): + def __init__(self, invalid_constraints: List[str]): + self.invalid_constraints = invalid_constraints + + def get_user_defined_constraints(self) -> List[str]: + return [name for name in self.invalid_constraints if not name.startswith('_')] diff --git a/pydfs_lineup_optimizer/solvers/pulp_solver.py b/pydfs_lineup_optimizer/solvers/pulp_solver.py index 96c8c0a..2d293c3 100644 --- a/pydfs_lineup_optimizer/solvers/pulp_solver.py +++ b/pydfs_lineup_optimizer/solvers/pulp_solver.py @@ -1,7 +1,7 @@ from pulp import LpProblem, LpMaximize, LpVariable, lpSum, LpStatusOptimal, LpBinary, LpInteger, PULP_CBC_CMD from .base import Solver from .constants import SolverSign -from .exceptions import SolverException +from .exceptions import SolverException, SolverInfeasibleSolutionException class PuLPSolver(Solver): @@ -21,19 +21,19 @@ def add_variable(self, name, min_value=None, max_value=None): def set_objective(self, variables, coefficients): self.prob += lpSum([variable * coefficient for variable, coefficient in zip(variables, coefficients)]) - def add_constraint(self, variables, coefficients, sign, rhs): + def add_constraint(self, variables, coefficients, sign, rhs, name=None): if coefficients: lhs = [variable * coefficient for variable, coefficient in zip(variables, coefficients)] else: lhs = variables if sign == SolverSign.EQ: - self.prob += lpSum(lhs) == rhs + self.prob += lpSum(lhs) == rhs, name elif sign == SolverSign.NOT_EQ: - self.prob += lpSum(lhs) != rhs + self.prob += lpSum(lhs) != rhs, name elif sign == SolverSign.GTE: - self.prob += lpSum(lhs) >= rhs + self.prob += lpSum(lhs) >= rhs, name elif sign == SolverSign.LTE: - self.prob += lpSum(lhs) <= rhs + self.prob += lpSum(lhs) <= rhs, name else: raise SolverException('Incorrect constraint sign') @@ -52,4 +52,5 @@ def solve(self): result.append(variable) return result else: - raise SolverException('Unable to solve') + invalid_constraints = [name for name, constraint in self.prob.constraints.items() if not constraint.valid()] + raise SolverInfeasibleSolutionException(invalid_constraints) diff --git a/tests/test_rules.py b/tests/test_rules.py index 99fca6e..a07b70b 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -574,7 +574,7 @@ def setUp(self): Player('11', '11', '11', ['P'], 'ARI', 3000, 5), Player('12', '12', '12', ['SS'], 'NY', 3000, 5), Player('13', '13', '13', ['OF'], 'POR', 3000, 5), - Player('13', '13', '13', ['OF'], 'MIN', 3000, 50), + Player('14', '14', '14', ['OF'], 'MIN', 3000, 50), ] self.optimizer = get_optimizer(Site.FANDUEL, Sport.BASEBALL) self.optimizer.settings.min_teams = 3