"""MockORBBroker: simulates Alpaca API for ORB paper trading tests. Generates synthetic OHLCV data and simulates order fills without any real Alpaca API calls. Suitable for offline unit/integration testing. """ from __future__ import annotations import datetime as dt import random import uuid from dataclasses import dataclass, field from typing import Any from zoneinfo import ZoneInfo from apps.paper_trader.alpaca_broker import AccountInfo, Bar, Order, Position _ET = ZoneInfo("America/New_York") # ── Synthetic data generation ───────────────────────────────────────────────── def make_daily_bars( ticker: str, date_str: str, n_days: int = 70, base_price: float = 50.0, daily_vol: float = 0.015, avg_volume: int = 3_000_000, ) -> list[Bar]: """Generate n_days of synthetic daily OHLCV bars ending at date_str. Designed to pass live_pre_screen() filters: - close >= 10 (min_price) - ATR14 >= 0.5 (min_atr_14) → base_price * daily_vol * ~1.4 per day - avg_dollar_vol_30d >= 25M → avg_volume * base_price """ rng = random.Random(hash(ticker + date_str)) today = dt.date.fromisoformat(date_str) price = base_price bars = [] for i in range(n_days, 0, -1): bar_date = today - dt.timedelta(days=i) if bar_date.weekday() >= 5: # skip weekends continue day_ret = rng.gauss(0, daily_vol) open_ = price * (1 + rng.gauss(0, 0.003)) close = price * (1 + day_ret) high = max(open_, close) * (1 + abs(rng.gauss(0, 0.005))) low = min(open_, close) * (1 - abs(rng.gauss(0, 0.005))) vol = int(avg_volume * rng.uniform(0.7, 1.4)) bars.append(Bar( date=bar_date.isoformat(), open=round(open_, 2), high=round(high, 2), low=round(low, 2), close=round(close, 2), volume=vol, )) price = close return bars def make_intraday_bars( ticker: str, date_str: str, n_bars: int = 12, base_price: float = 50.0, bar_vol: float = 0.003, avg_volume_per_bar: int = 200_000, orb_minutes: int = 10, ) -> list[dict[str, Any]]: """Generate synthetic 5-min intraday bars starting at 9:30 ET. The ORB high is inflated slightly so breakout is clear. n_bars covers from 9:30 to ~10:30 (12 bars × 5 min). """ rng = random.Random(hash(ticker + date_str + "intraday")) today = dt.date.fromisoformat(date_str) price = base_price bars = [] orb_bar_count = orb_minutes // 5 # how many 5-min bars form the ORB window for i in range(n_bars): bar_start = dt.datetime( today.year, today.month, today.day, 9, 30, tzinfo=_ET ) + dt.timedelta(minutes=5 * i) is_orb = i < orb_bar_count # Make ORB bars slightly more bullish for a clean breakout signal ret = rng.gauss(0.002 if is_orb else 0.0, bar_vol) open_ = price close = price * (1 + ret) high = max(open_, close) * (1 + abs(rng.gauss(0, 0.002))) low = min(open_, close) * (1 - abs(rng.gauss(0, 0.002))) vol = int(avg_volume_per_bar * rng.uniform(0.5, 1.5)) bars.append({ "timestamp": bar_start.isoformat(), "open": round(open_, 2), "high": round(high, 2), "low": round(low, 2), "close": round(close, 2), "volume": vol, }) price = close return bars # ── MockORBBroker ───────────────────────────────────────────────────────────── class MockORBBroker: """Simulates AlpacaBroker for ORB paper trading tests. Maintains in-memory positions and cash; no API calls. """ def __init__( self, initial_equity: float = 10_000.0, tickers: list[str] | None = None, ) -> None: self._equity = initial_equity self._cash = float(initial_equity) self._tickers = tickers or ["AAPL", "NVDA", "MSFT"] self._positions: dict[str, dict[str, Any]] = {} # ticker -> {qty, avg_price} self._orders: dict[str, Order] = {} self._filled_at: dict[str, float] = {} # order_id -> fill price # Precompute base prices so they're consistent self._base_prices = { t: 40.0 + hash(t) % 60 for t in self._tickers } # ── Account ─────────────────────────────────────────────────────────────── def get_account(self) -> AccountInfo: market_value = sum( p["qty"] * p["avg_price"] for p in self._positions.values() ) return AccountInfo( equity=self._cash + market_value, cash=self._cash, buying_power=self._cash, long_market_value=market_value, unrealized_pl=0.0, portfolio_value=self._cash + market_value, ) # ── Bars ────────────────────────────────────────────────────────────────── def get_bars( self, tickers: list[str], start: dt.date, end: dt.date, ) -> dict[str, list[Bar]]: """Return synthetic daily bars. Ignores start/end for simplicity.""" result: dict[str, list[Bar]] = {} for ticker in tickers: base = self._base_prices.get(ticker, 50.0) result[ticker] = make_daily_bars(ticker, end.isoformat(), base_price=base) return result def get_intraday_bars( self, tickers: list[str], start: dt.datetime, end: dt.datetime, timeframe_minutes: int = 5, ) -> dict[str, list[dict[str, Any]]]: """Return synthetic 5-min intraday bars.""" date_str = start.date().isoformat() result: dict[str, list[dict[str, Any]]] = {} for ticker in tickers: base = self._base_prices.get(ticker, 50.0) result[ticker] = make_intraday_bars(ticker, date_str, base_price=base) return result # ── Orders ──────────────────────────────────────────────────────────────── def _make_order(self, ticker: str, qty: int, side: str, fill_price: float) -> Order: order_id = str(uuid.uuid4())[:8] order = Order( id=order_id, symbol=ticker, qty=qty, side=side, status="filled", filled_avg_price=fill_price, filled_qty=qty, ) self._orders[order_id] = order self._filled_at[order_id] = fill_price return order def submit_market_buy(self, ticker: str, qty: int) -> Order: price = self._base_prices.get(ticker, 50.0) if ticker in self._positions: self._positions[ticker]["qty"] += qty else: self._positions[ticker] = {"qty": qty, "avg_price": price} self._cash -= price * qty return self._make_order(ticker, qty, "buy", price) def submit_market_sell(self, ticker: str, qty: int) -> Order: """Short sell.""" price = self._base_prices.get(ticker, 50.0) self._cash += price * qty self._positions[ticker] = {"qty": -qty, "avg_price": price} return self._make_order(ticker, qty, "sell", price) def get_order(self, order_id: str) -> Order: return self._orders[order_id] def close_position(self, ticker: str) -> Order: pos = self._positions.pop(ticker, None) price = self._base_prices.get(ticker, 50.0) if pos: pnl = (price - pos["avg_price"]) * pos["qty"] self._cash += price * abs(pos["qty"]) + pnl return self._make_order(ticker, abs(pos["qty"]) if pos else 1, "sell", price) def list_positions(self) -> list[Position]: result = [] for ticker, pos in self._positions.items(): price = self._base_prices.get(ticker, 50.0) result.append(Position( symbol=ticker, qty=pos["qty"], avg_entry_price=pos["avg_price"], current_price=price, unrealized_pl=(price - pos["avg_price"]) * pos["qty"], market_value=price * pos["qty"], )) return result # ── Mock Oracle snapshots ───────────────────────────────────────────────────── def make_mock_snapshots( tickers: list[str], date_str: str, price_mult: float = 1.01, ) -> dict[str, Any]: """Return fake AlpacaSnapshot objects with price slightly above ORB high. Used to patch libs.oracle_client.alpaca.get_snapshots() in tests. price_mult > 1.0 ensures the snapshot price triggers a long breakout. """ from libs.oracle_client.alpaca import AlpacaSnapshot result: dict[str, Any] = {} for ticker in tickers: base = 40.0 + hash(ticker) % 60 snap_price = round(base * price_mult, 2) result[ticker] = AlpacaSnapshot( ticker=ticker, price=snap_price, bid=round(snap_price - 0.01, 2), ask=round(snap_price + 0.01, 2), prev_close=round(base * 0.99, 2), change=round(snap_price - base * 0.99, 2), change_pct=round((snap_price / (base * 0.99) - 1) * 100, 2), ) return result