Add BingX sandbox status sidecar
This commit is contained in:
126
prod/bingx/sandbox_status.py
Normal file
126
prod/bingx/sandbox_status.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_SANDBOX_STATUS_PATH = Path("/tmp/bingx_sandbox_status.json")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxSandboxStatus:
|
||||
"""Small sidecar snapshot for BingX demo/testnet state.
|
||||
|
||||
The snapshot is intentionally local-only so it can be used by tests and
|
||||
operators without writing into BLUE state, ClickHouse, or production logs.
|
||||
"""
|
||||
|
||||
ts: str
|
||||
environment: str
|
||||
balance: float
|
||||
equity: float
|
||||
available_margin: float
|
||||
unrealized_profit: float
|
||||
used_margin: float
|
||||
open_positions: int
|
||||
open_orders: int
|
||||
account_currency: str = "VST"
|
||||
clean: bool = False
|
||||
notes: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ts": self.ts,
|
||||
"environment": self.environment,
|
||||
"account_currency": self.account_currency,
|
||||
"balance": self.balance,
|
||||
"equity": self.equity,
|
||||
"available_margin": self.available_margin,
|
||||
"unrealized_profit": self.unrealized_profit,
|
||||
"used_margin": self.used_margin,
|
||||
"open_positions": self.open_positions,
|
||||
"open_orders": self.open_orders,
|
||||
"clean": self.clean,
|
||||
"notes": self.notes or {},
|
||||
}
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
out = float(value)
|
||||
except Exception:
|
||||
return default
|
||||
return out if out == out else default
|
||||
|
||||
|
||||
def _count_positions(positions: Any) -> int:
|
||||
if isinstance(positions, list):
|
||||
return sum(1 for item in positions if isinstance(item, dict))
|
||||
return 0
|
||||
|
||||
|
||||
def _count_orders(open_orders: Any) -> int:
|
||||
if isinstance(open_orders, dict):
|
||||
orders = open_orders.get("orders")
|
||||
if isinstance(orders, list):
|
||||
return sum(1 for item in orders if isinstance(item, dict))
|
||||
if isinstance(open_orders, list):
|
||||
return sum(1 for item in open_orders if isinstance(item, dict))
|
||||
return 0
|
||||
|
||||
|
||||
def build_sandbox_status(
|
||||
*,
|
||||
balance_payload: dict[str, Any],
|
||||
positions_payload: Any,
|
||||
open_orders_payload: Any,
|
||||
environment: str = "VST",
|
||||
account_currency: str = "VST",
|
||||
notes: dict[str, Any] | None = None,
|
||||
) -> BingxSandboxStatus:
|
||||
balance_row = balance_payload.get("balance", balance_payload) if isinstance(balance_payload, dict) else {}
|
||||
if not isinstance(balance_row, dict):
|
||||
balance_row = {}
|
||||
balance = _safe_float(balance_row.get("balance"), 0.0)
|
||||
equity = _safe_float(balance_row.get("equity"), balance)
|
||||
available_margin = _safe_float(balance_row.get("availableMargin"), 0.0)
|
||||
unrealized_profit = _safe_float(balance_row.get("unrealizedProfit"), 0.0)
|
||||
used_margin = _safe_float(balance_row.get("usedMargin"), 0.0)
|
||||
open_positions = _count_positions(positions_payload)
|
||||
open_orders = _count_orders(open_orders_payload)
|
||||
return BingxSandboxStatus(
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
environment=str(environment),
|
||||
account_currency=str(account_currency),
|
||||
balance=balance,
|
||||
equity=equity,
|
||||
available_margin=available_margin,
|
||||
unrealized_profit=unrealized_profit,
|
||||
used_margin=used_margin,
|
||||
open_positions=open_positions,
|
||||
open_orders=open_orders,
|
||||
clean=(open_positions == 0 and open_orders == 0),
|
||||
notes=notes or {},
|
||||
)
|
||||
|
||||
|
||||
def snapshot_path(path: str | Path | None = None) -> Path:
|
||||
return Path(path) if path is not None else DEFAULT_SANDBOX_STATUS_PATH
|
||||
|
||||
|
||||
def write_sandbox_status(status: BingxSandboxStatus, path: str | Path | None = None) -> Path:
|
||||
target = snapshot_path(path)
|
||||
target.write_text(json.dumps(status.to_dict(), indent=2, sort_keys=True))
|
||||
return target
|
||||
|
||||
|
||||
def load_sandbox_status(path: str | Path | None = None) -> dict[str, Any] | None:
|
||||
target = snapshot_path(path)
|
||||
if not target.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(target.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
559
prod/tests/test_bingx_nautilus_execution.py
Normal file
559
prod/tests/test_bingx_nautilus_execution.py
Normal 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"
|
||||
71
prod/tests/test_bingx_sandbox_status.py
Normal file
71
prod/tests/test_bingx_sandbox_status.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from prod.bingx.sandbox_status import build_sandbox_status
|
||||
from prod.bingx.sandbox_status import load_sandbox_status
|
||||
from prod.bingx.sandbox_status import write_sandbox_status
|
||||
|
||||
|
||||
def test_build_sandbox_status_marks_clean_when_flat():
|
||||
status = build_sandbox_status(
|
||||
balance_payload={
|
||||
"balance": {
|
||||
"balance": "12000.5",
|
||||
"equity": "12000.5",
|
||||
"availableMargin": "12000.5",
|
||||
"unrealizedProfit": "0",
|
||||
"usedMargin": "0",
|
||||
}
|
||||
},
|
||||
positions_payload=[],
|
||||
open_orders_payload={"orders": []},
|
||||
environment="VST",
|
||||
)
|
||||
|
||||
assert status.clean is True
|
||||
assert status.balance == 12000.5
|
||||
assert status.equity == 12000.5
|
||||
assert status.open_positions == 0
|
||||
assert status.open_orders == 0
|
||||
|
||||
|
||||
def test_build_sandbox_status_marks_dirty_when_positions_or_orders_exist():
|
||||
status = build_sandbox_status(
|
||||
balance_payload={
|
||||
"balance": {
|
||||
"balance": "12000.5",
|
||||
"equity": "12500.5",
|
||||
"availableMargin": "9000.5",
|
||||
"unrealizedProfit": "500",
|
||||
"usedMargin": "3000",
|
||||
}
|
||||
},
|
||||
positions_payload=[{"symbol": "BTC-USDT"}, {"symbol": "ETH-USDT"}],
|
||||
open_orders_payload={"orders": [{"symbol": "BTC-USDT"}]},
|
||||
environment="VST",
|
||||
)
|
||||
|
||||
assert status.clean is False
|
||||
assert status.open_positions == 2
|
||||
assert status.open_orders == 1
|
||||
assert status.unrealized_profit == 500.0
|
||||
|
||||
|
||||
def test_write_and_load_sandbox_status_round_trip(tmp_path: Path):
|
||||
status = build_sandbox_status(
|
||||
balance_payload={"balance": {"balance": "10", "equity": "11", "availableMargin": "9", "unrealizedProfit": "1", "usedMargin": "2"}},
|
||||
positions_payload=[],
|
||||
open_orders_payload=[],
|
||||
environment="VST",
|
||||
notes={"source": "unit-test"},
|
||||
)
|
||||
path = tmp_path / "bingx_sandbox_status.json"
|
||||
write_sandbox_status(status, path)
|
||||
|
||||
loaded = load_sandbox_status(path)
|
||||
assert loaded is not None
|
||||
assert loaded["balance"] == 10.0
|
||||
assert loaded["equity"] == 11.0
|
||||
assert loaded["clean"] is True
|
||||
assert loaded["notes"]["source"] == "unit-test"
|
||||
87
prod/tests/test_capital_restore_selection.py
Normal file
87
prod/tests/test_capital_restore_selection.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for capital restore source selection on startup."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.nautilus_event_trader import DolphinLiveTrader
|
||||
|
||||
|
||||
class _MapStub:
|
||||
def __init__(self, payloads):
|
||||
self._payloads = payloads
|
||||
|
||||
def blocking(self):
|
||||
return self
|
||||
|
||||
def get(self, key):
|
||||
return self._payloads.get(key)
|
||||
|
||||
|
||||
def _build_trader() -> DolphinLiveTrader:
|
||||
trader = DolphinLiveTrader()
|
||||
trader._build_engine()
|
||||
trader.eng.begin_day(datetime.now(timezone.utc).strftime("%Y-%m-%d"), posture="APEX")
|
||||
return trader
|
||||
|
||||
|
||||
def test_restore_prefers_fresher_engine_snapshot_over_stale_latest_nautilus():
|
||||
trader = _build_trader()
|
||||
trader.eng.capital = 25_000.0
|
||||
|
||||
trader.state_map = _MapStub(
|
||||
{
|
||||
"latest_nautilus": json.dumps(
|
||||
{
|
||||
"capital": 31_049.44,
|
||||
"updated_at": "2026-05-13T10:52:40+00:00",
|
||||
}
|
||||
),
|
||||
"engine_snapshot": json.dumps(
|
||||
{
|
||||
"capital": 33_150.07,
|
||||
"timestamp": "2026-05-13T16:20:38+00:00",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
trader.pnl_map = _MapStub({})
|
||||
|
||||
with patch.dict(os.environ, {"DOLPHIN_CAPITAL_SEED_STALE_LAG_SEC": "180"}, clear=False):
|
||||
trader._restore_capital()
|
||||
|
||||
assert trader.eng.capital == pytest.approx(33_150.07, abs=0.01)
|
||||
assert trader._restore_source == "HZ engine_snapshot"
|
||||
|
||||
|
||||
def test_restore_can_force_latest_nautilus_override():
|
||||
trader = _build_trader()
|
||||
trader.eng.capital = 25_000.0
|
||||
|
||||
trader.state_map = _MapStub(
|
||||
{
|
||||
"latest_nautilus": json.dumps(
|
||||
{
|
||||
"capital": 31_049.44,
|
||||
"updated_at": "2026-05-13T10:52:40+00:00",
|
||||
}
|
||||
),
|
||||
"engine_snapshot": json.dumps(
|
||||
{
|
||||
"capital": 33_150.07,
|
||||
"timestamp": "2026-05-13T16:20:38+00:00",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
trader.pnl_map = _MapStub({})
|
||||
|
||||
with patch.dict(os.environ, {"DOLPHIN_FORCE_LATEST_NAUTILUS_RESTORE": "1"}, clear=False):
|
||||
trader._restore_capital()
|
||||
|
||||
assert trader.eng.capital == pytest.approx(31_049.44, abs=0.01)
|
||||
assert trader._restore_source == "HZ latest_nautilus"
|
||||
Reference in New Issue
Block a user