Coverage for src/finbot/execution/reconciliation.py: 97%
63 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import logging
2from dataclasses import dataclass
3from typing import Protocol
5from finbot.execution.errors import OrderConflictError, ReconciliationError
6from finbot.execution.models import BrokerPosition, OrderState
7from finbot.execution.state import ExecutionStateStore, PersistedOrder
8from finbot.logger import get_logger, log_event
10logger = get_logger(__name__)
13class ReconciliationBroker(Protocol):
14 def get_order(self, order_id: str) -> OrderState | None:
15 ...
17 def get_order_by_client_order_id(self, client_order_id: str) -> OrderState | None:
18 ...
20 def get_positions(self) -> list[BrokerPosition]:
21 ...
24@dataclass(frozen=True)
25class ReconciliationReport:
26 checked: int
27 reconciled: int
28 positions_checked: int
29 positions_changed: int
32class ExecutionReconciler:
33 def __init__(self, broker: ReconciliationBroker, state_store: ExecutionStateStore):
34 self._broker = broker
35 self._state_store = state_store
37 def reconcile(self) -> ReconciliationReport:
38 persisted_orders = self._state_store.list_reconcilable()
40 log_event(
41 logger,
42 logging.INFO,
43 "reconciliation.started",
44 orders_to_check=len(persisted_orders),
45 )
47 reconciled = 0
48 unresolved: list[str] = []
50 for persisted in persisted_orders:
51 state = self._lookup(persisted)
53 if state is None:
54 client_order_id = persisted.order.client_order_id
55 assert client_order_id is not None
57 unresolved.append(client_order_id)
58 continue
60 try:
61 self._validate_match(persisted, state)
62 except OrderConflictError:
63 log_event(
64 logger,
65 logging.ERROR,
66 "reconciliation.failed",
67 reason="order_conflict",
68 client_order_id=persisted.order.client_order_id,
69 local_broker_order_id=persisted.broker_order_id,
70 broker_order_id=state.order_id,
71 )
72 raise
74 self._state_store.update(state)
75 reconciled += 1
77 if unresolved:
78 log_event(
79 logger,
80 logging.ERROR,
81 "reconciliation.failed",
82 reason="unresolved_orders",
83 unresolved_client_order_ids=unresolved,
84 checked=len(persisted_orders),
85 reconciled=reconciled,
86 )
88 raise ReconciliationError(unresolved)
90 broker_positions = self._broker.get_positions()
91 local_positions = self._state_store.list_positions()
93 local_by_symbol = {position.symbol: position for position in local_positions}
94 broker_by_symbol = {position.symbol: position for position in broker_positions}
96 symbols = set(local_by_symbol) | set(broker_by_symbol)
98 positions_changed = sum(
99 (local_by_symbol.get(symbol) != broker_by_symbol.get(symbol) for symbol in symbols),
100 )
102 self._state_store.replace_positions(broker_positions)
104 report = ReconciliationReport(
105 checked=len(persisted_orders),
106 reconciled=reconciled,
107 positions_checked=len(symbols),
108 positions_changed=positions_changed,
109 )
111 log_event(
112 logger,
113 logging.INFO,
114 "reconciliation.completed",
115 checked=report.checked,
116 reconciled=report.reconciled,
117 positions_checked=report.positions_checked,
118 positions_changed=report.positions_changed,
119 )
121 return report
123 def _lookup(self, persisted: PersistedOrder) -> OrderState | None:
124 if persisted.broker_order_id is not None:
125 return self._broker.get_order(persisted.broker_order_id)
127 client_order_id = persisted.order.client_order_id
128 assert client_order_id is not None
130 return self._broker.get_order_by_client_order_id(client_order_id)
132 @staticmethod
133 def _validate_match(persisted: PersistedOrder, state: OrderState) -> None:
134 if state.order != persisted.order:
135 raise OrderConflictError(
136 f"Client order ID {persisted.order.client_order_id!r} "
137 f"is associated with a different broker order",
138 )
140 if persisted.broker_order_id is not None and state.order_id != persisted.broker_order_id: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 raise OrderConflictError(
142 f"Broker order ID {persisted.broker_order_id!r} "
143 f"does not match reconciled order {state.order_id!r}.",
144 )