Merge pull request #12559 from freqtrade/feat/dry_stop

Enhance dry-run stoploss functionality
This commit is contained in:
Matthias
2025-11-29 13:44:45 +01:00
committed by GitHub
4 changed files with 148 additions and 27 deletions

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

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