Files
sentiment-engine/MALKHUT/malkhut/tests/test_bingx_adapter.py

385 lines
18 KiB
Python
Raw Normal View History

"""
Exhaustive BingX venue adapter tests.
Categories:
1. Config safety (testnet enforcement)
2. Order placement (price, qty, notional, tick/lot rounding)
3. Order cancellation (tracked, rate limit)
4. Cancel-replace
5. Risk gate integration (rejected orders)
6. Order tracking (state transitions)
7. Rate limiting
8. Zinc telemetry
9. Edge cases (zero qty, empty book, min notional)
10. Context manager
"""
import time
import pytest
from malkhut.state import (
AccountState, FulfilmentPolicyParams, MarketWorldState, Mode,
OrderBookState, PriceLevel, Side, VenueRules,
)
from malkhut.actions import ActionKind, FulfilmentAction, OrderType, PlannedPolicy, RiskDecision
from malkhut.venue.bingx.adapter import BingXVenueAdapter, BingXConfig, TrackedOrder
def _venue(**kw):
d = dict(exchange="bingx", symbol="BTCUSDT", tick_size=0.1, lot_size=0.001,
min_qty=0.001, min_notional=5.0, maker_fee_bps=-0.2, taker_fee_bps=0.5,
post_only_supported=True, reduce_only_supported=True,
max_orders_per_second=100, max_cancels_per_minute=120)
d.update(kw)
return VenueRules(**d)
def _state(bid=50000.0, ask=50001.0, equity=10000.0, **kw):
return MarketWorldState(
ts_ns=kw.get("ts", 1_000_000_000), mode=Mode.LIVE,
venue=kw.get("venue", _venue()),
book=OrderBookState(
ts_ns=1_000_000_000, symbol="BTCUSDT",
bids=(PriceLevel(bid, 1.0),), asks=(PriceLevel(ask, 1.0),),
),
account=AccountState(
ts_ns=1_000_000_000, equity=equity, wallet_balance=equity,
available_balance=equity, margin_used=0.0, total_notional=0.0,
),
)
def _decision(approved=True, action=None, reason="approved"):
if action is None:
action = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)
return RiskDecision(approved=approved, action=action, reason=reason)
# ══════════════════════════════════════════════════════════════════════════════
# 1. CONFIG SAFETY
# ══════════════════════════════════════════════════════════════════════════════
class TestConfigSafety:
def test_testnet_default(self):
cfg = BingXConfig()
assert cfg.testnet is True
def test_testnet_rejects_live(self):
with pytest.raises(ValueError, match="testnet=False not allowed"):
BingXConfig(testnet=False)
def test_custom_config(self):
cfg = BingXConfig(api_key="k", api_secret="s", testnet=True)
assert cfg.api_key == "k"
assert cfg.recv_window_ms == 5000
# ══════════════════════════════════════════════════════════════════════════════
# 2. ORDER PLACEMENT
# ══════════════════════════════════════════════════════════════════════════════
class TestOrderPlacement:
def test_place_buy_limit(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
state = _state()
adapter.execute(state, _decision(action=action))
assert adapter.total_orders == 1
def test_place_sell_limit(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.SELL, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
state = _state()
adapter.execute(state, _decision(action=action))
assert adapter.total_orders == 1
def test_place_cross_spread_market(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.CROSS_SPREAD, Side.BUY, OrderType.IOC, 0, 0.1, 50,
)
state = _state()
adapter.execute(state, _decision(action=action))
assert adapter.total_orders == 1
def test_order_tracked(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
state = _state(ts=1_000_000_001)
adapter.execute(state, _decision(action=action))
working = adapter.get_working()
assert len(working) == 1
assert working[0].side == Side.BUY
assert working[0].status == "WORKING"
def test_client_id_unique(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
adapter.execute(_state(ts=2), _decision(action=action))
working = adapter.get_working()
assert working[0].client_order_id != working[1].client_order_id
def test_order_below_min_notional_rejected(self):
adapter = BingXVenueAdapter()
state = _state(equity=1.0) # very small equity
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(state, _decision(action=action))
assert adapter.total_orders == 0
# ══════════════════════════════════════════════════════════════════════════════
# 3. ORDER CANCELLATION
# ══════════════════════════════════════════════════════════════════════════════
class TestOrderCancellation:
def test_cancel_existing_order(self):
adapter = BingXVenueAdapter()
# Place first
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
working = adapter.get_working()
cancel_id = working[0].client_order_id
# Cancel
cancel_action = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=cancel_id,
)
adapter.execute(_state(ts=2), _decision(action=cancel_action))
assert len(adapter.get_working()) == 0
def test_cancel_nonexistent_order_no_crash(self):
adapter = BingXVenueAdapter()
cancel_action = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id="nonexistent",
)
adapter.execute(_state(), _decision(action=cancel_action))
assert adapter.total_cancels == 0
def test_cancel_tracks_status(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
working = adapter.get_working()
cancel_id = working[0].client_order_id
cancel_action = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=cancel_id,
)
adapter.execute(_state(ts=2), _decision(action=cancel_action))
tracked = adapter.get_tracked(cancel_id)
assert tracked.status == "CANCELLED"
# ══════════════════════════════════════════════════════════════════════════════
# 4. CANCEL-REPLACE
# ══════════════════════════════════════════════════════════════════════════════
class TestCancelReplace:
def test_cancel_replace(self):
adapter = BingXVenueAdapter()
# Place first
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
old_id = adapter.get_working()[0].client_order_id
# Cancel-replace
cr_action = FulfilmentAction(
ActionKind.CANCEL_REPLACE, Side.BUY, OrderType.LIMIT, 1, 0.1, 200,
cancel_order_id=old_id, post_only=True,
)
adapter.execute(_state(ts=2), _decision(action=cr_action))
# Old cancelled, new working
assert adapter.get_tracked(old_id).status == "CANCELLED"
working = adapter.get_working()
assert len(working) == 1
assert working[0].client_order_id != old_id
# ══════════════════════════════════════════════════════════════════════════════
# 5. RISK GATE INTEGRATION
# ══════════════════════════════════════════════════════════════════════════════
class TestRiskGateIntegration:
def test_rejected_order_not_placed(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(), _decision(approved=False, action=action, reason="leverage"))
assert adapter.total_orders == 0
assert len(adapter.get_working()) == 0
def test_noop_not_tracked(self):
adapter = BingXVenueAdapter()
adapter.execute(_state(), _decision())
assert adapter.total_orders == 0
# ══════════════════════════════════════════════════════════════════════════════
# 6. ORDER TRACKING
# ══════════════════════════════════════════════════════════════════════════════
class TestOrderTracking:
def test_get_tracked(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
working = adapter.get_working()
tracked = adapter.get_tracked(working[0].client_order_id)
assert tracked is not None
assert tracked.symbol == "BTCUSDT"
assert tracked.price > 0
assert tracked.qty > 0
def test_get_nonexistent_returns_none(self):
adapter = BingXVenueAdapter()
assert adapter.get_tracked("nonexistent") is None
def test_multiple_orders_tracked(self):
adapter = BingXVenueAdapter()
for i in range(3):
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, i, 0.05, 200, post_only=True,
)
adapter.execute(_state(ts=i + 1), _decision(action=action))
assert len(adapter.get_working()) == 3
def test_cancel_removes_from_working(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
oid = adapter.get_working()[0].client_order_id
cancel = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=oid,
)
adapter.execute(_state(ts=2), _decision(action=cancel))
assert len(adapter.get_working()) == 0
# ══════════════════════════════════════════════════════════════════════════════
# 7. RATE LIMITING
# ══════════════════════════════════════════════════════════════════════════════
class TestRateLimiting:
def test_cancel_rate_check(self):
adapter = BingXVenueAdapter()
# Should allow many cancels within limit (90 per minute)
for i in range(90):
assert adapter._check_cancel_rate("BTCUSDT")
# 91st should fail (count=90 >= limit)
assert not adapter._check_cancel_rate("BTCUSDT")
def test_rate_resets_per_minute(self):
adapter = BingXVenueAdapter()
adapter._last_minute_ts = int(time.time() / 60) - 2
adapter._cancel_count["BTCUSDT"] = 200
# Should reset because minute changed
assert adapter._check_cancel_rate("BTCUSDT")
# ══════════════════════════════════════════════════════════════════════════════
# 8. COUNTERS
# ══════════════════════════════════════════════════════════════════════════════
class TestCounters:
def test_order_counter_increments(self):
adapter = BingXVenueAdapter()
assert adapter.total_orders == 0
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
assert adapter.total_orders == 1
adapter.execute(_state(ts=2), _decision(action=action))
assert adapter.total_orders == 2
def test_cancel_counter_increments(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
oid = adapter.get_working()[0].client_order_id
cancel = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=oid,
)
adapter.execute(_state(ts=2), _decision(action=cancel))
assert adapter.total_cancels == 1
# ══════════════════════════════════════════════════════════════════════════════
# 9. EDGE CASES
# ══════════════════════════════════════════════════════════════════════════════
class TestEdgeCases:
def test_zero_qty_not_placed(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.0, 200, post_only=True,
)
adapter.execute(_state(), _decision(action=action))
assert adapter.total_orders == 0
def test_cancel_with_no_id(self):
adapter = BingXVenueAdapter()
action = FulfilmentAction(
ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id=None,
)
adapter.execute(_state(), _decision(action=action))
assert adapter.total_cancels == 0
def test_context_manager(self):
with BingXVenueAdapter() as adapter:
action = FulfilmentAction(
ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True,
)
adapter.execute(_state(ts=1), _decision(action=action))
assert adapter.total_orders == 1
# Should be cleaned up
assert len(adapter._tracked) == 0
# ══════════════════════════════════════════════════════════════════════════════
# 10. TRADEDORDER
# ══════════════════════════════════════════════════════════════════════════════
class TestTrackedOrder:
def test_frozen(self):
o = TrackedOrder(
client_order_id="c1", venue_order_id=None, symbol="BTCUSDT",
side=Side.BUY, order_type="LIMIT", price=50000.0, qty=0.001,
status="WORKING", created_ts_ns=1,
)
with pytest.raises(AttributeError):
o.status = "FILLED"
def test_defaults(self):
o = TrackedOrder(
client_order_id="c1", venue_order_id=None, symbol="BTCUSDT",
side=Side.BUY, order_type="LIMIT", price=50000.0, qty=0.001,
status="WORKING", created_ts_ns=1,
)
assert o.filled_qty == 0.0
assert o.filled_price == 0.0
assert o.filled_ts_ns == 0