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
+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