"""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),
)