diff --git a/docs/utils.md b/docs/utils.md index 524fefc21..789462de4 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -614,6 +614,48 @@ Show whitelist when using a [dynamic pairlist](plugins.md#pairlists). freqtrade test-pairlist --config config.json --quote USDT BTC ``` +## Webserver mode + +!!! Warning "Experimental" + Webserver mode is an experimental mode to increase backesting and strategy development productivity. + There may still be bugs - so if you happen to stumble across these, please report them as github issues, thanks. + +Run freqtrade in webserver mode. +Freqtrade will start the webserver and allow FreqUI to start and control backtesting processes. +This has the advantage that data will not be reloaded between backtesting runs (as long as timeframe and timerange remain identical). +FreqUI will also show the backtesting results. + +``` +usage: freqtrade webserver [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] + [--userdir PATH] [-s NAME] [--strategy-path PATH] + +optional arguments: + -h, --help show this help message and exit + +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: + `userdir/config.json` or `config.json` whichever + exists). 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. + +Strategy arguments: + -s NAME, --strategy NAME + Specify strategy class name which will be used by the + bot. + --strategy-path PATH Specify additional strategy lookup path. + +``` + ## List Hyperopt results You can list the hyperoptimization epochs the Hyperopt module evaluated previously with the `hyperopt-list` sub-command. diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index 784b99bed..04e46ee23 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -20,3 +20,4 @@ from freqtrade.commands.optimize_commands import start_backtesting, start_edge, from freqtrade.commands.pairlist_commands import start_test_pairlist from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit from freqtrade.commands.trade_commands import start_trading +from freqtrade.commands.webserver_commands import start_webserver diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index ba37237f6..1143db394 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -16,6 +16,8 @@ ARGS_STRATEGY = ["strategy", "strategy_path"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"] +ARGS_WEBSERVER: List[str] = [] + ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv", "max_open_trades", "stake_amount", "fee", "pairs"] @@ -176,7 +178,8 @@ class Arguments: start_list_markets, start_list_strategies, start_list_timeframes, start_new_config, start_new_hyperopt, start_new_strategy, start_plot_dataframe, start_plot_profit, - start_show_trades, start_test_pairlist, start_trading) + start_show_trades, start_test_pairlist, start_trading, + start_webserver) subparsers = self.parser.add_subparsers(dest='command', # Use custom message when no subhandler is added @@ -384,3 +387,9 @@ class Arguments: ) plot_profit_cmd.set_defaults(func=start_plot_profit) self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd) + + # Add webserver subcommand + webserver_cmd = subparsers.add_parser('webserver', help='Webserver module.', + parents=[_common_parser]) + webserver_cmd.set_defaults(func=start_webserver) + self._build_args(optionlist=ARGS_WEBSERVER, parser=webserver_cmd) diff --git a/freqtrade/commands/webserver_commands.py b/freqtrade/commands/webserver_commands.py new file mode 100644 index 000000000..9a5975227 --- /dev/null +++ b/freqtrade/commands/webserver_commands.py @@ -0,0 +1,15 @@ +from typing import Any, Dict + +from freqtrade.enums import RunMode + + +def start_webserver(args: Dict[str, Any]) -> None: + """ + Main entry point for webserver mode + """ + from freqtrade.configuration import Configuration + from freqtrade.rpc.api_server import ApiServer + + # Initialize configuration + config = Configuration(args, RunMode.WEBSERVER).get_config() + ApiServer(config, standalone=True) diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index ea202355a..bd30adcae 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -71,7 +71,7 @@ class Configuration: # Merge config options, overwriting old values config = deep_merge_dicts(load_config_file(path), config) - + config['config_files'] = files # Normalize config if 'internals' not in config: config['internals'] = {} diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index 78163d86f..ac5f804c9 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa: F401 +from freqtrade.enums.backteststate import BacktestState from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.selltype import SellType diff --git a/freqtrade/enums/backteststate.py b/freqtrade/enums/backteststate.py new file mode 100644 index 000000000..490814497 --- /dev/null +++ b/freqtrade/enums/backteststate.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class BacktestState(Enum): + """ + Bot application states + """ + STARTUP = 1 + DATALOAD = 2 + ANALYZE = 3 + CONVERT = 4 + BACKTEST = 5 + + def __str__(self): + return f"{self.name.lower()}" diff --git a/freqtrade/enums/runmode.py b/freqtrade/enums/runmode.py index 7826d1d0c..6545aaec7 100644 --- a/freqtrade/enums/runmode.py +++ b/freqtrade/enums/runmode.py @@ -14,6 +14,7 @@ class RunMode(Enum): UTIL_EXCHANGE = "util_exchange" UTIL_NO_EXCHANGE = "util_no_exchange" PLOT = "plot" + WEBSERVER = "webserver" OTHER = "other" diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 83cd8c2bb..ebd4135d9 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -17,10 +17,11 @@ from freqtrade.data import history from freqtrade.data.btanalysis import trade_list_to_dataframe from freqtrade.data.converter import trim_dataframes from freqtrade.data.dataprovider import DataProvider -from freqtrade.enums import SellType +from freqtrade.enums import BacktestState, SellType from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.mixins import LoggingMixin +from freqtrade.optimize.bt_progress import BTProgress from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results, store_backtest_stats) from freqtrade.persistence import LocalTrade, PairLocks, Trade @@ -57,6 +58,7 @@ class Backtesting: LoggingMixin.show_output = False self.config = config + self.results: Optional[Dict[str, Any]] = None # Reset keys for backtesting remove_credentials(self.config) @@ -118,6 +120,9 @@ class Backtesting: self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) + self.progress = BTProgress() + self.abort = False + def __del__(self): LoggingMixin.show_output = True PairLocks.use_db = True @@ -147,6 +152,8 @@ class Backtesting: Loads backtest data and returns the data combined with the timerange as tuple. """ + self.progress.init_step(BacktestState.DATALOAD, 1) + timerange = TimeRange.parse_timerange(None if self.config.get( 'timerange') is None else str(self.config.get('timerange'))) @@ -170,6 +177,7 @@ class Backtesting: timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe), self.required_startup, min_date) + self.progress.set_new_value(1) return data, timerange def prepare_backtest(self, enable_protections): @@ -184,6 +192,15 @@ class Backtesting: self.rejected_trades = 0 self.dataprovider.clear_cache() + def check_abort(self): + """ + Check if abort was requested, raise DependencyException if that's the case + Only applies to Interactive backtest mode (webserver mode) + """ + if self.abort: + self.abort = False + raise DependencyException("Stop requested") + def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]: """ Helper function to convert a processed dataframes into lists for performance reasons. @@ -194,8 +211,12 @@ class Backtesting: # and eventually change the constants for indexes at the top headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high'] data: Dict = {} + self.progress.init_step(BacktestState.CONVERT, len(processed)) + # Create dict with data for pair, pair_data in processed.items(): + self.check_abort() + self.progress.increment() if not pair_data.empty: pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist pair_data.loc[:, 'sell'] = 0 # cleanup if sell_signal is exist @@ -417,10 +438,13 @@ class Backtesting: open_trades: Dict[str, List[LocalTrade]] = defaultdict(list) open_trade_count = 0 + self.progress.init_step(BacktestState.BACKTEST, int( + (end_date - start_date) / timedelta(minutes=self.timeframe_min))) + # Loop timerange and get candle for each pair at that point in time while tmp <= end_date: open_trade_count_start = open_trade_count - + self.check_abort() for i, pair in enumerate(data): row_index = indexes[pair] try: @@ -476,6 +500,7 @@ class Backtesting: self.protections.global_stop(tmp) # Move time one configured time_interval ahead. + self.progress.increment() tmp += timedelta(minutes=self.timeframe_min) trades += self.handle_left_open(open_trades, data=data) @@ -491,6 +516,8 @@ class Backtesting: } def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange): + self.progress.init_step(BacktestState.ANALYZE, 0) + logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) backtest_start_time = datetime.now(timezone.utc) self._set_strategy(strat) @@ -517,6 +544,7 @@ class Backtesting: "No data left after adjusting for startup candles.") min_date, max_date = history.get_timerange(preprocessed) + logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' f'({(max_date - min_date).days} days).') @@ -551,11 +579,12 @@ class Backtesting: for strat in self.strategylist: min_date, max_date = self.backtest_one_strategy(strat, data, timerange) if len(self.strategylist) > 0: - stats = generate_backtest_stats(data, self.all_results, - min_date=min_date, max_date=max_date) + + self.results = generate_backtest_stats(data, self.all_results, + min_date=min_date, max_date=max_date) if self.config.get('export', 'none') == 'trades': - store_backtest_stats(self.config['exportfilename'], stats) + store_backtest_stats(self.config['exportfilename'], self.results) # Show backtest results - show_backtest_results(self.config, stats) + show_backtest_results(self.config, self.results) diff --git a/freqtrade/optimize/bt_progress.py b/freqtrade/optimize/bt_progress.py new file mode 100644 index 000000000..d295956c7 --- /dev/null +++ b/freqtrade/optimize/bt_progress.py @@ -0,0 +1,33 @@ +from freqtrade.enums import BacktestState + + +class BTProgress: + _action: BacktestState = BacktestState.STARTUP + _progress: float = 0 + _max_steps: float = 0 + + def __init__(self): + pass + + def init_step(self, action: BacktestState, max_steps: float): + self._action = action + self._max_steps = max_steps + self._proress = 0 + + def set_new_value(self, new_value: float): + self._progress = new_value + + def increment(self): + self._progress += 1 + + @property + def progress(self): + """ + Get progress as ratio, capped to be between 0 and 1 (to avoid small calculation errors). + """ + return max(min(round(self._progress / self._max_steps, 5) + if self._max_steps > 0 else 0, 1), 0) + + @property + def action(self): + return str(self._action) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py new file mode 100644 index 000000000..76b4a8169 --- /dev/null +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -0,0 +1,176 @@ +import asyncio +import logging +from copy import deepcopy + +from fastapi import APIRouter, BackgroundTasks, Depends + +from freqtrade.enums import BacktestState +from freqtrade.exceptions import DependencyException +from freqtrade.rpc.api_server.api_schemas import BacktestRequest, BacktestResponse +from freqtrade.rpc.api_server.deps import get_config +from freqtrade.rpc.api_server.webserver import ApiServer +from freqtrade.rpc.rpc import RPCException + + +logger = logging.getLogger(__name__) + +# Private API, protected by authentication +router = APIRouter() + + +@router.post('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) +async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: BackgroundTasks, + config=Depends(get_config)): + """Start backtesting if not done so already""" + if ApiServer._bgtask_running: + raise RPCException('Bot Background task already running') + + btconfig = deepcopy(config) + settings = dict(bt_settings) + # Pydantic models will contain all keys, but non-provided ones are None + for setting in settings.keys(): + if settings[setting] is not None: + btconfig[setting] = settings[setting] + + # Start backtesting + # Initialize backtesting object + def run_backtest(): + from freqtrade.optimize.optimize_reports import generate_backtest_stats + from freqtrade.resolvers import StrategyResolver + asyncio.set_event_loop(asyncio.new_event_loop()) + try: + # Reload strategy + lastconfig = ApiServer._bt_last_config + strat = StrategyResolver.load_strategy(btconfig) + + if ( + not ApiServer._bt + or lastconfig.get('timeframe') != strat.timeframe + or lastconfig.get('dry_run_wallet') != btconfig.get('dry_run_wallet', 0) + ): + from freqtrade.optimize.backtesting import Backtesting + ApiServer._bt = Backtesting(btconfig) + + # Only reload data if timeframe or timerange changed. + if ( + not ApiServer._bt_data + or not ApiServer._bt_timerange + or lastconfig.get('timerange') != btconfig['timerange'] + or lastconfig.get('stake_amount') != btconfig.get('stake_amount') + or lastconfig.get('enable_protections') != btconfig.get('enable_protections') + or lastconfig.get('protections') != btconfig.get('protections', []) + or lastconfig.get('timeframe') != strat.timeframe + ): + lastconfig['timerange'] = btconfig['timerange'] + lastconfig['protections'] = btconfig.get('protections', []) + lastconfig['enable_protections'] = btconfig.get('enable_protections') + lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet') + lastconfig['timeframe'] = strat.timeframe + ApiServer._bt_data, ApiServer._bt_timerange = ApiServer._bt.load_bt_data() + + ApiServer._bt.abort = False + min_date, max_date = ApiServer._bt.backtest_one_strategy( + strat, ApiServer._bt_data, ApiServer._bt_timerange) + ApiServer._bt.results = generate_backtest_stats( + ApiServer._bt_data, ApiServer._bt.all_results, + min_date=min_date, max_date=max_date) + logger.info("Backtest finished.") + + except DependencyException as e: + logger.info(f"Backtesting caused an error: {e}") + pass + finally: + ApiServer._bgtask_running = False + + background_tasks.add_task(run_backtest) + ApiServer._bgtask_running = True + + return { + "status": "running", + "running": True, + "progress": 0, + "step": str(BacktestState.STARTUP), + "status_msg": "Backtest started", + } + + +@router.get('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) +def api_get_backtest(): + """ + Get backtesting result. + Returns Result after backtesting has been ran. + """ + from freqtrade.persistence import LocalTrade + if ApiServer._bgtask_running: + return { + "status": "running", + "running": True, + "step": ApiServer._bt.progress.action if ApiServer._bt else str(BacktestState.STARTUP), + "progress": ApiServer._bt.progress.progress if ApiServer._bt else 0, + "trade_count": len(LocalTrade.trades), + "status_msg": "Backtest running", + } + + if not ApiServer._bt: + return { + "status": "not_started", + "running": False, + "step": "", + "progress": 0, + "status_msg": "Backtest not yet executed" + } + + return { + "status": "ended", + "running": False, + "status_msg": "Backtest ended", + "step": "finished", + "progress": 1, + "backtest_result": ApiServer._bt.results, + } + + +@router.delete('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) +def api_delete_backtest(): + """Reset backtesting""" + if ApiServer._bgtask_running: + return { + "status": "running", + "running": True, + "step": "", + "progress": 0, + "status_msg": "Backtest running", + } + if ApiServer._bt: + del ApiServer._bt + ApiServer._bt = None + del ApiServer._bt_data + ApiServer._bt_data = None + logger.info("Backtesting reset") + return { + "status": "reset", + "running": False, + "step": "", + "progress": 0, + "status_msg": "Backtest reset", + } + + +@router.get('/backtest/abort', response_model=BacktestResponse, tags=['webserver', 'backtest']) +def api_backtest_abort(): + if not ApiServer._bgtask_running: + return { + "status": "not_running", + "running": False, + "step": "", + "progress": 0, + "status_msg": "Backtest ended", + } + ApiServer._bt.abort = True + return { + "status": "stopping", + "running": False, + "step": "", + "progress": 0, + "status_msg": "Backtest ended", + } diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index d3eec9be6..c6b6a6d28 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -123,17 +123,17 @@ class ShowConfig(BaseModel): stake_currency_decimals: int max_open_trades: int minimal_roi: Dict[str, Any] - stoploss: float - trailing_stop: bool + stoploss: Optional[float] + trailing_stop: Optional[bool] trailing_stop_positive: Optional[float] trailing_stop_positive_offset: Optional[float] trailing_only_offset_is_reached: Optional[bool] use_custom_stoploss: Optional[bool] - timeframe: str + timeframe: Optional[str] timeframe_ms: int timeframe_min: int exchange: str - strategy: str + strategy: Optional[str] forcebuy_enabled: bool ask_strategy: Dict[str, Any] bid_strategy: Dict[str, Any] @@ -318,3 +318,24 @@ class PairHistory(BaseModel): json_encoders = { datetime: lambda v: v.strftime(DATETIME_PRINT_FORMAT), } + + +class BacktestRequest(BaseModel): + strategy: str + timeframe: Optional[str] + timerange: Optional[str] + max_open_trades: Optional[int] + stake_amount: Optional[Union[float, str]] + enable_protections: bool + dry_run_wallet: Optional[float] + + +class BacktestResponse(BaseModel): + status: str + running: bool + status_msg: str + step: str + progress: float + trade_count: Optional[float] + # TODO: Properly type backtestresult... + backtest_result: Optional[Dict[str, Any]] diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 965664028..61d69707e 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -1,3 +1,4 @@ +import logging from copy import deepcopy from pathlib import Path from typing import List, Optional @@ -22,6 +23,8 @@ from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional from freqtrade.rpc.rpc import RPCException +logger = logging.getLogger(__name__) + # Public API, requires no auth. router_public = APIRouter() # Private API, protected by authentication @@ -249,7 +252,7 @@ def list_available_pairs(timeframe: Optional[str] = None, stake_currency: Option pair_interval = sorted(pair_interval, key=lambda x: x[0]) pairs = list({x[0] for x in pair_interval}) - + pairs.sort() result = { 'length': len(pairs), 'pairs': pairs, diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index a43d4abe6..235063191 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -8,6 +8,7 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse +from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler @@ -28,17 +29,37 @@ class FTJSONResponse(JSONResponse): class ApiServer(RPCHandler): + __instance = None + __initialized = False + _rpc: RPC + # Backtesting type: Backtesting + _bt = None + _bt_data = None + _bt_timerange = None + _bt_last_config: Dict[str, Any] = {} _has_rpc: bool = False + _bgtask_running: bool = False _config: Dict[str, Any] = {} - def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: - super().__init__(rpc, config) - self._server = None + def __new__(cls, *args, **kwargs): + """ + This class is a singleton. + We'll only have one instance of it around. + """ + if ApiServer.__instance is None: + ApiServer.__instance = object.__new__(cls) + ApiServer.__initialized = False + return ApiServer.__instance - ApiServer._rpc = rpc - ApiServer._has_rpc = True + def __init__(self, config: Dict[str, Any], standalone: bool = False) -> None: ApiServer._config = config + if self.__initialized and (standalone or self._standalone): + return + self._standalone: bool = standalone + self._server = None + ApiServer.__initialized = True + api_config = self._config['api_server'] self.app = FastAPI(title="Freqtrade API", @@ -50,12 +71,33 @@ class ApiServer(RPCHandler): self.start_api() + def add_rpc_handler(self, rpc: RPC): + """ + Attach rpc handler + """ + if not self._has_rpc: + ApiServer._rpc = rpc + ApiServer._has_rpc = True + else: + # This should not happen assuming we didn't mess up. + raise OperationalException('RPC Handler already attached.') + def cleanup(self) -> None: """ Cleanup pending module resources """ - if self._server: + ApiServer._has_rpc = False + del ApiServer._rpc + if self._server and not self._standalone: logger.info("Stopping API Server") self._server.cleanup() + @classmethod + def shutdown(cls): + cls.__initialized = False + del cls.__instance + cls.__instance = None + cls._has_rpc = False + cls._rpc = None + def send_msg(self, msg: Dict[str, str]) -> None: pass @@ -68,6 +110,7 @@ class ApiServer(RPCHandler): def configure_app(self, app: FastAPI, config): from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login + from freqtrade.rpc.api_server.api_backtest import router as api_backtest from freqtrade.rpc.api_server.api_v1 import router as api_v1 from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public from freqtrade.rpc.api_server.web_ui import router_ui @@ -77,6 +120,9 @@ class ApiServer(RPCHandler): app.include_router(api_v1, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) + app.include_router(api_backtest, prefix="/api/v1", + dependencies=[Depends(http_basic_or_jwt_token)], + ) app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') @@ -125,6 +171,9 @@ class ApiServer(RPCHandler): ) try: self._server = UvicornServer(uvconfig) - self._server.run_in_thread() + if self._standalone: + self._server.run() + else: + self._server.run_in_thread() except Exception: logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index db89443bf..b3198fa1c 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -119,9 +119,9 @@ class RPC: 'bot_name': config.get('bot_name', 'freqtrade'), 'timeframe': config.get('timeframe'), 'timeframe_ms': timeframe_to_msecs(config['timeframe'] - ) if 'timeframe' in config else '', + ) if 'timeframe' in config else 0, 'timeframe_min': timeframe_to_minutes(config['timeframe'] - ) if 'timeframe' in config else '', + ) if 'timeframe' in config else 0, 'exchange': config['exchange']['name'], 'strategy': config['strategy'], 'forcebuy_enabled': config.get('forcebuy_enable', False), diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index b3f45ab41..67842e849 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -36,15 +36,16 @@ class RPCManager: if config.get('api_server', {}).get('enabled', False): logger.info('Enabling rpc.api_server') from freqtrade.rpc.api_server import ApiServer - - self.registered_modules.append(ApiServer(self._rpc, config)) + apiserver = ApiServer(config) + apiserver.add_rpc_handler(self._rpc) + self.registered_modules.append(apiserver) def cleanup(self) -> None: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') while self.registered_modules: mod = self.registered_modules.pop() - logger.debug('Cleaning up rpc.%s ...', mod.name) + logger.info('Cleaning up rpc.%s ...', mod.name) mod.cleanup() del mod diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index de0cb80e4..5e9b1c7db 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -13,7 +13,7 @@ from freqtrade.commands import (start_convert_data, start_create_userdir, start_ start_list_data, start_list_exchanges, start_list_hyperopts, start_list_markets, start_list_strategies, start_list_timeframes, start_new_hyperopt, start_new_strategy, start_show_trades, - start_test_pairlist, start_trading) + start_test_pairlist, start_trading, start_webserver) from freqtrade.commands.deploy_commands import (clean_ui_subdir, download_and_install_ui, get_ui_download_url, read_ui_version) from freqtrade.configuration import setup_utils_configuration @@ -58,6 +58,18 @@ def test_start_trading_fail(mocker, caplog): assert log_has('Fatal exception!', caplog) +def test_start_webserver(mocker, caplog): + + api_server_mock = mocker.patch("freqtrade.rpc.api_server.ApiServer", ) + + args = [ + 'webserver', + '-c', 'config_bittrex.json.example' + ] + start_webserver(get_args(args)) + assert api_server_mock.call_count == 1 + + def test_list_exchanges(capsys): args = [ diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index a1881c306..77b9e23fa 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -346,6 +346,20 @@ def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None: assert processed['UNITTEST/BTC'].equals(processed2['UNITTEST/BTC']) +def test_backtest_abort(default_conf, mocker, testdatadir) -> None: + patch_exchange(mocker) + backtesting = Backtesting(default_conf) + backtesting.check_abort() + + backtesting.abort = True + + with pytest.raises(DependencyException, match="Stop requested"): + backtesting.check_abort() + # abort flag resets + assert backtesting.abort is False + assert backtesting.progress.progress == 0 + + def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: def get_timerange(input1): return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 4dea2bbfa..eeb829719 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -2,6 +2,7 @@ Unit test file for rpc/api_server.py """ +import json from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock @@ -16,7 +17,7 @@ from requests.auth import _basic_auth_str from freqtrade.__init__ import __version__ from freqtrade.enums import RunMode, State -from freqtrade.exceptions import ExchangeError +from freqtrade.exceptions import DependencyException, ExchangeError, OperationalException from freqtrade.loggers import setup_logging, setup_logging_pre from freqtrade.persistence import PairLocks, Trade from freqtrade.rpc import RPC @@ -48,9 +49,13 @@ def botclient(default_conf, mocker): ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock()) - apiserver = ApiServer(rpc, default_conf) - yield ftbot, TestClient(apiserver.app) - # Cleanup ... ? + try: + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(rpc) + yield ftbot, TestClient(apiserver.app) + # Cleanup ... ? + finally: + ApiServer.shutdown() def client_post(client, url, data={}): @@ -235,8 +240,13 @@ def test_api__init__(default_conf, mocker): }}) mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) mocker.patch('freqtrade.rpc.api_server.webserver.ApiServer.start_api', MagicMock()) - apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) assert apiserver._config == default_conf + with pytest.raises(OperationalException, match="RPC Handler already attached."): + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + + ApiServer.shutdown() def test_api_UvicornServer(mocker): @@ -298,15 +308,21 @@ def test_api_run(default_conf, mocker, caplog): }}) mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) - server_mock = MagicMock() + server_inst_mock = MagicMock() + server_inst_mock.run_in_thread = MagicMock() + server_inst_mock.run = MagicMock() + server_mock = MagicMock(return_value=server_inst_mock) mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', server_mock) - apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) assert server_mock.call_count == 1 assert apiserver._config == default_conf apiserver.start_api() assert server_mock.call_count == 2 + assert server_inst_mock.run_in_thread.call_count == 2 + assert server_inst_mock.run.call_count == 0 assert server_mock.call_args_list[0][0][0].host == "127.0.0.1" assert server_mock.call_args_list[0][0][0].port == 8080 assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) @@ -325,6 +341,8 @@ def test_api_run(default_conf, mocker, caplog): apiserver.start_api() assert server_mock.call_count == 1 + assert server_inst_mock.run_in_thread.call_count == 1 + assert server_inst_mock.run.call_count == 0 assert server_mock.call_args_list[0][0][0].host == "0.0.0.0" assert server_mock.call_args_list[0][0][0].port == 8089 assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) @@ -338,12 +356,24 @@ def test_api_run(default_conf, mocker, caplog): "Please make sure that this is intentional!", caplog) assert log_has_re("SECURITY WARNING - `jwt_secret_key` seems to be default.*", caplog) + server_mock.reset_mock() + apiserver._standalone = True + apiserver.start_api() + assert server_inst_mock.run_in_thread.call_count == 0 + assert server_inst_mock.run.call_count == 1 + + apiserver1 = ApiServer(default_conf) + assert id(apiserver1) == id(apiserver) + + apiserver._standalone = False + # Test crashing API server caplog.clear() mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', MagicMock(side_effect=Exception)) apiserver.start_api() assert log_has("Api server failed to start.", caplog) + ApiServer.shutdown() def test_api_cleanup(default_conf, mocker, caplog): @@ -359,11 +389,13 @@ def test_api_cleanup(default_conf, mocker, caplog): server_mock.cleanup = MagicMock() mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', server_mock) - apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) apiserver.cleanup() assert apiserver._server.cleanup.call_count == 1 assert log_has("Stopping API Server", caplog) + ApiServer.shutdown() def test_api_reloadconf(botclient): @@ -1221,3 +1253,108 @@ def test_list_available_pairs(botclient): assert rc.json()['length'] == 1 assert rc.json()['pairs'] == ['XRP/ETH'] assert len(rc.json()['pair_interval']) == 1 + + +def test_api_backtesting(botclient, mocker, fee, caplog): + ftbot, client = botclient + mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) + + # Backtesting not started yet + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) + + result = rc.json() + assert result['status'] == 'not_started' + assert not result['running'] + assert result['status_msg'] == 'Backtest not yet executed' + assert result['progress'] == 0 + + # Reset backtesting + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'reset' + assert not result['running'] + assert result['status_msg'] == 'Backtest reset' + + # start backtesting + data = { + "strategy": "DefaultStrategy", + "timeframe": "5m", + "timerange": "20180110-20180111", + "max_open_trades": 3, + "stake_amount": 100, + "dry_run_wallet": 1000, + "enable_protections": False + } + rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data)) + assert_response(rc) + result = rc.json() + + assert result['status'] == 'running' + assert result['progress'] == 0 + assert result['running'] + assert result['status_msg'] == 'Backtest started' + + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) + + result = rc.json() + assert result['status'] == 'ended' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' + assert result['progress'] == 1 + assert result['backtest_result'] + + rc = client_get(client, f"{BASE_URI}/backtest/abort") + assert_response(rc) + result = rc.json() + assert result['status'] == 'not_running' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' + + # Simulate running backtest + ApiServer._bgtask_running = True + rc = client_get(client, f"{BASE_URI}/backtest/abort") + assert_response(rc) + result = rc.json() + assert result['status'] == 'stopping' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' + + # Get running backtest... + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'running' + assert result['running'] + assert result['step'] == "backtest" + assert result['status_msg'] == "Backtest running" + + # Try delete with task still running + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'running' + + # Post to backtest that's still running + rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data)) + assert_response(rc, 502) + result = rc.json() + assert 'Bot Background task already running' in result['error'] + + ApiServer._bgtask_running = False + + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest_one_strategy', + side_effect=DependencyException()) + rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data)) + assert log_has("Backtesting caused an error: ", caplog) + + # Delete backtesting to avoid leakage since the backtest-object may stick around. + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) + + result = rc.json() + assert result['status'] == 'reset' + assert not result['running'] + assert result['status_msg'] == 'Backtest reset' diff --git a/tests/rpc/test_rpc_manager.py b/tests/rpc/test_rpc_manager.py index 918022386..596b5ae20 100644 --- a/tests/rpc/test_rpc_manager.py +++ b/tests/rpc/test_rpc_manager.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock from freqtrade.enums import RPCMessageType from freqtrade.rpc import RPCManager +from freqtrade.rpc.api_server.webserver import ApiServer from tests.conftest import get_patched_freqtradebot, log_has @@ -190,3 +191,4 @@ def test_init_apiserver_enabled(mocker, default_conf, caplog) -> None: assert len(rpc_manager.registered_modules) == 1 assert 'apiserver' in [mod.name for mod in rpc_manager.registered_modules] assert run_mock.call_count == 1 + ApiServer.shutdown()