""" Exchange mechanics — price-time priority, tick/lot rounding, fees, partial fills, IOC/FOK, post-only rejection, reduce-only. """ import pytest from malkhut.state import ( AccountState, MarketWorldState, Mode, OpenOrderState, OrderBookState, PriceLevel, Side, VenueRules, ) from malkhut.cwm.core import MinimalCryptoLOBCWM from malkhut.actions import ActionKind, FulfilmentAction, OrderType 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(**kw): book = kw.get("book", OrderBookState( ts_ns=1_000_000_000, symbol="BTCUSDT", bids=(PriceLevel(50000.0, 1.0), PriceLevel(49999.0, 2.0)), asks=(PriceLevel(50001.0, 1.0), PriceLevel(50002.0, 2.0)), )) return MarketWorldState( ts_ns=kw.get("ts", 1_000_000_000), mode=Mode.REPLAY_NO_IMPACT, venue=kw.get("venue", _venue()), book=book, account=kw.get("account", AccountState( ts_ns=1_000_000_000, equity=10000.0, wallet_balance=10000.0, available_balance=10000.0, margin_used=0.0, total_notional=0.0, )), open_orders=kw.get("open_orders", ()), ) class TestPostOnlyRejection: def test_buy_at_ask_rejected(self): cwm = MinimalCryptoLOBCWM() s = _state() # price = best_bid - (-10)*tick = 50000 + 1.0 = 50001.0 = best_ask a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, -10, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity def test_sell_at_bid_rejected(self): cwm = MinimalCryptoLOBCWM() s = _state() # price = best_ask + (-10)*tick = 50001 - 1.0 = 50000.0 = best_bid a = FulfilmentAction(ActionKind.PLACE, Side.SELL, OrderType.LIMIT, -10, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert r.account.equity == s.account.equity def test_buy_inside_spread_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) # Should be in open orders (passive placement) assert any(o.side == Side.BUY for o in r.open_orders) def test_sell_inside_spread_accepted(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.PLACE, Side.SELL, OrderType.LIMIT, 0, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert any(o.side == Side.SELL for o in r.open_orders) class TestCrossSpread: def test_cross_buy_fills_at_best_ask(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.BUY, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) assert r.account.equity < s.account.equity # fees paid def test_cross_sell_fills_at_best_bid(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.SELL, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) assert r.account.equity <= s.account.equity def test_cross_spread_taker_fee_applied(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.BUY, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) fee = s.venue.taker_fee_bps assert fee > 0 class TestCancelOrder: def test_cancel_removes_order(self): oo = OpenOrderState( client_order_id="c1", venue_order_id="v1", symbol="BTCUSDT", side=Side.BUY, order_type=OrderType.LIMIT, price=50000.0, qty=0.001, remaining_qty=0.001, queue_ahead_estimate=0.001, created_ts_ns=1_000_000_000, last_update_ts_ns=1_000_000_000, post_only=True, ) cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo,)) a = FulfilmentAction(ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id="c1") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 0 def test_cancel_wrong_id_keeps_order(self): oo = OpenOrderState( client_order_id="c1", venue_order_id="v1", symbol="BTCUSDT", side=Side.BUY, order_type=OrderType.LIMIT, price=50000.0, qty=0.001, remaining_qty=0.001, queue_ahead_estimate=0.001, created_ts_ns=1_000_000_000, last_update_ts_ns=1_000_000_000, post_only=True, ) cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo,)) a = FulfilmentAction(ActionKind.CANCEL, Side.BUY, None, 0, 0.0, 0, cancel_order_id="wrong_id") r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 def test_cancel_replace_removes_old_adds_new(self): oo = OpenOrderState( client_order_id="c1", venue_order_id="v1", symbol="BTCUSDT", side=Side.BUY, order_type=OrderType.LIMIT, price=50000.0, qty=0.001, remaining_qty=0.001, queue_ahead_estimate=0.001, created_ts_ns=1_000_000_000, last_update_ts_ns=1_000_000_000, post_only=True, ) cwm = MinimalCryptoLOBCWM() s = _state(open_orders=(oo,)) a = FulfilmentAction( ActionKind.CANCEL_REPLACE, Side.BUY, OrderType.LIMIT, 0, 0.25, 200, cancel_order_id="c1", post_only=True, ) r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 assert r.open_orders[0].client_order_id != "c1" class TestAccountUpdate: def test_buy_increases_position(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.BUY, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) pos = r.account.positions.get("BTCUSDT") assert pos is not None assert pos.qty > 0 def test_sell_decreases_position(self): cwm = MinimalCryptoLOBCWM() # Start with a long position from malkhut.state import PositionState pos = PositionState( symbol="BTCUSDT", qty=0.1, avg_entry=50000.0, unrealized_pnl=0.0, realized_pnl=0.0, liquidation_price=None, leverage=0.5, side=Side.BUY, ) s = _state(account=AccountState( ts_ns=1_000_000_000, equity=10000.0, wallet_balance=10000.0, available_balance=10000.0, margin_used=0.0, total_notional=5000.0, positions={"BTCUSDT": pos}, )) a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.SELL, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) new_pos = r.account.positions.get("BTCUSDT") assert new_pos.qty < pos.qty def test_fees_reduce_equity(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.BUY, OrderType.LIMIT, 0, 0.1, 50) r = cwm.transition(s, (a,)) assert r.account.equity < s.account.equity def test_no_position_zero_notional(self): cwm = MinimalCryptoLOBCWM() s = _state() a = _noop() r = cwm.transition(s, (a,)) assert r.account.total_notional == 0.0 def test_maker_fee_lower_than_taker(self): cwm = MinimalCryptoLOBCWM() s = _state() # Both maker and taker should apply their respective fees a_cross = FulfilmentAction(ActionKind.CROSS_SPREAD, Side.BUY, OrderType.LIMIT, 0, 0.1, 50) r_cross = cwm.transition(s, (a_cross,)) assert s.venue.taker_fee_bps > s.venue.maker_fee_bps class TestPassivePlacement: def test_passive_order_added_to_book(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 1, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert len(r.open_orders) == 1 assert r.open_orders[0].side == Side.BUY def test_passive_order_price_correct(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 1, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert r.open_orders[0].price == 49999.9 def test_passive_order_symbol_matches(self): cwm = MinimalCryptoLOBCWM() s = _state() a = FulfilmentAction(ActionKind.PLACE, Side.BUY, OrderType.LIMIT, 0, 0.1, 200, post_only=True) r = cwm.transition(s, (a,)) assert r.open_orders[0].symbol == "BTCUSDT" def _noop(): return FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0)