piker/tests/test_ems_ipc.py

459 lines
15 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/>.
'''
Real local-actor qualification of clearing's directional contracts.
No market-data service, credentials, or persistent account is opened.
'''
from decimal import Decimal
import pytest
import tractor
from tractor.trionics import collapse_eg
import trio
from piker.clearing._codec import ProtocolError, validate
from piker.clearing._protocol import (
BrokerdPhase,
Stream,
open_stream,
)
from piker.clearing._schema import (
BrokerdEvent,
BrokerdFill,
BrokerdOpenOrder,
BrokerdOpenOrdersComplete,
BrokerdOrder,
BrokerdOrderAck,
BrokerdPayload,
BrokerdRequest,
BrokerdStartup,
Order,
validate_startup,
BrokerdStatus,
Cancel,
EmsEvent,
EmsPayload,
EmsRequest,
EmsStartup,
Status,
validate_ems_startup,
)
from piker.clearing._state import Effects, OrderState
@tractor.context(pld_spec=BrokerdPayload)
async def fake_broker(
ctx: tractor.Context,
fault: str,
empty: bool,
) -> None:
'''
Exchange typed broker traffic or inject a selected wire fault.
``fault`` selects a provider exception, wrong direction, wrong
phase, or malformed execution. ``empty`` omits the existing order
while retaining the explicit completion barrier.
'''
startup: BrokerdStartup = BrokerdStartup(
broker='fixture',
mode='paper',
accounts=('paper',),
)
await ctx.started(validate_startup(startup))
stream: Stream[BrokerdRequest, BrokerdEvent]
async with open_stream(
ctx,
BrokerdRequest,
BrokerdEvent,
) as stream:
if not empty:
await stream.send(
BrokerdOpenOrder(
broker='fixture',
reqid='existing-1',
time_ns=1,
order=Order(
oid='fixture:paper:existing-1',
account='paper',
symbol='btc.spot.fixture',
price=Decimal('12.25'),
size=1.0,
action='buy',
exec_mode='live',
),
)
)
await stream.send(
BrokerdOpenOrdersComplete(
broker='fixture',
)
)
request: BrokerdOrder = await stream.receive()
if fault == 'peer_error':
raise RuntimeError('fixture provider failed')
if fault == 'wrong_direction':
# Deliberately bypass the local send guard to prove that
# the other actor's scoped wire decoder rejects this.
await stream.raw.send(request)
elif fault == 'wrong_phase':
await stream.raw.send(startup)
elif fault == 'malformed':
await stream.raw.send(
{
'type': 'broker_fill',
'time_ns': 1,
'broker_time': 1.0,
'reqid': 1,
'account': 'paper',
'broker': 'fixture',
'action': 'buy',
'size': 1.0,
'price': 12.25,
}
)
else:
await stream.send(
BrokerdOrderAck(
oid=request.oid,
reqid=1,
account='paper',
)
)
await stream.send(
BrokerdFill(
time_ns=1,
broker_time=1.0,
reqid=1,
account='paper',
broker='fixture',
fill_id='fill-1',
action='buy',
size=1.0,
price=12.25,
)
)
# A malicious peer must not hold teardown open after its
# receiver rejects a payload. The parent cancels this sleep.
await trio.sleep_forever()
async def exercise(fault: str = '', empty: bool = False) -> None:
'''
Own the fake broker actor tree through exchange and teardown.
``fault`` and ``empty`` select ``fake_broker`` behavior. A Trio
deadline encloses all actor scopes; the test runner adds an
independent process timeout.
'''
# The deadline encloses the entire actor tree, including
# teardown.
# An outer process timeout remains necessary for shielded runtime
# failures; the run-tests harness supplies that second bound.
with trio.fail_after(15):
nursery: tractor.ActorNursery
async with (
collapse_eg(),
tractor.open_root_actor(
name='clearing-schema-test',
tpt_bind_addrs=[('127.0.0.1', 0)],
),
tractor.open_nursery() as nursery,
):
portal: tractor.Portal = await nursery.start_actor(
'fake-broker',
enable_modules=[__name__],
)
ctx: tractor.Context
startup: BrokerdStartup
async with portal.open_context(
fake_broker,
fault=fault,
empty=empty,
) as (ctx, startup):
phase: BrokerdPhase = BrokerdPhase(startup)
stream: Stream[BrokerdEvent, BrokerdRequest]
async with open_stream(
ctx,
BrokerdEvent,
BrokerdRequest,
) as stream:
if not empty:
existing: BrokerdEvent = phase.receive(
await stream.receive(),
)
assert isinstance(existing, BrokerdOpenOrder)
assert isinstance(existing.order, Order)
assert existing.order.price == Decimal(
'12.25',
)
phase.receive(await stream.receive())
assert phase.complete
request: BrokerdOrder = BrokerdOrder(
oid='client-1',
account='paper',
time_ns=1,
symbol='btc.spot',
price=Decimal('12.25'),
size=1.0,
action='buy',
)
await stream.send(phase.request(request))
ack: BrokerdEvent = phase.receive(
await stream.receive()
)
assert type(ack) is BrokerdOrderAck
fill: BrokerdEvent = phase.receive(
await stream.receive()
)
assert type(fill) is BrokerdFill
assert fill.fill_id == 'fill-1'
await ctx.cancel()
await nursery.cancel()
@pytest.mark.parametrize('empty', [True, False])
def test_typed_startup_and_stream(empty: bool) -> None:
'''
Typed startup must precede an explicit completion barrier, even
with no existing orders. Exercise both snapshots through a real
child process and exchange a request, ack and identified fill.
Nested Order and Decimal types must survive actor serialization.
'''
trio.run(exercise, '', empty)
@pytest.mark.parametrize(
'fault',
[
'wrong_direction',
'wrong_phase',
'malformed',
],
)
def test_wire_rejection_and_bounded_teardown(fault: str) -> None:
'''
A peer bypassing outbound checks must still fail at wire decode.
Send a request in the event direction, a startup in the stream,
or a fill without its execution ID. Require MsgTypeError and let
exercise's deadline cover cancellation of the sleeping producer.
'''
with pytest.raises(tractor.MsgTypeError):
trio.run(exercise, fault)
def test_provider_failure_is_not_swallowed() -> None:
'''
Provider failure while a client waits for an ack must abort that
receive and terminate the actor tree. Raise inside the fake
broker and require its remote RuntimeError within the teardown
deadline.
'''
error: pytest.ExceptionInfo[tractor.RemoteActorError]
with pytest.raises(tractor.RemoteActorError) as error:
trio.run(exercise, 'peer_error')
assert error.value.boxed_type is RuntimeError
@tractor.context(pld_spec=BrokerdPayload)
async def wrong_final_result(
ctx: tractor.Context,
) -> BrokerdStartup:
'''
Return a wire-valid startup in the forbidden final phase.
'''
startup: BrokerdStartup = BrokerdStartup(
broker='fixture',
mode='paper',
accounts=('paper',),
)
await ctx.started(startup)
return startup
def test_final_result_has_its_own_phase() -> None:
'''
A lifecycle union permits startup objects but a finished broker
context returns only None. Receive a wire-valid wrong Return and
prove the phase validator rejects it after the child terminates.
'''
async def main() -> None:
'''
Receive a completed child result and reject its startup
payload.
'''
with trio.fail_after(15):
nursery: tractor.ActorNursery
async with (
collapse_eg(),
tractor.open_root_actor(
tpt_bind_addrs=[('127.0.0.1', 0)],
),
tractor.open_nursery() as nursery,
):
portal: tractor.Portal = await nursery.start_actor(
'wrong-return',
enable_modules=[__name__],
)
ctx: tractor.Context
_: BrokerdStartup
async with portal.open_context(
wrong_final_result,
) as (ctx, _):
result: BrokerdPayload = (
await ctx.wait_for_result()
)
with pytest.raises(ProtocolError):
validate(result, type(None))
await nursery.cancel()
trio.run(main)
@tractor.context(pld_spec=EmsPayload)
async def fake_ems(ctx: tractor.Context) -> None:
'''
Apply client cancellation before publishing the provider ack.
The receive order fixes the interleaving: ``OrderState.cancel()``
runs before ``OrderState.accept()`` handles the ack. Assert one
deferred cancellation and publish its terminal response.
'''
await ctx.started(
EmsStartup(
broker='fixture',
positions=(),
accounts=('paper',),
dialogs=(),
)
)
state: OrderState = OrderState()
stream: Stream[EmsRequest, EmsEvent]
async with open_stream(ctx, EmsRequest, EmsEvent) as stream:
order: Order = await stream.receive()
state.register(order)
cancel: Cancel = await stream.receive()
assert cancel.oid == order.oid
assert not state.cancel(cancel.oid).broker
effects: Effects = state.accept(
BrokerdOrderAck(
oid=order.oid,
account=order.account,
reqid=1,
)
)
assert len(effects.broker) == 1
assert effects.broker[0].oid == order.oid
effects = state.accept(
BrokerdStatus(
time_ns=1,
account=order.account,
broker='fixture',
reqid=1,
status='canceled',
)
)
await stream.send(effects.clients[0])
await trio.sleep_forever()
def test_ems_cancel_before_ack_over_ipc() -> None:
'''
Client Order and Cancel must arrive as typed requests and produce
an immutable nested Status. Hold the fake provider ack until the
cancel reaches the remote EMS reducer, then require one deferred
broker cancel and a terminal client response through real IPC.
'''
async def main() -> None:
'''
Send ordered client intent and require a typed canceled
snapshot.
'''
with trio.fail_after(15):
nursery: tractor.ActorNursery
async with (
collapse_eg(),
tractor.open_root_actor(
tpt_bind_addrs=[('127.0.0.1', 0)],
),
tractor.open_nursery() as nursery,
):
portal: tractor.Portal = await nursery.start_actor(
'fake-ems',
enable_modules=[__name__],
)
ctx: tractor.Context
startup: EmsStartup
async with portal.open_context(
fake_ems,
) as (ctx, startup):
startup = validate_ems_startup(
startup
)
assert startup.dialogs == ()
stream: Stream[EmsEvent, EmsRequest]
async with open_stream(
ctx,
EmsEvent,
EmsRequest,
) as stream:
order: Order = Order(
oid='client-1',
account='paper',
symbol='btc.spot.fixture',
price=Decimal('12.25'),
size=1.0,
action='buy',
exec_mode='live',
)
await stream.send(order)
await stream.send(
Cancel(
oid=order.oid,
symbol=order.symbol,
)
)
status: EmsEvent = await stream.receive()
assert isinstance(status, Status)
assert status.req == order
assert status.resp == 'canceled'
assert status.cancel_called
assert isinstance(
status.brokerd_event,
BrokerdStatus,
)
await ctx.cancel()
await nursery.cancel()
trio.run(main)