Example #1
0
class PopulationManager:
    @logged_initializer
    def __init__(self, config):
        self.config = config
        self.logger.set_level(self.config['log_level'])
        self.population_size = self.config['population_size']
        self.population = self.generate_initial_population()

        # Initialize api manager -- maybe by reference in the future O:
        self.api_manager = ApiManager(config)

        # Warmup API Manager
        while not self.api_manager.is_warm:
            sleep(1)
        else:
            self.logger.progress('API Manager is warm')

        # Housekeeping for later
        self.population_first_frame_price = None

        # self.catchup_population()

    def get_terminal(self):
        terminal_to_create = choice(self.config['terminals'])

        if terminal_to_create.type == 'constant':
            terminal_value = uniform(*self.config['constants_range'])
        elif terminal_to_create.type == 'run_time_evaluated':
            FrameKeyPair = namedtuple('FrameKeyPair', 'frame key')
            frame = randint(0, self.config['frames'] - 1)
            terminal_value = FrameKeyPair(frame, terminal_to_create.value)
        else:
            terminal_value = terminal_to_create.value

        return TerminalNode(terminal_to_create.type, terminal_value)

    def get_function_node(self, depth, max_depth):
        function_type = choice(self.config['node_functions'])
        argument_count = randint(function_type.min_arity,
                                 function_type.max_arity)

        if depth == max_depth:
            function_nodes = [
                self.get_terminal() for _ in range(argument_count)
            ]

        else:
            function_nodes = []
            for _ in range(argument_count):
                function_selection_roll = random()
                if function_selection_roll < self.config[
                        'function_selection_chance']:
                    function_nodes.append(
                        self.get_function_node(depth + 1, max_depth))
                else:
                    function_nodes.append(self.get_terminal())

        return FunctionNode(function_type, function_nodes) \
            if depth != 0 else RootNode(function_type, function_nodes)

    @logged_class_function
    def generate_initial_population(self):
        population = []
        trees_to_generate = self.config['population_size']
        depth = 1

        next_step_trees = math.floor(trees_to_generate / 2)
        while next_step_trees >= 1:

            for _ in range(next_step_trees):
                population.append(self.get_function_node(0, depth))

            trees_to_generate -= next_step_trees
            next_step_trees = math.floor(trees_to_generate / 2)
            depth += 1

        return population

    @logged_class_function
    def generate_next_generation(self, window):
        def get_weighted_random_index():
            random_int_range = (self.population_size /
                                2) * (self.population_size + 1)
            random_choice = randint(0, random_int_range)
            b = 2 * self.population_size + 1
            random_index = math.floor(
                (-b + math.sqrt(b**2 - 8 * random_choice)) / (-2))
            return random_index

        # In this step, get fitness alongside the sort
        # sorted_fitness_index_pairs = self.sort_population_with_fitness(window)
        self.sort_population(window)

        # Okay here we go
        next_population = []

        # Take an amount of elites
        elites = 10
        elites_from_last_generation = self.population[:elites]
        next_population.extend(elites_from_last_generation)

        trees_from_last_generation_count = round(
            self.config['replacement'] * self.population_size) - elites

        # Positionally weighted reselection
        for _ in range(trees_from_last_generation_count):
            try:
                index = get_weighted_random_index()
                next_population.append(self.population[index])
            except IndexError:
                print(f'Index error, tried to select index: {index}')
                next_population.append(self.population[-1])

        # Recombination
        trees_to_recombine = round(self.config['recombination'] *
                                   self.population_size)

        def get_all_function_nodes(tree, nodes=None):
            if type(tree) == TerminalNode:
                return
            elif nodes is None:
                nodes = [tree]

            for node in tree.child_nodes:
                if type(node) == FunctionNode:
                    nodes.append(node)
                    get_all_function_nodes(node, nodes)

            return nodes

        def get_all_nodes(tree, nodes=None):
            if nodes is None:
                nodes = [tree]

            for node in tree.child_nodes:
                if type(node) == FunctionNode:
                    nodes.append(node)
                    get_all_nodes(node, nodes)
                elif type(node) == TerminalNode:
                    nodes.append(node)

            return nodes

        for _ in range(trees_to_recombine):
            # Select parents
            try:
                index = get_weighted_random_index()
                parent_1 = deepcopy(self.population[index])
            except IndexError:
                print(f'Index error, tried to select index: {index}')
                parent_1 = deepcopy(self.population[-1])

            try:
                index = get_weighted_random_index()
                parent_2 = deepcopy(self.population[index])
            except IndexError:
                print(f'Index error, tried to select index: {index}')
                parent_2 = deepcopy(self.population[-1])

            # Get all nodes
            parent_1_function_nodes = get_all_function_nodes(parent_1)
            parent_2_nodes = get_all_nodes(parent_2)

            # Select recombination point from parent 1
            parent_1_recombination_point = choice(parent_1_function_nodes)
            parent_2_recombination_point = choice(parent_2_nodes)

            # Combine trees
            replacement_index = randint(
                0,
                len(parent_1_recombination_point.child_nodes) - 1)
            parent_1_recombination_point.child_nodes[
                replacement_index] = parent_2_recombination_point

            # Add to list of trees
            recombined_tree = parent_1
            next_population.append(recombined_tree)

        trees_to_mutate = round(self.population_size * self.config['mutation'])
        for _ in range(trees_to_mutate):
            tree_to_mutate = deepcopy(
                self.population[get_weighted_random_index()])
            parent_mutation_node = choice(
                get_all_function_nodes(tree_to_mutate))
            mutation_index = randint(0,
                                     len(parent_mutation_node.child_nodes) - 1)
            new_node = self.get_function_node(3, 4)
            parent_mutation_node.child_nodes[mutation_index] = new_node
            next_population.append(tree_to_mutate)

        leftover_trees = self.population_size - len(next_population)
        # Just take more copies of elites to fill the gap
        next_population.extend(self.population[:leftover_trees])

        # Housekeeping
        [tree.reset_cash() for tree in next_population]
        self.population_first_frame_price = None

        self.population = next_population

    @logged_class_function
    def sort_population(self, window):
        initial_buyable_asset = self.config[
            'starting_value'] / self.population_first_frame_price
        initial_bought_value_asset = initial_buyable_asset * window[0]['price']
        self.population.sort(key=lambda tree: get_tree_fitness(
            tree, window, initial_bought_value_asset),
                             reverse=True)

    # def sort_population_with_fitness(self, window):
    #     fitnesses_with_index = [(get_tree_fitness(tree, window, initial_bought_value_asset), index) for index, tree in enumerate(self.population)]
    #     fitnesses_with_index.sort(key=lambda fitness_index: fitness_index[0], reverse=True)
    #     return fitnesses_with_index

    @logged_class_function
    def get_best_candidate(self, window):
        self.sort_population(window)
        return self.population[0]

    @logged_class_function
    def do_trades(self, window):
        if self.population_first_frame_price is None:
            self.population_first_frame_price = window[0]['price']
        for tree in self.population:
            decision = tree.get_decision(window)
            tree.dollar_count -= decision
            tree.asset_count += decision * window[0]['dollar_to_asset_ratio']

    @logged_class_function
    def get_population_statistics(self, window):
        statistics = {
            'average_value':
            sum([score_tree(tree, window)
                 for tree in self.population]) / len(self.population),
            'values': [(index, tree.last_ev)
                       for index, tree in enumerate(self.population)],
            'cash_on_hand': [(index, tree.dollar_count)
                             for index, tree in enumerate(self.population)],
            'asset_on_hand': [(index, tree.asset_count)
                              for index, tree in enumerate(self.population)],
            'current_btc_price':
            window[0]['price']
        }

        initial_buyable_asset = self.config[
            'starting_value'] / self.population_first_frame_price
        initial_bought_value_asset = initial_buyable_asset * window[0]['price']
        statistics['normalized_average_value'] = statistics[
            'average_value'] / initial_bought_value_asset
        statistics['normalized_values'] = [
            (index, value / initial_bought_value_asset)
            for index, value in statistics['values']
        ]
        return statistics

    def train(self):
        # Setup stat saving
        os.umask(0)
        run_path = f"run_stats/{self.config['run_id']}"
        if not os.path.isdir(run_path):
            os.mkdir(run_path)

        for epoch in range(self.config['epochs']):
            self.logger.progress(f'Starting Epoch {epoch}')

            save_directory = f"{run_path}/epoch_{epoch}"
            if not os.path.isdir(save_directory):
                os.mkdir(save_directory, 0o777)

            # Do catchup trades
            catchup_trade_start_time = datetime.now()
            catchup_window = self.api_manager.get_catchup_window()
            for frame_index in range(self.config['frames']):
                catchup_frame = catchup_window[self.config['frames'] -
                                               frame_index:len(catchup_window
                                                               ) - frame_index]
                self.do_trades(catchup_frame)
                stats = self.get_population_statistics(catchup_frame)
                with open(f'{save_directory}/stats_{frame_index}.p',
                          'wb') as fp:
                    pickle.dump(stats, fp, protocol=pickle.HIGHEST_PROTOCOL)
            catchup_trade_time_elapsed = (datetime.now() -
                                          catchup_trade_start_time).seconds
            self.logger.progress(
                f"{catchup_trade_time_elapsed} seconds of catchup trades")

            # Add in extra catchup frames from evaluation (recursively)

            # Do live trades
            time_elapsed = 0
            window = None
            while time_elapsed < self.config['seconds_before_evaluation']:
                # log.info(f"Trading, time left: {config['seconds_before_evaluation'] - time_elapsed}")
                trade_start_time = datetime.now()
                window = self.api_manager.get_window()
                self.do_trades(window)
                stats = self.get_population_statistics(window)

                # Save stats of the run
                with open(
                        f'{save_directory}/stats_{time_elapsed + self.config["frames"]}.p',
                        'wb') as fp:
                    pickle.dump(stats, fp, protocol=pickle.HIGHEST_PROTOCOL)

                # TODO On next frame from API manager
                sleep(1)
                time_elapsed += (datetime.now() - trade_start_time).seconds
                self.logger.info(
                    f'Epoch_{epoch}: Time Elapsed: {time_elapsed}')

            # Zip the epoch data
            self.logger.info('Zipping stats')
            output_filename = f"{run_path}/epoch_{epoch}_stats"
            shutil.make_archive(output_filename, 'zip', save_directory)

            # Unlink to save space
            self.logger.info('Removing old stats')
            shutil.rmtree(save_directory)

            # log.debug('Generation average EV: ', population_manager.get_population_statistics()['average_value'])
            self.logger.info('Generating next generation of trees')
            self.generate_next_generation(window)