o
    <‚†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d„ZG dd	„ d	ƒZG d
d„ dƒZG dd„ de	jƒZedkrje	 ¡  dS dS )as  
test_resistance_freeze_bugfix.py

Regression test for the "frozen resistance" bug found in production logs
on 2026-08-19: CLSK entered 5 times between 11:27 and 13:24 citing the
identical "breakout of resistance $11.70" every time, despite trading as
high as $12.20 in between. Root cause: monitor.py's
_run_intraday_full_rescan() re-inserted a symbol's OLD dict from
self.premarket_20 (with a stale pm_high/resistance) into the new pool
whenever that symbol had an open position but fell outside the top
full_rescan_pool_size health cut on that pass -- instead of using the
symbol's freshly-rescored dict (which the same rescan pass had already
computed, with a current pm_high).

This test simulates two consecutive full rescans where the held symbol's
health score dips low enough to fall out of the top-N cut both times,
while its price genuinely makes new highs between rescans, and asserts
that self.premarket_20's entry for that symbol reflects the NEW price
high after the fix (it did NOT, before the fix).

Run:
    python -m pytest tests/test_resistance_freeze_bugfix.py -v
or:
    python tests/test_resistance_freeze_bugfix.py
é    N)ÚpatchÚ	MagicMock)ÚdatetimeÚ	timedeltaÚtimezoneÚWATCHc              	   C   s*   | |||d |dur|n||dddœdœS )z¢Build a minimal scored-candidate dict shaped like
    scorer.score_premarket_candidate()'s output plus the fields
    premarket_scanner.scan()/monitor.py bolt on.g      ð?NT)Úmeets_min_volumeÚ	spread_ok)ÚsymbolÚtotal_scoreÚpm_highÚpm_lowÚhealth_scoreÚhealth_stateÚflags© )r
   r   r   r   r   r   r   úB/var/www/screener/premarket/tests/test_resistance_freeze_bugfix.pyÚ_scored&   s   ùr   c                   @   ó   e Zd Zdd„ Zdd„ ZdS )ÚFakePositionManagerc                 C   s   t |ƒ| _d S ©N©ÚlistÚ_open)ÚselfÚopen_symbolsr   r   r   Ú__init__6   s   zFakePositionManager.__init__c                 C   s
   t | jƒS r   r   ©r   r   r   r   Úget_open_symbols9   s   
z$FakePositionManager.get_open_symbolsN)Ú__name__Ú
__module__Ú__qualname__r   r   r   r   r   r   r   5   ó    r   c                   @   r   )Ú
FakeStreamc                 C   ó   d S r   r   )r   Úsymbolsr   r   r   Úadd_symbols>   s   zFakeStream.add_symbolsc                    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>D   s    ÿÿz'FakeStream.get_bars.<locals>.<listcomp>é   )r   r3   r   ÚutcÚrange)r   r
   r   r2   r   Úget_barsA   s   
þzFakeStream.get_barsN)r   r    r!   r&   r8   r   r   r   r   r#   =   r"   r#   c                   @   s,   e Zd Zdd„ Zdd„ Zdd„ Zdd„ Zd	S )
ÚTestResistanceFreezeBugfixc              	   C   s˜   t j t j¡| _ddddœddidœ| j_g d¢| j_td	gd
�| j_tƒ | j_	dd„ | j_
td	ddddd�tdddddd�tdddddd�g| j_d S )Né   r(   é   )Úfull_rescan_pool_sizeÚfull_rescan_lookback_hoursÚfull_rescan_interval_minutesÚmin_avg_daily_volumei ¡ )Úintraday_healthÚuniverse)ÚCLSKÚAAAÚBBBrB   )r   c                 S   r$   r   r   )Úpoolr   r   r   Ú<lambda>X   s    z2TestResistanceFreezeBugfix.setUp.<locals>.<lambda>gffffff'@é(   r   ©r   r   r   r   rC   g      4@éZ   ÚHEALTHYrD   g      >@éU   )ÚmonitorÚSessionOrchestratorÚ__new__ÚorchÚcfgÚ_prefiltered_universer   Úposition_mgrr#   ÚstreamÚ_sync_opening_volume_baseliner   Úpremarket_20r   r   r   r   ÚsetUpK   s   ý
ú
ýz TestResistanceFreezeBugfix.setUpc              
      s  t d|ˆ d�t dddd�t dddd�g}t td	¡�h}t td
¡�I}t td¡�3}||j_‡ fdd„}||j_d|j_i |j	_d|j
_i |j_d|j_d|j_| j ¡  W d  ƒ n1 s^w   Y  W d  ƒ n1 smw   Y  W d  ƒ dS W d  ƒ dS 1 s…w   Y  dS )aG  Simulate one full rescan pass where the whole universe gets
        rescored fresh (CLSK's price has moved to clsk_fresh_pm_high),
        but CLSK's health_score is deliberately kept low so it does NOT
        make the top-2 (full_rescan_pool_size) cut -- forcing the
        'keep open-position symbol' fallback path to fire.rB   )r   r   rC   g     €4@é[   rD   g     €>@éV   Úpremarket_scannerr@   Ú
data_storec                    s*   t ƒ }| dkr	ˆ nd|_d|_|ddifS )NrB   é_   r   Ústate)r   r   Úconfirmed_state)r
   ÚbarsÚavg_vol_baseliner   Úreading©Úclsk_health_scorer   r   Úfake_evaluate_symbolz   s   zITestResistanceFreezeBugfix._run_rescan_with.<locals>.fake_evaluate_symbolTN)r   r   ÚobjectrL   ÚscanÚreturn_valueÚevaluate_symbolÚside_effectÚis_eligible_for_entryÚload_health_stateÚsave_health_stateÚload_watchlistÚsave_watchlistÚwrite_top_stocksrO   Ú_run_intraday_full_rescan)r   Úclsk_fresh_pm_highrb   Úrescored_universeÚmock_pmsÚmock_healthÚmock_dsrc   r   ra   r   Ú_run_rescan_withc   s0   ý
ÿ
þæ€P z+TestResistanceFreezeBugfix._run_rescan_withc                 C   sp   | j ddd� tdd„ | jjD ƒƒ}|  |d dd¡ | j dd	d� td
d„ | jjD ƒƒ}|  |d dd¡ d S )Ngffffff(@é#   )rp   rb   c                 s   ó    � | ]}|d  dkr|V  qdS ©r
   rB   Nr   ©r0   Úrr   r   r   Ú	<genexpr>�   ó   € zdTestResistanceFreezeBugfix.test_open_position_symbol_gets_fresh_pm_high_after_fix.<locals>.<genexpr>r   a  CLSK's pm_high/resistance should reflect this rescan's fresh price high (12.20), not the old stale value (11.70) -- this is exactly the bug: resistance froze at $11.70 across 5 real CLSK entries spanning 11:27-13:24 today despite price trading up to $12.20.g33333³(@é&   c                 s   rw   rx   r   ry   r   r   r   r{       r|   ztpm_high should keep tracking fresh price action on subsequent rescans too, not just refresh once and then re-freeze.)ru   ÚnextrO   rU   ÚassertEqual)r   Ú
clsk_entryr   r   r   Ú6test_open_position_symbol_gets_fresh_pm_high_after_fix‹   s   þþzQTestResistanceFreezeBugfix.test_open_position_symbol_gets_fresh_pm_high_after_fixc                 C   sš   t ƒ | j_tdddddd�}t td¡�}||j_| j 	d¡ W d  ƒ n1 s)w   Y  d	d
„ | jj
D ƒ}|  t|ƒdd¡ |  |d d dd¡ dS )z‚_handle_slot_freed should replace (not skip) an existing stale
        entry for a symbol chosen as a fresh replacement candidate.rB   g      )@éF   rJ   rH   Ú
top_stocksÚSOME_EXITED_SYMBOLNc                 S   s   g | ]
}|d  dkr|‘qS )r
   rB   r   ry   r   r   r   r4   ³   s    zRTestResistanceFreezeBugfix.test_slot_freed_upserts_stale_entry.<locals>.<listcomp>r(   z-should not end up with duplicate CLSK entriesr   r   z—the fresh replacement candidate's pm_high should overwrite the stale entry, not be silently discarded because CLSK was already present in premarket_20.)r#   rO   rS   r   r   rd   rL   Úfind_replacementrf   Ú_handle_slot_freedrU   r   Úlen)r   Úfresh_candidateÚmock_top_stocksÚclsk_entriesr   r   r   Ú#test_slot_freed_upserts_stale_entry§   s   
ÿþþz>TestResistanceFreezeBugfix.test_slot_freed_upserts_stale_entryN)r   r    r!   rV   ru   r�   r‹   r   r   r   r   r9   J   s
    (r9   Ú__main__)Nr   )Ú__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__rL   r   r   r#   ÚTestCaser9   r   Úmainr   r   r   r   Ú<module>   s    "&
sÿ