"""Tests for blue-sky projected targets. When a ticker has NO S/R level overhead in the trade direction (e.g. a stock at all-time highs), the scanner would otherwise produce no setup and the last stale one would linger. Instead we project a measured-move (ATR) target so a breakout name still yields a setup — one that faces a stricter activation bar. These tests cover the target generator's projection rule and the scanner emission. """ from __future__ import annotations from datetime import date, datetime, timedelta, timezone from types import SimpleNamespace import pytest 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.services.recommendation_service import ( PROJECTED_TARGET_ATR_MULTIPLE, PROJECTED_TARGET_STRENGTH, target_generator, ) from app.services.rr_scanner_service import scan_ticker def _lvl(price: float, type_: str, strength: int = 50, id_: int = 1) -> SimpleNamespace: return SimpleNamespace(id=id_, price_level=price, type=type_, strength=strength) class TestGenerateTargetsProjection: """target_generator.generate_targets(direction, entry, stop, sr_levels, atr).""" def test_blue_sky_long_projects_target(self): # Entry 100, stop 97 (risk 3), ATR 2 — no resistance overhead at all. targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[_lvl(90.0, "support"), _lvl(85.0, "support")], atr_value=2.0, ) assert len(targets) == 1 t = targets[0] assert t["projected"] is True assert t["sr_level_id"] == -1 assert t["sr_strength"] == PROJECTED_TARGET_STRENGTH # 100 + 3 ATR (=6) = 106; reward 6 / risk 3 = 2.0 R:R assert t["price"] == pytest.approx(100.0 + PROJECTED_TARGET_ATR_MULTIPLE * 2.0) assert t["rr_ratio"] == pytest.approx(2.0) def test_blue_sky_with_no_levels_at_all_projects(self): targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[], atr_value=2.0, ) assert len(targets) == 1 and targets[0]["projected"] is True def test_overhead_resistance_too_close_does_not_project(self): # A resistance 0.5 ATR above (< 1.0 ATR min distance) is filtered out as a # candidate — but it IS real overhead, so we must NOT project through it. targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[_lvl(101.0, "resistance")], atr_value=2.0, ) assert targets == [] def test_normal_overhead_resistance_is_not_projected(self): targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[_lvl(106.0, "resistance", strength=80)], atr_value=2.0, ) assert len(targets) == 1 assert not targets[0].get("projected") assert targets[0]["sr_level_id"] == 1 def test_resistance_tagged_straddle_does_not_project(self): # A resistance-tagged zone rep whose near edge sits just BELOW entry still # means real overhead — never project through it, even though its price is # under entry and it isn't a valid candidate. targets = target_generator.generate_targets( direction="long", entry_price=100.0, stop_loss=97.0, sr_levels=[_lvl(99.5, "resistance"), _lvl(90.0, "support")], atr_value=2.0, ) assert targets == [] def test_blue_sky_short_projects_below(self): targets = target_generator.generate_targets( direction="short", entry_price=100.0, stop_loss=103.0, sr_levels=[_lvl(110.0, "resistance")], atr_value=2.0, ) assert len(targets) == 1 assert targets[0]["projected"] is True assert targets[0]["price"] == pytest.approx(100.0 - PROJECTED_TARGET_ATR_MULTIPLE * 2.0) # --------------------------------------------------------------------------- # Scanner emission # --------------------------------------------------------------------------- @pytest.fixture async def scan_session() -> AsyncSession: from tests.conftest import _test_session_factory async with _test_session_factory() as session: yield session def _make_bars(ticker_id: int, num_bars: int = 20, base_close: float = 100.0): bars = [] start = date(2024, 1, 1) for i in range(num_bars): close = base_close + (i % 3 - 1) * 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 @pytest.mark.asyncio async def test_scan_emits_projected_long_when_blue_sky(scan_session: AsyncSession): """A ticker with only support below entry (no overhead) yields a projected long.""" ticker = Ticker(symbol="BLUESKY") scan_session.add(ticker) await scan_session.flush() scan_session.add_all(_make_bars(ticker.id, num_bars=20, base_close=100.0)) # Only support levels below entry — nothing overhead. scan_session.add_all([ SRLevel(ticker_id=ticker.id, price_level=95.0, type="support", strength=80, detection_method="pivot_point"), SRLevel(ticker_id=ticker.id, price_level=90.0, type="support", strength=60, detection_method="pivot_point"), ]) scan_session.add(CompositeScore( ticker_id=ticker.id, score=70.0, is_stale=False, weights_json="{}", computed_at=datetime.now(timezone.utc), )) await scan_session.commit() setups = await scan_ticker(scan_session, "BLUESKY", rr_threshold=1.5, atr_multiplier=1.5) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "blue-sky ticker should still yield a long setup" long_setup = long_setups[0] # Target is the measured-move projection, well above entry, R:R ~2.0. assert long_setup.target > long_setup.entry_price assert long_setup.rr_ratio == pytest.approx(2.0, abs=0.05) primary = [t for t in long_setup.targets if t.get("is_primary")] assert primary and primary[0]["projected"] is True assert primary[0]["sr_level_id"] == -1 assert any("projected-target" in c for c in long_setup.conflict_flags) @pytest.mark.asyncio async def test_scan_does_not_project_when_resistance_overhead(scan_session: AsyncSession): """With a normal resistance overhead, the long target is that S/R level, not a projection.""" ticker = Ticker(symbol="CAPPED") scan_session.add(ticker) await scan_session.flush() scan_session.add_all(_make_bars(ticker.id, num_bars=20, base_close=100.0)) scan_session.add_all([ SRLevel(ticker_id=ticker.id, price_level=106.0, type="resistance", strength=80, detection_method="pivot_point"), SRLevel(ticker_id=ticker.id, price_level=95.0, type="support", strength=60, detection_method="pivot_point"), ]) scan_session.add(CompositeScore( ticker_id=ticker.id, score=70.0, is_stale=False, weights_json="{}", computed_at=datetime.now(timezone.utc), )) await scan_session.commit() setups = await scan_ticker(scan_session, "CAPPED", rr_threshold=1.5, atr_multiplier=1.5) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1 primary = [t for t in long_setups[0].targets if t.get("is_primary")] assert primary and not primary[0].get("projected")