From 6e06f51bb6d0042e178d57365d3760953d18924f Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 14 Jun 2026 14:25:04 +0200 Subject: [PATCH] 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 --- app/schemas/watchlist.py | 5 +- app/services/watchlist_service.py | 169 +++++------------- .../components/watchlist/WatchlistTable.tsx | 23 ++- frontend/src/lib/types.ts | 3 + frontend/src/pages/DashboardPage.tsx | 29 +-- tests/unit/test_watchlist_service.py | 130 ++++++++------ 6 files changed, 164 insertions(+), 195 deletions(-) diff --git a/app/schemas/watchlist.py b/app/schemas/watchlist.py index 17bcce7..b0fa36f 100644 --- a/app/schemas/watchlist.py +++ b/app/schemas/watchlist.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import date, datetime from typing import Literal from pydantic import BaseModel, Field @@ -33,4 +33,7 @@ class WatchlistEntryResponse(BaseModel): rr_ratio: float | None = None rr_direction: str | None = None sr_levels: list[SRLevelSummary] = [] + last_close: float | None = None + change_pct: float | None = None + price_date: date | None = None added_at: datetime diff --git a/app/services/watchlist_service.py b/app/services/watchlist_service.py index 1a52ecc..7eebba0 100644 --- a/app/services/watchlist_service.py +++ b/app/services/watchlist_service.py @@ -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: diff --git a/frontend/src/components/watchlist/WatchlistTable.tsx b/frontend/src/components/watchlist/WatchlistTable.tsx index 799c570..209c8c1 100644 --- a/frontend/src/components/watchlist/WatchlistTable.tsx +++ b/frontend/src/components/watchlist/WatchlistTable.tsx @@ -1,7 +1,6 @@ import { Link } from 'react-router-dom'; import type { WatchlistEntry } from '../../lib/types'; import { formatPrice } from '../../lib/format'; -import { Badge } from '../ui/Badge'; import { useRemoveFromWatchlist } from '../../hooks/useWatchlist'; function scoreColor(score: number): string { @@ -10,6 +9,12 @@ function scoreColor(score: number): string { return 'text-red-400'; } +function changeColor(value: number): string { + if (value > 0) return 'text-emerald-400'; + if (value < 0) return 'text-red-400'; + return 'text-gray-300'; +} + interface WatchlistTableProps { entries: WatchlistEntry[]; } @@ -31,7 +36,8 @@ export function WatchlistTable({ entries }: WatchlistTableProps) { Symbol - Type + Price + Day Score Dimensions R:R @@ -54,8 +60,17 @@ export function WatchlistTable({ entries }: WatchlistTableProps) { {entry.symbol} - - + + {entry.last_close !== null ? formatPrice(entry.last_close) : } + + + {entry.change_pct !== null ? ( + + {entry.change_pct >= 0 ? '+' : ''}{entry.change_pct.toFixed(2)}% + + ) : ( + + )} {entry.composite_score !== null ? ( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8b9f12e..57606cd 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -20,6 +20,9 @@ export interface WatchlistEntry { rr_ratio: number | null; rr_direction: string | null; sr_levels: SRLevelSummary[]; + last_close: number | null; + change_pct: number | null; + price_date: string | null; added_at: string; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 458edf4..b1e8539 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -78,7 +78,7 @@ export default function DashboardPage() { () => [...(watchlist.data ?? [])] .sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1)) - .slice(0, 6), + .slice(0, 10), [watchlist.data], ); @@ -212,13 +212,15 @@ export default function DashboardPage() { - {/* Watchlist pulse */} + {/* My watchlist */}
-
+
{watchlist.isLoading && } {watchlist.isError && Failed to load watchlist} {watchlist.data && topWatchlist.length === 0 && ( - Watchlist is empty — add tickers on the Market page. + + Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”. + )} {topWatchlist.length > 0 && (
@@ -229,13 +231,20 @@ export default function DashboardPage() { to={`/ticker/${entry.symbol}`} className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]" > - {entry.symbol} - - {entry.rr_ratio != null && ( - {entry.rr_ratio.toFixed(1)}:1 + + {entry.symbol} + {entry.composite_score != null && ( + score {entry.composite_score.toFixed(0)} )} - - {entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'} + + + + {entry.last_close != null ? formatPrice(entry.last_close) : '—'} + + + {entry.change_pct != null + ? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%` + : '—'} diff --git a/tests/unit/test_watchlist_service.py b/tests/unit/test_watchlist_service.py index 1289bda..2fbc73a 100644 --- a/tests/unit/test_watchlist_service.py +++ b/tests/unit/test_watchlist_service.py @@ -1,26 +1,24 @@ -"""Tests for watchlist remove → dismiss → revive behaviour. +"""Tests for the user-curated watchlist service. -Regression cover for the bug where removing a top-ranked ticker did nothing, -because get_watchlist re-runs auto_populate on every read and would instantly -re-add it. Removals are now tombstoned as "dismissed" so auto-population skips -them; a later manual add revives the row. +The watchlist is fully manual: the user adds and removes tickers, capped at +WATCHLIST_MAX. Removals are hard deletes and must stick. Entries are enriched +on read with the latest price and day-over-day move. """ from __future__ import annotations -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone import pytest -from sqlalchemy import select +from app.exceptions import DuplicateError, NotFoundError, ValidationError +from app.models.ohlcv import OHLCVRecord from app.models.score import CompositeScore from app.models.ticker import Ticker from app.models.user import User -from app.models.watchlist import WatchlistEntry from app.services.watchlist_service import ( - DISMISSED, + WATCHLIST_MAX, add_manual_entry, - auto_populate, get_watchlist, remove_entry, ) @@ -36,69 +34,91 @@ async def session(): yield s -async def _seed(session) -> tuple[int, list[int]]: +async def _make_user(session) -> int: user = User(username="u", password_hash="x", role="user", has_access=True) session.add(user) await session.flush() + await session.commit() + return user.id - now = datetime.now(timezone.utc) - ticker_ids: list[int] = [] - for i, sym in enumerate(["AAA", "BBB", "CCC"]): - t = Ticker(symbol=sym) - session.add(t) - await session.flush() - ticker_ids.append(t.id) + +async def _make_ticker(session, symbol: str, *, score: float | None = None) -> int: + t = Ticker(symbol=symbol) + session.add(t) + await session.flush() + if score is not None: session.add( CompositeScore( ticker_id=t.id, - score=90.0 - i, # AAA highest + score=score, is_stale=False, weights_json="{}", - computed_at=now, + computed_at=datetime.now(timezone.utc), ) ) await session.commit() - return user.id, ticker_ids + return t.id -async def test_remove_dismisses_and_survives_auto_populate(session): - user_id, _ = await _seed(session) - - await auto_populate(session, user_id) - await session.commit() +async def test_add_and_remove_sticks(session): + user_id = await _make_user(session) + await _make_ticker(session, "AAA", score=80.0) + await add_manual_entry(session, user_id, "AAA") rows = await get_watchlist(session, user_id) - assert {r["symbol"] for r in rows} == {"AAA", "BBB", "CCC"} + assert {r["symbol"] for r in rows} == {"AAA"} - # Remove a top-ranked ticker, then force another auto-population pass. + # Removal is permanent — nothing re-adds it. await remove_entry(session, user_id, "AAA") - await auto_populate(session, user_id) + rows = await get_watchlist(session, user_id) + assert rows == [] + + +async def test_duplicate_add_rejected(session): + user_id = await _make_user(session) + await _make_ticker(session, "AAA") + await add_manual_entry(session, user_id, "AAA") + with pytest.raises(DuplicateError): + await add_manual_entry(session, user_id, "AAA") + + +async def test_remove_absent_raises(session): + user_id = await _make_user(session) + await _make_ticker(session, "AAA") + with pytest.raises(NotFoundError): + await remove_entry(session, user_id, "AAA") + + +async def test_cap_enforced(session): + user_id = await _make_user(session) + for i in range(WATCHLIST_MAX): + sym = f"T{i:02d}" + await _make_ticker(session, sym) + await add_manual_entry(session, user_id, sym) + + await _make_ticker(session, "OVER") + with pytest.raises(ValidationError): + await add_manual_entry(session, user_id, "OVER") + + +async def test_enrichment_includes_price_and_change(session): + user_id = await _make_user(session) + ticker_id = await _make_ticker(session, "AAA", score=70.0) + + today = date.today() + session.add(OHLCVRecord( + ticker_id=ticker_id, date=today - timedelta(days=1), + open=100, high=100, low=100, close=100.0, volume=1, + )) + session.add(OHLCVRecord( + ticker_id=ticker_id, date=today, + open=110, high=110, low=110, close=110.0, volume=1, + )) await session.commit() + await add_manual_entry(session, user_id, "AAA") rows = await get_watchlist(session, user_id) - symbols = {r["symbol"] for r in rows} - assert "AAA" not in symbols - assert symbols == {"BBB", "CCC"} - - # The row is tombstoned, not deleted. - res = await session.execute( - select(WatchlistEntry).join(Ticker).where(Ticker.symbol == "AAA") - ) - entry = res.scalar_one() - assert entry.entry_type == DISMISSED - - -async def test_manual_add_revives_dismissed(session): - user_id, _ = await _seed(session) - await auto_populate(session, user_id) - await session.commit() - - await remove_entry(session, user_id, "AAA") - - # Re-adding the dismissed ticker should succeed (not DuplicateError) and - # reappear as a manual entry. - revived = await add_manual_entry(session, user_id, "AAA") - assert revived.entry_type == "manual" - - rows = await get_watchlist(session, user_id) - assert "AAA" in {r["symbol"] for r in rows} + row = rows[0] + assert row["last_close"] == 110.0 + assert row["change_pct"] == pytest.approx(10.0) + assert row["price_date"] == today