Merge pull request #5243 from freqtrade/feat/webservermode_progress
Introduce webserver mode subcommand
This commit is contained in:
commit
7b7d9c02d7
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
15
freqtrade/commands/webserver_commands.py
Normal file
15
freqtrade/commands/webserver_commands.py
Normal 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)
|
@ -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'] = {}
|
||||
|
@ -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
|
||||
|
15
freqtrade/enums/backteststate.py
Normal file
15
freqtrade/enums/backteststate.py
Normal 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()}"
|
@ -14,6 +14,7 @@ class RunMode(Enum):
|
||||
UTIL_EXCHANGE = "util_exchange"
|
||||
UTIL_NO_EXCHANGE = "util_no_exchange"
|
||||
PLOT = "plot"
|
||||
WEBSERVER = "webserver"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
|
@ -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,
|
||||
|
||||
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)
|
||||
|
33
freqtrade/optimize/bt_progress.py
Normal file
33
freqtrade/optimize/bt_progress.py
Normal 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)
|
176
freqtrade/rpc/api_server/api_backtest.py
Normal file
176
freqtrade/rpc/api_server/api_backtest.py
Normal 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",
|
||||
}
|
@ -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]]
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
if self._standalone:
|
||||
self._server.run()
|
||||
else:
|
||||
self._server.run_in_thread()
|
||||
except Exception:
|
||||
logger.exception("Api server failed to start.")
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 = [
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
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'
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user