384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""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}"
|
|
)
|