mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-14 11:51:19 +00:00
Merge pull request #12559 from freqtrade/feat/dry_stop
Enhance dry-run stoploss functionality
This commit is contained in:
@@ -104,6 +104,7 @@ from freqtrade.misc import (
|
|||||||
deep_merge_dicts,
|
deep_merge_dicts,
|
||||||
file_dump_json,
|
file_dump_json,
|
||||||
file_load_json,
|
file_load_json,
|
||||||
|
safe_value_fallback,
|
||||||
safe_value_fallback2,
|
safe_value_fallback2,
|
||||||
)
|
)
|
||||||
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
|
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
|
||||||
@@ -1119,6 +1120,7 @@ class Exchange:
|
|||||||
leverage: float,
|
leverage: float,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
stop_loss: bool = False,
|
stop_loss: bool = False,
|
||||||
|
stop_price: float | None = None,
|
||||||
) -> CcxtOrder:
|
) -> CcxtOrder:
|
||||||
now = dt_now()
|
now = dt_now()
|
||||||
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
|
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
|
||||||
@@ -1145,7 +1147,7 @@ class Exchange:
|
|||||||
}
|
}
|
||||||
if stop_loss:
|
if stop_loss:
|
||||||
dry_order["info"] = {"stopPrice": dry_order["price"]}
|
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
|
# Workaround to avoid filling stoploss orders immediately
|
||||||
dry_order["ft_order_type"] = "stoploss"
|
dry_order["ft_order_type"] = "stoploss"
|
||||||
orderbook: OrderBook | None = None
|
orderbook: OrderBook | None = None
|
||||||
@@ -1163,7 +1165,11 @@ class Exchange:
|
|||||||
|
|
||||||
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
|
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
|
||||||
# Update market order pricing
|
# 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(
|
dry_order.update(
|
||||||
{
|
{
|
||||||
"average": average,
|
"average": average,
|
||||||
@@ -1203,7 +1209,13 @@ class Exchange:
|
|||||||
return dry_order
|
return dry_order
|
||||||
|
|
||||||
def get_dry_market_fill_price(
|
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:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Get the market order fill price based on orderbook interpolation
|
Get the market order fill price based on orderbook interpolation
|
||||||
@@ -1212,8 +1224,6 @@ class Exchange:
|
|||||||
if not orderbook:
|
if not orderbook:
|
||||||
orderbook = self.fetch_l2_order_book(pair, 20)
|
orderbook = self.fetch_l2_order_book(pair, 20)
|
||||||
ob_type: OBLiteral = "asks" if side == "buy" else "bids"
|
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
|
remaining_amount = amount
|
||||||
filled_value = 0.0
|
filled_value = 0.0
|
||||||
@@ -1237,11 +1247,10 @@ class Exchange:
|
|||||||
forecast_avg_filled_price = max(filled_value, 0) / amount
|
forecast_avg_filled_price = max(filled_value, 0) / amount
|
||||||
# Limit max. slippage to specified value
|
# Limit max. slippage to specified value
|
||||||
if side == "buy":
|
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:
|
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 self.price_to_precision(pair, forecast_avg_filled_price)
|
||||||
|
|
||||||
return rate
|
return rate
|
||||||
@@ -1253,13 +1262,15 @@ class Exchange:
|
|||||||
limit: float,
|
limit: float,
|
||||||
orderbook: OrderBook | None = None,
|
orderbook: OrderBook | None = None,
|
||||||
offset: float = 0.0,
|
offset: float = 0.0,
|
||||||
|
is_stop: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self.exchange_has("fetchL2OrderBook"):
|
if not self.exchange_has("fetchL2OrderBook"):
|
||||||
return True
|
# True unless checking a stoploss order
|
||||||
|
return not is_stop
|
||||||
if not orderbook:
|
if not orderbook:
|
||||||
orderbook = self.fetch_l2_order_book(pair, 1)
|
orderbook = self.fetch_l2_order_book(pair, 1)
|
||||||
try:
|
try:
|
||||||
if side == "buy":
|
if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
|
||||||
price = orderbook["asks"][0][0]
|
price = orderbook["asks"][0][0]
|
||||||
if limit * (1 - offset) >= price:
|
if limit * (1 - offset) >= price:
|
||||||
return True
|
return True
|
||||||
@@ -1278,6 +1289,38 @@ class Exchange:
|
|||||||
"""
|
"""
|
||||||
Check dry-run limit order fill and update fee (if it filled).
|
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 (
|
if (
|
||||||
order["status"] != "closed"
|
order["status"] != "closed"
|
||||||
and order["type"] in ["limit"]
|
and order["type"] in ["limit"]
|
||||||
@@ -1517,8 +1560,9 @@ class Exchange:
|
|||||||
ordertype,
|
ordertype,
|
||||||
side,
|
side,
|
||||||
amount,
|
amount,
|
||||||
stop_price_norm,
|
limit_rate or stop_price_norm,
|
||||||
stop_loss=True,
|
stop_loss=True,
|
||||||
|
stop_price=stop_price_norm,
|
||||||
leverage=leverage,
|
leverage=leverage,
|
||||||
)
|
)
|
||||||
return dry_order
|
return dry_order
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
|
|||||||
assert "type" in order
|
assert "type" in order
|
||||||
|
|
||||||
assert order["type"] == order_type
|
assert order["type"] == order_type
|
||||||
assert order["price"] == 220
|
assert order["price"] == 217.8
|
||||||
|
assert order["stopPrice"] == 220
|
||||||
assert order["amount"] == 1
|
assert order["amount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"side,limit,offset,expected",
|
"side,limit,offset,is_stop,expected",
|
||||||
[
|
[
|
||||||
("buy", 46.0, 0.0, True),
|
("buy", 46.0, 0.0, False, True),
|
||||||
("buy", 26.0, 0.0, True),
|
("buy", 46.0, 0.0, True, False),
|
||||||
("buy", 25.55, 0.0, False),
|
("buy", 26.0, 0.0, False, True),
|
||||||
("buy", 1, 0.0, False), # Very far away
|
("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
|
||||||
("sell", 25.5, 0.0, True),
|
("buy", 25.55, 0.0, False, False),
|
||||||
("sell", 50, 0.0, False), # Very far away
|
("buy", 25.55, 0.0, True, True), # Stop - triggered
|
||||||
("sell", 25.58, 0.0, False),
|
("buy", 1, 0.0, False, False), # Very far away
|
||||||
("sell", 25.563, 0.01, False),
|
("buy", 1, 0.0, True, True), # Current price is above stop - triggered
|
||||||
("sell", 5.563, 0.01, True),
|
("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(
|
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 bid 25.563
|
||||||
# Best ask 25.566
|
# 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
|
exchange.fetch_l2_order_book = order_book_l2_usd
|
||||||
orderbook = order_book_l2_usd.return_value
|
orderbook = order_book_l2_usd.return_value
|
||||||
result = exchange._dry_is_price_crossed(
|
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 result is expected
|
||||||
assert order_book_l2_usd.call_count == 0
|
assert order_book_l2_usd.call_count == 0
|
||||||
|
|
||||||
# Test without passing orderbook
|
# Test without passing orderbook
|
||||||
order_book_l2_usd.reset_mock()
|
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
|
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()
|
exchange.fetch_l2_order_book = MagicMock()
|
||||||
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
|
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", "buy", 1.0)
|
||||||
|
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
|
||||||
assert exchange.fetch_l2_order_book.call_count == 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(
|
@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),
|
(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,
|
default_conf,
|
||||||
mocker,
|
mocker,
|
||||||
crossed,
|
crossed,
|
||||||
@@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized(
|
|||||||
assert fee_mock.call_count == expected_calls
|
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(
|
@pytest.mark.parametrize(
|
||||||
"side,price,filled,converted",
|
"side,price,filled,converted",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
|
|||||||
assert "type" in order
|
assert "type" in order
|
||||||
|
|
||||||
assert order["type"] == order_type
|
assert order["type"] == order_type
|
||||||
assert order["price"] == 220
|
assert order["price"] == 217.8
|
||||||
|
assert order["stopPrice"] == 220
|
||||||
assert order["amount"] == 1
|
assert order["amount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user