"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``). A dashboard 'top pick' is the highest-momentum *qualified* long setup. Sentiment can never move a ticker's momentum percentile (the gate's core axis) — only its confidence and EV ranking. So the tickers that are, or could become with positive sentiment, a top pick are exactly the momentum leaders that already carry a tradeable long setup over the R:R floor. These tests pin that priority tier (always refreshed, cap-exempt) and the capped filler tier behind it. """ from __future__ import annotations from datetime import datetime, timedelta, timezone import pytest from app import scheduler from app.models.paper_trade import PaperTrade from app.models.score import CompositeScore from app.models.sentiment import SentimentScore from app.models.settings import SystemSetting from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup from app.models.watchlist import WatchlistEntry @pytest.fixture async def session(): from tests.conftest import _test_session_factory async with _test_session_factory() as s: yield s async def _add_ticker(session, symbol: str) -> Ticker: t = Ticker(symbol=symbol) session.add(t) await session.flush() return t async def _add_setup( session, ticker: Ticker, *, direction: str = "long", momentum_percentile: float | None = 95.0, rr_ratio: float = 2.0, detected_at: datetime | None = None, ) -> TradeSetup: session.add(TradeSetup( ticker_id=ticker.id, direction=direction, entry_price=100.0, stop_loss=95.0, target=110.0, rr_ratio=rr_ratio, composite_score=60.0, momentum_percentile=momentum_percentile, detected_at=detected_at or datetime.now(timezone.utc), )) await session.commit() async def _add_composite(session, ticker: Ticker, score: float) -> None: session.add(CompositeScore( ticker_id=ticker.id, score=score, is_stale=False, weights_json="{}", computed_at=datetime.now(timezone.utc), )) await session.commit() async def _add_watchlist(session, ticker: Ticker) -> None: session.add(WatchlistEntry( user_id=1, ticker_id=ticker.id, entry_type="manual", added_at=datetime.now(timezone.utc), )) await session.commit() async def _add_open_trade(session, ticker: Ticker) -> None: session.add(PaperTrade( user_id=1, ticker_id=ticker.id, direction="long", entry_price=100.0, shares=10.0, stop_loss=95.0, target=110.0, status="open", opened_at=datetime.now(timezone.utc), )) await session.commit() async def _add_sentiment(session, ticker: Ticker, hours_ago: float) -> None: session.add(SentimentScore( ticker_id=ticker.id, classification="bullish", confidence=80, source="test", timestamp=datetime.now(timezone.utc) - timedelta(hours=hours_ago), )) await session.commit() async def _set_setting(session, key: str, value: str) -> None: session.add(SystemSetting(key=key, value=value, updated_at=datetime.now(timezone.utc))) await session.commit() async def test_top_pick_feeder_included_below_cutoff_excluded(session): """A momentum leader with a tradeable long setup over the R:R floor is fetched; one whose setup is below the gate's percentile is not.""" feeder = await _add_ticker(session, "FEEDER") await _add_setup(session, feeder, momentum_percentile=95.0) laggard = await _add_ticker(session, "LAGGARD") await _add_setup(session, laggard, momentum_percentile=50.0) # below the gate await _set_setting(session, "activation_min_momentum_percentile", "80") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "FEEDER" in symbols assert "LAGGARD" not in symbols async def test_leader_without_a_setup_excluded(session): """A ticker with no long setup can't be a top pick, so it's no longer pulled in on momentum alone — the budget goes to actual top-pick feeders.""" await _add_ticker(session, "NOSETUP") await _set_setting(session, "activation_min_momentum_percentile", "80") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "NOSETUP" not in symbols async def test_short_only_setup_excluded(session): """The gate is long-only while active; a short setup can never be a top pick, so positive sentiment can't promote it and it stays out of scope.""" t = await _add_ticker(session, "SHORTY") await _add_setup(session, t, direction="short", momentum_percentile=95.0) await _set_setting(session, "activation_min_momentum_percentile", "80") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "SHORTY" not in symbols async def test_long_setup_below_rr_floor_excluded(session): """A long leader whose setup doesn't clear the R:R floor isn't tradeable as a top pick regardless of sentiment.""" t = await _add_ticker(session, "THINRR") await _add_setup(session, t, momentum_percentile=95.0, rr_ratio=0.5) await _set_setting(session, "activation_min_momentum_percentile", "80") await _set_setting(session, "activation_min_rr", "1.2") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "THINRR" not in symbols async def test_gate_disabled_no_priority_tier(session): """With the momentum gate off there is no leader axis to anchor on, so a strong long setup is not pulled in on its own — scope falls back to the filler set.""" t = await _add_ticker(session, "FEEDER") await _add_setup(session, t, momentum_percentile=95.0) await _set_setting(session, "activation_min_momentum_percentile", "0") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "FEEDER" not in symbols async def test_fresh_feeder_skipped_stale_refetched(session): """A feeder refreshed within the fresh window is skipped; one past it is re-fetched.""" fresh = await _add_ticker(session, "FRESH") await _add_setup(session, fresh, momentum_percentile=95.0) await _add_sentiment(session, fresh, hours_ago=1.0) stale = await _add_ticker(session, "STALE") await _add_setup(session, stale, momentum_percentile=95.0) await _add_sentiment(session, stale, hours_ago=settings_fresh_hours() + 50) await _set_setting(session, "activation_min_momentum_percentile", "80") symbols = await scheduler._get_sentiment_priority_tickers(session) assert "FRESH" not in symbols assert "STALE" in symbols async def test_watchlist_and_open_trades_always_included(session): """The curated watchlist and open paper trades are always in scope — they're the set we never want shown without sentiment, independent of any top pick.""" await _set_setting(session, "activation_min_momentum_percentile", "80") wl = await _add_ticker(session, "WATCHED") await _add_watchlist(session, wl) held = await _add_ticker(session, "HELD") await _add_open_trade(session, held) symbols = await scheduler._get_sentiment_priority_tickers(session) assert "WATCHED" in symbols assert "HELD" in symbols async def test_dismissed_watchlist_entry_excluded(session): """A dismissed watchlist entry is not refreshed.""" await _set_setting(session, "activation_min_momentum_percentile", "80") t = await _add_ticker(session, "DISMISSED") session.add(WatchlistEntry( user_id=1, ticker_id=t.id, entry_type="dismissed", added_at=datetime.now(timezone.utc), )) await session.commit() symbols = await scheduler._get_sentiment_priority_tickers(session) assert "DISMISSED" not in symbols async def test_no_per_run_cap_everything_stale_is_fetched(session, monkeypatch): """No truncation: every stale name in the relevant set is returned, however many there are (the cap was removed).""" await _set_setting(session, "activation_min_momentum_percentile", "80") feeders = [f"F{i:02d}" for i in range(30)] # well past the old cap of 25 for sym in feeders: t = await _add_ticker(session, sym) await _add_setup(session, t, momentum_percentile=95.0) filler = await _add_ticker(session, "FILL") await _add_composite(session, filler, score=99.0) symbols = await scheduler._get_sentiment_priority_tickers(session) assert set(feeders).issubset(set(symbols)) # all feeders, no truncation assert "FILL" in symbols # filler fetched too — nothing crowded out def settings_fresh_hours() -> float: return float(scheduler.settings.sentiment_fresh_hours)