Files
signal-platform/tests/unit/test_rr_scanner_integration.py
T
dennisthiessen b0e33e1606 fix: align production defaults and close review parity gaps
Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator
cache invalidation, and UI/gate language that treats GTL as screening not exit.
Align strategy_rank missing-vol fallback live vs backtest, single-source
PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients.
2026-07-18 13:03:22 +02:00

339 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration tests for R:R scanner full flow with probability-based primary.
Verifies scan_ticker → enhance_trade_setup: headline target is the primary
selected by probability floors (not the pre-enhance quality candidate loop),
TradeSetup fields, and persistence.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 3.4**
"""
from __future__ import annotations
import json
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
# ---------------------------------------------------------------------------
# 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 probability primary,
# correct TradeSetup fields, and database persistence
# ===========================================================================
def _assert_headline_is_probability_primary(setup: TradeSetup) -> None:
"""Headline target/rr must match the starred primary from _select_primary_target."""
targets = setup.targets or []
assert targets, "expected generated targets after enhance"
primaries = [t for t in targets if t.get("is_primary")]
assert len(primaries) == 1, "exactly one primary target expected"
primary = primaries[0]
assert setup.target == pytest.approx(float(primary["price"]), abs=0.01)
assert setup.rr_ratio == pytest.approx(float(primary["rr_ratio"]), abs=0.01)
worthwhile = [
t for t in targets
if float(t.get("rr_ratio", 0.0)) >= 1.5 and float(t.get("probability", 0.0)) >= 20.0
]
pool = worthwhile or targets
best = max(pool, key=lambda t: (float(t["probability"]), float(t["rr_ratio"])))
assert primary["price"] == pytest.approx(float(best["price"]), abs=0.01)
@pytest.mark.asyncio
async def test_scan_ticker_full_flow_probability_primary_and_persistence(
scan_session: AsyncSession,
):
"""Integration test for the complete scan_ticker → enhance 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) → typically highest reach-prob
B: price=115, strength=40 (medium, mid)
C: price=135, strength=5 (weak, far / lottery)
- 3 support levels below (short candidates):
D: price=95, strength=85 (strong, near)
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. Headline is the probability-based primary (not a distant lottery)
3. Near/strong levels win over far/weak when they clear floors
4. rr_ratio matches the selected primary's R:R
5. 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,
gate_levels_override=sr_levels,
)
# -- 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: headline is probability primary; near/strong beats far lottery --
_assert_headline_is_probability_primary(long_setup)
_assert_headline_is_probability_primary(short_setup)
assert long_setup.target == pytest.approx(105.0, abs=0.01), (
f"Long primary should be 105.0 (near, high reach-prob), got {long_setup.target}"
)
assert short_setup.target == pytest.approx(95.0, abs=0.01), (
f"Short primary should be 95.0 (near, high reach-prob), 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)
# -- 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 --
# History is preserved: old setup remains, 2 new setups are appended
db_result = await scan_session.execute(
select(TradeSetup).where(TradeSetup.ticker_id == ticker.id)
)
persisted = list(db_result.scalars().all())
assert len(persisted) == 3, (
f"Expected 3 persisted setups (1 old + 2 new), got {len(persisted)}"
)
persisted_directions = sorted(s.direction for s in persisted)
assert persisted_directions == ["long", "long", "short"], (
f"Expected ['long', 'long', 'short'] persisted, got {persisted_directions}"
)
# Verify latest persisted records match returned setups
persisted_long = max((s for s in persisted if s.direction == "long"), key=lambda s: s.id)
persisted_short = max((s for s in persisted if s.direction == "short"), key=lambda s: s.id)
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
@pytest.mark.asyncio
async def test_scan_ticker_uses_transient_ladder_not_persisted_chart_levels(
scan_session: AsyncSession,
monkeypatch,
):
ticker = Ticker(symbol="DUAL")
scan_session.add(ticker)
await scan_session.flush()
scan_session.add_all(_make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0))
scan_session.add(SRLevel(
ticker_id=ticker.id,
price_level=130.0,
type="resistance",
strength=100,
detection_method="pivot_point",
))
await scan_session.commit()
ladder = [
{
"price_level": 105.0,
"type": "resistance",
"strength": 90,
"detection_method": "range_grid",
"sources": ["range_grid"],
"rejection_count": 5,
"last_rejection_age": None,
},
{
"price_level": 95.0,
"type": "support",
"strength": 85,
"detection_method": "range_grid",
"sources": ["range_grid"],
"rejection_count": 4,
"last_rejection_age": None,
},
]
monkeypatch.setattr(
"app.services.rr_scanner_service.detect_gate_target_ladder",
lambda highs, lows, closes: ladder,
)
setups = await scan_ticker(
scan_session,
"DUAL",
rr_threshold=1.5,
)
long_setup = next(setup for setup in setups if setup.direction == "long")
assert long_setup.target == pytest.approx(105.0, abs=0.01)
assert long_setup.target != pytest.approx(130.0, abs=0.01)
targets = json.loads(long_setup.targets_json or "[]")
assert targets
assert all(target["sr_level_id"] < 0 for target in targets)
assert all(target["sr_sources"] == ["range_grid"] for target in targets)