from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress from typing import Any from app.backtest import Backtester from app.broker import build_broker from app.broker.base import Broker from app.config import Settings from app.engine.engine import TradingEngine from app.engine.strategies import get_strategy from app.market import build_market_data from app.market.base import MarketData from app.schemas import BacktestRequest, BacktestResult, StatusOut from app.store import Store FeedFactory = Callable[[Settings], MarketData] BrokerFactory = Callable[[Settings], Broker] class Runtime: """Owns the active engine and fans out live updates to WebSocket clients.""" def __init__( self, settings: Settings, store: Store | None = None, *, feed_factory: FeedFactory = build_market_data, broker_factory: BrokerFactory = build_broker, ) -> None: self.settings = settings self.store = store or Store() self._feed_factory = feed_factory self._broker_factory = broker_factory self.engine: TradingEngine | None = None self._subscribers: set[asyncio.Queue] = set() # --- pub/sub ------------------------------------------------------ def subscribe(self) -> asyncio.Queue: queue: asyncio.Queue = asyncio.Queue(maxsize=100) self._subscribers.add(queue) return queue def unsubscribe(self, queue: asyncio.Queue) -> None: self._subscribers.discard(queue) def _broadcast(self, payload: dict[str, Any]) -> None: for queue in list(self._subscribers): if queue.full(): with suppress(asyncio.QueueEmpty): queue.get_nowait() with suppress(asyncio.QueueFull): queue.put_nowait(payload) # --- lifecycle ---------------------------------------------------- async def start( self, strategy_key: str, params: dict[str, Any] | None = None, *, symbol: str | None = None, timeframe: str | None = None, starting_balance: float | None = None, ) -> StatusOut: if self.engine is not None and self.engine.running: await self.stop() engine = TradingEngine( feed=self._feed_factory(self.settings), broker=self._broker_factory(self.settings), store=self.store, settings=self.settings, ) engine.set_publisher(self._broadcast) self.engine = engine await engine.start( strategy_key, params, symbol=symbol, timeframe=timeframe, starting_balance=starting_balance, ) return engine.status() async def stop(self) -> StatusOut: if self.engine is not None: await self.engine.stop() return self.engine.status() return self._idle_status() async def run_backtest(self, request: BacktestRequest) -> BacktestResult: symbol = request.symbol or self.settings.symbol timeframe = request.timeframe or self.settings.timeframe strategy_cls = get_strategy(request.strategy_key) strategy = strategy_cls(request.params) feed = self._feed_factory(self.settings) try: candles = await feed.fetch_candles(symbol, timeframe, request.limit) finally: await feed.close() backtester = Backtester( fee_bps=self.settings.fee_bps, slippage_bps=self.settings.slippage_bps, starting_balance=request.starting_balance or self.settings.starting_balance, ) return backtester.run(candles, strategy, symbol, timeframe) async def shutdown(self) -> None: if self.engine is not None: await self.engine.shutdown() def status(self) -> StatusOut: if self.engine is not None: return self.engine.status() return self._idle_status() def _idle_status(self) -> StatusOut: return StatusOut( running=False, mode=self.settings.mode, exchange=self.settings.exchange, symbol=self.settings.symbol, timeframe=self.settings.timeframe, strategy_key=None, strategy_name=None, params={}, price=None, cash=self.settings.starting_balance, equity=self.settings.starting_balance, starting_balance=self.settings.starting_balance, realized_pnl=0.0, unrealized_pnl=0.0, total_pnl=0.0, open_positions=0, last_tick=None, )