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

1import logging 

2from dataclasses import dataclass 

3from typing import Protocol 

4 

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 

9 

10logger = get_logger(__name__) 

11 

12 

13class ReconciliationBroker(Protocol): 

14 def get_order(self, order_id: str) -> OrderState | None: 

15 ... 

16 

17 def get_order_by_client_order_id(self, client_order_id: str) -> OrderState | None: 

18 ... 

19 

20 def get_positions(self) -> list[BrokerPosition]: 

21 ... 

22 

23 

24@dataclass(frozen=True) 

25class ReconciliationReport: 

26 checked: int 

27 reconciled: int 

28 positions_checked: int 

29 positions_changed: int 

30 

31 

32class ExecutionReconciler: 

33 def __init__(self, broker: ReconciliationBroker, state_store: ExecutionStateStore): 

34 self._broker = broker 

35 self._state_store = state_store 

36 

37 def reconcile(self) -> ReconciliationReport: 

38 persisted_orders = self._state_store.list_reconcilable() 

39 

40 log_event( 

41 logger, 

42 logging.INFO, 

43 "reconciliation.started", 

44 orders_to_check=len(persisted_orders), 

45 ) 

46 

47 reconciled = 0 

48 unresolved: list[str] = [] 

49 

50 for persisted in persisted_orders: 

51 state = self._lookup(persisted) 

52 

53 if state is None: 

54 client_order_id = persisted.order.client_order_id 

55 assert client_order_id is not None 

56 

57 unresolved.append(client_order_id) 

58 continue 

59 

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 

73 

74 self._state_store.update(state) 

75 reconciled += 1 

76 

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 ) 

87 

88 raise ReconciliationError(unresolved) 

89 

90 broker_positions = self._broker.get_positions() 

91 local_positions = self._state_store.list_positions() 

92 

93 local_by_symbol = {position.symbol: position for position in local_positions} 

94 broker_by_symbol = {position.symbol: position for position in broker_positions} 

95 

96 symbols = set(local_by_symbol) | set(broker_by_symbol) 

97 

98 positions_changed = sum( 

99 (local_by_symbol.get(symbol) != broker_by_symbol.get(symbol) for symbol in symbols), 

100 ) 

101 

102 self._state_store.replace_positions(broker_positions) 

103 

104 report = ReconciliationReport( 

105 checked=len(persisted_orders), 

106 reconciled=reconciled, 

107 positions_checked=len(symbols), 

108 positions_changed=positions_changed, 

109 ) 

110 

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 ) 

120 

121 return report 

122 

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) 

126 

127 client_order_id = persisted.order.client_order_id 

128 assert client_order_id is not None 

129 

130 return self._broker.get_order_by_client_order_id(client_order_id) 

131 

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 ) 

139 

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 )