You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

267 lines
9.7 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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