"""Single-ticker activation ranks: manual refresh must attach residual ranks. A daily ``scan_all`` path ranks the universe and stamps each setup. Manual ``/ingestion/fetch`` re-scans one symbol; without the same stamps the new setup has ``momentum_percentile=None`` and fails the activation gate. """ from __future__ import annotations from datetime import datetime, timezone import pytest from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup from app.services import rr_scanner_service from tests.conftest import _test_session_factory # type: ignore @pytest.fixture async def session(): async with _test_session_factory() as s: yield s async def test_resolve_ranks_uses_fresh_universe_rank(session, monkeypatch): session.add(Ticker(symbol="AAA")) await session.commit() async def _fake_ranks(db): return { "AAA": { "momentum_percentile": 91.0, "strategy_rank": 88.5, "volatility_percentile": 70.0, } } monkeypatch.setattr( "app.services.momentum_service.compute_activation_ranks", _fake_ranks, ) ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "aaa") assert ranks["momentum_percentile"] == 91.0 assert ranks["strategy_rank"] == 88.5 assert ranks["volatility_percentile"] == 70.0 async def test_resolve_ranks_falls_back_to_previous_setup(session, monkeypatch): ticker = Ticker(symbol="BBB") session.add(ticker) await session.flush() session.add( TradeSetup( ticker_id=ticker.id, direction="long", entry_price=100.0, stop_loss=95.0, target=110.0, rr_ratio=2.0, composite_score=60.0, momentum_percentile=84.0, strategy_rank=80.0, volatility_percentile=55.0, detected_at=datetime.now(timezone.utc), ) ) await session.commit() async def _empty_ranks(db): return {} monkeypatch.setattr( "app.services.momentum_service.compute_activation_ranks", _empty_ranks, ) ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "BBB") assert ranks["momentum_percentile"] == 84.0 assert ranks["strategy_rank"] == 80.0 assert ranks["volatility_percentile"] == 55.0 async def test_resolve_ranks_skips_null_prior_setups(session, monkeypatch): """Broken prior setups (null percentile) must not block a still-older ranked row.""" ticker = Ticker(symbol="CCC") session.add(ticker) await session.flush() now = datetime.now(timezone.utc) session.add_all([ TradeSetup( ticker_id=ticker.id, direction="long", entry_price=100.0, stop_loss=95.0, target=110.0, rr_ratio=2.0, composite_score=60.0, momentum_percentile=93.0, strategy_rank=90.0, volatility_percentile=40.0, detected_at=now.replace(year=now.year - 1) if now.year > 2000 else now, ), TradeSetup( ticker_id=ticker.id, direction="long", entry_price=101.0, stop_loss=96.0, target=111.0, rr_ratio=2.1, composite_score=61.0, momentum_percentile=None, strategy_rank=None, volatility_percentile=None, detected_at=now, ), ]) await session.commit() async def _empty_ranks(db): return {} monkeypatch.setattr( "app.services.momentum_service.compute_activation_ranks", _empty_ranks, ) ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "CCC") assert ranks["momentum_percentile"] == 93.0 assert ranks["strategy_rank"] == 90.0 async def test_resolve_ranks_returns_empty_when_unavailable(session, monkeypatch): session.add(Ticker(symbol="DDD")) await session.commit() async def _boom(db): raise RuntimeError("ranker down") monkeypatch.setattr( "app.services.momentum_service.compute_activation_ranks", _boom, ) ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "DDD") assert ranks == { "momentum_percentile": None, "strategy_rank": None, "volatility_percentile": None, }