Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

US Stock Symbols Range / Lower-Band Retest Scanner & Backtester

A research and scanning system for daily US stock market data (NASDAQ, NYSE, AMEX). It looks for stocks trading inside a sideways range and flags setups where price returns to the lower band a second time with bullish confirmation (rejection, liquidity sweep, reclaim, CHoCH/BOS, FVG, volume).

This is a research/scanning/backtesting tool only. It never places live orders, and nothing here is a guarantee of future profitability — see "Research disclaimer" below.

It uses the smart-money-concepts package for swing pivots, BOS/CHoCH, and FVG primitives, but all range detection, retest state-machine logic, risk management, scanning, and backtesting is project-specific code (see "Design notes" below for exactly how the package is used and where its output is treated as untrusted).

Ticker universe

The ticker universe is dynamically fetched from the US-Stock-Symbols GitHub repository, which provides ticker lists for NASDAQ, NYSE, and AMEX exchanges. The project caches these lists locally in src/tickers.py (ALL_TICKERS combines all three exchanges), and src/data/ticker_loader.py reads from that module.

You can scan specific exchanges:

  • NASDAQ — ~4,169 tickers
  • NYSE — ~2,713 tickers
  • AMEX — ~293 tickers
  • ALL_TICKERS — ~7,175 tickers (combined)

Tickers using a dot for share classes (BRK.B, BF.B) are translated to Yahoo Finance's dash form (BRK-B, BF-B) only at the data-fetch boundary (src/data/ticker_normalizer.py) — the original symbol is preserved for all display/output.

Installation

python3 -m venv .venv          # if you don't already have one
source .venv/bin/activate
pip install -r requirements.txt

Tested with Python 3.11+ (this environment used 3.14). Network access to Yahoo Finance is required for live scans/backtests; downloaded history is cached locally under data/cache/.

Usage

# Scan all US tickers (default)
python -m src.cli scan

# Scan by exchange
python -m src.cli scan --exchange nasdaq      # Scan NASDAQ only (~4,169 tickers)
python -m src.cli scan --exchange nyse        # Scan NYSE only (~2,713 tickers)
python -m src.cli scan --exchange amex        # Scan AMEX only (~293 tickers)
python -m src.cli scan --exchange all         # Scan all exchanges (~7,175 tickers)

# Filter by setup status
python -m src.cli scan --exchange nasdaq --status signal_active
python -m src.cli scan --exchange nyse --status waiting_for_second_touch
python -m src.cli scan --status both          # Show both (default)

# Scan specific tickers
python -m src.cli scan --ticker CRM MSFT

# Reproducible historical scan (never uses data after this date)
python -m src.cli scan --date 2026-08-17

# Backtest one or more tickers
python -m src.cli backtest --ticker CRM --start 2020-01-01 --end 2026-01-01

# Backtest the full universe
python -m src.cli backtest --all --start 2020-01-01 --end 2026-01-01

# Render a chart for a ticker
python -m src.cli chart --ticker CRM --date 2026-08-17

# Detailed per-ticker diagnostic: structural/active range, provenance,
# touch/reaction sequence, score breakdown -- the exact same row `scan` writes
python -m src.cli diagnose --ticker CRM MSFT

Scan options

  • --exchange {nasdaq,nyse,amex,all} — Select ticker exchange(s) to scan (default: all)
  • --status {signal_active,waiting_for_second_touch,both} — Filter results by setup status (default: both)
    • signal_active — Show only ready-to-trade setups
    • waiting_for_second_touch — Show setups waiting for confirming second touch
    • both — Show both status types

Common flags

All commands support: --config PATH (default config.yaml), --cache-dir, --output-dir, --confirmation-mode {basic,standard,strict}, -v/--verbose.

scan command also supports: --min-score (filter by score), --no-charts (skip chart generation).

Outputs:

  • outputs/scans/scan_<date>.csv / .json — every ticker scanned, ranked.
  • outputs/scans/scan_<date>_failures.csv / .json — one row per ticker that failed to scan (original_ticker, normalized_ticker, error_type, error_message); a failed ticker never aborts the batch, and this file is only written when there was at least one failure.
  • outputs/diagnostics/diagnostic_<date>.csv / .json — from diagnose, same schema as the scan output (see below), for a focused set of tickers.
  • outputs/backtests/backtest_<timestamp>_{trades,equity,metrics}.{csv,json}.
  • outputs/charts/{ticker}_{date}.html — only for the top-N candidates (output.top_n_charts in config.yaml) or an explicit chart call.

Scan/diagnose output: three separate concepts

Every row separates three things that are easy to conflate (see src/scanner/stock_scanner.py::result_to_row, the single function both scan and diagnose use, so their output can never drift apart):

  1. Structural range (structural_* columns) — the broader, context-only range from select_best_range's largest validated window. Never the setup itself.
  2. Active/local range (active_* columns, plus range_quality_score, touch counts, upper_band_status, range_classification) — the range that actually drives touches, reactions, second-touch detection, entries, stops, and targets. active_range_status is VALID / INVALIDATED / NONE, independent of setup_status below (a range can be valid with no complete setup yet).
  3. Trade setup / signal (setup_status, signal_type, first_lower_touch_*, first_reaction_*, second_lower_touch_*, entry_price/stop_price/ take_profit_*, score, score_breakdown) — only populated (signal_type: "long") once the state machine reaches SIGNAL_ACTIVE; a range or an active range can both exist with no setup at all (signal_type: "NONE"), which is expected and common.

Provenance for both bands is always included: range_selection_method ("clustered_confirmed_pivots"), range_selection_reason (why this window won its role), and lower_band_source_pivots / upper_band_source_pivots (the exact confirmed-pivot dates/prices that produced each band, straight from the clusters detect_range already builds — see RangeInfo in range_detector.py).

setup_status uses the same SetupStatus values documented below (NO_RANGE through SIGNAL_ACTIVE/INVALIDATED/ENTRY_MISSED/ SETUP_EXPIRED) rather than a separate NO_SETUP sentinel — NO_RANGE already unambiguously means "no current setup"; introducing a second name for the same thing would only add a redundant status without adding information.

Every row also carries a signal-gate breakdown — every individual gate a setup must clear to reach SIGNAL_ACTIVE (gate_has_valid_range through gate_final_signal_eligible, plus failed_gates), computed by stock_scanner.py::build_signal_gate_breakdown from the same ScanResult — so a row that isn't SIGNAL_ACTIVE always explains exactly which gate(s) are missing, rather than just reporting an opaque status name. This works even when no Signal ever fired: the raw confirmation-evaluation result (sweep/reclaim/CHoCH/BOS/FVG/volume) is retained on every bar regardless of pass/fail (previously only kept on success), and a "prospective" trade plan (armed_plan, computed once at arming time using the entry trigger) covers WAITING_FOR_CONFIRMATION/ENTRY_MISSED rows so entry_price/stop_price/ take_profit_* are never just blank for those.

All strategy/risk/backtest parameters live in config.yaml (documented inline there) — nothing is hard-coded in the source.

Strategy logic

Range detection (src/indicators/range_detector.py)

Over a given lookback window: confirmed swing highs/lows are clustered independently (1D greedy merge within tolerance = max(zone_tolerance_atr * ATR, price * zone_tolerance_pct)); the lower band is the lowest-priced swing-low cluster with enough spaced touches. Bands are zones (*_low/*_center/*_high), not single price levels, using the cluster's median as the center. A range is only valid if the lower band has enough touches, the upper band has at least some qualifying evidence (see "Asymmetric upper band" below), its width sits within [minimum_range_width_atr, maximum_range_width_atr] * ATR, too few closes sit outside it, there's no persistent trend (linear-regression slope of closes, ATR-normalized), and enough of the window's closes sit inside it. A range_quality_score (0-100) is computed from all of these (weights documented in the module), then scaled by the range's confidence multiplier (see below).

Asymmetric upper band

The strategy's primary objective is a second test of the lower band — the lower band is the primary trading structure and a hard requirement (minimum_lower_band_touches, default 2, independent, spaced touches, always), while the upper boundary is mainly used for context, range classification, scoring, and target estimation. A range is never rejected solely because the upper band hasn't been tested twice. detect_range tries three tiers, in order, recorded on RangeInfo.upper_band_status ("confirmed" | "anchored" | "provisional"), RangeInfo.range_confidence (same three values), and RangeInfo.range_classification:

  1. Confirmed (→ range_classification = "CONFIRMED_RANGE") — the highest-priced swing-high cluster with >= minimum_upper_band_touches spaced touches (same logic as the lower band).
  2. Anchored (→ range_classification = "ASYMMETRIC_RANGE") — a single swing high qualifies as a strong, confirmed rejection if: it's a confirmed swing high; the rejection candle closes well off its high ((high-close)/(high-low) >= minimum_upper_rejection_strength); its bar range isn't an abnormal isolated wick (<= maximum_upper_wick_atr * ATR — this guards against a one-bar data spike, not against a genuinely long rejection wick, which is exactly what a strong rejection looks like); and price subsequently rotates at least minimum_rotation_fraction of the way back down toward the lower band within the window. The highest-priced qualifying pivot wins. A swing high sitting at or below the range's own midpoint is never eligible here — a midpoint rejection is not counted as an upper-band touch; any swing high closer than 0.5 * minimum_range_width_atr * ATR above the lower band is filtered out before clustering/selection, so it's only ever read as rotation/ mean-reversion evidence for the anchored tier's rule above.
  3. Provisional (→ range_classification = "LOWER_BAND_CONSOLIDATION") — neither of the above, but at least one swing-high pivot exists in the window, used as a best-effort boundary so the range isn't discarded outright.

If there's no swing-high pivot at all, the range is invalid (no evidence of an upper boundary whatsoever). Confidence is applied as a score multiplier, not a hard gate — confirmed=1.0, anchored=range_confidence_multiplier_anchored (default 0.85), provisional=range_confidence_multiplier_provisional (default 0.65). A second upper-band touch is therefore a score bonus, never a mandatory condition. The scanner's 4th classification, INVALIDATED, is layered on top once the state machine observes a confirmed breakdown of a range it was tracking (SetupStatus.INVALIDATED) — see src/scanner/ranking.py::range_classification_for_result.

Structural vs. active ranges (multi-window selection)

A single fixed lookback conflates two different things: the broad, months-long structure a stock is actually trading inside, and the tighter, currently relevant slice of that structure price is testing right now. So instead of one lookback, select_best_range evaluates several window sizes independently (range.range_windows, default 126 / 252 / 378 trading days — roughly 6/12/18 months) via detect_range, then, among the windows that still qualify:

  • filters out any window with a confirmed breakout (latest close more than breakout_buffer_atr * ATR beyond a band) or with no recent interaction (no touch of either band or the midpoint within the last recent_interaction_bars bars). A range is never rejected just because it started long ago — only because it's stale or already broken.
  • the active range is the survivor with the highest range_quality_score — this is what drives touches, second-test signals, entries, stops, and targets.
  • the structural range is the survivor with the largest window — context only, not used for trade mechanics.

These can be (and often are) the same object. The scanner always outputs both, in separate active_* / structural_* column blocks (see "Scan/diagnose output: three separate concepts" above and src/scanner/stock_scanner.py::result_to_row) — the active range's own active_range_status reflects invalidation only when it's literally the same object as the structural one; a genuinely wider, separate structural window stays valid context even after the active range resolves.

Lower-band retest state machine (src/strategy/state_machine.py)

NO_RANGE -> RANGE_CONFIRMED -> FIRST_TOUCH_COMPLETED -> WAITING_FOR_SECOND_TOUCH
         -> SECOND_TOUCH_DETECTED -> WAITING_FOR_CONFIRMATION -> SIGNAL_ACTIVE

INVALIDATED is reachable from any active state, including SIGNAL_ACTIVE, on either: a confirmed close below lower_band_low - invalidation_buffer_atr * ATR (breakdown), or a confirmed close beyond either band per has_confirmed_breakout (breakout_buffer_atr) — the same breakout definition already used once when a range is first selected (select_best_range), re-applied every bar for as long as the range is tracked. This closes a gap where a range that had already resolved via a sustained breakout months (or a year+) earlier could otherwise keep being used as a stale basis for touches and even signals — see invalidation_reason in the scan/diagnose output for which side triggered it. ENTRY_MISSED is reached if WAITING_FOR_CONFIRMATION persists past entry_validity_bars without the entry trigger firing (was named EXPIRED; renamed to be explicit that a real, confirmed opportunity existed and simply wasn't filled in time — its details, not just the fact something once happened, are retained via armed_plan, visible in scan/diagnose output). SETUP_EXPIRED is reached when 2+ independent touches exist but the latest one never confirmed and is older than signal_recency_bars — see "Current, recent, and expired setups" below. Neither status forces a range reselection the way INVALIDATED does; a genuinely new touch naturally pulls the setup back into SECOND_TOUCH_DETECTED/WAITING_FOR_CONFIRMATION the moment something fresh happens. (signal_expiry_bars remains defined in config for backward compatibility but is superseded by entry_validity_bars for this transition — see "Assumptions & limitations" for the recommended cleanup.)

Current, recent, and expired setups

A confirmation succeeding is not the end of the story — the resulting entry trigger only stays actionable for entry_validity_bars bars (default 3), and an unconfirmed touch only stays "current" for signal_recency_bars bars (default 3) before the setup is relabeled SETUP_EXPIRED rather than an ageless SECOND_TOUCH_DETECTED. This was added after finding, empirically, that several tickers (MHK/IBM/AON/EQR/OMC) showed a plain SECOND_TOUCH_DETECTED that was actually months stale — in two cases (IBM, EQR) a real confirmation had succeeded on a real historical date but its entry window passed unfilled, and the memory of that was previously discarded entirely rather than surfaced as ENTRY_MISSED. Confirmation is still only ever evaluated live, on a touch's own real entry bar, in true historical sequence — the batch scanner does not use a shortcut that only checks the final bar; tracing proved a real historical confirmation shows up on its actual historical date during the walk, not just retroactively at the end.

Design choices worth knowing:

  • Once confirmed, a range's band levels are frozen (not recomputed every bar) so touches are always measured against the same line a trader would have drawn, not a level that silently drifts underneath them — but the range is still checked, every bar, for whether it has since resolved (see INVALIDATED above); "frozen" means the levels don't move, not that the range is assumed to still be relevant forever.
  • Only one signal fires per confirmed range instance — after SIGNAL_ACTIVE, no further signals are generated until the range is invalidated and a new one is confirmed. This is what prevents repeated daily signals while price lingers near the lower zone.

Touch counting: one canonical event list (src/strategy/lower_band_retest.py)

How many independent times price has tested the lower band is answered by exactly one place: LowerBandTouchEvent / advance_touch_events / detect_lower_band_touch_events. A visit opens on the first bar whose low enters the zone; multiple candles inside one continuous visit just extend it (never split into more events); a visit closes once reaction_achieved(...) fires (the same ATR/midpoint-fraction rule used for the first touch, now applied uniformly to every touch — this is the one deliberate behavior change from the original single-touch-slot implementation); a new event can only open after the previous one closed, gated by the existing minimum_bars_between_touches spacing rule. Every event is independent by construction — no post-hoc filtering needed. setup_status is derived from this list, never tracked separately, so the chart, the scan/diagnose output, and setup_status can never disagree:

0 valid independent events   -> RANGE_CONFIRMED
1 event, not yet reacted     -> FIRST_TOUCH_COMPLETED
1 event, reacted             -> WAITING_FOR_SECOND_TOUCH
2+ events                    -> NEVER WAITING_FOR_SECOND_TOUCH; SECOND_TOUCH_DETECTED
                                 while the latest event's confirmation is
                                 pending/failed, WAITING_FOR_CONFIRMATION once
                                 armed, SIGNAL_ACTIVE on fill.

A failed confirmation attempt on a 2nd-or-later touch does not reset the touch count or revert to WAITING_FOR_SECOND_TOUCH — the range simply waits for the next independent touch to try again (status stays SECOND_TOUCH_DETECTED, now legitimately persisting across bars rather than being a one-bar transient). When a range is newly selected, its touch history is seeded once, in a single bounded pass, over [range_info.window_start_index, current_bar] — the same window the range was itself validated over — so touches that happened before this particular range instance was selected, but are still within its own validated window (and therefore still visible on a chart drawing the full history against the frozen bands), are not invisible to the tracker. Scan/ diagnose output reports both lower_touch_count (the range's own validation- time pivot count, from range_detector.py, unrelated and untouched) and independent_lower_touch_count (this canonical, live retest-progression count) side by side — they describe different things and can legitimately differ; neither is hidden.

confirmation_mode gates what's required at the second touch:

  • basic: bullish rejection candle (close > open, closes back at/above the zone, rejection strength ≥ 0.5).
  • standard: basic + reclaim of the lower band + a bullish CHoCH or BOS.
  • strict: standard + a liquidity sweep below the band + volume confirmation.

Entry, stop, targets, sizing (src/strategy/risk_management.py)

  • Entry (default conservative): a stop-entry armed at rejection_candle_high + entry_buffer_ticks, filled on the first later bar whose high breaks it — using that bar's own open if it gapped above the trigger (never a synthetic/unreachable fill). aggressive mode instead fills at the next bar's open. Either way, the earliest possible fill is the bar after the signal (confirmation) candle — daily signals are only actionable once the signal candle has closed.
  • Stop: min(sweep_low, lower_band_low) - stop_buffer_atr * ATR if a sweep occurred, else lower_band_low - stop_buffer_atr * ATR.
  • Targets: TP1 = range midpoint, always. TP2 = upper-band center only when the upper band is "confirmed" (>=2 touches); if it's only "anchored" or "provisional", the upper band isn't confirmed enough to stake a full take-profit on, so TP2 = entry_price + preferred_risk_reward * risk_per_share (a fixed risk/reward target) instead — see risk_management.py::compute_targets.
  • Position size: floor(account_equity * risk_per_trade / risk_per_share), capped by max_position_value_pct. Non-positive risk-per-share always yields "no trade" (None), never a synthetic size.
  • Setups below minimum_risk_reward are not rejected outright — they're flagged with a warning and the R:R-quality scoring component (5/100) marks them down, so the scanner still surfaces them for review with full transparency rather than silently hiding them.

Scoring (src/strategy/scoring.py)

0-100, explainable via reasons/warnings lists on every Signal: range quality 25, lower-band quality 20, second-retest quality 20, liquidity sweep 15, volume confirmation 5, bullish CHoCH/BOS 10, risk/reward quality 5. Candidates that haven't reached SIGNAL_ACTIVE yet are ranked with a comparable but distinct "prospect score" (src/scanner/ranking.py): range quality plus how far the setup has progressed through the state machine, since most of the full rubric (sweep, volume, structure, R:R) isn't knowable before a second touch actually occurs.

Look-ahead bias prevention

See tests/test_no_lookahead.py for the executable proofs; the guarantees:

  1. Bounded evaluation — every indicator/range/state computation for bar i reads at most ohlc.iloc[:i+1]. Range detection and ATR use a fixed context window ending at i (also good for O(n) performance across a multi-year walk-forward instead of O(n²)).
  2. Confirmed pivots onlysmartmoneyconcepts.smc.swing_highs_lows identifies a swing at bar i using swing_length candles on each side, so it's only a genuine, confirmed fact once swing_length bars after i exist. Every pivot carries confirmed_at_index = i + swing_length, and callers only use pivots where that's <= current_index.
  3. Edge-artifact stripping — the smart-money-concepts package unconditionally marks the first and last row of whatever frame it's given as synthetic boundary pivots (see its source). In a walk-forward loop the last row is always "today", so trusting that marker would make yesterday's already-reported range repaint daily. src/indicators/pivots.py always discards these two edge markers before use.
  4. One code path — the scanner (generate_scan_result) and backtester (run_backtest) both call the exact same run_walk_forward state machine, so there is no separate "live" vs "historical" logic that could drift.
  5. Next-bar execution — a signal's entry is never filled on the candle that generated it; the earliest fill is the following bar (see above).
  6. Recorded, not revisited — once a day's state/signal is recorded, later data never rewrites it (the walk only appends forward).

tests/test_no_lookahead.py proves these directly: range detection is identical whether or not the frame has rows after the evaluation index; pivots are unusable before their confirmation index; the first/last row of any window is never trusted as a real pivot; appending future rows leaves already-computed history byte-for-byte identical; entries never fill on the signal candle; and the scanner's "as of T" view matches a fresh walk-forward run up to T.

Backtester

src/backtest/engine.py runs each ticker's state machine from the start of its available history (so state is correctly warmed up before your requested --start date, not artificially cold-started), then merges every ticker's entry signals into one calendar-date event stream and walks a single shared Portfolio forward one day at a time: existing positions are checked for stop/target fills first, then new entries are opened subject to max_open_positions / max_portfolio_exposure_pct / max_positions_per_sector (from config.yaml's risk: section), then equity is marked.

Same-bar stop/target ambiguity: daily OHLC can't tell you whether the stop or the target was touched first within one candle. execution.py resolves this conservatively — the stop-loss is always checked before any take-profit. Gaps: a fill is always the bar's open if that open has already gapped through the level, never the theoretical level price.

Trade log fields: ticker, signal_date, entry_date, entry_price, stop_price, take_profit_1, take_profit_2, exit_date, exit_price, quantity, risk_amount, pnl, return_pct, r_multiple, exit_reason, holding_period.

Metrics: total_trades, winning_trades, losing_trades, win_rate, average_win, average_loss, profit_factor, expectancy, total_return, annualized_return, max_drawdown, average_r_multiple, median_holding_period, sharpe_ratio, sortino_ratio.

Assumptions & limitations

  • history_period must cover your backtest window. The default (3y) comfortably covers the largest default range_windows entry (378 trading days, ~1.5y) plus warmup, but a --start earlier than ~3 years back will silently run over a shorter effective window. Increase data.history_period in config.yaml (e.g. 5y) for longer backtests or larger range_windows.
  • No sector data source is configured, so max_positions_per_sector is accepted but effectively a no-op (sector=None everywhere) until a sector mapping is wired in — the guard function already supports it once available.
  • Earnings-date filtering is not implemented (exclude_upcoming_earnings is not read); this would need an additional data source.
  • Range levels are frozen once confirmed rather than continuously recomputed every bar — a deliberate design choice (see "Design choices" above), but it means a very slowly drifting range could be treated as static until it's re-detected after invalidation/expiry.
  • Both the scanner and the backtester walk each ticker's entire available history (not a bounded recent window) so a touch/reaction/second-touch cycle that started long ago on a large structural window is never "cold started" mid-cycle. Range/pivot computation itself stays O(1) per bar internally (bounded context window inside detect_range), so this stays cheap even over history_period-long histories.
  • Daily bars only; no intraday timeframes are supported.
  • Position sizing/commission/slippage are simplified (fixed commission per trade, fixed slippage in bps, no borrow costs, no partial fills beyond one TP1 partial exit).
  • Recommended cleanup (not applied automatically): strategy.signal_expiry_bars is now unused — entry_validity_bars governs the armed-entry-window -> ENTRY_MISSED transition it used to. It's kept defined so nothing referencing it breaks, but removing it (and any lingering references) would avoid two config knobs describing what's now one concept.

Research disclaimer

Backtest results reflect what happened on this historical data with these parameters — they are not a forecast, and this system does not claim the strategy is profitable. Always treat scan/backtest output as a starting point for further research, not a trading recommendation. This tool never places live orders.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages