o
    ,A–jùK  ã                
   @   sj  d Z ddlmZmZ ddlmZmZ ddlmZ ddlm	Z	 ddl
mZmZmZmZmZmZmZmZmZmZmZ ddlZddlZe	dƒZg d	¢ZeG d
d„ dƒƒZd0dd„Zdedefdd„Zdededede def
dd„Z!de"dededefdd„Z#d1d ed!e d"ede fd#d$„Z$de"deded%e de%f
d&d'„Z&d(e de fd)d*„Z'd+e"defd,d-„Z(d edefd.d/„Z)dS )2aN	  
intraday_health.py

[FEATURE 2026-08-17]

Continuously evaluates every symbol in premarket_20 during the trading
day to answer one question: "is this stock still behaving like a
healthy intraday candidate?" -- independent of whether the bot
currently holds a position in it.

CRITICAL SEPARATION OF RESPONSIBILITIES (do not blur this):
    This module NEVER closes a position and is never consulted by
    position_manager.py's exit logic. Health state only affects which
    symbols are ELIGIBLE to be picked as a NEW entry or slot-replacement
    candidate (see top_stocks.find_replacement() and monitor.py's
    _scan_for_entries()). An existing open position is managed
    exclusively by position_manager.py / risk_manager.py per the
    project's exit rules (trailing stop / end-of-day), regardless of
    what this module reports about that symbol.

Health states, best to worst:
    HEALTHY   - positive/supported slope, volume participation, healthy
                structure (higher highs / higher lows)
    WATCH     - has weakened but not yet confirmed stale/unhealthy
    STALE     - flat/low-participation; NOT necessarily unhealthy, just
                quiet (a stock can stay healthy while consolidating).
                [2026-08-20] Also reached when price_slope is still
                positive but momentum is decelerating AND at least one
                other weak signal corroborates (volume declining,
                lower-highs/lower-lows, or price already lost VWAP) --
                a fading bounce that hasn't fully broken down yet, but
                isn't confirming fresh strength either.
    UNHEALTHY - negative slope + VWAP lost + deteriorating structure
                (lower highs / lower lows)
    REMOVED   - confirmed UNHEALTHY for enough consecutive evaluations
                in a row; dropped from the active candidate pool until
                the next full rescan brings a fresh premarket_20

State transitions require hysteresis (config: intraday_health.*) so a
single noisy bar can't flip a symbol's state back and forth. A "severe"
reading (see _is_severe()) is the one deliberately-documented exception:
it can demote a symbol to UNHEALTHY immediately, skipping the normal
confirm-count requirement, but it still cannot jump straight to REMOVED
without going through the same consecutive-unhealthy count everyone
else does.
é    )Ú	dataclassÚfield)ÚdatetimeÚtimezone)Ú
get_config)Ú
get_logger)ÚvwapÚvwap_seriesÚrelative_volumeÚvolume_accelerationÚis_higher_highs_higher_lowsÚis_lower_highs_lower_lowsÚconsolidation_tightnessÚdistance_from_high_pctÚatrÚnormalized_slope_pctÚclassify_slopeNÚintraday_health)ÚHEALTHYÚWATCHÚSTALEÚ	UNHEALTHYÚREMOVEDc                   @   s‚   e Zd ZU eed< eed< eed< eed< eed< eed< eed< eed< eed	< eed
< eed�Zeed< eed�Z	eed< dS )ÚHealthReadingÚsymbolÚhealth_scoreÚ	raw_stateÚconfirmed_stateÚprice_slope_pctÚvwap_slope_pctÚmomentum_slope_pctÚprice_slope_classÚvwap_slope_classÚmomentum_slope_class)Údefault_factoryÚ	breakdownÚflagsN)
Ú__name__Ú
__module__Ú__qualname__ÚstrÚ__annotations__Úfloatr   Údictr%   r&   © r.   r.   úK/var/www/screener/trade/premarket_backup_2026-09-08_2010/intraday_health.pyr   C   s   
 r   ç        ç      Y@c                 C   s   t |t|| ƒƒS )N)ÚmaxÚmin)ÚxÚloÚhir.   r.   r/   Ú_clampS   s   r7   ÚclosesÚreturnc                 C   sF   g }t dt| ƒƒD ]}| |d  }|r | | | | | d ¡ q	|S )a  Bar-over-bar % returns -- the input to the momentum-slope
    regression. Whether *momentum itself* is accelerating or decaying
    is a different question from whether price is rising, which is
    exactly why this gets its own slope rather than reusing price.é   r1   )ÚrangeÚlenÚappend)r8   ÚoutÚiÚprevr.   r.   r/   Ú_momentum_seriesW   s   €rA   r   Úprice_above_vwapÚlower_hlÚcfgc                 C   s   | |d  ko| o|S )a§  
    A deliberately narrow "skip the confirm count" exception, per the
    project's explicit instruction: single-bad-tick churn should be
    avoided EXCEPT for a genuinely severe risk condition. Severe here
    means: sharply negative regression slope, price already lost VWAP,
    AND the bar structure itself is confirming lower-highs/lower-lows
    -- three independent signals agreeing, not just one noisy print.
    Úsevere_negative_slope_pctr.   )r   rB   rC   rD   r.   r.   r/   Ú
_is_severed   s
   
ÿþrF   r   ÚbarsÚavg_vol_baselinec           .      C   sX  t ƒ d }t|d t|ƒƒ}t|ƒdk r%t| dddddddddddid	�S || d
… }dd„ |D ƒ}dd„ |D ƒ}|d }t|ƒ}	t|ƒ| d
… }
t|ƒ}t|
ƒ}t|ƒdkr_tt|ƒƒnd}|d }t||ƒ}t||ƒ}t||ƒ}||	k}t|d t|ƒƒ}t	||d�}t
||d�}t|ƒ}t||ƒ}t|ƒ}tdd„ |D ƒƒ}t||ƒ}t|td|d ƒd�}|d
td|d ƒ … pÀ|}t|td|d ƒd�}|dkoÛ|| | d |d k}t|t|d tdt|ƒd ƒƒd�}|d } i }!| d |dkrýdn|dk�r|d n|d   |!d< |�o|d!v }"| d" |�r!|dk�r!dn
|"�r(|d# n|d$  |!d"< ||d% k }#|d& }$| d' t|#�sD||$ n||$ |d(  ddƒ |!d'< |�rXd}%n
|�r^d}%n|d) }%| d* |% |!d*< | d+ |�rt|d, nd |!d+< |d- }&| d. t|&| |& ddƒ |!d.< t|! ¡ ƒ}'t|  ¡ ƒ}(tt|'|( d dd/ƒdƒ})|d0k}*||||#|t|dƒt|d1ƒ||d2 k|*d3œ	}+t||||ƒ},|,�rÍd4}-nF|dk�rá|"�rá|#�sá|�sá|*�sád5}-n2|d0k�rï|�sï|�rïd4}-n$|dk�r|#�sÿ|�sÿ|+d6 �rd}-n|*�r|#�s|�s|�sd}-nd7}-t| |)|-|-t|dƒt|dƒt|dƒ||||!|+d8�S )9a  
    Pure evaluation function -- no state, no hysteresis, no file I/O.
    Given a symbol's recent intraday bars, returns this instant's raw
    health reading. update_health_state() is what applies hysteresis on
    top of this to produce the persisted, confirmed state.
    r   Úslope_lookback_barsé   r0   r   ÚflatÚinsufficient_dataT)r   r   r   r   r   r   r    r!   r"   r#   r&   Nc                 S   ó   g | ]}|d  ‘qS )Úcr.   ©Ú.0Úbr.   r.   r/   Ú
<listcomp>‡   ó    z"compute_health.<locals>.<listcomp>c                 S   rM   )Úvr.   rO   r.   r.   r/   rR   ˆ   rS   éÿÿÿÿÚflat_slope_threshold_pctÚstructure_lookback_bars)Úlookbackc                 s   s   � | ]}|d  V  qdS )ÚhNr.   rO   r.   r.   r/   Ú	<genexpr>    s   € z!compute_health.<locals>.<genexpr>é   r   r1   Ústale_range_contraction_pctÚ
atr_periodr:   )ÚperiodÚweightsÚprice_slopeÚpositiveg      ð?Úprice_slope_flat_creditÚprice_slope_negative_credit)ra   rK   Úvwap_supportÚvwap_support_partial_creditÚvwap_support_weak_creditÚstale_volume_decline_ratioÚ volume_participation_rvol_targetÚvolume_participationÚ!volume_declining_score_multiplierÚstructure_mixed_creditÚ	structureÚrange_expansionÚ"range_contracting_score_multiplierÚ!max_distance_from_recent_high_pctÚdistance_from_recent_highéd   Únegativeé   Úno_new_highs_threshold_pct)	rB   Úhigher_highs_higher_lowsÚlower_highs_lower_lowsÚvolume_decliningÚrange_contractingÚrvolr   Úno_new_highsÚmomentum_fadingr   r   rz   r   )r   r   r   r   r   r   r    r!   r"   r#   r%   r&   )r   r3   r<   r   r   r	   r   rA   r   r   r   Úsumr
   r   r2   r   r   r   r7   ÚvaluesÚroundrF   ).r   rG   rH   rD   rX   Úwindowr8   ÚvolumesÚcurrent_priceÚv_wapÚvwap_serr   r   r    Úflat_threshr!   r"   r#   rB   Ústruct_lookbackÚhh_hlrC   Útotal_volumery   Ú	vol_accelÚrecent_highÚdist_from_highÚtight_recentÚtight_earlier_barsÚtight_earlierrx   ÚaÚwr%   rd   rw   Úrvol_targetÚstructure_creditÚmax_distÚtotalÚmax_possibler   r{   r&   Úseverer   r.   r.   r/   Úcompute_healths   sè   
û




ÿÿ$ýý
þÿ
ÿ
÷.ÿÿþÿùr–   FÚreadingÚ	persistedr•   c           
      C   sš  t ƒ d }| dd¡}| dd¡}| dd¡}| jdv }| jdk}|r'|d	 nd}|r/|d	 nd}|}	|d
kr:d
}	n@|r?d}	n;|rJ||d krJd}	n0|rU||d krUd}	n%| jdkra|dv rad}	n| jdkrm|dkrmd}	n| jdkrz|dkrz|szd}	|r„||d kr„d}	|	dkr˜|dks�|d
kr˜||d kr˜d
}	|	|kr¸t d| j› d|› d|	› d| j› d| j› d| j› d�¡ |	||| j| j| j| j	t
 tj¡ ¡ dœS )a  
    Applies hysteresis on top of a single raw reading. `persisted` is
    this symbol's existing entry from state/intraday_health.json (a
    fresh {} if never seen before). Returns the new persisted entry;
    caller is responsible for saving it back via data_store.
    r   Ústater   Úconsecutive_badr   Úconsecutive_good)r   r   r:   r   r   Úunhealthy_confirm_readsÚhealthy_confirm_readsr   ©r   r   Ú"remove_after_consecutive_unhealthyz	[HEALTH] Ú z -> z (score=z, price_slope=z, vwap_slope=ú))r™   rš   r›   r   r!   r"   r#   Ú
updated_at)r   Úgetr   ÚlogÚinfor   r   r!   r"   r#   r   Únowr   ÚutcÚ	isoformat)
r—   r˜   r•   rD   Ú
prev_staterš   r›   Úis_bad_readÚis_good_readÚ	new_stater.   r.   r/   Úupdate_health_state  sZ   


ÿÿ
þør­   Úpersisted_statec           	      C   sf   t ƒ d }t| ||ƒ}t|j|j dd¡|j dd¡|ƒ}| | i ¡}t|||d�}|d |_||fS )a  
    Convenience wrapper: compute this instant's reading, apply
    hysteresis against the persisted state for this symbol, and return
    (HealthReading with confirmed_state filled in, new_persisted_entry).
    Does not touch disk -- caller batches the save.
    r   rB   Frv   )r•   r™   )r   r–   rF   r   r&   r£   r­   r   )	r   rG   rH   r®   rD   r—   r•   ÚentryÚ	new_entryr.   r.   r/   Úevaluate_symbold  s   
ÿ
r±   Úhealth_statec                 C   s|   t  ¡  ¡ }g }|  ¡ D ]\}}| d¡}|r!t  t |¡¡|kr&| |¡ q|D ]}| |= q)|r<t	 
dt|ƒ› d�¡ | S )a“  
    [BUGFIX 2026-08-31] state/intraday_health.json is loaded wholesale at
    every call site (monitor.py's _scan_for_entries(), _update_intraday_
    health(), _run_intraday_full_rescan()) with no date filtering, so
    entries from prior sessions pile up indefinitely (confirmed live:
    entries going back to 2026-08-18 still present). update_health_state()
    above assumed a symbol reappearing in premarket_20 would only ever see
    its OWN history via that same-session round-trip and would "get a
    clean slate at the next full rescan" (see the REMOVED-stickiness
    comment) -- but a full rescan only rebuilds premarket_20, it never
    touches this dict, so a symbol that returns to premarket_20 days later
    inherits its old consecutive_bad/consecutive_good counters and, worse,
    a sticky REMOVED/UNHEALTHY state from a session that has nothing to do
    with today, instead of starting fresh at WATCH.

    Called once at session startup (see monitor.py's SessionOrchestrator.
    __init__) so every symbol's hysteresis state actually starts clean for
    the day, same fix pattern as position_manager.py's
    _prune_stale_closed_positions().
    r¢   z[STARTUP] Pruned z0 stale health-state entr(y/ies) from a prior day)Úmarket_timeÚnow_etÚdateÚitemsr£   Úet_dater   Úfromisoformatr=   r¤   r¥   r<   )r²   ÚtodayÚstaler   r¯   r¢   r.   r.   r/   Úprune_stale_health_statev  s   

€r»   r   c                 C   s   | dv S )a  HEALTHY and WATCH are still tradeable; STALE/UNHEALTHY/REMOVED are not
    NEW-entry eligible (but, per this module's core rule, an existing open
    position in a STALE/UNHEALTHY symbol is left alone -- that's
    position_manager.py's call, not this module's).rž   r.   )r   r.   r.   r/   Úis_eligible_for_entry˜  s   r¼   c                 C   s
   | j dkS )aà  
    [FEATURE 2026-08-18] Used by position_manager.py's trailing-stop
    fade-confirmation check (config: stop.fade_confirmation). Reuses the
    exact same UNHEALTHY classification compute_health() already applies
    everywhere else in the system (negative price slope AND VWAP lost
    AND confirmed lower-highs/lower-lows structure, OR the severe-
    override condition) -- deliberately not a separate, looser
    definition of "fading" invented just for the exit path.
    r   )r   )r—   r.   r.   r/   Úis_confirmed_fading   s   

r½   )r0   r1   )F)*Ú__doc__Údataclassesr   r   r   r   Úconfig_loaderr   Úlogger_setupr   Ú
indicatorsr   r	   r
   r   r   r   r   r   r   r   r   Ú
data_storer³   r¤   ÚSTATES_BEST_TO_WORSTr   r7   ÚlistrA   r,   Úboolr-   rF   r*   r–   r­   Útupler±   r»   r¼   r½   r.   r.   r.   r/   Ú<module>   s@    /4

ÿÿ
ÿ ,Fÿ
ÿ"