2026-09-16 21:12:01 +00:00
|
|
|
'''
|
|
|
|
|
Execution identity across IB fill and commission callbacks.
|
|
|
|
|
|
|
|
|
|
'''
|
2026-09-17 22:24:17 +00:00
|
|
|
import asyncio
|
2026-09-16 21:12:01 +00:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
from bidict import bidict
|
2026-09-17 22:24:17 +00:00
|
|
|
from eventkit import Event
|
2026-09-16 21:12:01 +00:00
|
|
|
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'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 22:24:17 +00:00
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
2026-09-16 21:12:01 +00:00
|
|
|
@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'),
|
|
|
|
|
]
|