# 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 # . ''' 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)