diff --git a/docs/rest-api.md b/docs/rest-api.md index 075bd7e64..44f0b07cf 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -104,7 +104,7 @@ By default, the script assumes `127.0.0.1` (localhost) and port `8080` to be use python3 scripts/rest_client.py --config rest_config.json [optional parameters] ``` -## Available commands +## Available endpoints | Command | Description | |----------|-------------| @@ -129,8 +129,17 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `whitelist` | Show the current whitelist | `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `edge` | Show validated pairs by Edge if it is enabled. +| `pair_candles` | Returns dataframe for a pair / timeframe combination while the bot is running. **Alpha** +| `pair_history` | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy. **Alpha** +| `plot_config` | Get plot config from the strategy (or nothing if not configured). **Alpha** +| `strategies` | List strategies in strategy directory. **Alpha** +| `strategy ` | Get specific Strategy content. **Alpha** +| `available_pairs` | List available backtest data. **Alpha** | `version` | Show version +!!! Warning "Alpha status" + Endpoints labeled with *Alpha status* above may change at any time without notice. + Possible commands can be listed from the rest-client script using the `help` command. ``` bash @@ -140,6 +149,12 @@ python3 scripts/rest_client.py help ``` output Possible commands: +available_pairs + Return available pair (backtest data) based on timeframe / stake_currency selection + + :param timeframe: Only pairs with this timeframe available. + :param stake_currency: Only pairs that include this timeframe + balance Get the account balance. @@ -179,9 +194,27 @@ logs :param limit: Limits log messages to the last logs. No limit to get all the trades. +pair_candles + Return live dataframe for . + + :param pair: Pair to get data for + :param timeframe: Only pairs with this timeframe available. + :param limit: Limit result to the last n candles. + +pair_history + Return historic, analyzed dataframe + + :param pair: Pair to get data for + :param timeframe: Only pairs with this timeframe available. + :param strategy: Strategy to analyze and get values for + :param timerange: Timerange to get data for (same format than --timerange endpoints) + performance Return the performance of the different coins. +plot_config + Return plot configuration if the strategy defines one. + profit Return the profit summary. @@ -204,6 +237,14 @@ stop stopbuy Stop buying (but handle sells gracefully). Use `reload_config` to reset. +strategies + Lists available strategies + +strategy + Get strategy details + + :param strategy: Strategy class name + trades Return trades history. @@ -215,7 +256,6 @@ version whitelist Show the current whitelist. - ``` ## Advanced API usage using JWT tokens diff --git a/freqtrade/resolvers/iresolver.py b/freqtrade/resolvers/iresolver.py index 846c85a5c..37cfd70e6 100644 --- a/freqtrade/resolvers/iresolver.py +++ b/freqtrade/resolvers/iresolver.py @@ -51,7 +51,8 @@ class IResolver: :param object_name: Class name of the object :param enum_failed: If True, will return None for modules which fail. Otherwise, failing modules are skipped. - :return: generator containing matching objects + :return: generator containing tuple of matching objects + Tuple format: [Object, source] """ # Generate spec based on absolute path @@ -67,14 +68,16 @@ class IResolver: return iter([None]) valid_objects_gen = ( - obj for name, obj in inspect.getmembers(module, inspect.isclass) - if ((object_name is None or object_name == name) and - issubclass(obj, cls.object_type) and obj is not cls.object_type) + (obj, inspect.getsource(module)) for + name, obj in inspect.getmembers( + module, inspect.isclass) if ((object_name is None or object_name == name) + and issubclass(obj, cls.object_type) + and obj is not cls.object_type) ) return valid_objects_gen @classmethod - def _search_object(cls, directory: Path, object_name: str + def _search_object(cls, directory: Path, *, object_name: str, add_source: bool = False ) -> Union[Tuple[Any, Path], Tuple[None, None]]: """ Search for the objectname in the given directory @@ -93,11 +96,14 @@ class IResolver: obj = next(cls._get_valid_object(module_path, object_name), None) if obj: - return (obj, module_path) + obj[0].__file__ = str(entry) + if add_source: + obj[0].__source__ = obj[1] + return (obj[0], module_path) return (None, None) @classmethod - def _load_object(cls, paths: List[Path], object_name: str, + def _load_object(cls, paths: List[Path], *, object_name: str, add_source: bool = False, kwargs: dict = {}) -> Optional[Any]: """ Try to load object from path list. @@ -106,7 +112,8 @@ class IResolver: for _path in paths: try: (module, module_path) = cls._search_object(directory=_path, - object_name=object_name) + object_name=object_name, + add_source=add_source) if module: logger.info( f"Using resolved {cls.object_type.__name__.lower()[1:]} {object_name} " @@ -118,7 +125,7 @@ class IResolver: return None @classmethod - def load_object(cls, object_name: str, config: dict, kwargs: dict, + 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. @@ -133,10 +140,10 @@ class IResolver: 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 + found_object = cls._load_object(paths=abs_paths, object_name=object_name, + kwargs=kwargs) + if found_object: + return found_object raise OperationalException( f"Impossible to load {cls.object_type_str} '{object_name}'. This class does not exist " "or contains Python code errors." @@ -164,8 +171,8 @@ class IResolver: for obj in cls._get_valid_object(module_path, object_name=None, enum_failed=enum_failed): objects.append( - {'name': obj.__name__ if obj is not None else '', - 'class': obj, + {'name': obj[0].__name__ if obj is not None else '', + 'class': obj[0] if obj is not None else None, 'location': entry, }) return objects diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index ead7424ec..63a3f784e 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -174,7 +174,9 @@ class StrategyResolver(IResolver): strategy = StrategyResolver._load_object(paths=abs_paths, object_name=strategy_name, - kwargs={'config': config}) + add_source=True, + 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) diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 588062023..4e262b1ec 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -1,7 +1,9 @@ import logging import threading +from copy import deepcopy from datetime import date, datetime from ipaddress import IPv4Address +from pathlib import Path from typing import Any, Callable, Dict from arrow import Arrow @@ -15,7 +17,8 @@ from werkzeug.security import safe_str_cmp from werkzeug.serving import make_server from freqtrade.__init__ import __version__ -from freqtrade.constants import DATETIME_PRINT_FORMAT +from freqtrade.constants import DATETIME_PRINT_FORMAT, USERPATH_STRATEGIES +from freqtrade.exceptions import OperationalException from freqtrade.persistence import Trade from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.rpc.rpc import RPC, RPCException @@ -26,15 +29,15 @@ logger = logging.getLogger(__name__) BASE_URI = "/api/v1" -class ArrowJSONEncoder(JSONEncoder): +class FTJSONEncoder(JSONEncoder): def default(self, obj): try: if isinstance(obj, Arrow): return obj.for_json() - elif isinstance(obj, date): - return obj.strftime("%Y-%m-%d") elif isinstance(obj, datetime): return obj.strftime(DATETIME_PRINT_FORMAT) + elif isinstance(obj, date): + return obj.strftime("%Y-%m-%d") iterable = iter(obj) except TypeError: pass @@ -108,7 +111,7 @@ class ApiServer(RPC): 'jwt_secret_key', 'super-secret') self.jwt = JWTManager(self.app) - self.app.json_encoder = ArrowJSONEncoder + self.app.json_encoder = FTJSONEncoder self.app.teardown_appcontext(shutdown_session) @@ -160,16 +163,12 @@ class ApiServer(RPC): """ pass - def rest_dump(self, return_value): - """ Helper function to jsonify object for a webserver """ - return jsonify(return_value) - - def rest_error(self, error_msg): - return jsonify({"error": error_msg}), 502 + def rest_error(self, error_msg, error_code=502): + return jsonify({"error": error_msg}), error_code def register_rest_rpc_urls(self): """ - Registers flask app URLs that are calls to functonality in rpc.rpc. + Registers flask app URLs that are calls to functionality in rpc.rpc. First two arguments passed are /URL and 'Label' Label can be used as a shortcut when refactoring @@ -212,6 +211,20 @@ class ApiServer(RPC): view_func=self._trades, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/trades/', 'trades_delete', view_func=self._trades_delete, methods=['DELETE']) + + self.app.add_url_rule(f'{BASE_URI}/pair_candles', 'pair_candles', + view_func=self._analysed_candles, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/pair_history', 'pair_history', + view_func=self._analysed_history, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/plot_config', 'plot_config', + view_func=self._plot_config, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/strategies', 'strategies', + view_func=self._list_strategies, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/strategy/', 'strategy', + view_func=self._get_strategy, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/available_pairs', 'pairs', + view_func=self._list_available_pairs, methods=['GET']) + # Combined actions and infos self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist, methods=['GET', 'POST']) @@ -227,7 +240,7 @@ class ApiServer(RPC): """ Return "404 not found", 404. """ - return self.rest_dump({ + return jsonify({ 'status': 'error', 'reason': f"There's no API call for {request.base_url}.", 'code': 404 @@ -247,7 +260,7 @@ class ApiServer(RPC): 'access_token': create_access_token(identity=keystuff), 'refresh_token': create_refresh_token(identity=keystuff), } - return self.rest_dump(ret) + return jsonify(ret) return jsonify({"error": "Unauthorized"}), 401 @@ -262,7 +275,7 @@ class ApiServer(RPC): new_token = create_access_token(identity=current_user, fresh=False) ret = {'access_token': new_token} - return self.rest_dump(ret) + return jsonify(ret) @require_login @rpc_catch_errors @@ -272,7 +285,7 @@ class ApiServer(RPC): Starts TradeThread in bot if stopped. """ msg = self._rpc_start() - return self.rest_dump(msg) + return jsonify(msg) @require_login @rpc_catch_errors @@ -282,7 +295,7 @@ class ApiServer(RPC): Stops TradeThread in bot if running """ msg = self._rpc_stop() - return self.rest_dump(msg) + return jsonify(msg) @require_login @rpc_catch_errors @@ -292,14 +305,14 @@ class ApiServer(RPC): Sets max_open_trades to 0 and gracefully sells all open trades """ msg = self._rpc_stopbuy() - return self.rest_dump(msg) + return jsonify(msg) @rpc_catch_errors def _ping(self): """ - simple poing version + simple ping version """ - return self.rest_dump({"status": "pong"}) + return jsonify({"status": "pong"}) @require_login @rpc_catch_errors @@ -307,7 +320,7 @@ class ApiServer(RPC): """ Prints the bot's version """ - return self.rest_dump({"version": __version__}) + return jsonify({"version": __version__}) @require_login @rpc_catch_errors @@ -315,7 +328,7 @@ class ApiServer(RPC): """ Prints the bot's version """ - return self.rest_dump(self._rpc_show_config()) + return jsonify(self._rpc_show_config(self._config)) @require_login @rpc_catch_errors @@ -325,7 +338,7 @@ class ApiServer(RPC): Triggers a config file reload """ msg = self._rpc_reload_config() - return self.rest_dump(msg) + return jsonify(msg) @require_login @rpc_catch_errors @@ -335,7 +348,7 @@ class ApiServer(RPC): Returns the number of trades running """ msg = self._rpc_count() - return self.rest_dump(msg) + return jsonify(msg) @require_login @rpc_catch_errors @@ -353,7 +366,7 @@ class ApiServer(RPC): self._config.get('fiat_display_currency', '') ) - return self.rest_dump(stats) + return jsonify(stats) @require_login @rpc_catch_errors @@ -365,7 +378,7 @@ class ApiServer(RPC): limit: Only get a certain number of records """ limit = int(request.args.get('limit', 0)) or None - return self.rest_dump(self._rpc_get_logs(limit)) + return jsonify(self._rpc_get_logs(limit)) @require_login @rpc_catch_errors @@ -376,7 +389,7 @@ class ApiServer(RPC): """ stats = self._rpc_edge() - return self.rest_dump(stats) + return jsonify(stats) @require_login @rpc_catch_errors @@ -392,7 +405,7 @@ class ApiServer(RPC): self._config.get('fiat_display_currency') ) - return self.rest_dump(stats) + return jsonify(stats) @require_login @rpc_catch_errors @@ -405,7 +418,7 @@ class ApiServer(RPC): """ stats = self._rpc_performance() - return self.rest_dump(stats) + return jsonify(stats) @require_login @rpc_catch_errors @@ -417,9 +430,9 @@ class ApiServer(RPC): """ try: results = self._rpc_trade_status() - return self.rest_dump(results) + return jsonify(results) except RPCException: - return self.rest_dump([]) + return jsonify([]) @require_login @rpc_catch_errors @@ -431,7 +444,7 @@ class ApiServer(RPC): """ results = self._rpc_balance(self._config['stake_currency'], self._config.get('fiat_display_currency', '')) - return self.rest_dump(results) + return jsonify(results) @require_login @rpc_catch_errors @@ -443,7 +456,7 @@ class ApiServer(RPC): """ limit = int(request.args.get('limit', 0)) results = self._rpc_trade_history(limit) - return self.rest_dump(results) + return jsonify(results) @require_login @rpc_catch_errors @@ -456,7 +469,7 @@ class ApiServer(RPC): tradeid: Numeric trade-id assigned to the trade. """ result = self._rpc_delete(tradeid) - return self.rest_dump(result) + return jsonify(result) @require_login @rpc_catch_errors @@ -465,7 +478,7 @@ class ApiServer(RPC): Handler for /whitelist. """ results = self._rpc_whitelist() - return self.rest_dump(results) + return jsonify(results) @require_login @rpc_catch_errors @@ -475,7 +488,7 @@ class ApiServer(RPC): """ add = request.json.get("blacklist", None) if request.method == 'POST' else None results = self._rpc_blacklist(add) - return self.rest_dump(results) + return jsonify(results) @require_login @rpc_catch_errors @@ -487,9 +500,9 @@ class ApiServer(RPC): price = request.json.get("price", None) trade = self._rpc_forcebuy(asset, price) if trade: - return self.rest_dump(trade.to_json()) + return jsonify(trade.to_json()) else: - return self.rest_dump({"status": f"Error buying pair {asset}."}) + return jsonify({"status": f"Error buying pair {asset}."}) @require_login @rpc_catch_errors @@ -499,4 +512,132 @@ class ApiServer(RPC): """ tradeid = request.json.get("tradeid") results = self._rpc_forcesell(tradeid) - return self.rest_dump(results) + return jsonify(results) + + @require_login + @rpc_catch_errors + def _analysed_candles(self): + """ + Handler for /pair_candles. + Returns the dataframe the bot is using during live/dry operations. + Takes the following get arguments: + get: + parameters: + - pair: Pair + - timeframe: Timeframe to get data for (should be aligned to strategy.timeframe) + - limit: Limit return length to the latest X candles + """ + pair = request.args.get("pair") + timeframe = request.args.get("timeframe") + limit = request.args.get("limit", type=int) + if not pair or not timeframe: + return self.rest_error("Mandatory parameter missing.", 400) + + results = self._rpc_analysed_dataframe(pair, timeframe, limit) + return jsonify(results) + + @require_login + @rpc_catch_errors + def _analysed_history(self): + """ + Handler for /pair_history. + Returns the dataframe of a given timerange + Takes the following get arguments: + get: + parameters: + - pair: Pair + - timeframe: Timeframe to get data for (should be aligned to strategy.timeframe) + - strategy: Strategy to use - Must exist in configured strategy-path! + - timerange: timerange in the format YYYYMMDD-YYYYMMDD (YYYYMMDD- or (-YYYYMMDD)) + are als possible. If omitted uses all available data. + """ + pair = request.args.get("pair") + timeframe = request.args.get("timeframe") + timerange = request.args.get("timerange") + strategy = request.args.get("strategy") + + if not pair or not timeframe or not timerange or not strategy: + return self.rest_error("Mandatory parameter missing.", 400) + + config = deepcopy(self._config) + config.update({ + 'strategy': strategy, + }) + results = self._rpc_analysed_history_full(config, pair, timeframe, timerange) + return jsonify(results) + + @require_login + @rpc_catch_errors + def _plot_config(self): + """ + Handler for /plot_config. + """ + return jsonify(self._rpc_plot_config()) + + @require_login + @rpc_catch_errors + def _list_strategies(self): + directory = Path(self._config.get( + 'strategy_path', self._config['user_data_dir'] / USERPATH_STRATEGIES)) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + strategy_objs = StrategyResolver.search_all_objects(directory, False) + strategy_objs = sorted(strategy_objs, key=lambda x: x['name']) + + return jsonify({'strategies': [x['name'] for x in strategy_objs]}) + + @require_login + @rpc_catch_errors + def _get_strategy(self, strategy: str): + """ + Get a single strategy + get: + parameters: + - strategy: Only get this strategy + """ + config = deepcopy(self._config) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + try: + strategy_obj = StrategyResolver._load_strategy(strategy, config, + extra_dir=config.get('strategy_path')) + except OperationalException: + return self.rest_error("Strategy not found.", 404) + + return jsonify({ + 'strategy': strategy_obj.get_strategy_name(), + 'code': strategy_obj.__source__, + }) + + @require_login + @rpc_catch_errors + def _list_available_pairs(self): + """ + Handler for /available_pairs. + Returns an object, with pairs, available pair length and pair_interval combinations + Takes the following get arguments: + get: + parameters: + - stake_currency: Filter on this stake currency + - timeframe: Timeframe to get data for Filter elements to this timeframe + """ + timeframe = request.args.get("timeframe") + stake_currency = request.args.get("stake_currency") + + from freqtrade.data.history import get_datahandler + dh = get_datahandler(self._config['datadir'], self._config.get('dataformat_ohlcv', None)) + + pair_interval = dh.ohlcv_get_available_data(self._config['datadir']) + + if timeframe: + pair_interval = [pair for pair in pair_interval if pair[1] == timeframe] + if stake_currency: + pair_interval = [pair for pair in pair_interval if pair[0].endswith(stake_currency)] + pair_interval = sorted(pair_interval, key=lambda x: x[0]) + + pairs = list({x[0] for x in pair_interval}) + + result = { + 'length': len(pairs), + 'pairs': pairs, + 'pair_interval': pair_interval, + } + return jsonify(result) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index e4ac65981..b89284acf 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -9,9 +9,12 @@ from math import isnan from typing import Any, Dict, List, Optional, Tuple, Union import arrow -from numpy import NAN, mean +from numpy import NAN, int64, mean +from pandas import DataFrame +from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON +from freqtrade.data.history import load_data from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler @@ -90,13 +93,12 @@ class RPC: def send_msg(self, msg: Dict[str, str]) -> None: """ Sends a message to all registered rpc modules """ - def _rpc_show_config(self) -> Dict[str, Any]: + def _rpc_show_config(self, config) -> Dict[str, Any]: """ Return a dict of config options. Explicitly does NOT return the full config to avoid leakage of sensitive information via rpc. """ - config = self._freqtrade.config val = { 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], @@ -117,7 +119,7 @@ class RPC: 'forcebuy_enabled': config.get('forcebuy_enable', False), 'ask_strategy': config.get('ask_strategy', {}), 'bid_strategy': config.get('bid_strategy', {}), - 'state': str(self._freqtrade.state) + 'state': str(self._freqtrade.state) if self._freqtrade else '', } return val @@ -653,3 +655,80 @@ class RPC: if not self._freqtrade.edge: raise RPCException('Edge is not enabled.') return self._freqtrade.edge.accepted_pairs() + + def _convert_dataframe_to_dict(self, strategy: str, pair: str, timeframe: str, + dataframe: DataFrame, last_analyzed: datetime) -> Dict[str, Any]: + has_content = len(dataframe) != 0 + buy_signals = 0 + sell_signals = 0 + if has_content: + + dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].astype(int64) // 1000 // 1000 + # Move open to seperate column when signal for easy plotting + if 'buy' in dataframe.columns: + buy_mask = (dataframe['buy'] == 1) + buy_signals = int(buy_mask.sum()) + dataframe.loc[buy_mask, '_buy_signal_open'] = dataframe.loc[buy_mask, 'open'] + if 'sell' in dataframe.columns: + sell_mask = (dataframe['sell'] == 1) + sell_signals = int(sell_mask.sum()) + dataframe.loc[sell_mask, '_sell_signal_open'] = dataframe.loc[sell_mask, 'open'] + dataframe = dataframe.replace({NAN: None}) + + res = { + 'pair': pair, + 'timeframe': timeframe, + 'timeframe_ms': timeframe_to_msecs(timeframe), + 'strategy': strategy, + 'columns': list(dataframe.columns), + 'data': dataframe.values.tolist(), + 'length': len(dataframe), + 'buy_signals': buy_signals, + 'sell_signals': sell_signals, + 'last_analyzed': last_analyzed, + 'last_analyzed_ts': int(last_analyzed.timestamp()), + 'data_start': '', + 'data_start_ts': 0, + 'data_stop': '', + 'data_stop_ts': 0, + } + if has_content: + res.update({ + 'data_start': str(dataframe.iloc[0]['date']), + 'data_start_ts': int(dataframe.iloc[0]['__date_ts']), + 'data_stop': str(dataframe.iloc[-1]['date']), + 'data_stop_ts': int(dataframe.iloc[-1]['__date_ts']), + }) + return res + + def _rpc_analysed_dataframe(self, pair: str, timeframe: str, limit: int) -> Dict[str, Any]: + + _data, last_analyzed = self._freqtrade.dataprovider.get_analyzed_dataframe( + pair, timeframe) + _data = _data.copy() + if limit: + _data = _data.iloc[-limit:] + return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'], + pair, timeframe, _data, last_analyzed) + + def _rpc_analysed_history_full(self, config, pair: str, timeframe: str, + timerange: str) -> Dict[str, Any]: + timerange_parsed = TimeRange.parse_timerange(timerange) + + _data = load_data( + datadir=config.get("datadir"), + pairs=[pair], + timeframe=timeframe, + timerange=timerange_parsed, + data_format=config.get('dataformat_ohlcv', 'json'), + ) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + strategy = StrategyResolver.load_strategy(config) + df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) + + return self._convert_dataframe_to_dict(strategy.get_strategy_name(), pair, timeframe, + df_analyzed, arrow.Arrow.utcnow().datetime) + + def _rpc_plot_config(self) -> Dict[str, Any]: + + return self._freqtrade.strategy.plot_config diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 01d21c53c..7a6607632 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -755,7 +755,7 @@ class Telegram(RPC): :param update: message update :return: None """ - val = self._rpc_show_config() + val = self._rpc_show_config(self._freqtrade.config) if val['trailing_stop']: sl_info = ( f"*Initial Stoploss:* `{val['stoploss']}`\n" diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 04d7055ba..b6b36b1a4 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -123,6 +123,8 @@ class IStrategy(ABC): # and wallets - access to the current balance. dp: Optional[DataProvider] = None wallets: Optional[Wallets] = None + # container variable for strategy source code + __source__: str = '' # Definition of plot_config. See plotting documentation for more details. plot_config: Dict = {} diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 8512777df..46966d447 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -224,6 +224,70 @@ class FtRestClient(): return self._post("forcesell", data={"tradeid": tradeid}) + def strategies(self): + """Lists available strategies + + :return: json object + """ + return self._get("strategies") + + def strategy(self, strategy): + """Get strategy details + + :param strategy: Strategy class name + :return: json object + """ + return self._get(f"strategy/{strategy}") + + def plot_config(self): + """Return plot configuration if the strategy defines one. + + :return: json object + """ + return self._get("plot_config") + + def available_pairs(self, timeframe=None, stake_currency=None): + """Return available pair (backtest data) based on timeframe / stake_currency selection + + :param timeframe: Only pairs with this timeframe available. + :param stake_currency: Only pairs that include this timeframe + :return: json object + """ + return self._get("available_pairs", params={ + "stake_currency": stake_currency if timeframe else '', + "timeframe": timeframe if timeframe else '', + }) + + def pair_candles(self, pair, timeframe, limit=None): + """Return live dataframe for . + + :param pair: Pair to get data for + :param timeframe: Only pairs with this timeframe available. + :param limit: Limit result to the last n candles. + :return: json object + """ + return self._get("available_pairs", params={ + "pair": pair, + "timeframe": timeframe, + "limit": limit, + }) + + def pair_history(self, pair, timeframe, strategy, timerange=None): + """Return historic, analyzed dataframe + + :param pair: Pair to get data for + :param timeframe: Only pairs with this timeframe available. + :param strategy: Strategy to analyze and get values for + :param timerange: Timerange to get data for (same format than --timerange endpoints) + :return: json object + """ + return self._get("pair_history", params={ + "pair": pair, + "timeframe": timeframe, + "strategy": strategy, + "timerange": timerange if timerange else '', + }) + def add_arguments(): parser = argparse.ArgumentParser() diff --git a/tests/conftest.py b/tests/conftest.py index dbd0df8f6..2153fd327 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -146,6 +146,7 @@ def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: :return: FreqtradeBot """ patch_freqtradebot(mocker, config) + config['datadir'] = Path(config['datadir']) return FreqtradeBot(config) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index fea7b1c73..d0e5d3c37 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -3,6 +3,7 @@ Unit test file for rpc/api_server.py """ from datetime import datetime +from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock import pytest @@ -811,3 +812,176 @@ def test_api_forcesell(botclient, mocker, ticker, fee, markets): data='{"tradeid": "1"}') assert_response(rc) assert rc.json == {'result': 'Created sell order for trade 1.'} + + +def test_api_pair_candles(botclient, ohlcv_history): + ftbot, client = botclient + timeframe = '5m' + amount = 2 + + # No pair + rc = client_get(client, + f"{BASE_URI}/pair_candles?limit={amount}&timeframe={timeframe}") + assert_response(rc, 400) + + # No timeframe + rc = client_get(client, + f"{BASE_URI}/pair_candles?pair=XRP%2FBTC") + assert_response(rc, 400) + + rc = client_get(client, + f"{BASE_URI}/pair_candles?limit={amount}&pair=XRP%2FBTC&timeframe={timeframe}") + assert_response(rc) + assert 'columns' in rc.json + assert 'data_start_ts' in rc.json + assert 'data_start' in rc.json + assert 'data_stop' in rc.json + assert 'data_stop_ts' in rc.json + assert len(rc.json['data']) == 0 + ohlcv_history['sma'] = ohlcv_history['close'].rolling(2).mean() + ohlcv_history['buy'] = 0 + ohlcv_history.loc[1, 'buy'] = 1 + ohlcv_history['sell'] = 0 + + ftbot.dataprovider._set_cached_df("XRP/BTC", timeframe, ohlcv_history) + + rc = client_get(client, + f"{BASE_URI}/pair_candles?limit={amount}&pair=XRP%2FBTC&timeframe={timeframe}") + assert_response(rc) + assert 'strategy' in rc.json + assert rc.json['strategy'] == 'DefaultStrategy' + assert 'columns' in rc.json + assert 'data_start_ts' in rc.json + assert 'data_start' in rc.json + assert 'data_stop' in rc.json + assert 'data_stop_ts' in rc.json + assert rc.json['data_start'] == '2017-11-26 08:50:00+00:00' + assert rc.json['data_start_ts'] == 1511686200000 + assert rc.json['data_stop'] == '2017-11-26 08:55:00+00:00' + assert rc.json['data_stop_ts'] == 1511686500000 + assert isinstance(rc.json['columns'], list) + assert rc.json['columns'] == ['date', 'open', 'high', + 'low', 'close', 'volume', 'sma', 'buy', 'sell', + '__date_ts', '_buy_signal_open', '_sell_signal_open'] + assert 'pair' in rc.json + assert rc.json['pair'] == 'XRP/BTC' + + assert 'data' in rc.json + assert len(rc.json['data']) == amount + + assert (rc.json['data'] == + [['2017-11-26 08:50:00', 8.794e-05, 8.948e-05, 8.794e-05, 8.88e-05, 0.0877869, + None, 0, 0, 1511686200000, None, None], + ['2017-11-26 08:55:00', 8.88e-05, 8.942e-05, 8.88e-05, + 8.893e-05, 0.05874751, 8.886500000000001e-05, 1, 0, 1511686500000, 8.88e-05, None] + ]) + + +def test_api_pair_history(botclient, ohlcv_history): + ftbot, client = botclient + timeframe = '5m' + + # No pair + rc = client_get(client, + f"{BASE_URI}/pair_history?timeframe={timeframe}" + "&timerange=20180111-20180112&strategy=DefaultStrategy") + assert_response(rc, 400) + + # No Timeframe + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC" + "&timerange=20180111-20180112&strategy=DefaultStrategy") + assert_response(rc, 400) + + # No timerange + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" + "&strategy=DefaultStrategy") + assert_response(rc, 400) + + # No strategy + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" + "&timerange=20180111-20180112") + assert_response(rc, 400) + + # Working + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" + "&timerange=20180111-20180112&strategy=DefaultStrategy") + assert_response(rc, 200) + assert rc.json['length'] == 289 + assert len(rc.json['data']) == rc.json['length'] + assert 'columns' in rc.json + assert 'data' in rc.json + assert rc.json['pair'] == 'UNITTEST/BTC' + assert rc.json['strategy'] == 'DefaultStrategy' + assert rc.json['data_start'] == '2018-01-11 00:00:00+00:00' + assert rc.json['data_start_ts'] == 1515628800000 + assert rc.json['data_stop'] == '2018-01-12 00:00:00+00:00' + assert rc.json['data_stop_ts'] == 1515715200000 + + +def test_api_plot_config(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/plot_config") + assert_response(rc) + assert rc.json == {} + + ftbot.strategy.plot_config = {'main_plot': {'sma': {}}, + 'subplots': {'RSI': {'rsi': {'color': 'red'}}}} + rc = client_get(client, f"{BASE_URI}/plot_config") + assert_response(rc) + assert rc.json == ftbot.strategy.plot_config + assert isinstance(rc.json['main_plot'], dict) + + +def test_api_strategies(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/strategies") + + assert_response(rc) + assert rc.json == {'strategies': ['DefaultStrategy', 'TestStrategyLegacy']} + + +def test_api_strategy(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/strategy/DefaultStrategy") + + assert_response(rc) + assert rc.json['strategy'] == 'DefaultStrategy' + + data = (Path(__file__).parents[1] / "strategy/strats/default_strategy.py").read_text() + assert rc.json['code'] == data + + rc = client_get(client, f"{BASE_URI}/strategy/NoStrat") + assert_response(rc, 404) + + +def test_list_available_pairs(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/available_pairs") + + assert_response(rc) + assert rc.json['length'] == 12 + assert isinstance(rc.json['pairs'], list) + + rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m") + assert_response(rc) + assert rc.json['length'] == 12 + + rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH") + assert_response(rc) + assert rc.json['length'] == 1 + assert rc.json['pairs'] == ['XRP/ETH'] + assert len(rc.json['pair_interval']) == 2 + + rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH&timeframe=5m") + assert_response(rc) + assert rc.json['length'] == 1 + assert rc.json['pairs'] == ['XRP/ETH'] + assert len(rc.json['pair_interval']) == 1 diff --git a/tests/strategy/test_strategy.py b/tests/strategy/test_strategy.py index 240f3d8ec..1c692d2da 100644 --- a/tests/strategy/test_strategy.py +++ b/tests/strategy/test_strategy.py @@ -18,13 +18,15 @@ def test_search_strategy(): s, _ = StrategyResolver._search_object( directory=default_location, - object_name='DefaultStrategy' + object_name='DefaultStrategy', + add_source=True, ) assert issubclass(s, IStrategy) s, _ = StrategyResolver._search_object( directory=default_location, - object_name='NotFoundStrategy' + object_name='NotFoundStrategy', + add_source=True, ) assert s is None @@ -53,6 +55,9 @@ def test_load_strategy(default_conf, result): 'strategy_path': str(Path(__file__).parents[2] / 'freqtrade/templates') }) strategy = StrategyResolver.load_strategy(default_conf) + assert isinstance(strategy.__source__, str) + assert 'class SampleStrategy' in strategy.__source__ + assert isinstance(strategy.__file__, str) assert 'rsi' in strategy.advise_indicators(result, {'pair': 'ETH/BTC'})