Bybit TradFi (MT5)

Bybit TradFi provides access to traditional financial markets — Forex, Gold, Indices, and Stocks — through a MetaTrader 5 terminal. Unlike other NexusTrader connectors, it does not use WebSockets; all market data is obtained by polling the MT5 Python API from a dedicated single-threaded executor.

Note

The MetaTrader5 Python package is Windows only. This connector cannot be used on Linux or macOS.

Prerequisites

  1. A Bybit TradFi account (Demo or Live). Register at bybit.com/en/trade/tradfi/.

  2. MetaTrader5 terminal installed on Windows and logged in to your broker.

  3. The MetaTrader5 Python package:

    uv add MetaTrader5          # recommended
    # or
    pip install MetaTrader5
    

Account Types

from nexustrader.exchange.bybit_tradfi import BybitTradeFiAccountType

account_type = BybitTradeFiAccountType.DEMO   # demo / paper account
account_type = BybitTradeFiAccountType.LIVE    # live account

Symbol Format

MT5 symbol names are mapped to the NexusTrader symbol.EXCHANGE format. Leading # / . broker prefixes are stripped and any internal dots are replaced with underscores so they do not conflict with the .BYBIT_TRADFI exchange suffix:

MT5 Symbol

NexusTrader Symbol

EURUSD

EURUSD.BYBIT_TRADFI

XAUUSD.s

XAUUSD_s.BYBIT_TRADFI

TSLA.s

TSLA_s.BYBIT_TRADFI

US500

US500.BYBIT_TRADFI

#AAPL

AAPL.BYBIT_TRADFI

Always use the NexusTrader symbol in strategy APIs and cache lookups. For example, if MT5 shows XAUUSD.s, use XAUUSD_s.BYBIT_TRADFI:

book = self.cache.bookl1("XAUUSD_s.BYBIT_TRADFI")
position = self.cache.get_position("XAUUSD_s.BYBIT_TRADFI")

Do not pass the raw MT5 symbol to cache methods:

# Wrong: raw MT5 symbols are not cache keys
position = self.cache.get_position("XAUUSD.s")

Credentials

Add your MT5 account details to .secrets.toml in the project root:

[BYBIT_TRADFI.DEMO]
API_KEY    = "12345678"           # MT5 account login number
SECRET     = "your_password"     # MT5 account password
PASSPHRASE = "BybitBroker-Demo"  # MT5 broker server name

Then load them in your strategy:

from nexustrader.constants import settings

MT5_LOGIN    = settings.BYBIT_TRADFI.DEMO.API_KEY
MT5_PASSWORD = settings.BYBIT_TRADFI.DEMO.SECRET
MT5_SERVER   = settings.BYBIT_TRADFI.DEMO.PASSPHRASE

Configuration

from nexustrader.config import (
    BasicConfig, Config, LogConfig,
    PrivateConnectorConfig, PublicConnectorConfig,
)
from nexustrader.constants import ExchangeType, settings
from nexustrader.engine import Engine
from nexustrader.exchange.bybit_tradfi import BybitTradeFiAccountType
from nexustrader.strategy import Strategy

MT5_LOGIN    = settings.BYBIT_TRADFI.DEMO.API_KEY
MT5_PASSWORD = settings.BYBIT_TRADFI.DEMO.SECRET
MT5_SERVER   = settings.BYBIT_TRADFI.DEMO.PASSPHRASE

class MyStrategy(Strategy):
    ...

config = Config(
    strategy_id="tradfi_strategy",
    user_id="user_test",
    strategy=MyStrategy(),
    log_config=LogConfig(level_stdout="INFO"),
    basic_config={
        ExchangeType.BYBIT_TRADFI: BasicConfig(
            api_key=MT5_LOGIN,
            secret=MT5_PASSWORD,
            passphrase=MT5_SERVER,
            testnet=True,        # True = DEMO account
        )
    },
    public_conn_config={
        ExchangeType.BYBIT_TRADFI: [
            PublicConnectorConfig(account_type=BybitTradeFiAccountType.DEMO)
        ]
    },
    private_conn_config={
        ExchangeType.BYBIT_TRADFI: [
            PrivateConnectorConfig(account_type=BybitTradeFiAccountType.DEMO)
        ]
    },
)

engine = Engine(config)

if __name__ == "__main__":
    try:
        engine.start()
    finally:
        engine.dispose()

Tick Poll Interval

Because the MT5 connector uses polling rather than a WebSocket push feed, you can tune the tick polling interval via PublicConnectorConfig.tick_poll_interval (in seconds). The default is 10 ms (0.01).

# Default: 10 ms polling
PublicConnectorConfig(account_type=BybitTradeFiAccountType.DEMO)

# Custom: 20 ms polling
PublicConnectorConfig(account_type=BybitTradeFiAccountType.DEMO, tick_poll_interval=0.02)

# Aggressive: 5 ms polling (higher CPU load)
PublicConnectorConfig(account_type=BybitTradeFiAccountType.DEMO, tick_poll_interval=0.005)

Note

Each symbol_info_tick() call to MT5 takes roughly 15–60 µs over the local named-pipe IPC. A 10 ms interval is well within the practical limit and gives an average tick-detection latency of ~5 ms. Going below 5 ms yields diminishing returns because retail broker tick feeds typically arrive at 2–10 ticks/second under normal market conditions.

Market Data

Historical klines

Call request_klines() inside on_start() to load historical bars before subscribing to live data:

from nexustrader.constants import KlineInterval
from nexustrader.exchange.bybit_tradfi import BybitTradeFiAccountType

def on_start(self):
    klines = self.request_klines(
        symbol="EURUSD.BYBIT_TRADFI",
        interval=KlineInterval.MINUTE_1,
        limit=100,
        account_type=BybitTradeFiAccountType.DEMO,
    )
    self.log.info(f"Loaded {len(klines)} bars")
    self.log.info(f"\n{klines.df.tail(5).to_string()}")

Live subscriptions

from nexustrader.schema import BookL1, Kline, Trade

def on_start(self):
    self.subscribe_bookl1(symbols="EURUSD.BYBIT_TRADFI", ready=False)
    self.subscribe_trade(symbols="EURUSD.BYBIT_TRADFI", ready=False)
    self.subscribe_kline(
        symbols="EURUSD.BYBIT_TRADFI",
        interval=KlineInterval.MINUTE_1,
        ready=False,
    )

def on_bookl1(self, bookl1: BookL1):
    self.log.info(f"bid={bookl1.bid}  ask={bookl1.ask}  spread={bookl1.spread}")

def on_trade(self, trade: Trade):
    self.log.info(f"price={trade.price}  side={trade.side.value}")

def on_kline(self, kline: Kline):
    if kline.confirm:
        self.log.info(f"Closed  O={kline.open} H={kline.high} L={kline.low} C={kline.close}")

Note

Kline events are emitted on bar open, when the close price changes within the current bar, and once on bar close (kline.confirm = True). They are not emitted on every poll tick.

Trading

Order placement

Use trigger="date" with run_date for one-shot scheduled steps. trigger="interval" fires repeatedly and will place multiple orders:

from datetime import datetime, timedelta
from decimal import Decimal
from nexustrader.constants import OrderSide, OrderType

def on_start(self):
    self.subscribe_bookl1(symbols="XAUUSD_s.BYBIT_TRADFI", ready=False)
    now = datetime.now()
    self.schedule(self._place_limit,  trigger="date", run_date=now + timedelta(seconds=5))
    self.schedule(self._cancel_limit, trigger="date", run_date=now + timedelta(seconds=15))
    self.schedule(self._place_market, trigger="date", run_date=now + timedelta(seconds=20))

def _place_limit(self):
    book = self.cache.bookl1("XAUUSD_s.BYBIT_TRADFI")
    price = self.price_to_precision("XAUUSD_s.BYBIT_TRADFI", book.bid * 0.98)
    self._oid = self.create_order(
        symbol="XAUUSD_s.BYBIT_TRADFI",
        side=OrderSide.BUY,
        type=OrderType.LIMIT,
        amount=Decimal("0.01"),
        price=price,
    )

def _cancel_limit(self):
    open_orders = self.cache.get_open_orders("XAUUSD_s.BYBIT_TRADFI")
    if self._oid and self._oid in open_orders:
        self.cancel_order(symbol="XAUUSD_s.BYBIT_TRADFI", oid=self._oid)

def _place_market(self):
    self.create_order(
        symbol="XAUUSD_s.BYBIT_TRADFI",
        side=OrderSide.BUY,
        type=OrderType.MARKET,
        amount=Decimal("0.01"),
    )

Fresh-price limit orders (price_type)

When placing a limit order on MT5, the price your strategy computed from the last on_bookl1 event may be slightly stale by the time the order reaches the broker. Pass the price_type keyword to create_order or create_order_ws to have the OMS call symbol_info_tick() at submission time and use the freshest available quote:

price_type

Price used for the limit order

(not set)

The price value you passed (default behaviour)

"bid"

Latest MT5 best bid, re-fetched at submission time

"ask"

Latest MT5 best ask, re-fetched at submission time

"opponent"

Best ask for a buy order, best bid for a sell order (taker-side price)

# Join the bid — price re-fetched from MT5 when the order is sent
self.create_order(
    symbol="EURUSD.BYBIT_TRADFI",
    side=OrderSide.BUY,
    type=OrderType.LIMIT,
    amount=Decimal("0.1"),
    price_type="bid",
)

# Cross the spread (opponent price) — most aggressive limit
self.create_order(
    symbol="EURUSD.BYBIT_TRADFI",
    side=OrderSide.BUY,
    type=OrderType.LIMIT,
    amount=Decimal("0.1"),
    price_type="opponent",
)

Warning

price_type is only supported for Bybit TradFi (MT5) limit orders. Passing it to any other exchange connector (Binance, Bybit crypto, OKX, etc.) has no effect — the price you supply will be used unchanged.

Order callbacks

from nexustrader.schema import Order

def on_pending_order(self, order: Order):
    self.log.info(f"Pending  oid={order.oid}  {order.side.value} {order.amount} @ {order.price}")

def on_accepted_order(self, order: Order):
    self.log.info(f"Accepted oid={order.oid}")

def on_filled_order(self, order: Order):
    self.log.info(f"Filled   oid={order.oid}  avg={order.average}")

def on_canceled_order(self, order: Order):
    self.log.info(f"Canceled oid={order.oid}")

def on_failed_order(self, order: Order):
    self.log.error(f"Failed   oid={order.oid}  reason={order.reason}")

Position cache

Bybit TradFi runs through a local MT5 terminal process, so NexusTrader does not receive pushed position updates from a private exchange WebSocket. The OMS polls MT5 positions_get() and refreshes the NexusTrader cache automatically after connect, periodically while running, and after immediate market fills or pending-order close events.

Read positions with the NexusTrader symbol:

from nexustrader.constants import PositionSide

position = self.cache.get_position("XAUUSD_s.BYBIT_TRADFI")
if position is None:
    self.log.info("No open XAUUSD_s position")
elif position.side == PositionSide.LONG:
    self.log.info(f"Long amount={position.amount}")
elif position.side == PositionSide.SHORT:
    self.log.info(f"Short amount={position.amount}")

Position.side is a position direction and uses PositionSide.LONG / PositionSide.SHORT / PositionSide.FLAT. This is intentionally different from order direction, which uses OrderSide.BUY / OrderSide.SELL.

Examples

Full runnable examples are in strategy/bybit_tradfi/:

  • demo_market_data.py — historical klines + live BookL1 / Trade / Kline

  • demo_trading.py — limit order → cancel → market order lifecycle