o
    0<¤jÉU  ã                
   @   s€  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d3d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#d4d 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 defd/d0„Z)d edefd1d2„Z*dS )5a`  
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):
    Health state's PRIMARY job is still to gate 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()) -- is_eligible_for_entry() and
    is_confirmed_fading() (consulted by position_manager.py's trailing-
    stop fade-confirmation check, but only AT the moment price actually
    touches the stop) are unaffected by the exception below.

    [FEATURE 2026-09-11] One deliberate, narrow exception: an OPEN
    position whose health has been confirmed deteriorating (raw state
    STALE or worse) for intraday_health.exit_confirm_reads consecutive
    evaluations in a row is force-closed by monitor.py's
    _update_intraday_health(), independent of whether price has ever
    touched the trailing stop. Added live, same day, after TJGC sat open
    3+ hours with a degrading, never-recovering STALE health score while
    price stayed inside a tight range and never threatened the stop --
    the fade-confirmation path only ever runs AT a stop touch, so a
    position that just goes quiet and stale without ever approaching the
    stop had no exit path at all. See should_force_exit_on_deterioration()
    below; gated by intraday_health.exit_on_deterioration_enabled
    (default on).

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.   ú./var/www/screener/premarket/intraday_health.pyr   P   s   
 r   ç        ç      Y@c                 C   s   t |t|| ƒƒS )N)ÚmaxÚmin)ÚxÚloÚhir.   r.   r/   Ú_clamp`   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_seriesd   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_severeq   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_health€   sè   
û




ÿÿ$ýý
þÿ
ÿ
÷.ÿÿþÿùr–   FÚreadingÚ	persistedr•   c                 C   sÂ  t ƒ d }| dd¡}| dd¡}| dd¡}| dd¡}| jdv }| jd	k}	| jd
v}
|r2|d nd}|	r:|d nd}|
rB|d nd}|}|dkrMd}n@|rRd}n;|r]||d kr]d}n0|	rh||d krhd	}n%| jdkrt|d
v rtd}n| jdkr€|dkr€d}n| jdkr�|d	kr�|	s�d}|	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Úconsecutive_deteriorating)r   r   ©r   r   r:   r   r   Úunhealthy_confirm_readsÚhealthy_confirm_readsr   Ú"remove_after_consecutive_unhealthyz	[HEALTH] Ú z -> z (score=z, price_slope=z, vwap_slope=ú))	r™   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›   rœ   Úis_bad_readÚis_good_readÚis_deteriorating_readÚ	new_stater.   r.   r/   Úupdate_health_state+  sb   



ÿÿ
þ÷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_symbol{  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_state�  s   

€r½   r   c                 C   s   | dv S )aU  HEALTHY and WATCH are still tradeable; STALE/UNHEALTHY/REMOVED are not
    NEW-entry eligible. An existing open position in a STALE/UNHEALTHY
    symbol is otherwise left alone by this module -- that's
    position_manager.py's call, not this module's -- except for the one
    narrow force-exit case in should_force_exit_on_deterioration().r�   r.   )r   r.   r.   r/   Úis_eligible_for_entry¯  s   r¾   r²   c                 C   s(   |  dd¡sdS |   dd¡|  dd¡kS )a’  
    [FEATURE 2026-09-11] Consulted by monitor.py's _update_intraday_
    health() for symbols with an OPEN position, right after that
    symbol's new_entry (this evaluation's post-hysteresis persisted
    state, from update_health_state()/evaluate_symbol()) is computed.
    True once consecutive_deteriorating has reached
    cfg["exit_confirm_reads"] -- the same "N confirmations in a row"
    hysteresis pattern this module already uses everywhere else
    (healthy_confirm_reads / unhealthy_confirm_reads), applied here to
    a standing open position instead of pool eligibility. See this
    module's docstring for why this one exception exists.
    Úexit_on_deterioration_enabledTFrœ   r   Úexit_confirm_readsrJ   )r¤   )r²   rD   r.   r.   r/   Ú"should_force_exit_on_deterioration¸  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.   r/   Ú<module>   sB    <4

ÿÿ
ÿ ,Pÿ
ÿ"	