Source code for trader.persistence.snapshots

"""Repository for account/position snapshots (requirements §10).

The only repository wired in Slice 1. Later slices add repositories for
trades, decisions, watchlist events, and backtest runs against the same
schema.
"""

from collections.abc import Sequence
from datetime import UTC, datetime

from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload, sessionmaker

from trader.domain import Account, Position
from trader.persistence.models import AccountSnapshot, PositionSnapshot

__all__ = ["SnapshotRepository"]


[docs] class SnapshotRepository: """Reads and writes portfolio snapshots.""" def __init__(self, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory
[docs] def save_snapshot( self, account: Account, positions: Sequence[Position], captured_at: datetime | None = None, ) -> int: """Persist an account snapshot and its positions. Returns the new id.""" snapshot = AccountSnapshot( captured_at=captured_at or datetime.now(UTC), account_id=account.account_id, cash=account.cash, equity=account.equity, buying_power=account.buying_power, portfolio_value=account.portfolio_value, is_paper=account.is_paper, positions=[ PositionSnapshot( ticker=position.symbol, quantity=position.quantity, avg_entry_price=position.avg_entry_price, current_price=position.current_price, market_value=position.market_value, unrealized_pl=position.unrealized_pl, ) for position in positions ], ) with self._session_factory() as session: session.add(snapshot) session.commit() return snapshot.id
[docs] def latest_snapshot(self) -> AccountSnapshot | None: """The most recently captured snapshot, with positions loaded.""" statement = ( select(AccountSnapshot) .options(selectinload(AccountSnapshot.positions)) .order_by(AccountSnapshot.captured_at.desc(), AccountSnapshot.id.desc()) .limit(1) ) with self._session_factory() as session: return session.scalars(statement).first()
[docs] def snapshots_in_range( self, start: datetime | None = None, end: datetime | None = None ) -> list[AccountSnapshot]: """Snapshots captured within `[start, end]` (both bounds inclusive), ordered oldest to newest — feeds `trader.performance.benchmark. account_return` (issue #19), which compares the FIRST snapshot in the list against the LAST. Both bounds inclusive, unlike the news archive's half-open window: that window exists to stop a decision seeing a headline it is being scored for anticipating, which has no analogue here — there is no lookahead risk in including a snapshot captured exactly at `end`. `None` for either bound means "no floor"/"no ceiling" — omitting both returns every snapshot ever captured. Positions are deliberately NOT eager-loaded here (contrast `latest_snapshot`): every caller of this method reads only `captured_at`/`portfolio_value`, so `selectinload` would cost an extra query per snapshot for data nothing uses. """ statement = select(AccountSnapshot).order_by( AccountSnapshot.captured_at.asc(), AccountSnapshot.id.asc() ) if start is not None: statement = statement.where(AccountSnapshot.captured_at >= start) if end is not None: statement = statement.where(AccountSnapshot.captured_at <= end) with self._session_factory() as session: return list(session.scalars(statement))
[docs] def snapshots_since(self, start: datetime | None = None) -> Sequence[AccountSnapshot]: """Snapshots in chronological order, oldest first (§12.3 equity curve). `id` is the tiebreak for equal `captured_at` values, ascending to match `latest_snapshot`'s descending tiebreak — both resolve a tie in favour of the row inserted later. Positions are deliberately not eager-loaded here: callers of this method (the equity curve) only read scalar account-level columns, so pulling `position_snapshots` along for every row would cost a join no caller needs. """ statement = select(AccountSnapshot).order_by( AccountSnapshot.captured_at.asc(), AccountSnapshot.id.asc() ) if start is not None: statement = statement.where(AccountSnapshot.captured_at >= start) with self._session_factory() as session: return session.scalars(statement).all()