diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4be298d7b..eb50a3a3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: additional_dependencies: - types-cachetools==5.3.0.5 - types-filelock==3.2.7 - - types-requests==2.30.0.0 + - types-requests==2.31.0.0 - types-tabulate==0.9.0.2 - types-python-dateutil==2.8.19.13 - SQLAlchemy==2.0.15 @@ -30,7 +30,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.0.263' + rev: 'v0.0.270' hooks: - id: ruff diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 8b6654c6c..8913d787b 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -342,16 +342,12 @@ The above configuration would therefore mean: The calculation does include fees. -To disable ROI completely, set it to an insanely high number: +To disable ROI completely, set it to an empty dictionary: ```python -minimal_roi = { - "0": 100 -} +minimal_roi = {} ``` -While technically not completely disabled, this would exit once the trade reaches 10000% Profit. - To use times based on candle duration (timeframe), the following snippet can be handy. This will allow you to change the timeframe for the strategy, and ROI times will still be set as candles (e.g. after 3 candles ...) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 3802ec3ad..7012acb7c 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -148,7 +148,6 @@ CONF_SCHEMA = { 'patternProperties': { '^[0-9.]+$': {'type': 'number'} }, - 'minProperties': 1 }, 'amount_reserve_percent': {'type': 'number', 'minimum': 0.0, 'maximum': 0.5}, 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True, 'minimum': -1}, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 3b1466c69..88022e19c 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -191,7 +191,7 @@ class Exchange: # Converts the interval provided in minutes in config to seconds self.markets_refresh_interval: int = exchange_conf.get( - "markets_refresh_interval", 60) * 60 + "markets_refresh_interval", 60) * 60 * 1000 if self.trading_mode != TradingMode.SPOT and load_leverage_tiers: self.fill_leverage_tiers() @@ -1662,39 +1662,18 @@ class Exchange: price_side = self._get_price_side(side, is_short, conf_strategy) - price_side_word = price_side.capitalize() - if conf_strategy.get('use_order_book', False): order_book_top = conf_strategy.get('order_book_top', 1) if order_book is None: order_book = self.fetch_l2_order_book(pair, order_book_top) - logger.debug('order_book %s', order_book) - # top 1 = index 0 - try: - obside: OBLiteral = 'bids' if price_side == 'bid' else 'asks' - rate = order_book[obside][order_book_top - 1][0] - except (IndexError, KeyError) as e: - logger.warning( - f"{pair} - {name} Price at location {order_book_top} from orderbook " - f"could not be determined. Orderbook: {order_book}" - ) - raise PricingError from e - logger.debug(f"{pair} - {name} price from orderbook {price_side_word}" - f"side - top {order_book_top} order book {side} rate {rate:.8f}") + rate = self._get_rate_from_ob(pair, side, order_book, name, price_side, + order_book_top) else: - logger.debug(f"Using Last {price_side_word} / Last Price") + logger.debug(f"Using Last {price_side.capitalize()} / Last Price") if ticker is None: ticker = self.fetch_ticker(pair) - ticker_rate = ticker[price_side] - if ticker['last'] and ticker_rate: - if side == 'entry' and ticker_rate > ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate) - elif side == 'exit' and ticker_rate < ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate - balance * (ticker_rate - ticker['last']) - rate = ticker_rate + rate = self._get_rate_from_ticker(side, ticker, conf_strategy, price_side) if rate is None: raise PricingError(f"{name}-Rate for {pair} was empty.") @@ -1703,6 +1682,43 @@ class Exchange: return rate + def _get_rate_from_ticker(self, side: EntryExit, ticker: Ticker, conf_strategy: Dict[str, Any], + price_side: BidAsk) -> Optional[float]: + """ + Get rate from ticker. + """ + ticker_rate = ticker[price_side] + if ticker['last'] and ticker_rate: + if side == 'entry' and ticker_rate > ticker['last']: + balance = conf_strategy.get('price_last_balance', 0.0) + ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate) + elif side == 'exit' and ticker_rate < ticker['last']: + balance = conf_strategy.get('price_last_balance', 0.0) + ticker_rate = ticker_rate - balance * (ticker_rate - ticker['last']) + rate = ticker_rate + return rate + + def _get_rate_from_ob(self, pair: str, side: EntryExit, order_book: OrderBook, name: str, + price_side: BidAsk, order_book_top: int) -> float: + """ + Get rate from orderbook + :raises: PricingError if rate could not be determined. + """ + logger.debug('order_book %s', order_book) + # top 1 = index 0 + try: + obside: OBLiteral = 'bids' if price_side == 'bid' else 'asks' + rate = order_book[obside][order_book_top - 1][0] + except (IndexError, KeyError) as e: + logger.warning( + f"{pair} - {name} Price at location {order_book_top} from orderbook " + f"could not be determined. Orderbook: {order_book}" + ) + raise PricingError from e + logger.debug(f"{pair} - {name} price from orderbook {price_side.capitalize()}" + f"side - top {order_book_top} order book {side} rate {rate:.8f}") + return rate + def get_rates(self, pair: str, refresh: bool, is_short: bool) -> Tuple[float, float]: entry_rate = None exit_rate = None diff --git a/freqtrade/exchange/gate.py b/freqtrade/exchange/gate.py index 2ac135fc1..eceab4ec1 100644 --- a/freqtrade/exchange/gate.py +++ b/freqtrade/exchange/gate.py @@ -33,7 +33,6 @@ class Gate(Exchange): _ft_has_futures: Dict = { "needs_trading_fees": True, "marketOrderRequiresPrice": False, - "tickers_have_bid_ask": False, "fee_cost_in_contracts": False, # Set explicitly to false for clarity "order_props_in_contracts": ['amount', 'filled', 'remaining'], "stop_price_type_field": "price_type", diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fc4c65caf..42ac85fdb 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1302,6 +1302,10 @@ class FreqtradeBot(LoggingMixin): f"(orderid:{order['id']}) in order to add another one ...") self.cancel_stoploss_on_exchange(trade) + if not trade.is_open: + logger.warning( + f"Trade {trade} is closed, not creating trailing stoploss order.") + return # Create new stoploss order if not self.create_stoploss_order(trade=trade, stop_price=stoploss_norm): diff --git a/freqtrade/loggers/__init__.py b/freqtrade/loggers/__init__.py index 58f207608..dc01e4e5b 100644 --- a/freqtrade/loggers/__init__.py +++ b/freqtrade/loggers/__init__.py @@ -32,7 +32,7 @@ def _set_loggers(verbosity: int = 0, api_verbosity: str = 'info') -> None: logging.INFO if verbosity <= 2 else logging.DEBUG ) logging.getLogger('telegram').setLevel(logging.INFO) - logging.getLogger('httpx').setLevel(logging.INFO) + logging.getLogger('httpx').setLevel(logging.WARNING) logging.getLogger('werkzeug').setLevel( logging.ERROR if api_verbosity == 'error' else logging.INFO diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 8fa1a87b8..411ba4978 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -16,14 +16,14 @@ from freqtrade.exchange.common import remove_exchange_credentials from freqtrade.misc import deep_merge_dicts from freqtrade.rpc.api_server.api_schemas import (BacktestHistoryEntry, BacktestRequest, BacktestResponse) -from freqtrade.rpc.api_server.deps import get_config, is_webserver_mode +from freqtrade.rpc.api_server.deps import get_config from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from freqtrade.rpc.rpc import RPCException logger = logging.getLogger(__name__) -# Private API, protected by authentication +# Private API, protected by authentication and webserver_mode dependency router = APIRouter() @@ -102,7 +102,7 @@ def __run_backtest_bg(btconfig: Config): @router.post('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) async def api_start_backtest( bt_settings: BacktestRequest, background_tasks: BackgroundTasks, - config=Depends(get_config), ws_mode=Depends(is_webserver_mode)): + config=Depends(get_config)): ApiBG.bt['bt_error'] = None """Start backtesting if not done so already""" if ApiBG.bgtask_running: @@ -143,7 +143,7 @@ async def api_start_backtest( @router.get('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_get_backtest(ws_mode=Depends(is_webserver_mode)): +def api_get_backtest(): """ Get backtesting result. Returns Result after backtesting has been ran. @@ -188,7 +188,7 @@ def api_get_backtest(ws_mode=Depends(is_webserver_mode)): @router.delete('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_delete_backtest(ws_mode=Depends(is_webserver_mode)): +def api_delete_backtest(): """Reset backtesting""" if ApiBG.bgtask_running: return { @@ -215,7 +215,7 @@ def api_delete_backtest(ws_mode=Depends(is_webserver_mode)): @router.get('/backtest/abort', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_backtest_abort(ws_mode=Depends(is_webserver_mode)): +def api_backtest_abort(): if not ApiBG.bgtask_running: return { "status": "not_running", @@ -236,15 +236,14 @@ def api_backtest_abort(ws_mode=Depends(is_webserver_mode)): @router.get('/backtest/history', response_model=List[BacktestHistoryEntry], tags=['webserver', 'backtest']) -def api_backtest_history(config=Depends(get_config), ws_mode=Depends(is_webserver_mode)): +def api_backtest_history(config=Depends(get_config)): # Get backtest result history, read from metadata files return get_backtest_resultlist(config['user_data_dir'] / 'backtest_results') @router.get('/backtest/history/result', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_backtest_history_result(filename: str, strategy: str, config=Depends(get_config), - ws_mode=Depends(is_webserver_mode)): +def api_backtest_history_result(filename: str, strategy: str, config=Depends(get_config)): # Get backtest result history, read from metadata files fn = config['user_data_dir'] / 'backtest_results' / filename results: Dict[str, Any] = { diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py index 8fd105d3e..4c118d274 100644 --- a/freqtrade/rpc/api_server/deps.py +++ b/freqtrade/rpc/api_server/deps.py @@ -1,7 +1,7 @@ from typing import Any, AsyncIterator, Dict, Optional from uuid import uuid4 -from fastapi import Depends +from fastapi import Depends, HTTPException from freqtrade.enums import RunMode from freqtrade.persistence import Trade @@ -57,5 +57,6 @@ def get_message_stream(): def is_webserver_mode(config=Depends(get_config)): if config['runmode'] != RunMode.WEBSERVER: - raise RPCException('Bot is not in the correct state') + raise HTTPException(status_code=503, + detail='Bot is not in the correct state.') return None diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 165849a7f..ea623e0ed 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -118,6 +118,7 @@ class ApiServer(RPCHandler): 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_ws import router as ws_router + from freqtrade.rpc.api_server.deps import is_webserver_mode from freqtrade.rpc.api_server.web_ui import router_ui app.include_router(api_v1_public, prefix="/api/v1") @@ -126,7 +127,8 @@ class ApiServer(RPCHandler): dependencies=[Depends(http_basic_or_jwt_token)], ) app.include_router(api_backtest, prefix="/api/v1", - dependencies=[Depends(http_basic_or_jwt_token)], + dependencies=[Depends(http_basic_or_jwt_token), + Depends(is_webserver_mode)], ) app.include_router(ws_router, prefix="/api/v1") app.include_router(router_login, prefix="/api/v1", tags=["auth"]) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index dedb35503..c3759e03a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -755,7 +755,7 @@ class RPC: return {'status': 'Reloaded from orders from exchange'} def __exec_force_exit(self, trade: Trade, ordertype: Optional[str], - amount: Optional[float] = None) -> None: + amount: Optional[float] = None) -> bool: # Check if there is there is an open order fully_canceled = False if trade.open_order_id: @@ -770,6 +770,9 @@ class RPC: self._freqtrade.handle_cancel_exit(trade, order, CANCEL_REASON['FORCE_EXIT']) if not fully_canceled: + if trade.open_order_id is not None: + # Order cancellation failed, so we can't exit. + return False # Get current rate and execute sell current_rate = self._freqtrade.exchange.get_rate( trade.pair, side='exit', is_short=trade.is_short, refresh=True) @@ -790,6 +793,9 @@ class RPC: trade, current_rate, exit_check, ordertype=order_type, sub_trade_amt=sub_amount) + return True + return False + def _rpc_force_exit(self, trade_id: str, ordertype: Optional[str] = None, *, amount: Optional[float] = None) -> Dict[str, str]: """ @@ -802,12 +808,12 @@ class RPC: with self._freqtrade._exit_lock: if trade_id == 'all': - # Execute sell for all open orders + # Execute exit for all open orders for trade in Trade.get_open_trades(): self.__exec_force_exit(trade, ordertype) Trade.commit() self._freqtrade.wallets.update() - return {'result': 'Created sell orders for all open trades.'} + return {'result': 'Created exit orders for all open trades.'} # Query for trade trade = Trade.get_trades( @@ -817,10 +823,12 @@ class RPC: logger.warning('force_exit: Invalid argument received') raise RPCException('invalid argument') - self.__exec_force_exit(trade, ordertype, amount) + result = self.__exec_force_exit(trade, ordertype, amount) Trade.commit() self._freqtrade.wallets.update() - return {'result': f'Created sell order for trade {trade_id}.'} + if not result: + raise RPCException('Failed to exit trade.') + return {'result': f'Created exit order for trade {trade_id}.'} def _force_entry_validations(self, pair: str, order_side: SignalDirection): if not self._freqtrade.config.get('force_entry_enable', False): diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 382f38c9a..80093fed8 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -48,7 +48,7 @@ class IStrategy(ABC, HyperStrategyMixin): _ft_params_from_file: Dict # associated minimal roi - minimal_roi: Dict = {"0": 10.0} + minimal_roi: Dict = {} # associated stoploss stoploss: float @@ -1265,7 +1265,7 @@ class IStrategy(ABC, HyperStrategyMixin): :return: minimal ROI entry value or None if none proper ROI entry was found. """ # Get highest entry in ROI dict where key <= trade-duration - roi_list = list(filter(lambda x: x <= trade_dur, self.minimal_roi.keys())) + roi_list = [x for x in self.minimal_roi.keys() if x <= trade_dur] if not roi_list: return None, None roi_entry = max(roi_list) diff --git a/requirements-dev.txt b/requirements-dev.txt index cc3463174..96d06ccbf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,12 +7,12 @@ -r docs/requirements-docs.txt coveralls==3.3.1 -ruff==0.0.269 +ruff==0.0.270 mypy==1.3.0 pre-commit==3.3.2 pytest==7.3.1 pytest-asyncio==0.21.0 -pytest-cov==4.0.0 +pytest-cov==4.1.0 pytest-mock==3.10.0 pytest-random-order==1.1.0 isort==5.12.0 @@ -25,6 +25,6 @@ nbconvert==7.4.0 # mypy types types-cachetools==5.3.0.5 types-filelock==3.2.7 -types-requests==2.30.0.0 +types-requests==2.31.0.0 types-tabulate==0.9.0.2 types-python-dateutil==2.8.19.13 diff --git a/requirements.txt b/requirements.txt index cff54ca1c..d04774361 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,16 +2,16 @@ numpy==1.24.3 pandas==2.0.1 pandas-ta==0.3.14b -ccxt==3.1.5 +ccxt==3.1.13 cryptography==40.0.2; platform_machine != 'armv7l' cryptography==40.0.1; platform_machine == 'armv7l' aiohttp==3.8.4 SQLAlchemy==2.0.15 python-telegram-bot==20.3 # can't be hard-pinned due to telegram-bot pinning httpx with ~ -httpx>=0.23.3 +httpx>=0.24.1 arrow==1.2.3 -cachetools==5.3.0 +cachetools==5.3.1 requests==2.31.0 urllib3==2.0.2 jsonschema==4.17.3 @@ -32,14 +32,14 @@ py_find_1st==1.1.5 # Load ticker files 30% faster python-rapidjson==1.10 # Properly format api responses -orjson==3.8.12 +orjson==3.8.14 # Notify systemd sdnotify==0.3.2 # API Server fastapi==0.95.2 -pydantic==1.10.7 +pydantic==1.10.8 uvicorn==0.22.0 pyjwt==2.7.0 aiofiles==23.1.0 diff --git a/setup.py b/setup.py index f8b8b515c..106b5b6d3 100644 --- a/setup.py +++ b/setup.py @@ -107,7 +107,7 @@ setup( 'ast-comments', 'aiohttp', 'cryptography', - 'httpx', + 'httpx>=0.24.1', 'python-dateutil', 'packaging', ], diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index ef70c8ba1..f022a0905 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -633,21 +633,23 @@ def test__load_markets(default_conf, mocker, caplog): assert ex.markets == expected_return -def test_reload_markets(default_conf, mocker, caplog): +def test_reload_markets(default_conf, mocker, caplog, time_machine): caplog.set_level(logging.DEBUG) initial_markets = {'ETH/BTC': {}} updated_markets = {'ETH/BTC': {}, "LTC/BTC": {}} - + start_dt = dt_now() + time_machine.move_to(start_dt, tick=False) api_mock = MagicMock() api_mock.load_markets = MagicMock(return_value=initial_markets) default_conf['exchange']['markets_refresh_interval'] = 10 exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance", mock_markets=False) exchange._load_async_markets = MagicMock() - exchange._last_markets_refresh = dt_ts() + assert exchange._last_markets_refresh == dt_ts() assert exchange.markets == initial_markets + time_machine.move_to(start_dt + timedelta(minutes=8), tick=False) # less than 10 minutes have passed, no reload exchange.reload_markets() assert exchange.markets == initial_markets @@ -655,12 +657,18 @@ def test_reload_markets(default_conf, mocker, caplog): api_mock.load_markets = MagicMock(return_value=updated_markets) # more than 10 minutes have passed, reload is executed - exchange._last_markets_refresh = dt_ts(dt_now() - timedelta(minutes=15)) + time_machine.move_to(start_dt + timedelta(minutes=11), tick=False) exchange.reload_markets() assert exchange.markets == updated_markets assert exchange._load_async_markets.call_count == 1 assert log_has('Performing scheduled market reload..', caplog) + # Not called again + exchange._load_async_markets.reset_mock() + + exchange.reload_markets() + assert exchange._load_async_markets.call_count == 0 + def test_reload_markets_exception(default_conf, mocker, caplog): caplog.set_level(logging.DEBUG) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 158dd04dc..82c036e07 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -820,7 +820,7 @@ tc52 = BTContainer(data=[ [2, 4900, 5250, 4500, 5100, 6172, 0, 0], # Order readjust [3, 5100, 5100, 4650, 4750, 6172, 0, 0], # stoploss hit? [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], - stop_loss=-0.03, roi={"0": 0.10}, profit_perc=-0.03, + stop_loss=-0.03, roi={}, profit_perc=-0.03, use_exit_signal=True, timeout=1000, custom_entry_price=4200, adjust_entry_price=5200, trades=[BTrade(exit_reason=ExitType.STOP_LOSS, open_tick=1, close_tick=2, is_short=False)] diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 405727d8c..31d7ae37b 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -703,15 +703,15 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: rpc._rpc_force_exit(None) msg = rpc._rpc_force_exit('all') - assert msg == {'result': 'Created sell orders for all open trades.'} + assert msg == {'result': 'Created exit orders for all open trades.'} freqtradebot.enter_positions() msg = rpc._rpc_force_exit('all') - assert msg == {'result': 'Created sell orders for all open trades.'} + assert msg == {'result': 'Created exit orders for all open trades.'} freqtradebot.enter_positions() msg = rpc._rpc_force_exit('2') - assert msg == {'result': 'Created sell order for trade 2.'} + assert msg == {'result': 'Created exit order for trade 2.'} freqtradebot.state = State.STOPPED with pytest.raises(RPCException, match=r'.*trader is not running*'): @@ -761,27 +761,11 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: freqtradebot.config['max_open_trades'] = 3 freqtradebot.enter_positions() - trade = Trade.session.scalars(select(Trade).filter(Trade.id == '2')).first() - amount = trade.amount - # make an limit-buy open trade, if there is no 'filled', don't sell it - mocker.patch( - f'{EXMS}.fetch_order', - return_value={ - 'status': 'open', - 'type': 'limit', - 'side': 'buy', - 'filled': None - } - ) - # check that the trade is called, which is done by ensuring exchange.cancel_order is called - msg = rpc._rpc_force_exit('4') - assert msg == {'result': 'Created sell order for trade 4.'} - assert cancel_order_mock.call_count == 2 - assert trade.amount == amount + cancel_order_mock.reset_mock() trade = Trade.session.scalars(select(Trade).filter(Trade.id == '3')).first() - - # make an limit-sell open trade + amount = trade.amount + # make an limit-sell open order trade mocker.patch( f'{EXMS}.fetch_order', return_value={ @@ -794,10 +778,54 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: 'id': trade.orders[0].order_id, } ) + cancel_order_3 = mocker.patch( + f'{EXMS}.cancel_order_with_result', + return_value={ + 'status': 'canceled', + 'type': 'limit', + 'side': 'sell', + 'amount': amount, + 'remaining': amount, + 'filled': 0.0, + 'id': trade.orders[0].order_id, + } + ) msg = rpc._rpc_force_exit('3') - assert msg == {'result': 'Created sell order for trade 3.'} + assert msg == {'result': 'Created exit order for trade 3.'} # status quo, no exchange calls - assert cancel_order_mock.call_count == 3 + assert cancel_order_3.call_count == 1 + assert cancel_order_mock.call_count == 0 + + trade = Trade.session.scalars(select(Trade).filter(Trade.id == '2')).first() + amount = trade.amount + # make an limit-buy open trade, if there is no 'filled', don't sell it + mocker.patch( + f'{EXMS}.fetch_order', + return_value={ + 'status': 'open', + 'type': 'limit', + 'side': 'buy', + 'filled': None + } + ) + cancel_order_4 = mocker.patch( + f'{EXMS}.cancel_order_with_result', + return_value={ + 'status': 'canceled', + 'type': 'limit', + 'side': 'sell', + 'amount': amount, + 'remaining': 0.0, + 'filled': amount, + 'id': trade.orders[0].order_id, + } + ) + # check that the trade is called, which is done by ensuring exchange.cancel_order is called + msg = rpc._rpc_force_exit('4') + assert msg == {'result': 'Created exit order for trade 4.'} + assert cancel_order_4.call_count == 1 + assert cancel_order_mock.call_count == 0 + assert trade.amount == amount def test_performance_handle(default_conf_usdt, ticker, fee, mocker) -> None: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 476a8ff2d..9c5891b03 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1333,7 +1333,7 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets): rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5", "ordertype": "market", "amount": 23}) assert_response(rc) - assert rc.json() == {'result': 'Created sell order for trade 5.'} + assert rc.json() == {'result': 'Created exit order for trade 5.'} Trade.rollback() trade = Trade.get_trades([Trade.id == 5]).first() @@ -1343,7 +1343,7 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets): rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"}) assert_response(rc) - assert rc.json() == {'result': 'Created sell order for trade 5.'} + assert rc.json() == {'result': 'Created exit order for trade 5.'} Trade.rollback() trade = Trade.get_trades([Trade.id == 5]).first() @@ -1756,7 +1756,8 @@ def test_api_backtesting(botclient, mocker, fee, caplog, tmpdir): rc = client_get(client, f"{BASE_URI}/backtest") # Backtest prevented in default mode - assert_response(rc, 502) + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' ftbot.config['runmode'] = RunMode.WEBSERVER # Backtesting not started yet @@ -1895,7 +1896,9 @@ def test_api_backtest_history(botclient, mocker, testdatadir): ]) rc = client_get(client, f"{BASE_URI}/backtest/history") - assert_response(rc, 502) + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' + ftbot.config['user_data_dir'] = testdatadir ftbot.config['runmode'] = RunMode.WEBSERVER