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,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
|
||||
|
||||
Reference in New Issue
Block a user