# piker: trading gear for hackers # Copyright (C) Tyler Goodlet (in stewardship for pikers) # This program is free software: you can redistribute it and/or # modify it under the terms of the GNU Affero General Public # License as published by the Free Software Foundation, either # version 3 of the License, or (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public # License along with this program. If not, see # . ''' Deterministic order-event sequencing without feeds or credentials. ''' from decimal import Decimal import pytest from piker.clearing._codec import ProtocolError from piker.clearing._schema import ( BrokerdCancel, BrokerdError, BrokerdFill, BrokerdOrderAck, BrokerdStatus, Order, Status, ) from piker.clearing._state import Effects, OrderState def order( oid: str = 'one', account: str = 'paper', ) -> Order: ''' Build live client intent with controllable dialog/account identity. ''' return Order( oid=oid, account=account, symbol='btc.usdt.binance', price=Decimal('10'), size=1.0, action='buy', exec_mode='live', ) def ack( oid: str = 'one', account: str = 'paper', reqid: int|str = 1, ) -> BrokerdOrderAck: ''' Build the acknowledgement establishing an account/provider ID. ''' return BrokerdOrderAck(oid=oid, account=account, reqid=reqid) def fill( fill_id: str = 'fill-1', account: str = 'paper', reqid: int|str = 1, ) -> BrokerdFill: ''' Build an execution whose delivery may precede its acknowledgement. ''' return BrokerdFill( time_ns=1, broker_time=1.0, account=account, broker='binance', reqid=reqid, fill_id=fill_id, size=0.5, price=10.0, action='buy', ) def closed() -> BrokerdStatus: ''' Build a terminal provider status for the default test order. ''' return BrokerdStatus( time_ns=1, reqid=1, account='paper', broker='binance', status='closed', ) def test_cancel_before_ack_once() -> None: ''' A cancel can reach EMS while broker submission is suspended. Register intent, cancel twice, then repeat the ack. Only the first ack may produce a cancel, carrying the original account and oid. The earlier published snapshot must remain unchanged. ''' state: OrderState = OrderState() initial: Status = state.register(order()) assert not state.cancel('one').broker assert not state.cancel('one').broker effects: Effects = state.accept(ack()) assert len(effects.broker) == 1 cancel: BrokerdCancel = effects.broker[0] assert (cancel.oid, cancel.reqid, cancel.account) == ( 'one', 1, 'paper', ) assert not state.accept(ack()).broker assert initial.cancel_called is False @pytest.mark.parametrize('terminal_first', [True, False]) def test_early_duplicate_and_late_fills( terminal_first: bool, ) -> None: ''' Native callbacks may precede an ack and a final fill may follow closed. Buffer both event orders, correlate with the ack, then repeat the fill and inject a distinct late execution. Each fill must be emitted once; neither may reopen the completed dialog. ''' state: OrderState = OrderState() state.register(order()) events: list[BrokerdStatus|BrokerdFill] = ( [closed(), fill()] if terminal_first else [fill(), closed()] ) event: BrokerdStatus|BrokerdFill for event in events: assert not state.accept(event).clients effects: Effects = state.accept(ack()) responses: list[str] = [] msg: Status for msg in effects.clients: responses.append(msg.resp) assert responses == ( ['closed', 'fill'] if terminal_first else ['fill', 'closed'] ) assert not state.accept(fill()).clients assert state.accept(fill('fill-2')).clients[0].resp == 'fill' assert not state.active assert state.terminal['one'].status.resp == 'closed' def test_closed_before_ack_cancels_no_finished_order() -> None: ''' An order can finish while its ack is delayed and cancel is queued. Replay the pre-ack terminal status before generating effects so the delayed acknowledgement cannot cancel an already closed order. ''' state: OrderState = OrderState() state.register(order()) state.cancel('one') state.accept(closed()) assert not state.accept(ack()).broker def test_accounts_partition_request_ids() -> None: ''' Separate broker sessions may both allocate request ID one. Register and acknowledge two accounts, then route their fills. Correlation must retain account ownership even when IDs collide. ''' state: OrderState = OrderState() oid: str account: str for oid, account in [('one', 'paper'), ('two', 'other')]: state.register(order(oid, account)) state.accept(ack(oid, account)) effect: Effects = state.accept(fill(account=account)) assert effect.clients[0].oid == oid def test_pre_ack_capacity_is_bounded() -> None: ''' A backend that never acknowledges must not grow EMS memory forever. Exceed a deliberately tiny buffer and assert a protocol failure preserves the already buffered execution instead of dropping it. ''' state: OrderState = OrderState(max_early=1) state.accept(fill()) with pytest.raises(ProtocolError, match='capacity'): state.accept(fill('fill-2')) assert state.early[('paper', 1)] == [fill()] def test_tombstone_eviction_removes_correlation() -> None: ''' Terminal dialogs have finite retention. Complete two orders with a one-entry limit; evicting the first must also remove every old request ID so a later event cannot target a missing dialog. ''' state: OrderState = OrderState(max_terminal=1) oid: str reqid: int for oid, reqid in [('one', 1), ('two', 2)]: state.register(order(oid)) state.accept(ack(oid, reqid=reqid)) state.accept(closed().copy(update={'reqid': reqid})) assert tuple(state.terminal) == ('two',) assert state.reqids == {('paper', 2): 'two'} def test_edit_before_ack_cannot_duplicate_submission() -> None: ''' An edit with no known backend request ID could place a second order. Hold the ack and try an edit; rejection must leave the original request intact for subsequent acknowledgement and cancellation. ''' state: OrderState = OrderState() initial: Status = state.register(order()) with pytest.raises( ProtocolError, match='before acknowledgement', ): state.register(order().copy(update={'price': Decimal('11')})) assert state.active['one'] is initial def test_edit_ack_keeps_late_fills_on_original_dialog() -> None: ''' An edit may replace the provider request ID while executions for the old ID remain in flight. A delayed duplicate ack must not revert the cancel target, and either ID must still route fills to the same EMS order. Cancel once per accepted request identity. ''' state: OrderState = OrderState() state.register(order()) state.accept(ack()) state.register(order().copy(update={'price': Decimal('11')})) state.accept(ack(reqid=2)) state.accept(ack(reqid=1)) assert state.active['one'].reqid == 2 assert state.cancel('one').broker[0].reqid == 2 assert not state.cancel('one').broker assert state.accept(fill(reqid=1)).clients[0].oid == 'one' effects: Effects = state.accept(fill('fill-2', reqid=2)) assert effects.clients[0].oid == 'one' assert state.active['one'].reqid == 2 state.accept(closed()) assert 'one' in state.active def test_startup_collision_does_not_register_second_order() -> None: ''' Two existing orders cannot claim one account/request ID pair. Correlate a normal order, then inject a startup order with the same provider ID. Rejection leaves ``OrderState.active`` and ``OrderState.dialogs`` free of the rejected second order. ''' from piker.clearing._schema import BrokerdOpenOrder state: OrderState = OrderState() state.register(order()) state.accept(ack()) with pytest.raises(ProtocolError, match='ownership'): state.accept( BrokerdOpenOrder( reqid=1, order=order('two'), broker='binance', time_ns=1, ) ) assert tuple(state.dialogs) == ('one',) assert tuple(state.active) == ('one',) def test_submission_and_edit_rejections() -> None: ''' A new submission error has no future ack to unblock cancellation. Its typed request identifies a terminal rejection; an edit error must retain the original live control ID so it can still cancel. Exercise both with identical reasons to prove request semantics, instead of parsing provider error strings, choose the transition. ''' from piker.clearing._state import broker_request state: OrderState = OrderState() state.register(order()) error: BrokerdError = BrokerdError( account='paper', broker='binance', reason='rejected', request=broker_request(order()), ) assert state.accept(error).clients[0].resp == 'error' assert not state.active assert 'one' in state.terminal state = OrderState() state.register(order()) state.accept(ack()) error = error.copy( update={ 'request': broker_request(order(), 1), } ) state.accept(error) assert state.cancel('one').broker[0].reqid == 1