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>
This commit is contained in:
@@ -21,16 +21,21 @@ export function OpenTradesPanel() {
|
||||
const close = useClosePaperTrade();
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0;
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of trades ?? []) {
|
||||
const p = tradePnl(t);
|
||||
if (!p) continue;
|
||||
priced += 1;
|
||||
pnl += p.pnl;
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
if (p) {
|
||||
priced += 1;
|
||||
pnl += p.pnl;
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
}
|
||||
if (t.alpha_usd != null) {
|
||||
alphaUsd += t.alpha_usd;
|
||||
alphaPriced += 1;
|
||||
}
|
||||
}
|
||||
return { pnl, winners, losers, priced };
|
||||
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
|
||||
}, [trades]);
|
||||
|
||||
if (isLoading) return null;
|
||||
@@ -58,6 +63,7 @@ export function OpenTradesPanel() {
|
||||
<th className="px-4 py-3 text-right">P&L</th>
|
||||
<th className="px-4 py-3 text-right">%</th>
|
||||
<th className="px-4 py-3 text-right">R</th>
|
||||
<th className="px-4 py-3 text-right">Alpha</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,6 +96,9 @@ export function OpenTradesPanel() {
|
||||
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
|
||||
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -110,12 +119,16 @@ export function OpenTradesPanel() {
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08]">
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
||||
Total unrealized P&L
|
||||
Total unrealized P&L · alpha vs S&P 500
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
||||
{money(totals.pnl)}
|
||||
</td>
|
||||
<td colSpan={3} />
|
||||
<td colSpan={2} />
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function MyTradesPanel() {
|
||||
const rows = (closed ?? []).map((t) => ({ t, p: tradePnl(t) }));
|
||||
const rs = rows.map((r) => r.p?.r).filter((r): r is number => r != null);
|
||||
const pnls = rows.map((r) => r.p?.pnl ?? 0);
|
||||
const alphas = rows.map((r) => r.t.alpha_usd).filter((a): a is number => a != null);
|
||||
const wins = pnls.filter((p) => p > 0).length;
|
||||
const losses = pnls.filter((p) => p < 0).length;
|
||||
const decided = wins + losses;
|
||||
@@ -49,6 +50,7 @@ export function MyTradesPanel() {
|
||||
avgR: rs.length ? rs.reduce((a, b) => a + b, 0) / rs.length : null,
|
||||
totalR: rs.length ? rs.reduce((a, b) => a + b, 0) : null,
|
||||
totalPnl: pnls.reduce((a, b) => a + b, 0),
|
||||
totalAlpha: alphas.length ? alphas.reduce((a, b) => a + b, 0) : null,
|
||||
rows,
|
||||
};
|
||||
}, [closed]);
|
||||
@@ -64,11 +66,12 @@ export function MyTradesPanel() {
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
||||
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-x-auto">
|
||||
@@ -81,6 +84,7 @@ export function MyTradesPanel() {
|
||||
<th className="px-4 py-2.5 text-right">Exit</th>
|
||||
<th className="px-4 py-2.5 text-right">P&L</th>
|
||||
<th className="px-4 py-2.5 text-right">R</th>
|
||||
<th className="px-4 py-2.5 text-right">Alpha</th>
|
||||
<th className="px-4 py-2.5 text-right">Closed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -97,6 +101,7 @@ export function MyTradesPanel() {
|
||||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-500">{t.closed_at ? new Date(t.closed_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
||||
<th className="px-4 py-3">Dimensions</th>
|
||||
<th className="px-4 py-3">R:R</th>
|
||||
<th className="px-4 py-3">Direction</th>
|
||||
<th className="px-4 py-3">S/R Levels</th>
|
||||
<th className="px-4 py-3">Momentum</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -114,15 +114,9 @@ export function WatchlistTable({ entries }: WatchlistTableProps) {
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
{entry.sr_levels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.sr_levels.map((level, i) => (
|
||||
<span key={i} className={`text-xs ${level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{formatPrice(level.price_level)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<td className="px-4 py-3.5 num text-gray-200">
|
||||
{entry.momentum_percentile !== null ? (
|
||||
`${Math.round(entry.momentum_percentile)}%ile`
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface WatchlistEntry {
|
||||
dimensions: DimensionScore[];
|
||||
rr_ratio: number | null;
|
||||
rr_direction: string | null;
|
||||
momentum_percentile: number | null;
|
||||
sr_levels: SRLevelSummary[];
|
||||
last_close: number | null;
|
||||
change_pct: number | null;
|
||||
@@ -201,6 +202,9 @@ export interface PaperTrade {
|
||||
close_price: number | null;
|
||||
closed_at: string | null;
|
||||
current_price: number | null;
|
||||
benchmark_return_pct: number | null;
|
||||
alpha_pct: number | null;
|
||||
alpha_usd: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestBucket {
|
||||
|
||||
@@ -100,8 +100,10 @@ export default function DashboardPage() {
|
||||
const exposure = useMemo(() => {
|
||||
const rows = openTrades.data ?? [];
|
||||
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
|
||||
let alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of rows) {
|
||||
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
|
||||
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
|
||||
const p = tradePnl(t);
|
||||
if (!p) continue;
|
||||
unrealUsd += p.pnl;
|
||||
@@ -109,7 +111,7 @@ export default function DashboardPage() {
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
}
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers };
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
|
||||
}, [openTrades.data]);
|
||||
|
||||
return (
|
||||
@@ -141,11 +143,11 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Metric strip */}
|
||||
{(trades.isLoading || openTrades.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric
|
||||
label="Live Setups"
|
||||
value={String(trades.data?.length ?? 0)}
|
||||
@@ -172,6 +174,16 @@ export default function DashboardPage() {
|
||||
: 'mark-to-market'
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label="Alpha vs S&P 500"
|
||||
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
|
||||
valueClass={
|
||||
exposure.alphaPriced > 0
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
: 'text-gray-100'
|
||||
}
|
||||
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user