Skip to content

Commit 02de1f0

Browse files
authored
Merge pull request freqtrade#12559 from freqtrade/feat/dry_stop
Enhance dry-run stoploss functionality
2 parents 3453bdf + ac2723c commit 02de1f0

File tree

4 files changed

+148
-27
lines changed

4 files changed

+148
-27
lines changed

freqtrade/exchange/exchange.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
deep_merge_dicts,
105105
file_dump_json,
106106
file_load_json,
107+
safe_value_fallback,
107108
safe_value_fallback2,
108109
)
109110
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
@@ -1119,6 +1120,7 @@ def create_dry_run_order(
11191120
leverage: float,
11201121
params: dict | None = None,
11211122
stop_loss: bool = False,
1123+
stop_price: float | None = None,
11221124
) -> CcxtOrder:
11231125
now = dt_now()
11241126
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
@@ -1145,7 +1147,7 @@ def create_dry_run_order(
11451147
}
11461148
if stop_loss:
11471149
dry_order["info"] = {"stopPrice": dry_order["price"]}
1148-
dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"]
1150+
dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"]
11491151
# Workaround to avoid filling stoploss orders immediately
11501152
dry_order["ft_order_type"] = "stoploss"
11511153
orderbook: OrderBook | None = None
@@ -1163,7 +1165,11 @@ def create_dry_run_order(
11631165

11641166
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
11651167
# Update market order pricing
1166-
average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook)
1168+
slippage = 0.05
1169+
worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
1170+
average = self.get_dry_market_fill_price(
1171+
pair, side, amount, rate, worst_rate, orderbook
1172+
)
11671173
dry_order.update(
11681174
{
11691175
"average": average,
@@ -1203,7 +1209,13 @@ def add_dry_order_fee(
12031209
return dry_order
12041210

12051211
def get_dry_market_fill_price(
1206-
self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None
1212+
self,
1213+
pair: str,
1214+
side: str,
1215+
amount: float,
1216+
rate: float,
1217+
worst_rate: float,
1218+
orderbook: OrderBook | None,
12071219
) -> float:
12081220
"""
12091221
Get the market order fill price based on orderbook interpolation
@@ -1212,8 +1224,6 @@ def get_dry_market_fill_price(
12121224
if not orderbook:
12131225
orderbook = self.fetch_l2_order_book(pair, 20)
12141226
ob_type: OBLiteral = "asks" if side == "buy" else "bids"
1215-
slippage = 0.05
1216-
max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
12171227

12181228
remaining_amount = amount
12191229
filled_value = 0.0
@@ -1237,11 +1247,10 @@ def get_dry_market_fill_price(
12371247
forecast_avg_filled_price = max(filled_value, 0) / amount
12381248
# Limit max. slippage to specified value
12391249
if side == "buy":
1240-
forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val)
1250+
forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate)
12411251

12421252
else:
1243-
forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val)
1244-
1253+
forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate)
12451254
return self.price_to_precision(pair, forecast_avg_filled_price)
12461255

12471256
return rate
@@ -1253,13 +1262,15 @@ def _dry_is_price_crossed(
12531262
limit: float,
12541263
orderbook: OrderBook | None = None,
12551264
offset: float = 0.0,
1265+
is_stop: bool = False,
12561266
) -> bool:
12571267
if not self.exchange_has("fetchL2OrderBook"):
1258-
return True
1268+
# True unless checking a stoploss order
1269+
return not is_stop
12591270
if not orderbook:
12601271
orderbook = self.fetch_l2_order_book(pair, 1)
12611272
try:
1262-
if side == "buy":
1273+
if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
12631274
price = orderbook["asks"][0][0]
12641275
if limit * (1 - offset) >= price:
12651276
return True
@@ -1278,6 +1289,38 @@ def check_dry_limit_order_filled(
12781289
"""
12791290
Check dry-run limit order fill and update fee (if it filled).
12801291
"""
1292+
if order["status"] != "closed" and order.get("ft_order_type") == "stoploss":
1293+
pair = order["symbol"]
1294+
if not orderbook and self.exchange_has("fetchL2OrderBook"):
1295+
orderbook = self.fetch_l2_order_book(pair, 20)
1296+
price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price")
1297+
crossed = self._dry_is_price_crossed(
1298+
pair, order["side"], price, orderbook, is_stop=True
1299+
)
1300+
if crossed:
1301+
average = self.get_dry_market_fill_price(
1302+
pair,
1303+
order["side"],
1304+
order["amount"],
1305+
price,
1306+
worst_rate=order["price"],
1307+
orderbook=orderbook,
1308+
)
1309+
order.update(
1310+
{
1311+
"status": "closed",
1312+
"filled": order["amount"],
1313+
"remaining": 0,
1314+
"average": average,
1315+
"cost": order["amount"] * average,
1316+
}
1317+
)
1318+
self.add_dry_order_fee(
1319+
pair,
1320+
order,
1321+
"taker" if immediate else "maker",
1322+
)
1323+
return order
12811324
if (
12821325
order["status"] != "closed"
12831326
and order["type"] in ["limit"]
@@ -1517,8 +1560,9 @@ def create_stoploss(
15171560
ordertype,
15181561
side,
15191562
amount,
1520-
stop_price_norm,
1563+
limit_rate or stop_price_norm,
15211564
stop_loss=True,
1565+
stop_price=stop_price_norm,
15221566
leverage=leverage,
15231567
)
15241568
return dry_order

tests/exchange/test_binance.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
157157
assert "type" in order
158158

159159
assert order["type"] == order_type
160-
assert order["price"] == 220
160+
assert order["price"] == 217.8
161+
assert order["stopPrice"] == 220
161162
assert order["amount"] == 1
162163

163164

tests/exchange/test_exchange.py

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees(
11111111

11121112

11131113
@pytest.mark.parametrize(
1114-
"side,limit,offset,expected",
1114+
"side,limit,offset,is_stop,expected",
11151115
[
1116-
("buy", 46.0, 0.0, True),
1117-
("buy", 26.0, 0.0, True),
1118-
("buy", 25.55, 0.0, False),
1119-
("buy", 1, 0.0, False), # Very far away
1120-
("sell", 25.5, 0.0, True),
1121-
("sell", 50, 0.0, False), # Very far away
1122-
("sell", 25.58, 0.0, False),
1123-
("sell", 25.563, 0.01, False),
1124-
("sell", 5.563, 0.01, True),
1116+
("buy", 46.0, 0.0, False, True),
1117+
("buy", 46.0, 0.0, True, False),
1118+
("buy", 26.0, 0.0, False, True),
1119+
("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
1120+
("buy", 25.55, 0.0, False, False),
1121+
("buy", 25.55, 0.0, True, True), # Stop - triggered
1122+
("buy", 1, 0.0, False, False), # Very far away
1123+
("buy", 1, 0.0, True, True), # Current price is above stop - triggered
1124+
("sell", 25.5, 0.0, False, True),
1125+
("sell", 50, 0.0, False, False), # Very far away
1126+
("sell", 25.58, 0.0, False, False),
1127+
("sell", 25.563, 0.01, False, False),
1128+
("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid
1129+
("sell", 25.566, 0.0, True, True), # stop order - triggered
1130+
("sell", 26, 0.01, True, True), # stop order - triggered
1131+
("sell", 5.563, 0.01, False, True),
1132+
("sell", 5.563, 0.0, True, False), # stop order - not triggered
11251133
],
11261134
)
11271135
def test__dry_is_price_crossed_with_orderbook(
1128-
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected
1136+
default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected
11291137
):
11301138
# Best bid 25.563
11311139
# Best ask 25.566
@@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook(
11341142
exchange.fetch_l2_order_book = order_book_l2_usd
11351143
orderbook = order_book_l2_usd.return_value
11361144
result = exchange._dry_is_price_crossed(
1137-
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset
1145+
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop
11381146
)
11391147
assert result is expected
11401148
assert order_book_l2_usd.call_count == 0
11411149

11421150
# Test without passing orderbook
11431151
order_book_l2_usd.reset_mock()
1144-
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset)
1152+
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop)
11451153
assert result is expected
11461154

11471155

@@ -1165,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
11651173
exchange.fetch_l2_order_book = MagicMock()
11661174
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
11671175
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
1176+
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
11681177
assert exchange.fetch_l2_order_book.call_count == 0
1178+
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True)
1179+
assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True)
11691180

11701181

11711182
@pytest.mark.parametrize(
@@ -1176,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
11761187
(False, False, "sell", 1.0, "open", None, 0, None),
11771188
],
11781189
)
1179-
def test_check_dry_limit_order_filled_parametrized(
1190+
def test_check_dry_limit_order_filled(
11801191
default_conf,
11811192
mocker,
11821193
crossed,
@@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized(
12201231
assert fee_mock.call_count == expected_calls
12211232

12221233

1234+
@pytest.mark.parametrize(
1235+
"immediate,crossed,expected_status,expected_fee_type",
1236+
[
1237+
(True, True, "closed", "taker"),
1238+
(False, True, "closed", "maker"),
1239+
(True, False, "open", None),
1240+
],
1241+
)
1242+
def test_check_dry_limit_order_filled_stoploss(
1243+
default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd
1244+
):
1245+
exchange = get_patched_exchange(mocker, default_conf)
1246+
mocker.patch.multiple(
1247+
EXMS,
1248+
exchange_has=MagicMock(return_value=True),
1249+
_dry_is_price_crossed=MagicMock(return_value=crossed),
1250+
fetch_l2_order_book=order_book_l2_usd,
1251+
)
1252+
average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25)
1253+
fee_mock = mocker.patch(
1254+
f"{EXMS}.add_dry_order_fee",
1255+
autospec=True,
1256+
side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order,
1257+
)
1258+
1259+
amount = 1.75
1260+
order = {
1261+
"symbol": "LTC/USDT",
1262+
"status": "open",
1263+
"type": "limit",
1264+
"side": "sell",
1265+
"amount": amount,
1266+
"filled": 0.0,
1267+
"remaining": amount,
1268+
"price": 25.0,
1269+
"average": 0.0,
1270+
"cost": 0.0,
1271+
"fee": None,
1272+
"ft_order_type": "stoploss",
1273+
"stopLossPrice": 24.5,
1274+
}
1275+
1276+
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
1277+
1278+
assert result["status"] == expected_status
1279+
assert order_book_l2_usd.call_count == 1
1280+
if crossed:
1281+
assert result["filled"] == amount
1282+
assert result["remaining"] == 0
1283+
assert result["average"] == 24.25
1284+
assert result["cost"] == pytest.approx(amount * 24.25)
1285+
assert average_mock.call_count == 1
1286+
assert fee_mock.call_count == 1
1287+
assert fee_mock.call_args[0][1] == "LTC/USDT"
1288+
assert fee_mock.call_args[0][3] == expected_fee_type
1289+
else:
1290+
assert result["filled"] == 0.0
1291+
assert result["remaining"] == amount
1292+
assert result["average"] == 0.0
1293+
1294+
assert average_mock.call_count == 0
1295+
assert fee_mock.call_count == 0
1296+
1297+
12231298
@pytest.mark.parametrize(
12241299
"side,price,filled,converted",
12251300
[

tests/exchange/test_htx.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
123123
assert "type" in order
124124

125125
assert order["type"] == order_type
126-
assert order["price"] == 220
126+
assert order["price"] == 217.8
127+
assert order["stopPrice"] == 220
127128
assert order["amount"] == 1
128129

129130

0 commit comments

Comments
 (0)