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
« 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
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
20_DEFAULT_KILL_SWITCH_PATH = Path(".finbot/state/KILL_SWITCH")
21_KILL_SWITCH_POLL_SECONDS = 0.5
22_TRADING_POLL_SECONDS = 60.0
24_DEFAULT_PAPER_STRATEGY = "sma_crossover"
25_DEFAULT_PAPER_STRATEGY_PARAMS = {"short_window": 20, "long_window": 50}
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 )
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
84 handled_signals = (signal.SIGINT, signal.SIGTERM)
85 previous_handlers = {signum: signal.getsignal(signum) for signum in handled_signals}
87 def handle_signal(signum: int, _frame: FrameType | None) -> None:
88 nonlocal shutdown_reason
90 if event.is_set():
91 return
93 shutdown_reason = signal.Signals(signum).name.lower()
94 event.set()
96 for signum in handled_signals:
97 signal.signal(signum, handle_signal)
99 try:
100 if kill_switch_path.exists():
101 runtime.activate_kill_switch("operator_file")
102 kill_switch_triggered = True
104 runtime.start()
106 next_trading_cycle = 0.0
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
113 now = time.monotonic()
115 if not kill_switch_triggered and now >= next_trading_cycle:
116 coordinator.run_once()
117 next_trading_cycle = time.monotonic() + trading_interval
119 event.wait(timeout=poll_interval)
120 finally:
121 runtime.shutdown(shutdown_reason)
123 for signum, previous_handler in previous_handlers.items():
124 signal.signal(signum, previous_handler)
126 return 0
129def run(args: argparse.Namespace) -> int:
130 settings = load_settings(api_key=args.api_key, api_secret=args.api_secret)
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 )
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.")
141 symbol = raw_symbol.strip().upper()
143 strategy_name: str = args.strategy
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 = {}
154 strategy = create_strategy(strategy_name, strategy_params)
156 state_store = ExecutionStateStore()
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 )
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)
171 coordinator = PaperTradingCoordinator(
172 market_data=market_data,
173 portfolio_broker=broker,
174 orchestrator=orchestrator,
175 runtime=runtime,
176 symbols=[symbol],
177 )
179 return run_until_signal(runtime, coordinator, kill_switch_path=args.kill_switch_file)