6e06f51bb6
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>
125 lines
3.8 KiB
Python
125 lines
3.8 KiB
Python
"""Tests for the user-curated watchlist service.
|
|
|
|
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 date, datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
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.services.watchlist_service import (
|
|
WATCHLIST_MAX,
|
|
add_manual_entry,
|
|
get_watchlist,
|
|
remove_entry,
|
|
)
|
|
|
|
# Plain (committing) session — the watchlist service commits, so the rolling-back
|
|
# db_session fixture would fight it. Reuse the shared in-memory engine.
|
|
from tests.conftest import _test_session_factory # type: ignore
|
|
|
|
|
|
@pytest.fixture
|
|
async def session():
|
|
async with _test_session_factory() as s:
|
|
yield s
|
|
|
|
|
|
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
|
|
|
|
|
|
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=score,
|
|
is_stale=False,
|
|
weights_json="{}",
|
|
computed_at=datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
await session.commit()
|
|
return t.id
|
|
|
|
|
|
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"}
|
|
|
|
# Removal is permanent — nothing re-adds it.
|
|
await remove_entry(session, user_id, "AAA")
|
|
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)
|
|
row = rows[0]
|
|
assert row["last_close"] == 110.0
|
|
assert row["change_pct"] == pytest.approx(10.0)
|
|
assert row["price_date"] == today
|