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.

323 lines
13 KiB
Python

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