Source code for trader.persistence.outcomes

"""Order fills and round-trip P/L — the substrate for judging decisions later.

A round trip is defined by the **position**, not by a pair of orders: opened
when the broker's position in a symbol goes from zero to non-zero, closed when
it returns to zero. That is the only rule that survives partial fills, which
Alpaca does produce.

"Open" is an explicit `is_open` boolean rather than a null exit price, because
zero is a legitimate exit price and "unset" must stay distinguishable from
"zero". `open_round_trip` seeds `exit_price` and `realized_pl` to zero and sets
the flag; `close_round_trip` overwrites all three and clears it.
"""

import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal

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

from trader.domain import SubmittedOrder
from trader.persistence.models import RoundTrip, Trade
from trader.reporting.returns import cost_basis

__all__ = ["OutcomeRepository", "OutcomeSummary"]

_logger = logging.getLogger("trader.outcomes")


[docs] @dataclass(frozen=True, slots=True) class OutcomeSummary: """Aggregates across *every* closed round trip, not just a displayed page. `trader outcomes --limit N` shows only the N most recent closed trips, but a totals line under that table has to answer for all of them — a "total return %" computed against only the visible page would silently change value every time `--limit` did. `OutcomeRepository.recent()` is itself capped (at 10,000) for the same reason `total_realized_pl()` already was; this type exists so the CLI computes the total P/L, the total cost basis, and the win count from one read of that list instead of three. """ total_realized_pl: Decimal total_cost_basis: Decimal win_count: int trip_count: int
[docs] class OutcomeRepository: """Writes fills onto trades, and maintains round trips.""" def __init__(self, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory
[docs] def apply_fill( self, order: SubmittedOrder, filled_qty: Decimal, filled_avg_price: Decimal | None, filled_at: datetime, ) -> bool: """Record a fill against the trade with this broker order id. Returns `False` when no such trade exists — which is normal, not an error: the broker reports every order on the account, including ones placed before this app existed or by hand in the dashboard. `filled_avg_price` accepts `None`: the column is nullable, and a caller that cannot determine a real fill price should record that gap honestly rather than pass a fabricated `0`, which would read back as a real (if worthless) fill. """ statement = select(Trade).where(Trade.alpaca_order_id == order.order_id) with self._session_factory() as session: trade = session.scalars(statement).first() if trade is None: return False trade.filled_qty = filled_qty trade.filled_avg_price = filled_avg_price trade.filled_at = filled_at trade.status = order.status trade.updated_at = datetime.now(UTC) session.commit() return True
[docs] def open_round_trip( self, *, symbol: str, strategy_id: str | None, entry_trade_id: int | None, decision_id: int | None, quantity: Decimal, entry_price: Decimal, opened_at: datetime, ) -> int: """Record that a position opened. Returns the new row id. `exit_price` and `realized_pl` are seeded to zero and `closed_at` to `opened_at`; `close_round_trip` overwrites all three. The row is identified as still open by the explicit `is_open` flag, not by these placeholder values. """ trip = RoundTrip( symbol=symbol, strategy_id=strategy_id, entry_trade_id=entry_trade_id, exit_trade_id=None, decision_id=decision_id, quantity=quantity, entry_price=entry_price, exit_price=Decimal(0), realized_pl=Decimal(0), opened_at=opened_at, closed_at=opened_at, is_open=True, ) with self._session_factory() as session: session.add(trip) session.commit() return trip.id
[docs] def close_round_trip( self, *, symbol: str, exit_trade_id: int | None, quantity: Decimal, exit_price: Decimal, closed_at: datetime, ) -> int | None: """Close the open round trip for `symbol`. Returns its id, or `None`. `None` means there was no open trip — which happens when a position is closed that this app never opened, and is worth logging rather than inventing a trip for. """ with self._session_factory() as session: trip = session.scalars( select(RoundTrip) .where(RoundTrip.symbol == symbol, RoundTrip.is_open.is_(True)) # Tiebreaker mirrors `recent()`: `opened_at` alone is not # unique (two trips can open in the same second), so without # `id.desc()` this query and `open_round_trip_for` could pick # different rows out of the same open set. .order_by(RoundTrip.opened_at.desc(), RoundTrip.id.desc()) ).first() if trip is None: _logger.info( "%s closed with no open round trip on record; not inventing one", symbol, ) return None trip.exit_trade_id = exit_trade_id trip.quantity = quantity trip.exit_price = exit_price trip.realized_pl = (exit_price - trip.entry_price) * quantity trip.closed_at = closed_at trip.is_open = False session.commit() return trip.id
[docs] def open_round_trip_for(self, symbol: str) -> RoundTrip | None: """The currently open round trip for `symbol`, if any. A partial unique index on `(symbol, is_open=True)` makes it impossible for two rows to match here in a correctly running system, but the `order_by` keeps this query and `close_round_trip`'s in agreement even if that invariant is ever violated (e.g. read against a database from before the index existed). """ statement = ( select(RoundTrip) .where(RoundTrip.symbol == symbol, RoundTrip.is_open.is_(True)) .order_by(RoundTrip.opened_at.desc(), RoundTrip.id.desc()) ) with self._session_factory() as session: return session.scalars(statement).first()
[docs] def trade_settled(self, trade_id: int) -> bool: """Whether this trade id has already contributed to some round trip. `AlpacaBroker.get_filled_orders()` has no dedupe of its own: the same closed order reappears in every later poll until it ages out of the broker's response window. Without this check, replaying an already-applied fill would open (or close) a *second* round trip for the same trade, silently inflating `total_realized_pl()` without bound on every no-op cycle. **Not exhaustive, and no longer the primary check.** `entry_trade_id`/`exit_trade_id` only ever reference the *first* trade on each side of a round trip, so a side built from several fills leaves the rest invisible here. That was originally reasoned to be safe — a replayed non-first fill "hits the ordinary 'no round trip open' guard instead" — and it is not: that holds for a replayed sell, which is logged and dropped, but a replayed *buy* with no trip open was appended to the pending entry and opened a brand-new, position-less round trip, after which every genuine trip on that symbol was discarded as pyramiding. `trades.settled_at`, stamped on every contributing fill when a side commits, is the exhaustive check `reconcile_fills` uses now; this one remains only so rows written before that column existed are still recognised. """ statement = ( select(RoundTrip.id) .where( or_( RoundTrip.entry_trade_id == trade_id, RoundTrip.exit_trade_id == trade_id, ) ) .limit(1) ) with self._session_factory() as session: return session.scalars(statement).first() is not None
[docs] def recent( self, limit: int = 20, *, start: datetime | None = None, end: datetime | None = None, ) -> list[RoundTrip]: """Closed round trips, newest first, optionally windowed by close date (issue #57). `start`/`end` filter on `closed_at`, both bounds inclusive — same convention as `SnapshotRepository.snapshots_in_range`: `None` for either means "no floor"/"no ceiling", so every pre-issue-#57 caller (which passes neither) is completely unaffected. Filtering happens here, in SQL, rather than in the CLI after the fact, so `--limit` still caps the WINDOWED result — the most recent N trips inside the window, not the most recent N ever with the window applied afterward. """ statement = ( select(RoundTrip) .where(RoundTrip.is_open.is_(False)) .order_by(RoundTrip.closed_at.desc(), RoundTrip.id.desc()) .limit(limit) ) if start is not None: statement = statement.where(RoundTrip.closed_at >= start) if end is not None: statement = statement.where(RoundTrip.closed_at <= end) with self._session_factory() as session: return list(session.scalars(statement))
[docs] def total_realized_pl(self) -> Decimal: """Sum of realized P/L across closed round trips.""" return sum((t.realized_pl for t in self.recent(limit=10_000)), start=Decimal(0))
[docs] def outcome_summary( self, *, start: datetime | None = None, end: datetime | None = None ) -> OutcomeSummary: """Realized P/L, cost basis, and win count across every closed trip, optionally windowed by close date (issue #57) the same way `recent` is — pass the SAME `start`/`end` a windowed `recent()` call used, or the totals line will not match the table above it. One read of `recent(limit=10_000, start=start, end=end)`, not three separate ones — see `OutcomeSummary`. A "winner" is `realized_pl > 0`; a round trip that closed at exactly break-even counts toward neither the win count nor an implied loss count, `trip_count - win_count` includes it. """ trips = self.recent(limit=10_000, start=start, end=end) total_pl = sum((t.realized_pl for t in trips), start=Decimal(0)) total_basis = sum( (cost_basis(t.entry_price, t.quantity) for t in trips), start=Decimal(0) ) wins = sum(1 for t in trips if t.realized_pl > 0) return OutcomeSummary( total_realized_pl=total_pl, total_cost_basis=total_basis, win_count=wins, trip_count=len(trips), )