Compare commits

...

12 Commits

Author SHA1 Message Date
Matthias
02de1f0a1e Merge pull request #12559 from freqtrade/feat/dry_stop
Enhance dry-run stoploss functionality
2025-11-29 13:44:45 +01:00
Matthias
3453bdf607 chore: bump version to 2025.12-dev 2025-11-29 13:10:16 +01:00
Matthias
ac2723c3a0 test: add explicit test for dry stoploss order filling 2025-11-29 12:23:15 +01:00
Matthias
1536c09df3 test: improved test naming 2025-11-29 12:07:13 +01:00
Matthias
fade66afd9 fix: ensure we always have a price when checking stops 2025-11-28 15:52:30 +01:00
Matthias
f63484d0b0 feat: add dry-limit check for stoploss orders 2025-11-28 15:52:30 +01:00
Matthias
3543e96ec5 refactor: extract dry-market order slippage from function 2025-11-28 15:52:30 +01:00
Matthias
c83ea0db4f chore: fix default behavior for crossed mode 2025-11-28 15:52:30 +01:00
Matthias
ad256367be feat: dry-is-crossed should support stoploss 2025-11-28 15:52:30 +01:00
Matthias
73b427370b test: add tests for dry crossed stoploss 2025-11-28 15:52:30 +01:00
Matthias
ca7234e33f test: fix dry-stop tests 2025-11-28 15:52:30 +01:00
Matthias
cd7b267171 feat: record dry-run stop_price price separately 2025-11-28 15:52:30 +01:00
6 changed files with 150 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
"""Freqtrade bot"""
__version__ = "2025.11-dev"
__version__ = "2025.12-dev"
if "dev" in __version__:
from pathlib import Path

View File

@@ -104,6 +104,7 @@ from freqtrade.misc import (
deep_merge_dicts,
file_dump_json,
file_load_json,
safe_value_fallback,
safe_value_fallback2,
)
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
@@ -1119,6 +1120,7 @@ class Exchange:
leverage: float,
params: dict | None = None,
stop_loss: bool = False,
stop_price: float | None = None,
) -> CcxtOrder:
now = dt_now()
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
@@ -1145,7 +1147,7 @@ class Exchange:
}
if stop_loss:
dry_order["info"] = {"stopPrice": dry_order["price"]}
dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"]
dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"]
# Workaround to avoid filling stoploss orders immediately
dry_order["ft_order_type"] = "stoploss"
orderbook: OrderBook | None = None
@@ -1163,7 +1165,11 @@ class Exchange:
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
# Update market order pricing
average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook)
slippage = 0.05
worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
average = self.get_dry_market_fill_price(
pair, side, amount, rate, worst_rate, orderbook
)
dry_order.update(
{
"average": average,
@@ -1203,7 +1209,13 @@ class Exchange:
return dry_order
def get_dry_market_fill_price(
self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None
self,
pair: str,
side: str,
amount: float,
rate: float,
worst_rate: float,
orderbook: OrderBook | None,
) -> float:
"""
Get the market order fill price based on orderbook interpolation
@@ -1212,8 +1224,6 @@ class Exchange:
if not orderbook:
orderbook = self.fetch_l2_order_book(pair, 20)
ob_type: OBLiteral = "asks" if side == "buy" else "bids"
slippage = 0.05
max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
remaining_amount = amount
filled_value = 0.0
@@ -1237,11 +1247,10 @@ class Exchange:
forecast_avg_filled_price = max(filled_value, 0) / amount
# Limit max. slippage to specified value
if side == "buy":
forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val)
forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate)
else:
forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val)
forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate)
return self.price_to_precision(pair, forecast_avg_filled_price)
return rate
@@ -1253,13 +1262,15 @@ class Exchange:
limit: float,
orderbook: OrderBook | None = None,
offset: float = 0.0,
is_stop: bool = False,
) -> bool:
if not self.exchange_has("fetchL2OrderBook"):
return True
# True unless checking a stoploss order
return not is_stop
if not orderbook:
orderbook = self.fetch_l2_order_book(pair, 1)
try:
if side == "buy":
if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
price = orderbook["asks"][0][0]
if limit * (1 - offset) >= price:
return True
@@ -1278,6 +1289,38 @@ class Exchange:
"""
Check dry-run limit order fill and update fee (if it filled).
"""
if order["status"] != "closed" and order.get("ft_order_type") == "stoploss":
pair = order["symbol"]
if not orderbook and self.exchange_has("fetchL2OrderBook"):
orderbook = self.fetch_l2_order_book(pair, 20)
price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price")
crossed = self._dry_is_price_crossed(
pair, order["side"], price, orderbook, is_stop=True
)
if crossed:
average = self.get_dry_market_fill_price(
pair,
order["side"],
order["amount"],
price,
worst_rate=order["price"],
orderbook=orderbook,
)
order.update(
{
"status": "closed",
"filled": order["amount"],
"remaining": 0,
"average": average,
"cost": order["amount"] * average,
}
)
self.add_dry_order_fee(
pair,
order,
"taker" if immediate else "maker",
)
return order
if (
order["status"] != "closed"
and order["type"] in ["limit"]
@@ -1517,8 +1560,9 @@ class Exchange:
ordertype,
side,
amount,
stop_price_norm,
limit_rate or stop_price_norm,
stop_loss=True,
stop_price=stop_price_norm,
leverage=leverage,
)
return dry_order

View File

@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient
__version__ = "2025.11-dev"
__version__ = "2025.12-dev"
if "dev" in __version__:
from pathlib import Path

View File

@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
assert "type" in order
assert order["type"] == order_type
assert order["price"] == 220
assert order["price"] == 217.8
assert order["stopPrice"] == 220
assert order["amount"] == 1

View File

@@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees(
@pytest.mark.parametrize(
"side,limit,offset,expected",
"side,limit,offset,is_stop,expected",
[
("buy", 46.0, 0.0, True),
("buy", 26.0, 0.0, True),
("buy", 25.55, 0.0, False),
("buy", 1, 0.0, False), # Very far away
("sell", 25.5, 0.0, True),
("sell", 50, 0.0, False), # Very far away
("sell", 25.58, 0.0, False),
("sell", 25.563, 0.01, False),
("sell", 5.563, 0.01, True),
("buy", 46.0, 0.0, False, True),
("buy", 46.0, 0.0, True, False),
("buy", 26.0, 0.0, False, True),
("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
("buy", 25.55, 0.0, False, False),
("buy", 25.55, 0.0, True, True), # Stop - triggered
("buy", 1, 0.0, False, False), # Very far away
("buy", 1, 0.0, True, True), # Current price is above stop - triggered
("sell", 25.5, 0.0, False, True),
("sell", 50, 0.0, False, False), # Very far away
("sell", 25.58, 0.0, False, False),
("sell", 25.563, 0.01, False, False),
("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid
("sell", 25.566, 0.0, True, True), # stop order - triggered
("sell", 26, 0.01, True, True), # stop order - triggered
("sell", 5.563, 0.01, False, True),
("sell", 5.563, 0.0, True, False), # stop order - not triggered
],
)
def test__dry_is_price_crossed_with_orderbook(
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected
default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected
):
# Best bid 25.563
# Best ask 25.566
@@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook(
exchange.fetch_l2_order_book = order_book_l2_usd
orderbook = order_book_l2_usd.return_value
result = exchange._dry_is_price_crossed(
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop
)
assert result is expected
assert order_book_l2_usd.call_count == 0
# Test without passing orderbook
order_book_l2_usd.reset_mock()
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset)
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop)
assert result is expected
@@ -1165,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
exchange.fetch_l2_order_book = MagicMock()
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
assert exchange.fetch_l2_order_book.call_count == 0
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True)
assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True)
@pytest.mark.parametrize(
@@ -1176,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
(False, False, "sell", 1.0, "open", None, 0, None),
],
)
def test_check_dry_limit_order_filled_parametrized(
def test_check_dry_limit_order_filled(
default_conf,
mocker,
crossed,
@@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized(
assert fee_mock.call_count == expected_calls
@pytest.mark.parametrize(
"immediate,crossed,expected_status,expected_fee_type",
[
(True, True, "closed", "taker"),
(False, True, "closed", "maker"),
(True, False, "open", None),
],
)
def test_check_dry_limit_order_filled_stoploss(
default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd
):
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.multiple(
EXMS,
exchange_has=MagicMock(return_value=True),
_dry_is_price_crossed=MagicMock(return_value=crossed),
fetch_l2_order_book=order_book_l2_usd,
)
average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25)
fee_mock = mocker.patch(
f"{EXMS}.add_dry_order_fee",
autospec=True,
side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order,
)
amount = 1.75
order = {
"symbol": "LTC/USDT",
"status": "open",
"type": "limit",
"side": "sell",
"amount": amount,
"filled": 0.0,
"remaining": amount,
"price": 25.0,
"average": 0.0,
"cost": 0.0,
"fee": None,
"ft_order_type": "stoploss",
"stopLossPrice": 24.5,
}
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
assert result["status"] == expected_status
assert order_book_l2_usd.call_count == 1
if crossed:
assert result["filled"] == amount
assert result["remaining"] == 0
assert result["average"] == 24.25
assert result["cost"] == pytest.approx(amount * 24.25)
assert average_mock.call_count == 1
assert fee_mock.call_count == 1
assert fee_mock.call_args[0][1] == "LTC/USDT"
assert fee_mock.call_args[0][3] == expected_fee_type
else:
assert result["filled"] == 0.0
assert result["remaining"] == amount
assert result["average"] == 0.0
assert average_mock.call_count == 0
assert fee_mock.call_count == 0
@pytest.mark.parametrize(
"side,price,filled,converted",
[

View File

@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
assert "type" in order
assert order["type"] == order_type
assert order["price"] == 220
assert order["price"] == 217.8
assert order["stopPrice"] == 220
assert order["amount"] == 1