Coverage for src/finbot/execution/runtime.py: 100%

63 statements  

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

1import logging 

2 

3from finbot.execution.broker import Broker 

4from finbot.execution.errors import KillSwitchActiveError, RuntimeNotReadyError, RuntimeStoppedError 

5from finbot.execution.models import Order, OrderState 

6from finbot.execution.reconciliation import ExecutionReconciler, ReconciliationReport 

7from finbot.logger import get_logger, log_event 

8 

9logger = get_logger(__name__) 

10 

11 

12class PaperTradingRuntime: 

13 def __init__( 

14 self, 

15 broker: Broker, 

16 reconciler: ExecutionReconciler, 

17 ) -> None: 

18 self._broker = broker 

19 self._reconciler = reconciler 

20 self._started = False 

21 self._stopped = False 

22 self._startup_report: ReconciliationReport | None = None 

23 self._kill_switch_active = False 

24 self._kill_switch_reason: str | None = None 

25 

26 @property 

27 def started(self) -> bool: 

28 return self._started 

29 

30 @property 

31 def stopped(self) -> bool: 

32 return self._stopped 

33 

34 @property 

35 def kill_switch_active(self) -> bool: 

36 return self._kill_switch_active 

37 

38 @property 

39 def kill_switch_reason(self) -> str | None: 

40 return self._kill_switch_reason 

41 

42 def start(self) -> ReconciliationReport: 

43 if self._stopped: 

44 raise RuntimeStoppedError( 

45 "Paper trading runtime has been stopped and cannot be restarted.", 

46 ) 

47 

48 if self._startup_report is not None: 

49 return self._startup_report 

50 

51 log_event(logger, logging.INFO, "runtime.starting") 

52 

53 report = self._reconciler.reconcile() 

54 

55 self._startup_report = report 

56 self._started = True 

57 

58 log_event( 

59 logger, 

60 logging.INFO, 

61 "runtime.started", 

62 checked=report.checked, 

63 reconciled=report.reconciled, 

64 positions_checked=report.positions_checked, 

65 positions_changed=report.positions_changed, 

66 ) 

67 

68 return report 

69 

70 def shutdown(self, reason: str = "requested") -> None: 

71 if self._stopped: 

72 return 

73 

74 log_event(logger, logging.INFO, "runtime.shutdown_requested", reason=reason) 

75 

76 self._stopped = True 

77 self._started = False 

78 

79 log_event(logger, logging.INFO, "runtime.stopped", reason=reason) 

80 

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

82 if self._stopped: 

83 log_event( 

84 logger, 

85 logging.WARNING, 

86 "order.submit_blocked", 

87 reason="runtime_stopped", 

88 symbol=order.symbol, 

89 side=order.side.value, 

90 quantity=order.quantity, 

91 client_order_id=order.client_order_id, 

92 ) 

93 

94 raise RuntimeStoppedError( 

95 "Paper trading runtime has been stopped; " 

96 "new orders cannot be submitted.", 

97 ) 

98 

99 if self._kill_switch_active: 

100 log_event( 

101 logger, 

102 logging.WARNING, 

103 "order.submit_blocked", 

104 reason="kill_switch_active", 

105 kill_switch_reason=self._kill_switch_reason, 

106 symbol=order.symbol, 

107 side=order.side.value, 

108 quantity=order.quantity, 

109 client_order_id=order.client_order_id, 

110 ) 

111 

112 raise KillSwitchActiveError( 

113 "Paper trading kill switch is active; new orders cannot be submitted.", 

114 ) 

115 

116 if not self._started: 

117 log_event( 

118 logger, 

119 logging.WARNING, 

120 "order.submit_blocked", 

121 reason="startup_reconciliation_required", 

122 symbol=order.symbol, 

123 side=order.side.value, 

124 quantity=order.quantity, 

125 client_order_id=order.client_order_id, 

126 ) 

127 

128 raise RuntimeNotReadyError( 

129 "Paper trading runtime must complete startup " 

130 "reconciliation before orders can be submitted.", 

131 ) 

132 

133 return self._broker.submit_order(order) 

134 

135 def activate_kill_switch(self, reason: str = "manual") -> None: 

136 if self._kill_switch_active: 

137 return 

138 

139 self._kill_switch_active = True 

140 self._kill_switch_reason = reason 

141 

142 log_event(logger, logging.CRITICAL, "kill_switch.activated", reason=reason)