piker/tests/test_ems_messages.py

334 lines
8.8 KiB
Python
Raw Normal View History

# 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
# <https://www.gnu.org/licenses/>.
'''
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)