Coverage for src/finbot/engine/orchestrator.py: 100%

66 statements  

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

1import logging 

2from dataclasses import dataclass 

3from enum import StrEnum 

4 

5from finbot.execution.models import Order, OrderSide 

6from finbot.logger import get_logger, log_event 

7from finbot.market.models import Bar 

8from finbot.portfolio.portfolio import Portfolio 

9from finbot.risk import PositionSizer, RiskManager 

10from finbot.strategy.core import Signal, SignalDirection, Strategy 

11 

12logger = get_logger(__name__) 

13 

14 

15class RejectionStage(StrEnum): 

16 RISK = "risk" 

17 SIZING = "sizing" 

18 

19 

20@dataclass(frozen=True) 

21class TradingRejection: 

22 signal: Signal 

23 reason: str 

24 stage: RejectionStage 

25 

26 

27@dataclass(frozen=True) 

28class NoAction: 

29 pass 

30 

31 

32TradingResult = Order | TradingRejection | NoAction 

33 

34 

35class TradingOrchestrator: 

36 def __init__( 

37 self, 

38 strategy: Strategy, 

39 risk_manager: RiskManager, 

40 position_sizer: PositionSizer | None = None, 

41 ): 

42 self.strategy = strategy 

43 self.risk_manager = risk_manager 

44 self.position_sizer = position_sizer or PositionSizer() 

45 

46 def generate_signals(self, bar: Bar) -> list[Signal]: 

47 strategy_result = self.strategy.on_bar(bar) 

48 

49 if strategy_result is None: 

50 signals: list[Signal] = [] 

51 elif isinstance(strategy_result, Signal): 

52 signals = [strategy_result] 

53 else: 

54 signals = strategy_result 

55 

56 for signal in signals: 

57 log_event( 

58 logger, 

59 logging.INFO, 

60 "signal.generated", 

61 symbol=signal.symbol, 

62 direction=signal.direction.value, 

63 strength=signal.strength, 

64 metadata=signal.metadata, 

65 ) 

66 

67 return signals 

68 

69 def process_bar( 

70 self, 

71 bar: Bar, 

72 portfolio: Portfolio, 

73 market_prices: dict[str, float], 

74 *, 

75 fee_rate: float = 0.0, 

76 slippage_rate: float = 0.0, 

77 ) -> list[TradingResult]: 

78 signals = self.generate_signals(bar) 

79 

80 if not signals: 

81 return [NoAction()] 

82 

83 return [ 

84 self.process_signal( 

85 signal=signal, 

86 portfolio=portfolio, 

87 market_prices=market_prices, 

88 fee_rate=fee_rate, 

89 slippage_rate=slippage_rate, 

90 ) for signal in signals 

91 ] 

92 

93 def process_signal( 

94 self, 

95 signal: Signal, 

96 portfolio: Portfolio, 

97 market_prices: dict[str, float], 

98 *, 

99 fee_rate: float = 0.0, 

100 slippage_rate: float = 0.0, 

101 ) -> TradingResult: 

102 risk_decision = self.risk_manager.assess(signal, portfolio, market_prices) 

103 

104 if not risk_decision.accepted: 

105 reason = risk_decision.reason or "Risk manager rejected signal." 

106 

107 log_event( 

108 logger, 

109 logging.WARNING, 

110 "risk.decision", 

111 symbol=signal.symbol, 

112 direction=signal.direction.value, 

113 accepted=False, 

114 reason=reason, 

115 order_value_budget=risk_decision.order_value_budget, 

116 ) 

117 

118 return TradingRejection(signal=signal, reason=reason, stage=RejectionStage.RISK) 

119 

120 assert risk_decision.order_value_budget is not None 

121 

122 log_event( 

123 logger, 

124 logging.INFO, 

125 "risk.decision", 

126 symbol=signal.symbol, 

127 direction=signal.direction.value, 

128 accepted=True, 

129 reason=risk_decision.reason, 

130 order_value_budget=risk_decision.order_value_budget, 

131 ) 

132 

133 if signal.direction == SignalDirection.LONG: 

134 order_side = OrderSide.BUY 

135 max_quantity = None 

136 else: 

137 order_side = OrderSide.SELL 

138 position = portfolio.positions.get(signal.symbol) 

139 max_quantity = None if position is None else position.quantity 

140 

141 sizing_decision = self.position_sizer.size( 

142 order_side=order_side, 

143 market_price=market_prices[signal.symbol], 

144 order_value_budget=risk_decision.order_value_budget, 

145 fee_rate=fee_rate, 

146 slippage_rate=slippage_rate, 

147 max_quantity=max_quantity, 

148 ) 

149 

150 if not sizing_decision.accepted: 

151 reason = sizing_decision.reason or "Position sizer rejected signal." 

152 

153 log_event( 

154 logger, 

155 logging.WARNING, 

156 "sizing.decision", 

157 symbol=signal.symbol, 

158 side=order_side.value, 

159 accepted=False, 

160 reason=reason, 

161 quantity=sizing_decision.quantity, 

162 ) 

163 

164 return TradingRejection(signal=signal, reason=reason, stage=RejectionStage.SIZING) 

165 

166 assert sizing_decision.quantity is not None 

167 

168 log_event( 

169 logger, 

170 logging.INFO, 

171 "sizing.decision", 

172 symbol=signal.symbol, 

173 side=order_side.value, 

174 accepted=True, 

175 reason=sizing_decision.reason, 

176 quantity=sizing_decision.quantity, 

177 ) 

178 

179 order = Order(symbol=signal.symbol, side=order_side, quantity=sizing_decision.quantity) 

180 

181 log_event( 

182 logger, 

183 logging.INFO, 

184 "order.created", 

185 symbol=order.symbol, 

186 side=order.side.value, 

187 quantity=order.quantity, 

188 ) 

189 

190 return order