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
« 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
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
12logger = get_logger(__name__)
15class RejectionStage(StrEnum):
16 RISK = "risk"
17 SIZING = "sizing"
20@dataclass(frozen=True)
21class TradingRejection:
22 signal: Signal
23 reason: str
24 stage: RejectionStage
27@dataclass(frozen=True)
28class NoAction:
29 pass
32TradingResult = Order | TradingRejection | NoAction
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()
46 def generate_signals(self, bar: Bar) -> list[Signal]:
47 strategy_result = self.strategy.on_bar(bar)
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
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 )
67 return signals
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)
80 if not signals:
81 return [NoAction()]
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 ]
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)
104 if not risk_decision.accepted:
105 reason = risk_decision.reason or "Risk manager rejected signal."
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 )
118 return TradingRejection(signal=signal, reason=reason, stage=RejectionStage.RISK)
120 assert risk_decision.order_value_budget is not None
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 )
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
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 )
150 if not sizing_decision.accepted:
151 reason = sizing_decision.reason or "Position sizer rejected signal."
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 )
164 return TradingRejection(signal=signal, reason=reason, stage=RejectionStage.SIZING)
166 assert sizing_decision.quantity is not None
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 )
179 order = Order(symbol=signal.symbol, side=order_side, quantity=sizing_decision.quantity)
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 )
190 return order