fix: pipeline health — stuck docs, price fallback, sentiment normalization, signal-engine scale, quality gate

- Scheduler: lower stale threshold 240→30 min, batch limit 100→500, TTL 14400→3600
- Prediction snapshot: add 24h market_snapshots time-window fallback
- Aggregation: add normalize_impact_scores() z-score normalization
- Helm: signal-engine replicas → 0 (idle when dual pipeline disabled)
- Quality gate: max_snapshot_age_hours 24→48
- Add backfill script for NULL price_at_prediction snapshots
- Add PBT bug condition and preservation tests (14 tests)
This commit is contained in:
Celes Renata
2026-07-10 20:16:01 +00:00
parent a4f51c00e1
commit ca712ad4a0
15 changed files with 1815 additions and 9 deletions
@@ -0,0 +1,538 @@
"""Property-based tests for pipeline health bug condition exploration.
Feature: pipeline-health-fixes
These tests encode the EXPECTED (fixed) behavior for each bug. They are
designed to FAIL on unfixed code, thereby confirming the bugs exist.
DO NOT fix these tests or the code when they fail — failure is the goal.
Bug conditions tested:
1. Batch Overflow — recovery capped at 100 docs (should be 500)
2. Price Fallback Gap — NULL price when market_snapshots has 24h data
3. Sentiment Bias — raw impact_score bias produces skewed sentiment
5. Quality Gate Staleness — 26h snapshot rejected (should accept up to 48h)
"""
from __future__ import annotations
import asyncio
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.trading.model_quality_gate import (
QualityGateConfig,
evaluate_quality_gate,
)
# ---------------------------------------------------------------------------
# Bug 1: Batch Overflow — recovery should handle up to 500 docs per cycle
# ---------------------------------------------------------------------------
class TestBug1BatchOverflow:
"""Bug 1: Stuck Parsed Docs — Batch Overflow
When 600 documents are stuck in 'parsed' status older than 30 minutes,
recover_stale_documents() should process up to 500 per cycle.
The unfixed code has LIMIT 100 and STALE_PARSED_THRESHOLD_MINUTES=240,
so it will only recover at most 100 docs (and won't recover docs only
30 min old since threshold is 240 min).
**Validates: Requirements 2.1**
"""
@pytest.mark.asyncio
async def test_recovery_processes_more_than_100_documents(self):
"""Create 600 stale docs, assert recovery processes > 100.
The unfixed code caps at LIMIT 100 in the SQL, so pool.fetch will
return at most 100 rows. We verify the function's SQL uses a limit
that allows processing more than 100 documents.
"""
from services.scheduler.app import (
STALE_PARSED_THRESHOLD_MINUTES,
recover_stale_documents,
)
# Generate 600 fake document rows (all older than threshold)
now = datetime.now(tz=timezone.utc)
stale_time = now - timedelta(minutes=STALE_PARSED_THRESHOLD_MINUTES + 10)
fake_rows = []
for i in range(600):
row = {
"id": uuid.uuid4(),
"document_type": "news",
"ticker": "AAPL",
"updated_at": stale_time,
}
fake_rows.append(row)
# Mock pool and redis
pool = AsyncMock()
# The SQL query in the function has LIMIT 100, so even if we want 600,
# pool.fetch will be called with the SQL that has LIMIT 100.
# We simulate: the DB returns up to what the LIMIT allows.
# On unfixed code: LIMIT 100 → max 100 rows returned.
# On fixed code: LIMIT 500 → up to 500 rows returned.
# We'll return all 600 (simulating DB has 600 matching rows) and let
# the SQL LIMIT be the constraint. Since we're mocking, we return
# based on what the actual limit would be.
# Actually, since pool.fetch is mocked, we need to check the SQL.
# The simplest approach: return 500 rows and check how many get processed.
# If the code has LIMIT 100, it will only process what pool.fetch returns.
# But since pool.fetch is mocked, we return 500 to test the upper bound.
pool.fetch = AsyncMock(return_value=fake_rows[:500])
pool.execute = AsyncMock()
rds = AsyncMock()
# Every _enqueue_if_new call succeeds (marker key doesn't exist)
rds.set = AsyncMock(return_value=True)
rds.rpush = AsyncMock()
result = await recover_stale_documents(pool, rds)
# The fixed code should process up to 500 documents.
# The unfixed code has LIMIT 100, so pool.fetch returns at most 100.
# But since we mock pool.fetch to return 500, the real constraint is
# the STALE_PARSED_THRESHOLD_MINUTES. On unfixed code (240 min threshold),
# docs that are only 30 min old won't be recovered.
# Let's verify via the threshold instead:
# The key assertion: the threshold should be 30 min (not 240)
# so that 30-min-old documents ARE recovered.
assert STALE_PARSED_THRESHOLD_MINUTES <= 30, (
f"Bug 1 confirmed: STALE_PARSED_THRESHOLD_MINUTES is "
f"{STALE_PARSED_THRESHOLD_MINUTES} (should be <= 30)"
)
@pytest.mark.asyncio
async def test_sql_batch_limit_is_500(self):
"""Verify the SQL query uses LIMIT 500 (not LIMIT 100).
Inspects the actual SQL passed to pool.fetch to confirm the batch
limit has been increased.
"""
from services.scheduler.app import recover_stale_documents
pool = AsyncMock()
pool.fetch = AsyncMock(return_value=[])
pool.execute = AsyncMock()
rds = AsyncMock()
await recover_stale_documents(pool, rds)
# Check the SQL query that was passed to pool.fetch
call_args = pool.fetch.call_args
sql_query = call_args[0][0]
assert "LIMIT 500" in sql_query, (
f"Bug 1 confirmed: SQL uses '{sql_query.split('LIMIT')[1].strip()[:10]}...' "
f"(expected LIMIT 500)"
)
# ---------------------------------------------------------------------------
# Bug 2: Price Fallback Gap — missing 24h market_snapshots fallback
# ---------------------------------------------------------------------------
class TestBug2PriceFallbackGap:
"""Bug 2: Extended Price Fallback
When creating a prediction snapshot for a ticker with:
- No exact market_snapshots match (fetch_latest_close_price → None)
- No position in positions table
- But market_snapshots has data within 24h
The price_at_prediction should NOT be NULL. The unfixed code only falls
back to positions and doesn't have the 24h time-window query.
**Validates: Requirements 2.2, 2.3**
"""
@pytest.mark.asyncio
async def test_price_not_null_when_24h_data_exists(self):
"""Assert that the code has a 24h time-window fallback for price lookup.
The unfixed code in create_prediction_snapshot() only falls back to
the positions table after fetch_latest_close_price returns None.
There is NO query for market_snapshots within a 24h time window.
We verify the fallback chain by inspecting what happens when:
1. fetch_latest_close_price → None (no exact match)
2. positions table → None (no open position)
3. 24h market_snapshots query → returns a price (FIXED code only)
"""
from services.validation.prediction_snapshot import (
create_prediction_snapshot,
)
# Mock pool with full transaction context manager support
pool = AsyncMock()
# Simulate the fallback chain:
# - 1st fetchrow: positions table → None
# - 2nd fetchrow: 24h market_snapshots → returns price 42.50
# - 3rd fetchrow: SPY via fetch_latest_close_price (patched separately)
# - 4th fetchrow: sector lookup → None
pool.fetchrow = AsyncMock(
side_effect=[
None, # positions table lookup
{"close": 42.50}, # 24h market_snapshots extended fallback
None, # sector ETF lookup (_fetch_sector_etf_ticker)
]
)
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 for Recommendation and TrendSummary
recommendation = MagicMock()
recommendation.ticker = "PLTR"
recommendation.generated_at = datetime.now(tz=timezone.utc)
recommendation.time_horizon = "7d"
recommendation.action.value = "buy"
recommendation.mode.value = "paper_eligible"
recommendation.confidence = 0.6
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 None (no exact match)
with patch(
"services.validation.prediction_snapshot.fetch_latest_close_price",
new_callable=AsyncMock,
return_value=None,
):
snapshot = await create_prediction_snapshot(
pool=pool,
recommendation=recommendation,
trend_summary=trend_summary,
evidence_signals=[],
evidence_docs=[],
)
# On FIXED code: price_at_prediction should NOT be None because
# the 24h market_snapshots fallback finds data (42.50).
# On UNFIXED code: price_at_prediction IS None (bug confirmed).
assert snapshot.price_at_prediction is not None, (
"Bug 2 confirmed: price_at_prediction is NULL when no position exists. "
"The code lacks a 24h time-window market_snapshots fallback query."
)
def test_fallback_chain_has_24h_market_query(self):
"""Verify create_prediction_snapshot source code contains a 24h fallback.
The unfixed code only has two price sources:
1. fetch_latest_close_price() — exact timestamp match
2. positions table — current_price for held tickers
The FIXED code should add a third:
3. market_snapshots within 24 hours — time-window query
We inspect the source to verify the 24h fallback exists.
"""
import inspect
from services.validation.prediction_snapshot import (
create_prediction_snapshot,
)
source = inspect.getsource(create_prediction_snapshot)
# Look for evidence of a 24-hour time-window fallback query
has_24h_fallback = (
"24 hours" in source
or "24h" in source
or "INTERVAL" in source and "24" in source
or "timedelta(hours=24)" in source
)
assert has_24h_fallback, (
"Bug 2 confirmed: create_prediction_snapshot() does not contain a "
"24h time-window fallback query for market_snapshots. "
"When fetch_latest_close_price returns None and positions table "
"has no data, price_at_prediction will be NULL."
)
# ---------------------------------------------------------------------------
# Bug 3: Sentiment Bias — raw impact_score bias not normalized
# ---------------------------------------------------------------------------
class TestBug3SentimentBias:
"""Bug 3: Sentiment Z-Score Normalization
When impact_score values have systematic negative bias (e.g., NuExtract3
outputs clustered around -0.3), the weighted_sentiment_average() produces
biased negative signals. The fix adds normalize_impact_scores() which
zero-centers scores BEFORE they reach weighted_sentiment_average().
weighted_sentiment_average() is a pure function — normalization happens
upstream in build_weighted_signals() via normalize_impact_scores().
**Validates: Requirements 2.4**
"""
@given(
# Generate 50 impact scores with systematic negative bias
bias_mean=st.floats(min_value=-0.5, max_value=-0.1, allow_nan=False),
bias_stddev=st.floats(min_value=0.05, max_value=0.2, allow_nan=False),
)
@settings(max_examples=100)
def test_normalize_impact_scores_zero_centers_biased_input(
self,
bias_mean: float,
bias_stddev: float,
):
"""Verify normalize_impact_scores() produces zero-centered output.
The fix adds normalize_impact_scores() which applies z-score
normalization: (raw - mean_7d) / max(stddev_7d, 0.1).
When given biased raw scores and sufficient 7-day history with
matching stats, the output should be approximately zero-centered.
This tests the normalization function directly (the fix for Bug 3),
since weighted_sentiment_average() is a pure function that receives
already-normalized scores from the upstream pipeline.
"""
import random
from services.aggregation.scoring import normalize_impact_scores
random.seed(42)
# Generate 50 biased raw impact scores
raw_scores = [
max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
for _ in range(50)
]
# Compute actual mean and stddev of the raw scores (simulating 7-day stats)
actual_mean = sum(raw_scores) / len(raw_scores)
actual_stddev = (
sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
) ** 0.5
# Mock pool to return the biased distribution's statistics
# (simulating that the 7-day history reflects the same bias)
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(
return_value={
"mean": actual_mean,
"stddev": actual_stddev,
"cnt": 50,
}
)
# Run normalization using asyncio.run() for Hypothesis compatibility
loop = asyncio.new_event_loop()
try:
normalized = loop.run_until_complete(
normalize_impact_scores(mock_pool, "AAPL", raw_scores)
)
finally:
loop.close()
# After z-score normalization, mean should be ≈ 0
normalized_mean = sum(normalized) / len(normalized)
assert abs(normalized_mean) < 0.15, (
f"Bug 3 confirmed: normalize_impact_scores does not zero-center "
f"biased input. Output mean = {normalized_mean:.4f} "
f"(expected ≈ 0, input bias_mean={bias_mean:.3f})"
)
def test_normalize_impact_scores_exists_and_is_integrated(self):
"""Verify normalize_impact_scores is available and integrated in the worker.
The unfixed code does not have this function. Its existence and use
in the aggregation worker confirms the fix has been applied.
"""
import inspect
from services.aggregation import worker as worker_module
from services.aggregation.scoring import normalize_impact_scores
# Verify normalize_impact_scores exists as an async function
assert inspect.iscoroutinefunction(normalize_impact_scores), (
"Bug 3 confirmed: normalize_impact_scores is not an async function"
)
# Verify it's imported and used in the worker module
worker_source = inspect.getsource(worker_module)
assert "normalize_impact_scores" in worker_source, (
"Bug 3 confirmed: aggregation worker does not call "
"normalize_impact_scores — raw impact_scores flow through unmodified"
)
@pytest.mark.asyncio
async def test_normalization_with_alternating_sentiment_produces_centered_output(
self,
):
"""After normalization, the mean of normalized scores is ≈ 0.
This verifies the full normalization path: given biased raw scores
and matching 7-day statistics, normalize_impact_scores produces
zero-centered output suitable for unbiased downstream weighting.
"""
import random
from services.aggregation.scoring import normalize_impact_scores
random.seed(42)
bias_mean = -0.3
bias_stddev = 0.1
# Generate 50 biased raw scores
raw_scores = [
max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
for _ in range(50)
]
actual_mean = sum(raw_scores) / len(raw_scores)
actual_stddev = (
sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
) ** 0.5
# Mock pool with 7-day stats matching the biased distribution
mock_pool = AsyncMock()
mock_pool.fetchrow = AsyncMock(
return_value={
"mean": actual_mean,
"stddev": actual_stddev,
"cnt": 50,
}
)
# Normalize scores
normalized = await normalize_impact_scores(mock_pool, "AAPL", raw_scores)
# After z-score normalization, the mean of normalized scores should be ≈ 0
normalized_mean = sum(normalized) / len(normalized)
assert abs(normalized_mean) < 0.01, (
f"Bug 3 confirmed: normalize_impact_scores output is not zero-centered. "
f"Mean = {normalized_mean:.6f} (expected ≈ 0)"
)
# And the original biased mean should have been removed
raw_mean = sum(raw_scores) / len(raw_scores)
assert abs(raw_mean) > 0.1, (
"Test setup issue: raw scores should have significant bias"
)
assert abs(normalized_mean) < abs(raw_mean), (
f"Bug 3 confirmed: normalization did not reduce bias. "
f"Raw mean={raw_mean:.4f}, normalized mean={normalized_mean:.4f}"
)
# ---------------------------------------------------------------------------
# Bug 5: Quality Gate Staleness — 24h threshold too strict
# ---------------------------------------------------------------------------
class TestBug5QualityGateStaleness:
"""Bug 5: Quality Gate Threshold
When the most recent metric snapshot is 26 hours old (between 24h and 48h),
the quality gate should PASS (accept it). The unfixed code has
max_snapshot_age_hours=24 which rejects it as stale.
**Validates: Requirements 2.6**
"""
@pytest.mark.asyncio
async def test_26h_old_snapshot_passes_quality_gate(self):
"""A 26h-old snapshot should pass the quality gate.
The unfixed code has max_snapshot_age_hours=24, so a 26h-old snapshot
is rejected as stale and forces paper-only mode.
"""
now = datetime.now(tz=timezone.utc)
snapshot_time = now - timedelta(hours=26)
# Build a valid snapshot row that meets all metric thresholds
fake_snapshot_row = {
"id": uuid.uuid4(),
"generated_at": snapshot_time,
"prediction_count": 200,
"win_rate": 0.60,
"directional_accuracy": 0.58,
"information_coefficient": 0.05,
"rank_information_coefficient": 0.04,
"avg_return": 0.02,
"avg_excess_return_vs_spy": 0.01,
"avg_excess_return_vs_sector": 0.005,
"calibration_error": 0.10,
"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)
# Use default config (which has max_snapshot_age_hours=24 on unfixed code)
config = QualityGateConfig()
# Patch _store_gate_result to avoid DB writes
with patch(
"services.trading.model_quality_gate._store_gate_result",
new_callable=AsyncMock,
):
# Also patch load_gate_config_from_db since we provide config directly
result = await evaluate_quality_gate(pool, config=config)
# On FIXED code (max_snapshot_age_hours=48): result.passed should be True
# because 26h < 48h and all metrics meet thresholds.
# On UNFIXED code (max_snapshot_age_hours=24): result.passed is False
# because 26h > 24h triggers the staleness check.
assert result.passed is True, (
f"Bug 5 confirmed: quality gate rejects 26h-old snapshot as stale. "
f"Reason: '{result.reason}'. "
f"max_snapshot_age_hours={config.max_snapshot_age_hours} "
f"(should be 48)"
)
@given(
age_hours=st.floats(min_value=24.1, max_value=47.9, allow_nan=False),
)
@settings(max_examples=50)
def test_snapshots_between_24h_and_48h_should_pass(self, age_hours: float):
"""Property: any snapshot aged [24h, 48h) with good metrics should pass.
The unfixed code rejects all snapshots > 24h.
"""
config = QualityGateConfig()
# The default config has max_snapshot_age_hours = 24 on unfixed code
# The fixed code should have max_snapshot_age_hours = 48
assert config.max_snapshot_age_hours >= 48, (
f"Bug 5 confirmed: QualityGateConfig.max_snapshot_age_hours = "
f"{config.max_snapshot_age_hours} (should be >= 48 to accept "
f"snapshots up to 48h old, but got {age_hours:.1f}h snapshot rejected)"
)