feat: always-fresh sentiment for top picks, watchlist & open trades
Tiered, uncapped sentiment scope so the names that matter are never shown without sentiment. - Priority (always fully refreshed): top-pick feeders — momentum leaders with a tradeable long setup over the R:R floor (the tickers that are, or could become with positive sentiment, the dashboard top pick) — plus the curated watchlist and open paper trades. - Filler: top-N by composite, a discovery net, fetched after the priority set so a mid-run rate limit lands the important names first. - Removed the per-run cap (sentiment_max_per_run): the relevant set is naturally bounded (watchlist <= 20, composite <= top_composite), so a full refresh stays inside the free tier. extra="ignore" keeps a stale env var from breaking startup. - Refresh window 72h -> 120h (5 days): sentiment shifts slowly, score window is 7d. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,27 @@
|
||||
"""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.
|
||||
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 date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app import scheduler
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
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
|
||||
@@ -26,56 +32,216 @@ async def session():
|
||||
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``."""
|
||||
async def _add_ticker(session, symbol: str) -> Ticker:
|
||||
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),
|
||||
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 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")
|
||||
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 "LEADER" in symbols
|
||||
# Below the gate's percentile and not otherwise relevant → not fetched.
|
||||
assert "FEEDER" in symbols
|
||||
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")
|
||||
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 "LEADER" not in symbols
|
||||
assert "LAGGARD" not in symbols
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user