make watchlist fully manual; add price + day-change, two-block overview
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s

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:
2026-06-14 14:25:04 +02:00
parent 0e9f1846f6
commit 6e06f51bb6
6 changed files with 164 additions and 195 deletions
+4 -1
View File
@@ -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
+44 -125
View File
@@ -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:
@@ -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) {
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Symbol</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Price</th>
<th className="px-4 py-3">Day</th>
<th className="px-4 py-3">Score</th>
<th className="px-4 py-3">Dimensions</th>
<th className="px-4 py-3">R:R</th>
@@ -54,8 +60,17 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
{entry.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<Badge label={entry.entry_type} variant={entry.entry_type === 'auto' ? 'auto' : 'manual'} />
<td className="px-4 py-3.5 num text-gray-200">
{entry.last_close !== null ? formatPrice(entry.last_close) : <span className="text-gray-500"></span>}
</td>
<td className="px-4 py-3.5 num">
{entry.change_pct !== null ? (
<span className={changeColor(entry.change_pct)}>
{entry.change_pct >= 0 ? '+' : ''}{entry.change_pct.toFixed(2)}%
</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.composite_score !== null ? (
+3
View File
@@ -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;
}
+19 -10
View File
@@ -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() {
</Section>
</div>
{/* Watchlist pulse */}
{/* My watchlist */}
<div className="xl:col-span-2">
<Section title="Watchlist Pulse" hint="top by score">
<Section title="My Watchlist" hint="today's move">
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
{watchlist.data && topWatchlist.length === 0 && (
<Callout variant="empty">Watchlist is empty add tickers on the Market page.</Callout>
<Callout variant="empty">
Your watchlist is empty open any ticker and tap Add to watchlist.
</Callout>
)}
{topWatchlist.length > 0 && (
<div className="glass overflow-hidden">
@@ -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]"
>
<span className="font-medium text-gray-200">{entry.symbol}</span>
<span className="flex items-center gap-4">
{entry.rr_ratio != null && (
<span className="num text-xs text-gray-500">{entry.rr_ratio.toFixed(1)}:1</span>
<span className="flex items-baseline gap-2">
<span className="font-medium text-gray-200">{entry.symbol}</span>
{entry.composite_score != null && (
<span className="num text-[10px] text-gray-500">score {entry.composite_score.toFixed(0)}</span>
)}
<span className="num text-sm font-semibold text-blue-300">
{entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'}
</span>
<span className="flex items-baseline gap-3">
<span className="num text-sm text-gray-200">
{entry.last_close != null ? formatPrice(entry.last_close) : '—'}
</span>
<span className={`num w-16 text-right text-sm font-semibold ${rColor(entry.change_pct)}`}>
{entry.change_pct != null
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
: '—'}
</span>
</span>
</Link>
+75 -55
View File
@@ -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