diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b0d6cb9..b88d87928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,11 +60,16 @@ jobs: export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include pip install -r requirements-dev.txt + pip install -e ft_client/ pip install -e . + - name: Check for version alignment + run: | + python build_helpers/freqtrade_client_version_align.py + - name: Tests run: | - pytest --random-order --cov=freqtrade --cov-config=.coveragerc + pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc - name: Coveralls if: (runner.os == 'Linux' && matrix.python-version == '3.10' && matrix.os == 'ubuntu-22.04') @@ -188,6 +193,7 @@ jobs: export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include pip install -r requirements-dev.txt + pip install -e ft_client/ pip install -e . - name: Tests @@ -398,13 +404,14 @@ jobs: export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include pip install -r requirements-dev.txt + pip install -e ft_client/ pip install -e . - name: Tests incl. ccxt compatibility tests env: CI_WEB_PROXY: http://152.67.78.211:13128 run: | - pytest --random-order --longrun --durations 20 -n auto --dist loadscope + pytest --random-order --longrun --durations 20 -n auto # Notify only once - when CI completes (and after deploy) in case it's successfull @@ -467,6 +474,19 @@ jobs: dist retention-days: 10 + - name: Build Client distribution + run: | + pip install -U build + python -m build --sdist --wheel ft_client + + - name: Upload artifacts 📦 + uses: actions/upload-artifact@v4 + with: + name: freqtrade-client-build + path: | + ft_client/dist + retention-days: 10 + deploy-pypi: name: "Deploy to PyPI" needs: [ build ] @@ -484,8 +504,10 @@ jobs: - name: Download artifact 📦 uses: actions/download-artifact@v4 with: - name: freqtrade-build + name: freqtrade*-build path: dist + merge-multiple: true + - name: Publish to PyPI (Test) uses: pypa/gh-action-pypi-publish@v1.8.14 diff --git a/build_helpers/freqtrade_client_version_align.py b/build_helpers/freqtrade_client_version_align.py new file mode 100755 index 000000000..3e2c32e20 --- /dev/null +++ b/build_helpers/freqtrade_client_version_align.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +from freqtrade_client import __version__ as client_version + +from freqtrade import __version__ as ft_version + + +def main(): + if ft_version != client_version: + print(f"Versions do not match: \n" + f"ft: {ft_version} \n" + f"client: {client_version}") + exit(1) + print(f"Versions match: ft: {ft_version}, client: {client_version}") + exit(0) + + +if __name__ == '__main__': + main() diff --git a/docs/rest-api.md b/docs/rest-api.md index 229fa5f94..3baff8506 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -95,11 +95,13 @@ Make sure that the following 2 lines are available in your docker-compose file: ### Consuming the API -You can consume the API by using the script `scripts/rest_client.py`. -The client script only requires the `requests` module, so Freqtrade does not need to be installed on the system. +You can consume the API by using `freqtrade-client` (also available as `scripts/rest_client.py`). +This command can be installed independent of the bot by using `pip install freqtrade-client`. + +This module is designed to be lightweight, and only depends on the `requests` and `python-rapidjson` modules, skipping all heavy dependencies freqtrade otherwise needs. ``` bash -python3 scripts/rest_client.py [optional parameters] +freqtrade-client [optional parameters] ``` By default, the script assumes `127.0.0.1` (localhost) and port `8080` to be used, however you can specify a configuration file to override this behaviour. @@ -120,7 +122,7 @@ By default, the script assumes `127.0.0.1` (localhost) and port `8080` to be use ``` ``` bash -python3 scripts/rest_client.py --config rest_config.json [optional parameters] +freqtrade-client --config rest_config.json [optional parameters] ``` ### Available endpoints @@ -176,7 +178,7 @@ python3 scripts/rest_client.py --config rest_config.json [optional par Possible commands can be listed from the rest-client script using the `help` command. ``` bash -python3 scripts/rest_client.py help +freqtrade-client help ``` ``` output diff --git a/ft_client/LICENSE b/ft_client/LICENSE new file mode 120000 index 000000000..ea5b60640 --- /dev/null +++ b/ft_client/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/ft_client/MANIFEST.in b/ft_client/MANIFEST.in new file mode 100644 index 000000000..cee1ed220 --- /dev/null +++ b/ft_client/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md + +prune tests diff --git a/ft_client/README.md b/ft_client/README.md new file mode 100644 index 000000000..a1921aa24 --- /dev/null +++ b/ft_client/README.md @@ -0,0 +1,7 @@ +# Freqtrade Client + +# ![freqtrade](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/freqtrade_poweredby.svg) + +Provides a minimal rest client for the freqtrade rest api. + +Please check out the [main project](https://github.com/freqtrade/freqtrade) for more information or details. diff --git a/ft_client/freqtrade_client/__init__.py b/ft_client/freqtrade_client/__init__.py new file mode 100644 index 000000000..4d945935a --- /dev/null +++ b/ft_client/freqtrade_client/__init__.py @@ -0,0 +1,26 @@ +from freqtrade_client.ft_rest_client import FtRestClient + + +__version__ = '2024.3-dev' + +if 'dev' in __version__: + from pathlib import Path + try: + import subprocess + freqtrade_basedir = Path(__file__).parent + + __version__ = __version__ + '-' + subprocess.check_output( + ['git', 'log', '--format="%h"', '-n 1'], + stderr=subprocess.DEVNULL, cwd=freqtrade_basedir).decode("utf-8").rstrip().strip('"') + + except Exception: # pragma: no cover + # git not available, ignore + try: + # Try Fallback to freqtrade_commit file (created by CI while building docker image) + versionfile = Path('./freqtrade_commit') + if versionfile.is_file(): + __version__ = f"docker-{__version__}-{versionfile.read_text()[:8]}" + except Exception: + pass + +__all__ = ['FtRestClient'] diff --git a/ft_client/freqtrade_client/ft_client.py b/ft_client/freqtrade_client/ft_client.py new file mode 100644 index 000000000..96a7510ff --- /dev/null +++ b/ft_client/freqtrade_client/ft_client.py @@ -0,0 +1,106 @@ +import argparse +import inspect +import json +import logging +import re +import sys +from pathlib import Path +from typing import Any, Dict + +import rapidjson +from freqtrade_client import __version__ +from freqtrade_client.ft_rest_client import FtRestClient + + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) +logger = logging.getLogger("ft_rest_client") + + +def add_arguments(args: Any = None): + parser = argparse.ArgumentParser() + parser.add_argument("command", + help="Positional argument defining the command to execute.", + nargs="?" + ) + parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {__version__}') + parser.add_argument('--show', + help='Show possible methods with this client', + dest='show', + action='store_true', + default=False + ) + + parser.add_argument('-c', '--config', + help='Specify configuration file (default: %(default)s). ', + dest='config', + type=str, + metavar='PATH', + default='config.json' + ) + + parser.add_argument("command_arguments", + help="Positional arguments for the parameters for [command]", + nargs="*", + default=[] + ) + + pargs = parser.parse_args(args) + return vars(pargs) + + +def load_config(configfile): + file = Path(configfile) + if file.is_file(): + with file.open("r") as f: + config = rapidjson.load(f, parse_mode=rapidjson.PM_COMMENTS | + rapidjson.PM_TRAILING_COMMAS) + return config + else: + logger.warning(f"Could not load config file {file}.") + sys.exit(1) + + +def print_commands(): + # Print dynamic help for the different commands using the commands doc-strings + client = FtRestClient(None) + print("Possible commands:\n") + for x, y in inspect.getmembers(client): + if not x.startswith('_'): + doc = re.sub(':return:.*', '', getattr(client, x).__doc__, flags=re.MULTILINE).rstrip() + print(f"{x}\n\t{doc}\n") + + +def main_exec(args: Dict[str, Any]): + + if args.get("show"): + print_commands() + sys.exit() + + config = load_config(args['config']) + url = config.get('api_server', {}).get('listen_ip_address', '127.0.0.1') + port = config.get('api_server', {}).get('listen_port', '8080') + username = config.get('api_server', {}).get('username') + password = config.get('api_server', {}).get('password') + + server_url = f"http://{url}:{port}" + client = FtRestClient(server_url, username, password) + + m = [x for x, y in inspect.getmembers(client) if not x.startswith('_')] + command = args["command"] + if command not in m: + logger.error(f"Command {command} not defined") + print_commands() + return + + print(json.dumps(getattr(client, command)(*args["command_arguments"]))) + + +def main(): + """ + Main entry point for the client + """ + args = add_arguments() + main_exec(args) diff --git a/ft_client/freqtrade_client/ft_rest_client.py b/ft_client/freqtrade_client/ft_rest_client.py new file mode 100755 index 000000000..de782ee65 --- /dev/null +++ b/ft_client/freqtrade_client/ft_rest_client.py @@ -0,0 +1,424 @@ +""" +A Rest Client for Freqtrade bot + +Should not import anything from freqtrade, +so it can be used as a standalone script, and can be installed independently. +""" + +import json +import logging +from typing import Optional +from urllib.parse import urlencode, urlparse, urlunparse + +import requests +from requests.exceptions import ConnectionError + + +logger = logging.getLogger("ft_rest_client") + + +class FtRestClient: + + def __init__(self, serverurl, username=None, password=None, *, + pool_connections=10, pool_maxsize=10): + + self._serverurl = serverurl + self._session = requests.Session() + + # allow configuration of pool + adapter = requests.adapters.HTTPAdapter( + pool_connections=pool_connections, + pool_maxsize=pool_maxsize + ) + self._session.mount('http://', adapter) + + self._session.auth = (username, password) + + def _call(self, method, apipath, params: Optional[dict] = None, data=None, files=None): + + if str(method).upper() not in ('GET', 'POST', 'PUT', 'DELETE'): + raise ValueError(f'invalid method <{method}>') + basepath = f"{self._serverurl}/api/v1/{apipath}" + + hd = {"Accept": "application/json", + "Content-Type": "application/json" + } + + # Split url + schema, netloc, path, par, query, fragment = urlparse(basepath) + # URLEncode query string + query = urlencode(params) if params else "" + # recombine url + url = urlunparse((schema, netloc, path, par, query, fragment)) + + try: + resp = self._session.request(method, url, headers=hd, data=json.dumps(data)) + # return resp.text + return resp.json() + except ConnectionError: + logger.warning("Connection error") + + def _get(self, apipath, params: Optional[dict] = None): + return self._call("GET", apipath, params=params) + + def _delete(self, apipath, params: Optional[dict] = None): + return self._call("DELETE", apipath, params=params) + + def _post(self, apipath, params: Optional[dict] = None, data: Optional[dict] = None): + return self._call("POST", apipath, params=params, data=data) + + def start(self): + """Start the bot if it's in the stopped state. + + :return: json object + """ + return self._post("start") + + def stop(self): + """Stop the bot. Use `start` to restart. + + :return: json object + """ + return self._post("stop") + + def stopbuy(self): + """Stop buying (but handle sells gracefully). Use `reload_config` to reset. + + :return: json object + """ + return self._post("stopbuy") + + def reload_config(self): + """Reload configuration. + + :return: json object + """ + return self._post("reload_config") + + def balance(self): + """Get the account balance. + + :return: json object + """ + return self._get("balance") + + def count(self): + """Return the amount of open trades. + + :return: json object + """ + return self._get("count") + + def entries(self, pair=None): + """Returns List of dicts containing all Trades, based on buy tag performance + Can either be average for all pairs or a specific pair provided + + :return: json object + """ + return self._get("entries", params={"pair": pair} if pair else None) + + def exits(self, pair=None): + """Returns List of dicts containing all Trades, based on exit reason performance + Can either be average for all pairs or a specific pair provided + + :return: json object + """ + return self._get("exits", params={"pair": pair} if pair else None) + + def mix_tags(self, pair=None): + """Returns List of dicts containing all Trades, based on entry_tag + exit_reason performance + Can either be average for all pairs or a specific pair provided + + :return: json object + """ + return self._get("mix_tags", params={"pair": pair} if pair else None) + + def locks(self): + """Return current locks + + :return: json object + """ + return self._get("locks") + + def delete_lock(self, lock_id): + """Delete (disable) lock from the database. + + :param lock_id: ID for the lock to delete + :return: json object + """ + return self._delete(f"locks/{lock_id}") + + def daily(self, days=None): + """Return the profits for each day, and amount of trades. + + :return: json object + """ + return self._get("daily", params={"timescale": days} if days else None) + + def weekly(self, weeks=None): + """Return the profits for each week, and amount of trades. + + :return: json object + """ + return self._get("weekly", params={"timescale": weeks} if weeks else None) + + def monthly(self, months=None): + """Return the profits for each month, and amount of trades. + + :return: json object + """ + return self._get("monthly", params={"timescale": months} if months else None) + + def edge(self): + """Return information about edge. + + :return: json object + """ + return self._get("edge") + + def profit(self): + """Return the profit summary. + + :return: json object + """ + return self._get("profit") + + def stats(self): + """Return the stats report (durations, sell-reasons). + + :return: json object + """ + return self._get("stats") + + def performance(self): + """Return the performance of the different coins. + + :return: json object + """ + return self._get("performance") + + def status(self): + """Get the status of open trades. + + :return: json object + """ + return self._get("status") + + def version(self): + """Return the version of the bot. + + :return: json object containing the version + """ + return self._get("version") + + def show_config(self): + """ Returns part of the configuration, relevant for trading operations. + :return: json object containing the version + """ + return self._get("show_config") + + def ping(self): + """simple ping""" + configstatus = self.show_config() + if not configstatus: + return {"status": "not_running"} + elif configstatus['state'] == "running": + return {"status": "pong"} + else: + return {"status": "not_running"} + + def logs(self, limit=None): + """Show latest logs. + + :param limit: Limits log messages to the last logs. No limit to get the entire log. + :return: json object + """ + return self._get("logs", params={"limit": limit} if limit else 0) + + def trades(self, limit=None, offset=None): + """Return trades history, sorted by id + + :param limit: Limits trades to the X last trades. Max 500 trades. + :param offset: Offset by this amount of trades. + :return: json object + """ + params = {} + if limit: + params['limit'] = limit + if offset: + params['offset'] = offset + return self._get("trades", params) + + def trade(self, trade_id): + """Return specific trade + + :param trade_id: Specify which trade to get. + :return: json object + """ + return self._get(f"trade/{trade_id}") + + def delete_trade(self, trade_id): + """Delete trade from the database. + Tries to close open orders. Requires manual handling of this asset on the exchange. + + :param trade_id: Deletes the trade with this ID from the database. + :return: json object + """ + return self._delete(f"trades/{trade_id}") + + def cancel_open_order(self, trade_id): + """Cancel open order for trade. + + :param trade_id: Cancels open orders for this trade. + :return: json object + """ + return self._delete(f"trades/{trade_id}/open-order") + + def whitelist(self): + """Show the current whitelist. + + :return: json object + """ + return self._get("whitelist") + + def blacklist(self, *args): + """Show the current blacklist. + + :param add: List of coins to add (example: "BNB/BTC") + :return: json object + """ + if not args: + return self._get("blacklist") + else: + return self._post("blacklist", data={"blacklist": args}) + + def forcebuy(self, pair, price=None): + """Buy an asset. + + :param pair: Pair to buy (ETH/BTC) + :param price: Optional - price to buy + :return: json object of the trade + """ + data = {"pair": pair, + "price": price + } + return self._post("forcebuy", data=data) + + def forceenter(self, pair, side, price=None): + """Force entering a trade + + :param pair: Pair to buy (ETH/BTC) + :param side: 'long' or 'short' + :param price: Optional - price to buy + :return: json object of the trade + """ + data = {"pair": pair, + "side": side, + } + if price: + data['price'] = price + return self._post("forceenter", data=data) + + def forceexit(self, tradeid, ordertype=None, amount=None): + """Force-exit a trade. + + :param tradeid: Id of the trade (can be received via status command) + :param ordertype: Order type to use (must be market or limit) + :param amount: Amount to sell. Full sell if not given + :return: json object + """ + + return self._post("forceexit", data={ + "tradeid": tradeid, + "ordertype": ordertype, + "amount": amount, + }) + + 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 pairlists_available(self): + """Lists available pairlist providers + + :return: json object + """ + return self._get("pairlists/available") + + 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 + """ + params = { + "pair": pair, + "timeframe": timeframe, + } + if limit: + params['limit'] = limit + return self._get("pair_candles", params=params) + + def pair_history(self, pair, timeframe, strategy, timerange=None, freqaimodel=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 freqaimodel: FreqAI model to use for analysis + :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, + "freqaimodel": freqaimodel, + "timerange": timerange if timerange else '', + }) + + def sysinfo(self): + """Provides system information (CPU, RAM usage) + + :return: json object + """ + return self._get("sysinfo") + + def health(self): + """Provides a quick health check of the running bot. + + :return: json object + """ + return self._get("health") diff --git a/ft_client/pyproject.toml b/ft_client/pyproject.toml new file mode 100644 index 000000000..919e524f8 --- /dev/null +++ b/ft_client/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools >= 64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "freqtrade-client" +dynamic = ["version"] + +authors = [ + {name = "Freqtrade Team"}, + {name = "Freqtrade Team", email = "freqtrade@protonmail.com"}, +] + +description = "Freqtrade - Client scripts" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "GPLv3"} +# license = "GPLv3" +classifiers = [ + "Environment :: Console", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: MacOS", + "Operating System :: Unix", + "Topic :: Office/Business :: Financial :: Investment", +] + +dependencies = [ + 'requests >= 2.26.0', + 'python-rapidjson >= 1.0', +] + + +[project.urls] +Homepage = "https://github.com/freqtrade/freqtrade" +Documentation = "https://freqtrade.io" +"Bug Tracker" = "https://github.com/freqtrade/freqtrade/issues" + + +[project.scripts] +freqtrade-client = "freqtrade_client.ft_client:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["freqtrade_client*"] +exclude = ["tests", "tests.*"] +namespaces = true + +[tool.setuptools.dynamic] +version = {attr = "freqtrade_client.__version__"} diff --git a/ft_client/requirements.txt b/ft_client/requirements.txt new file mode 100644 index 000000000..56def4059 --- /dev/null +++ b/ft_client/requirements.txt @@ -0,0 +1,3 @@ +# Requirements for freqtrade client library +requests==2.31.0 +python-rapidjson==1.16 diff --git a/ft_client/test_client/__init__.py b/ft_client/test_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ft_client/test_client/test_rest_client.py b/ft_client/test_client/test_rest_client.py new file mode 100644 index 000000000..e7c2f32e6 --- /dev/null +++ b/ft_client/test_client/test_rest_client.py @@ -0,0 +1,152 @@ +import re +from unittest.mock import MagicMock + +import pytest +from freqtrade_client import FtRestClient +from freqtrade_client.ft_client import add_arguments, main_exec +from requests.exceptions import ConnectionError + + +def log_has_re(line, logs): + """Check if line matches some caplog's message.""" + return any(re.match(line, message) for message in logs.messages) + + +def get_rest_client(): + client = FtRestClient('http://localhost:8080', 'freqtrader', 'password') + client._session = MagicMock() + request_mock = MagicMock() + client._session.request = request_mock + return client, request_mock + + +def test_FtRestClient_init(): + client = FtRestClient('http://localhost:8080', 'freqtrader', 'password') + assert client is not None + assert client._serverurl == 'http://localhost:8080' + assert client._session is not None + assert client._session.auth is not None + assert client._session.auth == ('freqtrader', 'password') + + +@pytest.mark.parametrize('method', ['GET', 'POST', 'DELETE']) +def test_FtRestClient_call(method): + client, mock = get_rest_client() + client._call(method, '/dummytest') + assert mock.call_count == 1 + + getattr(client, f"_{method.lower()}")('/dummytest') + assert mock.call_count == 2 + + +def test_FtRestClient_call_invalid(caplog): + client, _ = get_rest_client() + with pytest.raises(ValueError): + client._call('PUTTY', '/dummytest') + + client._session.request = MagicMock(side_effect=ConnectionError()) + client._call('GET', '/dummytest') + + assert log_has_re('Connection error', caplog) + + +@pytest.mark.parametrize('method,args', [ + ('start', []), + ('stop', []), + ('stopbuy', []), + ('reload_config', []), + ('balance', []), + ('count', []), + ('entries', []), + ('exits', []), + ('mix_tags', []), + ('locks', []), + ('delete_lock', [2]), + ('daily', []), + ('daily', [15]), + ('weekly', []), + ('weekly', [15]), + ('monthly', []), + ('monthly', [12]), + ('edge', []), + ('profit', []), + ('stats', []), + ('performance', []), + ('status', []), + ('version', []), + ('show_config', []), + ('ping', []), + ('logs', []), + ('logs', [55]), + ('trades', []), + ('trades', [5]), + ('trades', [5, 5]), # With offset + ('trade', [1]), + ('delete_trade', [1]), + ('cancel_open_order', [1]), + ('whitelist', []), + ('blacklist', []), + ('blacklist', ['XRP/USDT']), + ('blacklist', ['XRP/USDT', 'BTC/USDT']), + ('forcebuy', ['XRP/USDT']), + ('forcebuy', ['XRP/USDT', 1.5]), + ('forceenter', ['XRP/USDT', 'short']), + ('forceenter', ['XRP/USDT', 'short', 1.5]), + ('forceexit', [1]), + ('forceexit', [1, 'limit']), + ('forceexit', [1, 'limit', 100]), + ('strategies', []), + ('strategy', ['sampleStrategy']), + ('pairlists_available', []), + ('plot_config', []), + ('available_pairs', []), + ('available_pairs', ['5m']), + ('pair_candles', ['XRP/USDT', '5m']), + ('pair_candles', ['XRP/USDT', '5m', 500]), + ('pair_history', ['XRP/USDT', '5m', 'SampleStrategy']), + ('sysinfo', []), + ('health', []), +]) +def test_FtRestClient_call_explicit_methods(method, args): + client, mock = get_rest_client() + exec = getattr(client, method) + exec(*args) + assert mock.call_count == 1 + + +def test_ft_client(mocker, capsys, caplog): + with pytest.raises(SystemExit): + args = add_arguments(['-V']) + + args = add_arguments(['--show']) + assert isinstance(args, dict) + assert args['show'] is True + with pytest.raises(SystemExit): + main_exec(args) + captured = capsys.readouterr() + assert 'Possible commands' in captured.out + + mock = mocker.patch('freqtrade_client.ft_client.FtRestClient._call') + args = add_arguments([ + '--config', + 'tests/testdata/testconfigs/main_test_config.json', + 'ping' + ]) + main_exec(args) + captured = capsys.readouterr() + assert mock.call_count == 1 + + with pytest.raises(SystemExit): + args = add_arguments(['--config', 'tests/testdata/testconfigs/nonexisting.json']) + main_exec(args) + + assert log_has_re(r'Could not load config file .*nonexisting\.json\.', + caplog) + + args = add_arguments([ + '--config', + 'tests/testdata/testconfigs/main_test_config.json', + 'whatever' + ]) + main_exec(args) + assert log_has_re('Command whatever not defined', caplog) diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 5970b0c5b..c22dd18ae 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -7,505 +7,8 @@ Should not import anything from freqtrade, so it can be used as a standalone script. """ -import argparse -import inspect -import json -import logging -import re -import sys -from pathlib import Path -from typing import Optional -from urllib.parse import urlencode, urlparse, urlunparse +from freqtrade_client.ft_client import main -import rapidjson -import requests -from requests.exceptions import ConnectionError - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', -) -logger = logging.getLogger("ft_rest_client") - - -class FtRestClient: - - def __init__(self, serverurl, username=None, password=None): - - self._serverurl = serverurl - self._session = requests.Session() - self._session.auth = (username, password) - - def _call(self, method, apipath, params: Optional[dict] = None, data=None, files=None): - - if str(method).upper() not in ('GET', 'POST', 'PUT', 'DELETE'): - raise ValueError(f'invalid method <{method}>') - basepath = f"{self._serverurl}/api/v1/{apipath}" - - hd = {"Accept": "application/json", - "Content-Type": "application/json" - } - - # Split url - schema, netloc, path, par, query, fragment = urlparse(basepath) - # URLEncode query string - query = urlencode(params) if params else "" - # recombine url - url = urlunparse((schema, netloc, path, par, query, fragment)) - - try: - resp = self._session.request(method, url, headers=hd, data=json.dumps(data)) - # return resp.text - return resp.json() - except ConnectionError: - logger.warning("Connection error") - - def _get(self, apipath, params: Optional[dict] = None): - return self._call("GET", apipath, params=params) - - def _delete(self, apipath, params: Optional[dict] = None): - return self._call("DELETE", apipath, params=params) - - def _post(self, apipath, params: Optional[dict] = None, data: Optional[dict] = None): - return self._call("POST", apipath, params=params, data=data) - - def start(self): - """Start the bot if it's in the stopped state. - - :return: json object - """ - return self._post("start") - - def stop(self): - """Stop the bot. Use `start` to restart. - - :return: json object - """ - return self._post("stop") - - def stopbuy(self): - """Stop buying (but handle sells gracefully). Use `reload_config` to reset. - - :return: json object - """ - return self._post("stopbuy") - - def reload_config(self): - """Reload configuration. - - :return: json object - """ - return self._post("reload_config") - - def balance(self): - """Get the account balance. - - :return: json object - """ - return self._get("balance") - - def count(self): - """Return the amount of open trades. - - :return: json object - """ - return self._get("count") - - def entries(self, pair=None): - """Returns List of dicts containing all Trades, based on buy tag performance - Can either be average for all pairs or a specific pair provided - - :return: json object - """ - return self._get("entries", params={"pair": pair} if pair else None) - - def exits(self, pair=None): - """Returns List of dicts containing all Trades, based on exit reason performance - Can either be average for all pairs or a specific pair provided - - :return: json object - """ - return self._get("exits", params={"pair": pair} if pair else None) - - def mix_tags(self, pair=None): - """Returns List of dicts containing all Trades, based on entry_tag + exit_reason performance - Can either be average for all pairs or a specific pair provided - - :return: json object - """ - return self._get("mix_tags", params={"pair": pair} if pair else None) - - def locks(self): - """Return current locks - - :return: json object - """ - return self._get("locks") - - def delete_lock(self, lock_id): - """Delete (disable) lock from the database. - - :param lock_id: ID for the lock to delete - :return: json object - """ - return self._delete(f"locks/{lock_id}") - - def daily(self, days=None): - """Return the profits for each day, and amount of trades. - - :return: json object - """ - return self._get("daily", params={"timescale": days} if days else None) - - def weekly(self, weeks=None): - """Return the profits for each week, and amount of trades. - - :return: json object - """ - return self._get("weekly", params={"timescale": weeks} if weeks else None) - - def monthly(self, months=None): - """Return the profits for each month, and amount of trades. - - :return: json object - """ - return self._get("monthly", params={"timescale": months} if months else None) - - def edge(self): - """Return information about edge. - - :return: json object - """ - return self._get("edge") - - def profit(self): - """Return the profit summary. - - :return: json object - """ - return self._get("profit") - - def stats(self): - """Return the stats report (durations, sell-reasons). - - :return: json object - """ - return self._get("stats") - - def performance(self): - """Return the performance of the different coins. - - :return: json object - """ - return self._get("performance") - - def status(self): - """Get the status of open trades. - - :return: json object - """ - return self._get("status") - - def version(self): - """Return the version of the bot. - - :return: json object containing the version - """ - return self._get("version") - - def show_config(self): - """ Returns part of the configuration, relevant for trading operations. - :return: json object containing the version - """ - return self._get("show_config") - - def ping(self): - """simple ping""" - configstatus = self.show_config() - if not configstatus: - return {"status": "not_running"} - elif configstatus['state'] == "running": - return {"status": "pong"} - else: - return {"status": "not_running"} - - def logs(self, limit=None): - """Show latest logs. - - :param limit: Limits log messages to the last logs. No limit to get the entire log. - :return: json object - """ - return self._get("logs", params={"limit": limit} if limit else 0) - - def trades(self, limit=None, offset=None): - """Return trades history, sorted by id - - :param limit: Limits trades to the X last trades. Max 500 trades. - :param offset: Offset by this amount of trades. - :return: json object - """ - params = {} - if limit: - params['limit'] = limit - if offset: - params['offset'] = offset - return self._get("trades", params) - - def trade(self, trade_id): - """Return specific trade - - :param trade_id: Specify which trade to get. - :return: json object - """ - return self._get(f"trade/{trade_id}") - - def delete_trade(self, trade_id): - """Delete trade from the database. - Tries to close open orders. Requires manual handling of this asset on the exchange. - - :param trade_id: Deletes the trade with this ID from the database. - :return: json object - """ - return self._delete(f"trades/{trade_id}") - - def cancel_open_order(self, trade_id): - """Cancel open order for trade. - - :param trade_id: Cancels open orders for this trade. - :return: json object - """ - return self._delete(f"trades/{trade_id}/open-order") - - def whitelist(self): - """Show the current whitelist. - - :return: json object - """ - return self._get("whitelist") - - def blacklist(self, *args): - """Show the current blacklist. - - :param add: List of coins to add (example: "BNB/BTC") - :return: json object - """ - if not args: - return self._get("blacklist") - else: - return self._post("blacklist", data={"blacklist": args}) - - def forcebuy(self, pair, price=None): - """Buy an asset. - - :param pair: Pair to buy (ETH/BTC) - :param price: Optional - price to buy - :return: json object of the trade - """ - data = {"pair": pair, - "price": price - } - return self._post("forcebuy", data=data) - - def forceenter(self, pair, side, price=None): - """Force entering a trade - - :param pair: Pair to buy (ETH/BTC) - :param side: 'long' or 'short' - :param price: Optional - price to buy - :return: json object of the trade - """ - data = {"pair": pair, - "side": side, - } - if price: - data['price'] = price - return self._post("forceenter", data=data) - - def forceexit(self, tradeid, ordertype=None, amount=None): - """Force-exit a trade. - - :param tradeid: Id of the trade (can be received via status command) - :param ordertype: Order type to use (must be market or limit) - :param amount: Amount to sell. Full sell if not given - :return: json object - """ - - return self._post("forceexit", data={ - "tradeid": tradeid, - "ordertype": ordertype, - "amount": amount, - }) - - 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 pairlists_available(self): - """Lists available pairlist providers - - :return: json object - """ - return self._get("pairlists/available") - - 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 - """ - params = { - "pair": pair, - "timeframe": timeframe, - } - if limit: - params['limit'] = limit - return self._get("pair_candles", params=params) - - def pair_history(self, pair, timeframe, strategy, timerange=None, freqaimodel=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 freqaimodel: FreqAI model to use for analysis - :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, - "freqaimodel": freqaimodel, - "timerange": timerange if timerange else '', - }) - - def sysinfo(self): - """Provides system information (CPU, RAM usage) - - :return: json object - """ - return self._get("sysinfo") - - def health(self): - """Provides a quick health check of the running bot. - - :return: json object - """ - return self._get("health") - - -def add_arguments(): - parser = argparse.ArgumentParser() - parser.add_argument("command", - help="Positional argument defining the command to execute.", - nargs="?" - ) - - parser.add_argument('--show', - help='Show possible methods with this client', - dest='show', - action='store_true', - default=False - ) - - parser.add_argument('-c', '--config', - help='Specify configuration file (default: %(default)s). ', - dest='config', - type=str, - metavar='PATH', - default='config.json' - ) - - parser.add_argument("command_arguments", - help="Positional arguments for the parameters for [command]", - nargs="*", - default=[] - ) - - args = parser.parse_args() - return vars(args) - - -def load_config(configfile): - file = Path(configfile) - if file.is_file(): - with file.open("r") as f: - config = rapidjson.load(f, parse_mode=rapidjson.PM_COMMENTS | - rapidjson.PM_TRAILING_COMMAS) - return config - else: - logger.warning(f"Could not load config file {file}.") - sys.exit(1) - - -def print_commands(): - # Print dynamic help for the different commands using the commands doc-strings - client = FtRestClient(None) - print("Possible commands:\n") - for x, y in inspect.getmembers(client): - if not x.startswith('_'): - doc = re.sub(':return:.*', '', getattr(client, x).__doc__, flags=re.MULTILINE).rstrip() - print(f"{x}\n\t{doc}\n") - - -def main(args): - - if args.get("show"): - print_commands() - sys.exit() - - config = load_config(args['config']) - url = config.get('api_server', {}).get('listen_ip_address', '127.0.0.1') - port = config.get('api_server', {}).get('listen_port', '8080') - username = config.get('api_server', {}).get('username') - password = config.get('api_server', {}).get('password') - - server_url = f"http://{url}:{port}" - client = FtRestClient(server_url, username, password) - - m = [x for x, y in inspect.getmembers(client) if not x.startswith('_')] - command = args["command"] - if command not in m: - logger.error(f"Command {command} not defined") - print_commands() - return - - print(json.dumps(getattr(client, command)(*args["command_arguments"]))) - - -if __name__ == "__main__": - args = add_arguments() - main(args) +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index dea1966fa..f292a5349 100644 --- a/setup.py +++ b/setup.py @@ -112,6 +112,7 @@ setup( 'python-dateutil', 'pytz', 'packaging', + 'freqtrade-client', ], extras_require={ 'dev': all_extra,