"""Fix-checking tests for R:R scanner quality-score selection. Verify that the fixed scan_ticker selects the candidate with the highest quality score among all candidates meeting the R:R threshold, for both long and short setups. **Validates: Requirements 2.1, 2.2, 2.3, 2.4** """ from __future__ import annotations from datetime import date, timedelta import pytest from hypothesis import given, settings, HealthCheck, strategies as st from sqlalchemy.ext.asyncio import AsyncSession from app.models.ohlcv import OHLCVRecord from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.services.rr_scanner_service import scan_ticker, _compute_quality_score # --------------------------------------------------------------------------- # Session fixture (plain session, not wrapped in begin()) # --------------------------------------------------------------------------- @pytest.fixture async def scan_session() -> AsyncSession: """Provide a DB session compatible with scan_ticker (which commits).""" from tests.conftest import _test_session_factory async with _test_session_factory() as session: yield session # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_ohlcv_bars( ticker_id: int, num_bars: int = 20, base_close: float = 100.0, ) -> list[OHLCVRecord]: """Generate OHLCV bars closing around base_close with ATR ≈ 2.0.""" bars: list[OHLCVRecord] = [] start = date(2024, 1, 1) for i in range(num_bars): close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5 bars.append(OHLCVRecord( ticker_id=ticker_id, date=start + timedelta(days=i), open=close - 0.3, high=close + 1.0, low=close - 1.0, close=close, volume=100_000, )) return bars # --------------------------------------------------------------------------- # Hypothesis strategy: multiple resistance levels above entry for longs # --------------------------------------------------------------------------- @st.composite def long_candidate_levels(draw: st.DrawFn) -> list[dict]: """Generate 2-5 resistance levels above entry_price=100. All levels meet the R:R threshold of 1.5 given ATR≈2, risk≈3, so min reward=4.5, min target=104.5. """ num_levels = draw(st.integers(min_value=2, max_value=5)) levels = [] for _ in range(num_levels): # Distance from entry: 5 to 50 (all above 4.5 threshold) distance = draw(st.floats(min_value=5.0, max_value=50.0)) strength = draw(st.integers(min_value=0, max_value=100)) levels.append({ "price": 100.0 + distance, "strength": strength, }) return levels @st.composite def short_candidate_levels(draw: st.DrawFn) -> list[dict]: """Generate 2-5 support levels below entry_price=100. All levels meet the R:R threshold of 1.5 given ATR≈2, risk≈3, so min reward=4.5, max target=95.5. """ num_levels = draw(st.integers(min_value=2, max_value=5)) levels = [] for _ in range(num_levels): # Distance below entry: 5 to 50 (all above 4.5 threshold) distance = draw(st.floats(min_value=5.0, max_value=50.0)) strength = draw(st.integers(min_value=0, max_value=100)) levels.append({ "price": 100.0 - distance, "strength": strength, }) return levels # --------------------------------------------------------------------------- # Property test: long setup selects highest quality score candidate # --------------------------------------------------------------------------- @pytest.mark.asyncio @given(levels=long_candidate_levels()) @settings( max_examples=20, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture], ) async def test_property_long_selects_highest_quality( levels: list[dict], scan_session: AsyncSession, ): """**Validates: Requirements 2.1, 2.3, 2.4** Property: when multiple resistance levels meet the R:R threshold, the fixed scan_ticker selects the one with the highest quality score. """ from tests.conftest import _test_engine, _test_session_factory from app.database import Base # Fresh DB state per hypothesis example async with _test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) async with _test_session_factory() as session: ticker = Ticker(symbol="FIXL") session.add(ticker) await session.flush() bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0) session.add_all(bars) sr_levels = [] for lv in levels: sr_levels.append(SRLevel( ticker_id=ticker.id, price_level=lv["price"], type="resistance", strength=lv["strength"], detection_method="volume_profile", )) session.add_all(sr_levels) await session.commit() setups = await scan_ticker(session, "FIXL", rr_threshold=1.5) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" selected_target = long_setups[0].target # Compute entry_price and risk from the bars (same logic as scan_ticker) # entry_price = last close ≈ 100.0, ATR ≈ 2.0, risk = ATR * 1.5 = 3.0 entry_price = bars[-1].close # Use approximate risk; the exact value comes from ATR computation # We reconstruct it from the setup's entry and stop risk = long_setups[0].entry_price - long_setups[0].stop_loss # Compute quality scores for all candidates that meet threshold best_quality = -1.0 best_target = None for lv in levels: distance = lv["price"] - entry_price if distance > 0: rr = distance / risk if rr >= 1.5: quality = _compute_quality_score(rr, lv["strength"], distance, entry_price) if quality > best_quality: best_quality = quality best_target = round(lv["price"], 4) assert best_target is not None, "At least one candidate should meet threshold" assert selected_target == pytest.approx(best_target, abs=0.01), ( f"Selected target {selected_target} != expected best-quality target " f"{best_target} (quality={best_quality:.4f})" ) # --------------------------------------------------------------------------- # Property test: short setup selects highest quality score candidate # --------------------------------------------------------------------------- @pytest.mark.asyncio @given(levels=short_candidate_levels()) @settings( max_examples=20, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture], ) async def test_property_short_selects_highest_quality( levels: list[dict], scan_session: AsyncSession, ): """**Validates: Requirements 2.2, 2.3, 2.4** Property: when multiple support levels meet the R:R threshold, the fixed scan_ticker selects the one with the highest quality score. """ from tests.conftest import _test_engine, _test_session_factory from app.database import Base # Fresh DB state per hypothesis example async with _test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) async with _test_session_factory() as session: ticker = Ticker(symbol="FIXS") session.add(ticker) await session.flush() bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0) session.add_all(bars) sr_levels = [] for lv in levels: sr_levels.append(SRLevel( ticker_id=ticker.id, price_level=lv["price"], type="support", strength=lv["strength"], detection_method="pivot_point", )) session.add_all(sr_levels) await session.commit() setups = await scan_ticker(session, "FIXS", rr_threshold=1.5) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" selected_target = short_setups[0].target entry_price = bars[-1].close risk = short_setups[0].stop_loss - short_setups[0].entry_price # Compute quality scores for all candidates that meet threshold best_quality = -1.0 best_target = None for lv in levels: distance = entry_price - lv["price"] if distance > 0: rr = distance / risk if rr >= 1.5: quality = _compute_quality_score(rr, lv["strength"], distance, entry_price) if quality > best_quality: best_quality = quality best_target = round(lv["price"], 4) assert best_target is not None, "At least one candidate should meet threshold" assert selected_target == pytest.approx(best_target, abs=0.01), ( f"Selected target {selected_target} != expected best-quality target " f"{best_target} (quality={best_quality:.4f})" ) # --------------------------------------------------------------------------- # Deterministic test: 3 levels with known quality scores (long) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_deterministic_long_three_levels(scan_session: AsyncSession): """**Validates: Requirements 2.1, 2.3, 2.4** Concrete example with 3 resistance levels of known quality scores. Entry=100, ATR≈2, risk≈3. Level A: price=105, strength=90 → rr=5/3≈1.67, dist=5 quality = 0.35*(1.67/10) + 0.35*(90/100) + 0.30*(1-5/100) = 0.35*0.167 + 0.35*0.9 + 0.30*0.95 = 0.0585 + 0.315 + 0.285 = 0.6585 Level B: price=112, strength=50 → rr=12/3=4.0, dist=12 quality = 0.35*(4/10) + 0.35*(50/100) + 0.30*(1-12/100) = 0.35*0.4 + 0.35*0.5 + 0.30*0.88 = 0.14 + 0.175 + 0.264 = 0.579 Level C: price=130, strength=10 → rr=30/3=10.0, dist=30 quality = 0.35*(10/10) + 0.35*(10/100) + 0.30*(1-30/100) = 0.35*1.0 + 0.35*0.1 + 0.30*0.7 = 0.35 + 0.035 + 0.21 = 0.595 Expected winner: Level A (quality=0.6585) """ ticker = Ticker(symbol="DET3L") scan_session.add(ticker) await scan_session.flush() bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0) scan_session.add_all(bars) level_a = SRLevel( ticker_id=ticker.id, price_level=105.0, type="resistance", strength=90, detection_method="volume_profile", ) level_b = SRLevel( ticker_id=ticker.id, price_level=112.0, type="resistance", strength=50, detection_method="volume_profile", ) level_c = SRLevel( ticker_id=ticker.id, price_level=130.0, type="resistance", strength=10, detection_method="volume_profile", ) scan_session.add_all([level_a, level_b, level_c]) await scan_session.flush() setups = await scan_ticker(scan_session, "DET3L", rr_threshold=1.5) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" # Level A (105, strength=90) should win with highest quality assert long_setups[0].target == pytest.approx(105.0, abs=0.01), ( f"Expected target=105.0 (highest quality), got {long_setups[0].target}" ) # --------------------------------------------------------------------------- # Deterministic test: 3 levels with known quality scores (short) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_deterministic_short_three_levels(scan_session: AsyncSession): """**Validates: Requirements 2.2, 2.3, 2.4** Concrete example with 3 support levels of known quality scores. Entry=100, ATR≈2, risk≈3. Level A: price=95, strength=85 → rr=5/3≈1.67, dist=5 quality = 0.35*(1.67/10) + 0.35*(85/100) + 0.30*(1-5/100) = 0.0585 + 0.2975 + 0.285 = 0.641 Level B: price=88, strength=45 → rr=12/3=4.0, dist=12 quality = 0.35*(4/10) + 0.35*(45/100) + 0.30*(1-12/100) = 0.14 + 0.1575 + 0.264 = 0.5615 Level C: price=70, strength=8 → rr=30/3=10.0, dist=30 quality = 0.35*(10/10) + 0.35*(8/100) + 0.30*(1-30/100) = 0.35 + 0.028 + 0.21 = 0.588 Expected winner: Level A (quality=0.641) """ ticker = Ticker(symbol="DET3S") scan_session.add(ticker) await scan_session.flush() bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0) scan_session.add_all(bars) level_a = SRLevel( ticker_id=ticker.id, price_level=95.0, type="support", strength=85, detection_method="pivot_point", ) level_b = SRLevel( ticker_id=ticker.id, price_level=88.0, type="support", strength=45, detection_method="pivot_point", ) level_c = SRLevel( ticker_id=ticker.id, price_level=70.0, type="support", strength=8, detection_method="pivot_point", ) scan_session.add_all([level_a, level_b, level_c]) await scan_session.flush() setups = await scan_ticker(scan_session, "DET3S", rr_threshold=1.5) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" # Level A (95, strength=85) should win with highest quality assert short_setups[0].target == pytest.approx(95.0, abs=0.01), ( f"Expected target=95.0 (highest quality), got {short_setups[0].target}" )