Coverage for src/finbot/cli/paper.py: 97%

87 statements  

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

1import argparse 

2import json 

3import signal 

4import threading 

5import time 

6from pathlib import Path 

7from types import FrameType 

8 

9from finbot.config import load_settings 

10from finbot.engine.orchestrator import TradingOrchestrator 

11from finbot.engine.paper import PaperTradingCoordinator 

12from finbot.execution.alpaca import AlpacaPaperBroker 

13from finbot.execution.reconciliation import ExecutionReconciler 

14from finbot.execution.runtime import PaperTradingRuntime 

15from finbot.execution.state import ExecutionStateStore 

16from finbot.market.alpaca import AlpacaMarketDataProvider 

17from finbot.risk.risk_manager import RiskLimits, RiskManager 

18from finbot.strategy.strategies import create_strategy 

19 

20_DEFAULT_KILL_SWITCH_PATH = Path(".finbot/state/KILL_SWITCH") 

21_KILL_SWITCH_POLL_SECONDS = 0.5 

22_TRADING_POLL_SECONDS = 60.0 

23 

24_DEFAULT_PAPER_STRATEGY = "sma_crossover" 

25_DEFAULT_PAPER_STRATEGY_PARAMS = {"short_window": 20, "long_window": 50} 

26 

27 

28def add_arguments(parser: argparse.ArgumentParser) -> None: 

29 parser.add_argument( 

30 "--api-key", 

31 "--api_key", 

32 dest="api_key", 

33 default=None, 

34 help="Alpaca API key", 

35 ) 

36 parser.add_argument( 

37 "--api-secret", 

38 "--api_secret", 

39 dest="api_secret", 

40 default=None, 

41 help="Alpaca API secret", 

42 ) 

43 parser.add_argument( 

44 "--strategy", 

45 default=_DEFAULT_PAPER_STRATEGY, 

46 help=f"Strategy to use for paper trading (default: {_DEFAULT_PAPER_STRATEGY})", 

47 ) 

48 parser.add_argument( 

49 "--strategy-params", 

50 help='Strategy parameters as JSON, e.g. \'{"short_window":20,"long_window":50}\'', 

51 ) 

52 parser.add_argument( 

53 "--symbol", 

54 default=None, 

55 help="Symbol to paper trade", 

56 ) 

57 # Activate kill switch by creating file 

58 # Linux: $ touch .finbot/state/KILL_SWITCH 

59 # Windows: $ New-Item -ItemType File .finbot/state/KILL_SWITCH -Force 

60 parser.add_argument( 

61 "--kill-switch-file", 

62 type=Path, 

63 default=_DEFAULT_KILL_SWITCH_PATH, 

64 help=( 

65 "File whose presence activates the trading kill switch " 

66 f"(default: {_DEFAULT_KILL_SWITCH_PATH})" 

67 ), 

68 ) 

69 

70 

71def run_until_signal( 

72 runtime: PaperTradingRuntime, 

73 coordinator: PaperTradingCoordinator, 

74 *, 

75 stop_event: threading.Event | None = None, 

76 kill_switch_path: Path = _DEFAULT_KILL_SWITCH_PATH, 

77 poll_interval: float = _KILL_SWITCH_POLL_SECONDS, 

78 trading_interval: float = _TRADING_POLL_SECONDS, 

79) -> int: 

80 event = stop_event or threading.Event() 

81 shutdown_reason = "runner_exit" 

82 kill_switch_triggered = False 

83 

84 handled_signals = (signal.SIGINT, signal.SIGTERM) 

85 previous_handlers = {signum: signal.getsignal(signum) for signum in handled_signals} 

86 

87 def handle_signal(signum: int, _frame: FrameType | None) -> None: 

88 nonlocal shutdown_reason 

89 

90 if event.is_set(): 

91 return 

92 

93 shutdown_reason = signal.Signals(signum).name.lower() 

94 event.set() 

95 

96 for signum in handled_signals: 

97 signal.signal(signum, handle_signal) 

98 

99 try: 

100 if kill_switch_path.exists(): 

101 runtime.activate_kill_switch("operator_file") 

102 kill_switch_triggered = True 

103 

104 runtime.start() 

105 

106 next_trading_cycle = 0.0 

107 

108 while not event.is_set(): 

109 if not kill_switch_triggered and kill_switch_path.exists(): 

110 runtime.activate_kill_switch("operator_file") 

111 kill_switch_triggered = True 

112 

113 now = time.monotonic() 

114 

115 if not kill_switch_triggered and now >= next_trading_cycle: 

116 coordinator.run_once() 

117 next_trading_cycle = time.monotonic() + trading_interval 

118 

119 event.wait(timeout=poll_interval) 

120 finally: 

121 runtime.shutdown(shutdown_reason) 

122 

123 for signum, previous_handler in previous_handlers.items(): 

124 signal.signal(signum, previous_handler) 

125 

126 return 0 

127 

128 

129def run(args: argparse.Namespace) -> int: 

130 settings = load_settings(api_key=args.api_key, api_secret=args.api_secret) 

131 

132 if not settings.paper_trading_enabled: 

133 raise RuntimeError( 

134 "Paper trading is disabled. Set FINBOT_PAPER_TRADING_ENABLED=true to enable it.", 

135 ) 

136 

137 raw_symbol: str | None = getattr(args, "symbol", None) 

138 if not raw_symbol or not raw_symbol.strip(): 

139 raise RuntimeError("Paper trading requires an explicit --symbol.") 

140 

141 symbol = raw_symbol.strip().upper() 

142 

143 strategy_name: str = args.strategy 

144 

145 if args.strategy_params is not None: 

146 strategy_params = json.loads(args.strategy_params) 

147 if not isinstance(strategy_params, dict): 147 ↛ 154line 147 didn't jump to line 154 because the condition on line 147 was always true

148 raise ValueError("strategy_params must be a JSON object") 

149 elif strategy_name == _DEFAULT_PAPER_STRATEGY: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 strategy_params = dict(_DEFAULT_PAPER_STRATEGY_PARAMS) 

151 else: 

152 strategy_params = {} 

153 

154 strategy = create_strategy(strategy_name, strategy_params) 

155 

156 state_store = ExecutionStateStore() 

157 

158 broker = AlpacaPaperBroker( 

159 settings.alpaca_api_key, 

160 settings.alpaca_api_secret, 

161 trading_enabled=settings.paper_trading_enabled, 

162 state_store=state_store, 

163 ) 

164 

165 reconciler = ExecutionReconciler(broker, state_store) 

166 runtime = PaperTradingRuntime(broker, reconciler) 

167 market_data = AlpacaMarketDataProvider(settings.alpaca_api_key, settings.alpaca_api_secret) 

168 risk_manager = RiskManager(RiskLimits()) 

169 orchestrator = TradingOrchestrator(strategy=strategy, risk_manager=risk_manager) 

170 

171 coordinator = PaperTradingCoordinator( 

172 market_data=market_data, 

173 portfolio_broker=broker, 

174 orchestrator=orchestrator, 

175 runtime=runtime, 

176 symbols=[symbol], 

177 ) 

178 

179 return run_until_signal(runtime, coordinator, kill_switch_path=args.kill_switch_file)