30effa89b7
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>
144 lines
4.5 KiB
Python
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
|