Redesign: phosphor-terminal identity and simplified 4-page structure
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 31s
Deploy / deploy (push) Successful in 22s

Information architecture (6 nav destinations -> 4):
- New Overview home: metric strip (live setups, high confidence,
  hit rate, expectancy), top-5 setups, watchlist pulse
- Market = Watchlist + Rankings merged as tabs; scoring weights
  moved into a collapsible disclosure
- Signals = Scanner + Performance merged as tabs (Setups | Track
  Record) with actions inside the panels
- Legacy routes redirect (/watchlist, /rankings, /scanner,
  /performance)

Visual identity:
- Warm ash-green dark palette replaces cold navy; citron lime
  accent replaces blue (Tailwind gray/blue remapped at config
  level so all components reskin)
- Primary buttons: lime with ink text; long/short stays
  emerald/red
- Typography: Bricolage Grotesque display, Instrument Sans body,
  IBM Plex Mono for all numerals incl. chart canvas labels
- Atmosphere: graph-paper grid + citron glow + film grain;
  pulsing brand dot; mono-numbered nav

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:42:05 +02:00
parent 21ed83c56c
commit 9c6a0a72fa
20 changed files with 548 additions and 213 deletions
+6 -3
View File
@@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal Dashboard</title>
<title>Signal — Trading Intelligence</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-[#0a0e1a] text-gray-100 font-sans">
<body class="bg-[#0e120f] text-gray-100 font-sans">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+11 -9
View File
@@ -3,11 +3,10 @@ import ProtectedRoute from './components/auth/ProtectedRoute';
import AppShell from './components/layout/AppShell';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import WatchlistPage from './pages/WatchlistPage';
import DashboardPage from './pages/DashboardPage';
import MarketPage from './pages/MarketPage';
import SignalsPage from './pages/SignalsPage';
import TickerDetailPage from './pages/TickerDetailPage';
import ScannerPage from './pages/ScannerPage';
import RankingsPage from './pages/RankingsPage';
import PerformancePage from './pages/PerformancePage';
import AdminPage from './pages/AdminPage';
export default function App() {
@@ -17,12 +16,15 @@ export default function App() {
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/watchlist" />} />
<Route path="/watchlist" element={<WatchlistPage />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/signals" element={<SignalsPage />} />
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
<Route path="/scanner" element={<ScannerPage />} />
<Route path="/rankings" element={<RankingsPage />} />
<Route path="/performance" element={<PerformancePage />} />
{/* Legacy routes from the old 6-page layout */}
<Route path="/watchlist" element={<Navigate to="/market" replace />} />
<Route path="/rankings" element={<Navigate to="/market?tab=rankings" replace />} />
<Route path="/scanner" element={<Navigate to="/signals" replace />} />
<Route path="/performance" element={<Navigate to="/signals?tab=track" replace />} />
<Route element={<ProtectedRoute requireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
@@ -43,7 +43,7 @@ export function SettingsForm() {
type="button"
onClick={() => handleToggleRegistration(setting.value)}
disabled={updateSetting.isPending}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-[#0a0e1a] disabled:opacity-50 ${
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-[#0e120f] disabled:opacity-50 ${
setting.value === 'true' ? 'bg-gradient-to-r from-blue-600 to-sky-500' : 'bg-white/[0.1]'
}`}
role="switch"
@@ -14,7 +14,7 @@ export default function ProtectedRoute({ requireAdmin }: ProtectedRouteProps) {
}
if (requireAdmin && role !== 'admin') {
return <Navigate to="/watchlist" replace />;
return <Navigate to="/" replace />;
}
return <Outlet />;
@@ -100,7 +100,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6b7280';
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= nTicks; i++) {
const v = lo + ((hi - lo) * i) / nTicks;
@@ -141,7 +141,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
// Label
ctx.fillStyle = color;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
@@ -182,7 +182,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
// Label with midpoint price and strength score
const yMid = yTop + rectHeight / 2;
ctx.fillStyle = labelColor;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(
`${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`,
@@ -238,7 +238,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
ctx.setLineDash([]);
// Labels on right side
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
@@ -334,7 +334,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
const priceMetrics = ctx.measureText(priceText);
const labelPadX = 5;
const labelPadY = 3;
+14 -8
View File
@@ -3,10 +3,9 @@ import { NavLink } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
const navItems = [
{ to: '/watchlist', label: 'Watchlist' },
{ to: '/scanner', label: 'Scanner' },
{ to: '/rankings', label: 'Rankings' },
{ to: '/performance', label: 'Performance' },
{ to: '/', label: 'Overview', end: true },
{ to: '/market', label: 'Market', end: false },
{ to: '/signals', label: 'Signals', end: false },
];
export default function MobileNav() {
@@ -16,7 +15,13 @@ export default function MobileNav() {
return (
<div className="lg:hidden">
<div className="flex items-center justify-between px-4 py-3 glass rounded-none border-x-0 border-t-0">
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 animate-signal-pulse" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-400/60" />
</span>
<h1 className="font-display text-lg font-bold tracking-tight text-gradient">Signal</h1>
</div>
<button
onClick={() => setOpen((v) => !v)}
className="p-2 text-gray-400 hover:text-gray-200 transition-colors duration-200"
@@ -40,15 +45,16 @@ export default function MobileNav() {
}`}
>
<nav className="px-3 py-2 space-y-1">
{navItems.map(({ to, label }) => (
{navItems.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white'
? 'bg-blue-400/[0.08] text-blue-300'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
@@ -63,7 +69,7 @@ export default function MobileNav() {
className={({ isActive }) =>
`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white'
? 'bg-blue-400/[0.08] text-blue-300'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
+25 -32
View File
@@ -4,12 +4,18 @@ import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health';
const navItems = [
{ to: '/watchlist', label: 'Watchlist', icon: '◈' },
{ to: '/scanner', label: 'Scanner', icon: '⬡' },
{ to: '/rankings', label: 'Rankings', icon: '△' },
{ to: '/performance', label: 'Performance', icon: '◎' },
{ to: '/', label: 'Overview', index: '01', end: true },
{ to: '/market', label: 'Market', index: '02', end: false },
{ to: '/signals', label: 'Signals', index: '03', end: false },
];
const linkClasses = (isActive: boolean) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-blue-400/[0.08] text-blue-300 border border-blue-400/20'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
}`;
export default function Sidebar() {
const { role, username, logout } = useAuthStore();
@@ -24,41 +30,28 @@ export default function Sidebar() {
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 h-screen sticky top-0 glass border-r border-white/[0.06] rounded-none border-l-0 border-t-0 border-b-0">
{/* Logo area */}
{/* Brand */}
<div className="px-6 py-6 border-b border-white/[0.06]">
<h1 className="text-lg font-semibold text-gradient">Signal Dashboard</h1>
<p className="text-[11px] text-gray-500 mt-0.5 tracking-wide">TRADING INTELLIGENCE</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 animate-signal-pulse" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-400/60" />
</span>
<h1 className="font-display text-xl font-bold tracking-tight text-gradient">Signal</h1>
</div>
<p className="text-[10px] text-gray-500 mt-1.5 font-mono uppercase tracking-[0.22em]">Trading Intelligence</p>
</div>
<nav className="flex-1 px-3 py-5 space-y-1">
{navItems.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
}`
}
>
<span className="text-base opacity-60">{icon}</span>
{navItems.map(({ to, label, index, end }) => (
<NavLink key={to} to={to} end={end} className={({ isActive }) => linkClasses(isActive)}>
<span className="font-mono text-[10px] tracking-widest opacity-50">{index}</span>
{label}
</NavLink>
))}
{role === 'admin' && (
<NavLink
to="/admin"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-white/[0.08] text-white shadow-lg shadow-blue-500/5 border border-white/[0.08]'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 border border-transparent'
}`
}
>
<span className="text-base opacity-60"></span>
<NavLink to="/admin" className={({ isActive }) => linkClasses(isActive)}>
<span className="font-mono text-[10px] tracking-widest opacity-50">04</span>
Admin
</NavLink>
)}
@@ -81,7 +74,7 @@ export default function Sidebar() {
)}
<button
onClick={logout}
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
className="w-full px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200 text-left"
>
Logout
</button>
@@ -1,17 +1,16 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTrades } from '../hooks/useTrades';
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../components/scanner/TradeTable';
import { SkeletonTable } from '../components/ui/Skeleton';
import { useToast } from '../components/ui/Toast';
import { Button } from '../components/ui/Button';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
import { Field, Input, Select } from '../components/ui/Field';
import { PageHeader } from '../components/ui/PageHeader';
import { triggerJob } from '../api/admin';
import type { TradeSetup } from '../lib/types';
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
import { useTrades } from '../../hooks/useTrades';
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
import { SkeletonTable } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Field, Input, Select } from '../ui/Field';
import { triggerJob } from '../../api/admin';
import type { TradeSetup } from '../../lib/types';
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
type DirectionFilter = 'both' | 'long' | 'short';
type ActionFilter = 'all' | 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL';
@@ -93,7 +92,7 @@ function sortTrades(
return sorted;
}
export default function ScannerPage() {
export function SetupsPanel() {
const { data: trades, isLoading, isError, error } = useTrades();
const queryClient = useQueryClient();
const toast = useToast();
@@ -132,17 +131,7 @@ export default function ScannerPage() {
}, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Trade Scanner"
subtitle="Asymmetric risk:reward setups across the ticker universe"
actions={
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
</Button>
}
/>
<div className="space-y-6">
{/* Filter toolbar */}
<div className="glass-sm flex flex-wrap items-end gap-4 p-4">
<Field label="Min Risk:Reward" htmlFor="min-rr">
@@ -196,6 +185,11 @@ export default function ScannerPage() {
<option value="NEUTRAL">{RECOMMENDATION_ACTION_LABELS.NEUTRAL}</option>
</Select>
</Field>
<div className="ml-auto">
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
</Button>
</div>
</div>
<Disclosure summary="How the scanner works & action glossary">
@@ -215,7 +209,6 @@ export default function ScannerPage() {
</div>
</Disclosure>
{/* Content */}
{isLoading && <SkeletonTable rows={8} cols={8} />}
{isError && (
@@ -1,15 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usePerformance } from '../hooks/usePerformance';
import { triggerJob } from '../api/admin';
import { Button } from '../components/ui/Button';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
import { PageHeader } from '../components/ui/PageHeader';
import { Section } from '../components/ui/Section';
import { SkeletonCard } from '../components/ui/Skeleton';
import { useToast } from '../components/ui/Toast';
import { RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
import type { OutcomeBucketStats } from '../lib/types';
import { usePerformance } from '../../hooks/usePerformance';
import { triggerJob } from '../../api/admin';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { SkeletonCard } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
import type { OutcomeBucketStats } from '../../lib/types';
function fmtR(value: number | null): string {
if (value === null) return '—';
@@ -35,8 +34,8 @@ function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
}) {
return (
<div className="glass p-5">
<p className="text-xs uppercase tracking-widest text-gray-500">{label}</p>
<p className={`mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
<p className="section-index">{label}</p>
<p className={`num mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
@@ -74,13 +73,13 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
{entries.map(([key, stats]) => (
<tr key={key} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3 font-medium text-gray-200">{mapLabel ? mapLabel(key) : key}</td>
<td className="px-4 py-3 text-right text-gray-300">{stats.total}</td>
<td className="px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
<td className="px-4 py-3 text-right text-red-400">{stats.losses}</td>
<td className="px-4 py-3 text-right text-gray-400">{stats.expired}</td>
<td className="px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
<td className="num px-4 py-3 text-right text-gray-300">{stats.total}</td>
<td className="num px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
<td className="num px-4 py-3 text-right text-red-400">{stats.losses}</td>
<td className="num px-4 py-3 text-right text-gray-400">{stats.expired}</td>
<td className="num px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
</tr>
))}
</tbody>
@@ -89,7 +88,7 @@ function BreakdownTable({ rows, labelHeader, mapLabel }: {
);
}
export default function PerformancePage() {
export function TrackRecordPanel() {
const { data, isLoading, isError, error } = usePerformance();
const queryClient = useQueryClient();
const toast = useToast();
@@ -106,29 +105,24 @@ export default function PerformancePage() {
});
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Performance"
subtitle="Do the signals actually win? Outcomes of past trade setups"
actions={
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
}
/>
<Disclosure summary="How outcomes are measured">
<p className="text-xs text-gray-400">
Each setup is replayed against the daily bars after its detection: a{' '}
<span className="text-emerald-400">win</span> means the target was reached before the
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
where both levels fall inside the same day count conservatively as losses). Setups with
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost 1R a
positive value means the signals have been profitable on a risk-adjusted basis. The
evaluator runs nightly after OHLCV collection.
</p>
</Disclosure>
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<Disclosure summary="How outcomes are measured">
<p className="text-xs text-gray-400">
Each setup is replayed against the daily bars after its detection: a{' '}
<span className="text-emerald-400">win</span> means the target was reached before the
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
where both levels fall inside the same day count conservatively as losses). Setups with
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost 1R a
positive value means the signals have been profitable on a risk-adjusted basis. The
evaluator runs nightly after OHLCV collection.
</p>
</Disclosure>
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending} className="shrink-0">
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
</div>
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+1 -1
View File
@@ -1,6 +1,6 @@
const variantStyles: Record<string, string> = {
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
manual: 'bg-sky-500/15 text-sky-400 border-sky-500/20',
manual: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
};
+1 -1
View File
@@ -24,7 +24,7 @@ export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInput
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#0d1322] ${className}`} {...rest}>
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
{children}
</select>
);
+217
View File
@@ -0,0 +1,217 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist';
import { usePerformance } from '../hooks/usePerformance';
import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation';
import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string {
if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
}
function rColor(value: number | null): string {
if (value === null) return 'text-gray-400';
if (value > 0) return 'text-emerald-400';
if (value < 0) return 'text-red-400';
return 'text-gray-300';
}
function Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
label: string;
value: string;
sub?: string;
valueClass?: string;
}) {
return (
<div className="glass glass-hover p-5">
<p className="section-index">{label}</p>
<p className={`num mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
function DirectionTag({ direction }: { direction: string }) {
const isLong = direction === 'long';
return (
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
isLong ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}>
{direction}
</span>
);
}
export default function DashboardPage() {
const trades = useTrades();
const watchlist = useWatchlist();
const performance = usePerformance();
const topSetups: TradeSetup[] = useMemo(
() => (trades.data ?? []).slice(0, 5),
[trades.data],
);
const highConfidenceCount = useMemo(
() => (trades.data ?? []).filter((t) => (t.confidence_score ?? 0) >= 70).length,
[trades.data],
);
const topWatchlist = useMemo(
() =>
[...(watchlist.data ?? [])]
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
.slice(0, 6),
[watchlist.data],
);
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric',
});
const stats = performance.data?.overall;
return (
<div className="space-y-8 animate-slide-up">
{/* Hero */}
<div>
<p className="num text-xs uppercase tracking-[0.22em] text-gray-500">{today}</p>
<h1 className="font-display mt-1 text-4xl font-bold tracking-tight text-gray-100">
Market overview
</h1>
</div>
{/* Metric strip */}
{(trades.isLoading || performance.isLoading) ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric
label="Live Setups"
value={String(trades.data?.length ?? 0)}
sub="latest per ticker & direction"
/>
<Metric
label="High Confidence"
value={String(highConfidenceCount)}
sub="confidence ≥ 70%"
valueClass={highConfidenceCount > 0 ? 'text-blue-300' : 'text-gray-100'}
/>
<Metric
label="Hit Rate"
value={stats?.hit_rate != null ? `${stats.hit_rate.toFixed(1)}%` : '—'}
sub={stats ? `${stats.wins}W / ${stats.losses}L evaluated` : 'no outcomes yet'}
/>
<Metric
label="Expectancy"
value={fmtR(stats?.avg_r ?? null)}
valueClass={rColor(stats?.avg_r ?? null)}
sub="average R per trade"
/>
</div>
)}
<div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */}
<div className="xl:col-span-3">
<Section title="Top Setups" hint="by confidence">
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && (
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
)}
{topSetups.length > 0 && (
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Ticker</th>
<th className="px-4 py-3">Dir</th>
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Conf.</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr>
</thead>
<tbody>
{topSetups.map((setup) => (
<tr key={setup.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3">
<Link to={`/ticker/${setup.symbol}`} className="font-medium text-blue-300 hover:text-blue-200 transition-colors">
{setup.symbol}
</Link>
</td>
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td>
<td className="num px-4 py-3 text-right text-gray-200">
{setup.confidence_score != null ? `${Math.round(setup.confidence_score)}%` : '—'}
</td>
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
{recommendationActionLabel(setup.recommended_action)}
</td>
</tr>
))}
</tbody>
</table>
<div className="border-t border-white/[0.04] px-4 py-2.5">
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
All setups
</Link>
</div>
</div>
)}
</Section>
</div>
{/* Watchlist pulse */}
<div className="xl:col-span-2">
<Section title="Watchlist Pulse" hint="top by score">
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
{watchlist.data && topWatchlist.length === 0 && (
<Callout variant="empty">Watchlist is empty add tickers on the Market page.</Callout>
)}
{topWatchlist.length > 0 && (
<div className="glass overflow-hidden">
<ul className="divide-y divide-white/[0.04]">
{topWatchlist.map((entry) => (
<li key={entry.symbol}>
<Link
to={`/ticker/${entry.symbol}`}
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
>
<span className="font-medium text-gray-200">{entry.symbol}</span>
<span className="flex items-center gap-4">
{entry.rr_ratio != null && (
<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">
{entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'}
</span>
</span>
</Link>
</li>
))}
</ul>
<div className="border-t border-white/[0.04] px-4 py-2.5">
<Link to="/market" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
Full watchlist
</Link>
</div>
</div>
)}
</Section>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
login.mutate(
{ username, password },
{
onSuccess: () => navigate('/watchlist'),
onSuccess: () => navigate('/'),
onError: (err) => setError(err instanceof Error ? err.message : 'Login failed'),
},
);
@@ -25,7 +25,7 @@ export default function LoginPage() {
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden">
{/* Ambient glow orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[120px] animate-glow-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-sky-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-emerald-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
<div className="w-full max-w-sm space-y-8 animate-slide-up">
<div className="text-center">
@@ -1,13 +1,22 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useWatchlist } from '../hooks/useWatchlist';
import { useRankings } from '../hooks/useScores';
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
import { RankingsTable } from '../components/rankings/RankingsTable';
import { WeightsForm } from '../components/rankings/WeightsForm';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
import { Select } from '../components/ui/Field';
import { PageHeader } from '../components/ui/PageHeader';
import { SkeletonTable } from '../components/ui/Skeleton';
import { Tabs } from '../components/ui/Tabs';
import type { WatchlistEntry } from '../lib/types';
const tabs = ['Watchlist', 'Rankings'] as const;
type Tab = (typeof tabs)[number];
type SortMode = 'name_asc' | 'name_desc' | 'score_desc' | 'score_asc';
function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[] {
@@ -42,7 +51,7 @@ function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[
return sorted;
}
export default function WatchlistPage() {
function WatchlistPanel() {
const { data, isLoading, isError, error } = useWatchlist();
const [sortMode, setSortMode] = useState<SortMode>('score_desc');
@@ -52,16 +61,15 @@ export default function WatchlistPage() {
);
return (
<div className="space-y-6 animate-slide-up">
<PageHeader title="Watchlist" subtitle="Track your favorite tickers" actions={<AddTickerForm />} />
<div className="space-y-3">
{isLoading && <SkeletonTable rows={6} cols={8} />}
{isError && <Callout variant="error">{error?.message || 'Failed to load watchlist'}</Callout>}
{data && (
<div className="space-y-3">
<div className="flex justify-end">
<>
<div className="flex items-center justify-between gap-4">
<AddTickerForm />
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Sort by</span>
<Select
@@ -78,8 +86,55 @@ export default function WatchlistPage() {
</div>
<WatchlistTable entries={sortedEntries} />
</div>
</>
)}
</div>
);
}
function RankingsPanel() {
const { data, isLoading, isError, error } = useRankings();
return (
<div className="space-y-4">
{isLoading && <SkeletonTable rows={8} cols={6} />}
{isError && (
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
)}
{data && (
<>
<Disclosure summary="Tune scoring weights">
<WeightsForm weights={data.weights} />
</Disclosure>
<RankingsTable rankings={data.rankings} />
</>
)}
</div>
);
}
export default function MarketPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab: Tab = searchParams.get('tab') === 'rankings' ? 'Rankings' : 'Watchlist';
const setTab = (tab: Tab) => {
setSearchParams(tab === 'Rankings' ? { tab: 'rankings' } : {}, { replace: true });
};
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Market"
subtitle="Your watchlist and the full composite-score leaderboard"
/>
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
<div className="animate-fade-in" key={activeTab}>
{activeTab === 'Watchlist' ? <WatchlistPanel /> : <RankingsPanel />}
</div>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { useRankings } from '../hooks/useScores';
import { RankingsTable } from '../components/rankings/RankingsTable';
import { WeightsForm } from '../components/rankings/WeightsForm';
import { Callout } from '../components/ui/Callout';
import { PageHeader } from '../components/ui/PageHeader';
import { SkeletonTable } from '../components/ui/Skeleton';
export default function RankingsPage() {
const { data, isLoading, isError, error } = useRankings();
return (
<div className="space-y-6 animate-slide-up">
<PageHeader title="Rankings" subtitle="Composite scoring leaderboard" />
{isLoading && <SkeletonTable rows={8} cols={6} />}
{isError && (
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
)}
{data && (
<>
<WeightsForm weights={data.weights} />
<RankingsTable rankings={data.rankings} />
</>
)}
</div>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export default function RegisterPage() {
return (
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden">
<div className="absolute top-1/4 right-1/4 w-96 h-96 bg-sky-500/10 rounded-full blur-[120px] animate-glow-pulse" />
<div className="absolute top-1/4 right-1/4 w-96 h-96 bg-emerald-500/10 rounded-full blur-[120px] animate-glow-pulse" />
<div className="absolute bottom-1/3 left-1/3 w-80 h-80 bg-blue-500/10 rounded-full blur-[100px] animate-glow-pulse" style={{ animationDelay: '1.5s' }} />
<div className="w-full max-w-sm space-y-8 animate-slide-up">
+32
View File
@@ -0,0 +1,32 @@
import { useSearchParams } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader';
import { Tabs } from '../components/ui/Tabs';
import { SetupsPanel } from '../components/signals/SetupsPanel';
import { TrackRecordPanel } from '../components/signals/TrackRecordPanel';
const tabs = ['Setups', 'Track Record'] as const;
type Tab = (typeof tabs)[number];
export default function SignalsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab: Tab = searchParams.get('tab') === 'track' ? 'Track Record' : 'Setups';
const setTab = (tab: Tab) => {
setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true });
};
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Signals"
subtitle="Detected trade setups and how past signals actually performed"
/>
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
<div className="animate-fade-in" key={activeTab}>
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />}
</div>
</div>
);
}
+70 -38
View File
@@ -4,11 +4,15 @@
@layer base {
body {
background: #0a0e1a;
background: #0e120f;
min-height: 100vh;
}
/* Mesh gradient background */
h1, h2, h3 {
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
}
/* Atmosphere: faint graph-paper grid + citron signal glow + film grain */
#root {
position: relative;
min-height: 100vh;
@@ -18,80 +22,108 @@
content: '';
position: fixed;
inset: 0;
z-index: -1;
z-index: -2;
background:
radial-gradient(ellipse 80% 60% at 10% 20%, rgba(56, 189, 248, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 10%, rgba(139, 92, 246, 0.07) 0%, transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(196, 232, 46, 0.06) 0%, transparent 55%),
radial-gradient(ellipse 50% 40% at 90% 90%, rgba(52, 211, 153, 0.04) 0%, transparent 50%),
linear-gradient(rgba(223, 242, 178, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(223, 242, 178, 0.025) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 48px 48px, 48px 48px;
pointer-events: none;
}
/* Film grain — keeps large dark areas from feeling flat */
#root::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.5;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
pointer-events: none;
}
}
@layer components {
/* Glass card — the core building block */
/* Panel — the core building block (kept class names so all components reskin) */
.glass {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 1rem;
background: rgba(238, 240, 233, 0.035);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(223, 242, 178, 0.09);
border-radius: 0.875rem;
}
.glass-sm {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.75rem;
background: rgba(238, 240, 233, 0.028);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(223, 242, 178, 0.07);
border-radius: 0.625rem;
}
.glass-hover {
transition: all 0.2s ease;
}
.glass-hover:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
background: rgba(238, 240, 233, 0.06);
border-color: rgba(214, 242, 92, 0.22);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
/* Gradient text — reserved for the brand wordmark only */
.text-gradient {
background: linear-gradient(135deg, #38bdf8, #60a5fa);
background: linear-gradient(120deg, #d6f25c, #6ee7b7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Primary button — single blue accent */
/* Primary button — luminous citron with ink text. The signature element. */
.btn-primary {
background: rgba(59, 130, 246, 0.85);
color: white;
border: 1px solid rgba(96, 165, 250, 0.35);
background: #c3e82e;
color: #16190a;
border: 1px solid rgba(214, 242, 92, 0.5);
border-radius: 0.5rem;
font-weight: 500;
font-weight: 600;
transition: all 0.2s ease;
}
.btn-primary:hover:not(:disabled) {
background: rgb(59, 130, 246);
box-shadow: 0 0 16px rgba(59, 130, 246, 0.25);
background: #d6f25c;
box-shadow: 0 0 20px rgba(196, 232, 46, 0.35);
}
/* Glass input */
/* Inputs */
.input-glass {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(238, 240, 233, 0.04);
border: 1px solid rgba(223, 242, 178, 0.12);
border-radius: 0.5rem;
color: #e2e8f0;
color: #eef0e9;
transition: all 0.2s ease;
}
.input-glass::placeholder {
color: rgba(148, 163, 184, 0.5);
color: rgba(149, 157, 135, 0.55);
}
.input-glass:focus {
outline: none;
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15), 0 0 20px rgba(99, 102, 241, 0.1);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(214, 242, 92, 0.5);
box-shadow: 0 0 0 3px rgba(196, 232, 46, 0.12);
background: rgba(238, 240, 233, 0.06);
}
/* Numbers — every metric reads in tabular mono */
.num {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
/* Section index label, e.g. "01 / Watchlist" */
.section-index {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.65rem;
letter-spacing: 0.18em;
color: #6e7562;
text-transform: uppercase;
}
/* Scrollbar styling */
@@ -103,10 +135,10 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
background: rgba(223, 242, 178, 0.12);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(223, 242, 178, 0.22);
}
}
+42 -5
View File
@@ -6,14 +6,46 @@ export default {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
sans: ['"Instrument Sans"', 'system-ui', 'sans-serif'],
display: ['"Bricolage Grotesque"', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
colors: {
// Warm ash neutrals (green-tinted) — replaces Tailwind's cool gray
// throughout the app, since components reference gray-* directly.
gray: {
50: '#f7f8f4',
100: '#eef0e9',
200: '#dde1d4',
300: '#bfc5b2',
400: '#959d87',
500: '#6e7562',
600: '#4d5344',
700: '#373c30',
800: '#22261d',
900: '#151911',
950: '#0e120f',
},
// Citron signal accent — replaces blue so every accent reference
// (text-blue-*, bg-blue-*, ring-blue-*) lights up in the brand color.
blue: {
50: '#fafee8',
100: '#f2fcc6',
200: '#e6f996',
300: '#d6f25c',
400: '#c3e82e',
500: '#a4cd14',
600: '#82a40b',
700: '#627c0e',
800: '#4e6212',
900: '#425314',
950: '#222e05',
},
surface: {
DEFAULT: 'rgba(255, 255, 255, 0.05)',
hover: 'rgba(255, 255, 255, 0.08)',
active: 'rgba(255, 255, 255, 0.12)',
border: 'rgba(255, 255, 255, 0.08)',
DEFAULT: 'rgba(255, 255, 255, 0.04)',
hover: 'rgba(255, 255, 255, 0.07)',
active: 'rgba(255, 255, 255, 0.1)',
border: 'rgba(223, 242, 178, 0.09)',
},
},
backdropBlur: {
@@ -23,6 +55,7 @@ export default {
'glow-pulse': 'glow-pulse 3s ease-in-out infinite',
'fade-in': 'fade-in 0.3s ease-out',
'slide-up': 'slide-up 0.4s ease-out',
'signal-pulse': 'signal-pulse 2.4s ease-in-out infinite',
},
keyframes: {
'glow-pulse': {
@@ -37,6 +70,10 @@ export default {
from: { opacity: '0', transform: 'translateY(12px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'signal-pulse': {
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
'50%': { opacity: '0.35', transform: 'scale(0.8)' },
},
},
},
},
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/loginpage.tsx","./src/pages/performancepage.tsx","./src/pages/rankingspage.tsx","./src/pages/registerpage.tsx","./src/pages/scannerpage.tsx","./src/pages/tickerdetailpage.tsx","./src/pages/watchlistpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}