make watchlist fully manual; add price + day-change, two-block overview
Per design decision: the watchlist is now purely user-curated (no auto-seeding of the top-10), so the auto_populate/dismissed machinery is removed and removals are plain deletes. Each entry is enriched with latest close + day-over-day move. Overview now shows two clear blocks: Top Setups (what to trade) and My Watchlist (my names with current price and today's %). Market watchlist table drops the now-meaningless auto/manual Type column in favour of Price and Day columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
"""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.
|
||||
A purely user-curated watchlist: the user adds and removes tickers, capped at
|
||||
WATCHLIST_MAX entries. Each entry is enriched on read with its composite score,
|
||||
best trade setup, active S/R levels, and latest price + day-over-day move.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,10 +10,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
@@ -23,13 +23,7 @@ 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"
|
||||
WATCHLIST_MAX = 20
|
||||
|
||||
|
||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
@@ -41,122 +35,39 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||
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.
|
||||
"""Add a ticker to the user's watchlist.
|
||||
|
||||
Raises DuplicateError if already on watchlist.
|
||||
Raises ValidationError if manual cap exceeded.
|
||||
Raises DuplicateError if already on the watchlist.
|
||||
Raises ValidationError if the watchlist cap is reached.
|
||||
"""
|
||||
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:
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
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:
|
||||
total = count_result.scalar() or 0
|
||||
if total >= WATCHLIST_MAX:
|
||||
raise ValidationError(
|
||||
f"Manual watchlist cap reached ({MAX_MANUAL}). "
|
||||
f"Watchlist cap reached ({WATCHLIST_MAX}). "
|
||||
"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,
|
||||
@@ -174,26 +85,20 @@ async def remove_entry(
|
||||
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.
|
||||
"""
|
||||
"""Remove a ticker from the user's watchlist."""
|
||||
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.delete(entry)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -202,7 +107,7 @@ async def _enrich_entry(
|
||||
entry: WatchlistEntry,
|
||||
symbol: str,
|
||||
) -> dict:
|
||||
"""Build enriched watchlist entry dict with scores, R:R, and SR levels."""
|
||||
"""Build enriched watchlist entry dict with scores, R:R, SR levels, price."""
|
||||
ticker_id = entry.ticker_id
|
||||
|
||||
# Composite score
|
||||
@@ -244,6 +149,23 @@ async def _enrich_entry(
|
||||
for lv in sr_result.scalars().all()
|
||||
]
|
||||
|
||||
# Latest two daily closes → current price + day-over-day move
|
||||
price_result = await db.execute(
|
||||
select(OHLCVRecord.close, OHLCVRecord.date)
|
||||
.where(OHLCVRecord.ticker_id == ticker_id)
|
||||
.order_by(OHLCVRecord.date.desc())
|
||||
.limit(2)
|
||||
)
|
||||
bars = price_result.all()
|
||||
last_close = bars[0].close if bars else None
|
||||
prev_close = bars[1].close if len(bars) > 1 else None
|
||||
change_pct = (
|
||||
(last_close - prev_close) / prev_close * 100
|
||||
if last_close is not None and prev_close
|
||||
else None
|
||||
)
|
||||
price_date = bars[0].date if bars else None
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"entry_type": entry.entry_type,
|
||||
@@ -252,6 +174,9 @@ async def _enrich_entry(
|
||||
"rr_ratio": setup.rr_ratio if setup else None,
|
||||
"rr_direction": setup.direction if setup else None,
|
||||
"sr_levels": sr_levels,
|
||||
"last_close": last_close,
|
||||
"change_pct": change_pct,
|
||||
"price_date": price_date,
|
||||
"added_at": entry.added_at,
|
||||
}
|
||||
|
||||
@@ -261,26 +186,15 @@ async def get_watchlist(
|
||||
user_id: int,
|
||||
sort_by: str = "composite",
|
||||
) -> list[dict]:
|
||||
"""Get user's watchlist with enriched data.
|
||||
"""Get the 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
|
||||
sort_by: "composite", "rr", "change", 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,
|
||||
)
|
||||
.where(WatchlistEntry.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
@@ -301,6 +215,11 @@ async def get_watchlist(
|
||||
key=lambda e: e["rr_ratio"] if e["rr_ratio"] is not None else -1,
|
||||
reverse=True,
|
||||
)
|
||||
elif sort_by == "change":
|
||||
entries.sort(
|
||||
key=lambda e: e["change_pct"] if e["change_pct"] is not None else float("-inf"),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
# Sort by a specific dimension score
|
||||
def _dim_sort_key(e: dict) -> float:
|
||||
|
||||
Reference in New Issue
Block a user