215 lines
6.0 KiB
Python
215 lines
6.0 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 lifecycle conformance for every future brokerd adapter.
|
|
|
|
'''
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from piker.clearing._codec import ProtocolError
|
|
from piker.clearing._protocol import BrokerdPhase
|
|
from piker.clearing._schema import (
|
|
BrokerdOpenOrder,
|
|
BrokerdOpenOrdersComplete,
|
|
BrokerdOrder,
|
|
BrokerdPosition,
|
|
BrokerdStartup,
|
|
EmsStartup,
|
|
Order,
|
|
Status,
|
|
validate_ems_startup,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('broker', ['ib', 'kraken', 'binance'])
|
|
@pytest.mark.parametrize('mode', ['live', 'paper'])
|
|
def test_startup_barrier(broker: str, mode: str) -> None:
|
|
'''
|
|
Every provider and paper mode shares one lifecycle contract.
|
|
Prohibit order submission before completion, allow an empty
|
|
snapshot, then reject a repeated completion or existing order.
|
|
These fixtures qualify the protocol, not the unmigrated adapters.
|
|
|
|
'''
|
|
account: str = 'paper' if mode == 'paper' else f'{broker}.test'
|
|
phase: BrokerdPhase = BrokerdPhase(
|
|
BrokerdStartup(
|
|
broker=broker,
|
|
mode=mode,
|
|
accounts=(account,),
|
|
)
|
|
)
|
|
request: BrokerdOrder = BrokerdOrder(
|
|
oid='one',
|
|
account=account,
|
|
symbol='btc.spot',
|
|
time_ns=1,
|
|
price=Decimal('10'),
|
|
size=1.0,
|
|
action='buy',
|
|
)
|
|
with pytest.raises(ProtocolError, match='before startup'):
|
|
phase.request(request)
|
|
complete: BrokerdOpenOrdersComplete = BrokerdOpenOrdersComplete(
|
|
broker=broker
|
|
)
|
|
assert phase.receive(complete) == complete
|
|
assert phase.request(request) == request
|
|
with pytest.raises(ProtocolError, match='Duplicate'):
|
|
phase.receive(complete)
|
|
with pytest.raises(ProtocolError, match='after startup'):
|
|
phase.receive(
|
|
BrokerdOpenOrder(
|
|
reqid=1,
|
|
time_ns=1,
|
|
broker=broker,
|
|
order=Order(
|
|
oid='existing',
|
|
account=account,
|
|
symbol=f'btc.spot.{broker}',
|
|
price=Decimal('10'),
|
|
size=1.0,
|
|
action='buy',
|
|
exec_mode='live',
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
def test_paper_fallback_never_opens_live_stream() -> None:
|
|
'''
|
|
A backend without credentials hands control to a paper endpoint.
|
|
Its fallback startup cannot masquerade as an empty live snapshot:
|
|
even a syntactically valid completion event must be rejected.
|
|
|
|
'''
|
|
phase: BrokerdPhase = BrokerdPhase(
|
|
BrokerdStartup(
|
|
broker='ib',
|
|
mode='paper_fallback',
|
|
)
|
|
)
|
|
with pytest.raises(ProtocolError, match='Fallback'):
|
|
phase.receive(BrokerdOpenOrdersComplete(broker='ib'))
|
|
assert not phase.complete
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'changes',
|
|
[
|
|
{'broker': 'kraken'},
|
|
{'account': 'other'},
|
|
{'symbol': 'btc.spot.kraken'},
|
|
],
|
|
)
|
|
def test_event_ownership(changes: dict[str, str]) -> None:
|
|
'''
|
|
A well-typed event may still belong to another session. Announce
|
|
one broker/account, then alter a position's owner or market
|
|
suffix. ``BrokerdPhase.receive()`` must reject it before EMS can
|
|
publish the position.
|
|
|
|
'''
|
|
phase: BrokerdPhase = BrokerdPhase(
|
|
BrokerdStartup(
|
|
broker='ib',
|
|
mode='live',
|
|
accounts=('ib.test',),
|
|
)
|
|
)
|
|
position: BrokerdPosition = BrokerdPosition(
|
|
broker='ib',
|
|
account='ib.test',
|
|
symbol='btc.spot.ib',
|
|
size=1.0,
|
|
avg_price=10.0,
|
|
)
|
|
with pytest.raises(ProtocolError):
|
|
phase.receive(position.copy(update=changes))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'field,value',
|
|
[
|
|
('oid', 'mismatch'),
|
|
('resp', 'closed'),
|
|
('resp', 'canceled'),
|
|
],
|
|
)
|
|
def test_ems_snapshot_has_only_active_owned_dialogs(
|
|
field: str,
|
|
value: str,
|
|
) -> None:
|
|
'''
|
|
Reconnect snapshots must not revive completed orders or associate
|
|
a request with another oid. Corrupt one field in a valid snapshot
|
|
and require semantic rejection after ordinary schema decoding.
|
|
|
|
'''
|
|
order: Order = Order(
|
|
oid='one',
|
|
account='paper',
|
|
symbol='btc.spot.ib',
|
|
price=Decimal('10'),
|
|
size=1.0,
|
|
action='buy',
|
|
exec_mode='live',
|
|
)
|
|
status: Status = Status(
|
|
time_ns=1, oid='one', resp='open', req=order
|
|
)
|
|
startup: EmsStartup = EmsStartup(
|
|
broker='ib',
|
|
accounts=('paper',),
|
|
positions=(),
|
|
dialogs=(status,),
|
|
)
|
|
assert validate_ems_startup(startup) == startup
|
|
bad: EmsStartup = startup.copy(
|
|
update={
|
|
'dialogs': (status.copy(update={field: value}),),
|
|
}
|
|
)
|
|
with pytest.raises(ProtocolError):
|
|
validate_ems_startup(bad)
|
|
|
|
|
|
def test_ems_startup_requires_an_account() -> None:
|
|
'''
|
|
EMS readiness means a live or paper account can accept requests.
|
|
An empty snapshot is legal, but an empty account set cannot
|
|
provide that service. Reject it even with no positions or
|
|
dialogs.
|
|
|
|
'''
|
|
with pytest.raises(
|
|
ProtocolError,
|
|
match='requires an account',
|
|
):
|
|
validate_ems_startup(
|
|
EmsStartup(
|
|
broker='ib',
|
|
accounts=(),
|
|
positions=(),
|
|
dialogs=(),
|
|
)
|
|
)
|