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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import logging
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
9logger = get_logger(__name__)
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
26 @property
27 def started(self) -> bool:
28 return self._started
30 @property
31 def stopped(self) -> bool:
32 return self._stopped
34 @property
35 def kill_switch_active(self) -> bool:
36 return self._kill_switch_active
38 @property
39 def kill_switch_reason(self) -> str | None:
40 return self._kill_switch_reason
42 def start(self) -> ReconciliationReport:
43 if self._stopped:
44 raise RuntimeStoppedError(
45 "Paper trading runtime has been stopped and cannot be restarted.",
46 )
48 if self._startup_report is not None:
49 return self._startup_report
51 log_event(logger, logging.INFO, "runtime.starting")
53 report = self._reconciler.reconcile()
55 self._startup_report = report
56 self._started = True
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 )
68 return report
70 def shutdown(self, reason: str = "requested") -> None:
71 if self._stopped:
72 return
74 log_event(logger, logging.INFO, "runtime.shutdown_requested", reason=reason)
76 self._stopped = True
77 self._started = False
79 log_event(logger, logging.INFO, "runtime.stopped", reason=reason)
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 )
94 raise RuntimeStoppedError(
95 "Paper trading runtime has been stopped; "
96 "new orders cannot be submitted.",
97 )
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 )
112 raise KillSwitchActiveError(
113 "Paper trading kill switch is active; new orders cannot be submitted.",
114 )
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 )
128 raise RuntimeNotReadyError(
129 "Paper trading runtime must complete startup "
130 "reconciliation before orders can be submitted.",
131 )
133 return self._broker.submit_order(order)
135 def activate_kill_switch(self, reason: str = "manual") -> None:
136 if self._kill_switch_active:
137 return
139 self._kill_switch_active = True
140 self._kill_switch_reason = reason
142 log_event(logger, logging.CRITICAL, "kill_switch.activated", reason=reason)