Skip to content

Commit 29a8840

Browse files
authored
Fix fill reconciliation when discrepancy with redis cache (#2706)
1 parent aaa54c3 commit 29a8840

File tree

2 files changed

+178
-1
lines changed

2 files changed

+178
-1
lines changed

nautilus_trader/live/execution_engine.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1080,7 +1080,24 @@ def _reconcile_fill_report(
10801080
instrument: Instrument,
10811081
) -> bool:
10821082
if report.trade_id in order.trade_ids:
1083-
return True # Fill already applied (assumes consistent trades)
1083+
# Fill already applied, but check if data is consistent
1084+
cached_fill = self._get_cached_fill_for_trade_id(order, report.trade_id)
1085+
1086+
if cached_fill is not None:
1087+
if self._fill_reports_differ(cached_fill, report):
1088+
self._log.warning(
1089+
f"Fill report data differs from cached data for trade_id {report.trade_id}. "
1090+
f"Cached: qty={cached_fill.last_qty}, px={cached_fill.last_px}, "
1091+
f"commission={cached_fill.commission}, liquidity={cached_fill.liquidity_side}, "
1092+
f"ts_event={cached_fill.ts_event}. "
1093+
f"Broker: qty={report.last_qty}, px={report.last_px}, "
1094+
f"commission={report.commission}, liquidity={report.liquidity_side}, "
1095+
f"ts_event={report.ts_event}. "
1096+
f"Continuing reconciliation with cached data to avoid state corruption.",
1097+
)
1098+
1099+
return True # Fill already applied, continue with cached data
1100+
10841101
try:
10851102
self._generate_order_filled(order, report, instrument)
10861103
except InvalidStateTrigger as e:
@@ -1094,6 +1111,55 @@ def _reconcile_fill_report(
10941111
)
10951112
return True
10961113

1114+
def _get_cached_fill_for_trade_id(self, order: Order, trade_id: TradeId) -> OrderFilled | None:
1115+
"""
1116+
Get the cached OrderFilled event for the given trade_id from the order's events.
1117+
1118+
Parameters
1119+
----------
1120+
order : Order
1121+
The order to search for the fill event.
1122+
trade_id : TradeId
1123+
The trade ID to search for.
1124+
1125+
Returns
1126+
-------
1127+
OrderFilled | None
1128+
The cached OrderFilled event if found, else None.
1129+
1130+
"""
1131+
for event in order.events:
1132+
if isinstance(event, OrderFilled) and event.trade_id == trade_id:
1133+
return event
1134+
1135+
return None
1136+
1137+
def _fill_reports_differ(self, cached_fill: OrderFilled, report: FillReport) -> bool:
1138+
"""
1139+
Check if the cached fill data differs from the broker's fill report.
1140+
1141+
Parameters
1142+
----------
1143+
cached_fill : OrderFilled
1144+
The cached OrderFilled event.
1145+
report : FillReport
1146+
The broker's fill report.
1147+
1148+
Returns
1149+
-------
1150+
bool
1151+
True if the data differs, else False.
1152+
1153+
"""
1154+
# Compare key fields that could differ
1155+
return (
1156+
cached_fill.last_qty != report.last_qty
1157+
or cached_fill.last_px != report.last_px
1158+
or cached_fill.commission != report.commission
1159+
or cached_fill.liquidity_side != report.liquidity_side
1160+
or cached_fill.ts_event != report.ts_event
1161+
)
1162+
10971163
def _reconcile_position_report(self, report: PositionStatusReport) -> bool:
10981164
if report.venue_position_id is not None:
10991165
return self._reconcile_position_report_hedging(report)

tests/unit_tests/live/test_execution_recon.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from nautilus_trader.model.enums import TriggerType
4141
from nautilus_trader.model.identifiers import ClientId
4242
from nautilus_trader.model.identifiers import ClientOrderId
43+
from nautilus_trader.model.identifiers import PositionId
4344
from nautilus_trader.model.identifiers import StrategyId
4445
from nautilus_trader.model.identifiers import TradeId
4546
from nautilus_trader.model.identifiers import Venue
@@ -543,3 +544,113 @@ async def test_reconcile_state_no_cached_with_partially_filled_order_and_cancele
543544
assert order.last_trade_id == TradeId("1")
544545
assert order.quantity == Quantity.from_int(10_000)
545546
assert order.filled_qty == Quantity.from_int(5_000)
547+
548+
@pytest.mark.asyncio()
549+
async def test_reconcile_state_with_cached_order_and_different_fill_data(self):
550+
# Arrange: Create a cached order with a fill
551+
venue_order_id = VenueOrderId("1")
552+
client_order_id = ClientOrderId("O-123456")
553+
554+
# Create and cache an order with an initial fill
555+
order = self.order_factory.market(
556+
instrument_id=AUDUSD_SIM.id,
557+
order_side=OrderSide.BUY,
558+
quantity=Quantity.from_int(10_000),
559+
client_order_id=client_order_id,
560+
)
561+
562+
# Submit and accept the order
563+
submitted = TestEventStubs.order_submitted(order, account_id=self.account_id)
564+
order.apply(submitted)
565+
self.cache.add_order(order, position_id=None)
566+
567+
accepted = TestEventStubs.order_accepted(
568+
order,
569+
account_id=self.account_id,
570+
venue_order_id=venue_order_id,
571+
)
572+
order.apply(accepted)
573+
self.cache.update_order(order)
574+
575+
# Apply an initial fill with specific data
576+
initial_fill = TestEventStubs.order_filled(
577+
order,
578+
instrument=AUDUSD_SIM,
579+
position_id=PositionId("P-1"),
580+
strategy_id=StrategyId("S-1"),
581+
last_qty=Quantity.from_int(5_000),
582+
last_px=Price.from_str("1.00000"),
583+
liquidity_side=LiquiditySide.MAKER,
584+
trade_id=TradeId("TRADE-1"),
585+
)
586+
order.apply(initial_fill)
587+
self.cache.update_order(order)
588+
589+
# Now create a broker report with different fill data for the same trade_id
590+
order_report = OrderStatusReport(
591+
account_id=self.account_id,
592+
instrument_id=AUDUSD_SIM.id,
593+
client_order_id=client_order_id,
594+
venue_order_id=venue_order_id,
595+
order_side=OrderSide.BUY,
596+
order_type=OrderType.MARKET,
597+
time_in_force=TimeInForce.GTC,
598+
order_status=OrderStatus.PARTIALLY_FILLED,
599+
quantity=Quantity.from_int(10_000),
600+
filled_qty=Quantity.from_int(5_000),
601+
avg_px=Decimal("1.00000"),
602+
report_id=UUID4(),
603+
ts_accepted=0,
604+
ts_triggered=0,
605+
ts_last=0,
606+
ts_init=0,
607+
)
608+
609+
# Fill report with DIFFERENT data than cached (different price, commission, liquidity)
610+
fill_report = FillReport(
611+
account_id=self.account_id,
612+
instrument_id=AUDUSD_SIM.id,
613+
client_order_id=client_order_id,
614+
venue_order_id=venue_order_id,
615+
venue_position_id=None,
616+
trade_id=TradeId("TRADE-1"), # Same trade_id as cached fill
617+
order_side=OrderSide.BUY,
618+
last_qty=Quantity.from_int(5_000),
619+
last_px=Price.from_str("1.00100"), # Different price
620+
commission=Money(10.0, USD), # Different commission
621+
liquidity_side=LiquiditySide.TAKER, # Different liquidity side
622+
report_id=UUID4(),
623+
ts_event=1000, # Different timestamp
624+
ts_init=0,
625+
)
626+
627+
self.client.add_order_status_report(order_report)
628+
self.client.add_fill_reports(venue_order_id, [fill_report])
629+
630+
# Act
631+
result = await self.exec_engine.reconcile_state()
632+
633+
# Assert: Reconciliation should succeed despite different fill data
634+
assert result
635+
636+
# The order should still exist and maintain its cached state
637+
cached_order = self.cache.order(client_order_id)
638+
assert cached_order is not None
639+
assert cached_order.status == OrderStatus.PARTIALLY_FILLED
640+
assert cached_order.filled_qty == Quantity.from_int(5_000)
641+
642+
# The cached fill data should remain unchanged (not updated with broker data)
643+
# This ensures we don't corrupt the order state
644+
fill_events = [
645+
event
646+
for event in cached_order.events
647+
if hasattr(event, "trade_id") and event.trade_id == TradeId("TRADE-1")
648+
]
649+
assert len(fill_events) == 1
650+
cached_fill_event = fill_events[0]
651+
652+
# Verify the cached data is preserved (original values, not broker values)
653+
assert cached_fill_event.last_px == Price.from_str("1.00000") # Original price
654+
# Note: commission is calculated automatically by TestEventStubs, so we just check it exists
655+
assert cached_fill_event.commission is not None
656+
assert cached_fill_event.liquidity_side == LiquiditySide.MAKER # Original liquidity

0 commit comments

Comments
 (0)