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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -33,4 +33,7 @@ class WatchlistEntryResponse(BaseModel):
|
|||||||
rr_ratio: float | None = None
|
rr_ratio: float | None = None
|
||||||
rr_direction: str | None = None
|
rr_direction: str | None = None
|
||||||
sr_levels: list[SRLevelSummary] = []
|
sr_levels: list[SRLevelSummary] = []
|
||||||
|
last_close: float | None = None
|
||||||
|
change_pct: float | None = None
|
||||||
|
price_date: date | None = None
|
||||||
added_at: datetime
|
added_at: datetime
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Watchlist service.
|
"""Watchlist service.
|
||||||
|
|
||||||
Auto-populates top-X tickers by composite score (default 10), supports
|
A purely user-curated watchlist: the user adds and removes tickers, capped at
|
||||||
manual add/remove (tagged, not subject to auto-population), enforces
|
WATCHLIST_MAX entries. Each entry is enriched on read with its composite score,
|
||||||
cap (auto + 10 manual, default max 20), and updates auto entries on
|
best trade setup, active S/R levels, and latest price + day-over-day move.
|
||||||
score recomputation.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,10 +10,11 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||||
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.score import CompositeScore, DimensionScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
from app.models.sr_level import SRLevel
|
from app.models.sr_level import SRLevel
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
@@ -23,13 +23,7 @@ from app.models.watchlist import WatchlistEntry
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_AUTO_SIZE = 10
|
WATCHLIST_MAX = 20
|
||||||
MAX_MANUAL = 10
|
|
||||||
|
|
||||||
# entry_type values. "dismissed" is a tombstone: the user removed the ticker, so
|
|
||||||
# auto-population must not silently re-add it on the next read. Dismissed rows are
|
|
||||||
# hidden from the watchlist and revived (→ manual) if the user adds them again.
|
|
||||||
DISMISSED = "dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
||||||
@@ -41,122 +35,39 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
|
|||||||
return ticker
|
return ticker
|
||||||
|
|
||||||
|
|
||||||
async def auto_populate(
|
|
||||||
db: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
top_x: int = DEFAULT_AUTO_SIZE,
|
|
||||||
) -> None:
|
|
||||||
"""Auto-populate watchlist with top-X tickers by composite score.
|
|
||||||
|
|
||||||
Replaces existing auto entries. Manual entries are untouched.
|
|
||||||
"""
|
|
||||||
# Get top-X tickers by composite score (non-stale, descending)
|
|
||||||
stmt = (
|
|
||||||
select(CompositeScore)
|
|
||||||
.where(CompositeScore.is_stale == False) # noqa: E712
|
|
||||||
.order_by(CompositeScore.score.desc())
|
|
||||||
.limit(top_x)
|
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
top_scores = list(result.scalars().all())
|
|
||||||
top_ticker_ids = {cs.ticker_id for cs in top_scores}
|
|
||||||
|
|
||||||
# Delete existing auto entries for this user
|
|
||||||
await db.execute(
|
|
||||||
delete(WatchlistEntry).where(
|
|
||||||
WatchlistEntry.user_id == user_id,
|
|
||||||
WatchlistEntry.entry_type == "auto",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get manual + dismissed ticker_ids so we don't duplicate or resurrect a
|
|
||||||
# ticker the user has explicitly removed.
|
|
||||||
kept_result = await db.execute(
|
|
||||||
select(WatchlistEntry.ticker_id).where(
|
|
||||||
WatchlistEntry.user_id == user_id,
|
|
||||||
WatchlistEntry.entry_type.in_(["manual", DISMISSED]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
excluded_ticker_ids = {row[0] for row in kept_result.all()}
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
for ticker_id in top_ticker_ids:
|
|
||||||
if ticker_id in excluded_ticker_ids:
|
|
||||||
continue # Already manual, or dismissed by the user
|
|
||||||
entry = WatchlistEntry(
|
|
||||||
user_id=user_id,
|
|
||||||
ticker_id=ticker_id,
|
|
||||||
entry_type="auto",
|
|
||||||
added_at=now,
|
|
||||||
)
|
|
||||||
db.add(entry)
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
async def add_manual_entry(
|
async def add_manual_entry(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> WatchlistEntry:
|
) -> WatchlistEntry:
|
||||||
"""Add a manual watchlist entry.
|
"""Add a ticker to the user's watchlist.
|
||||||
|
|
||||||
Raises DuplicateError if already on watchlist.
|
Raises DuplicateError if already on the watchlist.
|
||||||
Raises ValidationError if manual cap exceeded.
|
Raises ValidationError if the watchlist cap is reached.
|
||||||
"""
|
"""
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
|
|
||||||
# Check if already on watchlist. A dismissed row is revived as manual rather
|
|
||||||
# than rejected; an active (auto/manual) row is a genuine duplicate.
|
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
select(WatchlistEntry).where(
|
select(WatchlistEntry).where(
|
||||||
WatchlistEntry.user_id == user_id,
|
WatchlistEntry.user_id == user_id,
|
||||||
WatchlistEntry.ticker_id == ticker.id,
|
WatchlistEntry.ticker_id == ticker.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_entry = existing.scalar_one_or_none()
|
if existing.scalar_one_or_none() is not None:
|
||||||
if existing_entry is not None and existing_entry.entry_type != DISMISSED:
|
|
||||||
raise DuplicateError(f"Ticker already on watchlist: {ticker.symbol}")
|
raise DuplicateError(f"Ticker already on watchlist: {ticker.symbol}")
|
||||||
|
|
||||||
# Count current manual entries (dismissed tombstones don't count)
|
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
select(func.count()).select_from(WatchlistEntry).where(
|
select(func.count()).select_from(WatchlistEntry).where(
|
||||||
WatchlistEntry.user_id == user_id,
|
WatchlistEntry.user_id == user_id,
|
||||||
WatchlistEntry.entry_type == "manual",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
manual_count = count_result.scalar() or 0
|
total = count_result.scalar() or 0
|
||||||
|
if total >= WATCHLIST_MAX:
|
||||||
if manual_count >= MAX_MANUAL:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Manual watchlist cap reached ({MAX_MANUAL}). "
|
f"Watchlist cap reached ({WATCHLIST_MAX}). "
|
||||||
"Remove an entry before adding a new one."
|
"Remove an entry before adding a new one."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check total cap (exclude dismissed tombstones)
|
|
||||||
total_result = await db.execute(
|
|
||||||
select(func.count()).select_from(WatchlistEntry).where(
|
|
||||||
WatchlistEntry.user_id == user_id,
|
|
||||||
WatchlistEntry.entry_type != DISMISSED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
total_count = total_result.scalar() or 0
|
|
||||||
max_total = DEFAULT_AUTO_SIZE + MAX_MANUAL
|
|
||||||
|
|
||||||
if total_count >= max_total:
|
|
||||||
raise ValidationError(
|
|
||||||
f"Watchlist cap reached ({max_total}). "
|
|
||||||
"Remove an entry before adding a new one."
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_entry is not None:
|
|
||||||
# Revive a dismissed entry as manual.
|
|
||||||
existing_entry.entry_type = "manual"
|
|
||||||
existing_entry.added_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(existing_entry)
|
|
||||||
return existing_entry
|
|
||||||
|
|
||||||
entry = WatchlistEntry(
|
entry = WatchlistEntry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
ticker_id=ticker.id,
|
ticker_id=ticker.id,
|
||||||
@@ -174,26 +85,20 @@ async def remove_entry(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remove a watchlist entry (manual or auto).
|
"""Remove a ticker from the user's watchlist."""
|
||||||
|
|
||||||
Marks the entry as dismissed (a tombstone) rather than deleting it, so the
|
|
||||||
next auto-population pass won't immediately re-add a top-ranked ticker the
|
|
||||||
user just removed. A subsequent manual add revives the row.
|
|
||||||
"""
|
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(WatchlistEntry).where(
|
select(WatchlistEntry).where(
|
||||||
WatchlistEntry.user_id == user_id,
|
WatchlistEntry.user_id == user_id,
|
||||||
WatchlistEntry.ticker_id == ticker.id,
|
WatchlistEntry.ticker_id == ticker.id,
|
||||||
WatchlistEntry.entry_type != DISMISSED,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
entry = result.scalar_one_or_none()
|
entry = result.scalar_one_or_none()
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise NotFoundError(f"Ticker not on watchlist: {ticker.symbol}")
|
raise NotFoundError(f"Ticker not on watchlist: {ticker.symbol}")
|
||||||
|
|
||||||
entry.entry_type = DISMISSED
|
await db.delete(entry)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -202,7 +107,7 @@ async def _enrich_entry(
|
|||||||
entry: WatchlistEntry,
|
entry: WatchlistEntry,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build enriched watchlist entry dict with scores, R:R, and SR levels."""
|
"""Build enriched watchlist entry dict with scores, R:R, SR levels, price."""
|
||||||
ticker_id = entry.ticker_id
|
ticker_id = entry.ticker_id
|
||||||
|
|
||||||
# Composite score
|
# Composite score
|
||||||
@@ -244,6 +149,23 @@ async def _enrich_entry(
|
|||||||
for lv in sr_result.scalars().all()
|
for lv in sr_result.scalars().all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Latest two daily closes → current price + day-over-day move
|
||||||
|
price_result = await db.execute(
|
||||||
|
select(OHLCVRecord.close, OHLCVRecord.date)
|
||||||
|
.where(OHLCVRecord.ticker_id == ticker_id)
|
||||||
|
.order_by(OHLCVRecord.date.desc())
|
||||||
|
.limit(2)
|
||||||
|
)
|
||||||
|
bars = price_result.all()
|
||||||
|
last_close = bars[0].close if bars else None
|
||||||
|
prev_close = bars[1].close if len(bars) > 1 else None
|
||||||
|
change_pct = (
|
||||||
|
(last_close - prev_close) / prev_close * 100
|
||||||
|
if last_close is not None and prev_close
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
price_date = bars[0].date if bars else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"entry_type": entry.entry_type,
|
"entry_type": entry.entry_type,
|
||||||
@@ -252,6 +174,9 @@ async def _enrich_entry(
|
|||||||
"rr_ratio": setup.rr_ratio if setup else None,
|
"rr_ratio": setup.rr_ratio if setup else None,
|
||||||
"rr_direction": setup.direction if setup else None,
|
"rr_direction": setup.direction if setup else None,
|
||||||
"sr_levels": sr_levels,
|
"sr_levels": sr_levels,
|
||||||
|
"last_close": last_close,
|
||||||
|
"change_pct": change_pct,
|
||||||
|
"price_date": price_date,
|
||||||
"added_at": entry.added_at,
|
"added_at": entry.added_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,26 +186,15 @@ async def get_watchlist(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
sort_by: str = "composite",
|
sort_by: str = "composite",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get user's watchlist with enriched data.
|
"""Get the user's watchlist with enriched data.
|
||||||
|
|
||||||
Runs auto_populate first to ensure auto entries are current,
|
sort_by: "composite", "rr", "change", or a dimension name
|
||||||
then enriches each entry with scores, R:R, and SR levels.
|
|
||||||
|
|
||||||
sort_by: "composite", "rr", or a dimension name
|
|
||||||
(e.g. "technical", "sr_quality", "sentiment", "fundamental", "momentum").
|
(e.g. "technical", "sr_quality", "sentiment", "fundamental", "momentum").
|
||||||
"""
|
"""
|
||||||
# Auto-populate to refresh auto entries
|
|
||||||
await auto_populate(db, user_id)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Fetch all entries with ticker symbol
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(WatchlistEntry, Ticker.symbol)
|
select(WatchlistEntry, Ticker.symbol)
|
||||||
.join(Ticker, WatchlistEntry.ticker_id == Ticker.id)
|
.join(Ticker, WatchlistEntry.ticker_id == Ticker.id)
|
||||||
.where(
|
.where(WatchlistEntry.user_id == user_id)
|
||||||
WatchlistEntry.user_id == user_id,
|
|
||||||
WatchlistEntry.entry_type != DISMISSED,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
@@ -301,6 +215,11 @@ async def get_watchlist(
|
|||||||
key=lambda e: e["rr_ratio"] if e["rr_ratio"] is not None else -1,
|
key=lambda e: e["rr_ratio"] if e["rr_ratio"] is not None else -1,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
elif sort_by == "change":
|
||||||
|
entries.sort(
|
||||||
|
key=lambda e: e["change_pct"] if e["change_pct"] is not None else float("-inf"),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Sort by a specific dimension score
|
# Sort by a specific dimension score
|
||||||
def _dim_sort_key(e: dict) -> float:
|
def _dim_sort_key(e: dict) -> float:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { WatchlistEntry } from '../../lib/types';
|
import type { WatchlistEntry } from '../../lib/types';
|
||||||
import { formatPrice } from '../../lib/format';
|
import { formatPrice } from '../../lib/format';
|
||||||
import { Badge } from '../ui/Badge';
|
|
||||||
import { useRemoveFromWatchlist } from '../../hooks/useWatchlist';
|
import { useRemoveFromWatchlist } from '../../hooks/useWatchlist';
|
||||||
|
|
||||||
function scoreColor(score: number): string {
|
function scoreColor(score: number): string {
|
||||||
@@ -10,6 +9,12 @@ function scoreColor(score: number): string {
|
|||||||
return 'text-red-400';
|
return 'text-red-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeColor(value: number): string {
|
||||||
|
if (value > 0) return 'text-emerald-400';
|
||||||
|
if (value < 0) return 'text-red-400';
|
||||||
|
return 'text-gray-300';
|
||||||
|
}
|
||||||
|
|
||||||
interface WatchlistTableProps {
|
interface WatchlistTableProps {
|
||||||
entries: WatchlistEntry[];
|
entries: WatchlistEntry[];
|
||||||
}
|
}
|
||||||
@@ -31,7 +36,8 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
|
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
|
||||||
<th className="px-4 py-3">Symbol</th>
|
<th className="px-4 py-3">Symbol</th>
|
||||||
<th className="px-4 py-3">Type</th>
|
<th className="px-4 py-3">Price</th>
|
||||||
|
<th className="px-4 py-3">Day</th>
|
||||||
<th className="px-4 py-3">Score</th>
|
<th className="px-4 py-3">Score</th>
|
||||||
<th className="px-4 py-3">Dimensions</th>
|
<th className="px-4 py-3">Dimensions</th>
|
||||||
<th className="px-4 py-3">R:R</th>
|
<th className="px-4 py-3">R:R</th>
|
||||||
@@ -54,8 +60,17 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
|||||||
{entry.symbol}
|
{entry.symbol}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5 num text-gray-200">
|
||||||
<Badge label={entry.entry_type} variant={entry.entry_type === 'auto' ? 'auto' : 'manual'} />
|
{entry.last_close !== null ? formatPrice(entry.last_close) : <span className="text-gray-500">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5 num">
|
||||||
|
{entry.change_pct !== null ? (
|
||||||
|
<span className={changeColor(entry.change_pct)}>
|
||||||
|
{entry.change_pct >= 0 ? '+' : ''}{entry.change_pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
{entry.composite_score !== null ? (
|
{entry.composite_score !== null ? (
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export interface WatchlistEntry {
|
|||||||
rr_ratio: number | null;
|
rr_ratio: number | null;
|
||||||
rr_direction: string | null;
|
rr_direction: string | null;
|
||||||
sr_levels: SRLevelSummary[];
|
sr_levels: SRLevelSummary[];
|
||||||
|
last_close: number | null;
|
||||||
|
change_pct: number | null;
|
||||||
|
price_date: string | null;
|
||||||
added_at: string;
|
added_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function DashboardPage() {
|
|||||||
() =>
|
() =>
|
||||||
[...(watchlist.data ?? [])]
|
[...(watchlist.data ?? [])]
|
||||||
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
|
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
|
||||||
.slice(0, 6),
|
.slice(0, 10),
|
||||||
[watchlist.data],
|
[watchlist.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -212,13 +212,15 @@ export default function DashboardPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Watchlist pulse */}
|
{/* My watchlist */}
|
||||||
<div className="xl:col-span-2">
|
<div className="xl:col-span-2">
|
||||||
<Section title="Watchlist Pulse" hint="top by score">
|
<Section title="My Watchlist" hint="today's move">
|
||||||
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
|
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
|
||||||
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
||||||
{watchlist.data && topWatchlist.length === 0 && (
|
{watchlist.data && topWatchlist.length === 0 && (
|
||||||
<Callout variant="empty">Watchlist is empty — add tickers on the Market page.</Callout>
|
<Callout variant="empty">
|
||||||
|
Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”.
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
{topWatchlist.length > 0 && (
|
{topWatchlist.length > 0 && (
|
||||||
<div className="glass overflow-hidden">
|
<div className="glass overflow-hidden">
|
||||||
@@ -229,13 +231,20 @@ export default function DashboardPage() {
|
|||||||
to={`/ticker/${entry.symbol}`}
|
to={`/ticker/${entry.symbol}`}
|
||||||
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
|
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
|
<span className="flex items-baseline gap-2">
|
||||||
<span className="font-medium text-gray-200">{entry.symbol}</span>
|
<span className="font-medium text-gray-200">{entry.symbol}</span>
|
||||||
<span className="flex items-center gap-4">
|
{entry.composite_score != null && (
|
||||||
{entry.rr_ratio != null && (
|
<span className="num text-[10px] text-gray-500">score {entry.composite_score.toFixed(0)}</span>
|
||||||
<span className="num text-xs text-gray-500">{entry.rr_ratio.toFixed(1)}:1</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="num text-sm font-semibold text-blue-300">
|
</span>
|
||||||
{entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'}
|
<span className="flex items-baseline gap-3">
|
||||||
|
<span className="num text-sm text-gray-200">
|
||||||
|
{entry.last_close != null ? formatPrice(entry.last_close) : '—'}
|
||||||
|
</span>
|
||||||
|
<span className={`num w-16 text-right text-sm font-semibold ${rColor(entry.change_pct)}`}>
|
||||||
|
{entry.change_pct != null
|
||||||
|
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
|
||||||
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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,
|
The watchlist is fully manual: the user adds and removes tickers, capped at
|
||||||
because get_watchlist re-runs auto_populate on every read and would instantly
|
WATCHLIST_MAX. Removals are hard deletes and must stick. Entries are enriched
|
||||||
re-add it. Removals are now tombstoned as "dismissed" so auto-population skips
|
on read with the latest price and day-over-day move.
|
||||||
them; a later manual add revives the row.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
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.score import CompositeScore
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.watchlist import WatchlistEntry
|
|
||||||
from app.services.watchlist_service import (
|
from app.services.watchlist_service import (
|
||||||
DISMISSED,
|
WATCHLIST_MAX,
|
||||||
add_manual_entry,
|
add_manual_entry,
|
||||||
auto_populate,
|
|
||||||
get_watchlist,
|
get_watchlist,
|
||||||
remove_entry,
|
remove_entry,
|
||||||
)
|
)
|
||||||
@@ -36,69 +34,91 @@ async def session():
|
|||||||
yield s
|
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)
|
user = User(username="u", password_hash="x", role="user", has_access=True)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
return user.id
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
ticker_ids: list[int] = []
|
async def _make_ticker(session, symbol: str, *, score: float | None = None) -> int:
|
||||||
for i, sym in enumerate(["AAA", "BBB", "CCC"]):
|
t = Ticker(symbol=symbol)
|
||||||
t = Ticker(symbol=sym)
|
|
||||||
session.add(t)
|
session.add(t)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
ticker_ids.append(t.id)
|
if score is not None:
|
||||||
session.add(
|
session.add(
|
||||||
CompositeScore(
|
CompositeScore(
|
||||||
ticker_id=t.id,
|
ticker_id=t.id,
|
||||||
score=90.0 - i, # AAA highest
|
score=score,
|
||||||
is_stale=False,
|
is_stale=False,
|
||||||
weights_json="{}",
|
weights_json="{}",
|
||||||
computed_at=now,
|
computed_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return user.id, ticker_ids
|
return t.id
|
||||||
|
|
||||||
|
|
||||||
async def test_remove_dismisses_and_survives_auto_populate(session):
|
async def test_add_and_remove_sticks(session):
|
||||||
user_id, _ = await _seed(session)
|
user_id = await _make_user(session)
|
||||||
|
await _make_ticker(session, "AAA", score=80.0)
|
||||||
await auto_populate(session, user_id)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
await add_manual_entry(session, user_id, "AAA")
|
||||||
rows = await get_watchlist(session, user_id)
|
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 remove_entry(session, user_id, "AAA")
|
||||||
await auto_populate(session, user_id)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
rows = await get_watchlist(session, user_id)
|
rows = await get_watchlist(session, user_id)
|
||||||
symbols = {r["symbol"] for r in rows}
|
assert 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):
|
async def test_duplicate_add_rejected(session):
|
||||||
user_id, _ = await _seed(session)
|
user_id = await _make_user(session)
|
||||||
await auto_populate(session, user_id)
|
await _make_ticker(session, "AAA")
|
||||||
await session.commit()
|
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")
|
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"
|
|
||||||
|
|
||||||
|
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)
|
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