Files
signal-platform/app/services/watchlist_service.py
T
dennisthiessen 0e9f1846f6
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 23s
fix watchlist remove (was undone by auto-populate); add watch toggle on ticker page
Removing a ticker did nothing because get_watchlist re-runs auto_populate on
every read, instantly re-adding any top-ranked ticker the user had just removed.
Removals are now tombstoned as a "dismissed" entry_type: auto-population skips
them, the list hides them, and a later manual add revives the row. Also exposes
an Add/Remove-watchlist toggle in the ticker detail header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:17:27 +02:00

315 lines
9.5 KiB
Python

"""Watchlist service.
Auto-populates top-X tickers by composite score (default 10), supports
manual add/remove (tagged, not subject to auto-population), enforces
cap (auto + 10 manual, default max 20), and updates auto entries on
score recomputation.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.score import CompositeScore, DimensionScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.watchlist import WatchlistEntry
logger = logging.getLogger(__name__)
DEFAULT_AUTO_SIZE = 10
MAX_MANUAL = 10
# entry_type values. "dismissed" is a tombstone: the user removed the ticker, so
# auto-population must not silently re-add it on the next read. Dismissed rows are
# hidden from the watchlist and revived (→ manual) if the user adds them again.
DISMISSED = "dismissed"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
return ticker
async def auto_populate(
db: AsyncSession,
user_id: int,
top_x: int = DEFAULT_AUTO_SIZE,
) -> None:
"""Auto-populate watchlist with top-X tickers by composite score.
Replaces existing auto entries. Manual entries are untouched.
"""
# Get top-X tickers by composite score (non-stale, descending)
stmt = (
select(CompositeScore)
.where(CompositeScore.is_stale == False) # noqa: E712
.order_by(CompositeScore.score.desc())
.limit(top_x)
)
result = await db.execute(stmt)
top_scores = list(result.scalars().all())
top_ticker_ids = {cs.ticker_id for cs in top_scores}
# Delete existing auto entries for this user
await db.execute(
delete(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type == "auto",
)
)
# Get manual + dismissed ticker_ids so we don't duplicate or resurrect a
# ticker the user has explicitly removed.
kept_result = await db.execute(
select(WatchlistEntry.ticker_id).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type.in_(["manual", DISMISSED]),
)
)
excluded_ticker_ids = {row[0] for row in kept_result.all()}
now = datetime.now(timezone.utc)
for ticker_id in top_ticker_ids:
if ticker_id in excluded_ticker_ids:
continue # Already manual, or dismissed by the user
entry = WatchlistEntry(
user_id=user_id,
ticker_id=ticker_id,
entry_type="auto",
added_at=now,
)
db.add(entry)
await db.flush()
async def add_manual_entry(
db: AsyncSession,
user_id: int,
symbol: str,
) -> WatchlistEntry:
"""Add a manual watchlist entry.
Raises DuplicateError if already on watchlist.
Raises ValidationError if manual cap exceeded.
"""
ticker = await _get_ticker(db, symbol)
# Check if already on watchlist. A dismissed row is revived as manual rather
# than rejected; an active (auto/manual) row is a genuine duplicate.
existing = await db.execute(
select(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.ticker_id == ticker.id,
)
)
existing_entry = existing.scalar_one_or_none()
if existing_entry is not None and existing_entry.entry_type != DISMISSED:
raise DuplicateError(f"Ticker already on watchlist: {ticker.symbol}")
# Count current manual entries (dismissed tombstones don't count)
count_result = await db.execute(
select(func.count()).select_from(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type == "manual",
)
)
manual_count = count_result.scalar() or 0
if manual_count >= MAX_MANUAL:
raise ValidationError(
f"Manual watchlist cap reached ({MAX_MANUAL}). "
"Remove an entry before adding a new one."
)
# Check total cap (exclude dismissed tombstones)
total_result = await db.execute(
select(func.count()).select_from(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type != DISMISSED,
)
)
total_count = total_result.scalar() or 0
max_total = DEFAULT_AUTO_SIZE + MAX_MANUAL
if total_count >= max_total:
raise ValidationError(
f"Watchlist cap reached ({max_total}). "
"Remove an entry before adding a new one."
)
if existing_entry is not None:
# Revive a dismissed entry as manual.
existing_entry.entry_type = "manual"
existing_entry.added_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(existing_entry)
return existing_entry
entry = WatchlistEntry(
user_id=user_id,
ticker_id=ticker.id,
entry_type="manual",
added_at=datetime.now(timezone.utc),
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
async def remove_entry(
db: AsyncSession,
user_id: int,
symbol: str,
) -> None:
"""Remove a watchlist entry (manual or auto).
Marks the entry as dismissed (a tombstone) rather than deleting it, so the
next auto-population pass won't immediately re-add a top-ranked ticker the
user just removed. A subsequent manual add revives the row.
"""
ticker = await _get_ticker(db, symbol)
result = await db.execute(
select(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.ticker_id == ticker.id,
WatchlistEntry.entry_type != DISMISSED,
)
)
entry = result.scalar_one_or_none()
if entry is None:
raise NotFoundError(f"Ticker not on watchlist: {ticker.symbol}")
entry.entry_type = DISMISSED
await db.commit()
async def _enrich_entry(
db: AsyncSession,
entry: WatchlistEntry,
symbol: str,
) -> dict:
"""Build enriched watchlist entry dict with scores, R:R, and SR levels."""
ticker_id = entry.ticker_id
# Composite score
comp_result = await db.execute(
select(CompositeScore).where(CompositeScore.ticker_id == ticker_id)
)
comp = comp_result.scalar_one_or_none()
# Dimension scores
dim_result = await db.execute(
select(DimensionScore).where(DimensionScore.ticker_id == ticker_id)
)
dims = [
{"dimension": ds.dimension, "score": ds.score}
for ds in dim_result.scalars().all()
]
# Best trade setup (highest R:R) for this ticker
setup_result = await db.execute(
select(TradeSetup)
.where(TradeSetup.ticker_id == ticker_id)
.order_by(TradeSetup.rr_ratio.desc())
.limit(1)
)
setup = setup_result.scalar_one_or_none()
# Active SR levels
sr_result = await db.execute(
select(SRLevel)
.where(SRLevel.ticker_id == ticker_id)
.order_by(SRLevel.strength.desc())
)
sr_levels = [
{
"price_level": lv.price_level,
"type": lv.type,
"strength": lv.strength,
}
for lv in sr_result.scalars().all()
]
return {
"symbol": symbol,
"entry_type": entry.entry_type,
"composite_score": comp.score if comp else None,
"dimensions": dims,
"rr_ratio": setup.rr_ratio if setup else None,
"rr_direction": setup.direction if setup else None,
"sr_levels": sr_levels,
"added_at": entry.added_at,
}
async def get_watchlist(
db: AsyncSession,
user_id: int,
sort_by: str = "composite",
) -> list[dict]:
"""Get user's watchlist with enriched data.
Runs auto_populate first to ensure auto entries are current,
then enriches each entry with scores, R:R, and SR levels.
sort_by: "composite", "rr", or a dimension name
(e.g. "technical", "sr_quality", "sentiment", "fundamental", "momentum").
"""
# Auto-populate to refresh auto entries
await auto_populate(db, user_id)
await db.commit()
# Fetch all entries with ticker symbol
stmt = (
select(WatchlistEntry, Ticker.symbol)
.join(Ticker, WatchlistEntry.ticker_id == Ticker.id)
.where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type != DISMISSED,
)
)
result = await db.execute(stmt)
rows = result.all()
entries: list[dict] = []
for entry, symbol in rows:
enriched = await _enrich_entry(db, entry, symbol)
entries.append(enriched)
# Sort
if sort_by == "composite":
entries.sort(
key=lambda e: e["composite_score"] if e["composite_score"] is not None else -1,
reverse=True,
)
elif sort_by == "rr":
entries.sort(
key=lambda e: e["rr_ratio"] if e["rr_ratio"] is not None else -1,
reverse=True,
)
else:
# Sort by a specific dimension score
def _dim_sort_key(e: dict) -> float:
for d in e["dimensions"]:
if d["dimension"] == sort_by:
return d["score"]
return -1.0
entries.sort(key=_dim_sort_key, reverse=True)
return entries