Skip to content

Commit 57914f0

Browse files
committed
Fix Binance Futures trailing stop market orders
1 parent 310707a commit 57914f0

File tree

10 files changed

+94
-35
lines changed

10 files changed

+94
-35
lines changed

RELEASES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ None
1818
- Fixed DataFusion streaming backend mem usage (now constant mem usage) (#1693), thanks @twitu
1919
- Fixed `OrderBookDeltaDataWrangler` snapshot parsing (was not prepending a `CLEAR` action), thanks for reporting @VeraLyu
2020
- Fixed `Instrument.make_price` and `make_qty` when increments have a lower precision (was not rounding to the minimum increment)
21+
- Fixed `EMACrossTrailingStop` example strategy trailing stop logic (could submit multiple trailing stops on partial fills)
22+
- Fixed Binance `TRAILING_STOP_MARKET` orders (callback rounding was incorrect, was also not handling updates)
2123
- Fixed Interactive Brokers multiple gateway clients (incorrect port handling in factory) (#1702), thanks @dodofarm
2224

2325
---

docs/integrations/binance.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,22 @@ E.g. for Binance Futures, the said instruments symbol is `BTCUSDT-PERP` within t
5656

5757
### Trailing stops
5858

59-
Binance use the concept of an *activation price* for trailing stops ([see docs](https://www.binance.com/en-AU/support/faq/what-is-a-trailing-stop-order-360042299292)).
60-
To get trailing stop orders working for Binance we need to use the `trigger_price` value to set the *activation price*.
59+
Binance uses the concept of an activation price for trailing stops, as detailed in their [documentation](https://www.binance.com/en-AU/support/faq/what-is-a-trailing-stop-order-360042299292).
60+
This approach is somewhat unconventional. For trailing stop orders to function on Binance, the activation price can optionally be set using the `trigger_price` value.
6161

62-
For `TRAILING_STOP_MARKET` orders to be submitted successfully, you must define the following:
63-
- Specify a `trailing_offet_type` of either `DEFAULT` or `BASIS_POINTS`
64-
- Specify the `trailing_offset` in basis points (% * 100) e.g. for a callback rate of 1% use 100
62+
Note that the activation price is **not** the same as the trigger/STOP price. Binance will always calculate the trigger price for the order based on the current market price and the callback rate provided by `trailing_offset`.
63+
The activated price is simply the price at which the order will begin trailing based on the callback rate.
64+
65+
When submitting trailing stop orders from your strategy, you have two options:
66+
67+
1. Use the `trigger_price` to manually set the activation price.
68+
2. Leave the `trigger_price` as `None`, making the trailing action immediately "active".
6569

6670
You must also have at least *one* of the following:
6771

6872
- The `trigger_price` for the order is set (this will act as the Binance *activation_price*)
69-
- You have subscribed to quote ticks for the instrument you're submitting the order for (used to infer activation price)
70-
- You have subscribed to trade ticks for the instrument you're submitting the order for (used to infer activation price)
73+
- (or) you have subscribed to quote ticks for the instrument you're submitting the order for (used to infer activation price)
74+
- (or) you have subscribed to trade ticks for the instrument you're submitting the order for (used to infer activation price)
7175

7276
## Configuration
7377

examples/live/binance/binance_futures_testnet_ema_cross_with_trailing_stop.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
# Configure the trading node
4141
config_node = TradingNodeConfig(
4242
trader_id=TraderId("TESTER-001"),
43-
logging=LoggingConfig(log_level="INFO"),
43+
logging=LoggingConfig(log_level="INFO", use_pyo3=True),
4444
exec_engine=LiveExecEngineConfig(
4545
reconciliation=True,
4646
reconciliation_lookback_mins=1440,
@@ -80,10 +80,11 @@
8080
node = TradingNode(config=config_node)
8181

8282
# Configure your strategy
83+
symbol = "ETHUSDT-PERP"
8384
strat_config = EMACrossTrailingStopConfig(
84-
instrument_id=InstrumentId.from_str("ETHUSDT-PERP.BINANCE"),
85-
external_order_claims=[InstrumentId.from_str("ETHUSDT-PERP.BINANCE")],
86-
bar_type=BarType.from_str("ETHUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL"),
85+
instrument_id=InstrumentId.from_str(f"{symbol}.BINANCE"),
86+
external_order_claims=[InstrumentId.from_str(f"{symbol}.BINANCE")],
87+
bar_type=BarType.from_str(f"{symbol}.BINANCE-1-MINUTE-LAST-EXTERNAL"),
8788
fast_ema_period=10,
8889
slow_ema_period=20,
8990
atr_period=20,

nautilus_trader/adapters/binance/common/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@
1313
# limitations under the License.
1414
# -------------------------------------------------------------------------------------------------
1515

16+
from decimal import Decimal
1617
from typing import Final
1718

1819
from nautilus_trader.model.identifiers import Venue
1920

2021

2122
BINANCE_VENUE: Final[Venue] = Venue("BINANCE")
23+
24+
BINANCE_MIN_CALLBACK_RATE: Final[Decimal] = Decimal("0.1")
25+
BINANCE_MAX_CALLBACK_RATE: Final[Decimal] = Decimal("10.0")

nautilus_trader/adapters/binance/common/enums.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class BinanceTimeInForce(Enum):
207207
GTC = "GTC"
208208
IOC = "IOC"
209209
FOK = "FOK"
210-
GTX = "GTX" # FUTURES only, Good Till Crossing (Post Only)
210+
GTX = "GTX" # FUTURES only, Good-Till-Crossing (Post Only)
211211
GTD = "GTD" # FUTURES only
212212
GTE_GTC = "GTE_GTC" # Undocumented
213213

@@ -312,6 +312,7 @@ class BinanceErrorCode(Enum):
312312
INVALID_PARAMETER = -1130
313313
INVALID_NEW_ORDER_RESP_TYPE = -1136
314314

315+
INVALID_CALLBACK_RATE = -2007
315316
NEW_ORDER_REJECTED = -2010
316317
CANCEL_REJECTED = -2011
317318
CANCEL_ALL_FAIL = -2012

nautilus_trader/adapters/binance/common/execution.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import pandas as pd
2020

21+
from nautilus_trader.adapters.binance.common.constants import BINANCE_MAX_CALLBACK_RATE
22+
from nautilus_trader.adapters.binance.common.constants import BINANCE_MIN_CALLBACK_RATE
2123
from nautilus_trader.adapters.binance.common.constants import BINANCE_VENUE
2224
from nautilus_trader.adapters.binance.common.enums import BinanceAccountType
2325
from nautilus_trader.adapters.binance.common.enums import BinanceEnumParser
@@ -775,16 +777,28 @@ async def _submit_trailing_stop_market_order(self, order: TrailingStopMarketOrde
775777
)
776778
return
777779

780+
# Convert basis points to percentage rounded to 1 decimal place
781+
callback_rate = Decimal(f"{order.trailing_offset / 100:.1f}")
782+
783+
if callback_rate < BINANCE_MIN_CALLBACK_RATE or callback_rate > BINANCE_MAX_CALLBACK_RATE:
784+
self._log.error(
785+
f"Cannot submit order: invalid `order.trailing_offset`, was "
786+
f"{order.trailing_offset} {trailing_offset_type_to_str(order.trailing_offset_type)} "
787+
f"rounded to {callback_rate}%, "
788+
f"must in range [{BINANCE_MIN_CALLBACK_RATE}, {BINANCE_MAX_CALLBACK_RATE}]",
789+
)
790+
return
791+
778792
# Ensure activation price
779793
activation_price: Price | None = order.trigger_price
780794
if not activation_price:
781795
quote = self._cache.quote_tick(order.instrument_id)
782796
trade = self._cache.trade_tick(order.instrument_id)
783797
if quote:
784798
if order.side == OrderSide.BUY:
785-
activation_price = quote.ask_price
786-
elif order.side == OrderSide.SELL:
787799
activation_price = quote.bid_price
800+
elif order.side == OrderSide.SELL:
801+
activation_price = quote.ask_price
788802
elif trade:
789803
activation_price = trade.price
790804
else:
@@ -802,7 +816,7 @@ async def _submit_trailing_stop_market_order(self, order: TrailingStopMarketOrde
802816
good_till_date=self._determine_good_till_date(order, time_in_force),
803817
quantity=str(order.quantity),
804818
activation_price=str(activation_price),
805-
callback_rate=str(order.trailing_offset / 100),
819+
callback_rate=str(callback_rate),
806820
working_type=working_type,
807821
reduce_only=self._determine_reduce_only_str(order),
808822
new_client_order_id=order.client_order_id.value,

nautilus_trader/adapters/binance/futures/execution.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# -------------------------------------------------------------------------------------------------
1515

1616
import asyncio
17+
import json
1718
from decimal import Decimal
1819

1920
import msgspec
@@ -257,7 +258,7 @@ async def _batch_cancel_orders(self, command: BatchCancelOrders) -> None:
257258

258259
def _handle_user_ws_message(self, raw: bytes) -> None:
259260
# TODO: Uncomment for development
260-
# self._log.info(str(json.dumps(msgspec.json.decode(raw), indent=4)), color=LogColor.MAGENTA)
261+
self._log.info(str(json.dumps(msgspec.json.decode(raw), indent=4)), color=LogColor.MAGENTA)
261262
wrapper = self._decoder_futures_user_msg_wrapper.decode(raw)
262263
if not wrapper.stream or not wrapper.data:
263264
# Control message response

nautilus_trader/adapters/binance/futures/schemas/user.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from nautilus_trader.model.enums import LiquiditySide
3636
from nautilus_trader.model.enums import OrderSide
3737
from nautilus_trader.model.enums import OrderStatus
38+
from nautilus_trader.model.enums import OrderType
3839
from nautilus_trader.model.enums import TrailingOffsetType
3940
from nautilus_trader.model.identifiers import AccountId
4041
from nautilus_trader.model.identifiers import ClientOrderId
@@ -279,6 +280,18 @@ def handle_order_trade_update( # noqa: C901 (too complex)
279280
venue_order_id = VenueOrderId(str(self.i))
280281
instrument_id = exec_client._get_cached_instrument_id(self.s)
281282
strategy_id = exec_client._cache.strategy_id_for_order(client_order_id)
283+
284+
instrument = exec_client._instrument_provider.find(instrument_id=instrument_id)
285+
if instrument is None:
286+
raise ValueError(f"Cannot handle trade: instrument {instrument_id} not found")
287+
288+
price_precision = instrument.price_precision
289+
size_precision = instrument.size_precision
290+
291+
order = exec_client._cache.order(client_order_id)
292+
if not order:
293+
exec_client._log.error(f"Cannot find order {client_order_id!r}")
294+
282295
if strategy_id is None:
283296
report = self.parse_to_order_status_report(
284297
account_id=exec_client.account_id,
@@ -291,6 +304,9 @@ def handle_order_trade_update( # noqa: C901 (too complex)
291304
)
292305
exec_client._send_order_status_report(report)
293306
elif self.x == BinanceExecutionType.NEW:
307+
if order.order_type == OrderType.TRAILING_STOP_MARKET and order.is_open:
308+
return # Already accepted: this is an update
309+
294310
exec_client.generate_order_accepted(
295311
strategy_id=strategy_id,
296312
instrument_id=instrument_id,
@@ -299,10 +315,6 @@ def handle_order_trade_update( # noqa: C901 (too complex)
299315
ts_event=ts_event,
300316
)
301317
elif self.x == BinanceExecutionType.TRADE:
302-
instrument = exec_client._instrument_provider.find(instrument_id=instrument_id)
303-
if instrument is None:
304-
raise ValueError(f"Cannot handle trade: instrument {instrument_id} not found")
305-
306318
# Determine commission
307319
commission_asset: str | None = self.N
308320
commission_amount: str | None = self.n
@@ -325,8 +337,8 @@ def handle_order_trade_update( # noqa: C901 (too complex)
325337
trade_id=TradeId(str(self.t)), # Trade ID
326338
order_side=exec_client._enum_parser.parse_binance_order_side(self.S),
327339
order_type=exec_client._enum_parser.parse_binance_order_type(self.o),
328-
last_qty=Quantity(float(self.l), instrument.size_precision),
329-
last_px=Price(float(self.L), instrument.price_precision),
340+
last_qty=Quantity(float(self.l), size_precision),
341+
last_px=Price(float(self.L), price_precision),
330342
quote_currency=instrument.quote_currency,
331343
commission=commission,
332344
liquidity_side=LiquiditySide.MAKER if self.m else LiquiditySide.TAKER,
@@ -343,28 +355,43 @@ def handle_order_trade_update( # noqa: C901 (too complex)
343355
ts_event=ts_event,
344356
)
345357
elif self.x == BinanceExecutionType.AMENDMENT:
346-
instrument = exec_client._instrument_provider.find(instrument_id=instrument_id)
347-
if instrument is None:
348-
raise ValueError(f"Cannot handle amendment: instrument {instrument_id} not found")
349-
350358
exec_client.generate_order_updated(
351359
strategy_id=strategy_id,
352360
instrument_id=instrument_id,
353361
client_order_id=client_order_id,
354362
venue_order_id=venue_order_id,
355-
quantity=Quantity(float(self.q), instrument.size_precision),
356-
price=Price(float(self.p), instrument.price_precision),
363+
quantity=Quantity(float(self.q), size_precision),
364+
price=Price(float(self.p), price_precision),
357365
trigger_price=None,
358366
ts_event=ts_event,
359367
)
360368
elif self.x == BinanceExecutionType.EXPIRED:
361-
exec_client.generate_order_expired(
362-
strategy_id=strategy_id,
363-
instrument_id=instrument_id,
364-
client_order_id=client_order_id,
365-
venue_order_id=venue_order_id,
366-
ts_event=ts_event,
367-
)
369+
instrument = exec_client._instrument_provider.find(instrument_id=instrument_id)
370+
if instrument is None:
371+
raise ValueError(f"Cannot handle amendment: instrument {instrument_id} not found")
372+
373+
price_precision = instrument.price_precision
374+
size_precision = instrument.size_precision
375+
376+
if order.order_type == OrderType.TRAILING_STOP_MARKET:
377+
exec_client.generate_order_updated(
378+
strategy_id=strategy_id,
379+
instrument_id=instrument_id,
380+
client_order_id=client_order_id,
381+
venue_order_id=venue_order_id,
382+
quantity=Quantity(float(self.q), size_precision),
383+
price=Price(float(self.p), price_precision),
384+
trigger_price=(Price(float(self.sp), price_precision) if self.sp else None),
385+
ts_event=ts_event,
386+
)
387+
else:
388+
exec_client.generate_order_expired(
389+
strategy_id=strategy_id,
390+
instrument_id=instrument_id,
391+
client_order_id=client_order_id,
392+
venue_order_id=venue_order_id,
393+
ts_event=ts_event,
394+
)
368395
else:
369396
# Event not handled
370397
exec_client._log.warning(f"Received unhandled {self}")

nautilus_trader/adapters/binance/http/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from nautilus_trader.adapters.binance.http.error import BinanceServerError
2626
from nautilus_trader.common.component import LiveClock
2727
from nautilus_trader.common.component import Logger
28+
from nautilus_trader.common.enums import LogColor
2829
from nautilus_trader.core.nautilus_pyo3 import HttpClient
2930
from nautilus_trader.core.nautilus_pyo3 import HttpMethod
3031
from nautilus_trader.core.nautilus_pyo3 import HttpResponse
@@ -151,6 +152,8 @@ async def send_request(
151152
url_path += "?" + urllib.parse.urlencode(payload)
152153
payload = None # Don't send payload in the body
153154

155+
self._log.debug(f"{url_path} {payload}", LogColor.MAGENTA)
156+
154157
response: HttpResponse = await self._client.request(
155158
http_method,
156159
url=self._base_url + url_path,

nautilus_trader/examples/strategies/ema_cross_trailing_stop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ def on_event(self, event: Event) -> None:
366366
if self.trailing_stop and event.client_order_id == self.trailing_stop.client_order_id:
367367
self.trailing_stop = None
368368
elif isinstance(event, PositionOpened | PositionChanged):
369+
if self.trailing_stop:
370+
return # Already a trailing stop
369371
if self.entry and event.opening_order_id == self.entry.client_order_id:
370372
if event.entry == OrderSide.BUY:
371373
self.position_id = event.position_id

0 commit comments

Comments
 (0)