trader.execution.executor module¶
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.
- class trader.execution.executor.OrderExecutor(broker, config, dry_run=False)[source]¶
Bases:
objectPlaces 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.
- Parameters:
broker (OrderPlacingBroker)
config (PipelineConfig)
dry_run (bool)
- property dry_run: bool¶
Whether this executor logs intended orders instead of sending them.
- limit_price_for(last_close)[source]¶
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.
- Parameters:
last_close (Decimal)
- Return type:
Decimal
- effective_trail_percent()[source]¶
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 type:
Decimal
- stop_price_for(limit_price)[source]¶
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.
- Parameters:
limit_price (Decimal)
- Return type:
Decimal
- enter(symbol, quantity, last_close)[source]¶
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.
- Parameters:
symbol (str)
quantity (int)
last_close (Decimal)
- Return type:
ProtectedEntry | None
- reconcile_protection(positions)[source]¶
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.
- Parameters:
positions (Sequence[Position])
- Return type:
list[SubmittedOrder]
- ratchet_protection(positions)[source]¶
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.
- Parameters:
positions (Sequence[Position])
- Return type:
list[RatchetedStop]
- exit_position(position)[source]¶
Close a long: cancel the protective stop first, then sell.
- Parameters:
position (Position)
- Return type:
SubmittedOrder | None
- cancel_stale_entries()[source]¶
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.
- Return type:
list[str]
- pending_entry_for(symbol)[source]¶
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.
- Parameters:
symbol (str)
- Return type:
SubmittedOrder | None
- class trader.execution.executor.RatchetedStop(replaced_order_id, order)[source]¶
Bases:
objectA 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.
- Parameters:
replaced_order_id (str)
order (SubmittedOrder)
- replaced_order_id: str¶
- order: SubmittedOrder¶