diff --git a/build_helpers/schema.json b/build_helpers/schema.json index c5bbeb4ef..bc4f2e6f4 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -1032,6 +1032,7 @@ "type": "string", "enum": [ "running", + "paused", "stopped" ] }, diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index b7ab86eac..ec25cb616 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -177,7 +177,7 @@ sudo loginctl enable-linger "$USER" If you run the bot as a service, you can use systemd service manager as a software watchdog monitoring freqtrade bot state and restarting it in the case of failures. If the `internals.sd_notify` parameter is set to true in the configuration or the `--sd-notify` command line option is used, the bot will send keep-alive ping messages to systemd -using the sd_notify (systemd notifications) protocol and will also tell systemd its current state (Running or Stopped) +using the sd_notify (systemd notifications) protocol and will also tell systemd its current state (Running, Paused or Stopped) when it changes. The `freqtrade.service.watchdog` file contains an example of the service unit configuration file which uses systemd diff --git a/docs/configuration.md b/docs/configuration.md index f76b9360d..89261c3d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -266,7 +266,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String | `external_message_consumer` | Enable [Producer/Consumer mode](producer-consumer.md) for more details.
**Datatype:** Dict | | **Other** -| `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` +| `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `running`, `paused` or `stopped` | `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below.
**Datatype:** Boolean | `disable_dataframe_checks` | Disable checking the OHLCV dataframe returned from the strategy methods for correctness. Only use when intentionally changing the dataframe and understand what you are doing. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `False`*.
**Datatype:** Boolean | `internals.process_throttle_secs` | Set the process throttle, or minimum loop duration for one bot iteration loop. Value in second.
*Defaults to `5` seconds.*
**Datatype:** Positive Integer diff --git a/docs/rest-api.md b/docs/rest-api.md index 10e4534c0..cea4d2ee9 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -268,6 +268,9 @@ show_config start Start the bot if it's in the stopped state. +pause + Pause the bot if it's in the running state. If triggered on stopped state will handle open positions. + stats Return the stats report (durations, sell-reasons). @@ -333,6 +336,7 @@ All endpoints in the below table need to be prefixed with the base URL of the AP |-----------|--------|--------------------------| | `/ping` | GET | Simple command testing the API Readiness - requires no authentication. | `/start` | POST | Starts the trader. +| `/pause` | POST | Pause the trader. Gracefully handle open trades according to their rules. Do not enter new positions. | `/stop` | POST | Stops the trader. | `/stopbuy` | POST | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. | `/reload_config` | POST | Reloads the configuration file. diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index c7c434140..7dedd9d51 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -188,7 +188,7 @@ You can create your own keyboard in `config.json`: !!! Note "Supported Commands" Only the following commands are allowed. Command arguments are not supported! - `/start`, `/stop`, `/status`, `/status table`, `/trades`, `/profit`, `/performance`, `/daily`, `/stats`, `/count`, `/locks`, `/balance`, `/stopentry`, `/reload_config`, `/show_config`, `/logs`, `/whitelist`, `/blacklist`, `/edge`, `/help`, `/version`, `/marketdir` + `/start`, `/pause`, `/stop`, `/status`, `/status table`, `/trades`, `/profit`, `/performance`, `/daily`, `/stats`, `/count`, `/locks`, `/balance`, `/stopentry`, `/reload_config`, `/show_config`, `/logs`, `/whitelist`, `/blacklist`, `/edge`, `/help`, `/version`, `/marketdir` ## Telegram commands @@ -200,6 +200,7 @@ official commands. You can ask at any moment for help with `/help`. |----------|-------------| | **System commands** | `/start` | Starts the trader +| `/pause` | Pause the trader. Gracefully handle open trades according to their rules. Do not enter new positions. | `/stop` | Stops the trader | `/stopbuy | /stopentry` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. | `/reload_config` | Reloads the configuration file @@ -250,6 +251,10 @@ Below, example of Telegram message you will receive for each command. > **Status:** `running` +### /pause + +> **Status:** `paused` + ### /stop > `Stopping trader ...` diff --git a/freqtrade/configuration/config_schema.py b/freqtrade/configuration/config_schema.py index 166d4f4f6..3b0be5c8d 100644 --- a/freqtrade/configuration/config_schema.py +++ b/freqtrade/configuration/config_schema.py @@ -689,7 +689,7 @@ CONF_SCHEMA = { "initial_state": { "description": "Initial state of the system.", "type": "string", - "enum": ["running", "stopped"], + "enum": ["running", "paused", "stopped"], }, "force_entry_enable": { "description": "Force enable entry.", diff --git a/freqtrade/enums/state.py b/freqtrade/enums/state.py index 1ce486920..20b3766b9 100644 --- a/freqtrade/enums/state.py +++ b/freqtrade/enums/state.py @@ -7,8 +7,9 @@ class State(Enum): """ RUNNING = 1 - STOPPED = 2 - RELOAD_CONFIG = 3 + PAUSED = 2 + STOPPED = 3 + RELOAD_CONFIG = 4 def __str__(self): return f"{self.name.lower()}" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 4156b50e1..1baf253c0 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -300,7 +300,7 @@ class FreqtradeBot(LoggingMixin): self.process_open_trade_positions() # Then looking for entry opportunities - if self.get_free_open_trades(): + if self.state == State.RUNNING and self.get_free_open_trades(): self.enter_positions() self._schedule.run_pending() Trade.commit() @@ -781,6 +781,10 @@ class FreqtradeBot(LoggingMixin): ) if stake_amount is not None and stake_amount > 0.0: + if self.state == State.PAUSED: + logger.debug("Position adjustment aborted because the bot is in PAUSED state") + return + # We should increase our position if self.strategy.max_entry_position_adjustment > -1: count_of_entries = trade.nr_of_successful_entries diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index afab46cc8..0081871f0 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -369,10 +369,11 @@ def stop(rpc: RPC = Depends(get_rpc)): return rpc._rpc_stop() +@router.post("/pause", response_model=StatusMsg, tags=["botcontrol"]) @router.post("/stopentry", response_model=StatusMsg, tags=["botcontrol"]) @router.post("/stopbuy", response_model=StatusMsg, tags=["botcontrol"]) -def stop_buy(rpc: RPC = Depends(get_rpc)): - return rpc._rpc_stopentry() +def pause(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_pause() @router.post("/reload_config", response_model=StatusMsg, tags=["botcontrol"]) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 075ddd374..8903c7f2c 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -838,7 +838,7 @@ class RPC: def _rpc_stop(self) -> dict[str, str]: """Handler for stop""" - if self._freqtrade.state == State.RUNNING: + if self._freqtrade.state != State.STOPPED: self._freqtrade.state = State.STOPPED return {"status": "stopping trader ..."} @@ -849,16 +849,25 @@ class RPC: self._freqtrade.state = State.RELOAD_CONFIG return {"status": "Reloading config ..."} - def _rpc_stopentry(self) -> dict[str, str]: + def _rpc_pause(self) -> dict[str, str]: """ - Handler to stop buying, but handle open trades gracefully. + Handler to pause trading (stop entering new trades), but handle open trades gracefully. """ if self._freqtrade.state == State.RUNNING: - # Set 'max_open_trades' to 0 - self._freqtrade.config["max_open_trades"] = 0 - self._freqtrade.strategy.max_open_trades = 0 + self._freqtrade.state = State.PAUSED - return {"status": "No more entries will occur from now. Run /reload_config to reset."} + if self._freqtrade.state == State.STOPPED: + self._freqtrade.state = State.PAUSED + return { + "status": ( + "starting bot with trader in paused state, no entries will occur. " + "Run /start to enable entries." + ) + } + + return { + "status": "paused, no more entries will occur from now. Run /start to enable entries." + } def _rpc_reload_trade_from_exchange(self, trade_id: int) -> dict[str, str]: """ @@ -930,7 +939,7 @@ class RPC: Sells the given trade at current price """ - if self._freqtrade.state != State.RUNNING: + if self._freqtrade.state == State.STOPPED: raise RPCException("trader is not running") with self._freqtrade._exit_lock: @@ -1046,7 +1055,7 @@ class RPC: raise RPCException(f"Failed to enter position for {pair}.") def _rpc_cancel_open_order(self, trade_id: int): - if self._freqtrade.state != State.RUNNING: + if self._freqtrade.state == State.STOPPED: raise RPCException("trader is not running") with self._freqtrade._exit_lock: # Query for trade @@ -1214,7 +1223,7 @@ class RPC: def _rpc_count(self) -> dict[str, float]: """Returns the number of trades running""" - if self._freqtrade.state != State.RUNNING: + if self._freqtrade.state == State.STOPPED: raise RPCException("trader is not running") trades = Trade.get_open_trades() diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 739a2af86..f3f4a4196 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -178,6 +178,7 @@ class Telegram(RPCHandler): # problem in _help()). valid_keys: list[str] = [ r"/start$", + r"/pause$", r"/stop$", r"/status$", r"/status table$", @@ -293,7 +294,7 @@ class Telegram(RPCHandler): CommandHandler(["unlock", "delete_locks"], self._delete_locks), CommandHandler(["reload_config", "reload_conf"], self._reload_config), CommandHandler(["show_config", "show_conf"], self._show_config), - CommandHandler(["stopbuy", "stopentry"], self._stopentry), + CommandHandler(["stopbuy", "stopentry", "pause"], self._pause), CommandHandler("whitelist", self._whitelist), CommandHandler("blacklist", self._blacklist), CommandHandler(["blacklist_delete", "bl_delete"], self._blacklist_delete), @@ -1269,15 +1270,15 @@ class Telegram(RPCHandler): await self._send_msg(f"Status: `{msg['status']}`") @authorized_only - async def _stopentry(self, update: Update, context: CallbackContext) -> None: + async def _pause(self, update: Update, context: CallbackContext) -> None: """ - Handler for /stop_buy. - Sets max_open_trades to 0 and gracefully sells all open trades + Handler for /stop_buy /stop_entry and /pause. + Sets bot state to paused :param bot: telegram bot :param update: message update :return: None """ - msg = self._rpc._rpc_stopentry() + msg = self._rpc._rpc_pause() await self._send_msg(f"Status: `{msg['status']}`") @authorized_only @@ -1829,6 +1830,7 @@ class Telegram(RPCHandler): "_Bot Control_\n" "------------\n" "*/start:* `Starts the trader`\n" + "*/pause:* `Pause the new entries for trader, but handles open trades gracefully`\n" "*/stop:* `Stops the trader`\n" "*/stopentry:* `Stops entering, but handles open trades gracefully` \n" "*/forceexit |all:* `Instantly exits the given trade or all trades, " diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 61d5d9a64..8b754ddc3 100644 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -96,7 +96,10 @@ class Worker: logger.info( f"Changing state{f' from {old_state.name}' if old_state else ''} to: {state.name}" ) - if state == State.RUNNING: + if state in (State.RUNNING, State.PAUSED) and old_state not in ( + State.RUNNING, + State.PAUSED, + ): self.freqtrade.startup() if state == State.STOPPED: @@ -112,9 +115,10 @@ class Worker: self._throttle(func=self._process_stopped, throttle_secs=self._throttle_secs) - elif state == State.RUNNING: + elif state in (State.RUNNING, State.PAUSED): + state_str = "RUNNING" if state == State.RUNNING else "PAUSED" # Ping systemd watchdog before throttling - self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.") + self._notify(f"WATCHDOG=1\nSTATUS=State: {state_str}.") # Use an offset of 1s to ensure a new candle has been issued self._throttle( @@ -221,7 +225,7 @@ class Worker: # Load and validate config and create new instance of the bot self._init(True) - self.freqtrade.notify_status("config reloaded") + self.freqtrade.notify_status(f"{State(self.freqtrade.state)} after config reloaded") # Tell systemd that we completed reconfiguration self._notify("READY=1") diff --git a/tests/freqtradebot/test_worker.py b/tests/freqtradebot/test_worker.py index 1dfdca5b2..6423e63ee 100644 --- a/tests/freqtradebot/test_worker.py +++ b/tests/freqtradebot/test_worker.py @@ -3,6 +3,7 @@ import time from datetime import timedelta from unittest.mock import MagicMock, PropertyMock +import pytest import time_machine from freqtrade.data.dataprovider import DataProvider @@ -38,6 +39,25 @@ def test_worker_running(mocker, default_conf, caplog) -> None: assert isinstance(worker.freqtrade.strategy.dp, DataProvider) +def test_worker_paused(mocker, default_conf, caplog) -> None: + mock_throttle = MagicMock() + mocker.patch("freqtrade.worker.Worker._throttle", mock_throttle) + mocker.patch("freqtrade.persistence.Trade.stoploss_reinitialization", MagicMock()) + + worker = get_patched_worker(mocker, default_conf) + + worker.freqtrade.state = State.PAUSED + state = worker._worker(old_state=State.RUNNING) + + assert state is State.PAUSED + assert log_has("Changing state from RUNNING to: PAUSED", caplog) + assert mock_throttle.call_count == 1 + # Check strategy is loaded, and received a dataprovider object + assert worker.freqtrade.strategy + assert worker.freqtrade.strategy.dp + assert isinstance(worker.freqtrade.strategy.dp, DataProvider) + + def test_worker_stopped(mocker, default_conf, caplog) -> None: mock_throttle = MagicMock() mocker.patch("freqtrade.worker.Worker._throttle", mock_throttle) @@ -50,6 +70,54 @@ def test_worker_stopped(mocker, default_conf, caplog) -> None: assert mock_throttle.call_count == 1 +@pytest.mark.parametrize( + "old_state,target_state,startup_call,log_fragment", + [ + (State.STOPPED, State.PAUSED, True, "Changing state from STOPPED to: PAUSED"), + (State.RUNNING, State.PAUSED, False, "Changing state from RUNNING to: PAUSED"), + (State.PAUSED, State.RUNNING, False, "Changing state from PAUSED to: RUNNING"), + (State.PAUSED, State.STOPPED, False, "Changing state from PAUSED to: STOPPED"), + (State.RELOAD_CONFIG, State.RUNNING, True, "Changing state from RELOAD_CONFIG to: RUNNING"), + ( + State.RELOAD_CONFIG, + State.STOPPED, + False, + "Changing state from RELOAD_CONFIG to: STOPPED", + ), + ], +) +def test_worker_lifecycle( + mocker, + default_conf, + caplog, + old_state, + target_state, + startup_call, + log_fragment, +): + mock_throttle = mocker.MagicMock() + mocker.patch("freqtrade.worker.Worker._throttle", mock_throttle) + mocker.patch("freqtrade.persistence.Trade.stoploss_reinitialization") + startup = mocker.patch("freqtrade.freqtradebot.FreqtradeBot.startup") + + worker = get_patched_worker(mocker, default_conf) + worker.freqtrade.state = target_state + + new_state = worker._worker(old_state=old_state) + + assert new_state is target_state + assert log_has(log_fragment, caplog) + assert mock_throttle.call_count == 1 + assert startup.call_count == (1 if startup_call else 0) + + # For any state where the strategy should be initialized + if target_state in (State.RUNNING, State.PAUSED): + assert worker.freqtrade.strategy + assert isinstance(worker.freqtrade.strategy.dp, DataProvider) + else: + assert new_state is State.STOPPED + + def test_throttle(mocker, default_conf, caplog) -> None: def throttled_func(): return 42 diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index cb67d089e..8b9a0835a 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -806,19 +806,19 @@ def test_rpc_stop(mocker, default_conf) -> None: assert freqtradebot.state == State.STOPPED -def test_rpc_stopentry(mocker, default_conf) -> None: +def test_rpc_pause(mocker, default_conf) -> None: mocker.patch("freqtrade.rpc.telegram.Telegram", MagicMock()) mocker.patch.multiple(EXMS, fetch_ticker=MagicMock()) freqtradebot = get_patched_freqtradebot(mocker, default_conf) patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) - freqtradebot.state = State.RUNNING + freqtradebot.state = State.PAUSED - assert freqtradebot.config["max_open_trades"] != 0 - result = rpc._rpc_stopentry() - assert {"status": "No more entries will occur from now. Run /reload_config to reset."} == result - assert freqtradebot.config["max_open_trades"] == 0 + result = rpc._rpc_pause() + assert { + "status": "paused, no more entries will occur from now. Run /start to enable entries." + } == result def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 99d8350e9..62a054313 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -533,23 +533,26 @@ def test_api_reloadconf(botclient): assert ftbot.state == State.RELOAD_CONFIG -def test_api_stopentry(botclient): +def test_api_pause(botclient): ftbot, client = botclient - assert ftbot.config["max_open_trades"] != 0 - rc = client_post(client, f"{BASE_URI}/stopbuy") + rc = client_post(client, f"{BASE_URI}/pause") assert_response(rc) assert rc.json() == { - "status": "No more entries will occur from now. Run /reload_config to reset." + "status": "paused, no more entries will occur from now. Run /start to enable entries." + } + + rc = client_post(client, f"{BASE_URI}/pause") + assert_response(rc) + assert rc.json() == { + "status": "paused, no more entries will occur from now. Run /start to enable entries." } - assert ftbot.config["max_open_trades"] == 0 rc = client_post(client, f"{BASE_URI}/stopentry") assert_response(rc) assert rc.json() == { - "status": "No more entries will occur from now. Run /reload_config to reset." + "status": "paused, no more entries will occur from now. Run /start to enable entries." } - assert ftbot.config["max_open_trades"] == 0 def test_api_balance(botclient, mocker, rpc_balance, tickers): diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 71ce557e2..3409cc0e5 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -169,7 +169,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: "['stats'], ['daily'], ['weekly'], ['monthly'], " "['count'], ['locks'], ['delete_locks', 'unlock'], " "['reload_conf', 'reload_config'], ['show_conf', 'show_config'], " - "['stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " + "['pause', 'stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " "['bl_delete', 'blacklist_delete'], " "['logs'], ['edge'], ['health'], ['help'], ['version'], ['marketdir'], " "['order'], ['list_custom_data'], ['tg_info']]" @@ -1222,15 +1222,15 @@ async def test_stop_handle_already_stopped(default_conf, update, mocker) -> None assert "already stopped" in msg_mock.call_args_list[0][0][0] -async def test_stopbuy_handle(default_conf, update, mocker) -> None: +async def test_pause_handle(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - assert freqtradebot.config["max_open_trades"] != 0 - await telegram._stopentry(update=update, context=MagicMock()) - assert freqtradebot.config["max_open_trades"] == 0 + assert freqtradebot.state == State.RUNNING + await telegram._pause(update=update, context=MagicMock()) + assert freqtradebot.state == State.PAUSED assert msg_mock.call_count == 1 assert ( - "No more entries will occur from now. Run /reload_config to reset." + "paused, no more entries will occur from now. Run /start to enable entries." in msg_mock.call_args_list[0][0][0] )