diff --git a/config_full.json.example b/config_full.json.example index c74b59660..1d9a48762 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -48,5 +48,7 @@ "initial_state": "running", "internals": { "process_throttle_secs": 5 - } + }, + "strategy": "DefaultStrategy", + "strategy_path": "/some/folder/" } diff --git a/docs/bot-optimization.md b/docs/bot-optimization.md index 00938adbe..b9ff3fe40 100644 --- a/docs/bot-optimization.md +++ b/docs/bot-optimization.md @@ -42,6 +42,13 @@ You can test it with the parameter: `--strategy TestStrategy` python3 ./freqtrade/main.py --strategy AwesomeStrategy ``` +### Specify custom strategy location +If you want to use a strategy from a different folder you can pass `--strategy-path` + +```bash +python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder +``` + **For the following section we will use the [user_data/strategies/test_strategy.py](https://github.com/gcarq/freqtrade/blob/develop/user_data/strategies/test_strategy.py) file as reference.** diff --git a/docs/bot-usage.md b/docs/bot-usage.md index ea9b1e4d8..0ed073933 100644 --- a/docs/bot-usage.md +++ b/docs/bot-usage.md @@ -28,6 +28,7 @@ optional arguments: specify configuration file (default: config.json) -s NAME, --strategy NAME specify strategy class name (default: DefaultStrategy) + --strategy-path PATH specify additional strategy lookup path --dry-run-db Force dry run to use a local DB "tradesv3.dry_run.sqlite" instead of memory DB. Work only if dry_run is enabled. @@ -67,9 +68,16 @@ message the reason (File not found, or errors in your code). Learn more about strategy file in [optimize your bot](https://github.com/gcarq/freqtrade/blob/develop/docs/bot-optimization.md). +### How to use --strategy-path? +This parameter allows you to add an additional strategy lookup path, which gets +checked before the default locations (The passed path must be a folder!): +```bash +python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder +``` + #### How to install a strategy? This is very simple. Copy paste your strategy file into the folder -`user_data/strategies`. And voila, the bot is ready to use it. +`user_data/strategies` or use `--strategy-path`. And voila, the bot is ready to use it. ### How to use --dynamic-whitelist? Per default `--dynamic-whitelist` will retrieve the 20 currencies based diff --git a/docs/configuration.md b/docs/configuration.md index 5e3b15925..311205dad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,8 @@ The table below will list all configuration parameters. | `telegram.token` | token | No | Your Telegram bot token. Only required if `telegram.enabled` is `true`. | `telegram.chat_id` | chat_id | No | Your personal Telegram account id. Only required if `telegram.enabled` is `true`. | `initial_state` | running | No | Defines the initial application state. More information below. +| `strategy` | DefaultStrategy | No | Defines Strategy class to use. +| `strategy_path` | null | No | Adds an additional strategy lookup path (must be a folder). | `internals.process_throttle_secs` | 5 | Yes | Set the process throttle. Value in second. The definition of each config parameters is in diff --git a/freqtrade/analyze.py b/freqtrade/analyze.py index e6eb01e93..ccdbb139e 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -36,7 +36,7 @@ class Analyze(object): :param config: Bot configuration (use the one from Configuration()) """ self.config = config - self.strategy = StrategyResolver(self.config) + self.strategy = StrategyResolver(self.config).strategy @staticmethod def parse_ticker_dataframe(ticker: list) -> DataFrame: diff --git a/freqtrade/arguments.py b/freqtrade/arguments.py index 052b4534e..35f8c6609 100644 --- a/freqtrade/arguments.py +++ b/freqtrade/arguments.py @@ -86,6 +86,13 @@ class Arguments(object): type=str, metavar='NAME', ) + self.parser.add_argument( + '--strategy-path', + help='specify additional strategy lookup path', + dest='strategy_path', + type=str, + metavar='PATH', + ) self.parser.add_argument( '--dynamic-whitelist', help='dynamically generate and update whitelist \ diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index 1314f624e..4922bf402 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -33,8 +33,12 @@ class Configuration(object): logger.info('Using config: %s ...', self.args.config) config = self._load_config_file(self.args.config) - # Add the strategy file to use - config.update({'strategy': self.args.strategy}) + # Set strategy if not specified in config and or if it's non default + if self.args.strategy != Constants.DEFAULT_STRATEGY or not config.get('strategy'): + config.update({'strategy': self.args.strategy}) + + if self.args.strategy_path: + config.update({'strategy_path': self.args.strategy_path}) # Load Common configuration config = self._load_common_config(config) diff --git a/freqtrade/main.py b/freqtrade/main.py index d62c92b9f..9639922f9 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -3,7 +3,6 @@ Main Freqtrade bot script. Read the documentation to know what cli arguments you need. """ - import logging import sys from typing import List diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 4eb73fb2e..dcf665a02 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -33,7 +33,6 @@ class IStrategy(ABC): Based on TA indicators, populates the buy signal for the given dataframe :param dataframe: DataFrame :return: DataFrame with buy column - :return: """ @abstractmethod @@ -41,5 +40,5 @@ class IStrategy(ABC): """ Based on TA indicators, populates the sell signal for the given dataframe :param dataframe: DataFrame - :return: DataFrame with buy column + :return: DataFrame with sell column """ diff --git a/freqtrade/strategy/resolver.py b/freqtrade/strategy/resolver.py index 826a50513..de38cf67a 100644 --- a/freqtrade/strategy/resolver.py +++ b/freqtrade/strategy/resolver.py @@ -10,8 +10,6 @@ import os from collections import OrderedDict from typing import Optional, Dict, Type -from pandas import DataFrame - from freqtrade.constants import Constants from freqtrade.strategy.interface import IStrategy @@ -23,82 +21,74 @@ class StrategyResolver(object): """ This class contains all the logic to load custom strategy class """ + + __slots__ = ['strategy'] + def __init__(self, config: Optional[Dict] = None) -> None: """ Load the custom class from config parameter - :param config: - :return: + :param config: configuration dictionary or None """ config = config or {} # Verify the strategy is in the configuration, otherwise fallback to the default strategy - if 'strategy' in config: - strategy = config['strategy'] - else: - strategy = Constants.DEFAULT_STRATEGY - - # Try to load the strategy - self._load_strategy(strategy) + strategy_name = config.get('strategy') or Constants.DEFAULT_STRATEGY + self.strategy = self._load_strategy(strategy_name, extra_dir=config.get('strategy_path')) # Set attributes # Check if we need to override configuration if 'minimal_roi' in config: - self.custom_strategy.minimal_roi = config['minimal_roi'] + self.strategy.minimal_roi = config['minimal_roi'] logger.info("Override strategy \'minimal_roi\' with value in config file.") if 'stoploss' in config: - self.custom_strategy.stoploss = config['stoploss'] + self.strategy.stoploss = config['stoploss'] logger.info( "Override strategy \'stoploss\' with value in config file: %s.", config['stoploss'] ) if 'ticker_interval' in config: - self.custom_strategy.ticker_interval = config['ticker_interval'] + self.strategy.ticker_interval = config['ticker_interval'] logger.info( "Override strategy \'ticker_interval\' with value in config file: %s.", config['ticker_interval'] ) - # Minimal ROI designed for the strategy - self.minimal_roi = OrderedDict(sorted( - {int(key): value for (key, value) in self.custom_strategy.minimal_roi.items()}.items(), - key=lambda t: t[0])) # sort after converting to number + # Sort and apply type conversions + self.strategy.minimal_roi = OrderedDict(sorted( + {int(key): value for (key, value) in self.strategy.minimal_roi.items()}.items(), + key=lambda t: t[0])) + self.strategy.stoploss = float(self.strategy.stoploss) + self.strategy.ticker_interval = int(self.strategy.ticker_interval) - # Optimal stoploss designed for the strategy - self.stoploss = float(self.custom_strategy.stoploss) - - self.ticker_interval = int(self.custom_strategy.ticker_interval) - - def _load_strategy(self, strategy_name: str) -> None: + def _load_strategy( + self, strategy_name: str, extra_dir: Optional[str] = None) -> Optional[IStrategy]: """ Search and loads the specified strategy. :param strategy_name: name of the module to import - :return: None + :param extra_dir: additional directory to search for the given strategy + :return: Strategy instance or None """ - try: - current_path = os.path.dirname(os.path.realpath(__file__)) - abs_paths = [ - os.path.join(current_path, '..', '..', 'user_data', 'strategies'), - current_path, - ] - for path in abs_paths: - self.custom_strategy = self._search_strategy(path, strategy_name) - if self.custom_strategy: - logger.info('Using resolved strategy %s from \'%s\'', strategy_name, path) - return None + current_path = os.path.dirname(os.path.realpath(__file__)) + abs_paths = [ + os.path.join(current_path, '..', '..', 'user_data', 'strategies'), + current_path, + ] - raise ImportError('not found') - # Fallback to the default strategy - except (ImportError, TypeError) as error: - logger.error( - "Impossible to load Strategy '%s'. This class does not exist" - " or contains Python code errors", - strategy_name - ) - logger.error( - "The error is:\n%s.", - error - ) + if extra_dir: + # Add extra strategy directory on top of search paths + abs_paths.insert(0, extra_dir) + + for path in abs_paths: + strategy = self._search_strategy(path, strategy_name) + if strategy: + logger.info('Using resolved strategy %s from \'%s\'', strategy_name, path) + return strategy + + raise ImportError( + "Impossible to load Strategy '{}'. This class does not exist" + " or contains Python code errors".format(strategy_name) + ) @staticmethod def _get_valid_strategies(module_path: str, strategy_name: str) -> Optional[Type[IStrategy]]: @@ -139,28 +129,3 @@ class StrategyResolver(object): if strategy: return strategy() return None - - def populate_indicators(self, dataframe: DataFrame) -> DataFrame: - """ - Populate indicators that will be used in the Buy and Sell strategy - :param dataframe: Raw data from the exchange and parsed by parse_ticker_dataframe() - :return: a Dataframe with all mandatory indicators for the strategies - """ - return self.custom_strategy.populate_indicators(dataframe) - - def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame: - """ - Based on TA indicators, populates the buy signal for the given dataframe - :param dataframe: DataFrame - :return: DataFrame with buy column - :return: - """ - return self.custom_strategy.populate_buy_trend(dataframe) - - def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame: - """ - Based on TA indicators, populates the sell signal for the given dataframe - :param dataframe: DataFrame - :return: DataFrame with buy column - """ - return self.custom_strategy.populate_sell_trend(dataframe) diff --git a/freqtrade/tests/strategy/test_strategy.py b/freqtrade/tests/strategy/test_strategy.py index 075efd6ab..244910790 100644 --- a/freqtrade/tests/strategy/test_strategy.py +++ b/freqtrade/tests/strategy/test_strategy.py @@ -1,9 +1,10 @@ # pragma pylint: disable=missing-docstring, protected-access, C0103 import logging - import os +import pytest + from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.resolver import StrategyResolver @@ -19,46 +20,50 @@ def test_search_strategy(): def test_load_strategy(result): + resolver = StrategyResolver() + resolver._load_strategy('TestStrategy') + assert hasattr(resolver.strategy, 'populate_indicators') + assert 'adx' in resolver.strategy.populate_indicators(result) + + +def test_load_strategy_custom_directory(result): + resolver = StrategyResolver() + extra_dir = os.path.join('some', 'path') + with pytest.raises( + FileNotFoundError, + match=r".*No such file or directory: '{}'".format(extra_dir)): + resolver._load_strategy('TestStrategy', extra_dir) + + assert hasattr(resolver.strategy, 'populate_indicators') + assert 'adx' in resolver.strategy.populate_indicators(result) + + +def test_load_not_found_strategy(): strategy = StrategyResolver() - - assert not hasattr(StrategyResolver, 'custom_strategy') - strategy._load_strategy('TestStrategy') - - assert not hasattr(StrategyResolver, 'custom_strategy') - - assert hasattr(strategy.custom_strategy, 'populate_indicators') - assert 'adx' in strategy.populate_indicators(result) - - -def test_load_not_found_strategy(caplog): - strategy = StrategyResolver() - - assert not hasattr(StrategyResolver, 'custom_strategy') - strategy._load_strategy('NotFoundStrategy') - - error_msg = "Impossible to load Strategy '{}'. This class does not " \ - "exist or contains Python code errors".format('NotFoundStrategy') - assert ('freqtrade.strategy.resolver', logging.ERROR, error_msg) in caplog.record_tuples + with pytest.raises(ImportError, + match=r'Impossible to load Strategy \'NotFoundStrategy\'.' + r' This class does not exist or contains Python code errors'): + strategy._load_strategy('NotFoundStrategy') def test_strategy(result): - strategy = StrategyResolver({'strategy': 'DefaultStrategy'}) + resolver = StrategyResolver({'strategy': 'DefaultStrategy'}) - assert hasattr(strategy.custom_strategy, 'minimal_roi') - assert strategy.minimal_roi[0] == 0.04 + assert hasattr(resolver.strategy, 'minimal_roi') + assert resolver.strategy.minimal_roi[0] == 0.04 - assert hasattr(strategy.custom_strategy, 'stoploss') - assert strategy.stoploss == -0.10 + assert hasattr(resolver.strategy, 'stoploss') + assert resolver.strategy.stoploss == -0.10 - assert hasattr(strategy.custom_strategy, 'populate_indicators') - assert 'adx' in strategy.populate_indicators(result) + assert hasattr(resolver.strategy, 'populate_indicators') + assert 'adx' in resolver.strategy.populate_indicators(result) - assert hasattr(strategy.custom_strategy, 'populate_buy_trend') - dataframe = strategy.populate_buy_trend(strategy.populate_indicators(result)) + assert hasattr(resolver.strategy, 'populate_buy_trend') + dataframe = resolver.strategy.populate_buy_trend(resolver.strategy.populate_indicators(result)) assert 'buy' in dataframe.columns - assert hasattr(strategy.custom_strategy, 'populate_sell_trend') - dataframe = strategy.populate_sell_trend(strategy.populate_indicators(result)) + assert hasattr(resolver.strategy, 'populate_sell_trend') + dataframe = resolver.strategy.populate_sell_trend(resolver.strategy.populate_indicators(result)) assert 'sell' in dataframe.columns @@ -70,10 +75,10 @@ def test_strategy_override_minimal_roi(caplog): "0": 0.5 } } - strategy = StrategyResolver(config) + resolver = StrategyResolver(config) - assert hasattr(strategy.custom_strategy, 'minimal_roi') - assert strategy.minimal_roi[0] == 0.5 + assert hasattr(resolver.strategy, 'minimal_roi') + assert resolver.strategy.minimal_roi[0] == 0.5 assert ('freqtrade.strategy.resolver', logging.INFO, 'Override strategy \'minimal_roi\' with value in config file.' @@ -86,10 +91,10 @@ def test_strategy_override_stoploss(caplog): 'strategy': 'DefaultStrategy', 'stoploss': -0.5 } - strategy = StrategyResolver(config) + resolver = StrategyResolver(config) - assert hasattr(strategy.custom_strategy, 'stoploss') - assert strategy.stoploss == -0.5 + assert hasattr(resolver.strategy, 'stoploss') + assert resolver.strategy.stoploss == -0.5 assert ('freqtrade.strategy.resolver', logging.INFO, 'Override strategy \'stoploss\' with value in config file: -0.5.' @@ -103,31 +108,11 @@ def test_strategy_override_ticker_interval(caplog): 'strategy': 'DefaultStrategy', 'ticker_interval': 60 } - strategy = StrategyResolver(config) + resolver = StrategyResolver(config) - assert hasattr(strategy.custom_strategy, 'ticker_interval') - assert strategy.ticker_interval == 60 + assert hasattr(resolver.strategy, 'ticker_interval') + assert resolver.strategy.ticker_interval == 60 assert ('freqtrade.strategy.resolver', logging.INFO, 'Override strategy \'ticker_interval\' with value in config file: 60.' ) in caplog.record_tuples - - -def test_strategy_fallback_default_strategy(): - strategy = StrategyResolver() - strategy.logger = logging.getLogger(__name__) - - assert not hasattr(StrategyResolver, 'custom_strategy') - strategy._load_strategy('../../super_duper') - assert not hasattr(StrategyResolver, 'custom_strategy') - - -def test_strategy_singleton(): - strategy1 = StrategyResolver({'strategy': 'DefaultStrategy'}) - - assert hasattr(strategy1.custom_strategy, 'minimal_roi') - assert strategy1.minimal_roi[0] == 0.04 - - strategy2 = StrategyResolver() - assert hasattr(strategy2.custom_strategy, 'minimal_roi') - assert strategy2.minimal_roi[0] == 0.04 diff --git a/freqtrade/tests/test_arguments.py b/freqtrade/tests/test_arguments.py index 3e0639304..3377746b7 100644 --- a/freqtrade/tests/test_arguments.py +++ b/freqtrade/tests/test_arguments.py @@ -71,6 +71,26 @@ def test_parse_args_invalid() -> None: Arguments(['-c'], '').get_parsed_arg() +def test_parse_args_strategy() -> None: + args = Arguments(['--strategy', 'SomeStrategy'], '').get_parsed_arg() + assert args.strategy == 'SomeStrategy' + + +def test_parse_args_strategy_invalid() -> None: + with pytest.raises(SystemExit, match=r'2'): + Arguments(['--strategy'], '').get_parsed_arg() + + +def test_parse_args_strategy_path() -> None: + args = Arguments(['--strategy-path', '/some/path'], '').get_parsed_arg() + assert args.strategy_path == '/some/path' + + +def test_parse_args_strategy_path_invalid() -> None: + with pytest.raises(SystemExit, match=r'2'): + Arguments(['--strategy-path'], '').get_parsed_arg() + + def test_parse_args_dynamic_whitelist() -> None: args = Arguments(['--dynamic-whitelist'], '').get_parsed_arg() assert args.dynamic_whitelist == 20 diff --git a/freqtrade/tests/test_configuration.py b/freqtrade/tests/test_configuration.py index 1085b0060..22a6cb005 100644 --- a/freqtrade/tests/test_configuration.py +++ b/freqtrade/tests/test_configuration.py @@ -98,8 +98,8 @@ def test_load_config(default_conf, mocker) -> None: configuration = Configuration(args) validated_conf = configuration.load_config() - assert 'strategy' in validated_conf - assert validated_conf['strategy'] == 'DefaultStrategy' + assert validated_conf.get('strategy') == 'DefaultStrategy' + assert validated_conf.get('strategy_path') is None assert 'dynamic_whitelist' not in validated_conf assert 'dry_run_db' not in validated_conf @@ -115,19 +115,39 @@ def test_load_config_with_params(default_conf, mocker) -> None: args = [ '--dynamic-whitelist', '10', '--strategy', 'TestStrategy', - '--dry-run-db' + '--strategy-path', '/some/path', + '--dry-run-db', ] args = Arguments(args, '').get_parsed_arg() configuration = Configuration(args) validated_conf = configuration.load_config() - assert 'dynamic_whitelist' in validated_conf - assert validated_conf['dynamic_whitelist'] == 10 - assert 'strategy' in validated_conf - assert validated_conf['strategy'] == 'TestStrategy' - assert 'dry_run_db' in validated_conf - assert validated_conf['dry_run_db'] is True + assert validated_conf.get('dynamic_whitelist') == 10 + assert validated_conf.get('strategy') == 'TestStrategy' + assert validated_conf.get('strategy_path') == '/some/path' + assert validated_conf.get('dry_run_db') is True + + +def test_load_custom_strategy(default_conf, mocker) -> None: + """ + Test Configuration.load_config() without any cli params + """ + custom_conf = deepcopy(default_conf) + custom_conf.update({ + 'strategy': 'CustomStrategy', + 'strategy_path': '/tmp/strategies', + }) + mocker.patch('freqtrade.configuration.open', mocker.mock_open( + read_data=json.dumps(custom_conf) + )) + + args = Arguments([], '').get_parsed_arg() + configuration = Configuration(args) + validated_conf = configuration.load_config() + + assert validated_conf.get('strategy') == 'CustomStrategy' + assert validated_conf.get('strategy_path') == '/tmp/strategies' def test_show_info(default_conf, mocker, caplog) -> None: