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:
objectWrites 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]
- 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:
- class trader.persistence.outcomes.OutcomeSummary(total_realized_pl, total_cost_basis, win_count, trip_count)[source]¶
Bases:
objectAggregates 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¶