334 lines
8.8 KiB
Python
334 lines
8.8 KiB
Python
|
|
# 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)
|