2
\$\begingroup\$

I've been working on a bot that records the result of matches on SaltyBet.com. It uses that data to calculate the probability of winning for fighters of a given match. A fighters probability is based on the Elo rating system. I'm posting this here to share the code.

Repo: https://github.com/zakarh/salt-bot

import json

import selenium
from selenium import webdriver
from selenium.common import exceptions
from selenium.webdriver.common.keys import Keys


class SaltBot():
    def __init__(self):
        self.data = {}

    def watch(self, browser, source="data.json", output="data.json", matches=1):
        """
        Watch matches, make predictions, and record the results.
        :type browser: selenium.webDriver, Example: selenium.webdriver.Chrome(). 
        :type source: str, directory to source file.
        :type target: str, directory to output file.
        :type matches: int, No. matches to watch.
        """
        if type(browser) != selenium.webdriver.chrome.webdriver.WebDriver:
            raise TypeError(
                "type(browser) != selenium.webdriver.chrome.webdriver.WebDriver, type(browser) == {}".format(type(browser)))
        if type(source) != str:
            raise TypeError(
                "type(source) != str, type(source) == {}".format(type(source)))
        if type(output) != str:
            raise TypeError(
                "type(output) != str, type(output) == {}".format(type(output)))
        if type(matches) != int:
            raise TypeError(
                "type(matches) != int, type(matches) == {}".format(type(matches)))
        if matches <= 0:
            raise ValueError(
                "matches <= 0, matches == {}, matches must be > 0".format(matches))

        # Load data from source:
        self.load_data(source)

        # Navigate to SaltyBet.com using the browser.
        url_address = "https://www.saltybet.com/"
        browser.get(url_address)

        print("Salt Bot is now watching {} matches.\n".format(matches))

        # Handle operations performed in the while loop:
        bet_status = ""  # Handle control flow.
        recorded = False  # Decide when to record the result of matches.
        red = ""  # Fighter.
        blue = ""  # Fighter.

        match = 0
        print("{}/{} matches watched.".format(match, matches))
        while match <= matches:
            try:
                # Get bet status:
                current_bet_status = browser.find_element_by_id(
                    "betstatus").text
                if len(current_bet_status) == 0:
                    continue
                elif bet_status == "":
                    bet_status = current_bet_status
                elif bet_status != current_bet_status:
                    bet_status = current_bet_status
            except exceptions.StaleElementReferenceException:
                pass
            except Exception:
                pass

            try:
                # Get fighter names:
                red_current = browser.find_element_by_id(
                    "sbettors1").find_element_by_tag_name("strong").text
                blue_current = browser.find_element_by_id(
                    "sbettors2").find_element_by_tag_name("strong").text
                # Calculate probability for fighters:
                if len(red_current) == 0 or len(blue_current) == 0:
                    continue
                elif red == "" or blue == "":
                    red = self.extract_name(red_current)
                    blue = self.extract_name(blue_current)
                    self.display(red, blue)
                elif red != red_current or blue != blue_current:
                    red = self.extract_name(red_current)
                    blue = self.extract_name(blue_current)
                    self.display(red, blue)
            except exceptions.StaleElementReferenceException:
                pass
            except Exception:
                pass

            # Record the results of a match.
            try:
                if "win" in bet_status:
                    if recorded is False:
                        if red in bet_status:
                            s = "Winner: {}, Loser: {}\n".format(red, blue)
                            print(s)
                            self.save_data("data.json", red, blue)
                            self.log_data(s)
                        elif blue in bet_status:
                            s = "Winner: {}, Loser: {}\n".format(blue, red)
                            print(s)
                            self.save_data("data.json", blue, red)
                            self.log_data(s)
                        recorded = True
                        match += 1
                        print(
                            "{}/{} matches watched.".format(match, matches))
                else:
                    recorded = False
            except exceptions.StaleElementReferenceException:
                pass
            except Exception:
                pass

    def get_probability(self, red, blue):
        """
        Calculate the probability of red and blue winning the match using the Elo Rating System.
        :type red: str, name of fighter. 
        :type blue: str, name of fighter.
        :rtype: tuple, respective probability of red and blue winning the match.
        TODO: Account for win:loss ratio of red's opponents against blue vice versa.
        """
        # Validate input:
        if type(red) != str:
            raise TypeError("type(red) != str")
        if type(blue) != str:
            raise TypeError("type(blue) != str")

        # Insufficient data return (0.5, 0.5)
        if red not in self.data or blue not in self.data:
            return (0.5, 0.5)
        else:
            # Compute the sum of matches won by both fighters:
            red_rating = sum([self.data[red][loser]
                            for loser in self.data[red]])
            blue_rating = sum([self.data[blue][loser]
                            for loser in self.data[blue]])

            # Decremenet fighter rating by their total losses against the other fighter:
            if red in self.data[blue]:
                red_rating -= self.data[blue][red]
            if blue in self.data[red]:
                blue_rating -= self.data[red][blue]

            # Transform fighter rating:
            red_rating = pow(10, max(0, red_rating) / 400)
            blue_rating = pow(10, max(0, blue_rating) / 400)

            # Calculate red and blue's probability of winning:
            red_probability = red_rating / (red_rating + blue_rating)
            blue_probability = blue_rating / (red_rating + blue_rating)

            return (red_probability, blue_probability)

    def save_data(self, file_path, winner, loser):
        """
        Open the target file and update the winner and loser data. 
        :type file_path: str, directory path to target file.
        :type winner: str, name of fighter.
        :type loser: str, name of fighter.
        """
        # Validate input:
        if type(file_path) != str:
            raise TypeError("type(file_path) != str")
        if type(winner) != str:
            raise TypeError("type(winner) != str")
        if type(loser) != str:
            raise TypeError("type(loser) != str")

        # Open the target file and write the results.
        with open(file=file_path, mode="w+") as f:
            # Add winner and loser if they don't exist in self.data:
            self.data.setdefault(winner, {})
            self.data.setdefault(loser, {})
            self.data[winner].setdefault(loser, 0)
            self.data[loser].setdefault(winner, 0)
            # Increment the number of wins winner has against the loser:
            self.data[winner][loser] += 1
            f.write(json.dumps(self.data))

    def load_data(self, file_path):
        """
        Open the target file and load its contents as JSON.
        :type file_path: str, directory path to target file.
        :rtype: None
        """
        # Validate input:
        if type(file_path) != str:
            raise TypeError("type(file_path) != str")
        # Open the target file and load contents as json.
        with open(file=file_path, mode="r+") as f:
            data = json.load(f)
            self.data = data

    def log_data(self, data):
        file_path = "data.txt"
        with open(file=file_path, mode="a+") as f:
            f.write(data)

    def extract_name(self, raw_name):
        return str(raw_name).strip(" ")

    def display(self, red, blue):
        s = "\n{} vs. {}\n{}".format(
            red, blue, self.get_probability(red, blue))
        print(s)


def main():
    sb = SaltBot()
    sb.watch(browser=webdriver.Chrome(), source="data.json",
            output="data.json", matches=100)


if __name__ == '__main__':
    main()
\$\endgroup\$
  • \$\begingroup\$ Have you read giantbomb.com/profile/tycobb/blog/… ? \$\endgroup\$ – Reinderien Jan 7 at 15:43
  • 1
    \$\begingroup\$ Thanks for linking the article it was a good read. I expected other bots to exists but not at that level of automation. Anywho it has made me consider possible features I might add to salt bot. \$\endgroup\$ – Zakar H. Jan 8 at 4:46
3
\$\begingroup\$

Use f-strings

This:

"Salt Bot is now watching {} matches.\n".format(matches)

can be

f'Salt Bot is now watching {matches} matches\n'

and so on for your other format calls.

Use more subroutines

watch() is quite long. You should break it up into multiple functions.

Redundant else

This:

            if len(current_bet_status) == 0:
                continue
            elif bet_status == "":
                bet_status = current_bet_status

should use if instead of elif, due to the previous continue. Similar instances elsewhere.

Don't swallow exceptions

This:

        except Exception:
            pass

is really dangerous, and asking for trouble. If this is in a loop that you never ever want to die, you should still at least be outputting the exception to the console when it occurs. Otherwise, debugging is going to be much more difficult.

\$\endgroup\$
  • \$\begingroup\$ @Reinderein, is it best practices to use the latest features of a programming language? Given that I used the format() method instead of the f-strings for backward compatibility. Tips on subroutines, redundant else's, and exceptions duly noted. Thanks. \$\endgroup\$ – Zakar H. Jan 8 at 4:58
  • 2
    \$\begingroup\$ @ZakarH. Language level depends on a lot of things. If you're writing an application and this is not in a business context where you're told which versions of Python you need to support, then yes, you should be using the latest version. If (for instance) you're writing a library and you want it to have broader compatibility, then your language choices will be different. \$\endgroup\$ – Reinderien Jan 8 at 5:36
2
\$\begingroup\$

In Python 3.5 type annotations were introduced. These let you add annotations to variables and functions to denote which type they should be. This is not enforced on its own, but you can get access to these annotations with the __anotations__ property:

def f(a: str) -> str:
    return a
f.__annotations__
# {'a': str, 'return': str}

With this it is relatively easy to write a decorator that checks all specified types:

from functools import wraps
import inspect

def check_types(func):
    annotations = func.__annotations__
    params = inspect.signature(func).parameters
    @wraps(func)
    def wrapper(*args, **kwargs):
        # positional arguments
        for value, (name, param) in zip(args, params.items()):
            if param.annotation is inspect._empty:
                continue
            if not isinstance(value, param.annotation):
                raise TypeError(f"type({name}) != {param.annotation}, type({name}) == {type(value)}")
        # keyword arguments
        for name, value in kwargs.items():
            try:
                required_type = annotations[name]
            except KeyError:
                continue
            if not isinstance(value, required_type):
                raise TypeError(f"type({name}) != {required_type}, type({name}) == {type(value)}")
        return func(*args, **kwargs)
    return wrapper

Your methods would then simply look like this:

Chrome = selenium.webdriver.chrome.webdriver.WebDriver

class SaltBot:
    ...

    @check_types
    def watch(self, browser: Chrome, source: str = "data.json", output: str = "data.json", matches: int = 1):
        """
        Watch matches, make predictions, and record the results.
        :type browser: selenium.webDriver, Example: selenium.webdriver.Chrome(). 
        :type source: str, directory to source file.
        :type target: str, directory to output file.
        :type matches: int, No. matches to watch.
        """
        if matches <= 0:
            raise ValueError(
                "matches <= 0, matches == {}, matches must be > 0".format(matches))

        ...

This is of course only a dynamic type checking at run time and can probably be improved itself (I don't use type hints regularly). It does not conform to PEP 484 in that you can't e.g. use strings as types. You might want to go the full way and use the typing module and a static type checker, as described e.g. here.

\$\endgroup\$
  • \$\begingroup\$ that article you linked on Medium "How to Use Static Type Checking in Python 3.6" was a fantastic read. I'm going to start using typing in my programs. Question, how have you determined use cases that require typing? \$\endgroup\$ – Zakar H. Jan 8 at 5:06

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.