From c14fcf74e5e847f92db208ec54f1d0d762c7e724 Mon Sep 17 00:00:00 2001 From: kieran-mackle Date: Sat, 23 Mar 2024 09:39:19 +1000 Subject: [PATCH] feat: support adding user-defined projects for custom bot deployment --- README.md | 10 ++ pyproject.toml | 2 +- src/cryptobots/_cli/cli.py | 175 ++++++++++++++++++------------- src/cryptobots/_cli/constants.py | 2 +- src/cryptobots/_cli/utilities.py | 134 ++++++++++++++++++++--- 5 files changed, 230 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 995f4d7..645eab3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ yet to do extensive testing on them. More will be added soon!

[back to top]

+## Other Features + +### User-defined strategies +CryptoBots can also be used as a convenient tool to deploy your own +[AutoTrader](https://github.com/kieran-mackle/AutoTrader) bots. To do so, use the +`cryptobots configure` method to add the path to your project directory. Then, when you +are ready to deploy a bot, specify the project name using the `--project` argument in +the `cryptobots run` command. + + ## Getting Started ### Installation CryptoBots can be installed from [PyPi](https://pypi.org/project/cryptobots) using `pip install`: diff --git a/pyproject.toml b/pyproject.toml index 5a55254..461dfdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - 'autotrader >= 1.1.0', + 'autotrader >= 1.1.1', 'ccxt', 'Click', 'trogon', diff --git a/src/cryptobots/_cli/cli.py b/src/cryptobots/_cli/cli.py index ffca8c2..ffabdc8 100644 --- a/src/cryptobots/_cli/cli.py +++ b/src/cryptobots/_cli/cli.py @@ -38,6 +38,11 @@ def cli(): "-c", help="Specify the strategy configuration file.", ) +@click.option( + "--project", + "-p", + help="Run a bot from a custom project.", +) @click.option( "--background", "-b", @@ -58,11 +63,13 @@ def run( instrument: str, config: str, background: bool, + project: str, launch: str, ): """Run cryptobots.""" import os import sys + import json import subprocess from autotrader import AutoTrader, utilities from cryptobots._cli.utilities import ( @@ -74,11 +81,12 @@ def run( show_strategy_params, create_at_inputs, check_update_condition, + save_backtest_config, ) # Find home directory and configure paths home_dir = check_home_dir() - config_dir = os.path.join(home_dir, "config") + config_dir = os.path.join(home_dir, constants.CONFIG_DIRECTORY) file_dir = os.path.dirname(os.path.abspath(__file__)) strat_config_dir = os.path.normpath( os.path.join(file_dir, "..", constants.CONFIG_DIRECTORY) @@ -90,7 +98,38 @@ def run( user_config_dir = os.path.normpath( os.path.join(home_dir, constants.USER_CONFIG_DIRECTORY) ) - init_file = os.path.join(home_dir, constants.INIT_FILE) + init_file = os.path.join(home_dir, constants.CONFIG_FILE) + + if project: + # User project specified, check it has been added + if os.path.exists(init_file): + # Load existing config + with open(init_file, "r") as f: + cb_config = json.load(f) + else: + click.echo( + "Please complete the initialisation before trying to " + + "run a custom project." + ) + sys.exit() + + # Check + if "projects" not in cb_config: + click.echo("You have not added any projects yet!") + sys.exit() + + else: + if project not in cb_config["projects"]: + click.echo(f"Cannot find '{project} in your projects. Add it first.") + else: + # Project found; overwrite paths + project_dir_path = cb_config["projects"][project] + strategy_dir = os.path.join( + project_dir_path, constants.STRATEGY_DIRECTORY + ) + strat_config_dir = os.path.join( + project_dir_path, constants.CONFIG_DIRECTORY + ) # Check for pacakge update if os.path.exists(init_file): @@ -127,14 +166,6 @@ def run( # Get exchange to trade on exchange, environment = select_exchange_and_env(keys_config, exchange, mode) - # Get strategy to run - strategy_name = select_strategy(strat_config_dir, strategy) - - # Load the strategy object - strategy_obj_name, strategy_object = get_strategy_object( - strategy_name, strategy_dir - ) - # Load strategy configuration load_config = True if config is not None: @@ -158,12 +189,21 @@ def run( ) ) + # Get strategy to run if load_config: # Load default configuration + strategy_name = select_strategy(strat_config_dir, strategy) strategy_config = utilities.read_yaml( os.path.join(strat_config_dir, f"{strategy_name}.yaml") ) + else: + # Extract strategy name from loaded config + strategy_name = strategy_config["MODULE"] + + # Load the strategy object + _, strategy_object = get_strategy_object(strategy_name, strategy_dir) + # Show strategy parameters param_map = show_strategy_params( strategy_config, strategy_name, instrument=instrument @@ -182,6 +222,24 @@ def run( strategy_config, strategy_name, msg="Updated strategy parameters:\n" ) + # Optionally save this config to allow re-using + save_params = click.prompt( + text=click.style( + text="Would you like to save this configuration?", fg="green" + ), + type=click.BOOL, + default=False, + ) + if save_params: + default_filename = f"{strategy_name}.yaml" + filename = click.prompt( + text=click.style(text="Enter filename to save as", fg="green"), + type=click.STRING, + default=default_filename, + ) + fp = save_backtest_config(home_dir, filename, strategy_config) + click.echo(f"Saved configuration to {fp}.") + # Check strategy parameters if hasattr(strategy_object, "check_parameters"): valid, reason = strategy_object.check_parameters(strategy_config) @@ -435,19 +493,21 @@ def configure(): print_banner, update_keys_config, create_link, - write_init_file, + update_config, + configure_keys, + add_project_dir, ) # Define paths home_dir = check_home_dir() - init_file = os.path.join(home_dir, constants.INIT_FILE) + init_file = os.path.join(home_dir, constants.CONFIG_FILE) # Print banner first_time_config = False if os.path.exists(init_file) else True print_banner(first_time_config) # Check for config directory - config_dir = os.path.join(home_dir, "config") + config_dir = os.path.join(home_dir, constants.CONFIG_DIRECTORY) check_dir_exists(config_dir, create=True) # Display help to first time users @@ -455,77 +515,42 @@ def configure(): welcome_msg = ( "Welcome to CryptoBots! In order to trade on any exchanges, " + "you must first create API keys.\nOnce you have done that, you can use this " - + "method (i.e. `cryptobots configure`) to add or update those keys." + + "method (i.e. `cryptobots configure`) to add or\nupdate those keys.\n\n" + + "You can also use this method to point CryptoBots towards your own project " + + "directories,\nallowing you to use CryptoBots as a convenient way to deploy " + + "your own bots.\n" ) click.echo(welcome_msg) - # Ask to initialise keys - try: - configure_keys = click.prompt( - text=click.style( - text="Are you ready to configure your exchange API keys?", fg="green" - ), - default=True, - ) - except click.Abort: - configure_keys = False - - if configure_keys: - # Look for keys file - keys_filepath = os.path.join(home_dir, "config", "keys.yaml") - if os.path.exists(keys_filepath): - # File already exists, load it - keys_config = autotrader.utilities.read_yaml(keys_filepath) - else: - # Create new file - keys_config = {} - - # Add keys for exchanges - click.echo("Configuring API keys...") - while True: - valid_exchange = False - while not valid_exchange: - exchange: str = click.prompt( - text=click.style( - text="What is the name of the exchange you would " - + "like to configure?", - fg="green", - ), - type=click.STRING, - ) - if exchange.lower() not in ccxt.exchanges: - click.echo("Invalid exchange. Please check spelling and try again.") - valid_exchange = True - - keys_config = update_keys_config(keys_config, exchange) + # Prompt for option + options = {1: "Add exchange keys", 2: "Add project directory"} + options[len(options) + 1] = "Exit" + options_msg = "Configuration options:\n" + "\n".join( + [f" [{k}] {v}" for k, v in options.items()] + ) + click.echo(options_msg) + option_selection: int = click.prompt( + text=click.style(text="Select an option number", fg="green"), + type=click.IntRange(min=min(options), max=max(options)), + ) - # Continue - repeat = click.prompt( - text=click.style( - text="Would you like to configure another exchange?", fg="green" - ), - default=True, - ) - if not repeat: - break + # Process selection + match option_selection: + case 1: + # Configure exchange keys + configure_keys(home_dir=home_dir) - autotrader.utilities.write_yaml(keys_config, keys_filepath) - click.echo(f"Done configuring keys - written to {keys_filepath}.") + case 2: + # Add project directory + add_project_dir(home_dir=home_dir) - else: - # Display featured exchanges - bybit = create_link( - url="https://www.bybit.com/invite?ref=7NDOBW", label="Bybit" - ) - featured_excahnges = ( - "\nThe following exchanges are featured by CryptoBots (ctrl+click " - + f"to open):\n - {bybit}\n" - ) - click.echo(featured_excahnges) + case _: + # Exit + pass if first_time_config: # Add init file and write current time - write_init_file(init_file) + update_config(init_file) click.echo("Cryptobots initialised.") diff --git a/src/cryptobots/_cli/constants.py b/src/cryptobots/_cli/constants.py index 2b3d4a1..118f2e5 100644 --- a/src/cryptobots/_cli/constants.py +++ b/src/cryptobots/_cli/constants.py @@ -2,6 +2,6 @@ STRATEGY_DIRECTORY = "strategies" CONFIG_DIRECTORY = "config" USER_CONFIG_DIRECTORY = "user_configurations" -INIT_FILE = ".initialised" +CONFIG_FILE = "configuration.json" STRFTIME = "%Y-%m-%d" DEFAULT_HOME_DIRECTORY = ".cryptobots" diff --git a/src/cryptobots/_cli/utilities.py b/src/cryptobots/_cli/utilities.py index f31b579..13a5af9 100644 --- a/src/cryptobots/_cli/utilities.py +++ b/src/cryptobots/_cli/utilities.py @@ -13,7 +13,7 @@ from typing import Optional from cryptobots._cli import constants from datetime import datetime, timedelta -from autotrader.utilities import read_yaml +from autotrader.utilities import read_yaml, write_yaml def strategy_name_from_module_name(name: str): @@ -135,26 +135,26 @@ def check_for_update(): pip.main(["install", "--upgrade", "cryptobots"]) -def check_update_condition(init_file: str): +def check_update_condition(config_filepath: str): # Check when last update check was performed - with open(init_file, "r") as f: - lines = f.readlines() + if os.path.exists(config_filepath): + # Load existing config + with open(config_filepath, "r") as f: + config = json.load(f) - # Parse update time - if len(lines) == 0: - # Initialised previously, but no time present - last_update = datetime.now() - timedelta(days=2) + # Get update time + last_update = datetime.strptime(config["last_update"], constants.STRFTIME) else: - # Get last update check time - last_update = datetime.strptime(lines[0].strip("\n"), constants.STRFTIME) + # Config doesn't exist yet + last_update = datetime.now() - timedelta(days=2) # Check for update if last_update.date() < datetime.now().date(): check_for_update() # Update file - write_init_file(init_file) + update_config(config_filepath) def check_home_dir(): @@ -440,7 +440,7 @@ def save_backtest_config(home_dir: str, filename: str, config: dict): # Save fp = os.path.join(strat_conf_dir, filename) with open(fp, "w") as f: - json.dump(config, f) + json.dump(config, f, indent=2) return fp @@ -564,7 +564,109 @@ async def get_cash_and_carry(exchange: str, prices: bool): return cash_and_carry[show_cols] -def write_init_file(init_file: str): - """Writes the init file and current date.""" - with open(init_file, "w") as f: - f.write(f"{datetime.now().strftime(constants.STRFTIME)}\n") +def update_config(config_filepath: str, update_time: Optional[bool] = True, **kwargs): + """Update the configuration file.""" + # Check for existing file + if os.path.exists(config_filepath): + # Load existing config + with open(config_filepath, "r") as f: + config = json.load(f) + else: + # Config doesn't exist yet + config = {} + + # Add kwargs to config + for k, v in kwargs.items(): + if k == "project": + # Add this to the user projects + projects: dict[str, str] = config.setdefault("projects", {}) + projects[v[0]] = v[1] + + else: + config[k] = v + + # Update latest time + if update_time: + config["last_update"] = f"{datetime.now().strftime(constants.STRFTIME)}" + + # Dump updated config to file + with open(config_filepath, "w") as f: + json.dump(config, f, indent=2) + + +def configure_keys(home_dir: str): + click.clear() + print_banner() + + # Display featured exchanges + bybit = create_link(url="https://www.bybit.com/invite?ref=7NDOBW", label="Bybit") + featured_excahnges = ( + f"The following exchanges are featured by CryptoBots:\n - {bybit}\n" + ) + click.echo(featured_excahnges) + + # Look for keys file + keys_filepath = os.path.join( + home_dir, constants.CONFIG_DIRECTORY, constants.KEYS_FILENAME + ) + if os.path.exists(keys_filepath): + # File already exists, load it + keys_config = read_yaml(keys_filepath) + else: + # Create new file + keys_config = {} + + # Add keys for exchanges + click.echo("Configuring API keys") + while True: + valid_exchange = False + while not valid_exchange: + exchange: str = click.prompt( + text=click.style( + text="What is the name of the exchange you would " + + "like to configure?", + fg="green", + ), + type=click.STRING, + ) + if exchange.lower() not in ccxt.exchanges: + click.echo("Invalid exchange. Please check spelling and try again.") + valid_exchange = True + + keys_config = update_keys_config(keys_config, exchange) + + # Continue + repeat = click.prompt( + text=click.style( + text="Would you like to configure another exchange?", fg="green" + ), + default=True, + ) + if not repeat: + break + + write_yaml(keys_config, keys_filepath) + click.echo(f"Done configuring keys - written to {keys_filepath}.") + + +def add_project_dir(home_dir: str): + """Add the path to a user defined project directory.""" + project_dir_path = click.prompt( + text=click.style( + text="Enter the path to your project", + fg="green", + ), + type=click.Path(exists=True, file_okay=False, resolve_path=True), + ) + + # TODO - add project structure validity checks + project_name = os.path.basename(project_dir_path) + + # Add project directory to configuration file + config_filepath = os.path.join(home_dir, constants.CONFIG_FILE) + update_config( + config_filepath=config_filepath, + update_time=False, + project=(project_name, project_dir_path), + ) + click.echo(f"Added {project_name} to your projects.")