Merge pull request #11539 from Axel-CH/feat/add-paused-state

Feature: add paused state
This commit is contained in:
Matthias
2025-04-03 06:48:48 +02:00
committed by GitHub
16 changed files with 149 additions and 47 deletions

View File

@@ -1032,6 +1032,7 @@
"type": "string",
"enum": [
"running",
"paused",
"stopped"
]
},

View File

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

View File

@@ -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.<br> *Defaults to `freqtrade`*<br> **Datatype:** String
| `external_message_consumer` | Enable [Producer/Consumer mode](producer-consumer.md) for more details. <br> **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. <br>*Defaults to `stopped`.* <br> **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. <br>*Defaults to `stopped`.* <br> **Datatype:** Enum, either `running`, `paused` or `stopped`
| `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below. <br> **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).<br> *Defaults to `False`*. <br> **Datatype:** Boolean
| `internals.process_throttle_secs` | Set the process throttle, or minimum loop duration for one bot iteration loop. Value in second. <br>*Defaults to `5` seconds.* <br> **Datatype:** Positive Integer

View File

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

View File

@@ -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 ...`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <trade_id>|all:* `Instantly exits the given trade or all trades, "

View File

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

View File

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

View File

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

View File

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

View File

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