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
« 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
5from finbot.execution.models import Fill, Order, OrderSide, OrderState, OrderStatus
6from finbot.portfolio.portfolio import Portfolio
9@runtime_checkable
10class Broker(Protocol):
11 def submit_order(self, order: Order) -> OrderState:
12 ...
14 def get_order(self, order_id: str) -> OrderState | None:
15 ...
17 def cancel_order(self, order_id: str) -> OrderState:
18 ...
21@runtime_checkable
22class PortfolioBroker(Broker, Protocol):
23 def get_portfolio_snapshot(self) -> Portfolio:
24 ...
27@runtime_checkable
28class BacktestBroker(Broker, Protocol):
29 @property
30 def portfolio(self) -> Portfolio:
31 ...
33 @property
34 def fee_rate(self) -> float:
35 ...
37 @property
38 def slippage_rate(self) -> float:
39 ...
41 def execute(self, order: Order, market_price: float, timestamp: datetime | None = None) -> Fill:
42 ...
45class SimulatedBroker:
46 """A simulated financy broker. TBD: Replace with real brokers"""
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.")
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.")
59 self.portfolio = portfolio
60 self.fee_rate = fee_rate
61 self.slippage_rate = slippage_rate
63 self._next_order_id = 1
64 self._next_fill_id = 1
65 self._orders: dict[str, OrderState] = {}
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
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
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
83 def submit_order(self, order: Order) -> OrderState:
84 order_id = self._new_order_id()
86 state = OrderState(order_id=order_id, order=order, status=OrderStatus.OPEN)
87 self._orders[order_id] = state
89 return state
91 def cancel_order(self, order_id: str) -> OrderState:
92 state = self._orders.get(order_id)
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}")
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 )
107 return self._set_order_status(order_id, OrderStatus.CANCELED)
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.")
115 if timestamp is None:
116 timestamp = datetime.now(UTC)
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}")
125 state = self.submit_order(order)
127 slippage_cost = abs(price - market_price) * order.quantity
128 fee = price * order.quantity * self.fee_rate
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 )
142 try:
143 self.portfolio.apply_fill(fill)
144 except ValueError:
145 self._set_order_status(state.order_id, OrderStatus.REJECTED)
146 raise
148 self._orders[state.order_id] = OrderState(
149 order_id=state.order_id,
150 order=order,
151 status=OrderStatus.FILLED,
152 )
154 return fill
156 def get_order(self, order_id: str) -> OrderState | None:
157 return self._orders.get(order_id)