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