385 lines
18 KiB
Python
385 lines
18 KiB
Python
|
|
"""
|
||
|
|
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
|