Source code for trader.execution.executor

"""Order execution: entry, protection, and exit.

Two orderings in here are load-bearing, and neither is arbitrary.

**Protection is submitted atomically with the entry, and reconciliation is only
a repair path.** `enter` sends one Alpaca OTO request — a GTC limit buy with a
stop leg attached — so the broker holds protection from the instant of fill and
nothing depends on this process running again.

This paragraph used to read "Protection is reconciled, not sequenced", and that
wording is superseded and must not be restored. It was written against a real
failure — "buy, poll for the fill, then place the stop" leaves a position naked
forever if the process dies in between — but it *produced that same outcome by a
different route*: a `pending_new` buy is not a position, so a cycle that ended
before the fill reconciled nothing and protected nothing. Lost on 2026-08-07,
two positions, ~$10,093 unprotected, exit code 0.

So `reconcile_protection` stays, but only as a **repair** for a hand-placed
position or a leg cancelled outside the app — never as the mechanism that makes
an entry safe. It is still idempotent, which is why running `run-once` twice is
harmless, and it deliberately refuses to protect a symbol with an open entry BUY
(see its docstring: the attached leg is invisible while `HELD`, and a second sell
claim on a long-only account can open a short). `ratchet_protection` runs
*before* it, because a cancel-and-resubmit replace can leave a hole that only a
later repair closes.

**Exit cancels before it sells.** Selling while the protective stop is still
live risks the stop firing against a position that no longer exists, and on a
long-only account that opens a *short* — an unbounded-loss position this app
has no guardrails for. Cancelling first makes that impossible. The opposite
failure (cancel succeeds, sell fails) leaves the position briefly unprotected
and is repaired by the next reconciliation, which is a strictly better failure
to have.
"""

import logging
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal

from trader.brokers.base import OrderPlacingBroker
from trader.config.schema import PipelineConfig
from trader.domain import OrderSide, Position, ProtectedEntry, SubmittedOrder
from trader.errors import TraderError

__all__ = ["OrderExecutor", "RatchetedStop"]


[docs] @dataclass(frozen=True, slots=True) class RatchetedStop: """A protective stop that was raised, and the order it replaced. Both ids matter to the caller, for different reasons: `order` (the new broker order) has to be recorded into `trades` so its eventual fill is attributable, exactly like a freshly placed protective stop — and `replaced_order_id` has to be looked up and have its own `trades` row marked `settled_at`, or every ratchet leaves behind a row that never fills and never settles, pinning the fill-poll window (issue #1) at its submission time. Carrying only the new order and letting the old id be silently forgotten is what caused that exact regression the first time this type didn't exist. `settle_terminal_orders` now backstops that stamp — it settles a `replaced` order with a zero fill like any other terminal outcome — but it is a backstop, not a replacement: settling here is free, while the sweep costs a broker lookup and lands a cycle later. Measured 2026-08-13, two live rows (NET and ANNX) were `replaced` at the broker and still unsettled here, which is why the backstop exists at all. Do not delete this stamp because the sweep would eventually catch it. """ replaced_order_id: str order: SubmittedOrder
_BPS = Decimal(10_000) _CENTS = Decimal("0.01") _PCT = Decimal(100) _logger = logging.getLogger("trader.execution")
[docs] class OrderExecutor: """Places entries and protective stops, and closes positions. Every order submission is meant to go through here — never a broker call made directly from a command or a strategy. Three things live only in this class: the floor clamp on the trailing/fixed stop (see `effective_trail_percent`), the dry-run switch that turns a submission into a log line instead of a broker call, and the cancel-before-sell ordering that keeps a protective stop from firing against a position that no longer exists. A direct `broker.submit_*` call bypasses all three silently — dry-run would place a real order, an exit could sell before its stop is cancelled, and a stop could be set below the configured floor — with no exception to mark the gap. """ def __init__( self, broker: OrderPlacingBroker, config: PipelineConfig, dry_run: bool = False, ) -> None: self._broker = broker self._config = config self._dry_run = dry_run @property def dry_run(self) -> bool: """Whether this executor logs intended orders instead of sending them.""" return self._dry_run
[docs] def limit_price_for(self, last_close: Decimal) -> Decimal: """The entry limit: last close plus the configured buffer, in cents. Quantized because an unrounded product such as 187.798325 is a sub-penny price the broker rejects outright. """ raw = last_close * (Decimal(1) + Decimal(self._config.limit_buffer_bps) / _BPS) return raw.quantize(_CENTS, rounding=ROUND_HALF_UP)
[docs] def effective_trail_percent(self) -> Decimal: """The trail, clamped so the initial stop cannot start below the floor. A trailing stop only ratchets upward, so a stop that starts at or above the floor guarantees the floor for the life of the position — with the broker maintaining the high-water mark, and no process of ours required to stay alive for protection to work. """ return min(self._config.trail_percent, self._config.floor_pct)
[docs] def stop_price_for(self, limit_price: Decimal) -> Decimal: """The initial protective stop for an entry at `limit_price`. Quantized to cents for the same reason as `limit_price_for`: a sub-penny stop price is rejected by the broker outright. """ raw = limit_price * (Decimal(1) - self.effective_trail_percent() / _PCT) return raw.quantize(_CENTS, rounding=ROUND_HALF_UP)
[docs] def enter( self, symbol: str, quantity: int, last_close: Decimal ) -> ProtectedEntry | None: """Submit a limit buy with its protective stop attached atomically. One OTO request, not a buy followed by a separate stop: the broker holds protection from the instant of fill, and nothing depends on this process running again to place a second order. See `submit_limit_buy_with_stop` on `OrderPlacingBroker`. Returns `None` in dry-run mode. Returns the leg alongside the entry (`ProtectedEntry`) because the caller has to record **both** in `trades` — see that type's docstring for what happened while only the parent came back. """ limit_price = self.limit_price_for(last_close) stop_price = self.stop_price_for(limit_price) if self._dry_run: _logger.info( "DRY RUN would buy %s x%d at limit %s with stop %s", symbol, quantity, limit_price, stop_price, ) return None placed = self._broker.submit_limit_buy_with_stop( symbol, quantity, limit_price, stop_price ) _logger.info( "submitted limit buy %s x%d at limit %s with stop %s (order %s, " "protective leg %s)", symbol, quantity, limit_price, stop_price, placed.entry.order_id, placed.protective_leg.order_id if placed.protective_leg is not None else "MISSING", ) return placed
[docs] def reconcile_protection(self, positions: Sequence[Position]) -> list[SubmittedOrder]: """Place a trailing stop on every position that lacks a visible one. Two positions are deliberately left alone rather than protected: - One that already has an open protective SELL. Nothing to do. - One with an **open, unfilled entry BUY** on the same symbol. This is the refusal that matters, and it is a refusal to place a *second* claim on shares that may already be claimed. An entry is an OTO request whose protective leg the broker holds attached to the parent; measured 2026-08-10, that leg is `HELD` rather than `open` while the parent is unfilled, so it does not appear in `get_open_orders` and `_protective_order_for` cannot see it. Whether Alpaca keeps the leg `HELD` through a *partial* parent fill is unverified and needs a live probe — the operator's call — so this guard is written to be correct **either way**: if the leg is already active, a stop placed here would be a second sell claim on the same shares, and two sell claims on a long-only account can open a short; if the leg is not active yet, the entry has more to fill and the very next cycle protects whatever is then held, with the leg (or this repair) covering it. Refusing costs at most one cycle of protection on a partially filled entry; placing costs an uncovered short. The position is **not** silently dropped: it carries no visible protective SELL, so `_check_end_of_cycle_protection` reports it in the cycle's `unprotected` list and warns, which is what makes the refusal visible to an operator instead of merely quiet. """ placed: list[SubmittedOrder] = [] trail = self.effective_trail_percent() for position in positions: if self._protective_order_for(position.symbol) is not None: continue pending = self.pending_entry_for(position.symbol) if pending is not None: _logger.warning( "not protecting %s: entry order %s is still open on this " "symbol and may already carry an attached protective leg " "the broker holds invisibly; placing a second sell claim " "on the same shares could open a short. Reported as " "unprotected for this cycle instead", position.symbol, pending.order_id, ) continue quantity = int(position.quantity) if quantity < 1: _logger.warning( "skipping protection for %s: quantity %s is below one whole share", position.symbol, position.quantity, ) continue if self._dry_run: _logger.info( "DRY RUN would protect %s x%d with a %s%% trailing stop", position.symbol, quantity, trail, ) continue order = self._broker.submit_trailing_stop_sell( position.symbol, quantity, trail ) _logger.info( "protected %s x%d with a %s%% trailing stop (order %s)", position.symbol, quantity, trail, order.order_id, ) placed.append(order) return placed
[docs] def ratchet_protection(self, positions: Sequence[Position]) -> list[RatchetedStop]: """Raise each position's fixed stop toward the current price, never down. Alpaca cannot attach a *trailing* stop to an OTO leg (`StopLossRequest` accepts only `stop_price`/`limit_price`), so a new entry's protection is a FIXED stop (see `enter`). Calling this every cycle makes that fixed stop behave like a trailing one while the app is running — and, because each ratchet only ever raises the trigger, the position freezes at its *last replaced* level rather than going naked if the app stops running. What is actually known about `replace_stop_price`, and no more: this method issues exactly **one** request per ratchet and never itself cancels an order and then places another, so no gap is introduced by code on this side. Whether Alpaca's replace is *observably* gapless on the broker's side is unverified — measured 2026-08-10 (see `docs/superpowers/sdd/2026-08-10-atomic-protection/task-3-report.md`), `replace_order_by_id` is a cancel-and-resubmit under the hood that returns a **new** order id, which is evidence against gaplessness, not for it. Confirming either way needs a live protective order to be replaced and watched, which is the operator's call, not a claim to assert from the docs. That new order id is also why this method returns a `RatchetedStop` rather than a bare `SubmittedOrder`: the caller needs the replaced id too, to settle its `trades` row. Two kinds of position are deliberately left alone: - One protected by a `trailing_stop`. The broker already ratchets that order itself, and replacing its `stop_price` is not a meaningful operation on that order type — this app has no business touching the ten pre-existing live positions that carry one. - One with no protective order at all. That is `reconcile_protection`'s job; doing both here would race it, since this method has no way to know whether an absent stop is "not yet placed this cycle" or "deliberately absent". A candidate that has not risen enough to beat the existing stop is silently skipped — that is the ratchet working as designed, not a failure. One symbol's `replace_stop_price` raising `TraderError` is logged at `error` naming the symbol and does not stop the rest. """ updated: list[RatchetedStop] = [] trail = self.effective_trail_percent() for position in positions: order = self._protective_order_for(position.symbol) if order is None or order.order_type == "trailing_stop": continue if order.stop_price is None: _logger.error( "cannot ratchet the stop for %s: protective order %s " "reports no stop price", position.symbol, order.order_id, ) continue candidate = (position.current_price * (Decimal(1) - trail / _PCT)).quantize( _CENTS, rounding=ROUND_HALF_UP ) # The whole point of a ratchet: a stop must never move down, so a # position whose price has fallen (or not risen enough to beat # the existing trigger) is left exactly where it is. if candidate <= order.stop_price: continue if self._dry_run: _logger.info( "DRY RUN would raise the stop on %s from %s to %s", position.symbol, order.stop_price, candidate, ) continue try: new_order = self._broker.replace_stop_price(order.order_id, candidate) except TraderError as exc: _logger.error( "could not ratchet the stop for %s: %s", position.symbol, exc ) continue _logger.info( "raised the stop on %s from %s to %s (order %s)", position.symbol, order.stop_price, candidate, new_order.order_id, ) updated.append( RatchetedStop(replaced_order_id=order.order_id, order=new_order) ) return updated
[docs] def exit_position(self, position: Position) -> SubmittedOrder | None: """Close a long: cancel the protective stop first, then sell.""" quantity = int(position.quantity) if quantity < 1: _logger.warning( "not exiting %s: quantity %s is below one whole share", position.symbol, position.quantity, ) return None if self._dry_run: _logger.info("DRY RUN would close %s x%d", position.symbol, quantity) return None stop = self._protective_order_for(position.symbol) if stop is not None: # Order matters. See the module docstring: the reverse can open a # short on a long-only account. self._broker.cancel_order(stop.order_id) _logger.info( "cancelled protective stop %s on %s before selling", stop.order_id, position.symbol, ) order = self._broker.submit_market_sell(position.symbol, quantity) _logger.info( "submitted market sell %s x%d (order %s)", position.symbol, quantity, order.order_id, ) return order
[docs] def cancel_stale_entries(self) -> list[str]: """Cancel every one of this app's own unfilled entry BUYs, broker-wide. The entry is now a GTC OTO request (see `enter`): the protective leg inherits the parent's time-in-force, so a DAY parent would give it a DAY stop that expires at the close and leaves the position naked overnight — the reason the entry had to become GTC in the first place. But nothing then expires an unfilled *entry* on its own, so without this it would sit open indefinitely, holding exposure against `max_total_exposure_pct` and blocking a fresh attempt via `pending_entry_for` forever. This app has to own that expiry itself; this is where it does. Broker-wide (`get_open_orders()`, no symbol filter), not scoped to a caller-supplied symbol list — an earlier version took `symbols` and a review caught the hole that opened: an unfilled BUY creates no position, so a **discovered** symbol whose entry never fills and whose news story then drops out of the scan appears in neither `discovered` nor `held`, and `fixed_tickers` never contains a pure-discovery symbol either. Scoping this to `run_once`'s evaluated `symbols` would silently exempt exactly that population from ever being cancelled — reintroducing, for those symbols specifically, the indefinitely-open GTC exposure this whole method exists to close. One broker-wide read is also the same shape as the exposure seed in `run_once`, which already reads open orders broker-wide and unscoped: a single round trip, not one per symbol. The caller decides *when* to call this — near the close, using `MarketClock.next_close`; this method has no clock of its own. The BUY-only filter is inline here, not delegated to `pending_entry_for` (which answers "is there one for *this* symbol", a different question from "which of *all* open orders are BUYs"): cancelling a protective SELL here would leave the position it guards naked, which is precisely the failure this whole change exists to prevent. A **filled** entry is not an open order at all, so it never appears in `get_open_orders()` and this leaves it alone. One symbol's `cancel_order` raising `TraderError` is logged at `error` naming the symbol and does not stop the rest, the same discipline as `ratchet_protection`. """ cancelled: list[str] = [] for order in self._broker.get_open_orders(): if order.side is not OrderSide.BUY: continue if self._dry_run: _logger.info( "DRY RUN would cancel stale entry %s x%s at limit %s " "(order %s), unfilled with the close approaching", order.symbol, order.quantity, order.limit_price, order.order_id, ) continue try: self._broker.cancel_order(order.order_id) except TraderError as exc: _logger.error( "could not cancel the stale entry for %s: %s", order.symbol, exc ) continue _logger.info( "cancelled stale entry %s x%s at limit %s (order %s), unfilled " "with the close approaching", order.symbol, order.quantity, order.limit_price, order.order_id, ) cancelled.append(order.symbol) return cancelled
[docs] def pending_entry_for(self, symbol: str) -> SubmittedOrder | None: """An open, unfilled BUY order on `symbol`, if there is one. A position is not the whole picture. An unfilled limit buy creates no position, so a cycle that decides whether to enter by looking only at positions will buy again on the next run — leaving two open entry orders and double the intended exposure if both fill. This was found by running `run-once` twice against the paper account, not by a unit test. """ for order in self._broker.get_open_orders(symbol): if order.side is OrderSide.BUY: return order return None
def _protective_order_for(self, symbol: str) -> SubmittedOrder | None: """The open protective SELL order on `symbol`, if there is one. Filters on order *side*, not type, and treats any open SELL as protection — trailing stop, fixed stop, or otherwise. An unfilled limit BUY is an open order on the symbol but protects nothing, so BUY orders are excluded; every open SELL is a claim on the same shares a second protective order would also claim, and this app must never hold two. Widened deliberately beyond the three order types this app actually places (`trailing_stop`, the ten pre-existing live positions; `stop`, new atomic entries). Measured 2026-08-10: an OTO's protective leg is `HELD`, not `open`, until its parent fills, so it does not appear in `get_open_orders` at all — safe only because that window coincides exactly with "no position exists yet". Treating any open SELL as protection is cheap insurance against a future case where a protective leg is invisible to this call while a position already exists. """ for order in self._broker.get_open_orders(symbol): if order.side is OrderSide.SELL: return order return None