o
    U<¤j%  ã                
   @   s  d Z ddlZddlm  mZ ddlZddlZddl	Z	ddl
mZmZ ddlmZmZmZ ej dej ej ej e¡¡¡¡ ddlZddlZdd„ ZG dd„ de	jƒZG d	d
„ d
e	jƒZG dd„ dƒZG dd„ dƒZG dd„ de	jƒZedkre	 ¡  dS dS )al  
test_health_deterioration_exit.py

[FEATURE 2026-09-11] Regression/coverage for the new force-exit path
added after TJGC (2026-09-11) sat open for 3+ hours with a degrading,
never-recovering STALE health score while price stayed inside a tight
range and never touched the trailing stop -- the pre-existing
fade-confirmation path only ever runs AT a stop touch (see
position_manager._check_fade_confirmed_exit), so a position that just
goes quiet and stale without ever threatening the stop had no exit path
at all.

Covers:
    1. intraday_health.update_health_state()'s new consecutive_deteriorating
       counter (increments on STALE/UNHEALTHY raw reads, resets on
       HEALTHY/WATCH).
    2. intraday_health.should_force_exit_on_deterioration() threshold /
       disable-flag behavior.
    3. monitor.py's _update_intraday_health() actually triggers
       position_mgr.exit_position(..., "HEALTH_DETERIORATION") once the
       threshold is hit for an OPEN-position symbol, and leaves
       non-open-position symbols and below-threshold symbols alone.

Run:
    python -m pytest tests/test_health_deterioration_exit.py -v
é    N)Ú	MagicMockÚpatch)ÚdatetimeÚ	timedeltaÚtimezonec                 C   s   t jdd| | ddddddd�
S )NÚTJGCg      I@g        Úflat)
Ú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)Úintraday_healthÚHealthReading)r   © r   úC/var/www/screener/premarket/tests/test_health_deterioration_exit.pyÚ_reading(   s   ür   c                   @   s,   e Zd Zdd„ Zdd„ Zdd„ Zdd„ Zd	S )
Ú#TestConsecutiveDeterioratingCounterc                 C   ó6   i }t dƒD ]
}t tdƒ|¡}q|  |d d¡ d S )Né   ÚSTALEÚconsecutive_deteriorating©Úranger   Úupdate_health_stater   ÚassertEqual©ÚselfÚ	persistedÚ_r   r   r   Útest_increments_on_stale_reads2   ó   zBTestConsecutiveDeterioratingCounter.test_increments_on_stale_readsc                 C   r   )Nr   Ú	UNHEALTHYr   r   r!   r   r   r   Ú&test_increments_on_unhealthy_reads_too8   r&   zJTestConsecutiveDeterioratingCounter.test_increments_on_unhealthy_reads_tooc                 C   sX   i }t dƒD ]
}t tdƒ|¡}q|  |d d¡ t tdƒ|¡}|  |d dd¡ d S )Né   r   r   ÚWATCHr   za recovering WATCH read should reset the counter, not just hold it, so a symbol that dips then recovers never gets force-exitedr   r!   r   r   r   Ú$test_resets_on_watch_or_healthy_read>   s   ÿzHTestConsecutiveDeterioratingCounter.test_resets_on_watch_or_healthy_readc                 C   s2   i }dD ]
}t  t|ƒ|¡}q|  |d d¡ dS )zàA symbol bouncing between STALE and UNHEALTHY without ever
        touching WATCH/HEALTHY should still accumulate -- this is exactly
        the TJGC pattern (STALE the whole time, never UNHEALTHY, never
        recovering).)r   r   r   r   r   N)r   r   r   r    )r"   r#   Úrawr   r   r   Ú1test_mixed_state_reads_all_count_as_deterioratingJ   s   zUTestConsecutiveDeterioratingCounter.test_mixed_state_reads_all_count_as_deterioratingN)Ú__name__Ú
__module__Ú__qualname__r%   r(   r+   r-   r   r   r   r   r   1   s
    r   c                   @   s<   e Zd Zdd„ Zdd„ Zdd„ Zdd„ Zd	d
„ Zdd„ ZdS )Ú"TestShouldForceExitOnDeteriorationc                 C   s   dddœ| _ d S )NTr   ©Úexit_on_deterioration_enabledÚexit_confirm_reads)Úcfg©r"   r   r   r   ÚsetUpV   s   z(TestShouldForceExitOnDeterioration.setUpc                 C   ó   |   t ddi| j¡¡ d S )Nr   r)   )ÚassertFalser   Ú"should_force_exit_on_deteriorationr5   r6   r   r   r   Útest_false_below_thresholdY   ó   

ÿz=TestShouldForceExitOnDeterioration.test_false_below_thresholdc                 C   r8   )Nr   r   ©Ú
assertTruer   r:   r5   r6   r   r   r   Útest_true_at_threshold]   r<   z9TestShouldForceExitOnDeterioration.test_true_at_thresholdc                 C   r8   )Nr   é   r=   r6   r   r   r   Útest_true_above_thresholda   r<   z<TestShouldForceExitOnDeterioration.test_true_above_thresholdc                 C   s(   t | jdd�}|  t ddi|¡¡ d S )NF)r3   r   éc   )Údictr5   r9   r   r:   )r"   r5   r   r   r   Ú!test_disabled_flag_short_circuitse   s   
ÿzDTestShouldForceExitOnDeterioration.test_disabled_flag_short_circuitsc                 C   s0   |   t ddii ¡¡ |  t ddii ¡¡ d S )Nr   r   r)   )r>   r   r:   r9   r6   r   r   r   Ú(test_defaults_used_when_cfg_keys_missingj   s   ÿ
ÿzKTestShouldForceExitOnDeterioration.test_defaults_used_when_cfg_keys_missingN)	r.   r/   r0   r7   r;   r?   rA   rD   rE   r   r   r   r   r1   U   s    r1   c                   @   s$   e Zd Zdd„ Zdd„ Zdd„ ZdS )ÚFakePositionManagerc                 C   s   t |ƒ| _g | _d S ©N)ÚlistÚ_openÚ
exit_calls)r"   Úopen_symbolsr   r   r   Ú__init__s   s   

zFakePositionManager.__init__c                 C   s
   t | jƒS rG   )rH   rI   r6   r   r   r   Úget_open_symbolsw   s   
z$FakePositionManager.get_open_symbolsc                 C   s   | j  |||f¡ d S rG   )rJ   Úappend)r"   r	   ÚpriceÚreasonr   r   r   Úexit_positionz   s   z!FakePositionManager.exit_positionN)r.   r/   r0   rL   rM   rQ   r   r   r   r   rF   r   s    rF   c                   @   s   e Zd Zdd„ ZdS )Ú
FakeStreamc                    s"   t  tj¡‰ ‡ fdd„tdƒD ƒS )Nc              	      s(   g | ]}ˆ t |d � ddddddœ‘qS ))Úminutesé
   ç      %@éd   )ÚtÚoÚhÚlÚcÚv)r   )Ú.0Úi©Únowr   r   Ú
<listcomp>�   s    ÿÿz'FakeStream.get_bars.<locals>.<listcomp>r@   )r   r`   r   Úutcr   )r"   r	   r   r_   r   Úget_bars   s   
þzFakeStream.get_barsN)r.   r/   r0   rc   r   r   r   r   rR   ~   s    rR   c                   @   s>   e Zd Zdd„ Zddd„Zdd„ Zdd	„ Zd
d„ Zdd„ ZdS )Ú$TestMonitorForcesExitOnDeteriorationc                 C   sV   t j t j¡| _ddidddœdœ| j_tƒ | j_dd„ | j_d	d
id	dig| j_d S )NÚmin_avg_daily_volumei ¡ Tr   r2   )Úuniverser   c                   S   s   i S rG   r   r   r   r   r   Ú<lambda>’   ó    z<TestMonitorForcesExitOnDeterioration.setUp.<locals>.<lambda>r	   r   ÚOTHER)	ÚmonitorÚSessionOrchestratorÚ__new__Úorchr5   rR   ÚstreamÚ_get_volume_baselinesÚpremarket_20r6   r   r   r   r7   ˆ   s   þ
þ
z*TestMonitorForcesExitOnDeterioration.setUpNc              
      s
  || j _|p	dd„ | j _t td¡�i}t td¡�J}t td¡�4}i |j_d |j_‡ fdd„}||j	_
tj|j_
| j  ¡  |W  d   ƒ W  d   ƒ W  d   ƒ S 1 sWw   Y  W d   ƒ n1 sfw   Y  W d   ƒ d S W d   ƒ d S 1 s~w   Y  d S )Nc                 S   s   d S rG   r   )r	   r   r   r   rg   —   rh   z;TestMonitorForcesExitOnDeterioration._run.<locals>.<lambda>r   Ú
data_storeÚ
top_stocksc                    s*   t ƒ }ˆ  | dddœ¡}|d |_||fS )Nr   r   ©r   Ústatert   )r   Úgetr   )r	   ÚbarsÚavg_vol_baselineÚhealth_stateÚreadingÚentry©Úhealth_state_entriesr   r   Úfake_evaluate_symbol    s   ÿ
zGTestMonitorForcesExitOnDeterioration._run.<locals>.fake_evaluate_symbol)rm   Úposition_mgrÚ_handle_slot_freedr   Úobjectrj   Úload_health_stateÚreturn_valueÚsave_health_stateÚevaluate_symbolÚside_effectr   r:   Ú_update_intraday_health)r"   r~   r|   Úhandle_slot_freedÚmock_healthÚmock_dsÚmock_top_stocksr}   r   r{   r   Ú_run•   s$   
ÿ
þÿ
Žëz)TestMonitorForcesExitOnDeterioration._runc                 C   s†   t dgd�}g }| j|ddddœi|jd� |  t|jƒd¡ |jd \}}}|  |d¡ |  |d	¡ |  |d
¡ |  |dgd¡ d S )Nr   ©rK   r   r   rs   )r‡   é   r   ÚHEALTH_DETERIORATIONrU   z=slot should be freed exactly once for the force-exited symbol)rF   r‹   rN   r    ÚlenrJ   )r"   ÚpmÚfreedr	   rO   rP   r   r   r   Ú,test_open_position_force_exited_at_threshold°   s   þzQTestMonitorForcesExitOnDeterioration.test_open_position_force_exited_at_thresholdc                 C   s6   t dgd�}|  |ddddœi¡ |  |jg d¡ d S )Nr   rŒ   r)   r   rs   z?2 consecutive reads is below the exit_confirm_reads=3 threshold)rF   r‹   r    rJ   ©r"   r�   r   r   r   Ú-test_open_position_not_exited_below_threshold¿   s   zRTestMonitorForcesExitOnDeterioration.test_open_position_not_exited_below_thresholdc                 C   sL   t dgd�}|  |dddœdddœdœ¡ d	d
„ |jD ƒ}|  |dg¡ dS )zÃOTHER has no open position -- deteriorating health should only
        affect its entry eligibility (handled elsewhere), never trigger
        exit_position for a symbol with no position to exit.r   rŒ   r   r   rs   rT   r'   )r   ri   c                 S   s   g | ]}|d  ‘qS )r   r   )r]   r[   r   r   r   ra   Î   s    zxTestMonitorForcesExitOnDeterioration.test_non_open_position_symbol_never_exited_regardless_of_health.<locals>.<listcomp>N)rF   r‹   rJ   r    )r"   r�   Úsymbols_exitedr   r   r   Ú?test_non_open_position_symbol_never_exited_regardless_of_healthÅ   s   þzdTestMonitorForcesExitOnDeterioration.test_non_open_position_symbol_never_exited_regardless_of_healthc                 C   sF   d| j jd d< tdgd�}|  |ddddœi¡ |  |jg d	¡ d S )
NFr   r3   r   rŒ   rT   r   rs   zJdisabling the flag should suppress the force-exit even well past threshold)rm   r5   rF   r‹   r    rJ   r“   r   r   r   Ú&test_disabled_flag_prevents_force_exitÑ   s   zKTestMonitorForcesExitOnDeterioration.test_disabled_flag_prevents_force_exitrG   )	r.   r/   r0   r7   r‹   r’   r”   r–   r—   r   r   r   r   rd   ‡   s    
rd   Ú__main__) Ú__doc__ÚbuiltinsÚ@py_builtinsÚ_pytest.assertion.rewriteÚ	assertionÚrewriteÚ
@pytest_arÚsysÚosÚunittestÚunittest.mockr   r   r   r   r   ÚpathÚinsertÚdirnameÚabspathÚ__file__r   rj   r   ÚTestCaser   r1   rF   rR   rd   r.   Úmainr   r   r   r   Ú<module>   s$    "&	$	Rÿ