''' Execution identity across IB fill and commission callbacks. ''' import asyncio from types import SimpleNamespace from bidict import bidict from eventkit import Event 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' ) def test_callback_preserves_fill_and_disconnect() -> None: ''' The asyncio callback dropped commissionReportEvent's exact Fill, forcing the Trio side to guess by contract. Exercise the actual eventkit relay and assert it preserves the supplied object. After that callback, emit disconnectedEvent: the bridge must raise rather than return normally and leave its Trio owner asleep. One asyncio checkpoint lets recv_trade_updates install handlers before the test producer emits either event. ''' async def main(): ib = SimpleNamespace(**{ name: Event(name) for name in ( 'orderStatusEvent', 'commissionReportEvent', 'execDetailsEvent', 'positionEvent', 'disconnectedEvent', ) }) emitted = [] chan = SimpleNamespace( started_nowait=lambda _: None, send_nowait=emitted.append, ) client = SimpleNamespace(ib=ib, inline_errors=lambda _: None) fill = make_fill('A') report = CommissionReport(execId='A', commission=.61) async def produce(): await asyncio.sleep(0) ib.commissionReportEvent.emit(None, fill, report) ib.disconnectedEvent.emit() producer = asyncio.create_task(produce()) with pytest.raises(ConnectionError, match='socket'): await broker.recv_trade_updates(chan, client) await producer assert emitted == [('commissionReportEvent', (fill, report))] asyncio.run(main()) @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'), ]