Add BingX sandbox status sidecar

This commit is contained in:
Codex
2026-05-13 19:56:58 +02:00
parent 0d70c767e4
commit 34d01fe6a4
5 changed files with 2089 additions and 60 deletions

View File

@@ -0,0 +1,559 @@
"""
test_bingx_nautilus_execution.py
================================
End-to-end tests for the Nautilus execution path:
engine.step_bar() -> _exec_submit_entry() -> cache.instrument() -> order_factory -> submit_order
Tests cover:
1. Instrument registration from exec client into Nautilus cache
2. _exec_submit_entry returns early when instrument missing
3. _exec_submit_entry succeeds when instrument is in cache
4. Data-venue fallback when exec-venue instrument not available
5. Full order payload correctness (tags, side, quantity precision)
6. Venue symbol mapping uses raw_symbol from cached instrument
7. _venue_symbol fallback when instrument not in cache
8. Integration: build_actor_config with split venues
"""
from __future__ import annotations
import asyncio
import math
import sys
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "nautilus_dolphin"))
from nautilus_trader.model.enums import OrderSide, OrderType
from nautilus_trader.model.identifiers import InstrumentId
from nautilus_trader.model.objects import Currency, Price, Quantity
from prod.bingx.enums import BINGX_VENUE, BingxEnvironment
from prod.bingx.execution import BingxExecutionClient
from prod.bingx.sandbox_status import build_sandbox_status
from prod.bingx.sandbox_status import write_sandbox_status
# -- Helpers -------------------------------------------------------------------
def _make_bingx_instrument(symbol: str = "BTCUSDT"):
return SimpleNamespace(
id=InstrumentId.from_str(f"{symbol}.BINGX"),
instrument_id=InstrumentId.from_str(f"{symbol}.BINGX"),
symbol=SimpleNamespace(value=symbol),
raw_symbol=SimpleNamespace(value=f"{symbol[:-4]}-USDT"),
base_currency=Currency.from_str(symbol[:3]),
quote_currency=Currency.from_str("USDT"),
size_precision=3,
price_precision=2,
maker_fee=Decimal("0.0002"),
taker_fee=Decimal("0.0005"),
)
def _make_binance_instrument(symbol: str = "BTCUSDT"):
return SimpleNamespace(
id=InstrumentId.from_str(f"{symbol}.BINANCE"),
instrument_id=InstrumentId.from_str(f"{symbol}.BINANCE"),
symbol=SimpleNamespace(value=symbol),
raw_symbol=SimpleNamespace(value=symbol),
base_currency=Currency.from_str(symbol[:3]),
quote_currency=Currency.from_str("USDT"),
size_precision=3,
price_precision=2,
maker_fee=Decimal("0.0002"),
taker_fee=Decimal("0.0005"),
)
class FakeCache:
def __init__(self, instruments=None):
self._instruments = dict(instruments or {})
def instrument(self, instrument_id):
return self._instruments.get(instrument_id)
def add_instrument(self, instrument):
self._instruments[instrument.id] = instrument
def instruments(self):
return list(self._instruments.values())
def positions(self, venue=None):
return []
def order(self, client_order_id):
return None
def add_currency(self, currency):
pass
class FakeProvider:
def __init__(self, instruments=None):
self._instruments = list(instruments or [])
def list_all(self):
return self._instruments
def currencies(self):
return {}
async def initialize(self):
pass
async def _noop(*args, **kwargs):
pass
async def _persist_sandbox_status(client, *, environment: str = "VST", notes: dict[str, Any] | None = None):
balance = await client.signed_get("/openApi/swap/v2/user/balance")
positions = await client.signed_get("/openApi/swap/v2/user/positions")
open_orders = await client.signed_get("/openApi/swap/v2/trade/openOrders")
status = build_sandbox_status(
balance_payload=balance,
positions_payload=positions,
open_orders_payload=open_orders,
environment=environment,
notes=notes or {},
)
write_sandbox_status(status)
return status
def _make_connect_stub(cache, provider):
return SimpleNamespace(
_cache=cache,
_provider=provider,
_config=SimpleNamespace(prefer_websocket=False),
_log=SimpleNamespace(info=lambda *a, **kw: None, warning=lambda *a, **kw: None),
_start_pollers=lambda: None,
_refresh_account_state=_noop,
_restore_journal_snapshot=_noop,
_persist_journal_snapshot=_noop,
_await_account_registered=_noop,
)
def _make_actor_stub(cache, exec_venue="BINGX", data_venue="BINANCE"):
log_messages = []
class Log:
def info(self, msg, *a, **kw):
log_messages.append(("info", msg))
def warning(self, msg, *a, **kw):
log_messages.append(("warning", msg))
def error(self, msg, *a, **kw):
log_messages.append(("error", msg))
def debug(self, msg, *a, **kw):
log_messages.append(("debug", msg))
return SimpleNamespace(
cache=cache,
log=Log(),
_log_messages=log_messages,
dolphin_config={
"engine": {"max_account_leverage": 2.0},
"paper_trade": {"initial_capital": 25000.0},
},
engine=SimpleNamespace(capital=100000.0),
_last_portfolio_capital=100000.0,
_exec_venue_name=lambda: exec_venue,
_data_venue_name=lambda: data_venue,
_exec_open_positions={},
order_factory=SimpleNamespace(
market=lambda **kw: SimpleNamespace(
instrument_id=kw.get("instrument_id"),
order_side=kw.get("order_side"),
quantity=kw.get("quantity"),
tags=kw.get("tags", []),
client_order_id=SimpleNamespace(value="test-coid"),
order_type=OrderType.MARKET,
strategy_id=SimpleNamespace(value="test-strat"),
)
),
submit_order=MagicMock(),
clock=SimpleNamespace(timestamp_ns=lambda: 1000),
)
# -- Test: Instrument Registration --------------------------------------------
class TestExecClientRegistersInstrumentsInCache:
def test_instruments_registered_after_connect(self):
inst1 = _make_bingx_instrument("BTCUSDT")
inst2 = _make_bingx_instrument("ETHUSDT")
cache = FakeCache()
provider = FakeProvider([inst1, inst2])
stub = _make_connect_stub(cache, provider)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(BingxExecutionClient._connect(stub))
finally:
loop.close()
assert cache.instrument(InstrumentId.from_str("BTCUSDT.BINGX")) is inst1
assert cache.instrument(InstrumentId.from_str("ETHUSDT.BINGX")) is inst2
def test_empty_provider_no_crash(self):
cache = FakeCache()
provider = FakeProvider([])
stub = _make_connect_stub(cache, provider)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(BingxExecutionClient._connect(stub))
finally:
loop.close()
assert len(list(cache.instruments())) == 0
# -- Test: _exec_submit_entry instrument lookup ------------------------------
class TestExecSubmitEntryInstrumentLookup:
def test_returns_early_when_no_exec_instrument_no_data_fallback(self):
cache = FakeCache()
stub = _make_actor_stub(cache)
entry = {"asset": "XLMUSDT", "direction": -1, "notional": 5000.0, "entry_price": 0.10, "trade_id": "t1", "leverage": 1.0}
prices = {"XLMUSDT": 0.10}
result = DolphinActor._exec_submit_entry(stub, entry, prices)
assert result is None
assert not stub.submit_order.called
error_msgs = [m for lvl, m in stub._log_messages if lvl == "error"]
assert any("not in cache" in m for m in error_msgs)
def test_succeeds_with_exec_instrument_in_cache(self):
inst = _make_bingx_instrument("XLMUSDT")
cache = FakeCache({inst.id: inst})
stub = _make_actor_stub(cache)
entry = {"asset": "XLMUSDT", "direction": -1, "notional": 5000.0, "entry_price": 0.10, "trade_id": "t1", "leverage": 1.0}
prices = {"XLMUSDT": 0.10}
DolphinActor._exec_submit_entry(stub, entry, prices)
assert stub.submit_order.called
order = stub.submit_order.call_args[0][0]
assert str(order.instrument_id) == "XLMUSDT.BINGX"
assert order.order_side == OrderSide.SELL
assert order.tags[0] == "type:entry"
assert "direction:SHORT" in order.tags
def test_data_venue_fallback_with_warning(self):
binance_inst = _make_binance_instrument("XLMUSDT")
cache = FakeCache({binance_inst.id: binance_inst})
stub = _make_actor_stub(cache, exec_venue="BINGX", data_venue="BINANCE")
entry = {"asset": "XLMUSDT", "direction": -1, "notional": 5000.0, "entry_price": 0.10, "trade_id": "t1", "leverage": 1.0}
prices = {"XLMUSDT": 0.10}
DolphinActor._exec_submit_entry(stub, entry, prices)
assert stub.submit_order.called
warn_msgs = [m for lvl, m in stub._log_messages if lvl == "warning"]
assert any("borrowing metadata" in m for m in warn_msgs)
def test_info_log_on_successful_entry(self):
inst = _make_bingx_instrument("XLMUSDT")
cache = FakeCache({inst.id: inst})
stub = _make_actor_stub(cache)
entry = {"asset": "XLMUSDT", "direction": -1, "notional": 5000.0, "entry_price": 0.10, "trade_id": "t42", "leverage": 1.5}
prices = {"XLMUSDT": 0.10}
DolphinActor._exec_submit_entry(stub, entry, prices)
info_msgs = [m for lvl, m in stub._log_messages if lvl == "info"]
assert any("[EXEC] ENTRY SHORT" in m and "XLMUSDT" in m for m in info_msgs)
def test_quantity_uses_instrument_size_precision(self):
inst = _make_bingx_instrument("BTCUSDT")
inst.size_precision = 4
cache = FakeCache({inst.id: inst})
stub = _make_actor_stub(cache)
entry = {"asset": "BTCUSDT", "direction": 1, "notional": 50000.0, "entry_price": 100000.0, "trade_id": "t1", "leverage": 1.0}
prices = {"BTCUSDT": 100000.0}
DolphinActor._exec_submit_entry(stub, entry, prices)
order = stub.submit_order.call_args[0][0]
assert order.quantity.precision == 4
# -- Test: _venue_symbol mapping ---------------------------------------------
class TestVenueSymbolMapping:
def test_uses_raw_symbol_when_instrument_in_cache(self):
inst = _make_bingx_instrument("TRXUSDT")
cache = FakeCache({inst.id: inst})
stub = SimpleNamespace(_cache=cache)
result = BingxExecutionClient._venue_symbol(stub, InstrumentId.from_str("TRXUSDT.BINGX"))
assert result == "TRX-USDT"
def test_fallback_converts_usdt_suffix(self):
stub = SimpleNamespace(_cache=FakeCache())
result = BingxExecutionClient._venue_symbol(stub, InstrumentId.from_str("XLMUSDT.BINGX"))
assert result == "XLM-USDT"
def test_fallback_passes_through_hyphenated(self):
stub = SimpleNamespace(_cache=FakeCache())
result = BingxExecutionClient._venue_symbol(stub, InstrumentId.from_str("BTC-USDT.BINGX"))
assert result == "BTC-USDT"
# -- Test: _map_submit_order -------------------------------------------------
class TestMapSubmitOrderForMarketOrder:
def test_market_sell_with_tags(self):
inst = _make_bingx_instrument("ETHUSDT")
cache = FakeCache({inst.id: inst})
order = SimpleNamespace(
instrument_id=InstrumentId.from_str("ETHUSDT.BINGX"),
side=OrderSide.SELL,
order_type=OrderType.MARKET,
quantity=Quantity.from_str("1.500"),
client_order_id=SimpleNamespace(value="test-cid-001"),
is_post_only=False,
is_reduce_only=False,
has_price=False,
has_trigger_price=False,
price=None,
trigger_price=None,
time_in_force=None,
tags=["type:entry", "direction:SHORT", "cm:2.50", "tid:t99"],
)
adapter = SimpleNamespace(
_cache=cache,
_config=SimpleNamespace(use_reduce_only=True, recv_window_ms=5000),
_venue_symbol=lambda iid: BingxExecutionClient._venue_symbol(adapter, iid),
_format_quantity=lambda q: str(q),
_format_price=lambda p: str(p),
_map_order_type=BingxExecutionClient._map_order_type,
_map_time_in_force=BingxExecutionClient._map_time_in_force,
)
# Rebind _venue_symbol after adapter exists so self-referencing works
adapter._venue_symbol = lambda iid: BingxExecutionClient._venue_symbol(adapter, iid)
payload = BingxExecutionClient._map_submit_order(adapter, order)
assert payload["symbol"] == "ETH-USDT"
assert payload["side"] == "SELL"
assert payload["type"] == "MARKET"
assert payload["quantity"] == "1.500"
assert payload["clientOrderId"] == "test-cid-001"
# -- Test: Order tag parsing for leverage ------------------------------------
class TestLeverageTagParsing:
def test_extracts_lev_tag(self):
order = SimpleNamespace(tags=["type:entry", "lev:2.50", "cm:2.50", "tid:t1"])
assert BingxExecutionClient._parse_leverage_from_tags(order) == 2.50
def test_extracts_cm_tag(self):
order = SimpleNamespace(tags=["type:entry", "cm:3.00", "tid:t1"])
assert BingxExecutionClient._parse_leverage_from_tags(order) == 3.00
def test_returns_none_no_tags(self):
order = SimpleNamespace(tags=[])
assert BingxExecutionClient._parse_leverage_from_tags(order) is None
def test_returns_none_no_leverage_tags(self):
order = SimpleNamespace(tags=["type:entry", "direction:SHORT"])
assert BingxExecutionClient._parse_leverage_from_tags(order) is None
# -- Test: Split venue configuration -----------------------------------------
class TestSplitVenueConfig:
def test_split_venues_preserved(self):
from prod.launch_dolphin_live import build_actor_config
cfg = build_actor_config(data_venue="BINANCE", exec_venue="BINGX")
assert cfg["data_venue"] == "BINANCE"
assert cfg["exec_venue"] == "BINGX"
assert cfg["venue"] == "BINGX"
# -- Import DolphinActor -----------------------------------------------------
try:
from nautilus_dolphin.nautilus.dolphin_actor import DolphinActor
HAS_DOLPHIN_ACTOR = True
except ImportError:
HAS_DOLPHIN_ACTOR = False
@pytest.mark.skipif(not HAS_DOLPHIN_ACTOR, reason="DolphinActor not importable")
class TestDolphinActorExecSubmitEntry:
def _actor_stub(self, cache):
return _make_actor_stub(cache)
def test_full_entry_flow_short_order(self):
inst = _make_bingx_instrument("SOLUSDT")
cache = FakeCache({inst.id: inst})
stub = self._actor_stub(cache)
entry = {
"asset": "SOLUSDT", "direction": -1, "notional": 3000.0,
"entry_price": 150.0, "trade_id": "trade-sol-001",
"leverage": 2.0, "vel_div": -0.035,
}
prices = {"SOLUSDT": 150.0}
DolphinActor._exec_submit_entry(stub, entry, prices)
assert stub.submit_order.called
order = stub.submit_order.call_args[0][0]
assert "direction:SHORT" in order.tags
assert "cm:2.00" in order.tags
assert "lev:2.00" in order.tags
assert "tid:trade-sol-001" in order.tags
def test_full_entry_flow_long_order(self):
inst = _make_bingx_instrument("ADAUSDT")
cache = FakeCache({inst.id: inst})
stub = self._actor_stub(cache)
entry = {
"asset": "ADAUSDT", "direction": 1, "notional": 2000.0,
"entry_price": 0.45, "trade_id": "trade-ada-002",
"leverage": 1.0, "vel_div": -0.025,
}
prices = {"ADAUSDT": 0.45}
DolphinActor._exec_submit_entry(stub, entry, prices)
assert stub.submit_order.called
order = stub.submit_order.call_args[0][0]
assert order.order_side == OrderSide.BUY
assert "direction:LONG" in order.tags
def test_caps_notional_when_near_capacity_limit(self):
inst = _make_bingx_instrument("BTCUSDT")
cache = FakeCache({inst.id: inst})
stub = self._actor_stub(cache)
stub.engine = SimpleNamespace(capital=10.0)
stub.dolphin_config["engine"]["max_account_leverage"] = 0.01
entry = {
"asset": "BTCUSDT", "direction": -1, "notional": 5000.0,
"entry_price": 100000.0, "trade_id": "t1", "leverage": 1.0,
}
prices = {"BTCUSDT": 100000.0}
DolphinActor._exec_submit_entry(stub, entry, prices)
assert stub.submit_order.called
warn_msgs = [m for lvl, m in stub._log_messages if lvl == "warning"]
assert any("capped by portfolio exposure" in m for m in warn_msgs)
def test_skips_when_notional_zero(self):
inst = _make_bingx_instrument("BTCUSDT")
cache = FakeCache({inst.id: inst})
stub = self._actor_stub(cache)
entry = {
"asset": "BTCUSDT", "direction": -1, "notional": 0.0,
"entry_price": 100000.0, "trade_id": "t1", "leverage": 1.0,
}
prices = {"BTCUSDT": 100000.0}
result = DolphinActor._exec_submit_entry(stub, entry, prices)
assert result is None
assert not stub.submit_order.called
# -- Live integration (requires BingX VST credentials) -----------------------
@pytest.mark.skipif(
not Path("/mnt/dolphinng5_predict/.env").exists(),
reason="No .env file (no BingX credentials)",
)
class TestLiveInstrumentProvider:
def test_loads_instruments_from_vst(self):
import os
from dotenv import load_dotenv
load_dotenv("/mnt/dolphinng5_predict/.env")
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.http import BingxHttpClient
from prod.bingx.instrument_provider import BingxInstrumentProvider, BingxInstrumentProviderConfig
async def _run():
cfg = BingxExecClientConfig(
api_key=os.environ.get("BINGX_API_KEY", ""),
secret_key=os.environ.get("BINGX_SECRET_KEY", ""),
environment=BingxEnvironment.VST,
)
client = BingxHttpClient(config=cfg)
provider = BingxInstrumentProvider(
client=client,
config=BingxInstrumentProviderConfig(load_all=True),
)
await provider.initialize()
instruments = provider.list_all()
await _persist_sandbox_status(client, notes={"test": "loads_instruments_from_vst"})
await client.close()
return instruments
instruments = asyncio.run(_run())
assert len(instruments) > 0
symbols = {i.symbol.value for i in instruments}
assert "BTCUSDT" in symbols
assert "ETHUSDT" in symbols
def test_trxusdt_instrument_has_correct_precision(self):
import os
from dotenv import load_dotenv
load_dotenv("/mnt/dolphinng5_predict/.env")
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.http import BingxHttpClient
from prod.bingx.instrument_provider import BingxInstrumentProvider, BingxInstrumentProviderConfig
async def _run():
cfg = BingxExecClientConfig(
api_key=os.environ.get("BINGX_API_KEY", ""),
secret_key=os.environ.get("BINGX_SECRET_KEY", ""),
environment=BingxEnvironment.VST,
)
client = BingxHttpClient(config=cfg)
provider = BingxInstrumentProvider(
client=client,
config=BingxInstrumentProviderConfig(load_all=True),
)
await provider.initialize()
inst = provider.find(InstrumentId.from_str("TRXUSDT.BINGX"))
await _persist_sandbox_status(client, notes={"test": "trxusdt_instrument_has_correct_precision"})
await client.close()
return inst
inst = asyncio.run(_run())
assert inst is not None
assert inst.size_precision >= 1
assert inst.price_precision >= 1
assert inst.raw_symbol.value == "TRX-USDT"