274 lines
9.8 KiB
Python
274 lines
9.8 KiB
Python
"""Bug-condition exploration tests for R:R scanner target quality.
|
||
|
||
These tests confirm the bug described in bugfix.md: the old code always selected
|
||
the most distant S/R level (highest raw R:R) regardless of strength or proximity.
|
||
The fix replaces max-R:R selection with quality-score selection.
|
||
|
||
Since the code is already fixed, these tests PASS on the current codebase.
|
||
On the unfixed code they would FAIL, confirming the bug.
|
||
|
||
**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Session fixture that allows scan_ticker to commit
|
||
# ---------------------------------------------------------------------------
|
||
# The default db_session fixture wraps in session.begin() which conflicts
|
||
# with scan_ticker's internal commit(). We use a plain session instead.
|
||
|
||
@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 realistic OHLCV bars with small daily variation.
|
||
|
||
Produces bars where close ≈ base_close, with enough range for ATR
|
||
computation (needs >= 15 bars). The ATR will be roughly 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deterministic test: strong-near vs weak-far (long setup)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
|
||
"""With a strong nearby resistance and a weak distant resistance, the
|
||
scanner should pick the strong nearby one — NOT the most distant.
|
||
|
||
On unfixed code this would fail because max-R:R always picks the
|
||
farthest level.
|
||
"""
|
||
ticker = Ticker(symbol="EXPLR")
|
||
scan_session.add(ticker)
|
||
await scan_session.flush()
|
||
|
||
# 20 bars closing around 100
|
||
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
|
||
scan_session.add_all(bars)
|
||
|
||
# With ATR=2.0 and multiplier=1.5, risk=3.0.
|
||
# R:R threshold=1.5 → min reward=4.5 → min target=104.5
|
||
# Strong nearby resistance: price=105, strength=90 (R:R≈1.67, quality≈0.66)
|
||
near_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=105.0,
|
||
type="resistance",
|
||
strength=90,
|
||
detection_method="volume_profile",
|
||
)
|
||
# Weak distant resistance: price=130, strength=5 (R:R=10, quality≈0.58)
|
||
far_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=130.0,
|
||
type="resistance",
|
||
strength=5,
|
||
detection_method="volume_profile",
|
||
)
|
||
scan_session.add_all([near_level, far_level])
|
||
await scan_session.flush()
|
||
|
||
setups = await scan_ticker(scan_session, "EXPLR", 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
|
||
# The scanner must NOT pick the most distant level (130)
|
||
assert selected_target != pytest.approx(130.0, abs=0.01), (
|
||
"Bug: scanner picked the weak distant level (130) instead of the "
|
||
"strong nearby level (105)"
|
||
)
|
||
# It should pick the strong nearby level
|
||
assert selected_target == pytest.approx(105.0, abs=0.01)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deterministic test: strong-near vs weak-far (short setup)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_prefers_strong_near_over_weak_far(scan_session: AsyncSession):
|
||
"""Short-side mirror: strong nearby support should be preferred over
|
||
weak distant support.
|
||
"""
|
||
ticker = Ticker(symbol="EXPLS")
|
||
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)
|
||
|
||
# With ATR=2.0 and multiplier=1.5, risk=3.0.
|
||
# R:R threshold=1.5 → min reward=4.5 → min target below 95.5
|
||
# Strong nearby support: price=95, strength=85 (R:R≈1.67, quality≈0.64)
|
||
near_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=95.0,
|
||
type="support",
|
||
strength=85,
|
||
detection_method="pivot_point",
|
||
)
|
||
# Weak distant support: price=70, strength=5 (R:R=10, quality≈0.58)
|
||
far_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=70.0,
|
||
type="support",
|
||
strength=5,
|
||
detection_method="pivot_point",
|
||
)
|
||
scan_session.add_all([near_level, far_level])
|
||
await scan_session.flush()
|
||
|
||
setups = await scan_ticker(scan_session, "EXPLS", 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
|
||
assert selected_target != pytest.approx(70.0, abs=0.01), (
|
||
"Bug: scanner picked the weak distant level (70) instead of the "
|
||
"strong nearby level (95)"
|
||
)
|
||
assert selected_target == pytest.approx(95.0, abs=0.01)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hypothesis property test: selection is NOT always the most distant level
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@st.composite
|
||
def strong_near_weak_far_pair(draw: st.DrawFn) -> dict:
|
||
"""Generate a (strong-near, weak-far) resistance pair above entry=100.
|
||
|
||
Guarantees:
|
||
- near_price < far_price (both above entry)
|
||
- near_strength >> far_strength
|
||
- Both meet the R:R threshold of 1.5 given typical ATR ≈ 2 → risk ≈ 3
|
||
"""
|
||
# Near level: 5–15 above entry (R:R ≈ 1.7–5.0 with risk≈3)
|
||
near_dist = draw(st.floats(min_value=5.0, max_value=15.0))
|
||
near_strength = draw(st.integers(min_value=70, max_value=100))
|
||
|
||
# Far level: 25–60 above entry (R:R ≈ 8.3–20 with risk≈3)
|
||
far_dist = draw(st.floats(min_value=25.0, max_value=60.0))
|
||
far_strength = draw(st.integers(min_value=1, max_value=15))
|
||
|
||
return {
|
||
"near_price": 100.0 + near_dist,
|
||
"near_strength": near_strength,
|
||
"far_price": 100.0 + far_dist,
|
||
"far_strength": far_strength,
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@given(pair=strong_near_weak_far_pair())
|
||
@settings(
|
||
max_examples=15,
|
||
deadline=None,
|
||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||
)
|
||
async def test_property_scanner_does_not_always_pick_most_distant(
|
||
pair: dict,
|
||
scan_session: AsyncSession,
|
||
):
|
||
"""**Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4**
|
||
|
||
Property: when a strong nearby resistance exists alongside a weak distant
|
||
resistance, the scanner does NOT always select the most distant level.
|
||
|
||
On unfixed code this would fail for every example because max-R:R always
|
||
picks the farthest level.
|
||
"""
|
||
from tests.conftest import _test_engine, _test_session_factory
|
||
|
||
# Each hypothesis example needs a fresh DB state
|
||
async with _test_engine.begin() as conn:
|
||
from app.database import Base
|
||
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="PROP")
|
||
session.add(ticker)
|
||
await session.flush()
|
||
|
||
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
|
||
session.add_all(bars)
|
||
|
||
near_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=pair["near_price"],
|
||
type="resistance",
|
||
strength=pair["near_strength"],
|
||
detection_method="volume_profile",
|
||
)
|
||
far_level = SRLevel(
|
||
ticker_id=ticker.id,
|
||
price_level=pair["far_price"],
|
||
type="resistance",
|
||
strength=pair["far_strength"],
|
||
detection_method="volume_profile",
|
||
)
|
||
session.add_all([near_level, far_level])
|
||
await session.commit()
|
||
|
||
setups = await scan_ticker(session, "PROP", 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
|
||
most_distant = round(pair["far_price"], 4)
|
||
|
||
# The fixed scanner should prefer the strong nearby level, not the
|
||
# most distant weak one.
|
||
assert selected_target != pytest.approx(most_distant, abs=0.01), (
|
||
f"Bug: scanner picked the most distant level ({most_distant}) "
|
||
f"with strength={pair['far_strength']} over the nearby level "
|
||
f"({round(pair['near_price'], 4)}) with strength={pair['near_strength']}"
|
||
)
|