trader.persistence.outcomes module

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.

class trader.persistence.outcomes.OutcomeRepository(session_factory)[source]

Bases: object

Writes fills onto trades, and maintains round trips.

Parameters:

session_factory (sessionmaker[Session])

apply_fill(order, filled_qty, filled_avg_price, filled_at)[source]

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.

Parameters:
  • order (SubmittedOrder)

  • filled_qty (Decimal)

  • filled_avg_price (Decimal | None)

  • filled_at (datetime)

Return type:

bool

open_round_trip(*, symbol, strategy_id, entry_trade_id, decision_id, quantity, entry_price, opened_at)[source]

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.

Parameters:
  • symbol (str)

  • strategy_id (str | None)

  • entry_trade_id (int | None)

  • decision_id (int | None)

  • quantity (Decimal)

  • entry_price (Decimal)

  • opened_at (datetime)

Return type:

int

close_round_trip(*, symbol, exit_trade_id, quantity, exit_price, closed_at)[source]

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.

Parameters:
  • symbol (str)

  • exit_trade_id (int | None)

  • quantity (Decimal)

  • exit_price (Decimal)

  • closed_at (datetime)

Return type:

int | None

open_round_trip_for(symbol)[source]

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).

Parameters:

symbol (str)

Return type:

RoundTrip | None

trade_settled(trade_id)[source]

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.

Parameters:

trade_id (int)

Return type:

bool

recent(limit=20, *, start=None, end=None)[source]

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.

Parameters:
  • limit (int)

  • start (datetime | None)

  • end (datetime | None)

Return type:

list[RoundTrip]

total_realized_pl()[source]

Sum of realized P/L across closed round trips.

Return type:

Decimal

outcome_summary(*, start=None, end=None)[source]

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.

Parameters:
  • start (datetime | None)

  • end (datetime | None)

Return type:

OutcomeSummary

class trader.persistence.outcomes.OutcomeSummary(total_realized_pl, total_cost_basis, win_count, trip_count)[source]

Bases: object

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.

Parameters:
  • total_realized_pl (Decimal)

  • total_cost_basis (Decimal)

  • win_count (int)

  • trip_count (int)

total_realized_pl: Decimal
total_cost_basis: Decimal
win_count: int
trip_count: int