Read the code, not the docs. Here's how an automatic trade actually comes about.

Two independent engines, one order path

 ┌─ A. LLM propose loop ──────────┐   ┌─ B. entry engine ─────────────┐
 │ scheduler._trade_propose_loop  │   │ scheduler._entry_monitor_loop │
 │ every 20 min, RTH only         │   │ every 60 s, RTH only          │
 │ Opus swing_trading_analyst     │   │ deterministic ladder + 5m bars│
 └──────────────┬─────────────────┘   └──────────────┬────────────────┘
                └──────────► proposition dict ◄──────┘
                                   │
                    routes.trades._auto_submit_if_enabled   (money gates)
                                   │
                    submit_proposition → _submit_locked     (execution gates)
                                   │
                         Alpaca bracket order

Both engines produce the same dict shape and go through the same two gate stacks. Nothing else places an order automatically; loading the trades page starts nothing (routes/trades.py:1056 comment, propose_once).


1. Input data

Market-level (shared, refreshed on a clock)

Input Producer Cadence
Global dashboard snapshot (regime, sectors.green/red, breadth rsp_spy, vol level+direction) scheduler._refresh_loop → dashboard_global.build_and_cache_global_snapshot 900 s (3600 s weekends / 01–07 CET)
Interpret sections + today_brief (LLM prose) same loop 3600 s
snapshots/dashboard.md → injected as ## Market Snapshot into the analyst _load_market_snapshot read per run
Exchange calendar (real open/close, half-days) market_calendar._load ← Alpaca get_calendar, ±30 days in-process cache, refetched outside span

Universe & asset-level

Input Producer Cadence
static/symbols/nasdaq.json → _swing_liquid_universe(): price > $20, $10M/day turnover, mcap 2–200 bn, per sector _nasdaq_screener_loop re-download when > 10 days old
_compute_signals(sym) — 220 Polygon daily bars → EMA stack/slope, ADX, 8/21 cross, ROC, ATR, plus brk / pull / hs / poc detector rows called by write_top10_screeners over the whole universe cached 24 h per symbol
snapshots/screener_top10_{long,short,opds,hs,poc,entry}.json write_top10_screeners at the tail of each global snapshot written every 900 s, but content only really moves daily because the signals under it are 24 h-cached
Per-symbol 14-section snapshot (company, price/quote+spread, volume, fundamentals+DCF, technical, levels, sector RS, analyst, news, calendar, short/borrow, whales/insiders, market env, intraday patterns) routes.dashboard._make_fetchers(sym) → run_all() on demand via get_symbol_snapshot, reused if < 1 h old
5-minute intraday bars usage_admin._intraday(..., fresh=True) per candidate per 60 s cycle (engine B only)
Live quotes Alpaca get_snapshots(), batched for all pre-entry candidates per cycle
Earnings dates watchlist.earnings_soon() per propose run (cached call)

News

Input Producer Use
Alpaca/Benzinga news WebSocket → .data/news.db, 24 h retention news_stream daemon engine B's suspension gate (1 h window, 8 items scanned) and the snapshot's News section
Multi-source aggregate (WS store + Polygon + FMP + Benzinga REST + NewsAPI.ai) dashboard._aggregate_symbol_news News section of the per-symbol snapshot the analyst reads
News-tilt in screener scoring (NEWS_TILT 3.0 pts, saturating at 5 headlines/24 h) screener_top10 ranking only

Note the asymmetry: engine A reads news only as prose inside a snapshot; engine B reads it as a hard state transition (news_suspend) using only the item's shape — ≤ 3 tagged symbols and not matching the _NEWS_NOISE roundup regex. Any such item within 1 h suspends the candidate until the next session reset, regardless of what it says.


2. Engine A — the LLM path (20 min)

scheduler._trade_propose_loop wakes on :00/:20/:40 UTC, re-checks elapsed ≥ _PROPOSITION_TTL (1200 s, persisted in trade_db.meta so a restart can't double-tick), then per tenant:

Pre-run gates 1. is_vip and trades_settings.auto_pilot 2. _is_regular_trading_hours() — Alpaca clock, falls back to market_calendar.is_rth, False on unknown 3. open positions < _position_cap (paper 20 / live 10, overridable per account) 4. latest proposition is submitted/expired, or pending and older than one tick 5. no in-flight generation claim (stale claims > 1 tick are reclaimed)

generate_proposition (routes/trades.py:1665), in order: 1. Regime gate — _blocking_regime() off the cached snapshot. With LONGS_ONLY = False, only "Potential stabilization" actually blocks; Risk-Off does not (shorts remain permitted). 2. Exclusions — symbols open/pending (get_active_symbols) ∪ symbols analysed-and-passed in the last 12 h (_DROPPED_HOURS). 3. Universe — fixed to _ENGINE_LISTS = ("top10_long", "chart_patterns"); the tenant's own watchlist selection is deliberately ignored. 4. Earnings blackout — drop anything reporting within 7 d. 5. Pre-rank — rank_universe() scores bull/bear signal counts + ADX off the cached bars, interleaves the two sides, keeps top 15. 6. Pre-LLM drops — extension filter: a LONG already up > 2.0 % over the 3 completed sessions is dropped (shorts: inf, i.e. off). Empty result → no_candidates. 7. If ai is off → _propose_from_patterns: no LLM at all, picks triggered/armed detector rows, up to 5 per tick. 8. Opus pass — swing_trading_analyst with Market Snapshot as context, tools = fmp, fred, finra_short, newsapi_ai, newsdata, finviz screener, UW options flow, get_symbol_snapshot. Prompt forces a shortlist, ≤ 3 deep-dives, and a machine-parsed Final Trade Summary + JSON. 9. Analyst's own gates (prompt, agents/skills/swing_trading_analyst.md): Gate 1 tradability/spread, Gate 2 regime→score bar, not a veto (hard veto only for Extreme vol, Elevated/High and Rising, or explicit CHOP), Gate 3 ≥ 1R of clean room, Gate 4 events. 8-point institutional scorecard with a floor of 6 / 7 / 8 depending on regime. Stated PROBABILITY capped at 70 %, 35–45 % for an ordinary setup. 10. Post-LLM backstops — no ticker → no_setup (or regime if the output opens BINDING: MARKET); discard if held; discard if extended; require an entry. 11. Geometry rewrite — _widen_stop (push a too-tight stop out to ATR(14)/20-session swing, capped at 2× priced risk and 8 % of entry), then _clamp_target to exactly 1.0R. _setup_check is advisory logging only. 12. Haiku order_brief writes 5 thesis bullets; proposition saved; _auto_submit_if_enabled.

Everything analysed but not picked is recorded as dropped for 12 h.


3. Engine B — the entry engine (60 s)

scheduler._entry_monitor_loop → entry_engine.run_cycle(tenant, mode) per VIP+auto-pilot tenant, each under that tenant's Alpaca keys, requiring is_rth(). A 180 s DB lease guards restarts.

Session windows (market_calendar.window_for, every boundary clamped to close − 10 min, which is also the half-day handling):

ET window discovery?
09:30–09:45 observe no
09:45–10:00 early yes
10:00–11:20 scan yes
11:20–11:40 idle no
11:40–13:40 slow yes
13:40–14:00 idle no
14:00–15:20 rescan yes
15:20–15:50 eod no (existing candidates may still trigger)
≥ close−10 min manage no entries at all

Cycle order 1. Kill switches (disable_tenant:<id>, disable_discovery, disable_entries[:mode], disable_route:*, disable_symbol:*, disable_advice) — all of them gate reasons to buy only; the trade monitor reads none. 2. Session reset once per session date: ARMED/SUSPENDED → WATCH, geometry cleared, past-expiry → EXPIRED. 3. Promote _pattern_rows() → candidates for three routes: MOMENTUM_PULLBACK (ema_pullback, 2 sessions, 6.5 market-h arm timeout), CONTRACTION_BREAKOUT (breakout/breakout_vcp, 3 sessions, 13 h), BREAKOUT_RETEST (_EXPERIMENTAL — arms and is measured, never spends). Identity is pattern:entry_price, deliberately date-less, so a candidate survives re-scans. 4. Advisory LLM (candidate_analyst, ≤ 5 per cycle, once per candidate on promotion): returns strict JSON validated by whitelist — wrong symbol, bad enum, unknown key, fabricated source id or any embedded price → whole object dropped. It may veto (→ INVALIDATED) or annotate. It is never shown entry/stop/target and can never set a level. 5. News suspend (above). 6. Ladder per candidate on completed 5-minute bars, order load-bearing: expiry → stop_lost on a bar close below stop → arm timeout → entry blocks (closed/calendar_uncertain, no_closed_bars, stale_bars > 20 min, stale_quote > 120 s, adjustment_mismatch > 5 %) → arm/trigger. - arm: needs RTH, a confirmed swing high (k=2 bars either side), trigger = max(detector entry, swing) + 0.01, refused if the trigger drifts > 0.5R above the measured level (swing_above_level) or price is already through it (already_extended). - trigger: a completed bar high ≥ trigger. Capped at 3 triggers per cycle. State moves before the order goes out. 7. _submit: deterministic client_order_id = be-<candidate>-<armed_version> claimed in order_intents first (duplicate → ask the broker, never resubmit), proposition built with stop = detector's, target recomputed to 1R off the trigger, probability = _PATTERN_WIN_RATE (59 % for breakout/breakout_vcp, empty for everything else), then the same _auto_submit_if_enabled.

Consequence worth knowing: ema_pullback carries no measured win rate → empty probability → _expectancy returns None → auto-order declines with no_expectancy. It is suggestion-only by construction.


4. Money gates — _auto_submit_if_enabled

Applied identically to both engines:

  1. auto_order setting on (separate from auto_pilot)
  2. _market_hours_now() (fail-safe False)
  3. Expectancy p·R − (1−p) > 0, R recomputed from prices after the clamp. None → refuse.
  4. Daily loss limit — realised P&L today ≤ −$200 → halt for the session
  5. Committed risk — realised − Σ(open stop-distance × qty) − order_limit × 0.10 ≤ −$1000 → halt
  6. Correct Alpaca keys present for the mode

Each refusal is recorded in trade_db.record_refusal with a code and mirrored into the cockpit progress box.

5. Execution gates — _submit_locked

Serialised per (tenant, mode) by an in-process lock; idempotent on proposition id.

  1. Position cap re-check; skip_if_held
  2. Bracket sanity (stop/target on the right side of entry)
  3. Marketable entry: for a non-trigger order the limit is chased toward the live quote but never further than 0.25R (_ENTRY_CHASE_RISK_FRAC)
  4. Auto-only re-gate on the geometry actually going out: R ≥ 0.75 (_MIN_ORDER_R) and expectancy re-checked at the chased entry
  5. Sizing: _sized_budget shrinks the order limit so entry→stop loses at most 10 % of it (_RISK_FRAC); score no longer sizes anything. Refuse if one share costs more than the sized budget.
  6. Liquidity (auto only): 20-day ADV ≥ $2M or refuse; qty capped at 1 % of ADV; quoted spread > 0.5 % of mid → refuse
  7. Shorts: short_block_reason (shortable + easy-to-borrow, else refuse)
  8. Total deployed + this order ≤ min(total_limit, account cash)
  9. Cancel stale open orders for the ticker, then place the bracket — GTC limit normally, DAY stop-limit when entry_trigger is set (engine B / armed patterns), take-profit at target, stop-loss at stop.

6. Exits (_trade_monitor_loop, 60 s, not RTH-gated)


Periodicity summary

Thread Interval Window
Propose (LLM / AI-off) 20 min, clock-aligned RTH only (~19 ticks/session)
Entry engine 60 s RTH, and only in discovery windows for new candidates
Trade monitor (exits) 60 s always; time stop needs market open
Data refresh + all screener snapshots 15 min (1 h quiet) always
Underlying daily signals/detectors 24 h cache per symbol —
LLM dashboard prose 1 h —
Advisory candidate LLM once per candidate, ≤ 5/cycle —
Universe file refresh when > 10 days stale —
Daily reports 06:00 UTC unrelated to trading

Three things the code says that are easy to miss

  1. The expectancy gate structurally refuses most LLM trades. _clamp_target forces R = 1.0, so p·R − (1−p) > 0 needs p > 50 %, while the skill tells the analyst 35–45 % is what an ordinary A-grade setup looks like. Engine A can analyse all day and place almost nothing; engine B's breakout (59 %) clears at +0.18R. The comment at routes/trades.py:314 says this is intentional.
  2. The regime gate barely bites. _blocking_regime only returns non-empty for "Potential stabilization" while LONGS_ONLY = False; Risk-Off is handled downstream as a score bar inside the prompt, not as code.
  3. No critic argues against the analyst before money moves. The critic and revision round-trip were removed from the propose pipeline (cost), leaving analyst → Haiku bullets → order. The ponytail: note at routes/trades.py:~1720 flags putting it back if auto-pilot ever runs unattended on live money.

Want this as a shareable page (diagram + tables) instead of terminal scrollback? Say the word.