Merge pull request #5243 from freqtrade/feat/webservermode_progress

Introduce webserver mode subcommand
This commit is contained in:
Matthias 2021-07-18 10:48:55 +02:00 committed by GitHub
commit 7b7d9c02d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 595 additions and 34 deletions

View File

@ -614,6 +614,48 @@ Show whitelist when using a [dynamic pairlist](plugins.md#pairlists).
freqtrade test-pairlist --config config.json --quote USDT BTC 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 ## List Hyperopt results
You can list the hyperoptimization epochs the Hyperopt module evaluated previously with the `hyperopt-list` sub-command. You can list the hyperoptimization epochs the Hyperopt module evaluated previously with the `hyperopt-list` sub-command.

View File

@ -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.pairlist_commands import start_test_pairlist
from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit
from freqtrade.commands.trade_commands import start_trading from freqtrade.commands.trade_commands import start_trading
from freqtrade.commands.webserver_commands import start_webserver

View File

@ -16,6 +16,8 @@ ARGS_STRATEGY = ["strategy", "strategy_path"]
ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"]
ARGS_WEBSERVER: List[str] = []
ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv", ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
"max_open_trades", "stake_amount", "fee", "pairs"] "max_open_trades", "stake_amount", "fee", "pairs"]
@ -176,7 +178,8 @@ class Arguments:
start_list_markets, start_list_strategies, start_list_markets, start_list_strategies,
start_list_timeframes, start_new_config, start_new_hyperopt, start_list_timeframes, start_new_config, start_new_hyperopt,
start_new_strategy, start_plot_dataframe, start_plot_profit, 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', subparsers = self.parser.add_subparsers(dest='command',
# Use custom message when no subhandler is added # Use custom message when no subhandler is added
@ -384,3 +387,9 @@ class Arguments:
) )
plot_profit_cmd.set_defaults(func=start_plot_profit) plot_profit_cmd.set_defaults(func=start_plot_profit)
self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd) 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)

View File

@ -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)

View File

@ -71,7 +71,7 @@ class Configuration:
# Merge config options, overwriting old values # Merge config options, overwriting old values
config = deep_merge_dicts(load_config_file(path), config) config = deep_merge_dicts(load_config_file(path), config)
config['config_files'] = files
# Normalize config # Normalize config
if 'internals' not in config: if 'internals' not in config:
config['internals'] = {} config['internals'] = {}

View File

@ -1,4 +1,5 @@
# flake8: noqa: F401 # flake8: noqa: F401
from freqtrade.enums.backteststate import BacktestState
from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.rpcmessagetype import RPCMessageType
from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode
from freqtrade.enums.selltype import SellType from freqtrade.enums.selltype import SellType

View File

@ -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()}"

View File

@ -14,6 +14,7 @@ class RunMode(Enum):
UTIL_EXCHANGE = "util_exchange" UTIL_EXCHANGE = "util_exchange"
UTIL_NO_EXCHANGE = "util_no_exchange" UTIL_NO_EXCHANGE = "util_no_exchange"
PLOT = "plot" PLOT = "plot"
WEBSERVER = "webserver"
OTHER = "other" OTHER = "other"

View File

@ -17,10 +17,11 @@ from freqtrade.data import history
from freqtrade.data.btanalysis import trade_list_to_dataframe from freqtrade.data.btanalysis import trade_list_to_dataframe
from freqtrade.data.converter import trim_dataframes from freqtrade.data.converter import trim_dataframes
from freqtrade.data.dataprovider import DataProvider 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.exceptions import DependencyException, OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.bt_progress import BTProgress
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results, from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
store_backtest_stats) store_backtest_stats)
from freqtrade.persistence import LocalTrade, PairLocks, Trade from freqtrade.persistence import LocalTrade, PairLocks, Trade
@ -57,6 +58,7 @@ class Backtesting:
LoggingMixin.show_output = False LoggingMixin.show_output = False
self.config = config self.config = config
self.results: Optional[Dict[str, Any]] = None
# Reset keys for backtesting # Reset keys for backtesting
remove_credentials(self.config) remove_credentials(self.config)
@ -118,6 +120,9 @@ class Backtesting:
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) 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.exchange.validate_required_startup_candles(self.required_startup, self.timeframe)
self.progress = BTProgress()
self.abort = False
def __del__(self): def __del__(self):
LoggingMixin.show_output = True LoggingMixin.show_output = True
PairLocks.use_db = True PairLocks.use_db = True
@ -147,6 +152,8 @@ class Backtesting:
Loads backtest data and returns the data combined with the timerange Loads backtest data and returns the data combined with the timerange
as tuple. as tuple.
""" """
self.progress.init_step(BacktestState.DATALOAD, 1)
timerange = TimeRange.parse_timerange(None if self.config.get( timerange = TimeRange.parse_timerange(None if self.config.get(
'timerange') is None else str(self.config.get('timerange'))) '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), timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
self.required_startup, min_date) self.required_startup, min_date)
self.progress.set_new_value(1)
return data, timerange return data, timerange
def prepare_backtest(self, enable_protections): def prepare_backtest(self, enable_protections):
@ -184,6 +192,15 @@ class Backtesting:
self.rejected_trades = 0 self.rejected_trades = 0
self.dataprovider.clear_cache() 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]: 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. 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 # and eventually change the constants for indexes at the top
headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high'] headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high']
data: Dict = {} data: Dict = {}
self.progress.init_step(BacktestState.CONVERT, len(processed))
# Create dict with data # Create dict with data
for pair, pair_data in processed.items(): for pair, pair_data in processed.items():
self.check_abort()
self.progress.increment()
if not pair_data.empty: if not pair_data.empty:
pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist
pair_data.loc[:, 'sell'] = 0 # cleanup if sell_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_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
open_trade_count = 0 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 # Loop timerange and get candle for each pair at that point in time
while tmp <= end_date: while tmp <= end_date:
open_trade_count_start = open_trade_count open_trade_count_start = open_trade_count
self.check_abort()
for i, pair in enumerate(data): for i, pair in enumerate(data):
row_index = indexes[pair] row_index = indexes[pair]
try: try:
@ -476,6 +500,7 @@ class Backtesting:
self.protections.global_stop(tmp) self.protections.global_stop(tmp)
# Move time one configured time_interval ahead. # Move time one configured time_interval ahead.
self.progress.increment()
tmp += timedelta(minutes=self.timeframe_min) tmp += timedelta(minutes=self.timeframe_min)
trades += self.handle_left_open(open_trades, data=data) 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): 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()) logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
backtest_start_time = datetime.now(timezone.utc) backtest_start_time = datetime.now(timezone.utc)
self._set_strategy(strat) self._set_strategy(strat)
@ -517,6 +544,7 @@ class Backtesting:
"No data left after adjusting for startup candles.") "No data left after adjusting for startup candles.")
min_date, max_date = history.get_timerange(preprocessed) min_date, max_date = history.get_timerange(preprocessed)
logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
f'({(max_date - min_date).days} days).') f'({(max_date - min_date).days} days).')
@ -551,11 +579,12 @@ class Backtesting:
for strat in self.strategylist: for strat in self.strategylist:
min_date, max_date = self.backtest_one_strategy(strat, data, timerange) min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
if len(self.strategylist) > 0: if len(self.strategylist) > 0:
stats = generate_backtest_stats(data, self.all_results,
self.results = generate_backtest_stats(data, self.all_results,
min_date=min_date, max_date=max_date) min_date=min_date, max_date=max_date)
if self.config.get('export', 'none') == 'trades': 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
show_backtest_results(self.config, stats) show_backtest_results(self.config, self.results)

View File

@ -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)

View File

@ -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",
}

View File

@ -123,17 +123,17 @@ class ShowConfig(BaseModel):
stake_currency_decimals: int stake_currency_decimals: int
max_open_trades: int max_open_trades: int
minimal_roi: Dict[str, Any] minimal_roi: Dict[str, Any]
stoploss: float stoploss: Optional[float]
trailing_stop: bool trailing_stop: Optional[bool]
trailing_stop_positive: Optional[float] trailing_stop_positive: Optional[float]
trailing_stop_positive_offset: Optional[float] trailing_stop_positive_offset: Optional[float]
trailing_only_offset_is_reached: Optional[bool] trailing_only_offset_is_reached: Optional[bool]
use_custom_stoploss: Optional[bool] use_custom_stoploss: Optional[bool]
timeframe: str timeframe: Optional[str]
timeframe_ms: int timeframe_ms: int
timeframe_min: int timeframe_min: int
exchange: str exchange: str
strategy: str strategy: Optional[str]
forcebuy_enabled: bool forcebuy_enabled: bool
ask_strategy: Dict[str, Any] ask_strategy: Dict[str, Any]
bid_strategy: Dict[str, Any] bid_strategy: Dict[str, Any]
@ -318,3 +318,24 @@ class PairHistory(BaseModel):
json_encoders = { json_encoders = {
datetime: lambda v: v.strftime(DATETIME_PRINT_FORMAT), 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]]

View File

@ -1,3 +1,4 @@
import logging
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import List, Optional 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 from freqtrade.rpc.rpc import RPCException
logger = logging.getLogger(__name__)
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
# Private API, protected by authentication # 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]) pair_interval = sorted(pair_interval, key=lambda x: x[0])
pairs = list({x[0] for x in pair_interval}) pairs = list({x[0] for x in pair_interval})
pairs.sort()
result = { result = {
'length': len(pairs), 'length': len(pairs),
'pairs': pairs, 'pairs': pairs,

View File

@ -8,6 +8,7 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from freqtrade.exceptions import OperationalException
from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer
from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler
@ -28,17 +29,37 @@ class FTJSONResponse(JSONResponse):
class ApiServer(RPCHandler): class ApiServer(RPCHandler):
__instance = None
__initialized = False
_rpc: RPC _rpc: RPC
# Backtesting type: Backtesting
_bt = None
_bt_data = None
_bt_timerange = None
_bt_last_config: Dict[str, Any] = {}
_has_rpc: bool = False _has_rpc: bool = False
_bgtask_running: bool = False
_config: Dict[str, Any] = {} _config: Dict[str, Any] = {}
def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: def __new__(cls, *args, **kwargs):
super().__init__(rpc, config) """
self._server = None 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 def __init__(self, config: Dict[str, Any], standalone: bool = False) -> None:
ApiServer._has_rpc = True
ApiServer._config = config 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'] api_config = self._config['api_server']
self.app = FastAPI(title="Freqtrade API", self.app = FastAPI(title="Freqtrade API",
@ -50,12 +71,33 @@ class ApiServer(RPCHandler):
self.start_api() 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: def cleanup(self) -> None:
""" Cleanup pending module resources """ """ 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") logger.info("Stopping API Server")
self._server.cleanup() 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: def send_msg(self, msg: Dict[str, str]) -> None:
pass pass
@ -68,6 +110,7 @@ class ApiServer(RPCHandler):
def configure_app(self, app: FastAPI, config): 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_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 as api_v1
from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public
from freqtrade.rpc.api_server.web_ui import router_ui 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", app.include_router(api_v1, prefix="/api/v1",
dependencies=[Depends(http_basic_or_jwt_token)], 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"]) app.include_router(router_login, prefix="/api/v1", tags=["auth"])
# UI Router MUST be last! # UI Router MUST be last!
app.include_router(router_ui, prefix='') app.include_router(router_ui, prefix='')
@ -125,6 +171,9 @@ class ApiServer(RPCHandler):
) )
try: try:
self._server = UvicornServer(uvconfig) self._server = UvicornServer(uvconfig)
if self._standalone:
self._server.run()
else:
self._server.run_in_thread() self._server.run_in_thread()
except Exception: except Exception:
logger.exception("Api server failed to start.") logger.exception("Api server failed to start.")

View File

@ -119,9 +119,9 @@ class RPC:
'bot_name': config.get('bot_name', 'freqtrade'), 'bot_name': config.get('bot_name', 'freqtrade'),
'timeframe': config.get('timeframe'), 'timeframe': config.get('timeframe'),
'timeframe_ms': timeframe_to_msecs(config['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'] 'timeframe_min': timeframe_to_minutes(config['timeframe']
) if 'timeframe' in config else '', ) if 'timeframe' in config else 0,
'exchange': config['exchange']['name'], 'exchange': config['exchange']['name'],
'strategy': config['strategy'], 'strategy': config['strategy'],
'forcebuy_enabled': config.get('forcebuy_enable', False), 'forcebuy_enabled': config.get('forcebuy_enable', False),

View File

@ -36,15 +36,16 @@ class RPCManager:
if config.get('api_server', {}).get('enabled', False): if config.get('api_server', {}).get('enabled', False):
logger.info('Enabling rpc.api_server') logger.info('Enabling rpc.api_server')
from freqtrade.rpc.api_server import ApiServer from freqtrade.rpc.api_server import ApiServer
apiserver = ApiServer(config)
self.registered_modules.append(ApiServer(self._rpc, config)) apiserver.add_rpc_handler(self._rpc)
self.registered_modules.append(apiserver)
def cleanup(self) -> None: def cleanup(self) -> None:
""" Stops all enabled rpc modules """ """ Stops all enabled rpc modules """
logger.info('Cleaning up rpc modules ...') logger.info('Cleaning up rpc modules ...')
while self.registered_modules: while self.registered_modules:
mod = self.registered_modules.pop() mod = self.registered_modules.pop()
logger.debug('Cleaning up rpc.%s ...', mod.name) logger.info('Cleaning up rpc.%s ...', mod.name)
mod.cleanup() mod.cleanup()
del mod del mod

View File

@ -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_data, start_list_exchanges, start_list_hyperopts,
start_list_markets, start_list_strategies, start_list_timeframes, start_list_markets, start_list_strategies, start_list_timeframes,
start_new_hyperopt, start_new_strategy, start_show_trades, 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, from freqtrade.commands.deploy_commands import (clean_ui_subdir, download_and_install_ui,
get_ui_download_url, read_ui_version) get_ui_download_url, read_ui_version)
from freqtrade.configuration import setup_utils_configuration from freqtrade.configuration import setup_utils_configuration
@ -58,6 +58,18 @@ def test_start_trading_fail(mocker, caplog):
assert log_has('Fatal exception!', 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): def test_list_exchanges(capsys):
args = [ args = [

View File

@ -346,6 +346,20 @@ def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None:
assert processed['UNITTEST/BTC'].equals(processed2['UNITTEST/BTC']) 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 test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None:
def get_timerange(input1): def get_timerange(input1):
return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59)

View File

@ -2,6 +2,7 @@
Unit test file for rpc/api_server.py Unit test file for rpc/api_server.py
""" """
import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import ANY, MagicMock, PropertyMock 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.__init__ import __version__
from freqtrade.enums import RunMode, State 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.loggers import setup_logging, setup_logging_pre
from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence import PairLocks, Trade
from freqtrade.rpc import RPC from freqtrade.rpc import RPC
@ -48,9 +49,13 @@ def botclient(default_conf, mocker):
ftbot = get_patched_freqtradebot(mocker, default_conf) ftbot = get_patched_freqtradebot(mocker, default_conf)
rpc = RPC(ftbot) rpc = RPC(ftbot)
mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock()) mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock())
apiserver = ApiServer(rpc, default_conf) try:
apiserver = ApiServer(default_conf)
apiserver.add_rpc_handler(rpc)
yield ftbot, TestClient(apiserver.app) yield ftbot, TestClient(apiserver.app)
# Cleanup ... ? # Cleanup ... ?
finally:
ApiServer.shutdown()
def client_post(client, url, data={}): 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.telegram.Updater', MagicMock())
mocker.patch('freqtrade.rpc.api_server.webserver.ApiServer.start_api', 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 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): def test_api_UvicornServer(mocker):
@ -298,15 +308,21 @@ def test_api_run(default_conf, mocker, caplog):
}}) }})
mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) 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) 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 server_mock.call_count == 1
assert apiserver._config == default_conf assert apiserver._config == default_conf
apiserver.start_api() apiserver.start_api()
assert server_mock.call_count == 2 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].host == "127.0.0.1"
assert server_mock.call_args_list[0][0][0].port == 8080 assert server_mock.call_args_list[0][0][0].port == 8080
assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) 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() apiserver.start_api()
assert server_mock.call_count == 1 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].host == "0.0.0.0"
assert server_mock.call_args_list[0][0][0].port == 8089 assert server_mock.call_args_list[0][0][0].port == 8089
assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) 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) "Please make sure that this is intentional!", caplog)
assert log_has_re("SECURITY WARNING - `jwt_secret_key` seems to be default.*", 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 # Test crashing API server
caplog.clear() caplog.clear()
mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer',
MagicMock(side_effect=Exception)) MagicMock(side_effect=Exception))
apiserver.start_api() apiserver.start_api()
assert log_has("Api server failed to start.", caplog) assert log_has("Api server failed to start.", caplog)
ApiServer.shutdown()
def test_api_cleanup(default_conf, mocker, caplog): def test_api_cleanup(default_conf, mocker, caplog):
@ -359,11 +389,13 @@ def test_api_cleanup(default_conf, mocker, caplog):
server_mock.cleanup = MagicMock() server_mock.cleanup = MagicMock()
mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', server_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)))
apiserver.cleanup() apiserver.cleanup()
assert apiserver._server.cleanup.call_count == 1 assert apiserver._server.cleanup.call_count == 1
assert log_has("Stopping API Server", caplog) assert log_has("Stopping API Server", caplog)
ApiServer.shutdown()
def test_api_reloadconf(botclient): def test_api_reloadconf(botclient):
@ -1221,3 +1253,108 @@ def test_list_available_pairs(botclient):
assert rc.json()['length'] == 1 assert rc.json()['length'] == 1
assert rc.json()['pairs'] == ['XRP/ETH'] assert rc.json()['pairs'] == ['XRP/ETH']
assert len(rc.json()['pair_interval']) == 1 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'

View File

@ -5,6 +5,7 @@ from unittest.mock import MagicMock
from freqtrade.enums import RPCMessageType from freqtrade.enums import RPCMessageType
from freqtrade.rpc import RPCManager from freqtrade.rpc import RPCManager
from freqtrade.rpc.api_server.webserver import ApiServer
from tests.conftest import get_patched_freqtradebot, log_has 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 len(rpc_manager.registered_modules) == 1
assert 'apiserver' in [mod.name for mod in rpc_manager.registered_modules] assert 'apiserver' in [mod.name for mod in rpc_manager.registered_modules]
assert run_mock.call_count == 1 assert run_mock.call_count == 1
ApiServer.shutdown()