Files
signal-platform/tests/unit/test_watchlist_service.py
T
dennisthiessen 30effa89b7
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped
feat: ticker search, watchlist momentum column, alpha vs S&P 500
Three usability fixes:

1. Global ticker search in the sidebar (TickerSearch) — typeahead over the
   tracked universe that opens a ticker's detail page without adding it to the
   watchlist. Also wired into the mobile nav.

2. Watchlist table shows the ticker's 12-1 momentum percentile (the top-pick
   selector) instead of the noisy full S/R-level list. Enriched from the setup
   already loaded in watchlist_service._enrich_entry — no extra query.

3. Alpha vs the S&P 500 on paper trades (open + closed). New benchmark_prices
   table + benchmark_service store SPY daily closes (a standalone series, not a
   Ticker, so it never enters the scanner / momentum ranking / rankings) via a
   new daily-pipeline step. paper_trade_service computes per-trade
   benchmark_return / alpha_pct / alpha_usd over each holding period; the open-
   trades table, dashboard, and closed-trades panel surface per-trade and total
   alpha. The list read path never makes a provider call.

Deploy: alembic upgrade head, then run the benchmark/daily job once to populate
SPY closes (alpha shows "—" until then).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:44:40 +02:00

144 lines
4.5 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_enrich_includes_momentum_percentile(session):
"""The watchlist row carries the ticker's momentum percentile (from its setup),
which replaces the old S/R-levels column in the UI."""
from app.models.trade_setup import TradeSetup
user_id = await _make_user(session)
tid = await _make_ticker(session, "AAA", score=70.0)
session.add(TradeSetup(
ticker_id=tid, direction="long", entry_price=100.0, stop_loss=95.0,
target=110.0, rr_ratio=2.0, composite_score=70.0,
momentum_percentile=88.0, detected_at=datetime.now(timezone.utc),
))
await session.commit()
await add_manual_entry(session, user_id, "AAA")
rows = await get_watchlist(session, user_id)
assert rows[0]["momentum_percentile"] == 88.0
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