459 lines
15 KiB
Python
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)
|