o
    Ö·ªjK  ã                   @   sR  d Z ddlZddlmZmZmZ ddlmZ ddlmZ ddl	m
Z
 ddlmZ ddlmZ ddlZddlZed	ƒZd
efdd„Zd
efdd„Z		d,dedededed
ef
dd„Zded
efdd„Z			d-dedededededed
efdd„Zed kr¥eƒ ZeD ]#Zeed! d"›d#ed$ d%›d&ed' d( d)›d*ed' d+ d)›�ƒ qƒdS dS ).u=  
premarket_scanner.py

Runs at ~09:00 ET. Scans the tradable universe ($5-$15, liquid enough)
and produces a ranked list of candidates using scorer.py. Called by
monitor.py, but also runnable standalone for testing:

    python premarket_scanner.py

It does NOT buy anything â€” this module only discovers and ranks.
é    N)ÚdatetimeÚ	timedeltaÚtimezone)Ú
get_config)Ú
get_logger)Ú
get_client)Úscore_premarket_candidate)ÚatrÚpremarket_scannerÚreturnc              
   C   sJ   g }| D ]}|  |jt|jƒt|jƒt|jƒt|jƒt|jƒdœ¡ q|S )N)ÚtÚoÚhÚlÚcÚv)ÚappendÚ	timestampÚfloatÚopenÚhighÚlowÚcloseÚvolume)ÚbarsÚoutÚb© r   úpremarket_scanner.pyÚ_bars_to_dicts   s   
þr   c                    s€   t ƒ d }| d¡r|d S |  ¡ }t| dg ¡ƒ‰ ‡ fdd„|D ƒ}t|ƒ}tj|dd�}t d|t|ƒ › d	|› d
�¡ |S )až  
    Pulls active, tradable US equities and applies:
      1. the include/exclude_symbols lists from config
      2. ETF/fund/trust exclusion (symbol_filters.is_etf_or_fund)
      3. leveraged/inverse ("multiplier") product exclusion
      4. company-name syllable-count cap

    Alpaca's asset_class=US_EQUITY includes ETFs/ETNs, so filters 2-4
    are done on the asset's `name` field via symbol_filters.py.
    ÚuniverseÚinclude_only_symbolsÚexclude_symbolsc                    s&   g | ]}t |d dƒr|jˆ vr|‘qS )ÚtradableF)ÚgetattrÚsymbol)Ú.0Úa©Úexcludedr   r   Ú
<listcomp>7   s   & z(get_universe_symbols.<locals>.<listcomp>T)Úlog_rejectionszC[PREMARKET] Symbol filters (ETF/leveraged/name-complexity) removed z of ú candidates)	r   ÚgetÚget_tradable_assetsÚsetÚlenÚsymbol_filtersÚfilter_symbol_listÚlogÚinfo)ÚclientÚcfgÚassetsÚ
candidatesÚbeforeÚ	survivorsr   r(   r   Úget_universe_symbols%   s   


ÿ
ÿr;   ÚsymbolsÚbaseline_outÚprev_day_high_outÚprev_close_outc              
   C   s^  t ƒ d }t ƒ d }g }|  |¡}|s|S | ¡ D ]“\}	}
z„|
j}|
j}|du r*W qt|jƒ}|d |  kr=|d ks@n W q|rGt|jƒnd}|rU||d d k rUW qt|
d	dƒ}|durw|jrwt|jƒ}||d k roW q|durw|||	< |dur‰|dur‰|j	r‰t|j	ƒ||	< |dur›|dur›|j
r›t|j
ƒ||	< | |	¡ W q ttfy¬   Y qw |S )
aÙ  
    Cheap first pass using snapshot data to cut the universe down to a
    manageable set before pulling minute bars for full scoring. Filters
    on price band and a basic volume floor.

    baseline_out: [Phase 5 -- 2026-09-08, BUGFIXED 2026-09-15] if a dict is
        passed, this function populates it with {symbol: real_prior_day_
        volume} as a side effect (mutated in place, return value
        unchanged). Originally sourced from `snap.daily_bar.volume` --
        that was itself a fix for an older bug (every RVOL calculation
        comparing against the same flat universe.min_avg_daily_volume
        constant regardless of a symbol's real normal volume) but
        introduced a worse one: `daily_bar` is the CURRENT session's bar,
        which Alpaca already starts accumulating from 4:00am ET extended
        hours -- so by the ~09:00 scan time it's not a historical
        baseline at all, it's just today's premarket volume so far. A few
        minutes later scan() sums those same premarket bars into
        total_volume, so RVOL ended up comparing today's premarket volume
        to itself (~1.0x for nearly everything, real catalyst or not),
        silently killing the scanner's main volume-interest signal from
        2026-09-08 onward. Now sourced from `snap.previous_daily_bar.
        volume` instead (the prior COMPLETE session's real volume) --
        same fix pattern already used for prev_day_high_out below. Omit
        baseline_out (the default) to reproduce this function's exact
        pre-Phase-5 behavior -- callers that don't pass it are completely
        unaffected.

    prev_day_high_out: [FEATURE 2026-09-11, attribute name BUGFIXED
        2026-09-15] same pattern as baseline_out above, for
        fast_prediction_engine.py's resistance-context layer. Populated
        with {symbol: previous session's high} from Alpaca's snapshot --
        reads snap.previous_daily_bar (unambiguous, always "the prior
        completed session") rather than snap.daily_bar (which silently
        shifts to mean TODAY once regular hours start -- see baseline_
        out's history above for what trusting daily_bar here actually
        costs). Originally read via getattr(snap, "prev_daily_bar", None)
        -- alpaca-py's actual Snapshot field is `previous_daily_bar`, so
        that getattr's default silently fired every single time and this
        was empty from the day it shipped, with nothing to surface the
        typo since a missing value here fails soft by design (see below).
        Purely additive/optional context, never a required dependency --
        per explicit instruction, its absence must never block or reject
        a symbol; fast_prediction_engine.py falls back to premarket high/
        session high/detected support-resistance when this is missing.

    prev_close_out: [FEATURE 2026-09-16] same pattern as prev_day_high_out
        above -- {symbol: previous session's close} for breakout_scanner.
        py's gap_pct (needs the prior close as its denominator; unlike
        prev_day_high_out, breakout_scanner.py treats a missing value here
        as "can't score this symbol" rather than failing open, since gap%
        is a required, weighted input there -- see breakout_scanner.scan()).
    r    Úpremarket_scoringNÚ	price_minÚ	price_maxr   Úmin_avg_daily_volumegš™™™™™©?Úprevious_daily_bar)r   Úget_snapshotsÚitemsÚlatest_tradeÚ	daily_barr   Úpricer   r$   r   r   r   ÚAttributeErrorÚ	TypeError)r5   r<   r=   r>   r?   r6   Úscoring_cfgr:   Ú	snapshotsr%   ÚsnaprG   rH   rI   Útoday_vol_so_farÚprev_barÚprev_volumer   r   r   Úprefilter_by_snapshot?   sD   
6



ÿrR   c                 C   s¸   t ƒ d }| dd¡}t tj¡}|t|d d d� }|  |||¡}i }| ¡ D ]0\}}	|	| d… }	t	|	ƒdk r;q)|	d d	 }
|
sDq)t
|	td
t	|	ƒd ƒd�}||
 d ||< q)|S )a»  
    [FEATURE 2026-09-15] {symbol: trailing-N-day average daily true
    range, as a % of price} -- one bulk multi-symbol daily-bars call
    (get_daily_bars_bulk, chunked) instead of one REST call per symbol.
    Feeds scorer.py's daily_atr_pct / universe.min_daily_atr_pct floor.

    Uses DAILY bars specifically, not the 1-min premarket/intraday bars
    already being pulled elsewhere -- real-world calibration (2026-09-15)
    found per-1-minute-bar ATR% does NOT separate a structurally dead
    BDC/SPAC from a real momentum name (both read ~0.1-0.3%, dominated
    by bar-granularity noise); only the multi-day range history does
    (WHF 2.23%, APXT 0.09% vs. 4.3%-21.9% for real same-day candidates).

    A symbol absent from the result (too new, delisted, or the request
    failed) is simply missing -- callers must treat that as "unknown,"
    never as "confirmed low volatility" (see scorer.py's daily_atr_pct
    fail-open contract).
    r    Úvolatility_lookback_daysé   é   é
   )ÚdaysNéÿÿÿÿr   é   é   )Úperiodg      Y@)r   r-   r   Únowr   Úutcr   Úget_daily_bars_bulkrF   r0   r	   Úmin)r5   r<   r6   Úlookback_daysr\   ÚstartÚ
daily_barsr   r%   r   rI   Úatr_valr   r   r   Úcompute_volatility_baseline¨   s    
rd   é   Úcandidate_countÚprefilteredÚlookback_hoursÚvolume_baselinesÚprev_day_highsÚvolatility_baselinesc                 C   sz  t ƒ }| p
|d d } tƒ }t d¡ |du r5t|ƒ}t dt|ƒ› �¡ t||ƒ}t dt|ƒ› d�¡ t t	j
¡}	|	t|d� }
g }g }|D ]Ú}t| ||
|	¡ƒ}t|ƒd	k rht d
|› dt|ƒ› d�¡ qHtdd„ |D ƒƒ}tdd„ |D ƒƒ}| |¡}|r‰|jr‰t|jƒnd}|r•|jr•t|jƒnd}|pši  |¡p¤t ƒ d d }|p¨i  |¡}t||||||||d�}||d< ||d< |pÃi  |¡|d< |d  dd¡sÛt d
|› d�¡ qH|d  dd¡sít d
|› d�¡ qH|d  dd¡sÿt d
|› d�¡ qH|d  dd¡�st d
|› d �¡ | |¡ qH| |¡ t ||¡ qHt ƒ  d!i ¡}| d"d¡�r‡|�r‡| d#d$¡}t|ƒ|k �r‡|jd%d&„ dd'� |t|ƒ }|d|… }|D ]}d|d d(< | |¡ t |d) |¡ �qXt d*t|ƒt|ƒ › d+t|ƒ› d,t|ƒ› �¡ |jd-d&„ dd'� |d| … }|D ]}t d
|d) › d.|d/ › �¡ �q˜t d0t|ƒ› d1�¡ t |¡ |S )2a|  
    Full premarket scan. Returns a ranked list of scored candidate dicts,
    truncated to `candidate_count` (defaults to config's
    premarket_candidate_count, i.e. 20).

    [FEATURE 2026-08-17] `prefiltered` lets a caller (monitor.py) supply
    an already-computed universe+snapshot-filtered symbol list instead
    of this function pulling and filtering the full tradable-asset list
    again. monitor.py caches this list once at 09:00 and reuses it for
    every subsequent 30-minute intraday full rescan, so the expensive
    get_tradable_assets() + bulk snapshot call only ever happens once
    per day rather than once per rescan. `lookback_hours` defaults to 6
    (covers the overnight/premarket session) but monitor.py's intraday
    rescans pass a much shorter window (intraday_health.
    full_rescan_lookback_hours, default 1) since a 6-hour window is
    unnecessary once the regular session is already underway.

    volume_baselines: [Phase 5 -- new, optional] {symbol: real_prior_day_
        volume} from prefilter_by_snapshot()'s baseline_out -- when a
        symbol has a real entry here, its own historical volume is used
        for RVOL instead of the flat universe.min_avg_daily_volume
        constant. Omit (the default) to reproduce this function's exact
        pre-Phase-5 behavior.

    prev_day_highs: [FEATURE 2026-09-11] {symbol: previous session's high}
        from prefilter_by_snapshot()'s prev_day_high_out -- threaded
        through to each result dict as result["prev_day_high"] for
        fast_prediction_engine.py's resistance context. Optional, same
        fail-soft contract as volume_baselines above.

    volatility_baselines: [FEATURE 2026-09-15] {symbol: trailing-N-day
        avg daily ATR%} from compute_volatility_baseline() -- feeds
        scorer.py's daily_atr_pct floor (universe.min_daily_atr_pct),
        which hard-rejects structurally low-volatility names (BDCs,
        pre-merger SPACs) below. A symbol missing from this dict is
        "unknown," not "confirmed low volatility" -- never rejected on
        that basis alone, same fail-open contract as the other two
        baseline dicts above.
    r8   Úpremarket_candidate_countz[PREMARKET] Starting scanNz1[PREMARKET] Universe size after asset filtering: z[PREMARKET] Prefiltered to z symbols in price/volume band)Úhoursé   z[PREMARKET] z" rejected: insufficient bar data (z bars)c                 s   ó   � | ]}|d  V  qdS )r   Nr   ©r&   r   r   r   r   Ú	<genexpr>  ó   € zscan.<locals>.<genexpr>c                 s   ro   )r   Nr   rp   r   r   r   rq     rr   r    rC   )Údaily_atr_pctÚpm_highÚpm_lowÚprev_day_highÚflagsÚmeets_min_volumeFz% rejected: below min premarket volumeÚmeets_min_volatilityTzL rejected: below min daily ATR% (structurally low volatility, e.g. BDC/SPAC)Ú	spread_okz rejected: spread too wideÚmeets_min_rvolz rejected: below min RVOLÚrvol_starvation_fallbackÚenabledÚmin_candidatesé   c                 S   ó   | d S ©NÚtotal_scorer   ©Úrr   r   r   Ú<lambda>a  ó    zscan.<locals>.<lambda>)ÚkeyÚreverseÚrvol_floor_waivedr%   z+[PREMARKET] RVOL starvation fallback: only z+ candidate(s) cleared min_rvol; backfilled z1 more by score (rvol_floor_waived=True) to reach c                 S   r€   r�   r   rƒ   r   r   r   r…   o  r†   ú score=r‚   z[PREMARKET] Selected r,   )r   r   r3   r4   r;   r0   rR   r   r\   r   r]   r   r   Úget_minute_barsÚdebugÚmaxr_   Úget_latest_quoteÚ	bid_pricer   Ú	ask_pricer-   r   r   Ú
data_storeÚappend_premarket_snapshotÚsortÚwarningÚwrite_premarket_candidates)rf   rg   rh   ri   rj   rk   r6   r5   r    r\   ra   ÚscoredÚrvol_near_missr%   r   rt   ru   ÚquoteÚbidÚaskÚavg_vol_baseliners   ÚresultÚfallback_cfgr~   ÚneededÚ
backfilledr„   Útopr   r   r   ÚscanÑ   s’   *


ÿ


ÿþýÿ"
r¡   Ú__main__r%   Ú6srŠ   r‚   z6.2fz price=ÚmetricsrI   z.2fz rvol=Úrvol)NNN)NNre   NNN)Ú__doc__Úsysr   r   r   Úconfig_loaderr   Úlogger_setupr   Úalpaca_clientr   Úscorerr   Ú
indicatorsr	   r1   r‘   r3   Úlistr   r;   ÚdictrR   rd   Úintr   r¡   Ú__name__Úresultsr„   Úprintr   r   r   r   Ú<module>   s\    
ÿÿÿ
ÿi)þÿÿþ
þ *ÿ
ÿý