"""Property-based tests for pipeline health preservation properties. Feature: pipeline-health-fixes These tests encode the CURRENT correct behavior for non-bug inputs. They MUST PASS on unfixed code — confirming baseline behavior is preserved. Preservation properties tested: 1. Normal Doc Processing — documents < threshold age untouched by recovery 2. Primary Price Path — valid primary price used without fallback 3. Balanced Sentiment — symmetric impact_scores produce neutral output 4. Quality Gate Normal — snapshots < max age meeting criteria pass 5. Failed Extraction Independence — retry_failed_extractions behavior unchanged """ from __future__ import annotations import uuid from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from hypothesis import given, settings from hypothesis import strategies as st from services.aggregation.scoring import ( SignalWeight, WeightedSignal, weighted_sentiment_average, ) from services.trading.model_quality_gate import ( QualityGateConfig, evaluate_quality_gate, ) # --------------------------------------------------------------------------- # Property 1: Normal Doc Processing Preservation # Documents younger than the stale threshold are NOT touched by recovery. # --------------------------------------------------------------------------- class TestPreservationNormalDocProcessing: """Preservation: Normal Document Processing Documents that enter 'parsed' status and are younger than the current stale threshold are never touched by recover_stale_documents(). On unfixed code, STALE_PARSED_THRESHOLD_MINUTES=240, so any document younger than 240 min is untouched. We test with documents < 30 min old which are well within the threshold on both unfixed (240 min) and fixed (30 min) code. **Validates: Requirements 3.1, 3.7** """ @pytest.mark.asyncio @given( doc_age_minutes=st.floats(min_value=0.0, max_value=29.0, allow_nan=False), ) @settings(max_examples=100) async def test_young_documents_not_recovered(self, doc_age_minutes: float): """Property: for all documents with age < 30 min, recovery does NOT enqueue them. The SQL WHERE clause filters on `updated_at < NOW() - INTERVAL threshold`, so documents younger than the threshold won't appear in the query results. We mock pool.fetch to return empty (simulating the DB correctly filtering out young documents) and verify 0 are enqueued. """ from services.scheduler.app import ( STALE_PARSED_THRESHOLD_MINUTES, recover_stale_documents, ) # The current threshold is 240 min on unfixed code. # Documents < 30 min old are well below ANY threshold (240 or 30), # so the DB query returns nothing for them. assert doc_age_minutes < STALE_PARSED_THRESHOLD_MINUTES, ( f"Test assumes doc_age_minutes ({doc_age_minutes}) < threshold " f"({STALE_PARSED_THRESHOLD_MINUTES})" ) # Mock pool.fetch to return empty — simulating DB filtering out young docs pool = AsyncMock() pool.fetch = AsyncMock(return_value=[]) pool.execute = AsyncMock() rds = AsyncMock() rds.set = AsyncMock(return_value=True) rds.rpush = AsyncMock() result = await recover_stale_documents(pool, rds) # No documents should be enqueued assert result == 0, ( f"Young documents (age={doc_age_minutes:.1f} min) should not be " f"recovered, but {result} were enqueued" ) # Redis rpush should never have been called rds.rpush.assert_not_called() # --------------------------------------------------------------------------- # Property 2: Primary Price Path Preservation # When fetch_latest_close_price returns a valid price, no fallback is invoked. # --------------------------------------------------------------------------- class TestPreservationPrimaryPricePath: """Preservation: Primary Price Path When fetch_latest_close_price() returns a valid price, that price is used directly and no fallback (positions table or market_snapshots 24h) is invoked. **Validates: Requirements 3.2** """ @pytest.mark.asyncio @given( primary_price=st.floats( min_value=1.0, max_value=5000.0, allow_nan=False, allow_infinity=False ), ) @settings(max_examples=100) async def test_valid_primary_price_used_directly(self, primary_price: float): """Property: for all tickers where primary price exists, result equals primary price. When fetch_latest_close_price returns a valid float, the snapshot's price_at_prediction equals that value without any fallback invoked. """ from services.validation.prediction_snapshot import ( create_prediction_snapshot, ) pool = AsyncMock() # Track fetchrow calls to verify no fallback query is made call_count = {"fetchrow": 0} async def mock_fetchrow(*args, **kwargs): call_count["fetchrow"] += 1 # After the first price lookups, return None for sector ETF return None pool.fetchrow = AsyncMock(side_effect=mock_fetchrow) pool.execute = AsyncMock() # Setup transaction context manager mock conn_mock = AsyncMock() conn_mock.execute = AsyncMock() tx_mock = AsyncMock() tx_mock.__aenter__ = AsyncMock(return_value=None) tx_mock.__aexit__ = AsyncMock(return_value=None) conn_mock.transaction = MagicMock(return_value=tx_mock) acquire_mock = AsyncMock() acquire_mock.__aenter__ = AsyncMock(return_value=conn_mock) acquire_mock.__aexit__ = AsyncMock(return_value=None) pool.acquire = MagicMock(return_value=acquire_mock) # Build minimal mocks recommendation = MagicMock() recommendation.ticker = "AAPL" recommendation.generated_at = datetime.now(tz=timezone.utc) recommendation.time_horizon = "7d" recommendation.action.value = "buy" recommendation.mode.value = "paper_eligible" recommendation.confidence = 0.7 trend_summary = MagicMock() trend_summary.market_context = None trend_summary.window.value = "7d" trend_summary.trend_direction.value = "bullish" trend_summary.trend_strength = 0.7 trend_summary.contradiction_score = 0.1 trend_summary.p_bull = 0.7 # Patch fetch_latest_close_price to return the primary price # This simulates the primary price lookup succeeding async def mock_fetch_latest_close_price(pool_arg, ticker): if ticker == "AAPL": return primary_price # SPY and sector ETF can also have prices return primary_price * 0.5 with patch( "services.validation.prediction_snapshot.fetch_latest_close_price", new_callable=AsyncMock, side_effect=mock_fetch_latest_close_price, ): snapshot = await create_prediction_snapshot( pool=pool, recommendation=recommendation, trend_summary=trend_summary, evidence_signals=[], evidence_docs=[], ) # The snapshot price should equal the primary price exactly assert snapshot.price_at_prediction == primary_price, ( f"Primary price path broken: expected {primary_price}, " f"got {snapshot.price_at_prediction}" ) # --------------------------------------------------------------------------- # Property 3: Balanced Sentiment Preservation # Tickers with symmetric impact_score distributions produce neutral signals. # --------------------------------------------------------------------------- class TestPreservationBalancedSentiment: """Preservation: Balanced Sentiment Tickers with symmetric impact_score distributions (mean ≈ 0) produce neutral signals. The weighted_sentiment_average() with equal positive and negative sentiments and impact_scores centered around 0 should produce an output near 0. **Validates: Requirements 3.3** """ @given( # Generate symmetric impact_scores centered at 0 stddev=st.floats(min_value=0.1, max_value=1.0, allow_nan=False), n_pairs=st.integers(min_value=5, max_value=50), ) @settings(max_examples=100) def test_symmetric_impacts_produce_neutral_output( self, stddev: float, n_pairs: int, ): """Property: for all impact_score sets with mean ≈ 0, output is near 0. Generate WeightedSignals with impact_scores drawn symmetrically around 0 (for each +x there is a -x) and equal positive/negative sentiments. Verify weighted_sentiment_average() produces near-zero output. """ signals: list[WeightedSignal] = [] for i in range(n_pairs): # Create symmetric pairs of impact_scores # Use positive impact_score values (the scoring formula uses w = combined * impact_score) # With symmetric sentiments and equal positive impact_scores, output should be 0 impact_val = 0.1 + (i * stddev / n_pairs) # All positive, equal for both weight = SignalWeight( recency=0.8, credibility=0.7, novelty_bonus=0.1, confidence_gate=1.0, market_ctx_multiplier=1.0, combined=0.5, ) # Positive sentiment signal signals.append( WeightedSignal( document_id=f"doc_pos_{i}", weight=weight, sentiment_value=1.0, impact_score=impact_val, ) ) # Negative sentiment signal with same impact_score signals.append( WeightedSignal( document_id=f"doc_neg_{i}", weight=weight, sentiment_value=-1.0, impact_score=impact_val, ) ) avg = weighted_sentiment_average(signals) # With perfectly symmetric positive/negative sentiments and equal # impact_scores, the output should be exactly 0 assert abs(avg) < 1e-9, ( f"Balanced sentiment broken: expected ≈ 0, got {avg:.6f} " f"with {n_pairs} pairs and stddev={stddev:.3f}" ) # --------------------------------------------------------------------------- # Property 4: Quality Gate Normal Preservation # Snapshots < max_snapshot_age_hours meeting thresholds pass the quality gate. # --------------------------------------------------------------------------- class TestPreservationQualityGateNormal: """Preservation: Quality Gate Normal Snapshots younger than the max age threshold that meet all metric criteria continue to pass the quality gate and return passed=True. On unfixed code, max_snapshot_age_hours=24, so we test with ages in [0, 24h). This ensures the test passes on both unfixed and fixed code. **Validates: Requirements 3.5, 3.6** """ @pytest.mark.asyncio @given( age_hours=st.floats(min_value=0.1, max_value=23.9, allow_nan=False), win_rate=st.floats(min_value=0.53, max_value=0.85, allow_nan=False), ic=st.floats(min_value=0.03, max_value=0.3, allow_nan=False), prediction_count=st.integers(min_value=100, max_value=10000), ) @settings(max_examples=100) async def test_young_snapshots_meeting_criteria_pass( self, age_hours: float, win_rate: float, ic: float, prediction_count: int, ): """Property: for all snapshot ages in [0, 24h) meeting metric criteria, quality gate returns passed=True. We use ages < 24h which is within the threshold on BOTH unfixed (24h) and fixed (48h) code. """ now = datetime.now(tz=timezone.utc) snapshot_time = now - timedelta(hours=age_hours) # Build a snapshot row that meets all default thresholds fake_snapshot_row = { "id": uuid.uuid4(), "generated_at": snapshot_time, "prediction_count": prediction_count, "win_rate": win_rate, "directional_accuracy": 0.58, "information_coefficient": ic, "rank_information_coefficient": 0.04, "avg_return": 0.02, "avg_excess_return_vs_spy": 0.01, # >= 0.0 threshold "avg_excess_return_vs_sector": 0.005, "calibration_error": 0.10, # <= 0.15 threshold "brier_score": 0.20, "buy_win_rate": 0.62, "sell_win_rate": 0.58, "hold_win_rate": 0.55, } pool = AsyncMock() pool.fetchrow = AsyncMock(return_value=fake_snapshot_row) pool.execute = AsyncMock() pool.fetchval = AsyncMock(return_value=None) config = QualityGateConfig() with patch( "services.trading.model_quality_gate._store_gate_result", new_callable=AsyncMock, ): result = await evaluate_quality_gate(pool, config=config) assert result.passed is True, ( f"Quality gate should PASS for {age_hours:.1f}h-old snapshot " f"meeting all criteria, but got: {result.reason}" ) # --------------------------------------------------------------------------- # Property 5: Failed Extraction Independence Preservation # retry_failed_extractions() behavior is unaffected by stale doc recovery changes. # --------------------------------------------------------------------------- class TestPreservationFailedExtractionIndependence: """Preservation: Failed Extraction Independence retry_failed_extractions() processes documents in 'extraction_failed' status using its own threshold (EXTRACTION_FAILED_RETRY_MINUTES=60) and logic. Its behavior is independent of changes to recover_stale_documents(). **Validates: Requirements 3.7** """ @pytest.mark.asyncio @given( num_failed_docs=st.integers(min_value=1, max_value=100), ) @settings(max_examples=100) async def test_retry_failed_extractions_processes_failed_docs( self, num_failed_docs: int, ): """Property: for all documents in extraction_failed status, retry logic unchanged. Verify that retry_failed_extractions: 1. Queries documents with status='extraction_failed' 2. Uses EXTRACTION_FAILED_RETRY_MINUTES (60) as the age threshold 3. Enqueues them via _enqueue_if_new 4. Resets their status to 'parsed' 5. Deletes failed intelligence rows """ from services.scheduler.app import ( EXTRACTION_FAILED_RETRY_MINUTES, retry_failed_extractions, ) # Verify the retry threshold hasn't changed assert EXTRACTION_FAILED_RETRY_MINUTES == 60, ( f"EXTRACTION_FAILED_RETRY_MINUTES changed from 60 to " f"{EXTRACTION_FAILED_RETRY_MINUTES} — this should be preserved" ) now = datetime.now(tz=timezone.utc) old_time = now - timedelta(minutes=EXTRACTION_FAILED_RETRY_MINUTES + 10) # Generate fake failed document rows fake_rows = [] for i in range(num_failed_docs): fake_rows.append({ "id": uuid.uuid4(), "document_type": "news" if i % 3 != 0 else "macro_event", "ticker": f"TICK{i}", "updated_at": old_time, }) pool = AsyncMock() pool.fetch = AsyncMock(return_value=fake_rows) pool.execute = AsyncMock() rds = AsyncMock() rds.set = AsyncMock(return_value=True) # All enqueues succeed rds.rpush = AsyncMock() result = await retry_failed_extractions(pool, rds) # All docs should be enqueued assert result == num_failed_docs, ( f"Expected {num_failed_docs} docs retried, got {result}" ) # Verify the SQL query used the correct threshold fetch_call = pool.fetch.call_args sql_query = fetch_call[0][0] threshold_param = fetch_call[0][1] assert "extraction_failed" in sql_query, ( "retry_failed_extractions should query for 'extraction_failed' status" ) assert threshold_param == EXTRACTION_FAILED_RETRY_MINUTES, ( f"Expected threshold param {EXTRACTION_FAILED_RETRY_MINUTES}, " f"got {threshold_param}" ) # Verify pool.execute was called for both DELETE and UPDATE assert pool.execute.call_count == 2, ( f"Expected 2 pool.execute calls (DELETE + UPDATE), " f"got {pool.execute.call_count}" ) # Verify the DELETE query targets document_intelligence with failed status delete_call = pool.execute.call_args_list[0] assert "DELETE" in delete_call[0][0] and "document_intelligence" in delete_call[0][0], ( "First execute should DELETE from document_intelligence" ) # Verify the UPDATE resets status to 'parsed' update_call = pool.execute.call_args_list[1] assert "parsed" in update_call[0][0] and "UPDATE" in update_call[0][0], ( "Second execute should UPDATE status to 'parsed'" )