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).
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 tickersNYSE— ~2,713 tickersAMEX— ~293 tickersALL_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.
python3 -m venv .venv # if you don't already have one
source .venv/bin/activate
pip install -r requirements.txtTested 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/.
# 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--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 setupswaiting_for_second_touch— Show setups waiting for confirming second touchboth— Show both status types
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— fromdiagnose, 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_chartsinconfig.yaml) or an explicitchartcall.
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):
- Structural range (
structural_*columns) — the broader, context-only range fromselect_best_range's largest validated window. Never the setup itself. - Active/local range (
active_*columns, plusrange_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_statusisVALID/INVALIDATED/NONE, independent ofsetup_statusbelow (a range can be valid with no complete setup yet). - 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 reachesSIGNAL_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.
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).
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:
- Confirmed (→
range_classification = "CONFIRMED_RANGE") — the highest-priced swing-high cluster with>= minimum_upper_band_touchesspaced touches (same logic as the lower band). - 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 leastminimum_rotation_fractionof 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 than0.5 * minimum_range_width_atr * ATRabove 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. - 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.
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 * ATRbeyond a band) or with no recent interaction (no touch of either band or the midpoint within the lastrecent_interaction_barsbars). 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.
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.)
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
INVALIDATEDabove); "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.
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 (default
conservative): a stop-entry armed atrejection_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).aggressivemode 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 * ATRif a sweep occurred, elselower_band_low - stop_buffer_atr * ATR. - Targets:
TP1 = range midpoint, always.TP2 = upper-band centeronly 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, soTP2 = entry_price + preferred_risk_reward * risk_per_share(a fixed risk/reward target) instead — seerisk_management.py::compute_targets. - Position size:
floor(account_equity * risk_per_trade / risk_per_share), capped bymax_position_value_pct. Non-positive risk-per-share always yields "no trade" (None), never a synthetic size. - Setups below
minimum_risk_rewardare 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.
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.
See tests/test_no_lookahead.py for the executable proofs; the guarantees:
- Bounded evaluation — every indicator/range/state computation for bar
ireads at mostohlc.iloc[:i+1]. Range detection and ATR use a fixed context window ending ati(also good for O(n) performance across a multi-year walk-forward instead of O(n²)). - Confirmed pivots only —
smartmoneyconcepts.smc.swing_highs_lowsidentifies a swing at bariusingswing_lengthcandles on each side, so it's only a genuine, confirmed fact onceswing_lengthbars afteriexist. Every pivot carriesconfirmed_at_index = i + swing_length, and callers only use pivots where that's<= current_index. - Edge-artifact stripping — the
smart-money-conceptspackage 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.pyalways discards these two edge markers before use. - One code path — the scanner (
generate_scan_result) and backtester (run_backtest) both call the exact samerun_walk_forwardstate machine, so there is no separate "live" vs "historical" logic that could drift. - 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).
- 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.
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.
history_periodmust cover your backtest window. The default (3y) comfortably covers the largest defaultrange_windowsentry (378 trading days, ~1.5y) plus warmup, but a--startearlier than ~3 years back will silently run over a shorter effective window. Increasedata.history_periodinconfig.yaml(e.g.5y) for longer backtests or largerrange_windows.- No sector data source is configured, so
max_positions_per_sectoris accepted but effectively a no-op (sector=Noneeverywhere) until a sector mapping is wired in — the guard function already supports it once available. - Earnings-date filtering is not implemented (
exclude_upcoming_earningsis 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 overhistory_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_barsis now unused —entry_validity_barsgoverns the armed-entry-window ->ENTRY_MISSEDtransition 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.
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.