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