Read the code, not the docs. Here's how an automatic trade actually comes about.
┌─ 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).
| 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 |
| 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) |
| 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.
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.
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.
_auto_submit_if_enabledApplied identically to both engines:
auto_order setting on (separate from auto_pilot)_market_hours_now() (fail-safe False)p·R − (1−p) > 0, R recomputed from prices after the clamp. None → refuse.realised − Σ(open stop-distance × qty) − order_limit × 0.10 ≤ −$1000 → haltEach refusal is recorded in trade_db.record_refusal with a code and mirrored into the cockpit progress box.
_submit_lockedSerialised per (tenant, mode) by an in-process lock; idempotent on proposition id.
skip_if_held_ENTRY_CHASE_RISK_FRAC)_MIN_ORDER_R) and expectancy re-checked at the chased entry_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.short_block_reason (shortable + easy-to-borrow, else refuse)min(total_limit, account cash)entry_trigger is set (engine B / armed patterns), take-profit at target, stop-loss at stop._trade_monitor_loop, 60 s, not RTH-gated)cancelled.market_hours_between, per-account overridable), market must be open, guarded by close_pending so a resting close isn't re-issued._STRATEGY_FROM — auto positions exit on their bracket. Requires two consecutive reads over the line.reconcile_closed_position needs 2 consecutive "not held" reads before booking a close; every cycle also samples unrealized_plpc into max/min for excursion stats.| 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 |
_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._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.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.