Coverage for src/finbot/execution/broker.py: 98%

77 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-04 12:05 +0000

1import math 

2from datetime import UTC, datetime 

3from typing import Protocol, runtime_checkable 

4 

5from finbot.execution.models import Fill, Order, OrderSide, OrderState, OrderStatus 

6from finbot.portfolio.portfolio import Portfolio 

7 

8 

9@runtime_checkable 

10class Broker(Protocol): 

11 def submit_order(self, order: Order) -> OrderState: 

12 ... 

13 

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

15 ... 

16 

17 def cancel_order(self, order_id: str) -> OrderState: 

18 ... 

19 

20 

21@runtime_checkable 

22class PortfolioBroker(Broker, Protocol): 

23 def get_portfolio_snapshot(self) -> Portfolio: 

24 ... 

25 

26 

27@runtime_checkable 

28class BacktestBroker(Broker, Protocol): 

29 @property 

30 def portfolio(self) -> Portfolio: 

31 ... 

32 

33 @property 

34 def fee_rate(self) -> float: 

35 ... 

36 

37 @property 

38 def slippage_rate(self) -> float: 

39 ... 

40 

41 def execute(self, order: Order, market_price: float, timestamp: datetime | None = None) -> Fill: 

42 ... 

43 

44 

45class SimulatedBroker: 

46 """A simulated financy broker. TBD: Replace with real brokers""" 

47 

48 def __init__(self, portfolio: Portfolio, fee_rate: float = 0.0, slippage_rate: float = 0.0): 

49 if not math.isfinite(fee_rate): 

50 raise ValueError("Fee rate must be a valid number.") 

51 if not math.isfinite(slippage_rate): 

52 raise ValueError("Slippage rate must be a valid number.") 

53 

54 if fee_rate < 0: 

55 raise ValueError("Fee rate cannot be negative.") 

56 if not 0 <= slippage_rate <= 1.0: 

57 raise ValueError("Slippage rate must be between 0.0 and 1.0.") 

58 

59 self.portfolio = portfolio 

60 self.fee_rate = fee_rate 

61 self.slippage_rate = slippage_rate 

62 

63 self._next_order_id = 1 

64 self._next_fill_id = 1 

65 self._orders: dict[str, OrderState] = {} 

66 

67 def _new_order_id(self) -> str: 

68 order_id = f"sim-order-{self._next_order_id}" 

69 self._next_order_id += 1 

70 return order_id 

71 

72 def _new_fill_id(self) -> str: 

73 fill_id = f"sim-fill-{self._next_fill_id}" 

74 self._next_fill_id += 1 

75 return fill_id 

76 

77 def _set_order_status(self, order_id: str, status: OrderStatus) -> OrderState: 

78 state = self._orders[order_id] 

79 updated = OrderState(order_id=state.order_id, order=state.order, status=status) 

80 self._orders[order_id] = updated 

81 return updated 

82 

83 def submit_order(self, order: Order) -> OrderState: 

84 order_id = self._new_order_id() 

85 

86 state = OrderState(order_id=order_id, order=order, status=OrderStatus.OPEN) 

87 self._orders[order_id] = state 

88 

89 return state 

90 

91 def cancel_order(self, order_id: str) -> OrderState: 

92 state = self._orders.get(order_id) 

93 

94 if state is None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 raise KeyError(f"Unknown order ID: {order_id}") 

96 

97 if state.status in ( 

98 OrderStatus.FILLED, 

99 OrderStatus.CANCELED, 

100 OrderStatus.REJECTED, 

101 OrderStatus.EXPIRED, 

102 ): 

103 raise ValueError( 

104 f"Order {order_id} cannot be canceled from status {state.status.value}.", 

105 ) 

106 

107 return self._set_order_status(order_id, OrderStatus.CANCELED) 

108 

109 def execute(self, order: Order, market_price: float, timestamp: datetime | None = None) -> Fill: 

110 if not math.isfinite(market_price): 

111 raise ValueError("Market price must be a valid number.") 

112 if market_price <= 0: 

113 raise ValueError("Market price must be greater than zero.") 

114 

115 if timestamp is None: 

116 timestamp = datetime.now(UTC) 

117 

118 if order.side == OrderSide.BUY: 

119 price = market_price * (1 + self.slippage_rate) 

120 elif order.side == OrderSide.SELL: 

121 price = market_price * (1 - self.slippage_rate) 

122 else: 

123 raise ValueError(f"Unsupported order side: {order.side}") 

124 

125 state = self.submit_order(order) 

126 

127 slippage_cost = abs(price - market_price) * order.quantity 

128 fee = price * order.quantity * self.fee_rate 

129 

130 fill = Fill( 

131 symbol=order.symbol, 

132 side=order.side, 

133 quantity=order.quantity, 

134 price=price, 

135 timestamp=timestamp, 

136 fee=fee, 

137 slippage_cost=slippage_cost, 

138 fill_id=self._new_fill_id(), 

139 order_id=state.order_id, 

140 ) 

141 

142 try: 

143 self.portfolio.apply_fill(fill) 

144 except ValueError: 

145 self._set_order_status(state.order_id, OrderStatus.REJECTED) 

146 raise 

147 

148 self._orders[state.order_id] = OrderState( 

149 order_id=state.order_id, 

150 order=order, 

151 status=OrderStatus.FILLED, 

152 ) 

153 

154 return fill 

155 

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

157 return self._orders.get(order_id)