116 lines
3.3 KiB
Python
116 lines
3.3 KiB
Python
|
|
'''
|
||
|
|
Execution identity across IB fill and commission callbacks.
|
||
|
|
|
||
|
|
'''
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
from bidict import bidict
|
||
|
|
from ib_async import (
|
||
|
|
CommissionReport,
|
||
|
|
Contract,
|
||
|
|
Execution,
|
||
|
|
Fill,
|
||
|
|
Position,
|
||
|
|
)
|
||
|
|
import pytest
|
||
|
|
import trio
|
||
|
|
|
||
|
|
from piker.brokers.ib import broker
|
||
|
|
from piker.brokers.ib.ledger import api_trades_to_ledger_entries
|
||
|
|
|
||
|
|
|
||
|
|
def make_fill(tid: str) -> Fill:
|
||
|
|
return Fill(
|
||
|
|
Contract(conId=815824267, secType='FUT', symbol='MNQ'),
|
||
|
|
Execution(
|
||
|
|
execId=tid,
|
||
|
|
acctNumber='DU_TEST',
|
||
|
|
time=1789586554.0,
|
||
|
|
),
|
||
|
|
CommissionReport(),
|
||
|
|
1789586554.0,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_execution_id_survives_empty_commission() -> None:
|
||
|
|
'''
|
||
|
|
Flattening a default CommissionReport erased Execution.execId,
|
||
|
|
causing a real fill to be skipped as an ID-less adjustment. Pass
|
||
|
|
a fill with a valid execution and empty commission through ledger
|
||
|
|
conversion and assert that its account and execution survive.
|
||
|
|
|
||
|
|
'''
|
||
|
|
entries = api_trades_to_ledger_entries(
|
||
|
|
bidict({'DU_TEST': 'ib.algopaper'}),
|
||
|
|
[make_fill('execution-1')],
|
||
|
|
)
|
||
|
|
assert entries['ib.algopaper']['execution-1']['execId'] == (
|
||
|
|
'execution-1'
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('position_first', [True, False])
|
||
|
|
def test_overlapping_execution_commissions(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
position_first: bool,
|
||
|
|
) -> None:
|
||
|
|
'''
|
||
|
|
Two executions on one contract shared a single pending Fill.
|
||
|
|
A commission for execution A could therefore account execution B.
|
||
|
|
Queue both executions before either commission, delivering fees
|
||
|
|
in reverse order, and exercise positions before and after fees.
|
||
|
|
The deterministic channel order verifies every execution reaches
|
||
|
|
accounting once with its own fee, including a zero commission.
|
||
|
|
|
||
|
|
'''
|
||
|
|
fills = [make_fill('A'), make_fill('B')]
|
||
|
|
pos = Position('DU_TEST', fills[0].contract, 2, 58000)
|
||
|
|
recorded = []
|
||
|
|
|
||
|
|
async def capture(*args, ibpos, fill=None):
|
||
|
|
if fill is not None:
|
||
|
|
recorded.append((
|
||
|
|
fill.execution.execId,
|
||
|
|
fill.commissionReport.execId,
|
||
|
|
fill.commissionReport.commission,
|
||
|
|
ibpos.account,
|
||
|
|
))
|
||
|
|
|
||
|
|
monkeypatch.setattr(broker, 'emit_pp_update', capture)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
broker, 'pack_position', lambda *args: ('815824267', {})
|
||
|
|
)
|
||
|
|
|
||
|
|
async def main():
|
||
|
|
send, recv = trio.open_memory_channel(10)
|
||
|
|
events = [
|
||
|
|
('execDetailsEvent', (
|
||
|
|
SimpleNamespace(contract=fill.contract), fill,
|
||
|
|
))
|
||
|
|
for fill in fills
|
||
|
|
]
|
||
|
|
events.extend([
|
||
|
|
('commissionReportEvent', (
|
||
|
|
fills[1], CommissionReport(execId='B', commission=0),
|
||
|
|
)),
|
||
|
|
('commissionReportEvent', (
|
||
|
|
fills[0], CommissionReport(execId='A', commission=.61),
|
||
|
|
)),
|
||
|
|
])
|
||
|
|
events.insert(0 if position_first else len(events), (
|
||
|
|
'positionEvent', pos,
|
||
|
|
))
|
||
|
|
async with send, recv:
|
||
|
|
for event in events:
|
||
|
|
send.send_nowait(event)
|
||
|
|
await send.aclose()
|
||
|
|
await broker.deliver_trade_events(
|
||
|
|
recv, None, {}, {}, {}, {}, None,
|
||
|
|
)
|
||
|
|
|
||
|
|
trio.run(main)
|
||
|
|
assert recorded == [
|
||
|
|
('B', 'B', 0, 'DU_TEST'),
|
||
|
|
('A', 'A', .61, 'DU_TEST'),
|
||
|
|
]
|