major update
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions

View File

@@ -0,0 +1,259 @@
"""Integration tests for R:R scanner full flow with quality-based target selection.
Verifies the complete scan_ticker pipeline: quality-based S/R level selection,
correct TradeSetup field population, and database persistence.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 3.4**
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.rr_scanner_service import scan_ticker, _compute_quality_score
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@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
# ===========================================================================
# 8.1 Integration test: full scan_ticker flow with quality-based selection,
# correct TradeSetup fields, and database persistence
# ===========================================================================
@pytest.mark.asyncio
async def test_scan_ticker_full_flow_quality_selection_and_persistence(
scan_session: AsyncSession,
):
"""Integration test for the complete scan_ticker pipeline.
Scenario:
- Entry ≈ 100, ATR ≈ 2.0, risk ≈ 3.0 (atr_multiplier=1.5)
- 3 resistance levels above (long candidates):
A: price=105, strength=90 (strong, near) → highest quality
B: price=115, strength=40 (medium, mid)
C: price=135, strength=5 (weak, far)
- 3 support levels below (short candidates):
D: price=95, strength=85 (strong, near) → highest quality
E: price=85, strength=35 (medium, mid)
F: price=65, strength=8 (weak, far)
- CompositeScore: 72.5
Verifies:
1. Both long and short setups are produced
2. Long target = Level A (highest quality, not most distant)
3. Short target = Level D (highest quality, not most distant)
4. All TradeSetup fields are correct and rounded to 4 decimals
5. rr_ratio is the actual R:R of the selected level
6. Old setups are deleted, new ones persisted
"""
# -- Setup: create ticker --
ticker = Ticker(symbol="INTEG")
scan_session.add(ticker)
await scan_session.flush()
# -- Setup: OHLCV bars (20 bars, close ≈ 100, ATR ≈ 2.0) --
bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
scan_session.add_all(bars)
# -- Setup: S/R levels --
sr_levels = [
# Long candidates (resistance above entry)
SRLevel(ticker_id=ticker.id, price_level=105.0, type="resistance",
strength=90, detection_method="volume_profile"),
SRLevel(ticker_id=ticker.id, price_level=115.0, type="resistance",
strength=40, detection_method="volume_profile"),
SRLevel(ticker_id=ticker.id, price_level=135.0, type="resistance",
strength=5, detection_method="pivot_point"),
# Short candidates (support below entry)
SRLevel(ticker_id=ticker.id, price_level=95.0, type="support",
strength=85, detection_method="volume_profile"),
SRLevel(ticker_id=ticker.id, price_level=85.0, type="support",
strength=35, detection_method="pivot_point"),
SRLevel(ticker_id=ticker.id, price_level=65.0, type="support",
strength=8, detection_method="volume_profile"),
]
scan_session.add_all(sr_levels)
# -- Setup: CompositeScore --
comp = CompositeScore(
ticker_id=ticker.id,
score=72.5,
is_stale=False,
weights_json="{}",
computed_at=datetime.now(timezone.utc),
)
scan_session.add(comp)
# -- Setup: dummy old setups that should be deleted --
old_setup = TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=99.0,
stop_loss=96.0,
target=120.0,
rr_ratio=7.0,
composite_score=50.0,
detected_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
scan_session.add(old_setup)
await scan_session.commit()
# Verify old setup exists before scan
pre_result = await scan_session.execute(
select(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
pre_setups = list(pre_result.scalars().all())
assert len(pre_setups) == 1, "Dummy old setup should exist before scan"
# -- Act: run scan_ticker --
setups = await scan_ticker(scan_session, "INTEG", rr_threshold=1.5, atr_multiplier=1.5)
# -- Assert: both directions produced --
assert len(setups) == 2, f"Expected 2 setups (long + short), got {len(setups)}"
long_setups = [s for s in setups if s.direction == "long"]
short_setups = [s for s in setups if s.direction == "short"]
assert len(long_setups) == 1, f"Expected 1 long setup, got {len(long_setups)}"
assert len(short_setups) == 1, f"Expected 1 short setup, got {len(short_setups)}"
long_setup = long_setups[0]
short_setup = short_setups[0]
# -- Assert: long target is Level A (highest quality, not most distant) --
# Level A: price=105 (strong, near) should beat Level C: price=135 (weak, far)
assert long_setup.target == pytest.approx(105.0, abs=0.01), (
f"Long target should be 105.0 (highest quality), got {long_setup.target}"
)
# -- Assert: short target is Level D (highest quality, not most distant) --
# Level D: price=95 (strong, near) should beat Level F: price=65 (weak, far)
assert short_setup.target == pytest.approx(95.0, abs=0.01), (
f"Short target should be 95.0 (highest quality), got {short_setup.target}"
)
# -- Assert: entry_price is the last close (≈ 100) --
# Last bar: index 19, close = 100 + (19 % 3 - 1) * 0.5 = 100 + 0*0.5 = 100.0
expected_entry = 100.0
assert long_setup.entry_price == pytest.approx(expected_entry, abs=0.5)
assert short_setup.entry_price == pytest.approx(expected_entry, abs=0.5)
entry = long_setup.entry_price # actual entry for R:R calculations
# -- Assert: stop_loss values --
# ATR ≈ 2.0, risk = ATR × 1.5 = 3.0
# Long stop = entry - risk, Short stop = entry + risk
risk = long_setup.entry_price - long_setup.stop_loss
assert risk > 0, "Long risk must be positive"
assert short_setup.stop_loss > short_setup.entry_price, "Short stop must be above entry"
# -- Assert: rr_ratio is the actual R:R of the selected level --
long_reward = long_setup.target - long_setup.entry_price
long_expected_rr = round(long_reward / risk, 4)
assert long_setup.rr_ratio == pytest.approx(long_expected_rr, abs=0.01), (
f"Long rr_ratio should be actual R:R={long_expected_rr}, got {long_setup.rr_ratio}"
)
short_risk = short_setup.stop_loss - short_setup.entry_price
short_reward = short_setup.entry_price - short_setup.target
short_expected_rr = round(short_reward / short_risk, 4)
assert short_setup.rr_ratio == pytest.approx(short_expected_rr, abs=0.01), (
f"Short rr_ratio should be actual R:R={short_expected_rr}, got {short_setup.rr_ratio}"
)
# -- Assert: composite_score matches --
assert long_setup.composite_score == pytest.approx(72.5, abs=0.01)
assert short_setup.composite_score == pytest.approx(72.5, abs=0.01)
# -- Assert: ticker_id is correct --
assert long_setup.ticker_id == ticker.id
assert short_setup.ticker_id == ticker.id
# -- Assert: detected_at is set --
assert long_setup.detected_at is not None
assert short_setup.detected_at is not None
# -- Assert: fields are rounded to 4 decimal places --
for setup in [long_setup, short_setup]:
for field_name in ("entry_price", "stop_loss", "target", "rr_ratio", "composite_score"):
val = getattr(setup, field_name)
rounded = round(val, 4)
assert val == pytest.approx(rounded, abs=1e-6), (
f"{setup.direction} {field_name}={val} not rounded to 4 decimals"
)
# -- Assert: database persistence --
# Old dummy setup should be gone, only the 2 new setups should exist
db_result = await scan_session.execute(
select(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
persisted = list(db_result.scalars().all())
assert len(persisted) == 2, (
f"Expected 2 persisted setups (old deleted), got {len(persisted)}"
)
persisted_directions = sorted(s.direction for s in persisted)
assert persisted_directions == ["long", "short"], (
f"Expected ['long', 'short'] persisted, got {persisted_directions}"
)
# Verify persisted records match returned setups
persisted_long = [s for s in persisted if s.direction == "long"][0]
persisted_short = [s for s in persisted if s.direction == "short"][0]
assert persisted_long.target == long_setup.target
assert persisted_long.rr_ratio == long_setup.rr_ratio
assert persisted_long.entry_price == long_setup.entry_price
assert persisted_long.stop_loss == long_setup.stop_loss
assert persisted_long.composite_score == long_setup.composite_score
assert persisted_short.target == short_setup.target
assert persisted_short.rr_ratio == short_setup.rr_ratio
assert persisted_short.entry_price == short_setup.entry_price
assert persisted_short.stop_loss == short_setup.stop_loss
assert persisted_short.composite_score == short_setup.composite_score