trader.pipeline.run_once module¶
One cycle of the trading pipeline — the service layer (requirements §4).
The CLI is a printer over this function; a dashboard or a future daemon calls the same one. Nothing here knows about Typer, which is the whole point: the Slice 1 carried debt warned that the account command inlined its orchestration and that the daemon would copy that pattern.
Every collaborator is injected. That is not ceremony — it is what lets the whole cycle be tested without a network, and what will let OllamaEvaluator replace BacktestEvaluator next slice without this file changing.
- class trader.pipeline.run_once.CycleReport(outcomes=<factory>, stops_placed=<factory>, stops_ratcheted=<factory>, entries_cancelled=<factory>, orders_settled=<factory>, snapshot_id=None, halted=False, unprotected=<factory>, double_protected=<factory>, outstanding_entries=<factory>)[source]¶
Bases:
objectEverything one cycle did, for the CLI to print or a caller to inspect.
- Parameters:
outcomes (list[SymbolOutcome])
stops_placed (list[str])
stops_ratcheted (list[str])
entries_cancelled (list[str])
orders_settled (list[str])
snapshot_id (int | None)
halted (bool)
unprotected (list[str])
double_protected (list[str])
outstanding_entries (list[str])
- outcomes: list[SymbolOutcome]¶
- stops_placed: list[str]¶
- stops_ratcheted: list[str]¶
Symbols whose fixed protective stop was raised this cycle. Populated from OrderExecutor.ratchet_protection, kept distinct from stops_placed because a ratchet replaces an existing stop rather than placing a new one, and the two must never be conflated in a report an operator reads.
- entries_cancelled: list[str]¶
Symbols whose own unfilled entry BUY was cancelled by this app because the close was near. Populated from OrderExecutor.cancel_stale_entries — see _cancel_entries_near_close’s docstring for why this exists: the entry became GTC to fix issue #3, and nothing else expires it.
- orders_settled: list[str]¶
Broker order ids whose trades row was settled this cycle because the order ended terminally without ever filling — cancelled, expired, rejected or replaced with a zero fill. Issue #1: nothing else ever settles those rows, and each one holds the fill-poll window open at its own submission time until it is. Populated by settle_terminal_orders.
- snapshot_id: int | None¶
- halted: bool¶
- unprotected: list[str]¶
Symbols holding a position with no open protective SELL on record, as of the very end of this cycle — after reconciliation has already had its turn. Requirements §8: “the app must never terminate quietly while it believes money is unprotected.” Populated by _check_end_of_cycle_protection; empty means “checked and found nothing”, never “not checked”.
- double_protected: list[str]¶
Symbols holding a position with more than one open protective SELL at the end of this cycle. The mirror of unprotected, and the worse of the two failures: two sell claims on the same shares can both execute, and on a long-only account the second one opens a short — an unbounded-loss position this app has no guardrails for. Checked from the same broker-wide open-orders read as outstanding_entries, so it costs nothing extra.
- outstanding_entries: list[str]¶
Symbols with an unfilled entry BUY still open at the broker at the end of this cycle. On 2026-08-07 a daemon cycle submitted two buys, logged a clean summary, and exited 0 while both sat unfilled — nothing checked, so nothing warned. This is what makes that loud instead, whatever process outlives (or doesn’t outlive) the order.
- class trader.pipeline.run_once.StrategyRun(strategy, mode, evaluator=None)[source]¶
Bases:
objectOne configured strategy, its mode, and the evaluator that gates it.
Bundled rather than passed as three parallel sequences, because three lists that must stay index-aligned is a bug waiting to happen.
- Parameters:
strategy (object)
mode (StrategyMode)
evaluator (object | None)
- strategy: object¶
- mode: StrategyMode¶
- evaluator: object | None¶
- property is_live: bool¶
Whether this strategy may actually place orders.
- class trader.pipeline.run_once.SymbolOutcome(symbol, strategy_id, action, reason, trade_id=None, order_id=None, rank=None, rejection_reason=None)[source]¶
Bases:
objectWhat one strategy decided about one symbol, and why.
- Parameters:
symbol (str)
strategy_id (str)
action (str)
reason (str)
trade_id (int | None)
order_id (str | None)
rank (int | None)
rejection_reason (str | None)
- symbol: str¶
- strategy_id: str¶
- action: str¶
- reason: str¶
- trade_id: int | None¶
- order_id: str | None¶
- rank: int | None¶
1-based place in the ranking that funded this cycle’s entries, or None for an outcome that never competed for capital. Surfaced so a working ranker and a silently failing one do not print identically.
- rejection_reason: str | None¶
The specific gate that overrode the strategy’s own signal, when one did — the same value written to decisions.rejection_reason (issue #21). None whenever nothing overrode the signal, mirroring veto in _record_decision; it is not derived from reason’s free text.
- trader.pipeline.run_once.reconcile_fills(*, broker, outcome_repository, trade_repository, previous_positions, now)[source]¶
Record fills, and open or close round trips as positions change.
Runs at the start of a cycle, so a fill that happened while nothing was watching is on record before anything reasons about it. That in-between state — an order placed but not yet filled — is precisely the one that no Plan B fixture represented and that produced a live duplicate order.
Driven by the filled orders themselves, replayed oldest-first, rather than by diffing position snapshots: a limit buy that fills and a trailing stop that exits the same position before the next cycle even runs is a symbol present in neither previous_positions nor current, so a snapshot diff would never see the round trip at all — every fill would still land on its Trade row, but no RoundTrip would ever exist. Current positions are still read, and used for two things only: sourcing a better price than an order without one carries, and a reconciliation check afterwards that logs a mismatch rather than silently leaving a round trip open (or absent) forever.
get_filled_orders() carries no dedupe of its own — the same closed order reappears on every later cycle until it ages out of the broker’s response window — so every order is checked for prior settlement before it is allowed to affect round-trip state at all. Without that, a replayed buy/sell pair would open and close a second round trip for exactly the same trade every idle cycle, silently inflating total_realized_pl() without bound. The check is trades.settled_at, stamped on every fill committed on a side; OutcomeRepository.trade_settled only knows the first fill on each side, which left a replayed second buy free to open a position-less ghost trip that then wedged the symbol forever.
A fill counts when shares actually moved (_shares_moved), not when the terminal status string happens to be “filled”: a DAY entry that fills 20 of 29 shares and then expires put real shares in the account.
A side can also span more than one order (Alpaca can split a single exit across several fills that arrive as separate orders): fills on the same side are accumulated and committed as one open_round_trip/ close_round_trip call with a quantity-weighted average price, rather than letting the first fill on a side decide the whole trip and silently discarding the rest.
Never raises: a broker hiccup here must not cost the whole cycle.
- Parameters:
previous_positions (dict[str, Position])
now (datetime)
- Return type:
list[str]
- trader.pipeline.run_once.run_once(*, broker, cache, strategies, guardrails, executor, trade_repository, decision_repository, snapshot_repository, outcome_repository, symbols, pipeline_config, now=None, previous_positions=None, fixed_symbols=None, max_discovered_positions=0, entry_blocked=None, ranker=None, simulation=None, benchmark_symbol=None)[source]¶
Walk the watchlist once with every configured strategy.
Every strategy sees every symbol, so the report grows from one entry per symbol to one per (symbol, strategy) pair. Only the strategy configured as live may reach the executor; every other one is shadowed — evaluated and recorded, but never allowed to spend money.
Three phases, in this order:
Decide. Every (symbol, strategy) pair is evaluated and gets a decisions row. SELLs execute here, inside this phase, because they are time-sensitive and do not compete for capital — deferring one behind a ranking call would put a signal-driven exit behind a model call. So do holds, halts, entry screens, evaluator rejections and errors. An approved BUY submits nothing; it becomes a _DeferredEntry instead.
Rank. The deferred entries are ordered by ranker, or by the deterministic confidence fallback when there is none. Never raises: a ranking failure degrades the order, it does not cost the cycle. pipeline_config.confidence_ordering_enabled (issue #86, off by default) forces the confidence-descending order regardless of ranker, to isolate that mechanism from LLM ranking when simulating.
Allocate. The ranked candidates are funded top-down until the headroom runs out, with ExposureCounter and the discovered cap still advancing inside the loop. Whichever candidate sits earliest in the watchlist no longer wins the capital by virtue of coming first.
- Parameters:
strategies (Sequence[StrategyRun])
symbols (Sequence[str])
pipeline_config (PipelineConfig)
now (datetime | None)
previous_positions (dict[str, Position] | None)
fixed_symbols (Collection[str] | None)
max_discovered_positions (int)
entry_blocked (Mapping[str, FilterOutcome] | None)
ranker (Ranker | None)
simulation (SimulationRunner | None)
benchmark_symbol (str | None)
- Return type:
- trader.pipeline.run_once.settle_terminal_orders(*, broker, trade_repository, now)[source]¶
Settle rows whose broker order ended without ever filling (issue #1).
reconcile_fills only ever learns about orders that filled: it stamps settled_at when a fill is folded into round-trip state, and nothing else does. An order that expires, is cancelled, or is rejected therefore stays unsettled forever and holds _fill_poll_since open at its own submission time. Measured on the live database 2026-08-13: 12 unsettled rows out of 44, the oldest a pending_new SPY buy from 2026-07-31 17:52:35 — 13 days stale, up from 4 such rows nine days earlier, so it grows at roughly one a day. Alpaca answers get_filled_orders with at most 200 orders, and when that cap is reached the oldest fills drop out silently. A stop-driven exit that goes unreconciled is this app’s dominant exit path going unattributed.
The naive fix is dangerous, and this is not it. Absence from get_open_orders does not mean an order never filled — it usually means it did. Settling on absence would stamp settled_at on a filled order, reconcile_fills would then skip it forever, and those shares would never reach the ledger at all: the exact failure the window exists to prevent, and the one measured in _cancel_entries_near_close’s A/B on a 20-of-29 partial. So this settles on positive evidence of a terminal, non-filling outcome and on nothing else. Two conditions, both required:
the broker reports a status in _TERMINAL_NON_FILLING_STATUSES — asked per order id, which is also the only query Alpaca’s 200-order cap cannot truncate; and
it reports filled_qty and that quantity is zero.
Condition 2 is filled_qty == 0, not not _shares_moved(order), and the difference is deliberate. _shares_moved answers “did shares move?” and treats an absent filled_qty on a non-filled order as “no” — which is the right reading when deciding whether to record a fill, because guessing “yes” would invent one. Here the consequence is inverted: a wrong “no” settles the row and hides real shares permanently. So an absent filled_qty is unknown, not zero, and unknown does not settle. A partially filled cancelled order is likewise left alone; reconcile_fills picks its fill up and mark_settled stamps the row once the round trip commits, which is the only path that may ever settle a row that moved shares.
The open-orders read is a cost filter, never evidence. An id in that list is demonstrably still live, so there is nothing to settle and no reason to spend a lookup on it — and in the steady state that is most of the population, since every held position carries an open protective SELL whose row stays legitimately unsettled until it fires. Skipping cannot cause a wrong settle, because the settle decision is made from the per-id answer alone.
Never raises, the same discipline as every other guarded phase in run_once: a failed read yields fewer settles, not a lost cycle. Each id is isolated too — one order the broker will not answer about must not stop the rest, which is the same per-item isolation discovery uses.
Returns the broker order ids settled, for the cycle report.
- Parameters:
now (datetime)
- Return type:
list[str]