Repository: freqtrade/freqtrade

Issue:
Easy way to list supported timeframes for exchange(s)
  * Operating system: macOS 10.14.5
  * Python Version: Python 3.7.2
  * CCXT version: ccxt==1.18.1112
  * Branch: Develop
  * Last Commit ID: 962d48
  
## Step 3: Describe the problem:

Add a way to list available timeframes for exchanges.
Given that strategies are defined for a certain timeframe, it'd be nice to know which exchanges support that timeframe for backtesting and/or running the strategy.

### Steps to reproduce:

  1. Set timeframe to 4h
  2. Try to download data / backtest using bittrex
  3. Notice error: `2019-09-02 15:22:24,289 - freqtrade - ERROR - Invalid ticker 4h, this Exchange supports {'1m': 'oneMin', '5m': 'fiveMin', '30m': 'thirtyMin', '1h': 'hour', '1d': 'day'}`
  



Discussion:
Note to myself and other devs:

It's not about the particular error, but is an enhancement about new subcommand that would print the timeframes available on exchange.

Thanks for suggestion! 👍 

Resolving patch:
diff --git a/freqtrade/configuration/arguments.py b/freqtrade/configuration/arguments.py
--- a/freqtrade/configuration/arguments.py
+++ b/freqtrade/configuration/arguments.py
@@ -31,6 +31,8 @@
 
 ARGS_LIST_EXCHANGES = ["print_one_column"]
 
+ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column"]
+
 ARGS_CREATE_USERDIR = ["user_data_dir"]
 
 ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "exchange", "timeframes", "erase"]
@@ -41,7 +43,7 @@
 ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
                     "trade_source", "ticker_interval"]
 
-NO_CONF_REQURIED = ["download-data", "plot-dataframe", "plot-profit"]
+NO_CONF_REQURIED = ["download-data", "list-timeframes", "plot-dataframe", "plot-profit"]
 
 NO_CONF_ALLOWED = ["create-userdir", "list-exchanges"]
 
@@ -103,7 +105,8 @@ def _build_subcommands(self) -> None:
         :return: None
         """
         from freqtrade.optimize import start_backtesting, start_hyperopt, start_edge
-        from freqtrade.utils import start_create_userdir, start_download_data, start_list_exchanges
+        from freqtrade.utils import (start_create_userdir, start_download_data,
+                                     start_list_exchanges, start_list_timeframes)
 
         subparsers = self.parser.add_subparsers(dest='subparser')
 
@@ -136,6 +139,14 @@ def _build_subcommands(self) -> None:
         list_exchanges_cmd.set_defaults(func=start_list_exchanges)
         self._build_args(optionlist=ARGS_LIST_EXCHANGES, parser=list_exchanges_cmd)
 
+        # Add list-timeframes subcommand
+        list_timeframes_cmd = subparsers.add_parser(
+            'list-timeframes',
+            help='Print available ticker intervals (timeframes) for the exchange.'
+        )
+        list_timeframes_cmd.set_defaults(func=start_list_timeframes)
+        self._build_args(optionlist=ARGS_LIST_TIMEFRAMES, parser=list_timeframes_cmd)
+
         # Add download-data subcommand
         download_data_cmd = subparsers.add_parser(
             'download-data',
diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py
--- a/freqtrade/exchange/exchange.py
+++ b/freqtrade/exchange/exchange.py
@@ -133,6 +133,9 @@ def __init__(self, config: dict) -> None:
 
         logger.info('Using Exchange "%s"', self.name)
 
+        # Check if timeframe is available
+        self.validate_timeframes(config.get('ticker_interval'))
+
         # Converts the interval provided in minutes in config to seconds
         self.markets_refresh_interval: int = exchange_config.get(
             "markets_refresh_interval", 60) * 60
@@ -144,10 +147,6 @@ def __init__(self, config: dict) -> None:
         self.validate_ordertypes(config.get('order_types', {}))
         self.validate_order_time_in_force(config.get('order_time_in_force', {}))
 
-        if config.get('ticker_interval'):
-            # Check if timeframe is available
-            self.validate_timeframes(config['ticker_interval'])
-
     def __del__(self):
         """
         Destructor - clean up async stuff
@@ -199,6 +198,10 @@ def id(self) -> str:
         """exchange ccxt id"""
         return self._api.id
 
+    @property
+    def timeframes(self) -> List[str]:
+        return list((self._api.timeframes or {}).keys())
+
     @property
     def markets(self) -> Dict:
         """exchange ccxt markets"""
@@ -291,7 +294,7 @@ def get_valid_pair_combination(self, curr_1, curr_2) -> str:
                 return pair
         raise DependencyException(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
 
-    def validate_timeframes(self, timeframe: List[str]) -> None:
+    def validate_timeframes(self, timeframe: Optional[str]) -> None:
         """
         Checks if ticker interval from config is a supported timeframe on the exchange
         """
@@ -304,10 +307,9 @@ def validate_timeframes(self, timeframe: List[str]) -> None:
                 f"for the exchange \"{self.name}\" and this exchange "
                 f"is therefore not supported. ccxt fetchOHLCV: {self.exchange_has('fetchOHLCV')}")
 
-        timeframes = self._api.timeframes
-        if timeframe not in timeframes:
+        if timeframe and (timeframe not in self.timeframes):
             raise OperationalException(
-                f'Invalid ticker {timeframe}, this Exchange supports {timeframes}')
+                f"Invalid ticker interval '{timeframe}'. This exchange supports: {self.timeframes}")
 
     def validate_ordertypes(self, order_types: Dict) -> None:
         """
diff --git a/freqtrade/utils.py b/freqtrade/utils.py
--- a/freqtrade/utils.py
+++ b/freqtrade/utils.py
@@ -97,3 +97,21 @@ def start_download_data(args: Dict[str, Any]) -> None:
         if pairs_not_available:
             logger.info(f"Pairs [{','.join(pairs_not_available)}] not available "
                         f"on exchange {config['exchange']['name']}.")
+
+
+def start_list_timeframes(args: Dict[str, Any]) -> None:
+    """
+    Print ticker intervals (timeframes) available on Exchange
+    """
+    config = setup_utils_configuration(args, RunMode.OTHER)
+    # Do not use ticker_interval set in the config
+    config['ticker_interval'] = None
+
+    # Init exchange
+    exchange = ExchangeResolver(config['exchange']['name'], config).exchange
+
+    if args['print_one_column']:
+        print('\n'.join(exchange.timeframes))
+    else:
+        print(f"Timeframes available for the exchange `{config['exchange']['name']}`: "
+              f"{', '.join(exchange.timeframes)}")


Test patch:
diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py
--- a/tests/exchange/test_exchange.py
+++ b/tests/exchange/test_exchange.py
@@ -409,7 +409,8 @@ def test_validate_timeframes_failed(default_conf, mocker):
     mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
     mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={}))
     mocker.patch('freqtrade.exchange.Exchange.validate_pairs', MagicMock())
-    with pytest.raises(OperationalException, match=r'Invalid ticker 3m, this Exchange supports.*'):
+    with pytest.raises(OperationalException,
+                       match=r"Invalid ticker interval '3m'. This exchange supports.*"):
         Exchange(default_conf)
 
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -7,7 +7,8 @@
 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_download_data, start_list_exchanges,
+                             start_list_timeframes)
 from tests.conftest import get_args, log_has, patch_exchange
 
 
@@ -48,6 +49,96 @@ def test_list_exchanges(capsys):
     assert re.search(r"^bittrex$", captured.out, re.MULTILINE)
 
 
+def test_list_timeframes(mocker, capsys):
+
+    api_mock = MagicMock()
+    api_mock.timeframes = {'1m': 'oneMin',
+                           '5m': 'fiveMin',
+                           '30m': 'thirtyMin',
+                           '1h': 'hour',
+                           '1d': 'day',
+                           }
+    patch_exchange(mocker, api_mock=api_mock)
+    args = [
+        "list-timeframes",
+    ]
+    pargs = get_args(args)
+    pargs['config'] = None
+    with pytest.raises(OperationalException,
+                       match=r"This command requires a configured exchange.*"):
+        start_list_timeframes(pargs)
+
+    # Test with --config config.json.example
+    args = [
+        '--config', 'config.json.example',
+        "list-timeframes",
+    ]
+    start_list_timeframes(get_args(args))
+    captured = capsys.readouterr()
+    assert re.match("Timeframes available for the exchange `bittrex`: "
+                    "1m, 5m, 30m, 1h, 1d",
+                    captured.out)
+
+    # Test with --exchange bittrex
+    args = [
+        "list-timeframes",
+        "--exchange", "bittrex",
+    ]
+    start_list_timeframes(get_args(args))
+    captured = capsys.readouterr()
+    assert re.match("Timeframes available for the exchange `bittrex`: "
+                    "1m, 5m, 30m, 1h, 1d",
+                    captured.out)
+
+    api_mock.timeframes = {'1m': '1m',
+                           '5m': '5m',
+                           '15m': '15m',
+                           '30m': '30m',
+                           '1h': '1h',
+                           '6h': '6h',
+                           '12h': '12h',
+                           '1d': '1d',
+                           '3d': '3d',
+                           }
+    patch_exchange(mocker, api_mock=api_mock)
+    # Test with --exchange binance
+    args = [
+        "list-timeframes",
+        "--exchange", "binance",
+    ]
+    start_list_timeframes(get_args(args))
+    captured = capsys.readouterr()
+    assert re.match("Timeframes available for the exchange `binance`: "
+                    "1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d, 3d",
+                    captured.out)
+
+    # Test with --one-column
+    args = [
+        '--config', 'config.json.example',
+        "list-timeframes",
+        "--one-column",
+    ]
+    start_list_timeframes(get_args(args))
+    captured = capsys.readouterr()
+    assert re.search(r"^1m$", captured.out, re.MULTILINE)
+    assert re.search(r"^5m$", captured.out, re.MULTILINE)
+    assert re.search(r"^1h$", captured.out, re.MULTILINE)
+    assert re.search(r"^1d$", captured.out, re.MULTILINE)
+
+    # Test with --exchange binance --one-column
+    args = [
+        "list-timeframes",
+        "--exchange", "binance",
+        "--one-column",
+    ]
+    start_list_timeframes(get_args(args))
+    captured = capsys.readouterr()
+    assert re.search(r"^1m$", captured.out, re.MULTILINE)
+    assert re.search(r"^5m$", captured.out, re.MULTILINE)
+    assert re.search(r"^1h$", captured.out, re.MULTILINE)
+    assert re.search(r"^1d$", captured.out, re.MULTILINE)
+
+
 def test_create_datadir_failed(caplog):
 
     args = [