4
\$\begingroup\$

I'm trying to write a function that takes a sports bet and returns its Net result.

If you are not familiar with betting, an exhaustive test suite should help with understanding what the program is supposed to do. How can I solve this problem in a way distinct from enumerating all of the possible cases?

SUPPORTED_BET_TYPES = ['total', 'handicap']
SIDES = ['home', 'away', 'over', 'under']
BET_OUTCOMES = ['Won', 'Lost', 'Cancelled', 'Half Won', 'Half Lost']


def settle_bet(bet_type, side, points, price, bet_amount, home_score, away_score):
    '''Returns a result of the bet'''

    if bet_type == 'total':
        return settle_total_bet(side, points, price, bet_amount, home_score, away_score)
    elif bet_type == 'handicap':
        return settle_handicap_bet(side, points, price, bet_amount, home_score, away_score)


def settle_total_bet(side, points, price, bet_amount, home_score, away_score):
    '''Returns Net result of the bet on total'''
    outcome = determine_total_bet_outcome(side, points, home_score, away_score)

    if outcome == 'Won':
        return bet_amount * (price - 1)
    elif outcome == 'Half Won':
        return bet_amount * ((price - 1) / 2)
    elif outcome == 'Cancelled':
        return 0
    elif outcome == 'Half Lost':
        return bet_amount * (-1 / 2)
    else:
        return bet_amount * -1


def determine_total_bet_outcome(side, points, home_score, away_score):
    '''Returns the appropriate outcome of the bet from BET_OUTCOMES'''
    total_score = home_score + away_score
    points_score_diff = points - total_score

    if points_score_diff == 0:
        return 'Cancelled'
    elif points_score_diff == 0.25:
        if side == 'over':
            return 'Half Lost'
        else:
            return 'Half Won'
    elif points_score_diff == -0.25:
        if side == 'over':
            return 'Half Won'
        else:
            return 'Half Lost'
    elif points_score_diff >= 0.5:
        if side == 'over':
            return 'Lost'
        else:
            return 'Won'
    elif points_score_diff <= -0.5:
        if side == 'over':
            return 'Won'
        else:
            return 'Lost'


def test():
    # Bets on Total

    # Won or Lost
    assert settle_bet('total', 'over', 2.5, 1.90, 100, 3, 2) == 100 * (1.90 - 1)
    assert settle_bet('total', 'over', 3.5, 1.85, 100, 0, 1) == 100 * -1
    assert settle_bet('total', 'under', 2.5, 1.94, 100, 0, 0) == 100 * (1.94 - 1)
    assert settle_bet('total', 'under', 3.5, 1.75, 100, 1, 3) == 100 * -1

    # Won or Lost Or Cancelled
    assert settle_bet('total', 'over', 3.0, 1.82, 100, 2, 2) == 100 * (1.82 - 1)
    assert settle_bet('total', 'over', 3.0, 1.82, 100, 1, 2) == 100 * 0
    assert settle_bet('total', 'over', 3.0, 1.82, 100, 0, 0) == 100 * -1

    assert settle_bet('total', 'under', 3.0, 1.82, 100, 2, 2) == 100 * -1
    assert settle_bet('total', 'under', 3.0, 1.82, 100, 1, 2) == 100 * 0
    assert settle_bet('total', 'under', 3.0, 1.82, 100, 0, 0) == 100 * (1.82 - 1)

    # Won or Lost or Half Won or Half Lost
    assert settle_bet('total', 'over', 2.25, 1.95, 100, 2, 1) == 100 * (1.95 - 1)
    assert settle_bet('total', 'over', 2.25, 1.90, 100, 0, 0) == 100 * -1
    assert settle_bet('total', 'over', 2.25, 1.80, 100, 1, 1) == 100 * (-1 / 2)

    assert settle_bet('total', 'under', 2.25, 1.95, 100, 2, 1) == 100 * -1
    assert settle_bet('total', 'under', 2.25, 1.90, 100, 0, 0) == 100 * (1.90 - 1)
    assert settle_bet('total', 'under', 2.25, 1.80, 100, 1, 1) == 100 * ((1.80 - 1) / 2)

    assert settle_bet('total', 'over', 2.75, 1.90, 100, 3, 3) == 100 * (1.90 - 1)
    assert settle_bet('total', 'over', 2.75, 1.88, 100, 1, 2) == 100 * ((1.88 - 1) / 2)
    assert settle_bet('total', 'over', 2.75, 1.90, 100, 1, 0) == 100 * -1

    assert settle_bet('total', 'under', 2.75, 1.90, 100, 3, 3) == 100 * -1
    assert settle_bet('total', 'under', 2.75, 1.88, 100, 1, 2) == 100 * (-1 / 2)
    assert settle_bet('total', 'under', 2.75, 1.90, 100, 1, 0) == 100 * (1.90 - 1)

    print("All tests passed.")


if __name__ == '__main__':
    test()
\$\endgroup\$
  • \$\begingroup\$ Your question is off-topic here, because we require code to be completely working (to the best of the authors knowledge), before we review it. You can ask these kind of questions on stackoverflow.com \$\endgroup\$ – vulpini99 May 22 at 9:06
  • \$\begingroup\$ The code is working! The tests pass. That's funny, that I asked the same question on stackoverlow and some user suggested that it should have been asken on codereview! Please take a look stackoverflow.com/questions/61951015/… \$\endgroup\$ – Konstantin Kostanzhoglo May 22 at 9:08
  • \$\begingroup\$ "I couldn't finish settling bets on handicap since it led to some enormous wall of nested if/else statements". That doesn't sound like the code is working. If your code really is working completely, I suggest to edit your question. That will help to prevent misunderstandings. \$\endgroup\$ – vulpini99 May 22 at 10:29
  • \$\begingroup\$ I had deleted unfinished part before posting and posted a complete working program (though without a part that I had initially intended to do) so that formally everything is ok. \$\endgroup\$ – Konstantin Kostanzhoglo May 22 at 10:33
  • 2
    \$\begingroup\$ Fine, I have deleted that part. Feel free to go ahead :) \$\endgroup\$ – Konstantin Kostanzhoglo May 22 at 10:46
4
\$\begingroup\$

Tests

You've written some - great! Keep that up. If you want to add more structure, consider Python's unittest library.

Unused globals

BET_OUTCOMES, SUPPORTED_BET_TYPES and SIDES are not used. My assumption is that this is related to the other segment of your code that you deleted. If it stays deleted, then delete these, too.

Similarly, this docstring:

'''Returns the appropriate outcome of the bet from BET_OUTCOMES'''

is now incorrect.

Stringly-typed variables

bet_type being either total or handicap should not be represented as a string. It should be represented as an Enum, or maybe if there will remain only two states, a boolean such as is_handicap_bet. The same applies to outcome.

Negation

bet_amount * -1 should be -bet_amount. bet_amount * (-1 / 2) should be -bet_amount / 2.

| improve this answer | |
\$\endgroup\$
  • 1
    \$\begingroup\$ Thanks for you review! Globals are provided for readers which are not familiar with gambling rather than to be used in the code itself. The docstring should be fine because the function returns only the values listed in BET_OUTCOMES. Agree that stringly-typed variables could be replaced with enum. Enum seems more suitable than boolean because it allows to add other bet types such as "individual total" or "to qualify". Negation in tests is used in order to show how the result is obtained. \$\endgroup\$ – Konstantin Kostanzhoglo May 24 at 19:28
4
\$\begingroup\$

This problem appears to be a poster child for basic object-oriented programming. But please note that I don't know anything about sports betting, so I'll probably get some of the details wrong.

A good rule of thumb is this: If you find yourself switching on internal data, look for a class instead.

In your case, you switch on bet_type, you switch on outcome, you switch on side, and you switch on points_scored_diff. I'm willing to ... bet ... that there are some class behaviors to be found in all that.

Since you aren't using classes in your code, I'm going to assume you might not be familiar with them. So I'll keep this as straightforward as possible. (If you are doing this for homework and forbidden to use classes, you should have mentioned that -- some of this could be tuples with lambdas.

class WagerOutcome:
    """ Base class for wager outcomes.
    """
    def __init__(self, name, price):
        self.price = price
        self.name = name

    def __str__(self) -> str:
        return self.name

class TotalWagerWon(WagerOutcome):
    def __init__(self, price):
        super().__init__("Won", price)

    def payout(self, bet_amount: float) -> float:
        return bet_amount * (self.price - 1)

    def __str__(self) -> str:
        """ Stringify this object. Because the price affects the payout for this 
            outcome, I am including price in the display.
        """
        return f"{self.name}({self.price})"

You can figure out the rest, I suspect.

With outcomes now a class, let's turn to the bet types. You didn't include any examples of handicap bets, which is unfortunate. Other than simple point handicaps, I can't imagine what else there would be. So I'm ignoring handicap types.

class Wager:
    """ Base class for all wager types.
    """
    def __init__(self, *, type: str, side: str, points: SupportsFloat = None, price: SupportsFloat, amount: SupportsFloat):
        self.type = type
        self.side = side
        self.points = float(points)
        self.price = float(price)
        self.amount = float(amount)

class WagerTotalHome(Wager):
    """ Total wager on home team.
    """
    def __init__(self, *, price: SupportsFloat, amount: SupportsFloat, points: int = None):
        super().__init__(type='total', side='home', price=price, amount=amount, points=points)

    def get_outcome(self, home_points, away_points):
        """ Determine outcome by points scored. Return a WagerOutcome.
        """
        if home_points > away_points:
            return TotalWagerWon(self.price)
        else:
            return TotalWagerLost(self.price)

Now I can write a factory function that maps the strings into types:

def make_wager(type, side, points, price, amount) -> Wager:
    """ Construct and return Wager objects of a type determined by the arguments.
    """
    wager_classes = {
        ('total', 'home'): WagerTotalHome,
        ('total', 'away'): WagerTotalAway,
        ('total', 'over'): WagerTotalOver,
        ('total', 'under'): WagerTotalUnder,
    }

    klass = wager_classes[(type, side)]
    wager = klass(points=points, price=price, amount=amount)
    return wager

Then I can say:

bet = make_wager('total', 'home', price=2, amount=100)
outcome = bet.get_outcome(3, 0)
payout = outcome.payout(bet.amount)

That last bit is a little shaky: I really shouldn't have to feed in the bet.amount if the outcome is returned by the bet. But I'm not clear where your trouble with handicap bets lies, so I left things loose.

The point, really, is that using objects/classes, I can move the "wall of if/else" statements into a collection of discrete behaviors. Once we know the bet type is "WagerTotalHome", there is no more need for if/else statements involving the type or the side. All that remains is to determine the outcome of the wager and return that.

Knowing the outcome, there is no need for if/else statements about anything. Simply encode the computation of the payout using the price and bet amount, and return that.

The one remaining bit of complexity is mapping input strings (or dropdown menu items or whatever) onto wager classes. The factory function uses a dictionary of tuples for that, so it's not as bad as it might first seem.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ First of all, thanks for taking time to write such a detailed review! Let's start with Outcome classes and in order to keep things simple lets ignore handicap bets for now and focus on totals, I have tried to go on on your suggestion and create a class for all of the 5 possible outcomes, here is my code: pastebin.com/DQnr19cU As far as Wager class is concerned I don't understand what * parameter means in __init__ method and why points parameter has an int type whereas it can be 2.5 or 3.25. Regarding to make_wager(): is it a stand-alone function? \$\endgroup\$ – Konstantin Kostanzhoglo May 24 at 21:01
  • \$\begingroup\$ The * froces named args. points was int because I don't know sports betting -- I'll edit it to float. Yes, make_wager is an example of a factory function. You could also use a factory method on a base class, if you prefer. \$\endgroup\$ – Austin Hastings May 25 at 11:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.