fix: scope sentiment collection to the gate's momentum leaders
Qualified setups could carry no sentiment because the sentiment job scoped its relevant-set to watchlist + open trades + top-N composite score, while the activation gate qualifies on 12-1 momentum percentile — a different axis. A top-momentum ticker outside the composite top-N never got sentiment, so the R:R scan enhanced it as neutral. Add the gate's momentum leaders (percentile >= activation min_momentum_percentile) to the sentiment relevant-set so scope tracks the gate. Best-effort: a momentum or config failure falls back to the base set rather than aborting collection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
-3
@@ -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.
|
"""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
|
Scope: only tickers that matter — watchlist + open paper trades + top-N by
|
||||||
composite score. Skip any refreshed within ``sentiment_fresh_hours``. Cap the
|
composite score + the momentum leaders the activation gate qualifies on. Skip
|
||||||
run at ``sentiment_max_per_run``, oldest/missing first. Once the relevant set
|
any refreshed within ``sentiment_fresh_hours``. Cap the run at
|
||||||
is fresh, runs make zero grounded searches until it ages out.
|
``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.paper_trade import PaperTrade
|
||||||
from app.models.score import CompositeScore
|
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())
|
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:
|
if not relevant:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user