diff --git a/crypto_seller/__init__.py b/crypto_seller/__init__.py index 0b5b77a..66c4cf0 100644 --- a/crypto_seller/__init__.py +++ b/crypto_seller/__init__.py @@ -95,14 +95,20 @@ most mature on the danish market, and does support KuCoin. ## TODO -- [ ] Present an overview of what the script will be doing - * Sell what for what? How much, how often? - * Give an estimate of how long it will take. - * Wait 20 seconds before starting, to allow the user to review. -- [ ] Ensure that a failure during selling results in a safe winding down of the system. - * Catch runtime errors when selling - * Show errors to log. - * Stop loop and exit with results, and error indicator. +- [X] Allow multiple secrets configs +- [X] Allow multiple output directories +- [X] Fix `TimeoutError` issue occuring from slow Kucoin endpoint. Might + require implementing own kucoin backend in `fin_depo`. +- [X] Ensure that a failure during selling results in a safe winding down of the system. + * Catch runtime errors when selling + * Show status + * Show errors to log. + * Stop loop and exit with results, and error indicator. +- [X] Present an overview of what the script will be doing + * Sell what for what? How much, how often? + * Give an estimate of how long it will take. + * Wait 20 seconds before starting, to allow the user to review. +- [X] Document command-line arguments - [X] Document configuration - [X] Document code auditing - [X] Parse configuration from json. @@ -119,7 +125,7 @@ import datetime import logging import random from collections.abc import Callable -from decimal import Decimal +from decimal import ROUND_DOWN, Decimal from typing import Any import fin_defs @@ -197,8 +203,10 @@ def sample_from_range(rng: random.Random, rang: tuple[Any, Any]) -> Any: return rang[0] + (rang[1] - rang[0]) * multiplier - -def run_auto_sell(config: AutoSellConfig) -> AutoSellRunResults: +def run_auto_sell( + config: AutoSellConfig, + initial_rounds_to_skip: int = 0, +) -> AutoSellRunResults: """Executes the sell-off. Sell-offs are performed in rounds of sizes and with intervals randomly @@ -210,39 +218,65 @@ def run_auto_sell(config: AutoSellConfig) -> AutoSellRunResults: all_executed_orders: list[fin_depo.data.TradeOrderDetails] = [] total_sleep_duration = datetime.timedelta(seconds=0) - while True: - # Check that account has tokens. - input_amount_available = config.seller.get_depo().get_amount_of_asset( - config.input_asset, - ) - logger.info('Currently own %s %s', input_amount_available, config.input_asset.raw_short_name()) - - if input_amount_available > 0: - amount_to_sell = sample_from_range(rng, config.input_amount_range) - amount_to_sell = min(input_amount_available, amount_to_sell) - amount_to_sell = amount_to_sell.quantize(QUANTIZE_DECIMALS) - logger.info('Selling %s %s', amount_to_sell, config.input_asset.raw_short_name()) - - order_details = config.seller.place_market_order( - fin_defs.AssetAmount(config.input_asset, amount_to_sell), - config.output_asset, + try: + while True: + # Check that account has tokens. + input_amount_available = config.seller.get_depo().get_amount_of_asset( + config.input_asset, + ) + logger.info( + 'Currently own %s %s', + input_amount_available, + config.input_asset.raw_short_name(), ) - config.log_order_to_csv(order_details) - all_executed_orders.append(order_details) + if initial_rounds_to_skip > 0: + initial_rounds_to_skip -= 1 + logger.info('skipping this round') + elif input_amount_available > 0: + amount_to_sell = sample_from_range(rng, config.input_amount_range) + amount_to_sell = min(input_amount_available, amount_to_sell) + amount_to_sell = amount_to_sell.quantize( + QUANTIZE_DECIMALS, + rounding=ROUND_DOWN, + ) + logger.info( + 'Attempting to sell %s %s', + amount_to_sell, + config.input_asset, + ) - del amount_to_sell - elif config.exit_when_empty: - break + order_details = config.seller.place_market_order( + config.input_asset, + amount_to_sell, + config.output_asset, + ) - # Time out - time_to_sleep = sample_from_range(rng, config.interval_range) - time_to_sleep_secs = time_to_sleep.total_seconds() - logger.info('Sleeping %s (%d seconds)', time_to_sleep, time_to_sleep_secs) - config.sleep(time_to_sleep_secs) - total_sleep_duration += time_to_sleep + config.log_order_to_csv(order_details) + all_executed_orders.append(order_details) - del input_amount_available + del amount_to_sell + elif config.exit_when_empty: + break + + # Time out + time_to_sleep = sample_from_range(rng, config.interval_range) + time_to_sleep_secs = time_to_sleep.total_seconds() + logger.info('Sleeping %s (%d seconds)', time_to_sleep, time_to_sleep_secs) + config.sleep(time_to_sleep_secs) + total_sleep_duration += time_to_sleep + + del input_amount_available + except KeyboardInterrupt: + logger.warning('Manual interrupt') + except RuntimeError: + logger.exception( + 'A unexpected and serious error occured. The program will be winding down', + ) + logger.fatal('Please send the above error message to: mailto:jonjmaa@gmail.com') + logger.fatal( + 'He will attempt to diagnosticate the problem, and help with recovery', + ) time_end = datetime.datetime.now(tz=datetime.UTC) @@ -253,6 +287,68 @@ def run_auto_sell(config: AutoSellConfig) -> AutoSellRunResults: ) +def log_estimates(config: AutoSellConfig): + current_balance = config.seller.get_depo().get_amount_of_asset( + config.input_asset, + ) + + average_sleep = (config.interval_range[0] + config.interval_range[1]) / 2 + average_amount = (config.input_amount_range[0] + config.input_amount_range[1]) / 2 + + expected_num_sell_offs = current_balance / average_amount + expected_duration = average_sleep * float(expected_num_sell_offs) + + fastest_duration = config.interval_range[0] * float( + current_balance / config.input_amount_range[1], + ) + slowest_duration = config.interval_range[1] * float( + current_balance / config.input_amount_range[0], + ) + + minisleep = 0.1 + + logger.info('') + logger.info('Welcome to crypto seller!') + config.sleep(3) + logger.info('') + config.sleep(minisleep) + logger.info('I, the great crypto seller, your humble servant, will') + config.sleep(minisleep) + logger.info('now analyse your configuration and divine some estimates!') + config.sleep(3) + logger.info('') + config.sleep(minisleep) + logger.info( + '- Current balance: %s %s', + current_balance, + config.input_asset.raw_short_name(), + ) + config.sleep(minisleep) + logger.info('- Average sleep: %s seconds', average_sleep) + config.sleep(minisleep) + logger.info( + '- Average amount: %s %s', + average_amount, + config.input_asset.raw_short_name(), + ) + config.sleep(minisleep) + logger.info('- Expected counts: %s', expected_num_sell_offs) + config.sleep(minisleep) + logger.info('- Expected time: %s', expected_duration) + config.sleep(minisleep) + logger.info('- Fastest time: %s', fastest_duration) + config.sleep(minisleep) + logger.info('- Slowest time: %s', slowest_duration) + config.sleep(minisleep) + logger.info('') + config.sleep(minisleep) + logger.info('Do you still want to proceed?') + config.sleep(minisleep) + logger.info('If not, press CTRL+C within the next 10 seconds...') + config.sleep(10) + logger.info('') + + def log_results(results: AutoSellRunResults): logger.info('Stats:') logger.info('- Num orders: %s', len(results.order_details)) diff --git a/crypto_seller/__main__.py b/crypto_seller/__main__.py index 0429b1d..c26935a 100644 --- a/crypto_seller/__main__.py +++ b/crypto_seller/__main__.py @@ -3,6 +3,7 @@ import datetime import json import logging import logging.handlers +import sys import time from decimal import Decimal from pathlib import Path @@ -12,6 +13,7 @@ import fin_depo from . import ( AutoSellConfig, + log_estimates, log_results, order_csv, run_auto_sell, @@ -20,6 +22,12 @@ from . import logger as module_logger logger = logging.getLogger(__name__) +################################################################################ +# Constants # + +PATH_FILE_LOG = 'log.txt' +PATH_FILE_TRADES = 'trades.csv' + ################################################################################ # Application Setup # @@ -50,7 +58,14 @@ def setup_logging(path_log_file: Path): CLI_DESCRIPTION = """ -Sells financial assets from an online account. +Script to automatically and with little effort sell financial assets from +online accounts. +""".strip() + +CLI_EPILOG = """ +Author : Jon Michael Aanes (jonjmaa@gmail.com) +Website : https://gitfub.space/Jmaa/crypto-seller +License : MIT License (see website for full text) """.strip() @@ -88,9 +103,32 @@ def load_config(config_path: Path, path_trades_file: Path) -> AutoSellConfig: def parse_args(): - parser = argparse.ArgumentParser('crypto_seller', description=CLI_DESCRIPTION) - parser.add_argument('--config', type=Path, dest='config_file', required=True) - parser.add_argument('--output', type=Path, dest='output_folder', required=True) + parser = argparse.ArgumentParser( + prog='crypto_seller', + description=CLI_DESCRIPTION, + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=CLI_EPILOG, + ) + parser.add_argument( + '--config', + type=Path, + dest='config_file', + required=True, + help='Trading configuration file', + ) + parser.add_argument( + '--output', + type=Path, + dest='output_directory', + required=True, + help='Directory for outputing logs and trades list', + ) + parser.add_argument( + '--wait-before-first', + action='store_true', + dest='wait_before_first', + help='Skip the first sell-off round, and wait for the next.', + ) return parser.parse_args() @@ -110,6 +148,20 @@ def main(): auto_sell_config = load_config(args.config_file, path_trades_file) results = run_auto_sell(auto_sell_config) + + # Display estimates + try: + log_estimates(auto_sell_config) + except KeyboardInterrupt: + sys.exit(1) + + # Run auto sell + results = run_auto_sell( + auto_sell_config, + initial_rounds_to_skip=args.wait_before_first and 1 or 0, + ) + + # Display results logging.info('Sell-offs complete') log_results(results) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..dbcb406 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_cli = True +log_level=INFO diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest diff --git a/test/test_auto_sell.py b/test/test_auto_sell.py index f5c580d..39d1658 100644 --- a/test/test_auto_sell.py +++ b/test/test_auto_sell.py @@ -61,11 +61,13 @@ def test_auto_run(): exit_when_empty=True, seller=seller_mock, log_order_to_csv=crypto_seller.order_csv.CsvFileLogger( - Path('output/test-trades.csv'), + Path('test/output/test-trades.csv'), ), sleep=sleep_mock, ) + crypto_seller.log_estimates(config) + results = crypto_seller.run_auto_sell(config) # Check results @@ -76,4 +78,4 @@ def test_auto_run(): # Check mocks agree assert seller_mock.amount_left == 0 - assert sleep_mock.time_slept == 1000 + assert sleep_mock.time_slept == 1000 + 17.2