Merge pull request #6558 from samgermain/recursive-strategy-folder

Recursively search subdirectories in config['user_data_dir']/strategies for a strategy
This commit is contained in:
Matthias 2022-04-23 14:23:53 +02:00 committed by GitHub
commit d4e12371c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 76 additions and 18 deletions

View File

@ -182,6 +182,7 @@
"disable_dataframe_checks": false, "disable_dataframe_checks": false,
"strategy": "SampleStrategy", "strategy": "SampleStrategy",
"strategy_path": "user_data/strategies/", "strategy_path": "user_data/strategies/",
"recursive_strategy_search": false,
"add_config_files": [], "add_config_files": [],
"dataformat_ohlcv": "json", "dataformat_ohlcv": "json",
"dataformat_trades": "jsongz" "dataformat_trades": "jsongz"

View File

@ -173,6 +173,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `order_types` | Configure order-types depending on the action (`"entry"`, `"exit"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).<br> **Datatype:** Dict | `order_types` | Configure order-types depending on the action (`"entry"`, `"exit"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).<br> **Datatype:** Dict
| `order_time_in_force` | Configure time in force for entry and exit orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict | `order_time_in_force` | Configure time in force for entry and exit orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict
| `custom_price_max_distance_ratio` | Configure maximum distance ratio between current and custom entry or exit price. <br>*Defaults to `0.02` 2%).*<br> **Datatype:** Positive float | `custom_price_max_distance_ratio` | Configure maximum distance ratio between current and custom entry or exit price. <br>*Defaults to `0.02` 2%).*<br> **Datatype:** Positive float
| `recursive_strategy_search` | Set to `true` to recursively search sub-directories inside `user_data/strategies` for a strategy. <br> **Datatype:** Boolean
| `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename). <br> **Datatype:** String | `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename). <br> **Datatype:** String
| `exchange.sandbox` | Use the 'sandbox' version of the exchange, where the exchange provides a sandbox for risk-free integration. See [here](sandbox-testing.md) in more details.<br> **Datatype:** Boolean | `exchange.sandbox` | Use the 'sandbox' version of the exchange, where the exchange provides a sandbox for risk-free integration. See [here](sandbox-testing.md) in more details.<br> **Datatype:** Boolean
| `exchange.key` | API key to use for the exchange. Only required when you are in production mode.<br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String | `exchange.key` | API key to use for the exchange. Only required when you are in production mode.<br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String

View File

@ -12,7 +12,7 @@ from freqtrade.constants import DEFAULT_CONFIG
ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_data_dir"] ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_data_dir"]
ARGS_STRATEGY = ["strategy", "strategy_path"] ARGS_STRATEGY = ["strategy", "strategy_path", "recursive_strategy_search"]
ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"]
@ -37,7 +37,8 @@ ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"] ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"]
ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column", "print_colorized"] ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column", "print_colorized",
"recursive_strategy_search"]
ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"] ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"]

View File

@ -83,6 +83,11 @@ AVAILABLE_CLI_OPTIONS = {
help='Reset sample files to their original state.', help='Reset sample files to their original state.',
action='store_true', action='store_true',
), ),
"recursive_strategy_search": Arg(
'--recursive-strategy-search',
help='Recursively search for a strategy in the strategies folder.',
action='store_true',
),
# Main options # Main options
"strategy": Arg( "strategy": Arg(
'-s', '--strategy', '-s', '--strategy',

View File

@ -41,7 +41,7 @@ def start_list_exchanges(args: Dict[str, Any]) -> None:
print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason'])) print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason']))
def _print_objs_tabular(objs: List, print_colorized: bool) -> None: def _print_objs_tabular(objs: List, print_colorized: bool, base_dir: Path) -> None:
if print_colorized: if print_colorized:
colorama_init(autoreset=True) colorama_init(autoreset=True)
red = Fore.RED red = Fore.RED
@ -55,7 +55,7 @@ def _print_objs_tabular(objs: List, print_colorized: bool) -> None:
names = [s['name'] for s in objs] names = [s['name'] for s in objs]
objs_to_print = [{ objs_to_print = [{
'name': s['name'] if s['name'] else "--", 'name': s['name'] if s['name'] else "--",
'location': s['location'].name, 'location': s['location'].relative_to(base_dir),
'status': (red + "LOAD FAILED" + reset if s['class'] is None 'status': (red + "LOAD FAILED" + reset if s['class'] is None
else "OK" if names.count(s['name']) == 1 else "OK" if names.count(s['name']) == 1
else yellow + "DUPLICATE NAME" + reset) else yellow + "DUPLICATE NAME" + reset)
@ -77,7 +77,8 @@ def start_list_strategies(args: Dict[str, Any]) -> None:
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
strategy_objs = StrategyResolver.search_all_objects(directory, not args['print_one_column']) strategy_objs = StrategyResolver.search_all_objects(
directory, not args['print_one_column'], config.get('recursive_strategy_search', False))
# Sort alphabetically # Sort alphabetically
strategy_objs = sorted(strategy_objs, key=lambda x: x['name']) strategy_objs = sorted(strategy_objs, key=lambda x: x['name'])
for obj in strategy_objs: for obj in strategy_objs:
@ -89,7 +90,7 @@ def start_list_strategies(args: Dict[str, Any]) -> None:
if args['print_one_column']: if args['print_one_column']:
print('\n'.join([s['name'] for s in strategy_objs])) print('\n'.join([s['name'] for s in strategy_objs]))
else: else:
_print_objs_tabular(strategy_objs, config.get('print_colorized', False)) _print_objs_tabular(strategy_objs, config.get('print_colorized', False), directory)
def start_list_timeframes(args: Dict[str, Any]) -> None: def start_list_timeframes(args: Dict[str, Any]) -> None:

View File

@ -248,6 +248,12 @@ class Configuration:
self._args_to_config(config, argname='strategy_list', self._args_to_config(config, argname='strategy_list',
logstring='Using strategy list of {} strategies', logfun=len) logstring='Using strategy list of {} strategies', logfun=len)
self._args_to_config(
config,
argname='recursive_strategy_search',
logstring='Recursively searching for a strategy in the strategies folder.',
)
self._args_to_config(config, argname='timeframe', self._args_to_config(config, argname='timeframe',
logstring='Overriding timeframe with Command line argument') logstring='Overriding timeframe with Command line argument')

View File

@ -41,7 +41,8 @@ class HyperoptTools():
""" """
from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.resolvers.strategy_resolver import StrategyResolver
directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
strategy_objs = StrategyResolver.search_all_objects(directory, False) strategy_objs = StrategyResolver.search_all_objects(
directory, False, config.get('recursive_strategy_search', False))
strategies = [s for s in strategy_objs if s['name'] == strategy_name] strategies = [s for s in strategy_objs if s['name'] == strategy_name]
if strategies: if strategies:
strategy = strategies[0] strategy = strategies[0]

View File

@ -44,7 +44,7 @@ class IResolver:
@classmethod @classmethod
def build_search_paths(cls, config: Dict[str, Any], user_subdir: Optional[str] = None, def build_search_paths(cls, config: Dict[str, Any], user_subdir: Optional[str] = None,
extra_dir: Optional[str] = None) -> List[Path]: extra_dirs: List[str] = []) -> List[Path]:
abs_paths: List[Path] = [] abs_paths: List[Path] = []
if cls.initial_search_path: if cls.initial_search_path:
@ -53,9 +53,9 @@ class IResolver:
if user_subdir: if user_subdir:
abs_paths.insert(0, config['user_data_dir'].joinpath(user_subdir)) abs_paths.insert(0, config['user_data_dir'].joinpath(user_subdir))
if extra_dir: # Add extra directory to the top of the search paths
# Add extra directory to the top of the search paths for dir in extra_dirs:
abs_paths.insert(0, Path(extra_dir).resolve()) abs_paths.insert(0, Path(dir).resolve())
return abs_paths return abs_paths
@ -164,9 +164,13 @@ class IResolver:
:return: Object instance or None :return: Object instance or None
""" """
extra_dirs: List[str] = []
if extra_dir:
extra_dirs.append(extra_dir)
abs_paths = cls.build_search_paths(config, abs_paths = cls.build_search_paths(config,
user_subdir=cls.user_subdir, user_subdir=cls.user_subdir,
extra_dir=extra_dir) extra_dirs=extra_dirs)
found_object = cls._load_object(paths=abs_paths, object_name=object_name, found_object = cls._load_object(paths=abs_paths, object_name=object_name,
kwargs=kwargs) kwargs=kwargs)
@ -178,18 +182,25 @@ class IResolver:
) )
@classmethod @classmethod
def search_all_objects(cls, directory: Path, def search_all_objects(cls, directory: Path, enum_failed: bool,
enum_failed: bool) -> List[Dict[str, Any]]: recursive: bool = False) -> List[Dict[str, Any]]:
""" """
Searches a directory for valid objects Searches a directory for valid objects
:param directory: Path to search :param directory: Path to search
:param enum_failed: If True, will return None for modules which fail. :param enum_failed: If True, will return None for modules which fail.
Otherwise, failing modules are skipped. Otherwise, failing modules are skipped.
:param recursive: Recursively walk directory tree searching for strategies
:return: List of dicts containing 'name', 'class' and 'location' entries :return: List of dicts containing 'name', 'class' and 'location' entries
""" """
logger.debug(f"Searching for {cls.object_type.__name__} '{directory}'") logger.debug(f"Searching for {cls.object_type.__name__} '{directory}'")
objects = [] objects = []
for entry in directory.iterdir(): for entry in directory.iterdir():
if (
recursive and entry.is_dir()
and not entry.name.startswith('__')
and not entry.name.startswith('.')
):
objects.extend(cls.search_all_objects(entry, enum_failed, recursive=recursive))
# Only consider python files # Only consider python files
if entry.suffix != '.py': if entry.suffix != '.py':
logger.debug('Ignoring %s', entry) logger.debug('Ignoring %s', entry)

View File

@ -7,8 +7,9 @@ import logging
import tempfile import tempfile
from base64 import urlsafe_b64decode from base64 import urlsafe_b64decode
from inspect import getfullargspec from inspect import getfullargspec
from os import walk
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from freqtrade.configuration.config_validation import validate_migrated_strategy_settings from freqtrade.configuration.config_validation import validate_migrated_strategy_settings
from freqtrade.constants import REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES, USERPATH_STRATEGIES from freqtrade.constants import REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES, USERPATH_STRATEGIES
@ -237,10 +238,19 @@ class StrategyResolver(IResolver):
:param extra_dir: additional directory to search for the given strategy :param extra_dir: additional directory to search for the given strategy
:return: Strategy instance or None :return: Strategy instance or None
""" """
if config.get('recursive_strategy_search', False):
extra_dirs: List[str] = [
path[0] for path in walk(f"{config['user_data_dir']}/{USERPATH_STRATEGIES}")
] # sub-directories
else:
extra_dirs = []
if extra_dir:
extra_dirs.append(extra_dir)
abs_paths = StrategyResolver.build_search_paths(config, abs_paths = StrategyResolver.build_search_paths(config,
user_subdir=USERPATH_STRATEGIES, user_subdir=USERPATH_STRATEGIES,
extra_dir=extra_dir) extra_dirs=extra_dirs)
if ":" in strategy_name: if ":" in strategy_name:
logger.info("loading base64 encoded strategy") logger.info("loading base64 encoded strategy")

View File

@ -253,7 +253,8 @@ def list_strategies(config=Depends(get_config)):
directory = Path(config.get( directory = Path(config.get(
'strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) 'strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategies = StrategyResolver.search_all_objects(directory, False) strategies = StrategyResolver.search_all_objects(
directory, False, config.get('recursive_strategy_search', False))
strategies = sorted(strategies, key=lambda x: x['name']) strategies = sorted(strategies, key=lambda x: x['name'])
return {'strategies': [x['name'] for x in strategies]} return {'strategies': [x['name'] for x in strategies]}

View File

@ -847,7 +847,7 @@ def test_start_convert_trades(mocker, caplog):
assert convert_mock.call_count == 1 assert convert_mock.call_count == 1
def test_start_list_strategies(mocker, caplog, capsys): def test_start_list_strategies(capsys):
args = [ args = [
"list-strategies", "list-strategies",
@ -892,6 +892,26 @@ def test_start_list_strategies(mocker, caplog, capsys):
assert "legacy_strategy_v1.py" in captured.out assert "legacy_strategy_v1.py" in captured.out
assert CURRENT_TEST_STRATEGY in captured.out assert CURRENT_TEST_STRATEGY in captured.out
assert "LOAD FAILED" in captured.out assert "LOAD FAILED" in captured.out
# Recursive
assert "TestStrategyNoImplements" not in captured.out
# Test recursive
args = [
"list-strategies",
"--strategy-path",
str(Path(__file__).parent.parent / "strategy" / "strats"),
'--no-color',
'--recursive-strategy-search'
]
pargs = get_args(args)
# pargs['config'] = None
start_list_strategies(pargs)
captured = capsys.readouterr()
assert "TestStrategyLegacyV1" in captured.out
assert "legacy_strategy_v1.py" in captured.out
assert "StrategyTestV2" in captured.out
assert "TestStrategyNoImplements" in captured.out
assert str(Path("broken_strats/broken_futures_strategies.py")) in captured.out
def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys):