|
|
"""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
|