# 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 # . ''' Offline clearing schema and local API boundary conformance. ''' from decimal import Decimal from types import UnionType import msgspec from msgspec import msgpack import pytest from piker.clearing._codec import ( Message, ProtocolError, decoder, validate, ) from piker.clearing._schema import ( BrokerdCancel, BrokerdError, BrokerdEvent, BrokerdFill, BrokerdOpenOrder, BrokerdOpenOrdersComplete, BrokerdOrder, BrokerdOrderAck, BrokerdPayload, BrokerdPosition, BrokerdRequest, BrokerdStartup, BrokerdStatus, Cancel, EmsEvent, EmsPayload, EmsRequest, EmsStartup, Order, Status, validate_startup, ) ORDER: Order = Order( oid='client-1', symbol='btc.usdt.binance', account='paper', action='buy', exec_mode='live', price=Decimal('12.25'), size=1.0, brokers=('binance',), ) REQUEST: BrokerdOrder = BrokerdOrder( oid=ORDER.oid, account=ORDER.account, symbol='btc.usdt', time_ns=1, price=ORDER.price, size=ORDER.size, action='buy', ) POSITION: BrokerdPosition = BrokerdPosition( broker='binance', account='paper', symbol=ORDER.symbol, size=1.0, avg_price=12.25, ) EVENTS: tuple[BrokerdEvent, ...] = ( BrokerdOrderAck(oid=ORDER.oid, reqid=7, account='paper'), BrokerdStatus( time_ns=1, reqid=7, status='open', account='paper', broker='binance', ), BrokerdFill( time_ns=1, broker_time=1.0, reqid=7, fill_id='exec-1', account='paper', broker='binance', action='buy', size=1.0, price=12.25, ), BrokerdError( account='paper', broker='binance', reason='rejected', request=REQUEST, ), POSITION, BrokerdOpenOrder( reqid=7, order=ORDER, broker='binance', time_ns=1, ), BrokerdOpenOrdersComplete(broker='binance'), ) STATUS: Status = Status( time_ns=1, oid=ORDER.oid, resp='fill', req=ORDER, brokerd_event=EVENTS[2], ) STARTUP: BrokerdStartup = BrokerdStartup( broker='binance', mode='paper', accounts=('paper',), positions=(POSITION,), ) event: BrokerdEvent @pytest.mark.parametrize( 'msg,spec', [ (ORDER, EmsRequest), (Cancel(oid=ORDER.oid, symbol=ORDER.symbol), EmsRequest), (REQUEST, BrokerdRequest), ( BrokerdCancel( oid=ORDER.oid, symbol=ORDER.symbol, account='paper', time_ns=1, reqid=7, ), BrokerdRequest, ), *((event, BrokerdEvent) for event in EVENTS), (STATUS, EmsEvent), (STARTUP, BrokerdPayload), ( EmsStartup( broker='binance', positions=(POSITION,), accounts=('paper',), dialogs=(STATUS,), ), EmsPayload, ), ], ) def test_protocol_roundtrip( msg: Message|None, spec: type|UnionType, ) -> None: ''' Every clearing variant must round-trip through its real union, including nested orders, fills, Decimal prices and startup tuples. Decoding with the declared peer spec proves discriminators are unique and fields are supported without hooks or Any fallbacks. ''' decoded: Message = decoder(spec).decode(msgpack.encode(msg)) assert type(decoded) is type(msg) assert decoded == msg @pytest.mark.parametrize( 'msg,spec', [ (ORDER, BrokerdRequest), (REQUEST, BrokerdEvent), (EVENTS[0], BrokerdRequest), (STARTUP, BrokerdEvent), (STATUS, EmsRequest), (None, EmsRequest), ], ) def test_wrong_direction_and_phase( msg: Message|None, spec: type|UnionType, ) -> None: ''' A valid lifecycle message is invalid in another direction or streaming phase. Test both the local send guard and wire decoder: sharing a transport does not widen an endpoint's receive set. ''' with pytest.raises(ProtocolError): validate(msg, spec) with pytest.raises(msgspec.ValidationError): decoder(spec).decode(msgpack.encode(msg)) @pytest.mark.parametrize( 'changes', [ {'extra': 'unknown'}, {'action': 'cancel'}, {'size': '1.0'}, {'account': None}, {'price': []}, ], ) def test_invalid_order_fields(changes: dict[str, object]) -> None: ''' Foreign fields and incorrectly typed fields must fail at decode. Modify a genuine encoded order so only the tested field changes; successful parsing of the remaining fields cannot hide the error. ''' raw: dict[str, object] = ( msgpack.decode(msgpack.encode(ORDER)) | changes ) with pytest.raises(msgspec.ValidationError): decoder(EmsRequest).decode(msgpack.encode(raw)) def test_construction_is_not_validation() -> None: ''' msgspec constructors accept an invalid status literal. The local boundary must reject it before any transport send is attempted. A structurally valid tagged dictionary is also rejected locally, although its wire representation correctly decodes to a Struct. ''' bad: BrokerdStatus = BrokerdStatus( time_ns=1, reqid=7, status='filled', account='paper', broker='binance', ) with pytest.raises(msgspec.ValidationError): validate(bad, BrokerdEvent) raw: dict[str, object] = msgpack.decode(msgpack.encode(ORDER)) with pytest.raises(ProtocolError): validate(raw, EmsRequest) assert decoder(EmsRequest).decode(msgpack.encode(raw)) == ORDER def test_immutable_snapshot_and_copy() -> None: ''' Editing an order must not mutate a previously published snapshot. Exercise the public copy method, frozen fields and nested request validation so an invalid replacement cannot enter local history. ''' replacement: Order = ORDER.copy(update={'price': Decimal('13')}) assert ORDER.price == Decimal('12.25') assert replacement.price == Decimal('13') with pytest.raises(AttributeError): ORDER.price = Decimal('14') with pytest.raises(msgspec.ValidationError): STATUS.copy( update={ 'req': msgspec.structs.replace(ORDER, size='bad'), } ) @pytest.mark.parametrize( 'changes', [ {'accounts': ('paper', 'paper')}, {'accounts': (), 'positions': ()}, {'mode': 'live', 'accounts': (), 'positions': ()}, {'accounts': ('other',), 'positions': ()}, {'accounts': ('other',)}, {'broker': 'kraken'}, {'positions': (POSITION, POSITION)}, {'mode': 'paper_fallback'}, ], ) def test_startup_ownership(changes: dict[str, object]) -> None: ''' A typed startup can still claim another backend's positions or duplicate an account. Alter one invariant at a time and verify the semantic validator rejects it before EMS publishes the state. ''' with pytest.raises(ProtocolError): validate_startup(STARTUP.copy(update=changes)) assert validate_startup(STARTUP) == STARTUP @pytest.mark.parametrize( 'bad', [ {'req': msgpack.decode(msgpack.encode(ORDER))}, { 'req': msgspec.structs.replace( ORDER, price=Decimal('NaN'), ), }, {'req': msgspec.structs.replace(ORDER, price='12.25')}, {'req': msgspec.structs.replace(ORDER, brokers=['binance'])}, {'req': msgspec.structs.replace(ORDER, size=float('inf'))}, ], ) def test_local_nested_objects_are_typed( bad: dict[str, object], ) -> None: ''' A tagged nested map can encode identically to an Order and a string can encode identically to Decimal. The local API must still require typed objects, immutable tuples and finite prices. Replace nested fields without constructor validation and require rejection before the snapshot can enter a queue or order history. ''' with pytest.raises(ProtocolError): validate(msgspec.structs.replace(STATUS, **bad), EmsEvent)