"""MockBroker: simulates Alpaca Paper Trading API using Oracle historical bar data. Used by simulate_2week.py and the `fithia2 paper backtest` command. """ from __future__ import annotations import asyncio import datetime as dt from typing import Any from apps.paper_trader.alpaca_broker import AccountInfo, Bar, Order, Position class MockBroker: """Simulates Alpaca Paper Trading API using Oracle historical bar data. Must call set_sim_context(date, phase) before each phase ('open' or 'close'). """ def __init__(self, initial_equity: float, bars_cache: dict[str, dict[dt.date, dict]]) -> None: self._initial_equity = initial_equity self._cash = float(initial_equity) self._positions: dict[str, dict] = {} # symbol -> {qty, avg_price, current_price} self._bars = bars_cache # pre-fetched: symbol -> {date -> {open,high,low,close,volume}} self._sim_date: dt.date | None = None self._sim_phase: str = "close" # 'open' or 'close' self._order_counter = 0 self._trade_log: list[dict] = [] # ── Context ──────────────────────────────────────────────────────────────── def set_sim_context(self, date: dt.date, phase: str) -> None: """Set current simulation date + phase before each engine call.""" self._sim_date = date self._sim_phase = phase # Refresh position current prices using the appropriate price field for the phase price_field = "open" if phase == "open" else "close" for sym, pos in self._positions.items(): bar = self._get_bar_for_date(sym, date) if bar: pos["current_price"] = bar.get(price_field) or bar["close"] # ── Account ──────────────────────────────────────────────────────────────── def get_account(self) -> AccountInfo: market_value = sum(p["qty"] * p["current_price"] for p in self._positions.values()) equity = self._cash + market_value unrealized_pl = sum( (p["current_price"] - p["avg_price"]) * p["qty"] for p in self._positions.values() ) return AccountInfo( equity=equity, cash=self._cash, buying_power=self._cash, long_market_value=market_value, unrealized_pl=unrealized_pl, portfolio_value=equity, ) # ── Orders ───────────────────────────────────────────────────────────────── def submit_market_buy(self, symbol: str, qty: int) -> Order: """Fill at today's open price.""" price = self._fill_price(symbol, "open") if price is None or price <= 0: price = self._fill_price(symbol, "close") or 0.0 return self._fill_buy(symbol, qty, price, "market") def submit_moc_buy(self, symbol: str, qty: int) -> Order: """Fill at today's close price.""" price = self._fill_price(symbol, "close") if price is None or price <= 0: price = self._fill_price(symbol, "open") or 0.0 return self._fill_buy(symbol, qty, price, "moc") def submit_market_sell(self, symbol: str, qty: int) -> Order: return self.close_position(symbol, qty) def get_order(self, order_id: str) -> Order: for t in self._trade_log: if t["order_id"] == order_id: return Order( id=order_id, symbol=t["symbol"], qty=t["qty"], side=t["side"], status="filled", filled_avg_price=t["price"], filled_qty=t["qty"], ) raise ValueError(f"Order {order_id} not found") def list_orders(self, status: str = "open") -> list[Order]: return [] def cancel_order(self, order_id: str) -> None: pass # ── Positions ────────────────────────────────────────────────────────────── def list_positions(self) -> list[Position]: return [ Position( symbol=sym, qty=p["qty"], avg_entry_price=p["avg_price"], current_price=p["current_price"], unrealized_pl=(p["current_price"] - p["avg_price"]) * p["qty"], market_value=p["qty"] * p["current_price"], ) for sym, p in self._positions.items() if p["qty"] > 0 ] def get_position(self, symbol: str) -> Position | None: p = self._positions.get(symbol) if p is None or p["qty"] <= 0: return None return Position( symbol=symbol, qty=p["qty"], avg_entry_price=p["avg_price"], current_price=p["current_price"], unrealized_pl=(p["current_price"] - p["avg_price"]) * p["qty"], market_value=p["qty"] * p["current_price"], ) def close_position(self, symbol: str, qty: int | None = None, *, fill_price: float | None = None) -> Order: p = self._positions.get(symbol) if p is None or p["qty"] <= 0: raise ValueError(f"No open position in {symbol}") close_qty = qty if qty is not None else p["qty"] price = fill_price if fill_price is not None else (self._fill_price(symbol, self._sim_phase) or p["avg_price"]) self._cash += price * close_qty if close_qty >= p["qty"]: del self._positions[symbol] else: p["qty"] -= close_qty self._order_counter += 1 oid = f"mock_sell_{self._order_counter}" self._trade_log.append({ "order_id": oid, "symbol": symbol, "qty": close_qty, "side": "sell", "price": price, }) return Order( id=oid, symbol=symbol, qty=close_qty, side="sell", status="filled", filled_avg_price=price, filled_qty=close_qty, ) def close_all_positions(self) -> list[Order]: orders = [] for sym in list(self._positions.keys()): try: orders.append(self.close_position(sym)) except Exception: pass return orders # ── Price data ───────────────────────────────────────────────────────────── def get_bars_as_dict( self, symbols: list[str], start: dt.date, end: dt.date, ) -> dict[str, dict[dt.date, dict[str, Any]]]: """Return bars from pre-fetched cache filtered to [start, end].""" result: dict[str, dict[dt.date, dict]] = {} for sym in symbols: sym_bars = self._bars.get(sym, {}) result[sym] = {d: b for d, b in sym_bars.items() if start <= d <= end} return result def get_latest_bars(self, symbols: list[str]) -> dict[str, Bar]: result: dict[str, Bar] = {} for sym in symbols: sym_bars = self._bars.get(sym, {}) if not sym_bars: continue latest = max(sym_bars.keys()) b = sym_bars[latest] result[sym] = Bar( date=latest.isoformat(), open=b["open"], high=b["high"], low=b["low"], close=b["close"], volume=b.get("volume", 0), ) return result # ── Helpers ──────────────────────────────────────────────────────────────── def _fill_price(self, symbol: str, field: str) -> float | None: bar = self._get_bar_for_date(symbol, self._sim_date) if bar: return bar.get(field) sym_bars = self._bars.get(symbol, {}) if sym_bars: available = [d for d in sym_bars if d <= (self._sim_date or dt.date.today())] if available: return sym_bars[max(available)].get(field) return None def _get_bar_for_date(self, symbol: str, date: dt.date | None) -> dict | None: if date is None: return None sym_bars = self._bars.get(symbol, {}) if date in sym_bars: return sym_bars[date] available = [d for d in sym_bars if d <= date] if available: return sym_bars[max(available)] return None def _fill_buy(self, symbol: str, qty: int, price: float, order_type: str) -> Order: if price <= 0: raise ValueError(f"Cannot fill {symbol}: no price available for {self._sim_date}") cost = price * qty self._cash -= cost if symbol in self._positions: p = self._positions[symbol] total_qty = p["qty"] + qty p["avg_price"] = (p["avg_price"] * p["qty"] + price * qty) / total_qty p["qty"] = total_qty p["current_price"] = price else: self._positions[symbol] = { "qty": qty, "avg_price": price, "current_price": price, } self._order_counter += 1 oid = f"mock_buy_{self._order_counter}" self._trade_log.append({ "order_id": oid, "symbol": symbol, "qty": qty, "side": "buy", "price": price, }) return Order( id=oid, symbol=symbol, qty=qty, side="buy", status="filled", filled_avg_price=price, filled_qty=qty, ) # ───────────────────────────────────────────────────────────────────────────── # Bar pre-fetcher # ───────────────────────────────────────────────────────────────────────────── async def prefetch_bars( symbols: list[str], start: dt.date, end: dt.date, oracle_url: str, console=None, ) -> dict[str, dict[dt.date, dict]]: """Fetch OHLCV bars from Oracle for given symbols and date range.""" from libs.oracle_client import OracleClient, PriceService cache: dict[str, dict[dt.date, dict]] = {} sem = asyncio.Semaphore(10) async def _fetch_one(sym: str) -> None: async with sem: try: async with OracleClient(base_url=oracle_url) as client: svc = PriceService(client) resp = await svc.get_daily_bars( sym, start=start.isoformat(), end=end.isoformat(), ) date_map: dict[dt.date, dict] = {} for b in resp.bars: d = dt.date.fromisoformat(b.date[:10]) date_map[d] = { "date": d, "open": float(b.open), "high": float(b.high), "low": float(b.low), "close": float(b.close), "volume": float(b.volume), } cache[sym] = date_map except Exception as exc: msg = f" Bar fetch failed for {sym}: {exc}" if console is not None: console.print(f" [yellow]{msg}[/]") cache[sym] = {} await asyncio.gather(*(_fetch_one(sym) for sym in symbols)) return cache async def get_event_symbols_from_db( db_dsn: str, start_date: dt.date, end_date: dt.date, console=None, ) -> list[str]: """Query pipeline DB for symbols appearing in events during the date range.""" try: from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from libs.db.models import Event, EventLabel, SymbolMaster engine = create_async_engine(db_dsn, echo=False, connect_args={"timeout": 5}) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session() as session: stmt = ( select(SymbolMaster.ticker) .join(Event, Event.symbol_id == SymbolMaster.symbol_id) .join(EventLabel, Event.event_id == EventLabel.event_id) .where(EventLabel.entry_date >= start_date) .where(EventLabel.entry_date <= end_date) .where(EventLabel.label_status.in_(["ok", "truncated", "pending"])) .distinct() ) rows = (await session.execute(stmt)).all() await engine.dispose() return [r[0] for r in rows if r[0]] except Exception as exc: msg = f"DB symbol fetch failed: {type(exc).__name__}: {exc}" if str(exc) else f"DB symbol fetch failed: {type(exc).__name__}" if console is not None: console.print(f" [red]{msg}[/]") return []