Merge pull request #2712 from freqtrade/strategylist

add list-strategies subcommand
This commit is contained in:
hroff-1902 2019-12-28 12:32:06 +03:00 committed by GitHub
commit 004993583b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 245 additions and 138 deletions

View File

@ -108,6 +108,47 @@ With custom user directory
freqtrade new-hyperopt --userdir ~/.freqtrade/ --hyperopt AwesomeHyperopt
```
## List Strategies
Use the `list-strategies` subcommand to see all strategies in one particular directory.
```
freqtrade list-strategies --help
usage: freqtrade list-strategies [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--strategy-path PATH] [-1]
optional arguments:
-h, --help show this help message and exit
--strategy-path PATH Specify additional strategy lookup path.
-1, --one-column Print output in one column.
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
--logfile FILE Log to the file specified. Special values are: 'syslog', 'journald'. See the documentation for more details.
-V, --version show program's version number and exit
-c PATH, --config PATH
Specify configuration file (default: `config.json`). Multiple --config options may be used. Can be set to `-`
to read config from stdin.
-d PATH, --datadir PATH
Path to directory with historical backtesting data.
--userdir PATH, --user-data-dir PATH
Path to userdata directory.
```
!!! Warning
Using this command will try to load all python files from a directory. This can be a security risk if untrusted files reside in this directory, since all module-level code is executed.
Example: search default strategy directory within userdir
``` bash
freqtrade list-strategies --userdir ~/.freqtrade/
```
Example: search dedicated strategy path
``` bash
freqtrade list-strategies --strategy-path ~/.freqtrade/strategies/
```
## List Exchanges
Use the `list-exchanges` subcommand to see the exchanges available for the bot.

View File

@ -30,6 +30,8 @@ ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"]
ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column"]
ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all"]
ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column"]
@ -62,7 +64,8 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop
"print_json", "hyperopt_show_no_header"]
NO_CONF_REQURIED = ["download-data", "list-timeframes", "list-markets", "list-pairs",
"hyperopt-list", "hyperopt-show", "plot-dataframe", "plot-profit"]
"list-strategies", "hyperopt-list", "hyperopt-show", "plot-dataframe",
"plot-profit"]
NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-hyperopt", "new-strategy"]
@ -131,8 +134,9 @@ class Arguments:
from freqtrade.utils import (start_create_userdir, start_download_data,
start_hyperopt_list, start_hyperopt_show,
start_list_exchanges, start_list_markets,
start_new_hyperopt, start_new_strategy,
start_list_timeframes, start_test_pairlist, start_trading)
start_list_strategies, start_new_hyperopt,
start_new_strategy, start_list_timeframes,
start_test_pairlist, start_trading)
from freqtrade.plot.plot_utils import start_plot_dataframe, start_plot_profit
subparsers = self.parser.add_subparsers(dest='command',
@ -185,6 +189,15 @@ class Arguments:
build_hyperopt_cmd.set_defaults(func=start_new_hyperopt)
self._build_args(optionlist=ARGS_BUILD_HYPEROPT, parser=build_hyperopt_cmd)
# Add list-strategies subcommand
list_strategies_cmd = subparsers.add_parser(
'list-strategies',
help='Print available strategies.',
parents=[_common_parser],
)
list_strategies_cmd.set_defaults(func=start_list_strategies)
self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd)
# Add list-exchanges subcommand
list_exchanges_cmd = subparsers.add_parser(
'list-exchanges',

View File

@ -14,6 +14,7 @@ class ExchangeResolver(IResolver):
"""
This class contains all the logic to load a custom exchange class
"""
object_type = Exchange
@staticmethod
def load_exchange(exchange_name: str, config: dict, validate: bool = True) -> Exchange:

View File

@ -5,7 +5,7 @@ This module load custom hyperopt
"""
import logging
from pathlib import Path
from typing import Optional, Dict
from typing import Dict
from freqtrade import OperationalException
from freqtrade.constants import DEFAULT_HYPEROPT_LOSS, USERPATH_HYPEROPTS
@ -20,6 +20,10 @@ class HyperOptResolver(IResolver):
"""
This class contains all the logic to load custom hyperopt class
"""
object_type = IHyperOpt
object_type_str = "Hyperopt"
user_subdir = USERPATH_HYPEROPTS
initial_search_path = Path(__file__).parent.parent.joinpath('optimize').resolve()
@staticmethod
def load_hyperopt(config: Dict) -> IHyperOpt:
@ -33,8 +37,9 @@ class HyperOptResolver(IResolver):
hyperopt_name = config['hyperopt']
hyperopt = HyperOptResolver._load_hyperopt(hyperopt_name, config,
extra_dir=config.get('hyperopt_path'))
hyperopt = HyperOptResolver.load_object(hyperopt_name, config,
kwargs={'config': config},
extra_dir=config.get('hyperopt_path'))
if not hasattr(hyperopt, 'populate_indicators'):
logger.warning("Hyperopt class does not provide populate_indicators() method. "
@ -47,36 +52,15 @@ class HyperOptResolver(IResolver):
"Using populate_sell_trend from the strategy.")
return hyperopt
@staticmethod
def _load_hyperopt(
hyperopt_name: str, config: Dict, extra_dir: Optional[str] = None) -> IHyperOpt:
"""
Search and loads the specified hyperopt.
:param hyperopt_name: name of the module to import
:param config: configuration dictionary
:param extra_dir: additional directory to search for the given hyperopt
:return: HyperOpt instance or None
"""
current_path = Path(__file__).parent.parent.joinpath('optimize').resolve()
abs_paths = IResolver.build_search_paths(config, current_path=current_path,
user_subdir=USERPATH_HYPEROPTS,
extra_dir=extra_dir)
hyperopt = IResolver._load_object(paths=abs_paths, object_type=IHyperOpt,
object_name=hyperopt_name, kwargs={'config': config})
if hyperopt:
return hyperopt
raise OperationalException(
f"Impossible to load Hyperopt '{hyperopt_name}'. This class does not exist "
"or contains Python code errors."
)
class HyperOptLossResolver(IResolver):
"""
This class contains all the logic to load custom hyperopt loss class
"""
object_type = IHyperOptLoss
object_type_str = "HyperoptLoss"
user_subdir = USERPATH_HYPEROPTS
initial_search_path = Path(__file__).parent.parent.joinpath('optimize').resolve()
@staticmethod
def load_hyperoptloss(config: Dict) -> IHyperOptLoss:
@ -89,8 +73,9 @@ class HyperOptLossResolver(IResolver):
# default hyperopt loss
hyperoptloss_name = config.get('hyperopt_loss') or DEFAULT_HYPEROPT_LOSS
hyperoptloss = HyperOptLossResolver._load_hyperoptloss(
hyperoptloss_name, config, extra_dir=config.get('hyperopt_path'))
hyperoptloss = HyperOptLossResolver.load_object(hyperoptloss_name,
config, kwargs={},
extra_dir=config.get('hyperopt_path'))
# Assign ticker_interval to be used in hyperopt
hyperoptloss.__class__.ticker_interval = str(config['ticker_interval'])
@ -100,29 +85,3 @@ class HyperOptLossResolver(IResolver):
f"Found HyperoptLoss class {hyperoptloss_name} does not "
"implement `hyperopt_loss_function`.")
return hyperoptloss
@staticmethod
def _load_hyperoptloss(hyper_loss_name: str, config: Dict,
extra_dir: Optional[str] = None) -> IHyperOptLoss:
"""
Search and loads the specified hyperopt loss class.
:param hyper_loss_name: name of the module to import
:param config: configuration dictionary
:param extra_dir: additional directory to search for the given hyperopt
:return: HyperOptLoss instance or None
"""
current_path = Path(__file__).parent.parent.joinpath('optimize').resolve()
abs_paths = IResolver.build_search_paths(config, current_path=current_path,
user_subdir=USERPATH_HYPEROPTS,
extra_dir=extra_dir)
hyperoptloss = IResolver._load_object(paths=abs_paths, object_type=IHyperOptLoss,
object_name=hyper_loss_name)
if hyperoptloss:
return hyperoptloss
raise OperationalException(
f"Impossible to load HyperoptLoss '{hyper_loss_name}'. This class does not exist "
"or contains Python code errors."
)

View File

@ -7,7 +7,9 @@ import importlib.util
import inspect
import logging
from pathlib import Path
from typing import Any, List, Optional, Tuple, Union, Generator
from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union
from freqtrade import OperationalException
logger = logging.getLogger(__name__)
@ -16,12 +18,17 @@ class IResolver:
"""
This class contains all the logic to load custom classes
"""
# Childclasses need to override this
object_type: Type[Any]
object_type_str: str
user_subdir: Optional[str] = None
initial_search_path: Path
@staticmethod
def build_search_paths(config, current_path: Path, user_subdir: Optional[str] = None,
@classmethod
def build_search_paths(cls, config, user_subdir: Optional[str] = None,
extra_dir: Optional[str] = None) -> List[Path]:
abs_paths: List[Path] = [current_path]
abs_paths: List[Path] = [cls.initial_search_path]
if user_subdir:
abs_paths.insert(0, config['user_data_dir'].joinpath(user_subdir))
@ -32,12 +39,11 @@ class IResolver:
return abs_paths
@staticmethod
def _get_valid_object(object_type, module_path: Path,
object_name: str) -> Generator[Any, None, None]:
@classmethod
def _get_valid_object(cls, module_path: Path,
object_name: Optional[str]) -> Generator[Any, None, None]:
"""
Generator returning objects with matching object_type and object_name in the path given.
:param object_type: object_type (class)
:param module_path: absolute path to the module
:param object_name: Class name of the object
:return: generator containing matching objects
@ -45,7 +51,7 @@ class IResolver:
# Generate spec based on absolute path
# Pass object_name as first argument to have logging print a reasonable name.
spec = importlib.util.spec_from_file_location(object_name, str(module_path))
spec = importlib.util.spec_from_file_location(object_name or "", str(module_path))
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module) # type: ignore # importlib does not use typehints
@ -55,19 +61,20 @@ class IResolver:
valid_objects_gen = (
obj for name, obj in inspect.getmembers(module, inspect.isclass)
if object_name == name and object_type in obj.__bases__
if (object_name is None or object_name == name) and cls.object_type in obj.__bases__
)
return valid_objects_gen
@staticmethod
def _search_object(directory: Path, object_type, object_name: str,
kwargs: dict = {}) -> Union[Tuple[Any, Path], Tuple[None, None]]:
@classmethod
def _search_object(cls, directory: Path, object_name: str
) -> Union[Tuple[Any, Path], Tuple[None, None]]:
"""
Search for the objectname in the given directory
:param directory: relative or absolute directory path
:return: object instance
:param object_name: ClassName of the object to load
:return: object class
"""
logger.debug("Searching for %s %s in '%s'", object_type.__name__, object_name, directory)
logger.debug(f"Searching for {cls.object_type.__name__} {object_name} in '{directory}'")
for entry in directory.iterdir():
# Only consider python files
if not str(entry).endswith('.py'):
@ -75,14 +82,14 @@ class IResolver:
continue
module_path = entry.resolve()
obj = next(IResolver._get_valid_object(object_type, module_path, object_name), None)
obj = next(cls._get_valid_object(module_path, object_name), None)
if obj:
return (obj(**kwargs), module_path)
return (obj, module_path)
return (None, None)
@staticmethod
def _load_object(paths: List[Path], object_type, object_name: str,
@classmethod
def _load_object(cls, paths: List[Path], object_name: str,
kwargs: dict = {}) -> Optional[Any]:
"""
Try to load object from path list.
@ -90,16 +97,63 @@ class IResolver:
for _path in paths:
try:
(module, module_path) = IResolver._search_object(directory=_path,
object_type=object_type,
object_name=object_name,
kwargs=kwargs)
(module, module_path) = cls._search_object(directory=_path,
object_name=object_name)
if module:
logger.info(
f"Using resolved {object_type.__name__.lower()[1:]} {object_name} "
f"Using resolved {cls.object_type.__name__.lower()[1:]} {object_name} "
f"from '{module_path}'...")
return module
return module(**kwargs)
except FileNotFoundError:
logger.warning('Path "%s" does not exist.', _path.resolve())
return None
@classmethod
def load_object(cls, object_name: str, config: dict, kwargs: dict,
extra_dir: Optional[str] = None) -> Any:
"""
Search and loads the specified object as configured in hte child class.
:param objectname: name of the module to import
:param config: configuration dictionary
:param extra_dir: additional directory to search for the given pairlist
:raises: OperationalException if the class is invalid or does not exist.
:return: Object instance or None
"""
abs_paths = cls.build_search_paths(config,
user_subdir=cls.user_subdir,
extra_dir=extra_dir)
pairlist = cls._load_object(paths=abs_paths, object_name=object_name,
kwargs=kwargs)
if pairlist:
return pairlist
raise OperationalException(
f"Impossible to load {cls.object_type_str} '{object_name}'. This class does not exist "
"or contains Python code errors."
)
@classmethod
def search_all_objects(cls, directory: Path) -> List[Dict[str, Any]]:
"""
Searches a directory for valid objects
:param directory: Path to search
:return: List of dicts containing 'name', 'class' and 'location' entires
"""
logger.debug(f"Searching for {cls.object_type.__name__} '{directory}'")
objects = []
for entry in directory.iterdir():
# Only consider python files
if not str(entry).endswith('.py'):
logger.debug('Ignoring %s', entry)
continue
module_path = entry.resolve()
logger.debug(f"Path {module_path}")
for obj in cls._get_valid_object(module_path, object_name=None):
objects.append(
{'name': obj.__name__,
'class': obj,
'location': entry,
})
return objects

View File

@ -6,7 +6,6 @@ This module load custom pairlists
import logging
from pathlib import Path
from freqtrade import OperationalException
from freqtrade.pairlist.IPairList import IPairList
from freqtrade.resolvers import IResolver
@ -17,6 +16,10 @@ class PairListResolver(IResolver):
"""
This class contains all the logic to load custom PairList class
"""
object_type = IPairList
object_type_str = "Pairlist"
user_subdir = None
initial_search_path = Path(__file__).parent.parent.joinpath('pairlist').resolve()
@staticmethod
def load_pairlist(pairlist_name: str, exchange, pairlistmanager,
@ -31,33 +34,10 @@ class PairListResolver(IResolver):
:param pairlist_pos: Position of the pairlist in the list of pairlists
:return: initialized Pairlist class
"""
return PairListResolver._load_pairlist(pairlist_name, config,
kwargs={'exchange': exchange,
'pairlistmanager': pairlistmanager,
'config': config,
'pairlistconfig': pairlistconfig,
'pairlist_pos': pairlist_pos})
@staticmethod
def _load_pairlist(pairlist_name: str, config: dict, kwargs: dict) -> IPairList:
"""
Search and loads the specified pairlist.
:param pairlist_name: name of the module to import
:param config: configuration dictionary
:param extra_dir: additional directory to search for the given pairlist
:return: PairList instance or None
"""
current_path = Path(__file__).parent.parent.joinpath('pairlist').resolve()
abs_paths = IResolver.build_search_paths(config, current_path=current_path,
user_subdir=None, extra_dir=None)
pairlist = IResolver._load_object(paths=abs_paths, object_type=IPairList,
object_name=pairlist_name, kwargs=kwargs)
if pairlist:
return pairlist
raise OperationalException(
f"Impossible to load Pairlist '{pairlist_name}'. This class does not exist "
"or contains Python code errors."
)
return PairListResolver.load_object(pairlist_name, config,
kwargs={'exchange': exchange,
'pairlistmanager': pairlistmanager,
'config': config,
'pairlistconfig': pairlistconfig,
'pairlist_pos': pairlist_pos},
)

View File

@ -11,7 +11,9 @@ from inspect import getfullargspec
from pathlib import Path
from typing import Dict, Optional
from freqtrade import constants, OperationalException
from freqtrade import OperationalException
from freqtrade.constants import (REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES,
USERPATH_STRATEGY)
from freqtrade.resolvers import IResolver
from freqtrade.strategy.interface import IStrategy
@ -22,6 +24,10 @@ class StrategyResolver(IResolver):
"""
This class contains the logic to load custom strategy class
"""
object_type = IStrategy
object_type_str = "Strategy"
user_subdir = USERPATH_STRATEGY
initial_search_path = Path(__file__).parent.parent.joinpath('strategy').resolve()
@staticmethod
def load_strategy(config: Optional[Dict] = None) -> IStrategy:
@ -114,11 +120,11 @@ class StrategyResolver(IResolver):
@staticmethod
def _strategy_sanity_validations(strategy):
if not all(k in strategy.order_types for k in constants.REQUIRED_ORDERTYPES):
if not all(k in strategy.order_types for k in REQUIRED_ORDERTYPES):
raise ImportError(f"Impossible to load Strategy '{strategy.__class__.__name__}'. "
f"Order-types mapping is incomplete.")
if not all(k in strategy.order_time_in_force for k in constants.REQUIRED_ORDERTIF):
if not all(k in strategy.order_time_in_force for k in REQUIRED_ORDERTIF):
raise ImportError(f"Impossible to load Strategy '{strategy.__class__.__name__}'. "
f"Order-time-in-force mapping is incomplete.")
@ -132,11 +138,10 @@ class StrategyResolver(IResolver):
:param extra_dir: additional directory to search for the given strategy
:return: Strategy instance or None
"""
current_path = Path(__file__).parent.parent.joinpath('strategy').resolve()
abs_paths = IResolver.build_search_paths(config, current_path=current_path,
user_subdir=constants.USERPATH_STRATEGY,
extra_dir=extra_dir)
abs_paths = StrategyResolver.build_search_paths(config,
user_subdir=USERPATH_STRATEGY,
extra_dir=extra_dir)
if ":" in strategy_name:
logger.info("loading base64 encoded strategy")
@ -154,8 +159,9 @@ class StrategyResolver(IResolver):
# register temp path with the bot
abs_paths.insert(0, temp.resolve())
strategy = IResolver._load_object(paths=abs_paths, object_type=IStrategy,
object_name=strategy_name, kwargs={'config': config})
strategy = StrategyResolver._load_object(paths=abs_paths,
object_name=strategy_name,
kwargs={'config': config})
if strategy:
strategy._populate_fun_len = len(getfullargspec(strategy.populate_indicators).args)
strategy._buy_fun_len = len(getfullargspec(strategy.populate_buy_trend).args)

View File

@ -23,7 +23,7 @@ from freqtrade.data.history import (convert_trades_to_ohlcv,
from freqtrade.exchange import (available_exchanges, ccxt_exchanges,
market_is_active, symbol_is_pair)
from freqtrade.misc import plural, render_template
from freqtrade.resolvers import ExchangeResolver
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.state import RunMode
logger = logging.getLogger(__name__)
@ -223,6 +223,24 @@ def start_download_data(args: Dict[str, Any]) -> None:
f"on exchange {exchange.name}.")
def start_list_strategies(args: Dict[str, Any]) -> None:
"""
Print Strategies available in a directory
"""
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGY))
strategies = StrategyResolver.search_all_objects(directory)
# Sort alphabetically
strategies = sorted(strategies, key=lambda x: x['name'])
strats_to_print = [{'name': s['name'], 'location': s['location'].name} for s in strategies]
if args['print_one_column']:
print('\n'.join([s['name'] for s in strategies]))
else:
print(tabulate(strats_to_print, headers='keys', tablefmt='pipe'))
def start_list_timeframes(args: Dict[str, Any]) -> None:
"""
Print ticker intervals (timeframes) available on Exchange

View File

@ -159,7 +159,7 @@ def test_hyperoptresolver(mocker, default_conf, caplog) -> None:
delattr(hyperopt, 'populate_buy_trend')
delattr(hyperopt, 'populate_sell_trend')
mocker.patch(
'freqtrade.resolvers.hyperopt_resolver.HyperOptResolver._load_hyperopt',
'freqtrade.resolvers.hyperopt_resolver.HyperOptResolver.load_object',
MagicMock(return_value=hyperopt(default_conf))
)
default_conf.update({'hyperopt': 'DefaultHyperOpt'})
@ -195,7 +195,7 @@ def test_hyperoptlossresolver(mocker, default_conf, caplog) -> None:
hl = DefaultHyperOptLoss
mocker.patch(
'freqtrade.resolvers.hyperopt_resolver.HyperOptLossResolver._load_hyperoptloss',
'freqtrade.resolvers.hyperopt_resolver.HyperOptLossResolver.load_object',
MagicMock(return_value=hl)
)
x = HyperOptLossResolver.load_hyperoptloss(default_conf)

View File

@ -15,26 +15,29 @@ from tests.conftest import log_has, log_has_re
def test_search_strategy():
default_config = {}
default_location = Path(__file__).parent.parent.joinpath('strategy').resolve()
s, _ = StrategyResolver._search_object(
directory=default_location,
object_type=IStrategy,
kwargs={'config': default_config},
object_name='DefaultStrategy'
)
assert isinstance(s, IStrategy)
assert issubclass(s, IStrategy)
s, _ = StrategyResolver._search_object(
directory=default_location,
object_type=IStrategy,
kwargs={'config': default_config},
object_name='NotFoundStrategy'
)
assert s is None
def test_search_all_strategies():
directory = Path(__file__).parent
strategies = StrategyResolver.search_all_objects(directory)
assert isinstance(strategies, list)
assert len(strategies) == 3
assert isinstance(strategies[0], dict)
def test_load_strategy(default_conf, result):
default_conf.update({'strategy': 'SampleStrategy',
'strategy_path': str(Path(__file__).parents[2] / 'freqtrade/templates')

View File

@ -7,11 +7,12 @@ import pytest
from freqtrade import OperationalException
from freqtrade.state import RunMode
from freqtrade.utils import (setup_utils_configuration, start_create_userdir,
start_download_data, start_list_exchanges,
start_list_markets, start_list_timeframes,
start_new_hyperopt, start_new_strategy,
start_test_pairlist, start_trading,
start_hyperopt_list, start_hyperopt_show)
start_download_data, start_hyperopt_list,
start_hyperopt_show, start_list_exchanges,
start_list_markets, start_list_strategies,
start_list_timeframes, start_new_hyperopt,
start_new_strategy, start_test_pairlist,
start_trading)
from tests.conftest import (get_args, log_has, log_has_re, patch_exchange,
patched_configuration_load_config_file)
@ -630,6 +631,37 @@ def test_download_data_trades(mocker, caplog):
assert convert_mock.call_count == 1
def test_start_list_strategies(mocker, caplog, capsys):
args = [
"list-strategies",
"--strategy-path",
str(Path(__file__).parent / "strategy"),
"-1"
]
pargs = get_args(args)
# pargs['config'] = None
start_list_strategies(pargs)
captured = capsys.readouterr()
assert "TestStrategyLegacy" in captured.out
assert "legacy_strategy.py" not in captured.out
assert "DefaultStrategy" in captured.out
# Test regular output
args = [
"list-strategies",
"--strategy-path",
str(Path(__file__).parent / "strategy"),
]
pargs = get_args(args)
# pargs['config'] = None
start_list_strategies(pargs)
captured = capsys.readouterr()
assert "TestStrategyLegacy" in captured.out
assert "legacy_strategy.py" in captured.out
assert "DefaultStrategy" in captured.out
def test_start_test_pairlist(mocker, caplog, markets, tickers, default_conf, capsys):
mocker.patch.multiple('freqtrade.exchange.Exchange',
markets=PropertyMock(return_value=markets),