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.
339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""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)
|