# SIP Day-Trading Bot — Independent Build

A completely independent automated day-trading system for **$5–$15 stocks**, built around
Alpaca SIP market data. Predicts premarket, confirms after the open, enters only on
confirmation, and lets winners run with a dynamic trailing stop.

This system does **not** import from, modify, or depend on any other trading bot codebase.

---

## 1. Installation

```bash
cd sip_bot
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

### Credentials

```bash
cp .env.example .env
```

Edit `.env` and fill in your Alpaca **paper trading** keys:

```
ALPACA_API_KEY=your_key
ALPACA_SECRET_KEY=your_secret
ALPACA_BASE_URL=https://paper-api.alpaca.markets
ALPACA_DATA_URL=https://data.alpaca.markets
```

`.env` is loaded automatically by `config_loader.py` — no manual `export` needed.
**Never commit `.env`.**

### Verify setup before trading

```bash
python dry_run_check.py
```

This validates config, credentials, Alpaca connectivity, and market data access —
**it places no orders**.

---

## 2. Running the bot

```bash
python monitor.py
```

Intended to be started once per trading day (e.g. via cron before 09:29 ET) and it will:
wait for 09:29 → one full-universe scan → stream → trade all day → liquidate at end of
day → exit. (The scan time is `schedule.premarket_scan_time`; the earlier 09:00 scan +
09:00–09:25 development-monitoring window + 09:25 final-scoring review were eliminated
2026-09-01 — see `monitor.py`'s module docstring.)

`config.json` → `mode.execution_mode` controls what actually happens:

| mode         | behavior                                                         |
|--------------|-------------------------------------------------------------------|
| `simulation` | Full logic runs, positions tracked locally, **no orders sent**   |
| `paper`      | Real orders sent to your Alpaca **paper** account                |
| `live`       | Real orders sent to a **live** account — do not use until fully validated |

### Cron example

```cron
55 8 * * 1-5 cd /path/to/sip_bot && /path/to/venv/bin/python monitor.py >> logs/cron.log 2>&1
```

Only one `monitor.py` can run at a time — it takes an exclusive file lock
(`state/monitor.pid.lock`) and refuses to start a second instance against the same account.

---

## 3. Project structure

```
sip_bot/
├── monitor.py             # main orchestrator — the daily lifecycle
├── premarket_scanner.py   # single full-universe scan at premarket_scan_time → candidates
├── top_stocks.py          # replacement-candidate finder when a slot frees up
├── scorer.py               # transparent, weighted scoring engine (used pre- and intraday)
├── entry_engine.py         # opening-confirmation logic → ENTRY / NO ENTRY + reasons
├── intraday_health.py      # continuous HEALTHY/WATCH/STALE/UNHEALTHY/REMOVED scoring
├── risk_manager.py         # position sizing + dynamic trailing stop math
├── position_manager.py     # position lifecycle, state, broker reconciliation
├── stream.py                # Alpaca SIP streaming manager (reconnect, buffering)
├── alpaca_client.py         # all direct Alpaca SDK calls, isolated here
├── indicators.py            # VWAP, EMA, ATR, structure-detection helpers
├── market_time.py           # timezone-safe schedule checks (America/New_York)
├── config_loader.py         # reads config.json + .env
├── logger_setup.py          # rotating file + console logging
├── data_store.py            # all file I/O: premarket/intraday/trades/state
├── dry_run_check.py         # pre-flight connectivity check, no trading
├── config.json               # ALL tunable parameters live here
├── .env.example
├── requirements.txt
├── tests/
│   ├── test_core_logic.py                  # unit tests (indicators/scoring/risk), no network
│   ├── test_simulation_e2e.py              # full pipeline test on synthetic data, no network
│   ├── test_config_validation.py           # config_loader.validate_config() coverage
│   ├── test_entry_shortlist_starvation.py  # 2026-09-01 shortlist-starvation regression
│   ├── test_min_health_score_gate.py
│   ├── test_momentum_classification.py
│   ├── test_resistance_freeze_bugfix.py
│   └── test_logger_setup.py
├── logs/
├── data/{premarket,intraday,trades}/
└── state/   # positions.json, watchlist.json, cooldowns.json, top_stocks.json, intraday.json
```

---

## 4. Module explanations

### `premarket_scanner.py`
Pulls the active tradable universe, applies `symbol_filters.py` (ETFs/funds/trusts,
leveraged/inverse "multiplier" products, and a company-name syllable-count cap — see
below), prefilters to the $5–$15 band with a basic liquidity floor via Alpaca snapshots,
then pulls 1-min premarket bars for survivors and scores each with `scorer.py`. Returns
the top N (default 20, `candidates.premarket_candidate_count`). Rejections are logged with
reasons when `logging.log_rejections` is true.

### `symbol_filters.py` — ETF / leveraged / name-complexity filtering
Applied by **both** scanners — `premarket_scanner.py`'s morning universe pull, and
`top_stocks.py`'s same-day replacement search (defensively re-checked there too, so a
config change mid-day can't let something through). Alpaca's `asset_class=US_EQUITY`
includes ETFs and ETNs, so filtering has to be done on the asset's `name` field:

- `universe.exclude_etfs_and_funds` (default `true`) — rejects names containing ETF/ETN/
  Trust/Fund/known issuer brands (ProShares, Direxion, iShares, SPDR, VanEck, etc.)
- `universe.exclude_leveraged_inverse` (default `true`) — rejects "multiplier" products:
  2x/3x, Ultra(Short), Bull/Bear, Leveraged, Inverse, Daily Target
- `universe.max_name_syllables` (default `5`) — rejects names whose syllable count (after
  stripping legal suffixes like Inc./Corp./Class A) exceeds this cap

**Tradeoff to know:** the syllable cap applies to the literal company name, so it also
excludes some legitimate single-purpose stocks with longer names (e.g. "Advanced Micro
Devices" at 8 syllables, "Rivian Automotive" at 6). Raise `max_name_syllables` or set it to
`0`/`null` to disable that specific filter if this trims names you want kept.

### `scorer.py` — the scoring system
Every candidate gets a `total_score` (0–100) **and** a `breakdown` dict showing exactly how
many points each factor contributed:

```
price_quality, volume_quality, rvol, volume_acceleration, price_momentum,
price_structure, vwap_structure, vwap_slope, consolidation_quality,
distance_from_pm_high, spread_liquidity, momentum_consistency,
extension_risk_penalty (negative)
```

Weights come from `config.json → premarket_scoring.weights` — nothing is a black box, and
nothing is hard-coded. (The earlier `score_development()` trend classifier — which compared
a symbol's score history across a 09:00–09:25 monitoring window and applied a multiplier to
a 09:25 final score — was removed 2026-09-01 along with that window; see §2.)

`top_stocks.py` reuses the exact same `score_premarket_candidate()` function against live
intraday bars when searching for a replacement, so premarket and intraday candidates are
always judged by identical math.

### `intraday_health.py` — continuous candidate-health scoring
Runs every `intraday_health.eval_interval_seconds` (default 60s) against every symbol
currently in `premarket_20`, independent of whether the bot holds a position in it. Answers
one question: "is this stock still behaving like a healthy intraday candidate?" States, best
to worst: `HEALTHY` → `WATCH` → `STALE` → `UNHEALTHY` → `REMOVED`. Only `HEALTHY`/`WATCH` are
entry-eligible (`is_eligible_for_entry()`). State changes require hysteresis
(`healthy_confirm_reads`/`unhealthy_confirm_reads` consecutive reads) so one noisy bar can't
flip a symbol back and forth — except a genuinely severe reading (`_is_severe()`: sharply
negative slope + VWAP lost + confirmed lower-highs/lower-lows, three signals agreeing at
once), which can demote straight to `UNHEALTHY` immediately.

**Critical separation of responsibilities:** this module *never* closes a position and is
never consulted by the exit path — it only gates what's *eligible to be newly entered or
picked as a replacement* (`top_stocks.find_replacement()`, `monitor.py`'s
`_scan_for_entries()`). An open position in a symbol that later reads `UNHEALTHY` is left
alone; that's `position_manager.py`/`risk_manager.py`'s call exclusively, via the trailing
stop.

`health_score` (0–100, from `intraday_health.weights`: price_slope, vwap_support,
volume_participation, structure, range_expansion, distance_from_recent_high) is what
`monitor.py`'s shortlist ranking and `top_stocks.py`'s replacement selection both sort by —
one source of truth for "how good does this look right now," reused everywhere instead of
two competing scores.

### `entry_engine.py` — the entry confirmation system
Never enters on premarket rank alone. `evaluate_entry()` checks, against live SIP bars since
the open:

- price above VWAP
- VWAP rising
- opening volume expansion vs. premarket baseline
- higher-lows structure
- breakout of premarket high/resistance **that holds** for N bars (or, if configured, a
  healthy pullback-then-continuation as an alternative confirmation path)
- (optional, off by default) momentum not fading, volume not declining — as a soft vote, or
  as a hard hard disqualifier if `require_momentum_not_fading_strict` is true

...and separately checks **disqualifiers** that block entry regardless of score, none of
which can be outvoted by other checks passing: a stale/failed fresh `intraday_health` read at
the moment of entry (always required — never optional, never soft-weighted), a health score
below `entry.min_health_score_by_state`'s floor for that state, spread too wide, price too
extended above (or, if `min_extension_from_vwap_pct` is set, too close to) VWAP, RSI
overbought (opt-in via `require_rsi_not_overbought`), and an immediate post-open fade. A
`confirmation_score` (% of the non-disqualifier checks passed) must also clear
`entry.min_confirmation_score`. A symbol already traded (and closed) earlier the same
session must additionally clear the much higher `entry.reentry_min_confirmation_score` bar
(100% by default) to be re-entered — see `entry.require_full_confirmation_on_reentry`. Every
decision returns human-readable `reasons_for`/`reasons_against`, logged on both outcomes when
`logging.log_rejections` is true.

`entry.time_based_overrides` (disabled unless `.enabled` is `true`) lets different parts of
the trading day use different threshold values for the same config keys — see the three
example windows in `config.json`.

### `risk_manager.py` — position sizing + the trailing stop system
Position size = min(risk-based size, notional cap), where risk-based size is
`(equity × risk_pct) / (entry − initial_stop)`.

Trailing stop supports four configurable methods (`stop.method` in config):
`fixed_cents`, `percentage`, `atr`, `volatility_adjusted`. **As of 2026-09-01, the live
default is `atr`** (previously `fixed_cents` at a flat $0.10) — see
[Recent changes](#10-recent-changes-2026-09-01) below for why and what changed. The core
trailing rule itself is unchanged regardless of method, exactly as specified:

```python
distance = compute_stop_distance(highest_price, bars)   # method-dependent; ATR-based by default now
new_stop = highest_price - distance
if new_stop > current_stop:      # NEVER moves down
    current_stop = new_stop
```

One `stop.method` setting drives both `compute_initial_stop()` (using
`atr_multiplier_initial`) and the trailing update above (using `atr_multiplier_trailing`) —
there's no separate initial-vs-trailing method switch, only separate multipliers once `atr`
is selected. If no bars are available when a stop needs computing, it falls back to the
`fixed_cents` values (`initial_distance`/`trailing_distance`) automatically, so those keys
are kept configured (not deleted) even while unused as the primary method.

Verified against the spec's worked example (fixed_cents case) in
`tests/test_core_logic.py::test_trailing_stop_only_moves_up`.

### `position_manager.py`
Owns the full position record (`entry_price`, `entry_time`, `initial_stop`, `current_stop`,
`highest_price`, `current_price`, `shares`, `current_pl`, `mfe`, `exit_price`,
`exit_reason`, `status`). `reconcile_with_broker()` compares local state against Alpaca's
actual positions and **logs mismatches rather than silently auto-correcting them** —
past experience shows blind auto-liquidation of "orphaned" broker positions can itself
cause losses; a human should review.

### `top_stocks.py` — the slot-replacement system
Triggered every time a position closes. Re-scores **current** market conditions (not the
original scan-time list) for every symbol in `premarket_20` — refreshing `intraday_health` for the
*whole* pool first (including currently-held/cooldown symbols, so the dashboard and the
entry shortlist never read a stale health entry), then ranking the ones that can actually
fill the slot (not held, not on cooldown, health-eligible) by `health_score` (ties broken by
premarket-style `total_score`). A `trading.min_replacement_health_score` floor means a weak
field can leave the slot empty this cycle rather than filling it with the least-bad option.
The winner still has to pass `entry_engine.evaluate_entry()` before `monitor.py` buys it —
ranking and buying are separate steps, exactly as specified.

### `monitor.py`'s intraday rotation + entry shortlist
`premarket_20` isn't frozen at scan time for the rest of the day: every
`intraday_health.full_rescan_interval_minutes` (default 30), `_run_intraday_full_rescan()`
re-scores the **entire** prefiltered universe (hundreds of symbols, not just the current 20)
and rotates `premarket_20` to the current top `full_rescan_pool_size` by health score — this
is how a stock that wasn't strong enough to make the original scan-time list can still be
discovered and traded later in the day. Every 5s (`poll_interval_seconds_intraday`),
`_scan_for_entries()` ranks the current pool by health score and only evaluates the top
`entry_shortlist_size` (default 5) through `entry_engine.evaluate_entry()` that cycle — this
keeps the per-cycle API/compute cost bounded regardless of how large the pool gets.

**2026-09-01 fix — shortlist starvation:** a symbol that ranks in the top N by health score
but keeps failing confirmation on an unchanging condition (e.g. a hard momentum
disqualifier) used to occupy a shortlist slot on every single cycle indefinitely, since
nothing ever demoted it — confirmed in production logs starving out a genuinely
better-scoring candidate for ~24 minutes straight. `_EntryAttemptTracker` (in `monitor.py`)
now benches a symbol for `confirmation_failure_cooldown_seconds` after
`max_consecutive_confirmation_failures` straight not-confirmed results, freeing its slot for
the next-best candidate. See [Recent changes](#10-recent-changes-2026-09-01) for the full
story and `tests/test_entry_shortlist_starvation.py` for the regression test.

### `stream.py`
Wraps `alpaca.data.live.StockDataStream` in a background thread with a per-symbol rolling
bar buffer (`SymbolBuffer`), reconnect with configurable backoff
(`streaming.reconnect_backoff_seconds`), and stale-data detection
(`streaming.stale_data_seconds`) so `monitor.py`'s main loop is simple synchronous polling
against an in-memory buffer rather than juggling async callbacks itself.

### `monitor.py` — the orchestrator
Runs the full daily lifecycle end to end: one full-universe premarket scan at
`schedule.premarket_scan_time` → stream start → market-open wait → main trading loop
(update stops → scan for entries → reconcile broker state) → end-of-day liquidation. Holds
a singleton file lock so two instances can never trade the same account simultaneously.
(The 09:00 scan followed by a 09:00–09:25 development-monitoring window and 09:25
final-scoring review were eliminated 2026-09-01 — see [Recent changes](#10-recent-changes-2026-09-01).)

---

## 5. Configuration

Every tunable parameter lives in `config.json` — see the file for the full set, organized
under: `mode`, `universe`, `schedule`, `candidates`, `premarket_scoring`,
`intraday_health`, `streaming`, `entry`, `risk`, `stop`, `trading`,
`data_storage`, `logging`. Nothing described in this README is hard-coded in the Python
source. Every value that changed from an earlier default carries a dated `_note_*` /
`_note` sibling key right next to it explaining what changed, why, and what real
session/data prompted it — read those before re-tuning something, they usually already
record the tradeoff you're about to rediscover.

**Startup config validation:** `config_loader.validate_config()` runs automatically at the
start of every `monitor.py` session (and in `dry_run_check.py`) and raises
`ConfigValidationError` — listing every problem at once, not just the first — if any
numeric parameter that must be a positive magnitude (a lookback window, a percentage, a
ratio, a score floor, an ATR multiplier, etc.) is zero, negative, or non-numeric. This is
deliberately narrow: it does **not** make entry criteria more restrictive, does **not**
check cross-field ordering (e.g. min < max), and does **not** flag fields that legitimately
use `0` as a documented "disabled" sentinel (`same_symbol_reentry_cooldown_minutes`,
`min_extension_from_vwap_pct`) or scoring credits that can legitimately be `0`
(`price_slope_negative_credit`). See `config_loader.py`'s `_STRICTLY_POSITIVE_PATHS` comment
for the exact list and rationale, and `tests/test_config_validation.py` for what is and
isn't flagged.

---

## 6. Example log output

```
[PREMARKET] Starting scan
[PREMARKET] Universe size after asset filtering: 4213
[PREMARKET] Prefiltered to 187 symbols in price/volume band
[PREMARKET] XYZ score=82.4
[PREMARKET] ABC score=79.8
[PREMARKET] Selected 20 candidates

[OPEN] SIP stream active

[CONFIRMATION] XYZ confirmed (85%): price $8.78 above VWAP $8.71; VWAP rising; ...
[ENTRY] XYZ @ $8.78 shares=2277 reason='...'
[STOP] XYZ initial stop=$8.68

[TRAIL] XYZ high=$8.95 stop=$8.85
[TRAIL] XYZ high=$9.05 stop=$8.95

[EXIT] XYZ @ $9.04
[EXIT_REASON] TRAILING_STOP
[P/L] +$592.02

[SLOT] 1 position slot available
[REPLACEMENT] Running top_stocks.py
[REPLACEMENT] ABC selected (score=74.1)

[EOD] Force-liquidating all open positions
[EOD] Session summary: trades=6 wins=4 losses=2 total_P/L=+$1,204.55
```

---

## 7. Testing

```bash
# Run everything (recommended):
python -m pytest tests/ -v

# Pure-logic unit tests (indicators, scoring, risk math) — no network:
python tests/test_core_logic.py

# Full pipeline on synthetic data (score -> confirm -> enter -> trail -> exit -> replace):
python tests/test_simulation_e2e.py

# Config startup-validation coverage (config_loader.validate_config()):
python tests/test_config_validation.py

# Entry-shortlist starvation fix regression test (see section 4 / Recent changes):
python tests/test_entry_shortlist_starvation.py

# Connectivity / config sanity check against your real Alpaca account:
python dry_run_check.py
```

`tests/` also includes `test_logger_setup.py`, `test_min_health_score_gate.py`,
`test_momentum_classification.py`, and `test_resistance_freeze_bugfix.py` — one file per
significant bugfix/feature, matching this project's practice of adding a regression test at
the same time a real-data bug is fixed, not after the fact. Regression tests directly encode
real incidents found in production logs (see each file's docstring for the specific
date/symbols/values that prompted it) — that history is deliberately kept in the test files
rather than summarized here, since the exact numbers matter if you're deciding whether a
future config change would reintroduce the same bug.

**Known pre-existing gap (not touched by 2026-09-01's changes):**
`test_simulation_e2e.py::test_full_pipeline_developing_stock_enters_and_trails` currently
fails against real `config.json` — its hardcoded `open_bars` fixture predates the
2026-08-25 `require_momentum_not_fading_strict` feature and now reads as decelerating
momentum (a hard disqualifier under the current default `true`), plus its opening-volume
ratio (0.82x) sits below the current `opening_volume_expansion_ratio` (1.4x) floor. The
fixture bar data needs updating to reflect both thresholds; flagged here rather than fixed
silently since it's a test-data/config-drift issue unrelated to this session's work, not a
production bug.

**Before running with real (paper) orders:** run at least one full session with
`mode.execution_mode = "simulation"` and review `data/trades/<date>_summary.json` and
the logs to confirm the entries/exits look reasonable for your risk tolerance.

---

## 8. Failure handling notes

- **Bad data for one symbol** never crashes the loop — each symbol is evaluated
  independently inside `try/except`-guarded calls in `alpaca_client.py`, and functions
  return safe defaults (empty lists, `None`) rather than raising.
- **SIP disconnect**: `stream.py` reconnects with exponential-ish backoff up to
  `streaming.max_reconnect_attempts`; if exhausted, it logs an error and monitor.py
  continues (positions already open still get managed via the last-known buffer state —
  extending to a REST-polling fallback is the natural next enhancement if this matters
  for your use case).
- **Restart safety**: `positions.json`, `watchlist.json`, `cooldowns.json`, and
  `top_stocks.json` are all written atomically (`data_store._atomic_write_json`) so a
  crash mid-write can't corrupt state.
- **Duplicate processes**: blocked by the singleton file lock in `monitor.py`.
- **Market-time handling**: all schedule checks go through `market_time.py` using
  `zoneinfo("America/New_York")`, not naive local time.

---

## 9. Live monitoring dashboard (PHP)

`report/` is a small, dependency-free PHP dashboard for watching the bot during the trading
day: session phase, open positions with live P&L, today's closed trades, the current
watchlist (final 10 / premarket 20), symbol cooldowns, and a tailing activity log. It's
pure PHP + one JS file — reads the bot's own `state/` and `data/` JSON files directly, no
database, no build step.

### Running it

```bash
cd sip_bot/report
php -S 0.0.0.0:8090
```

Then open `http://<droplet-ip>:8090` (or `http://localhost:8090` if you're tunneling — see
below). It polls itself every 5 seconds; no page reload needed.

For a more permanent setup on the droplet, serve it with nginx + php-fpm pointing its
document root at `sip_bot/report/`, or keep using `php -S` inside a `systemd` unit /
`screen` session alongside `monitor.py`.

### Securing it

Your droplet is on the public internet, and this dashboard shows live account P&L. Two
options:

1. **Recommended — keep it off the public internet entirely.** Bind to localhost only
   (`php -S 127.0.0.1:8090`) and reach it via an SSH tunnel:
   ```bash
   ssh -L 8090:localhost:8090 you@your-droplet
   ```
   then open `http://localhost:8090` on your own machine.

2. **If you do expose it publicly**, set `REPORT_PASSWORD` in `.env` first. This turns on a
   simple session-based login (`report/login.php`) guarding both the page and its JSON API.
   Without it, `index.php` shows a warning banner and the dashboard is open to anyone with
   the URL.

### What it reads (and never writes)

Everything in `report/` is read-only against the bot's existing files — it never modifies
`positions.json`, `config.json`, or anything else the bot owns:

- `state/positions.json` → open positions table (live P/L, stop, highest price)
- `data/trades/<today>_trades.jsonl` → today's closed trades
- `state/watchlist.json` → premarket 20 / final 10 tabs
- `state/cooldowns.json` (+ `config.json`'s `risk.symbol_cooldown_minutes`) → cooldown countdowns
- `logs/sip_bot.log` → tailing activity log (last ~150 lines, colorized by level)
- `state/monitor.pid.lock` → "Monitor Running" indicator, checked via the same `flock()`
  mechanism `monitor.py` itself uses for its singleton lock

If a file doesn't exist yet (e.g. before the first premarket scan of the day), the
dashboard shows an empty state for that section rather than erroring.

---

## 10. Recent changes (2026-09-01)

Prompted by a review of the 2026-09-01 session log (9 trades, 3 wins / 6 losses,
net −$64.34, all exits trailing-stop-driven) and a specific question about why CRML — a
stock that scored competitively (70.22) when it rotated into `premarket_20` at 10:01 — was
never bought.

**1. Trailing (and initial) stop switched from fixed cents to ATR-based.**
`stop.method`: `"fixed_cents"` → `"atr"`. `stop.atr_multiplier_trailing`: `1.0` → `0.25`
(explicit request). `stop.atr_multiplier_initial` stays `1.2` (unchanged — only the trailing
multiplier was requested to change). No code change was needed — `risk_manager.py` already
fully implemented the `atr` method; this was a config-only switch. Net effect: the stop
distance now scales with each symbol's own recent volatility (14-period 1-min ATR × 0.25)
instead of one flat $0.10 for every symbol regardless of how quiet or wild it's trading —
tighter on quiet stocks, wider on volatile ones, versus the old one-size-fits-all distance.
`stop.min_stop_distance_cents` ($0.03) still floors it so an extremely quiet stock can't get
an unreasonably tight stop. **This has not been validated against a real trading day yet** —
watch fill/stop-out behavior in the next few sessions and adjust
`atr_multiplier_trailing` if whipsaws increase (raise it) or winners give back too much
before the stop catches them (lower it further).

**2. Config startup validation added** (`config_loader.validate_config()`). Deliberately
scoped to catch only unambiguous mistakes (a zero/negative value in a field that must be a
positive magnitude) — explicitly NOT a tool for tightening entry criteria, per direct
request to avoid over-restricting the entry config. See section 5 above and
`tests/test_config_validation.py`.

**3. Entry-shortlist starvation bug fixed.** Investigation corrected an earlier, incomplete
diagnosis: `premarket_20` rotation and entry evaluation for newly-rotated symbols were
*already* wired together correctly (this was previously misdiagnosed as CRML being
permanently locked out of the tradeable universe — it wasn't). The real, narrower bug: CRML
rotated into `premarket_20` at 10:01 with a competitive score, but `_scan_for_entries()`
only evaluates the top `entry_shortlist_size` (5) pool members by health score each 5-second
cycle, and `F` — which out-ranked CRML on health score but hard-failed confirmation on an
unchanging momentum disqualifier — occupied a shortlist slot continuously from 09:32 to
09:56 (~24 minutes) without ever being demoted. CRML rotated back out at the next 30-minute
rescan having never once reached `entry_engine.evaluate_entry()`. Fixed via
`_EntryAttemptTracker` in `monitor.py`: a symbol is benched from the shortlist for
`intraday_health.confirmation_failure_cooldown_seconds` (180s) after
`max_consecutive_confirmation_failures` (5) straight not-confirmed results, clearing
immediately on a confirmed result. This changes **which candidates get evaluated**, never
**what it takes to pass** — no entry criterion was loosened or tightened.
`tests/test_entry_shortlist_starvation.py` reproduces the exact F/CRML dynamic end-to-end.

**Reviewed but deliberately left unchanged this round** (explicitly scoped out, not
forgotten): the broker-settlement race behind 2026-09-01's 84 `RECONCILE` warnings / 7
`close_position` errors in the first 7 minutes of trading (self-heals via the existing
15s backoff; a real fix would gate stop-checks on confirmed fill), the complete absence of
any profit-taking/scale-out mechanism (every exit, winners included, is 100%
trailing-stop-driven), and the PMI-style pattern of entering an already-extended breakout
(RSI 65.8) that reversed in 75 seconds for the day's largest single loss (−$48.48).

**4. Premarket schedule compressed to a single scan right before the open, per explicit
request.** `schedule.premarket_scan_time`: `"09:00:00"` → `"09:29:00"`. The 09:00–09:25
development-monitoring window (`_run_development_monitoring()`, re-scoring `premarket_20`
every `poll_interval_seconds_premarket` seconds) and the 09:25 final-scoring review
(`_run_final_scoring()`, applying `score_development()`'s developing/fading trend
multiplier) are both removed from `monitor.py` — there's no time left for either between a
09:29 scan and the 09:30 open. `final_10` is now populated directly from `premarket_20`'s
top `candidates.final_candidate_count` by the scan's own `total_score`, with no trend
adjustment (there's no multi-snapshot history left to compute one from).
`score_development()` itself, the now-dead `development_monitoring` config section,
`schedule.premarket_development_end`, `schedule.poll_interval_seconds_premarket`, and
`market_time.is_development_window_over()` were all deleted rather than left as unused
dead code. **Timing risk to watch:** on 2026-09-01's real log, a full scan of 549
prefiltered symbols took ~41s (09:00:05–09:00:45) — that leaves very little buffer before
`market_open_time` (09:30:00) if the universe is larger or the API is slower on a given
day. Watch actual scan-completion timestamps after this change; move
`premarket_scan_time` earlier (e.g. `09:28:00` or `09:27:00`) if the scan is ever still
running at or after 09:30:00.

## 11. Known scope / next steps

- `get_universe_symbols()` currently pulls Alpaca's full tradable-asset list; for a large
  account this is a lot of snapshot calls each morning. Swapping in a maintained
  liquid-symbols file (e.g. yesterday's $5–$15 volume leaders) would speed up the scan.
- REST-polling fallback when the SIP stream is fully down is stubbed as a noted extension
  point, not implemented — the bot currently manages existing positions off last-known
  buffer state and logs an error rather than blindly guessing prices.
- ATR-based and volatility-adjusted stop methods are implemented and unit-tested but will
  benefit from a few real paper sessions of tuning `atr_multiplier_trailing` for your
  specific price range.
