feat: ticker search, watchlist momentum column, alpha vs S&P 500
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped

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>
This commit is contained in:
2026-06-28 08:44:40 +02:00
co-authored by Claude Opus 4.8
parent 4a96f85cd9
commit 30effa89b7
21 changed files with 506 additions and 31 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import TickerSearch from './TickerSearch';
const navItems = [
{ to: '/', label: 'Overview', end: true },
@@ -46,6 +47,9 @@ export default function MobileNav() {
}`}
>
<nav className="px-3 py-2 space-y-1">
<div className="pb-2">
<TickerSearch onNavigate={() => setOpen(false)} />
</div>
{navItems.map(({ to, label, end }) => (
<NavLink
key={to}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health';
import { getRunningJobs } from '../../api/jobs';
import TickerSearch from './TickerSearch';
const navItems = [
{ to: '/', label: 'Overview', index: '01', end: true },
@@ -54,6 +55,10 @@ export default function Sidebar() {
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
</div>
<div className="px-3 pt-4">
<TickerSearch />
</div>
<nav className="flex-1 px-3 py-5 space-y-1">
{navItems.map(({ to, label, index, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
@@ -0,0 +1,96 @@
import { useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTickers } from '../../hooks/useTickers';
import { Input } from '../ui/Field';
const MAX_RESULTS = 8;
/** Jump-to-ticker search over the tracked universe. Selecting a match opens its
* detail page — it does NOT add the ticker to the watchlist. */
export default function TickerSearch({ onNavigate }: { onNavigate?: () => void }) {
const tickers = useTickers();
const navigate = useNavigate();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const blurTimer = useRef<number | null>(null);
const matches = useMemo(() => {
const query = q.trim().toUpperCase();
if (!query) return [];
const all = tickers.data ?? [];
const starts = all.filter((t) => t.symbol.toUpperCase().startsWith(query));
const contains = all.filter(
(t) => !t.symbol.toUpperCase().startsWith(query) && t.symbol.toUpperCase().includes(query),
);
return [...starts, ...contains].slice(0, MAX_RESULTS);
}, [q, tickers.data]);
const go = (symbol: string) => {
navigate(`/ticker/${symbol}`);
setQ('');
setOpen(false);
setActive(0);
onNavigate?.();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const m = matches[active];
if (m) go(m.symbol);
} else if (e.key === 'Escape') {
setQ('');
setOpen(false);
}
};
const showList = open && q.trim().length > 0 && matches.length > 0;
return (
<div className="relative">
<Input
type="text"
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setOpen(false), 120);
}}
onKeyDown={onKeyDown}
placeholder="Search ticker…"
aria-label="Search ticker"
autoComplete="off"
className="w-full"
/>
{showList && (
<ul className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-lg glass py-1 shadow-xl">
{matches.map((t, i) => (
<li key={t.symbol}>
<button
type="button"
onMouseEnter={() => setActive(i)}
onClick={() => go(t.symbol)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
i === active ? 'bg-blue-400/[0.12] text-blue-200' : 'text-gray-300 hover:bg-white/[0.04]'
}`}
>
{t.symbol}
</button>
</li>
))}
</ul>
)}
</div>
);
}