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mZ ej dej ej ej e¡¡¡¡ ddlmZmZmZ ddlmZmZ ddlmZ dd„ Zd	d
„ ZG dd„ de	jƒZedkrke	  ¡  dS dS )aJ  
test_persistence_gap_bugfix.py

Regression test for the "stale persistence survives an evaluation gap"
bug found in production logs on 2026-09-14. fast_entry_gate.py's
persistence_state only ever stored {"since": timestamp}, and every call
just computed `now - since` with no check that the symbol had actually
been re-evaluated on every poll in between. Three real, distinct causes
of a gap all produced the same result -- a BUY firing off a stale "since"
with zero continuous re-confirmation across the gap:

  - ALLT: direction flipped UP at 09:49:25, got benched (5 consecutive
    non-confirms -> 180s off the shortlist) at 09:49:35, then bought
    instantly at 09:53:21 on the very first re-check (held=236.2s).
  - JBS: direction flipped UP at 09:50:08, health dropped to STALE at
    09:50:16 (dropping it out of the health-eligible pool entirely),
    then bought instantly at 09:53:28 once health recovered
    (held=199.5s).
  - FUBO: carried a since-timestamp across a 2h26m (8804.6s) health-STALE
    gap and bought the instant health recovered, then repeated a
    rapid-refire pattern (bought/exited/re-bought within seconds,
    since being open/closing also excludes a symbol from re-evaluation
    without clearing its persistence timer) -- same root cause as BTE's
    5 back-to-back buy/momentum_fade_exit cycles in under a minute.

Fix: persistence_state also tracks "last_seen" (the as_of of the last
call that reached the UP-tracking branch). If the gap between "now" and
"last_seen" exceeds max_evaluation_gap_seconds (config default 15s --
enough to tolerate one or two missed ~5s poll ticks, not a 180s bench or
a multi-minute/hour health gap), the old "since" is discarded as stale
and persistence restarts from zero.

Run:
    python -m pytest tests/test_persistence_gap_bugfix.py -v
or:
    python tests/test_persistence_gap_bugfix.py
é    N)ÚdatetimeÚ	timedeltaÚtimezone)Úevaluate_fast_entryÚ	STATE_BUYÚ
STATE_WAIT©ÚFastPredictionReadingÚDIRECTION_UP©ÚStreamFeaturesc                   C   s   t dtddddddddgd	�
S )
NÚTESTg      T@ÚStrongÚSteadyÚBullishÚSAFEÚNORMALzmomentum=Strong)
ÚsymbolÚ	directionÚ
confidenceÚmomentumÚvolume_stateÚ
vwap_stateÚ
ema9_stateÚ
resistanceÚ	extensionÚreasonsr   © r   r   ú@/var/www/screener/premarket/tests/test_persistence_gap_bugfix.pyÚ_up_prediction3   s   ür   c              	   C   s,   t d| ddiddiddidd	d
œdd	id�S )Nr   Úlastç      $@Úmomentum_accelerationg      à?Úrelative_volumeg       @g      #@gš™™™™™¹?)ÚvalueÚslopeÚ
spread_pct)r   Úcomputed_atÚpriceÚaccelerationÚvolumeÚvwapÚquoter   ©Úas_ofr   r   r   Ú_clean_features<   s   úr/   c                   @   s$   e Zd Zdd„ Zdd„ Zdd„ ZdS )ÚTestPersistenceGapBugfixc              	   C   s€   t ƒ }tddddddtjd�}i }d}tdƒD ]}|td| d	� }td
|t|ƒ||d�}|j}q|  	|j
t¡ |  |j¡ dS )zªSanity check: normal back-to-back ~5s polls with no gap should
        still accumulate persistence and fire BUY once >= 18s, unaffected
        by the gap-detection fix.éê  é	   é   é1   é   ©ÚtzinfoNé   ©Úsecondsr   r-   )r   r   r   ÚutcÚranger   r   r/   ÚpersistenceÚassertEqualÚstater   Ú
assertTrueÚshould_enter)ÚselfÚ
predictionÚt0r?   ÚdecisionÚiÚnowr   r   r   Ú>test_continuous_evaluation_still_buys_after_persistence_windowH   s   zWTestPersistenceGapBugfix.test_continuous_evaluation_still_buys_after_persistence_windowc              	   C   s”   t ƒ }tddddddtjd�}i }td|t|ƒ||d�}|j}|  |jt	¡ |t
d	d
� }td|t|ƒ||d�}|  |jt	d¡ |  |jdd¡ dS )a  Reproduces the ALLT case: UP flips, then a long gap (simulating
        a 180s bench) where the symbol isn't evaluated at all, then it
        comes back still UP. Before the fix this fired BUY instantly off
        the stale "since". After the fix it must restart from zero.r1   r2   r3   r4   r5   r6   r   r-   gfffff†m@r9   zha since-timestamp that survived a long unevaluated gap must not instantly satisfy the persistence windowg      ð?z>persistence must restart from ~0s, not carry over the 236s gapN)r   r   r   r;   r   r/   r=   r>   r?   r   r   Ú
assertLessÚpersistence_seconds_elapsed)rB   rC   Ú
t_up_startr?   rE   Út_after_benchr   r   r   Ú)test_gap_from_benching_resets_persistenceW   s   
ÿ
ÿzBTestPersistenceGapBugfix.test_gap_from_benching_resets_persistencec              	   C   sx   t ƒ }tddddddtjd�}i }td|t|ƒ||d�}|j}|td	d
� }td|t|ƒ||d�}| j|j	ddd� dS )z¶A single missed poll tick (e.g. a slow cycle) shouldn't punish
        a genuinely continuous setup -- only gaps beyond
        max_evaluation_gap_seconds (15s default) should reset.r1   r2   r3   r4   r5   r6   r   r-   é
   r9   r!   g{®Gáz„?)ÚdeltaN)
r   r   r   r;   r   r/   r=   r   ÚassertAlmostEqualrJ   )rB   rC   rD   r?   rE   Út1r   r   r   Ú.test_short_gap_within_tolerance_does_not_resetp   s   zGTestPersistenceGapBugfix.test_short_gap_within_tolerance_does_not_resetN)Ú__name__Ú
__module__Ú__qualname__rH   rM   rR   r   r   r   r   r0   G   s    r0   Ú__main__)!Ú__doc__ÚbuiltinsÚ@py_builtinsÚ_pytest.assertion.rewriteÚ	assertionÚrewriteÚ
@pytest_arÚsysÚosÚunittestr   r   r   ÚpathÚinsertÚdirnameÚabspathÚ__file__Úfast_entry_gater   r   r   Úfast_prediction_enginer	   r
   Ústream_featuresr   r   r/   ÚTestCaser0   rS   Úmainr   r   r   r   Ú<module>   s    "&&	9ÿ