Add paper trading system: broker integration, state management, reporter
New modules for live/mock broker interface, SQLite session state, auto-trading engine, and backtest result reporting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>main
parent
479eb76308
commit
9b92ab6589
@ -0,0 +1 @@
|
||||
"""Paper trading system with Alpaca API."""
|
||||
@ -0,0 +1,360 @@
|
||||
"""Alpaca Paper Trading API wrapper.
|
||||
|
||||
Switch from paper to live by setting paper=False (or ALPACA_PAPER=false).
|
||||
Requires: pip install alpaca-py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountInfo:
|
||||
equity: float
|
||||
cash: float
|
||||
buying_power: float
|
||||
long_market_value: float
|
||||
unrealized_pl: float
|
||||
portfolio_value: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
id: str
|
||||
symbol: str
|
||||
qty: int
|
||||
side: str # "buy" or "sell"
|
||||
status: str
|
||||
filled_avg_price: float | None
|
||||
filled_qty: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
symbol: str
|
||||
qty: int
|
||||
avg_entry_price: float
|
||||
current_price: float
|
||||
unrealized_pl: float
|
||||
market_value: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bar:
|
||||
date: str
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioHistory:
|
||||
timestamps: list[int]
|
||||
equity: list[float]
|
||||
profit_loss: list[float]
|
||||
profit_loss_pct: list[float]
|
||||
|
||||
|
||||
class AlpacaBroker:
|
||||
"""Thin wrapper around alpaca-py TradingClient for paper trading."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
secret_key: str | None = None,
|
||||
paper: bool = True,
|
||||
) -> None:
|
||||
self._api_key = api_key or os.environ.get("ALPACA_API_KEY", "")
|
||||
self._secret_key = secret_key or os.environ.get("ALPACA_SECRET_KEY", "")
|
||||
self._paper = paper
|
||||
|
||||
if not self._api_key or not self._secret_key:
|
||||
raise ValueError(
|
||||
"Alpaca credentials missing. Set ALPACA_API_KEY and ALPACA_SECRET_KEY env vars."
|
||||
)
|
||||
|
||||
try:
|
||||
from alpaca.trading.client import TradingClient
|
||||
from alpaca.data.historical import StockHistoricalDataClient
|
||||
except ImportError as exc:
|
||||
raise ImportError("alpaca-py not installed. Run: pip install alpaca-py") from exc
|
||||
|
||||
self._trading = TradingClient(
|
||||
api_key=self._api_key,
|
||||
secret_key=self._secret_key,
|
||||
paper=self._paper,
|
||||
)
|
||||
self._data = StockHistoricalDataClient(
|
||||
api_key=self._api_key,
|
||||
secret_key=self._secret_key,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Account
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
acct = self._trading.get_account()
|
||||
equity = float(acct.equity or 0)
|
||||
last_equity = float(acct.last_equity or equity)
|
||||
return AccountInfo(
|
||||
equity=equity,
|
||||
cash=float(acct.cash or 0),
|
||||
buying_power=float(acct.buying_power or 0),
|
||||
long_market_value=float(acct.long_market_value or 0),
|
||||
unrealized_pl=equity - last_equity,
|
||||
portfolio_value=float(acct.portfolio_value or equity),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Orders
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def submit_market_buy(self, symbol: str, qty: int) -> Order:
|
||||
from alpaca.trading.requests import MarketOrderRequest
|
||||
from alpaca.trading.enums import OrderSide, TimeInForce
|
||||
|
||||
req = MarketOrderRequest(
|
||||
symbol=symbol,
|
||||
qty=qty,
|
||||
side=OrderSide.BUY,
|
||||
time_in_force=TimeInForce.DAY,
|
||||
)
|
||||
order = self._trading.submit_order(req)
|
||||
return self._to_order(order)
|
||||
|
||||
def submit_moc_buy(self, symbol: str, qty: int) -> Order:
|
||||
"""Submit a Market-on-Close buy order (fills at today's closing price)."""
|
||||
from alpaca.trading.requests import MarketOrderRequest
|
||||
from alpaca.trading.enums import OrderSide, TimeInForce
|
||||
|
||||
req = MarketOrderRequest(
|
||||
symbol=symbol,
|
||||
qty=qty,
|
||||
side=OrderSide.BUY,
|
||||
time_in_force=TimeInForce.CLS,
|
||||
)
|
||||
order = self._trading.submit_order(req)
|
||||
return self._to_order(order)
|
||||
|
||||
def submit_market_sell(self, symbol: str, qty: int) -> Order:
|
||||
from alpaca.trading.requests import MarketOrderRequest
|
||||
from alpaca.trading.enums import OrderSide, TimeInForce
|
||||
|
||||
req = MarketOrderRequest(
|
||||
symbol=symbol,
|
||||
qty=qty,
|
||||
side=OrderSide.SELL,
|
||||
time_in_force=TimeInForce.DAY,
|
||||
)
|
||||
order = self._trading.submit_order(req)
|
||||
return self._to_order(order)
|
||||
|
||||
def get_order(self, order_id: str) -> Order:
|
||||
order = self._trading.get_order_by_id(order_id)
|
||||
return self._to_order(order)
|
||||
|
||||
def list_orders(self, status: str = "open") -> list[Order]:
|
||||
from alpaca.trading.requests import GetOrdersRequest
|
||||
from alpaca.trading.enums import QueryOrderStatus
|
||||
|
||||
status_map = {
|
||||
"open": QueryOrderStatus.OPEN,
|
||||
"closed": QueryOrderStatus.CLOSED,
|
||||
"all": QueryOrderStatus.ALL,
|
||||
}
|
||||
req = GetOrdersRequest(status=status_map.get(status, QueryOrderStatus.OPEN))
|
||||
orders = self._trading.get_orders(req)
|
||||
return [self._to_order(o) for o in orders]
|
||||
|
||||
def cancel_order(self, order_id: str) -> None:
|
||||
self._trading.cancel_order_by_id(order_id)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Positions
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_positions(self) -> list[Position]:
|
||||
positions = self._trading.get_all_positions()
|
||||
return [self._to_position(p) for p in positions]
|
||||
|
||||
def get_position(self, symbol: str) -> Position | None:
|
||||
try:
|
||||
pos = self._trading.get_open_position(symbol)
|
||||
return self._to_position(pos)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def close_position(self, symbol: str, qty: int | None = None, **kwargs) -> Order:
|
||||
"""Close a position. Pass qty for partial close."""
|
||||
from alpaca.trading.requests import ClosePositionRequest
|
||||
|
||||
if qty is not None:
|
||||
req = ClosePositionRequest(qty=str(qty))
|
||||
order = self._trading.close_position(symbol, close_options=req)
|
||||
else:
|
||||
order = self._trading.close_position(symbol)
|
||||
return self._to_order(order)
|
||||
|
||||
def close_all_positions(self) -> list[Order]:
|
||||
responses = self._trading.close_all_positions(cancel_orders=True)
|
||||
if not responses:
|
||||
return []
|
||||
result = []
|
||||
for resp in responses:
|
||||
try:
|
||||
# close_all_positions returns ClosePositionResponse; body is the actual Order
|
||||
order_obj = getattr(resp, "body", resp)
|
||||
result.append(self._to_order(order_obj))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Price data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_bars(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start: dt.date,
|
||||
end: dt.date,
|
||||
) -> dict[str, list[Bar]]:
|
||||
"""Fetch daily OHLCV bars for a list of symbols in [start, end]."""
|
||||
if not symbols:
|
||||
return {}
|
||||
|
||||
from alpaca.data.requests import StockBarsRequest
|
||||
from alpaca.data.timeframe import TimeFrame
|
||||
|
||||
req = StockBarsRequest(
|
||||
symbol_or_symbols=symbols,
|
||||
timeframe=TimeFrame.Day,
|
||||
start=dt.datetime.combine(start, dt.time.min),
|
||||
end=dt.datetime.combine(end, dt.time.max),
|
||||
feed="iex",
|
||||
)
|
||||
response = self._data.get_stock_bars(req)
|
||||
result: dict[str, list[Bar]] = {}
|
||||
for sym in symbols:
|
||||
try:
|
||||
bars_data = response[sym]
|
||||
except (KeyError, TypeError):
|
||||
bars_data = []
|
||||
result[sym] = [
|
||||
Bar(
|
||||
date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10],
|
||||
open=float(b.open),
|
||||
high=float(b.high),
|
||||
low=float(b.low),
|
||||
close=float(b.close),
|
||||
volume=float(b.volume),
|
||||
)
|
||||
for b in bars_data
|
||||
]
|
||||
return result
|
||||
|
||||
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 indexed by symbol → date → OHLCV dict (matches backtest format)."""
|
||||
raw = self.get_bars(symbols, start, end)
|
||||
result: dict[str, dict[dt.date, dict[str, Any]]] = {}
|
||||
for sym, bars in raw.items():
|
||||
date_map: dict[dt.date, dict[str, Any]] = {}
|
||||
for bar in bars:
|
||||
d = dt.date.fromisoformat(bar.date)
|
||||
date_map[d] = {
|
||||
"date": d,
|
||||
"open": bar.open,
|
||||
"high": bar.high,
|
||||
"low": bar.low,
|
||||
"close": bar.close,
|
||||
"volume": bar.volume,
|
||||
}
|
||||
result[sym] = date_map
|
||||
return result
|
||||
|
||||
def get_latest_bars(self, symbols: list[str]) -> dict[str, Bar]:
|
||||
"""Fetch the latest bar for each symbol."""
|
||||
if not symbols:
|
||||
return {}
|
||||
|
||||
from alpaca.data.requests import StockLatestBarRequest
|
||||
|
||||
req = StockLatestBarRequest(symbol_or_symbols=symbols, feed="iex")
|
||||
response = self._data.get_stock_latest_bar(req)
|
||||
result: dict[str, Bar] = {}
|
||||
for sym in symbols:
|
||||
b = response.get(sym)
|
||||
if b is not None:
|
||||
result[sym] = Bar(
|
||||
date=b.timestamp.date().isoformat() if hasattr(b.timestamp, "date") else str(b.timestamp)[:10],
|
||||
open=float(b.open),
|
||||
high=float(b.high),
|
||||
low=float(b.low),
|
||||
close=float(b.close),
|
||||
volume=float(b.volume),
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Portfolio history
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_portfolio_history(self, period: str = "1M") -> PortfolioHistory:
|
||||
from alpaca.trading.requests import GetPortfolioHistoryRequest
|
||||
|
||||
req = GetPortfolioHistoryRequest(period=period, timeframe="1D")
|
||||
hist = self._trading.get_portfolio_history(req)
|
||||
return PortfolioHistory(
|
||||
timestamps=list(hist.timestamp or []),
|
||||
equity=[float(v) for v in (hist.equity or [])],
|
||||
profit_loss=[float(v) for v in (hist.profit_loss or [])],
|
||||
profit_loss_pct=[float(v) for v in (hist.profit_loss_pct or [])],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _to_order(order: Any) -> Order:
|
||||
return Order(
|
||||
id=str(order.id),
|
||||
symbol=str(order.symbol),
|
||||
qty=int(float(order.qty or 0)),
|
||||
side=str(order.side.value if hasattr(order.side, "value") else order.side),
|
||||
status=str(order.status.value if hasattr(order.status, "value") else order.status),
|
||||
filled_avg_price=float(order.filled_avg_price) if order.filled_avg_price else None,
|
||||
filled_qty=int(float(order.filled_qty or 0)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_position(pos: Any) -> Position:
|
||||
return Position(
|
||||
symbol=str(pos.symbol),
|
||||
qty=int(float(pos.qty or 0)),
|
||||
avg_entry_price=float(pos.avg_entry_price or 0),
|
||||
current_price=float(pos.current_price or 0),
|
||||
unrealized_pl=float(pos.unrealized_pl or 0),
|
||||
market_value=float(pos.market_value or 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "AlpacaBroker":
|
||||
"""Create from environment variables."""
|
||||
paper = os.environ.get("ALPACA_PAPER", "true").lower() != "false"
|
||||
return cls(
|
||||
api_key=os.environ.get("ALPACA_API_KEY"),
|
||||
secret_key=os.environ.get("ALPACA_SECRET_KEY"),
|
||||
paper=paper,
|
||||
)
|
||||
@ -0,0 +1,322 @@
|
||||
"""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 []
|
||||
@ -0,0 +1,105 @@
|
||||
"""SQLite table definitions for the paper trading system."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
session_name TEXT NOT NULL,
|
||||
config_path TEXT NOT NULL,
|
||||
initial_equity REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_states (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
||||
symbol TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
engine_id TEXT NOT NULL DEFAULT 'default',
|
||||
order_id TEXT,
|
||||
entry_date TEXT NOT NULL,
|
||||
stop_price REAL NOT NULL,
|
||||
target_price REAL NOT NULL,
|
||||
current_stop REAL NOT NULL,
|
||||
peak_price REAL NOT NULL,
|
||||
days_held INTEGER NOT NULL DEFAULT 0,
|
||||
trade_direction TEXT NOT NULL DEFAULT 'long',
|
||||
candidate_json TEXT NOT NULL,
|
||||
plan_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
UNIQUE(session_id, symbol, status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_state (
|
||||
session_id TEXT PRIMARY KEY REFERENCES sessions(session_id),
|
||||
consecutive_losses INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
kill_switch_triggered INTEGER NOT NULL DEFAULT 0,
|
||||
daily_new_risk_used REAL NOT NULL DEFAULT 0.0,
|
||||
last_processed_date TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_events (
|
||||
session_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
processed_date TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
skip_reason TEXT,
|
||||
PRIMARY KEY (session_id, event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
trade_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
entry_date TEXT,
|
||||
exit_date TEXT,
|
||||
entry_price REAL,
|
||||
exit_price REAL,
|
||||
exit_reason TEXT,
|
||||
shares INTEGER,
|
||||
net_pnl REAL,
|
||||
r_multiple REAL,
|
||||
holding_days INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_snapshots (
|
||||
session_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
equity REAL NOT NULL,
|
||||
cash REAL NOT NULL,
|
||||
market_value REAL NOT NULL,
|
||||
daily_pnl REAL,
|
||||
total_pnl REAL,
|
||||
drawdown_pct REAL,
|
||||
open_position_count INTEGER,
|
||||
PRIMARY KEY (session_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_dates (
|
||||
session_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_phases (
|
||||
session_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, date, phase)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def create_schema(db_path: str | Path) -> None:
|
||||
"""Initialize the SQLite database with the paper trading schema."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@ -0,0 +1,576 @@
|
||||
"""Rich console output for paper trading status, positions, trades, and equity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from apps.paper_trader.alpaca_broker import AlpacaBroker, Position
|
||||
from apps.paper_trader.state import SessionRow, StateManager, StrategyStateRow
|
||||
|
||||
_console = Console(width=140)
|
||||
|
||||
|
||||
def _pnl_color(val: float) -> str:
|
||||
return "green" if val >= 0 else "red"
|
||||
|
||||
|
||||
def _fmt_pct(val: float) -> str:
|
||||
sign = "+" if val >= 0 else ""
|
||||
return f"{sign}{val:.2f}%"
|
||||
|
||||
|
||||
def _fmt_pnl(val: float) -> str:
|
||||
sign = "+" if val >= 0 else ""
|
||||
return f"{sign}${val:,.2f}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Session status
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def print_status(
|
||||
session: SessionRow,
|
||||
broker: AlpacaBroker,
|
||||
state: StateManager,
|
||||
) -> None:
|
||||
account = broker.get_account()
|
||||
session_st = state.get_session_state(session.session_id)
|
||||
snapshots = state.list_snapshots(session.session_id)
|
||||
|
||||
initial_equity = session.initial_equity
|
||||
# Use latest snapshot equity if available, otherwise fall back to initial
|
||||
if snapshots:
|
||||
current_equity = snapshots[-1]["equity"]
|
||||
else:
|
||||
current_equity = initial_equity
|
||||
total_pnl = current_equity - initial_equity
|
||||
total_pnl_pct = total_pnl / initial_equity * 100 if initial_equity else 0.0
|
||||
peak_equity = state.get_peak_equity(session.session_id, initial_equity)
|
||||
drawdown_pct = max(0.0, (peak_equity - current_equity) / peak_equity * 100) if peak_equity > 0 else 0.0
|
||||
|
||||
positions = broker.list_positions()
|
||||
kill_status = "[bold red]ON[/]" if session_st.kill_switch_triggered else "[green]OFF[/]"
|
||||
cooldown_str = f"{session_st.cooldown_remaining}d" if session_st.cooldown_remaining > 0 else "none"
|
||||
|
||||
pnl_style = _pnl_color(total_pnl)
|
||||
|
||||
_console.print()
|
||||
_console.print(Panel(
|
||||
f"[bold cyan]Session:[/] {session.session_name} "
|
||||
f"[dim]|[/] [bold]Config:[/] {session.config_path} "
|
||||
f"[dim]|[/] [bold]Status:[/] {session.status}",
|
||||
border_style="cyan",
|
||||
padding=(0, 1),
|
||||
))
|
||||
|
||||
tbl = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
|
||||
tbl.add_column("Key", style="bold yellow", no_wrap=True)
|
||||
tbl.add_column("Value", no_wrap=True)
|
||||
|
||||
tbl.add_row("Equity", f"[bold]${current_equity:,.2f}[/] [{pnl_style}]{_fmt_pnl(total_pnl)} ({_fmt_pct(total_pnl_pct)})[/]")
|
||||
tbl.add_row("Cash", f"${account.cash:,.2f}")
|
||||
tbl.add_row("Market Value", f"${account.long_market_value:,.2f}")
|
||||
tbl.add_row("Open Positions", str(len(positions)))
|
||||
tbl.add_row("Drawdown", f"[{'red' if drawdown_pct > 5 else 'green'}]{drawdown_pct:.2f}%[/]")
|
||||
tbl.add_row("Consecutive Losses", str(session_st.consecutive_losses))
|
||||
tbl.add_row("Cooldown", cooldown_str)
|
||||
tbl.add_row("Kill Switch", kill_status)
|
||||
tbl.add_row("Total Trades", str(len(state.list_trades(session.session_id))))
|
||||
if snapshots:
|
||||
tbl.add_row("Last Processed", snapshots[-1]["date"])
|
||||
|
||||
_console.print(tbl)
|
||||
_console.print()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Positions
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def print_positions(
|
||||
session: SessionRow,
|
||||
broker: AlpacaBroker,
|
||||
state: StateManager,
|
||||
) -> None:
|
||||
positions = broker.list_positions()
|
||||
strategy_states = {
|
||||
ss.symbol: ss
|
||||
for ss in state.get_open_strategy_states(session.session_id)
|
||||
}
|
||||
|
||||
if not positions:
|
||||
_console.print("[dim]No open positions.[/]")
|
||||
return
|
||||
|
||||
tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title=f"[bold cyan]Open Positions[/] — {session.session_name}",
|
||||
title_justify="left",
|
||||
)
|
||||
tbl.add_column("Symbol", style="bold", no_wrap=True)
|
||||
tbl.add_column("Qty", justify="right")
|
||||
tbl.add_column("Entry", justify="right")
|
||||
tbl.add_column("Current", justify="right")
|
||||
tbl.add_column("P&L $", justify="right")
|
||||
tbl.add_column("P&L %", justify="right")
|
||||
tbl.add_column("Days", justify="right")
|
||||
tbl.add_column("Stop", justify="right")
|
||||
tbl.add_column("Target", justify="right")
|
||||
|
||||
for pos in sorted(positions, key=lambda p: p.symbol):
|
||||
ss = strategy_states.get(pos.symbol)
|
||||
stop_str = f"${ss.current_stop:.2f}" if ss else "-"
|
||||
target_str = f"${ss.target_price:.2f}" if ss else "-"
|
||||
days_str = str(ss.days_held) if ss else "-"
|
||||
|
||||
pnl = pos.unrealized_pl
|
||||
pnl_pct = pnl / (pos.avg_entry_price * pos.qty) * 100 if pos.avg_entry_price and pos.qty else 0.0
|
||||
pnl_color = _pnl_color(pnl)
|
||||
|
||||
tbl.add_row(
|
||||
pos.symbol,
|
||||
str(pos.qty),
|
||||
f"${pos.avg_entry_price:.2f}",
|
||||
f"${pos.current_price:.2f}",
|
||||
f"[{pnl_color}]{_fmt_pnl(pnl)}[/]",
|
||||
f"[{pnl_color}]{_fmt_pct(pnl_pct)}[/]",
|
||||
days_str,
|
||||
stop_str,
|
||||
target_str,
|
||||
)
|
||||
|
||||
_console.print()
|
||||
_console.print(tbl)
|
||||
_console.print()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Trades
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def print_trades(
|
||||
session: SessionRow,
|
||||
state: StateManager,
|
||||
last: int | None = None,
|
||||
) -> None:
|
||||
trades = state.list_trades(session.session_id, limit=last)
|
||||
|
||||
if not trades:
|
||||
_console.print("[dim]No trades recorded yet.[/]")
|
||||
return
|
||||
|
||||
tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title=f"[bold cyan]Trades[/] — {session.session_name}",
|
||||
title_justify="left",
|
||||
)
|
||||
tbl.add_column("Symbol", style="bold", no_wrap=True)
|
||||
tbl.add_column("Entry Date", no_wrap=True)
|
||||
tbl.add_column("Exit Date", no_wrap=True)
|
||||
tbl.add_column("Entry $", justify="right")
|
||||
tbl.add_column("Exit $", justify="right")
|
||||
tbl.add_column("Shares", justify="right")
|
||||
tbl.add_column("Net P&L", justify="right")
|
||||
tbl.add_column("R", justify="right")
|
||||
tbl.add_column("Days", justify="right")
|
||||
tbl.add_column("Reason", style="dim")
|
||||
|
||||
for t in trades:
|
||||
pnl = t.get("net_pnl") or 0.0
|
||||
r = t.get("r_multiple") or 0.0
|
||||
pnl_color = _pnl_color(pnl)
|
||||
r_color = _pnl_color(r)
|
||||
|
||||
tbl.add_row(
|
||||
str(t.get("symbol", "")),
|
||||
str(t.get("entry_date", "-")),
|
||||
str(t.get("exit_date", "-")),
|
||||
f"${t.get('entry_price') or 0:.2f}",
|
||||
f"${t.get('exit_price') or 0:.2f}",
|
||||
str(t.get("shares", "-")),
|
||||
f"[{pnl_color}]{_fmt_pnl(pnl)}[/]",
|
||||
f"[{r_color}]{r:+.2f}R[/]",
|
||||
str(t.get("holding_days", "-")),
|
||||
str(t.get("exit_reason", "-")),
|
||||
)
|
||||
|
||||
_console.print()
|
||||
_console.print(tbl)
|
||||
_console.print()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Equity curve (ASCII sparkline)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def print_equity(
|
||||
session: SessionRow,
|
||||
state: StateManager,
|
||||
) -> None:
|
||||
snapshots = state.list_snapshots(session.session_id)
|
||||
|
||||
if not snapshots:
|
||||
_console.print("[dim]No equity history yet.[/]")
|
||||
return
|
||||
|
||||
tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title=f"[bold cyan]Equity Curve[/] — {session.session_name}",
|
||||
title_justify="left",
|
||||
)
|
||||
tbl.add_column("Date", no_wrap=True)
|
||||
tbl.add_column("Equity", justify="right")
|
||||
tbl.add_column("Daily P&L", justify="right")
|
||||
tbl.add_column("Total P&L", justify="right")
|
||||
tbl.add_column("Drawdown", justify="right")
|
||||
tbl.add_column("Positions", justify="right")
|
||||
|
||||
for snap in snapshots:
|
||||
equity = snap.get("equity", 0.0)
|
||||
daily_pnl = snap.get("daily_pnl") or 0.0
|
||||
total_pnl = snap.get("total_pnl") or 0.0
|
||||
dd = snap.get("drawdown_pct") or 0.0
|
||||
n_pos = snap.get("open_position_count")
|
||||
|
||||
daily_color = _pnl_color(daily_pnl)
|
||||
total_color = _pnl_color(total_pnl)
|
||||
dd_color = "red" if dd > 5 else "green"
|
||||
|
||||
tbl.add_row(
|
||||
str(snap.get("date", "-")),
|
||||
f"${equity:,.2f}",
|
||||
f"[{daily_color}]{_fmt_pnl(daily_pnl)}[/]",
|
||||
f"[{total_color}]{_fmt_pnl(total_pnl)}[/]",
|
||||
f"[{dd_color}]{dd:.2f}%[/]",
|
||||
str(n_pos) if n_pos is not None else "-",
|
||||
)
|
||||
|
||||
_console.print()
|
||||
_console.print(tbl)
|
||||
_console.print()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Sessions list
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def print_sessions(sessions: list[SessionRow]) -> None:
|
||||
if not sessions:
|
||||
_console.print("[dim]No sessions found.[/]")
|
||||
return
|
||||
|
||||
tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title="[bold cyan]Paper Trading Sessions[/]",
|
||||
title_justify="left",
|
||||
)
|
||||
tbl.add_column("ID", style="dim", no_wrap=True)
|
||||
tbl.add_column("Name", style="bold", no_wrap=True)
|
||||
tbl.add_column("Config", no_wrap=True)
|
||||
tbl.add_column("Capital", justify="right")
|
||||
tbl.add_column("Status", no_wrap=True)
|
||||
tbl.add_column("Created", no_wrap=True)
|
||||
|
||||
for s in sessions:
|
||||
status_color = "green" if s.status == "active" else "dim"
|
||||
tbl.add_row(
|
||||
s.session_id,
|
||||
s.session_name,
|
||||
s.config_path,
|
||||
f"${s.initial_equity:,.0f}",
|
||||
f"[{status_color}]{s.status}[/]",
|
||||
s.created_at[:10],
|
||||
)
|
||||
|
||||
_console.print()
|
||||
_console.print(tbl)
|
||||
_console.print()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Run summary
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _print_overlay_detail(r: dict) -> None:
|
||||
"""Print overlay regime allocation + per-book summary."""
|
||||
name = r["session_name"]
|
||||
|
||||
# Regime day counts
|
||||
regime_counts = r.get("regime_day_counts", {})
|
||||
if regime_counts:
|
||||
regime_tbl = Table(
|
||||
box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow",
|
||||
padding=(0, 1), title=f"[bold magenta]Regime Days — {name}[/]", title_justify="left",
|
||||
)
|
||||
regime_tbl.add_column("Regime", style="bold")
|
||||
regime_tbl.add_column("Days", justify="right")
|
||||
regime_tbl.add_column("Allocation", no_wrap=True)
|
||||
allocations = r.get("allocations", {})
|
||||
for regime, count in sorted(regime_counts.items()):
|
||||
alloc = allocations.get(regime, {})
|
||||
alloc_str = " ".join(f"{k}={v:.0%}" for k, v in alloc.items())
|
||||
regime_tbl.add_row(regime, str(count), alloc_str)
|
||||
_console.print(regime_tbl)
|
||||
|
||||
# Per-book summary
|
||||
book_results = r.get("book_results", [])
|
||||
if book_results:
|
||||
book_tbl = Table(
|
||||
box=box.SIMPLE_HEAD, show_header=True, header_style="bold yellow",
|
||||
padding=(0, 1), title=f"[bold magenta]Books — {name}[/]", title_justify="left",
|
||||
)
|
||||
book_tbl.add_column("Book", style="bold")
|
||||
book_tbl.add_column("Return", justify="right")
|
||||
book_tbl.add_column("MaxDD", justify="right")
|
||||
book_tbl.add_column("Trades", justify="right")
|
||||
book_tbl.add_column("WinRate", justify="right")
|
||||
book_tbl.add_column("Sharpe", justify="right")
|
||||
for br in book_results:
|
||||
bs = br["result"]["summary"]
|
||||
ret_color = "green" if bs["return_pct"] >= 0 else "red"
|
||||
book_tbl.add_row(
|
||||
br["label"],
|
||||
f"[{ret_color}]{bs['return_pct']:+.2f}%[/{ret_color}]",
|
||||
f"[red]-{bs['max_dd_pct']:.2f}%[/]",
|
||||
str(bs["trade_count"]),
|
||||
f"{bs['win_rate']:.0f}%",
|
||||
f"{bs['sharpe']:+.2f}",
|
||||
)
|
||||
_console.print(book_tbl)
|
||||
|
||||
|
||||
def print_backtest_results(results: list[dict], output_dir: str | None = None, show_trades: bool = True) -> None:
|
||||
"""Print equity curve comparison, summary table, and per-strategy trade logs."""
|
||||
import csv
|
||||
import os
|
||||
|
||||
if not results:
|
||||
_console.print("[dim]No backtest results.[/]")
|
||||
return
|
||||
|
||||
# ── Summary table ──────────────────────────────────────────────────────
|
||||
sum_tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title="[bold cyan]Summary[/]",
|
||||
title_justify="left",
|
||||
)
|
||||
sum_tbl.add_column("Strategy", style="bold", no_wrap=True)
|
||||
sum_tbl.add_column("Return", justify="right")
|
||||
sum_tbl.add_column("MaxDD", justify="right")
|
||||
sum_tbl.add_column("Trades", justify="right")
|
||||
sum_tbl.add_column("WinRate", justify="right")
|
||||
sum_tbl.add_column("Sharpe", justify="right")
|
||||
|
||||
for r in results:
|
||||
s = r["summary"]
|
||||
ret_color = "green" if s["return_pct"] >= 0 else "red"
|
||||
name = r["session_name"]
|
||||
if r.get("is_overlay"):
|
||||
name = f"{name} [overlay]"
|
||||
sum_tbl.add_row(
|
||||
name,
|
||||
f"[{ret_color}]{s['return_pct']:+.2f}%[/{ret_color}]",
|
||||
f"[red]-{s['max_dd_pct']:.2f}%[/]",
|
||||
str(s["trade_count"]),
|
||||
f"{s['win_rate']:.0f}%" if s.get("win_rate") else "-",
|
||||
f"{s['sharpe']:+.2f}",
|
||||
)
|
||||
|
||||
_console.print(sum_tbl)
|
||||
|
||||
# ── Overlay detail sections ───────────────────────────────────────────
|
||||
for r in results:
|
||||
if not r.get("is_overlay"):
|
||||
continue
|
||||
_print_overlay_detail(r)
|
||||
|
||||
# ── Per-strategy trade logs ────────────────────────────────────────────
|
||||
if not show_trades:
|
||||
if output_dir:
|
||||
import csv as _csv
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for r in results:
|
||||
name = r["session_name"]
|
||||
eq_path = os.path.join(output_dir, f"{name}_equity.csv")
|
||||
with open(eq_path, "w", newline="") as f:
|
||||
w = _csv.DictWriter(f, fieldnames=["date", "equity"])
|
||||
w.writeheader()
|
||||
for row in r["equity_curve"]:
|
||||
w.writerow({"date": row["date"].isoformat(), "equity": row["equity"]})
|
||||
trades_path = os.path.join(output_dir, f"{name}_trades.csv")
|
||||
if r.get("trades"):
|
||||
with open(trades_path, "w", newline="") as f:
|
||||
fieldnames = ["symbol", "event_type", "score", "entry_date", "exit_date", "shares", "entry_price", "exit_price", "pnl", "reason"]
|
||||
w = _csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
w.writeheader()
|
||||
w.writerows(r["trades"])
|
||||
_console.print(f"\n[dim]Results saved to {output_dir}[/]")
|
||||
return
|
||||
|
||||
for r in results:
|
||||
trades = r.get("trades", [])
|
||||
if not trades:
|
||||
_console.print(f" [dim]{r['session_name']}: no completed trades[/]")
|
||||
continue
|
||||
|
||||
tbl = Table(
|
||||
box=box.SIMPLE_HEAD,
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
padding=(0, 1),
|
||||
title=f"[bold cyan]Trades — {r['session_name']}[/]",
|
||||
title_justify="left",
|
||||
)
|
||||
tbl.add_column("Symbol", style="bold", no_wrap=True)
|
||||
tbl.add_column("EventType", no_wrap=True)
|
||||
tbl.add_column("Score", justify="right")
|
||||
tbl.add_column("Entry", no_wrap=True)
|
||||
tbl.add_column("Exit", no_wrap=True)
|
||||
tbl.add_column("Days", justify="right")
|
||||
tbl.add_column("Shares", justify="right")
|
||||
tbl.add_column("EntryPx", justify="right")
|
||||
tbl.add_column("ExitPx", justify="right")
|
||||
tbl.add_column("P&L", justify="right")
|
||||
tbl.add_column("Reason", style="dim")
|
||||
|
||||
for t in sorted(trades, key=lambda x: x.get("entry_date", "")):
|
||||
pnl = t.get("pnl", 0.0)
|
||||
color = _pnl_color(pnl)
|
||||
|
||||
entry_d = t.get("entry_date", "")
|
||||
exit_d = t.get("exit_date", "")
|
||||
try:
|
||||
import datetime as _dt
|
||||
holding_days = (_dt.date.fromisoformat(str(exit_d)) - _dt.date.fromisoformat(str(entry_d))).days if entry_d and exit_d else "-"
|
||||
except Exception:
|
||||
holding_days = "-"
|
||||
|
||||
score = t.get("score")
|
||||
score_str = f"{score:.2f}" if score is not None else "-"
|
||||
entry_px = t.get("entry_price")
|
||||
exit_px = t.get("exit_price")
|
||||
entry_px_str = f"${entry_px:.2f}" if entry_px is not None else "-"
|
||||
exit_px_str = f"${exit_px:.2f}" if exit_px is not None else "-"
|
||||
|
||||
tbl.add_row(
|
||||
t.get("symbol", ""),
|
||||
t.get("event_type", "-"),
|
||||
score_str,
|
||||
str(entry_d) if entry_d else "-",
|
||||
str(exit_d) if exit_d else "-",
|
||||
str(holding_days),
|
||||
str(t.get("shares", "-")),
|
||||
entry_px_str,
|
||||
exit_px_str,
|
||||
f"[{color}]{_fmt_pnl(pnl)}[/{color}]",
|
||||
t.get("reason", "-"),
|
||||
)
|
||||
_console.print(tbl)
|
||||
|
||||
# ── Optional CSV save ──────────────────────────────────────────────────
|
||||
if output_dir:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for r in results:
|
||||
name = r["session_name"]
|
||||
|
||||
eq_path = os.path.join(output_dir, f"{name}_equity.csv")
|
||||
with open(eq_path, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["date", "equity"])
|
||||
w.writeheader()
|
||||
for row in r["equity_curve"]:
|
||||
w.writerow({"date": row["date"].isoformat(), "equity": row["equity"]})
|
||||
|
||||
trades_path = os.path.join(output_dir, f"{name}_trades.csv")
|
||||
if r.get("trades"):
|
||||
with open(trades_path, "w", newline="") as f:
|
||||
fieldnames = ["symbol", "event_type", "score", "entry_date", "exit_date", "shares", "entry_price", "exit_price", "pnl", "reason"]
|
||||
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
w.writeheader()
|
||||
w.writerows(r["trades"])
|
||||
|
||||
_console.print(f"\n[dim]Results saved to {output_dir}[/]")
|
||||
|
||||
|
||||
def print_run_summary(summary: dict) -> None:
|
||||
date = summary.get("date", "-")
|
||||
status = summary.get("status", "-")
|
||||
|
||||
if status in ("already_processed", "non_trading_day"):
|
||||
_console.print(f"[dim]{date}: {status}[/]")
|
||||
return
|
||||
|
||||
_console.print(f"\nProcessing [bold]{date}[/]...")
|
||||
|
||||
exits = summary.get("exits", [])
|
||||
entries = summary.get("entries", [])
|
||||
rejected = summary.get("rejected", [])
|
||||
candidates_detected = summary.get("candidates_detected", None)
|
||||
account = summary.get("account", {})
|
||||
|
||||
# Exits
|
||||
if exits:
|
||||
_console.print(" [bold]EXITS:[/]")
|
||||
for e in exits:
|
||||
pnl = e.get("pnl", 0.0)
|
||||
r = e.get("r_multiple", 0.0)
|
||||
color = _pnl_color(pnl)
|
||||
_console.print(
|
||||
f" [{color}]{e['symbol']}[/] — {e['reason']} "
|
||||
f"P&L: [{color}]{_fmt_pnl(pnl)}[/] R: [{color}]{r:+.2f}R[/]"
|
||||
)
|
||||
else:
|
||||
_console.print(" [dim]EXITS: none[/]")
|
||||
|
||||
# Candidates detected
|
||||
if candidates_detected is not None:
|
||||
if candidates_detected == 0:
|
||||
_console.print(" [dim]CANDIDATES: 0 events in DB for this date[/]")
|
||||
else:
|
||||
_console.print(f" [dim]CANDIDATES: {candidates_detected} events detected from DB[/]")
|
||||
|
||||
# Entries
|
||||
if entries:
|
||||
_console.print(" [bold]ENTRIES:[/]")
|
||||
for e in entries:
|
||||
_console.print(
|
||||
f" [green]{e['symbol']}[/] ({e['event_type']}, score={e['score']:.2f}) "
|
||||
f"-> BUY {e['shares']} shares stop=${e['stop']:.2f} target=${e['target']:.2f}"
|
||||
)
|
||||
else:
|
||||
_console.print(" [dim]ENTRIES: none[/]")
|
||||
|
||||
# Rejected (only if non-zero)
|
||||
if rejected:
|
||||
_console.print(f" [dim]REJECTED: {len(rejected)} candidates[/]")
|
||||
|
||||
# Account summary
|
||||
if account:
|
||||
pnl = account.get("total_pnl", 0.0)
|
||||
color = _pnl_color(pnl)
|
||||
_console.print(
|
||||
f"\n Equity: [bold]${account.get('equity', 0):,.2f}[/] "
|
||||
f"Total P&L: [{color}]{_fmt_pnl(pnl)}[/] "
|
||||
f"Drawdown: {account.get('drawdown_pct', 0):.2f}%"
|
||||
)
|
||||
_console.print()
|
||||
@ -0,0 +1,412 @@
|
||||
"""SQLite state management for paper trading sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from apps.paper_trader.models import create_schema
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionRow:
|
||||
session_id: str
|
||||
session_name: str
|
||||
config_path: str
|
||||
initial_equity: float
|
||||
created_at: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyStateRow:
|
||||
session_id: str
|
||||
symbol: str
|
||||
event_id: str
|
||||
engine_id: str
|
||||
entry_date: str
|
||||
stop_price: float
|
||||
target_price: float
|
||||
current_stop: float
|
||||
peak_price: float
|
||||
days_held: int
|
||||
trade_direction: str
|
||||
candidate_json: str
|
||||
plan_json: str
|
||||
status: str
|
||||
id: int | None = None
|
||||
order_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionStateRow:
|
||||
session_id: str
|
||||
consecutive_losses: int = 0
|
||||
cooldown_remaining: int = 0
|
||||
kill_switch_triggered: bool = False
|
||||
daily_new_risk_used: float = 0.0
|
||||
last_processed_date: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailySnapshotRow:
|
||||
session_id: str
|
||||
date: str
|
||||
equity: float
|
||||
cash: float
|
||||
market_value: float
|
||||
daily_pnl: float | None = None
|
||||
total_pnl: float | None = None
|
||||
drawdown_pct: float | None = None
|
||||
open_position_count: int | None = None
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""SQLite CRUD for paper trading state."""
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
create_schema(self.db_path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Sessions
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
session_name: str,
|
||||
config_path: str,
|
||||
initial_equity: float,
|
||||
) -> str:
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
created_at = dt.datetime.now(tz=dt.timezone.utc).isoformat()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'active')",
|
||||
(session_id, session_name, config_path, initial_equity, created_at),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO session_state (session_id) VALUES (?)",
|
||||
(session_id,),
|
||||
)
|
||||
return session_id
|
||||
|
||||
def get_session(self, session_name_or_id: str) -> SessionRow | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ? OR session_name = ? LIMIT 1",
|
||||
(session_name_or_id, session_name_or_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return SessionRow(**dict(row))
|
||||
|
||||
def list_sessions(self) -> list[SessionRow]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY created_at"
|
||||
).fetchall()
|
||||
return [SessionRow(**dict(r)) for r in rows]
|
||||
|
||||
def set_session_status(self, session_id: str, status: str) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET status = ? WHERE session_id = ?",
|
||||
(status, session_id),
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Delete a session and all related data."""
|
||||
with self._connect() as conn:
|
||||
for table in (
|
||||
"processed_phases", "processed_dates", "daily_snapshots",
|
||||
"trades", "processed_events", "strategy_states", "session_state",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table} WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Strategy states
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_open_strategy_states(self, session_id: str) -> list[StrategyStateRow]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM strategy_states WHERE session_id = ? AND status IN ('open', 'partial')",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [StrategyStateRow(**dict(r)) for r in rows]
|
||||
|
||||
def get_strategy_state_by_symbol(
|
||||
self, session_id: str, symbol: str
|
||||
) -> StrategyStateRow | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM strategy_states WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial') LIMIT 1",
|
||||
(session_id, symbol),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return StrategyStateRow(**dict(row))
|
||||
|
||||
def save_strategy_state(self, session_id: str, state: StrategyStateRow) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO strategy_states
|
||||
(session_id, symbol, event_id, engine_id, order_id, entry_date,
|
||||
stop_price, target_price, current_stop, peak_price, days_held,
|
||||
trade_direction, candidate_json, plan_json, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, symbol, status) DO UPDATE SET
|
||||
current_stop=excluded.current_stop,
|
||||
peak_price=excluded.peak_price,
|
||||
days_held=excluded.days_held,
|
||||
order_id=excluded.order_id""",
|
||||
(
|
||||
session_id, state.symbol, state.event_id, state.engine_id,
|
||||
state.order_id, state.entry_date, state.stop_price, state.target_price,
|
||||
state.current_stop, state.peak_price, state.days_held,
|
||||
state.trade_direction, state.candidate_json, state.plan_json, state.status,
|
||||
),
|
||||
)
|
||||
|
||||
def update_strategy_state(
|
||||
self,
|
||||
session_id: str,
|
||||
symbol: str,
|
||||
*,
|
||||
days_held: int | None = None,
|
||||
current_stop: float | None = None,
|
||||
peak_price: float | None = None,
|
||||
status: str | None = None,
|
||||
) -> None:
|
||||
updates: list[str] = []
|
||||
values: list[Any] = []
|
||||
if days_held is not None:
|
||||
updates.append("days_held = ?")
|
||||
values.append(days_held)
|
||||
if current_stop is not None:
|
||||
updates.append("current_stop = ?")
|
||||
values.append(current_stop)
|
||||
if peak_price is not None:
|
||||
updates.append("peak_price = ?")
|
||||
values.append(peak_price)
|
||||
if status is not None:
|
||||
updates.append("status = ?")
|
||||
values.append(status)
|
||||
if not updates:
|
||||
return
|
||||
values.extend([session_id, symbol])
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE strategy_states SET {', '.join(updates)} "
|
||||
"WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial')",
|
||||
values,
|
||||
)
|
||||
|
||||
def close_strategy_state(self, session_id: str, symbol: str) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM strategy_states "
|
||||
"WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial')",
|
||||
(session_id, symbol),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Session-level state
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_session_state(self, session_id: str) -> SessionStateRow:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM session_state WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return SessionStateRow(session_id=session_id)
|
||||
d = dict(row)
|
||||
d["kill_switch_triggered"] = bool(d["kill_switch_triggered"])
|
||||
return SessionStateRow(**d)
|
||||
|
||||
def update_session_state(self, state: SessionStateRow) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO session_state
|
||||
(session_id, consecutive_losses, cooldown_remaining,
|
||||
kill_switch_triggered, daily_new_risk_used, last_processed_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
consecutive_losses=excluded.consecutive_losses,
|
||||
cooldown_remaining=excluded.cooldown_remaining,
|
||||
kill_switch_triggered=excluded.kill_switch_triggered,
|
||||
daily_new_risk_used=excluded.daily_new_risk_used,
|
||||
last_processed_date=excluded.last_processed_date""",
|
||||
(
|
||||
state.session_id,
|
||||
state.consecutive_losses,
|
||||
state.cooldown_remaining,
|
||||
int(state.kill_switch_triggered),
|
||||
state.daily_new_risk_used,
|
||||
state.last_processed_date,
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Processed events
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def has_processed_event(self, session_id: str, event_id: str) -> bool:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM processed_events WHERE session_id = ? AND event_id = ?",
|
||||
(session_id, event_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def record_processed_event(
|
||||
self,
|
||||
session_id: str,
|
||||
event_id: str,
|
||||
processed_date: str,
|
||||
action: str,
|
||||
skip_reason: str | None = None,
|
||||
) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO processed_events "
|
||||
"(session_id, event_id, processed_date, action, skip_reason) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, event_id, processed_date, action, skip_reason),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Trades
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def record_trade(
|
||||
self,
|
||||
session_id: str,
|
||||
symbol: str,
|
||||
entry_date: str | None,
|
||||
exit_date: str,
|
||||
entry_price: float | None,
|
||||
exit_price: float,
|
||||
exit_reason: str,
|
||||
shares: int,
|
||||
net_pnl: float,
|
||||
r_multiple: float,
|
||||
holding_days: int,
|
||||
) -> str:
|
||||
trade_id = str(uuid.uuid4())
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO trades (trade_id, session_id, symbol, entry_date, exit_date, "
|
||||
"entry_price, exit_price, exit_reason, shares, net_pnl, r_multiple, holding_days) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
trade_id, session_id, symbol, entry_date, exit_date,
|
||||
entry_price, exit_price, exit_reason, shares,
|
||||
net_pnl, r_multiple, holding_days,
|
||||
),
|
||||
)
|
||||
return trade_id
|
||||
|
||||
def list_trades(self, session_id: str, limit: int | None = None) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
if limit:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trades WHERE session_id = ? ORDER BY exit_date DESC LIMIT ?",
|
||||
(session_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM trades WHERE session_id = ? ORDER BY exit_date",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Daily snapshots
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def save_daily_snapshot(self, row: DailySnapshotRow) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO daily_snapshots
|
||||
(session_id, date, equity, cash, market_value, daily_pnl,
|
||||
total_pnl, drawdown_pct, open_position_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, date) DO UPDATE SET
|
||||
equity=excluded.equity,
|
||||
cash=excluded.cash,
|
||||
market_value=excluded.market_value,
|
||||
daily_pnl=excluded.daily_pnl,
|
||||
total_pnl=excluded.total_pnl,
|
||||
drawdown_pct=excluded.drawdown_pct,
|
||||
open_position_count=excluded.open_position_count""",
|
||||
(
|
||||
row.session_id, row.date, row.equity, row.cash, row.market_value,
|
||||
row.daily_pnl, row.total_pnl, row.drawdown_pct, row.open_position_count,
|
||||
),
|
||||
)
|
||||
|
||||
def list_snapshots(self, session_id: str) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_snapshots WHERE session_id = ? ORDER BY date",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_peak_equity(self, session_id: str, initial_equity: float) -> float:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(equity) FROM daily_snapshots WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row and row[0] is not None:
|
||||
return max(float(row[0]), initial_equity)
|
||||
return initial_equity
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Idempotency: processed dates
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def is_phase_processed(self, session_id: str, date: dt.date, phase: str) -> bool:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM processed_phases WHERE session_id = ? AND date = ? AND phase = ?",
|
||||
(session_id, date.isoformat(), phase),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def mark_phase_processed(self, session_id: str, date: dt.date, phase: str) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO processed_phases (session_id, date, phase) VALUES (?, ?, ?)",
|
||||
(session_id, date.isoformat(), phase),
|
||||
)
|
||||
|
||||
def is_date_processed(self, session_id: str, date: dt.date) -> bool:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM processed_dates WHERE session_id = ? AND date = ?",
|
||||
(session_id, date.isoformat()),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def mark_date_processed(self, session_id: str, date: dt.date) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO processed_dates (session_id, date) VALUES (?, ?)",
|
||||
(session_id, date.isoformat()),
|
||||
)
|
||||
Loading…
Reference in New Issue