diff --git a/app/scheduler.py b/app/scheduler.py index c232fff..d8b540d 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -218,9 +218,10 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]: """Symbols to fetch sentiment for, budgeted to stay in the free search tier. Scope: only tickers that matter — watchlist + open paper trades + top-N by - composite score. Skip any refreshed within ``sentiment_fresh_hours``. Cap the - run at ``sentiment_max_per_run``, oldest/missing first. Once the relevant set - is fresh, runs make zero grounded searches until it ages out. + composite score + the momentum leaders the activation gate qualifies on. Skip + any refreshed within ``sentiment_fresh_hours``. Cap the run at + ``sentiment_max_per_run``, oldest/missing first. Once the relevant set is + fresh, runs make zero grounded searches until it ages out. """ from app.models.paper_trade import PaperTrade from app.models.score import CompositeScore @@ -244,6 +245,31 @@ async def _get_sentiment_priority_tickers(db: AsyncSession) -> list[str]: ) relevant.update(r[0] for r in top.all()) + # Momentum leaders: the tickers that can clear the activation gate, which + # selects the top ``min_momentum_percentile`` slice by 12-1 momentum — a + # different axis than composite score. The gate qualifies setups on this + # percentile, so without including them a freshly-qualifying ticker carries no + # sentiment and gets enhanced as neutral. Pre-fetching their sentiment here (in + # the daily pipeline, sentiment runs right after the OHLCV refresh) means the + # following R:R scan reads real sentiment for the setups it qualifies. + # Best-effort: a momentum/config failure must not stop sentiment collection. + try: + from app.services import momentum_service + from app.services.admin_service import get_activation_config + + activation = await get_activation_config(db) + min_pct = float(activation.get("min_momentum_percentile", 0.0)) + if min_pct > 0: + percentiles = await momentum_service.compute_momentum_percentiles(db) + leaders = [sym for sym, pct in percentiles.items() if pct >= min_pct] + if leaders: + rows = await db.execute( + select(Ticker.id).where(Ticker.symbol.in_(leaders)) + ) + relevant.update(r[0] for r in rows.all()) + except Exception: + logger.exception("Sentiment momentum-leader scoping failed; using base relevant set") + if not relevant: return [] diff --git a/tests/unit/test_sentiment_priority.py b/tests/unit/test_sentiment_priority.py new file mode 100644 index 0000000..376e68d --- /dev/null +++ b/tests/unit/test_sentiment_priority.py @@ -0,0 +1,81 @@ +"""Tests for sentiment-collection scoping (``_get_sentiment_priority_tickers``). + +The activation gate qualifies setups on 12-1 momentum percentile, a different +axis than composite score. These tests pin the fix that adds the gate's momentum +leaders to the sentiment relevant-set so a freshly-qualifying ticker isn't left +without sentiment. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import pytest + +from app import scheduler +from app.models.ohlcv import OHLCVRecord +from app.models.settings import SystemSetting +from app.models.ticker import Ticker + + +@pytest.fixture +async def session(): + from tests.conftest import _test_session_factory + + async with _test_session_factory() as s: + yield s + + +async def _seed_history(session, symbol: str, rate: float, n: int = 280) -> Ticker: + """Seed a ticker with a full year+ of daily closes growing at ``rate``.""" + t = Ticker(symbol=symbol) + session.add(t) + await session.flush() + base = date(2024, 1, 1) + for i in range(n): + close = 100.0 * (rate ** i) + session.add(OHLCVRecord( + ticker_id=t.id, + date=base + timedelta(days=i), + open=close, high=close, low=close, close=close, + volume=1_000_000, + )) + await session.commit() + return t + + +async def _set_min_momentum(session, value: str) -> None: + session.add(SystemSetting( + key="activation_min_momentum_percentile", + value=value, + updated_at=datetime.now(timezone.utc), + )) + await session.commit() + + +async def test_momentum_leader_is_included_without_composite_or_watchlist(session): + """A top-percentile momentum ticker is fetched even when it has no composite + score, no watchlist entry, and no open trade — the case that previously left + qualifying setups with no sentiment.""" + await _seed_history(session, "LEADER", rate=1.010) # strong uptrend → pct 100 + await _seed_history(session, "LAGGARD", rate=0.999) # declining → pct 0 + await _set_min_momentum(session, "80") + + symbols = await scheduler._get_sentiment_priority_tickers(session) + + assert "LEADER" in symbols + # Below the gate's percentile and not otherwise relevant → not fetched. + assert "LAGGARD" not in symbols + + +async def test_momentum_leaders_skipped_when_gate_disabled(session): + """With the momentum gate off (min percentile 0), the leader is no longer + pulled in solely on momentum — scoping falls back to the base relevant set.""" + await _seed_history(session, "LEADER", rate=1.010) + await _seed_history(session, "LAGGARD", rate=0.999) + await _set_min_momentum(session, "0") + + symbols = await scheduler._get_sentiment_priority_tickers(session) + + assert "LEADER" not in symbols + assert "LAGGARD" not in symbols