first commit
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-20 17:31:01 +01:00
commit 61ab24490d
160 changed files with 17034 additions and 0 deletions

31
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { Routes, Route, Navigate } from 'react-router-dom';
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 TickerDetailPage from './pages/TickerDetailPage';
import ScannerPage from './pages/ScannerPage';
import RankingsPage from './pages/RankingsPage';
import AdminPage from './pages/AdminPage';
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to="/watchlist" />} />
<Route path="/watchlist" element={<WatchlistPage />} />
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
<Route path="/scanner" element={<ScannerPage />} />
<Route path="/rankings" element={<RankingsPage />} />
<Route element={<ProtectedRoute requireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Routes>
);
}

83
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,83 @@
import apiClient from './client';
import type { AdminUser, SystemSetting } from '../lib/types';
// Users
export function listUsers() {
return apiClient.get<AdminUser[]>('admin/users').then((r) => r.data);
}
export function createUser(data: {
username: string;
password: string;
role: string;
has_access: boolean;
}) {
return apiClient.post<AdminUser>('admin/users', data).then((r) => r.data);
}
export function updateAccess(userId: number, hasAccess: boolean) {
return apiClient
.put<{ message: string }>(`admin/users/${userId}/access`, {
has_access: hasAccess,
})
.then((r) => r.data);
}
export function resetPassword(userId: number, password: string) {
return apiClient
.put<{ message: string }>(`admin/users/${userId}/password`, { password })
.then((r) => r.data);
}
// Settings
export function listSettings() {
return apiClient
.get<SystemSetting[]>('admin/settings')
.then((r) => r.data);
}
export function updateSetting(key: string, value: string) {
return apiClient
.put<{ message: string }>(`admin/settings/${key}`, { value })
.then((r) => r.data);
}
export function updateRegistration(enabled: boolean) {
return apiClient
.put<{ message: string }>('admin/settings/registration', { enabled })
.then((r) => r.data);
}
// Jobs
export interface JobStatus {
name: string;
label: string;
enabled: boolean;
next_run_at: string | null;
registered: boolean;
}
export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
}
export function toggleJob(jobName: string, enabled: boolean) {
return apiClient
.put<{ message: string }>(`admin/jobs/${jobName}/toggle`, { enabled })
.then((r) => r.data);
}
export function triggerJob(jobName: string) {
return apiClient
.post<{ message: string }>(`admin/jobs/${jobName}/trigger`)
.then((r) => r.data);
}
// Data cleanup
export function cleanupData(olderThanDays: number) {
return apiClient
.post<{ message: string }>('admin/data/cleanup', {
older_than_days: olderThanDays,
})
.then((r) => r.data);
}

14
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,14 @@
import apiClient from './client';
import type { TokenResponse } from '../lib/types';
export function login(username: string, password: string) {
return apiClient
.post<TokenResponse>('auth/login', { username, password })
.then((r) => r.data);
}
export function register(username: string, password: string) {
return apiClient
.post<{ message: string }>('auth/register', { username, password })
.then((r) => r.data);
}

View File

@@ -0,0 +1,69 @@
import axios from 'axios';
import type { APIEnvelope } from '../lib/types';
import { useAuthStore } from '../stores/authStore';
/**
* Typed error class for API errors, providing structured error handling
* across the application.
*/
export class ApiError extends Error {
constructor(message: string) {
super(message);
this.name = 'ApiError';
}
}
/**
* Central Axios instance configured for the Stock Data Backend API.
* - Base URL: /api/v1/
* - Timeout: 30 seconds
* - JSON content type
*/
const apiClient = axios.create({
baseURL: '/api/v1/',
timeout: 30_000,
headers: { 'Content-Type': 'application/json' },
});
/**
* Request interceptor: attaches JWT Bearer token from the auth store
* to every outgoing request when a token is available.
*/
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/**
* Response interceptor:
* - Success path: unwraps the { status, data, error } envelope, returning
* only the `data` field. Throws ApiError if envelope status is 'error'.
* - Error path: handles 401 by clearing auth and redirecting to login.
* All other errors are wrapped in ApiError with a descriptive message.
*/
apiClient.interceptors.response.use(
(response) => {
const envelope = response.data as APIEnvelope;
if (envelope.status === 'error') {
throw new ApiError(envelope.error ?? 'Unknown API error');
}
// Return unwrapped data — callers receive the inner payload directly.
// We override the response shape here; downstream API functions cast as needed.
response.data = envelope.data;
return response;
},
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
const msg =
error.response?.data?.error ?? error.message ?? 'Network error';
throw new ApiError(msg);
},
);
export default apiClient;

View File

@@ -0,0 +1,8 @@
import apiClient from './client';
import type { FundamentalResponse } from '../lib/types';
export function getFundamentals(symbol: string) {
return apiClient
.get<FundamentalResponse>(`fundamentals/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,5 @@
import apiClient from './client';
export function check() {
return apiClient.get<{ status: string }>('health').then((r) => r.data);
}

View File

@@ -0,0 +1,24 @@
import apiClient from './client';
import type { IndicatorResult, EMACrossResult } from '../lib/types';
interface IndicatorEnvelopeData {
symbol: string;
indicator: IndicatorResult;
}
interface EMACrossEnvelopeData {
symbol: string;
ema_cross: EMACrossResult;
}
export function getIndicator(symbol: string, indicatorType: string) {
return apiClient
.get<IndicatorEnvelopeData>(`indicators/${symbol}/${indicatorType}`)
.then((r) => (r.data as unknown as IndicatorEnvelopeData).indicator);
}
export function getEMACross(symbol: string) {
return apiClient
.get<EMACrossEnvelopeData>(`indicators/${symbol}/ema-cross`)
.then((r) => (r.data as unknown as EMACrossEnvelopeData).ema_cross);
}

View File

@@ -0,0 +1,7 @@
import apiClient from './client';
export function fetchData(symbol: string) {
return apiClient
.post<{ message: string }>(`ingestion/fetch/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,6 @@
import apiClient from './client';
import type { OHLCVBar } from '../lib/types';
export function getOHLCV(symbol: string) {
return apiClient.get<OHLCVBar[]>(`ohlcv/${symbol}`).then((r) => r.data);
}

View File

@@ -0,0 +1,18 @@
import apiClient from './client';
import type { ScoreResponse, RankingsResponse } from '../lib/types';
export function getScores(symbol: string) {
return apiClient
.get<ScoreResponse>(`scores/${symbol}`)
.then((r) => r.data);
}
export function getRankings() {
return apiClient.get<RankingsResponse>('rankings').then((r) => r.data);
}
export function updateWeights(weights: Record<string, number>) {
return apiClient
.put<{ message: string }>('scores/weights', weights)
.then((r) => r.data);
}

View File

@@ -0,0 +1,8 @@
import apiClient from './client';
import type { SentimentResponse } from '../lib/types';
export function getSentiment(symbol: string) {
return apiClient
.get<SentimentResponse>(`sentiment/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,8 @@
import apiClient from './client';
import type { SRLevelResponse } from '../lib/types';
export function getLevels(symbol: string) {
return apiClient
.get<SRLevelResponse>(`sr-levels/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,16 @@
import apiClient from './client';
import type { Ticker } from '../lib/types';
export function list() {
return apiClient.get<Ticker[]>('tickers').then((r) => r.data);
}
export function create(symbol: string) {
return apiClient.post<Ticker>('tickers', { symbol }).then((r) => r.data);
}
export function deleteTicker(symbol: string) {
return apiClient
.delete<{ message: string }>(`tickers/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,6 @@
import apiClient from './client';
import type { TradeSetup } from '../lib/types';
export function list() {
return apiClient.get<TradeSetup[]>('trades').then((r) => r.data);
}

View File

@@ -0,0 +1,18 @@
import apiClient from './client';
import type { WatchlistEntry } from '../lib/types';
export function list() {
return apiClient.get<WatchlistEntry[]>('watchlist').then((r) => r.data);
}
export function add(symbol: string) {
return apiClient
.post<WatchlistEntry>(`watchlist/${symbol}`)
.then((r) => r.data);
}
export function remove(symbol: string) {
return apiClient
.delete<{ message: string }>(`watchlist/${symbol}`)
.then((r) => r.data);
}

View File

@@ -0,0 +1,36 @@
import { useState } from 'react';
import { useCleanupData } from '../../hooks/useAdmin';
export function DataCleanup() {
const [days, setDays] = useState(90);
const cleanup = useCleanupData();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (days < 1) return;
cleanup.mutate(days);
}
return (
<form onSubmit={handleSubmit} className="glass p-5 flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="cleanup-days" className="text-xs text-gray-400">Older than (days)</label>
<input
id="cleanup-days"
type="number"
min={1}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="w-28 input-glass px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={cleanup.isPending || days < 1}
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm font-medium text-white hover:from-red-500 hover:to-red-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{cleanup.isPending ? 'Cleaning…' : 'Run Cleanup'}
</button>
</form>
);
}

View File

@@ -0,0 +1,82 @@
import { useJobs, useToggleJob, useTriggerJob } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
function formatNextRun(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diffMs = d.getTime() - now.getTime();
if (diffMs < 0) return 'imminent';
const mins = Math.round(diffMs / 60_000);
if (mins < 60) return `in ${mins}m`;
const hrs = Math.round(mins / 60);
return `in ${hrs}h`;
}
export function JobControls() {
const { data: jobs, isLoading } = useJobs();
const toggleJob = useToggleJob();
const triggerJob = useTriggerJob();
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
return (
<div className="space-y-3">
{jobs?.map((job) => (
<div key={job.name} className="glass p-4 glass-hover">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Status dot */}
<span
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
job.enabled
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
: 'bg-gray-500'
}`}
/>
<div>
<span className="text-sm font-medium text-gray-200">{job.label}</span>
<div className="flex items-center gap-3 mt-0.5">
<span className={`text-[11px] font-medium ${job.enabled ? 'text-emerald-400' : 'text-gray-500'}`}>
{job.enabled ? 'Active' : 'Inactive'}
</span>
{job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
Next run {formatNextRun(job.next_run_at)}
</span>
)}
{!job.registered && (
<span className="text-[11px] text-red-400">Not registered</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleJob.mutate({ jobName: job.name, enabled: !job.enabled })}
disabled={toggleJob.isPending}
className={`rounded-lg border px-3 py-1.5 text-xs transition-all duration-200 disabled:opacity-50 ${
job.enabled
? 'border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
}`}
>
{job.enabled ? 'Disable' : 'Enable'}
</button>
<button
type="button"
onClick={() => triggerJob.mutate(job.name)}
disabled={triggerJob.isPending || !job.enabled}
className="btn-gradient px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{triggerJob.isPending ? 'Triggering…' : 'Trigger Now'}</span>
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types';
export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting();
const [edits, setEdits] = useState<Record<string, string>>({});
function currentValue(setting: SystemSetting) {
return edits[setting.key] ?? setting.value;
}
function handleChange(key: string, value: string) {
setEdits((prev) => ({ ...prev, [key]: value }));
}
function handleSave(key: string) {
const value = edits[key];
if (value === undefined) return;
updateSetting.mutate(
{ key, value },
{ onSuccess: () => setEdits((prev) => { const next = { ...prev }; delete next[key]; return next; }) },
);
}
function handleToggleRegistration(current: string) {
updateSetting.mutate({ key: 'registration', value: current === 'true' ? 'false' : 'true' });
}
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
return (
<div className="space-y-4">
{settings.map((setting) => (
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? (
<button
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-indigo-500 focus:ring-offset-2 focus:ring-offset-[#0a0e1a] disabled:opacity-50 ${
setting.value === 'true' ? 'bg-gradient-to-r from-blue-600 to-indigo-600' : 'bg-white/[0.1]'
}`}
role="switch"
aria-checked={setting.value === 'true'}
aria-label={`Toggle ${setting.key}`}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${setting.value === 'true' ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
) : (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={currentValue(setting)}
onChange={(e) => handleChange(setting.key, e.target.value)}
className="flex-1 input-glass px-3 py-2 text-sm"
/>
{edits[setting.key] !== undefined && edits[setting.key] !== setting.value && (
<button
onClick={() => handleSave(setting.key)}
disabled={updateSetting.isPending}
className="btn-gradient px-3 py-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{updateSetting.isPending ? 'Saving…' : 'Save'}</span>
</button>
)}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { useTickers, useAddTicker, useDeleteTicker } from '../../hooks/useTickers';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { SkeletonTable } from '../ui/Skeleton';
import { formatDateTime } from '../../lib/format';
export function TickerManagement() {
const { data: tickers, isLoading, isError, error } = useTickers();
const addTicker = useAddTicker();
const deleteTicker = useDeleteTicker();
const [newSymbol, setNewSymbol] = useState('');
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
function handleAdd(e: React.FormEvent) {
e.preventDefault();
const symbol = newSymbol.trim().toUpperCase();
if (!symbol) return;
addTicker.mutate(symbol, { onSuccess: () => setNewSymbol('') });
}
function handleConfirmDelete() {
if (!deleteTarget) return;
deleteTicker.mutate(deleteTarget, { onSuccess: () => setDeleteTarget(null) });
}
return (
<div className="space-y-6">
<form onSubmit={handleAdd} className="flex gap-3">
<input
type="text"
value={newSymbol}
onChange={(e) => setNewSymbol(e.target.value)}
placeholder="Enter ticker symbol (e.g. AAPL)"
className="flex-1 input-glass px-3 py-2.5 text-sm"
/>
<button
type="submit"
disabled={addTicker.isPending || !newSymbol.trim()}
className="btn-gradient px-4 py-2.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{addTicker.isPending ? 'Adding…' : 'Add Ticker'}</span>
</button>
</form>
{isLoading && <SkeletonTable rows={5} cols={3} />}
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load tickers'}</p>}
{tickers && tickers.length > 0 && (
<div className="glass overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="border-b border-white/[0.06] text-gray-500">
<tr>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Symbol</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Added</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{tickers.map((ticker) => (
<tr key={ticker.id} className="hover:bg-white/[0.03] transition-all duration-150">
<td className="px-4 py-3 font-medium text-gray-100">{ticker.symbol}</td>
<td className="px-4 py-3 text-gray-400">{formatDateTime(ticker.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(ticker.symbol)}
disabled={deleteTicker.isPending}
className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-1 text-xs text-red-400 hover:bg-red-500/20 disabled:opacity-50 transition-all duration-200"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tickers && tickers.length === 0 && (
<p className="text-sm text-gray-500">No tickers registered yet. Add one above.</p>
)}
<ConfirmDialog
open={deleteTarget !== null}
title="Delete Ticker"
message={`Are you sure you want to delete ${deleteTarget}? This action cannot be undone.`}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { AdminUser } from '../../lib/types';
export function UserTable() {
const { data: users, isLoading, isError, error } = useUsers();
const createUser = useCreateUser();
const updateAccess = useUpdateAccess();
const resetPassword = useResetPassword();
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('user');
const [newAccess, setNewAccess] = useState(true);
const [resetTarget, setResetTarget] = useState<number | null>(null);
const [resetPw, setResetPw] = useState('');
function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newUsername.trim() || !newPassword.trim()) return;
createUser.mutate(
{ username: newUsername.trim(), password: newPassword, role: newRole, has_access: newAccess },
{ onSuccess: () => { setNewUsername(''); setNewPassword(''); setNewRole('user'); setNewAccess(true); } },
);
}
function handleToggleAccess(user: AdminUser) {
updateAccess.mutate({ userId: user.id, hasAccess: !user.has_access });
}
function handleResetPassword(userId: number) {
if (!resetPw.trim()) return;
resetPassword.mutate(
{ userId, password: resetPw },
{ onSuccess: () => { setResetTarget(null); setResetPw(''); } },
);
}
return (
<div className="space-y-6">
{/* Create user form */}
<form onSubmit={handleCreate} className="glass p-5 flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Username</label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)}
placeholder="username" className="input-glass px-3 py-2 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Password</label>
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)}
placeholder="password" className="input-glass px-3 py-2 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Role</label>
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
className="rounded border-white/[0.1] bg-white/[0.04] text-blue-500 focus:ring-blue-500" />
Access
</label>
<button type="submit" disabled={createUser.isPending || !newUsername.trim() || !newPassword.trim()}
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<span>{createUser.isPending ? 'Creating…' : 'Create User'}</span>
</button>
</form>
{isLoading && <SkeletonTable rows={4} cols={4} />}
{isError && <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load users'}</p>}
{users && users.length > 0 && (
<div className="glass overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="border-b border-white/[0.06] text-gray-500">
<tr>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Username</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Role</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider">Access</th>
<th className="px-4 py-3 font-medium text-xs uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{users.map((user) => (
<tr key={user.id} className="hover:bg-white/[0.03] transition-all duration-150">
<td className="px-4 py-3 font-medium text-gray-100">{user.username}</td>
<td className="px-4 py-3 text-gray-300 capitalize">{user.role}</td>
<td className="px-4 py-3">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${user.has_access ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-red-400 shadow-lg shadow-red-400/40'}`} />
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2 flex-wrap">
<button onClick={() => handleToggleAccess(user)} disabled={updateAccess.isPending}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] disabled:opacity-50 transition-all duration-200">
{user.has_access ? 'Revoke' : 'Grant'}
</button>
{resetTarget === user.id ? (
<span className="flex items-center gap-1">
<input type="password" value={resetPw} onChange={(e) => setResetPw(e.target.value)}
placeholder="new password" className="w-32 input-glass px-2 py-1 text-xs" />
<button onClick={() => handleResetPassword(user.id)}
disabled={resetPassword.isPending || !resetPw.trim()}
className="btn-gradient px-2 py-1 text-xs disabled:opacity-50">
<span>Save</span>
</button>
<button onClick={() => { setResetTarget(null); setResetPw(''); }}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-2 py-1 text-xs text-gray-400 hover:bg-white/[0.08] transition-all duration-200">
Cancel
</button>
</span>
) : (
<button onClick={() => { setResetTarget(user.id); setResetPw(''); }}
className="rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-xs text-gray-300 hover:bg-white/[0.08] transition-all duration-200">
Reset Password
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{users && users.length === 0 && <p className="text-sm text-gray-500">No users found.</p>}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface ProtectedRouteProps {
requireAdmin?: boolean;
}
export default function ProtectedRoute({ requireAdmin }: ProtectedRouteProps) {
const token = useAuthStore((s) => s.token);
const role = useAuthStore((s) => s.role);
if (!token) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && role !== 'admin') {
return <Navigate to="/watchlist" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,232 @@
import { useMemo, useRef, useEffect, useCallback } from 'react';
import type { OHLCVBar, SRLevel } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
srLevels?: SRLevel[];
maxSRLevels?: number;
}
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
if (levels.length <= max) return levels;
return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max);
}
interface TooltipState {
visible: boolean;
x: number;
y: number;
bar: OHLCVBar | null;
}
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
const animFrame = useRef<number>(0);
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
const H = 400;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr;
const ch = H - mt - mb;
// Price range
const allPrices = data.flatMap((b) => [b.high, b.low]);
const srPrices = topLevels.map((l) => l.price_level);
const allVals = [...allPrices, ...srPrices];
const minP = Math.min(...allVals);
const maxP = Math.max(...allVals);
const pad = (maxP - minP) * 0.06 || 1;
const lo = minP - pad;
const hi = maxP + pad;
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / data.length;
const candleW = Math.max(barW * 0.65, 1);
// Grid lines (horizontal)
const nTicks = 6;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6b7280';
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= nTicks; i++) {
const v = lo + ((hi - lo) * i) / nTicks;
const y = yScale(v);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.fillText(formatPrice(v), W - 8, y + 4);
}
// X-axis labels
ctx.textAlign = 'center';
const labelInterval = Math.max(Math.floor(data.length / 8), 1);
for (let i = 0; i < data.length; i += labelInterval) {
const x = ml + i * barW + barW / 2;
ctx.fillStyle = '#6b7280';
ctx.fillText(formatDate(data[i].date), x, H - 6);
}
// S/R levels
topLevels.forEach((level) => {
const y = yScale(level.price_level);
const isSupport = level.type === 'support';
const color = isSupport ? '#10b981' : '#ef4444';
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.55;
ctx.setLineDash([6, 3]);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Label
ctx.fillStyle = color;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
ml + cw + 4,
y + 3
);
});
// Candles
data.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const color = bullish ? '#10b981' : '#ef4444';
const yHigh = yScale(bar.high);
const yLow = yScale(bar.low);
const yOpen = yScale(bar.open);
const yClose = yScale(bar.close);
// Wick
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
// Body
const bodyTop = Math.min(yOpen, yClose);
const bodyH = Math.max(Math.abs(yOpen - yClose), 1);
ctx.fillStyle = color;
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
});
// Store geometry for hit testing
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale };
}, [data, topLevels]);
useEffect(() => {
draw();
const onResize = () => {
cancelAnimationFrame(animFrame.current);
animFrame.current = requestAnimationFrame(draw);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(animFrame.current);
};
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const tip = tooltipRef.current;
if (!canvas || !tip || data.length === 0) return;
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.floor((mx - meta.ml) / meta.barW);
if (idx >= 0 && idx < data.length) {
const bar = data[idx];
tooltipState.current = { visible: true, x: e.clientX - rect.left, y: e.clientY - rect.top, bar };
tip.style.display = 'block';
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
tip.style.top = `${Math.max(e.clientY - rect.top - 80, 8)}px`;
tip.innerHTML = `
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
<span>Open</span><span class="text-right text-gray-200">${formatPrice(bar.open)}</span>
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
</div>`;
} else {
tip.style.display = 'none';
}
},
[data]
);
const handleMouseLeave = useCallback(() => {
const tip = tooltipRef.current;
if (tip) tip.style.display = 'none';
}, []);
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
No OHLCV data available
</div>
);
}
return (
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full cursor-crosshair"
style={{ height: 400 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
<div
ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import MobileNav from './MobileNav';
export default function AppShell() {
return (
<div className="flex min-h-screen text-gray-100">
<Sidebar />
<div className="flex-1 flex flex-col">
<MobileNav />
<main className="flex-1 p-4 lg:p-8 animate-fade-in">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
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' },
];
export default function MobileNav() {
const [open, setOpen] = useState(false);
const { role, username, logout } = useAuthStore();
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>
<button
onClick={() => setOpen((v) => !v)}
className="p-2 text-gray-400 hover:text-gray-200 transition-colors duration-200"
aria-label="Toggle menu"
>
{open ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
<div
className={`overflow-hidden transition-all duration-300 ease-out glass rounded-none border-x-0 border-t-0 ${
open ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0 border-b-0'
}`}
>
<nav className="px-3 py-2 space-y-1">
{navItems.map(({ to, label }) => (
<NavLink
key={to}
to={to}
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'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
>
{label}
</NavLink>
))}
{role === 'admin' && (
<NavLink
to="/admin"
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'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}`
}
>
Admin
</NavLink>
)}
</nav>
<div className="px-4 py-3 border-t border-white/[0.06]">
{username && (
<p className="text-xs text-gray-500 mb-2 truncate">{username}</p>
)}
<button
onClick={() => { logout(); setOpen(false); }}
className="w-full text-left px-3 py-2 text-sm text-gray-400 hover:text-gray-200 hover:bg-white/[0.04] rounded-lg transition-all duration-200"
>
Logout
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
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: '△' },
];
export default function Sidebar() {
const { role, username, logout } = useAuthStore();
const health = useQuery({
queryKey: ['health'],
queryFn: healthCheck,
refetchInterval: 30_000,
retry: 1,
});
const isBackendUp = health.isSuccess;
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 */}
<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>
<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>
{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>
Admin
</NavLink>
)}
</nav>
<div className="px-4 py-4 border-t border-white/[0.06] space-y-3">
<div className="flex items-center gap-2 px-1">
<span
className={`inline-block h-2 w-2 rounded-full ${
isBackendUp ? 'bg-emerald-400 shadow-lg shadow-emerald-400/50' : 'bg-red-400 shadow-lg shadow-red-400/50'
}`}
aria-label={isBackendUp ? 'Backend online' : 'Backend offline'}
/>
<span className="text-xs text-gray-500">
{isBackendUp ? 'Backend online' : 'Backend offline'}
</span>
</div>
{username && (
<p className="text-xs text-gray-500 truncate px-1">{username}</p>
)}
<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"
>
Logout
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom';
import type { RankingEntry } from '../../lib/types';
interface RankingsTableProps {
rankings: RankingEntry[];
}
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
export function RankingsTable({ rankings }: RankingsTableProps) {
if (rankings.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No rankings available.</p>;
}
const dimensionNames = rankings.length > 0 ? rankings[0].dimensions.map((d) => d.dimension) : [];
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Symbol</th>
<th className="px-4 py-3">Composite</th>
{dimensionNames.map((dim) => (
<th key={dim} className="px-4 py-3">{dim}</th>
))}
</tr>
</thead>
<tbody>
{rankings.map((entry, index) => (
<tr key={entry.symbol} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
<td className="px-4 py-3.5 font-mono text-gray-500">{index + 1}</td>
<td className="px-4 py-3.5">
<Link to={`/ticker/${entry.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
{entry.symbol}
</Link>
</td>
<td className={`px-4 py-3.5 font-semibold ${scoreColor(entry.composite_score)}`}>
{Math.round(entry.composite_score)}
</td>
{entry.dimensions.map((dim) => (
<td key={dim.dimension} className={`px-4 py-3.5 font-mono ${scoreColor(dim.score)}`}>
{Math.round(dim.score)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useState, type FormEvent } from 'react';
import { useUpdateWeights } from '../../hooks/useScores';
interface WeightsFormProps {
weights: Record<string, number>;
}
export function WeightsForm({ weights }: WeightsFormProps) {
const [localWeights, setLocalWeights] = useState<Record<string, number>>(weights);
const updateWeights = useUpdateWeights();
const handleChange = (key: string, value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) setLocalWeights((prev) => ({ ...prev, [key]: num }));
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
updateWeights.mutate(localWeights);
};
return (
<form onSubmit={handleSubmit} className="glass p-5">
<h3 className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-500">
Scoring Weights
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{Object.keys(weights).map((key) => (
<label key={key} className="flex flex-col gap-1.5">
<span className="text-xs text-gray-400 capitalize">{key.replace(/_/g, ' ')}</span>
<input
type="number"
step="any"
value={localWeights[key] ?? 0}
onChange={(e) => handleChange(key, e.target.value)}
className="input-glass px-2.5 py-1.5 text-sm"
/>
</label>
))}
</div>
<button
type="submit"
disabled={updateWeights.isPending}
className="mt-4 btn-gradient px-4 py-2 text-sm disabled:opacity-50"
>
<span>{updateWeights.isPending ? 'Updating…' : 'Update Weights'}</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,81 @@
import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatDateTime } from '../../lib/format';
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'rr_ratio' | 'composite_score' | 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
trades: TradeSetup[];
sortColumn: SortColumn;
sortDirection: SortDirection;
onSort: (column: SortColumn) => void;
}
const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' },
{ key: 'direction', label: 'Direction' },
{ key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' },
{ key: 'rr_ratio', label: 'R:R' },
{ key: 'composite_score', label: 'Score' },
{ key: 'detected_at', label: 'Detected' },
];
function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirection) {
if (column !== active) return '';
return dir === 'asc' ? ' ▲' : ' ▼';
}
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
if (trades.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No trade setups match the current filters.</p>;
}
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs uppercase tracking-wider text-gray-500">
{columns.map((col) => (
<th
key={col.key}
className="cursor-pointer select-none px-4 py-3 hover:text-gray-300 transition-colors duration-150"
onClick={() => onSort(col.key)}
>
{col.label}{sortIndicator(col.key, sortColumn, sortDirection)}
</th>
))}
</tr>
</thead>
<tbody>
{trades.map((trade) => (
<tr key={trade.id} className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]">
<td className="px-4 py-3.5">
<Link to={`/ticker/${trade.symbol}`} className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150">
{trade.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
{trade.direction}
</span>
</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
<td className="px-4 py-3.5 font-mono font-semibold text-gray-200">{trade.rr_ratio.toFixed(2)}</td>
<td className="px-4 py-3.5">
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
{Math.round(trade.composite_score)}
</span>
</td>
<td className="px-4 py-3.5 text-gray-400">{formatDateTime(trade.detected_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { formatPercent, formatLargeNumber } from '../../lib/format';
import type { FundamentalResponse } from '../../lib/types';
interface FundamentalsPanelProps {
data: FundamentalResponse;
}
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const items = [
{ label: 'P/E Ratio', value: data.pe_ratio !== null ? data.pe_ratio.toFixed(2) : '—' },
{ label: 'Revenue Growth', value: data.revenue_growth !== null ? formatPercent(data.revenue_growth) : '—' },
{ label: 'Earnings Surprise', value: data.earnings_surprise !== null ? formatPercent(data.earnings_surprise) : '—' },
{ label: 'Market Cap', value: data.market_cap !== null ? formatLargeNumber(data.market_cap) : '—' },
];
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
<div className="space-y-2.5 text-sm">
{items.map((item) => (
<div key={item.label} className="flex justify-between">
<span className="text-gray-400">{item.label}</span>
<span className="text-gray-200">{item.value}</span>
</div>
))}
{data.fetched_at && (
<p className="mt-2 text-xs text-gray-500">
Updated {new Date(data.fetched_at).toLocaleDateString()}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getIndicator, getEMACross } from '../../api/indicators';
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
interface IndicatorSelectorProps {
symbol: string;
}
const signalColors: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-gray-300',
};
function IndicatorResultDisplay({ result }: { result: IndicatorResult }) {
return (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Type</span>
<span className="text-gray-200">{result.indicator_type}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Normalized Score</span>
<span className="text-gray-200">{result.score.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Bars Used</span>
<span className="text-gray-200">{result.bars_used}</span>
</div>
{Object.keys(result.values).length > 0 && (
<div className="mt-2 border-t border-white/[0.06] pt-2">
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-gray-500">Values</p>
{Object.entries(result.values).map(([key, val]) => (
<div key={key} className="flex justify-between">
<span className="text-gray-400">{key}</span>
<span className="text-gray-200">{typeof val === 'number' ? val.toFixed(4) : String(val)}</span>
</div>
))}
</div>
)}
</div>
);
}
function EMACrossDisplay({ result }: { result: EMACrossResult }) {
return (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Signal</span>
<span className={signalColors[result.signal] ?? 'text-gray-300'}>{result.signal}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Short EMA ({result.short_period})</span>
<span className="text-gray-200">{result.short_ema.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Long EMA ({result.long_period})</span>
<span className="text-gray-200">{result.long_ema.toFixed(2)}</span>
</div>
</div>
);
}
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
const [selectedType, setSelectedType] = useState<string>('');
const [showEMACross, setShowEMACross] = useState(false);
const indicatorQuery = useQuery({
queryKey: ['indicator', symbol, selectedType],
queryFn: () => getIndicator(symbol, selectedType),
enabled: !!symbol && !!selectedType,
});
const emaCrossQuery = useQuery({
queryKey: ['ema-cross', symbol],
queryFn: () => getEMACross(symbol),
enabled: !!symbol && showEMACross,
});
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
<div className="mb-4">
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="w-full input-glass px-3 py-2.5 text-sm"
>
<option value="">Select indicator</option>
{INDICATOR_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
{selectedType && indicatorQuery.isLoading && (
<div className="animate-pulse space-y-2">
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
<div className="h-4 w-1/2 rounded bg-white/[0.05]" />
<div className="h-4 w-2/3 rounded bg-white/[0.05]" />
</div>
)}
{selectedType && indicatorQuery.isError && (
<p className="text-sm text-red-400">
{indicatorQuery.error instanceof Error ? indicatorQuery.error.message : 'Failed to load indicator'}
</p>
)}
{indicatorQuery.data && <IndicatorResultDisplay result={indicatorQuery.data} />}
<div className="mt-4 border-t border-white/[0.06] pt-4">
<button
onClick={() => setShowEMACross(true)}
disabled={showEMACross && emaCrossQuery.isLoading}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm text-gray-200 transition-all duration-200 hover:bg-white/[0.07] disabled:opacity-50"
>
{emaCrossQuery.isLoading ? 'Loading EMA Cross…' : 'Show EMA Cross Signal'}
</button>
{emaCrossQuery.isError && (
<p className="mt-2 text-sm text-red-400">
{emaCrossQuery.error instanceof Error ? emaCrossQuery.error.message : 'Failed to load EMA cross'}
</p>
)}
{emaCrossQuery.data && (
<div className="mt-3"><EMACrossDisplay result={emaCrossQuery.data} /></div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ReferenceLine } from 'recharts';
import type { SRLevel } from '../../lib/types';
import { formatPrice } from '../../lib/format';
interface SROverlayProps {
levels: SRLevel[];
}
export function SROverlay({ levels }: SROverlayProps) {
return (
<>
{levels.map((level) => {
const isSupport = level.type === 'support';
return (
<ReferenceLine
key={level.id}
y={level.price_level}
stroke={isSupport ? '#22c55e' : '#ef4444'}
strokeDasharray="6 3"
strokeWidth={1.5}
label={{
value: formatPrice(level.price_level),
position: 'right',
fill: isSupport ? '#22c55e' : '#ef4444',
fontSize: 11,
}}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,46 @@
import { formatPercent } from '../../lib/format';
import type { SentimentResponse } from '../../lib/types';
interface SentimentPanelProps {
data: SentimentResponse;
}
const classificationColors: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-gray-300',
};
export function SentimentPanel({ data }: SentimentPanelProps) {
const latest = data.scores[0];
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
{latest ? (
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Classification</span>
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
{latest.classification}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Confidence</span>
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Dimension Score</span>
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Sources</span>
<span className="text-gray-200">{data.count}</span>
</div>
</div>
) : (
<p className="text-sm text-gray-500">No sentiment data available</p>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
const variantStyles: Record<string, string> = {
auto: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
manual: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
default: 'bg-white/[0.06] text-gray-400 border-white/[0.08]',
};
interface BadgeProps {
label: string;
variant?: 'auto' | 'manual' | 'default';
}
export function Badge({ label, variant = 'default' }: BadgeProps) {
return (
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-xs font-medium backdrop-blur-sm ${variantStyles[variant]}`}>
{label}
</span>
);
}

View File

@@ -0,0 +1,35 @@
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onCancel} />
<div className="relative z-10 w-full max-w-md glass p-6 shadow-2xl animate-slide-up">
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
<p className="mt-2 text-sm text-gray-400">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onCancel}
className="rounded-lg border border-white/[0.1] bg-white/[0.04] px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.08] transition-all duration-200"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded-lg bg-gradient-to-r from-red-600 to-red-500 px-4 py-2 text-sm text-white hover:from-red-500 hover:to-red-400 transition-all duration-200"
>
Confirm
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
interface ScoreCardProps {
compositeScore: number | null;
dimensions: { dimension: string; score: number }[];
}
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
function ringGradient(score: number): string {
if (score > 70) return '#10b981';
if (score >= 40) return '#f59e0b';
return '#ef4444';
}
function barGradient(score: number): string {
if (score > 70) return 'from-emerald-500 to-emerald-400';
if (score >= 40) return 'from-amber-500 to-amber-400';
return 'from-red-500 to-red-400';
}
function ScoreRing({ score }: { score: number }) {
const radius = 36;
const circumference = 2 * Math.PI * radius;
const clamped = Math.max(0, Math.min(100, score));
const offset = circumference - (clamped / 100) * circumference;
const color = ringGradient(score);
return (
<div className="relative inline-flex items-center justify-center">
<svg width="88" height="88" className="-rotate-90">
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
<circle
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
stroke={color}
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
/>
</svg>
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
{Math.round(score)}
</span>
</div>
);
}
export function ScoreCard({ compositeScore, dimensions }: ScoreCardProps) {
return (
<div className="glass p-5">
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
</div>
</div>
{dimensions.length > 0 && (
<div className="mt-5 space-y-2.5">
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => (
<div key={d.dimension} className="flex items-center justify-between text-sm">
<span className="text-gray-300 capitalize">{d.dimension}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
/>
</div>
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
{Math.round(d.score)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
const pulse = 'animate-pulse rounded-lg bg-white/[0.05]';
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`${pulse} h-4 w-full ${className}`} />;
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${pulse} h-32 w-full ${className}`} />;
}
export function SkeletonTable({ rows = 5, cols = 4, className = '' }: { rows?: number; cols?: number; className?: string }) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: rows }, (_, r) => (
<div key={r} className="flex gap-4">
{Array.from({ length: cols }, (_, c) => (
<div key={c} className={`${pulse} h-6 flex-1`} />
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
}
interface ToastContextValue {
addToast: (type: ToastType, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const MAX_VISIBLE = 3;
const AUTO_DISMISS_MS = 8000;
const typeStyles: Record<ToastType, string> = {
error: 'border-red-500/30 bg-red-500/10 text-red-300',
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
info: 'border-blue-500/30 bg-blue-500/10 text-blue-300',
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((type: ToastType, message: string) => {
const id = String(nextId.current++);
setToasts((prev) => {
const next = [...prev, { id, type, message }];
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
});
}, []);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{createPortal(
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-96">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={removeToast} />
))}
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
useEffect(() => {
const timer = setTimeout(() => onDismiss(toast.id), AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [toast.id, onDismiss]);
return (
<div
role="alert"
className={`glass px-4 py-3 shadow-2xl animate-slide-up ${typeStyles[toast.type]}`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm">{toast.message}</p>
<button
onClick={() => onDismiss(toast.id)}
className="shrink-0 text-gray-400 hover:text-gray-200 transition-colors duration-200"
aria-label="Dismiss"
>
</button>
</div>
</div>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}

View File

@@ -0,0 +1,33 @@
import { FormEvent, useState } from 'react';
import { useAddToWatchlist } from '../../hooks/useWatchlist';
export function AddTickerForm() {
const [symbol, setSymbol] = useState('');
const addMutation = useAddToWatchlist();
function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = symbol.trim().toUpperCase();
if (!trimmed) return;
addMutation.mutate(trimmed, { onSuccess: () => setSymbol('') });
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="Add symbol (e.g. AAPL)"
className="input-glass px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={addMutation.isPending || !symbol.trim()}
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{addMutation.isPending ? 'Adding…' : 'Add'}</span>
</button>
</form>
);
}

View File

@@ -0,0 +1,131 @@
import { Link } from 'react-router-dom';
import type { WatchlistEntry } from '../../lib/types';
import { formatPrice } from '../../lib/format';
import { Badge } from '../ui/Badge';
import { useRemoveFromWatchlist } from '../../hooks/useWatchlist';
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
interface WatchlistTableProps {
entries: WatchlistEntry[];
}
export function WatchlistTable({ entries }: WatchlistTableProps) {
const removeMutation = useRemoveFromWatchlist();
if (entries.length === 0) {
return (
<p className="py-8 text-center text-sm text-gray-500">
No watchlist entries yet. Add a symbol above to get started.
</p>
);
}
return (
<div className="glass overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<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">Type</th>
<th className="px-4 py-3">Score</th>
<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"></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.symbol}
className="border-b border-white/[0.04] transition-all duration-200 hover:bg-white/[0.03]"
>
<td className="px-4 py-3.5">
<Link
to={`/ticker/${entry.symbol}`}
className="font-medium text-blue-400 hover:text-blue-300 transition-colors duration-150"
>
{entry.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<Badge label={entry.entry_type} variant={entry.entry_type === 'auto' ? 'auto' : 'manual'} />
</td>
<td className="px-4 py-3.5">
{entry.composite_score !== null ? (
<span className={`font-semibold ${scoreColor(entry.composite_score)}`}>
{Math.round(entry.composite_score)}
</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.dimensions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{entry.dimensions.map((d) => (
<span
key={d.dimension}
className={`inline-block rounded-md px-1.5 py-0.5 text-xs bg-white/[0.04] ${scoreColor(d.score)}`}
title={d.dimension}
>
{d.dimension.slice(0, 3).toUpperCase()} {Math.round(d.score)}
</span>
))}
</div>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5 font-mono">
{entry.rr_ratio !== null ? (
<span className="text-gray-200">{entry.rr_ratio.toFixed(2)}</span>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
{entry.rr_direction ? (
<span className={entry.rr_direction === 'long' ? 'text-emerald-400' : entry.rr_direction === 'short' ? 'text-red-400' : 'text-gray-400'}>
{entry.rr_direction}
</span>
) : (
<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>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5">
<button
onClick={() => removeMutation.mutate(entry.symbol)}
disabled={removeMutation.isPending}
className="rounded-lg px-2.5 py-1 text-xs text-red-400 transition-all duration-150 hover:bg-red-500/10 hover:text-red-300 disabled:opacity-50"
aria-label={`Remove ${entry.symbol}`}
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as adminApi from '../api/admin';
import { useToast } from '../components/ui/Toast';
// ── Users ──
export function useUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => adminApi.listUsers(),
});
}
export function useCreateUser() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (data: {
username: string;
password: string;
role: string;
has_access: boolean;
}) => adminApi.createUser(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to create user');
},
});
}
export function useUpdateAccess() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ userId, hasAccess }: { userId: number; hasAccess: boolean }) =>
adminApi.updateAccess(userId, hasAccess),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update access');
},
});
}
export function useResetPassword() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ userId, password }: { userId: number; password: string }) =>
adminApi.resetPassword(userId, password),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
addToast('success', 'Password reset successfully');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to reset password');
},
});
}
// ── Settings ──
export function useSettings() {
return useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => adminApi.listSettings(),
});
}
export function useUpdateSetting() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
adminApi.updateSetting(key, value),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update setting');
},
});
}
// ── Jobs ──
export function useJobs() {
return useQuery({
queryKey: ['admin', 'jobs'],
queryFn: () => adminApi.listJobs(),
refetchInterval: 15_000,
});
}
export function useToggleJob() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ jobName, enabled }: { jobName: string; enabled: boolean }) =>
adminApi.toggleJob(jobName, enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'jobs'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to toggle job');
},
});
}
export function useTriggerJob() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (jobName: string) => adminApi.triggerJob(jobName),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'jobs'] });
addToast('success', 'Job triggered successfully');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to trigger job');
},
});
}
// ── Data Cleanup ──
export function useCleanupData() {
const { addToast } = useToast();
return useMutation({
mutationFn: (olderThanDays: number) => adminApi.cleanupData(olderThanDays),
onSuccess: (data) => {
addToast('success', (data as { message: string }).message || 'Cleanup completed');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to cleanup data');
},
});
}

View File

@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import * as authApi from '../api/auth';
import { useAuthStore } from '../stores/authStore';
export function useLogin() {
const storeLogin = useAuthStore((s) => s.login);
return useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
authApi.login(username, password),
onSuccess: (data) => {
storeLogin(data.access_token);
},
});
}
export function useRegister() {
return useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
authApi.register(username, password),
});
}

View File

@@ -0,0 +1,26 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as scoresApi from '../api/scores';
import { useToast } from '../components/ui/Toast';
export function useRankings() {
return useQuery({
queryKey: ['rankings'],
queryFn: () => scoresApi.getRankings(),
});
}
export function useUpdateWeights() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (weights: Record<string, number>) => scoresApi.updateWeights(weights),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['rankings'] });
addToast('success', 'Weights updated successfully');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update weights');
},
});
}

View File

@@ -0,0 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { getOHLCV } from '../api/ohlcv';
import { getScores } from '../api/scores';
import { getLevels } from '../api/sr-levels';
import { getSentiment } from '../api/sentiment';
import { getFundamentals } from '../api/fundamentals';
export function useTickerDetail(symbol: string) {
const ohlcv = useQuery({
queryKey: ['ohlcv', symbol],
queryFn: () => getOHLCV(symbol),
enabled: !!symbol,
});
const scores = useQuery({
queryKey: ['scores', symbol],
queryFn: () => getScores(symbol),
enabled: !!symbol,
});
const srLevels = useQuery({
queryKey: ['sr-levels', symbol],
queryFn: () => getLevels(symbol),
enabled: !!symbol,
});
const sentiment = useQuery({
queryKey: ['sentiment', symbol],
queryFn: () => getSentiment(symbol),
enabled: !!symbol,
});
const fundamentals = useQuery({
queryKey: ['fundamentals', symbol],
queryFn: () => getFundamentals(symbol),
enabled: !!symbol,
});
return { ohlcv, scores, srLevels, sentiment, fundamentals };
}

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as tickersApi from '../api/tickers';
import { useToast } from '../components/ui/Toast';
export function useTickers() {
return useQuery({
queryKey: ['tickers'],
queryFn: () => tickersApi.list(),
});
}
export function useAddTicker() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (symbol: string) => tickersApi.create(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickers'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to add ticker');
},
});
}
export function useDeleteTicker() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (symbol: string) => tickersApi.deleteTicker(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickers'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to delete ticker');
},
});
}

View File

@@ -0,0 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import * as tradesApi from '../api/trades';
export function useTrades() {
return useQuery({
queryKey: ['trades'],
queryFn: () => tradesApi.list(),
});
}

View File

@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as watchlistApi from '../api/watchlist';
import { useToast } from '../components/ui/Toast';
export function useWatchlist() {
return useQuery({
queryKey: ['watchlist'],
queryFn: () => watchlistApi.list(),
});
}
export function useAddToWatchlist() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (symbol: string) => watchlistApi.add(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['watchlist'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to add to watchlist');
},
});
}
export function useRemoveFromWatchlist() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (symbol: string) => watchlistApi.remove(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['watchlist'] });
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to remove from watchlist');
},
});
}

View File

@@ -0,0 +1,74 @@
/**
* Format a number as a price string with 2 decimal places and thousands separators.
* e.g. 1234.5 → "1,234.50"
*/
export function formatPrice(n: number): string {
return n.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/**
* Format a number as a percentage string with 2 decimal places.
* e.g. 12.345 → "12.35%"
*/
export function formatPercent(n: number): string {
return `${n.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
}
/**
* Format a large number with K/M/B suffix.
* Values >= 1_000_000_000 → "1.23B"
* Values >= 1_000_000 → "456.7M"
* Values >= 1_000 → "12.3K"
* Values < 1_000 → plain number, no suffix
*/
export function formatLargeNumber(n: number): string {
const abs = Math.abs(n);
const sign = n < 0 ? '-' : '';
if (abs >= 1_000_000_000) {
return `${sign}${(abs / 1_000_000_000).toFixed(2).replace(/\.?0+$/, '')}B`;
}
if (abs >= 1_000_000) {
return `${sign}${(abs / 1_000_000).toFixed(1).replace(/\.?0+$/, '')}M`;
}
if (abs >= 1_000) {
return `${sign}${(abs / 1_000).toFixed(1).replace(/\.?0+$/, '')}K`;
}
return n.toString();
}
/**
* Format an ISO date string as a short date.
* e.g. "2025-01-15T14:30:00Z" → "Jan 15, 2025"
*/
export function formatDate(d: string): string {
const date = new Date(d);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/**
* Format an ISO date string as a date with time.
* e.g. "2025-01-15T14:30:00Z" → "Jan 15, 2025 2:30 PM"
*/
export function formatDateTime(d: string): string {
const date = new Date(d);
return `${date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})} ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}`;
}

172
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,172 @@
// API envelope (before unwrapping)
export interface APIEnvelope<T = unknown> {
status: 'success' | 'error';
data: T | null;
error: string | null;
}
// Auth
export interface TokenResponse {
access_token: string;
token_type: string;
}
// Watchlist
export interface WatchlistEntry {
symbol: string;
entry_type: 'auto' | 'manual';
composite_score: number | null;
dimensions: DimensionScore[];
rr_ratio: number | null;
rr_direction: string | null;
sr_levels: SRLevelSummary[];
added_at: string;
}
export interface DimensionScore {
dimension: string;
score: number;
}
export interface SRLevelSummary {
price_level: number;
type: 'support' | 'resistance';
strength: number;
}
// OHLCV
export interface OHLCVBar {
id: number;
ticker_id: number;
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
created_at: string;
}
// Scores
export interface ScoreResponse {
symbol: string;
composite_score: number | null;
composite_stale: boolean;
weights: Record<string, number>;
dimensions: DimensionScoreDetail[];
missing_dimensions: string[];
computed_at: string | null;
}
export interface DimensionScoreDetail {
dimension: string;
score: number;
is_stale: boolean;
computed_at: string | null;
}
export interface RankingEntry {
symbol: string;
composite_score: number;
dimensions: DimensionScoreDetail[];
}
export interface RankingsResponse {
rankings: RankingEntry[];
weights: Record<string, number>;
}
// Trade Setups
export interface TradeSetup {
id: number;
symbol: string;
direction: string;
entry_price: number;
stop_loss: number;
target: number;
rr_ratio: number;
composite_score: number;
detected_at: string;
}
// S/R Levels
export interface SRLevel {
id: number;
price_level: number;
type: 'support' | 'resistance';
strength: number;
detection_method: string;
created_at: string;
}
export interface SRLevelResponse {
symbol: string;
levels: SRLevel[];
count: number;
}
// Sentiment
export interface SentimentScore {
id: number;
classification: 'bullish' | 'bearish' | 'neutral';
confidence: number;
source: string;
timestamp: string;
}
export interface SentimentResponse {
symbol: string;
scores: SentimentScore[];
count: number;
dimension_score: number | null;
lookback_hours: number;
}
// Fundamentals
export interface FundamentalResponse {
symbol: string;
pe_ratio: number | null;
revenue_growth: number | null;
earnings_surprise: number | null;
market_cap: number | null;
fetched_at: string | null;
}
// Indicators
export interface IndicatorResult {
indicator_type: string;
values: Record<string, unknown>;
score: number;
bars_used: number;
}
export interface EMACrossResult {
short_ema: number;
long_ema: number;
short_period: number;
long_period: number;
signal: 'bullish' | 'bearish' | 'neutral';
}
// Tickers
export interface Ticker {
id: number;
symbol: string;
created_at: string;
}
// Admin
export interface AdminUser {
id: number;
username: string;
role: string;
has_access: boolean;
created_at: string | null;
updated_at: string | null;
}
export interface SystemSetting {
key: string;
value: string;
updated_at: string | null;
}

21
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from './components/ui/Toast';
import App from './App';
import './styles/globals.css';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { SettingsForm } from '../components/admin/SettingsForm';
import { TickerManagement } from '../components/admin/TickerManagement';
import { UserTable } from '../components/admin/UserTable';
const tabs = ['Users', 'Tickers', 'Settings', 'Jobs', 'Cleanup'] as const;
type Tab = (typeof tabs)[number];
export default function AdminPage() {
const [activeTab, setActiveTab] = useState<Tab>('Users');
return (
<div className="space-y-6 animate-slide-up">
<div>
<h1 className="text-2xl font-bold text-gradient">Admin</h1>
<p className="text-xs text-gray-500 mt-1">System management</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 glass-sm p-1 w-fit">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
activeTab === tab
? 'bg-white/[0.1] text-white shadow-lg shadow-blue-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/[0.04]'
}`}
>
{tab}
</button>
))}
</div>
{/* Tab content */}
<div className="animate-fade-in">
{activeTab === 'Users' && <UserTable />}
{activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && <SettingsForm />}
{activeTab === 'Jobs' && <JobControls />}
{activeTab === 'Cleanup' && <DataCleanup />}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useLogin } from '../hooks/useAuth';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const login = useLogin();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setError(null);
login.mutate(
{ username, password },
{
onSuccess: () => navigate('/watchlist'),
onError: (err) => setError(err instanceof Error ? err.message : 'Login failed'),
},
);
};
return (
<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-violet-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">
<h1 className="text-3xl font-bold text-gradient">Signal Dashboard</h1>
<p className="mt-2 text-sm text-gray-400">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="glass p-6 space-y-5">
{error && (
<div className="glass-sm bg-red-500/10 border-red-500/20 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1.5">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full input-glass px-3 py-2.5 text-sm"
placeholder="Enter username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full input-glass px-3 py-2.5 text-sm"
placeholder="Enter password"
/>
</div>
<button
type="submit"
disabled={login.isPending}
className="w-full btn-gradient px-4 py-2.5 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{login.isPending ? 'Signing in…' : 'Sign in'}</span>
</button>
</form>
<p className="text-center text-sm text-gray-400">
Don&apos;t have an account?{' '}
<Link to="/register" className="text-blue-400 hover:text-blue-300 transition-colors duration-200">
Register
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useRankings } from '../hooks/useScores';
import { RankingsTable } from '../components/rankings/RankingsTable';
import { WeightsForm } from '../components/rankings/WeightsForm';
import { SkeletonTable } from '../components/ui/Skeleton';
export default function RankingsPage() {
const { data, isLoading, isError, error } = useRankings();
if (isLoading) {
return (
<div className="space-y-6 animate-slide-up">
<h1 className="text-2xl font-bold text-gradient">Rankings</h1>
<SkeletonTable rows={8} cols={6} />
</div>
);
}
if (isError) {
return (
<div className="animate-slide-up">
<h1 className="text-2xl font-bold text-gradient">Rankings</h1>
<p className="mt-4 text-sm text-red-400">
Failed to load rankings: {(error as Error).message}
</p>
</div>
);
}
if (!data) return null;
return (
<div className="space-y-6 animate-slide-up">
<div>
<h1 className="text-2xl font-bold text-gradient">Rankings</h1>
<p className="text-xs text-gray-500 mt-1">Composite scoring leaderboard</p>
</div>
<WeightsForm weights={data.weights} />
<RankingsTable rankings={data.rankings} />
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router-dom';
import { useRegister } from '../hooks/useAuth';
export default function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [validationErrors, setValidationErrors] = useState<{ username?: string; password?: string }>({});
const register = useRegister();
const validate = (): boolean => {
const errors: { username?: string; password?: string } = {};
if (username.length < 1) errors.username = 'Username is required';
if (password.length < 6) errors.password = 'Password must be at least 6 characters';
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!validate()) return;
register.mutate(
{ username, password },
{
onSuccess: () => setSuccess(true),
onError: (err) => setError(err instanceof Error ? err.message : 'Registration failed'),
},
);
};
if (success) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6 text-center animate-slide-up">
<div className="glass-sm bg-emerald-500/10 border-emerald-500/20 px-4 py-3 text-sm text-emerald-300">
Account created successfully!
</div>
<Link
to="/login"
className="inline-block btn-gradient px-6 py-2.5 text-sm font-medium"
>
<span>Go to Login</span>
</Link>
</div>
</div>
);
}
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-violet-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">
<div className="text-center">
<h1 className="text-3xl font-bold text-gradient">Signal Dashboard</h1>
<p className="mt-2 text-sm text-gray-400">Create a new account</p>
</div>
<form onSubmit={handleSubmit} className="glass p-6 space-y-5">
{error && (
<div className="glass-sm bg-red-500/10 border-red-500/20 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1.5">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full input-glass px-3 py-2.5 text-sm"
placeholder="Enter username"
/>
{validationErrors.username && (
<p className="mt-1 text-xs text-red-400">{validationErrors.username}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full input-glass px-3 py-2.5 text-sm"
placeholder="Min 6 characters"
/>
{validationErrors.password && (
<p className="mt-1 text-xs text-red-400">{validationErrors.password}</p>
)}
</div>
<button
type="submit"
disabled={register.isPending}
className="w-full btn-gradient px-4 py-2.5 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{register.isPending ? 'Creating account…' : 'Create account'}</span>
</button>
</form>
<p className="text-center text-sm text-gray-400">
Already have an account?{' '}
<Link to="/login" className="text-blue-400 hover:text-blue-300 transition-colors duration-200">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useMemo, useState } from 'react';
import { useTrades } from '../hooks/useTrades';
import { TradeTable, type SortColumn, type SortDirection } from '../components/scanner/TradeTable';
import { SkeletonTable } from '../components/ui/Skeleton';
import type { TradeSetup } from '../lib/types';
type DirectionFilter = 'both' | 'long' | 'short';
function filterTrades(
trades: TradeSetup[],
minRR: number,
direction: DirectionFilter,
): TradeSetup[] {
return trades.filter((t) => {
if (t.rr_ratio < minRR) return false;
if (direction !== 'both' && t.direction !== direction) return false;
return true;
});
}
function sortTrades(
trades: TradeSetup[],
column: SortColumn,
direction: SortDirection,
): TradeSetup[] {
const sorted = [...trades].sort((a, b) => {
let cmp = 0;
switch (column) {
case 'symbol':
cmp = a.symbol.localeCompare(b.symbol);
break;
case 'direction':
cmp = a.direction.localeCompare(b.direction);
break;
case 'detected_at':
cmp = new Date(a.detected_at).getTime() - new Date(b.detected_at).getTime();
break;
default:
cmp = (a[column] as number) - (b[column] as number);
}
return direction === 'asc' ? cmp : -cmp;
});
return sorted;
}
export default function ScannerPage() {
const { data: trades, isLoading, isError, error } = useTrades();
const [minRR, setMinRR] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const handleSort = (column: SortColumn) => {
if (column === sortColumn) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const processed = useMemo(() => {
if (!trades) return [];
const filtered = filterTrades(trades, minRR, directionFilter);
return sortTrades(filtered, sortColumn, sortDirection);
}, [trades, minRR, directionFilter, sortColumn, sortDirection]);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-100">Trade Scanner</h1>
{/* Filter controls */}
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="min-rr" className="mb-1 block text-xs text-gray-400">
Min R:R
</label>
<input
id="min-rr"
type="number"
min={0}
step={0.1}
value={minRR}
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
className="w-24 rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
/>
</div>
<div>
<label htmlFor="direction" className="mb-1 block text-xs text-gray-400">
Direction
</label>
<select
id="direction"
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value as DirectionFilter)}
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
>
<option value="both">Both</option>
<option value="long">Long</option>
<option value="short">Short</option>
</select>
</div>
</div>
{/* Content */}
{isLoading && <SkeletonTable rows={8} cols={8} />}
{isError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error instanceof Error ? error.message : 'Failed to load trade setups'}
</div>
)}
{trades && (
<TradeTable
trades={processed}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,260 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTickerDetail } from '../hooks/useTickerDetail';
import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard';
import { SkeletonCard } from '../components/ui/Skeleton';
import { SentimentPanel } from '../components/ticker/SentimentPanel';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
import { useToast } from '../components/ui/Toast';
import { fetchData } from '../api/ingestion';
import { formatPrice } from '../lib/format';
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="glass-sm bg-red-500/10 border-red-500/20 p-4 text-sm text-red-400">
<p>{message}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-xs font-medium text-red-300 underline hover:text-red-200">
Retry
</button>
)}
</div>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
interface DataStatusItem {
label: string;
available: boolean;
timestamp?: string | null;
}
function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
return (
<div className="glass-sm p-3 flex flex-wrap gap-4">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
item.available ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-gray-600'
}`} />
<span className="text-xs text-gray-400">{item.label}</span>
{item.available && item.timestamp && (
<span className="text-[10px] text-gray-500">{timeAgo(item.timestamp)}</span>
)}
{!item.available && (
<span className="text-[10px] text-gray-600">no data</span>
)}
</div>
))}
</div>
);
}
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const { ohlcv, scores, srLevels, sentiment, fundamentals } = useTickerDetail(symbol);
const queryClient = useQueryClient();
const { addToast } = useToast();
const ingestion = useMutation({
mutationFn: () => fetchData(symbol),
onSuccess: (result: any) => {
// Show per-source status breakdown
const sources = result?.sources;
if (sources) {
const parts: string[] = [];
for (const [name, info] of Object.entries(sources) as [string, any][]) {
const label = name.charAt(0).toUpperCase() + name.slice(1);
if (info.status === 'ok') {
parts.push(`${label}`);
} else if (info.status === 'skipped') {
parts.push(`${label}: skipped (${info.message})`);
} else {
parts.push(`${label} ✗: ${info.message}`);
}
}
const hasError = Object.values(sources).some((s: any) => s.status === 'error');
const hasSkip = Object.values(sources).some((s: any) => s.status === 'skipped');
const toastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
addToast(toastType, parts.join(' · '));
} else {
addToast('success', `Data fetched for ${symbol.toUpperCase()}`);
}
queryClient.invalidateQueries({ queryKey: ['ohlcv', symbol] });
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
},
onError: (err: Error) => {
addToast('error', err.message || 'Failed to fetch data');
},
});
const dataStatus: DataStatusItem[] = useMemo(() => [
{
label: 'OHLCV',
available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
},
{
label: 'Sentiment',
available: !!sentiment.data && sentiment.data.count > 0,
timestamp: sentiment.data?.scores?.[0]?.timestamp,
},
{
label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at,
},
{
label: 'S/R Levels',
available: !!srLevels.data && srLevels.data.count > 0,
timestamp: srLevels.data?.levels?.[0]?.created_at,
},
{
label: 'Scores',
available: !!scores.data && scores.data.composite_score !== null,
timestamp: scores.data?.computed_at,
},
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
// Sort S/R levels by strength for the table
const sortedLevels = useMemo(() => {
if (!srLevels.data?.levels) return [];
return [...srLevels.data.levels].sort((a, b) => b.strength - a.strength);
}, [srLevels.data]);
return (
<div className="space-y-6 animate-slide-up">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gradient">{symbol.toUpperCase()}</h1>
<p className="text-sm text-gray-500 mt-0.5">Ticker Detail</p>
</div>
<button
onClick={() => ingestion.mutate()}
disabled={ingestion.isPending}
className="btn-gradient inline-flex items-center gap-2 px-5 py-2.5 text-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
{ingestion.isPending && (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
<span>{ingestion.isPending ? 'Fetching…' : 'Fetch Data'}</span>
</button>
</div>
{/* Data freshness bar */}
<DataFreshnessBar items={dataStatus} />
{/* Chart Section */}
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Price Chart</h2>
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
{ohlcv.isError && (
<SectionError
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
onRetry={() => ohlcv.refetch()}
/>
)}
{ohlcv.data && (
<div className="glass p-5">
<CandlestickChart data={ohlcv.data} srLevels={srLevels.data?.levels} />
{srLevels.isError && (
<p className="mt-2 text-xs text-yellow-500/80">S/R levels unavailable chart shown without overlays</p>
)}
</div>
)}
</section>
{/* Scores + Side Panels */}
<div className="grid gap-6 lg:grid-cols-3">
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Scores</h2>
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions.map((d) => ({ dimension: d.dimension, score: d.score }))} />
)}
</section>
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h2>
{sentiment.isLoading && <SkeletonCard />}
{sentiment.isError && (
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
)}
{sentiment.data && <SentimentPanel data={sentiment.data} />}
</section>
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h2>
{fundamentals.isLoading && <SkeletonCard />}
{fundamentals.isError && (
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
)}
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</section>
</div>
{/* Indicators */}
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Technical Indicators</h2>
<IndicatorSelector symbol={symbol} />
</section>
{/* S/R Levels Table — sorted by strength */}
{sortedLevels.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">
Support &amp; Resistance Levels
<span className="ml-2 text-gray-600 normal-case tracking-normal">sorted by strength</span>
</h2>
<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">Type</th>
<th className="px-4 py-3">Price Level</th>
<th className="px-4 py-3">Strength</th>
<th className="px-4 py-3">Method</th>
</tr>
</thead>
<tbody>
{sortedLevels.map((level) => (
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3">
<span className={level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}>{level.type}</span>
</td>
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useWatchlist } from '../hooks/useWatchlist';
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
import { SkeletonTable } from '../components/ui/Skeleton';
export default function WatchlistPage() {
const { data, isLoading, isError, error } = useWatchlist();
return (
<div className="space-y-6 animate-slide-up">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gradient">Watchlist</h1>
<p className="text-xs text-gray-500 mt-1">Track your favorite tickers</p>
</div>
<AddTickerForm />
</div>
{isLoading && <SkeletonTable rows={6} cols={8} />}
{isError && (
<div className="glass-sm bg-red-500/10 border-red-500/20 px-4 py-3 text-sm text-red-400">
{error?.message || 'Failed to load watchlist'}
</div>
)}
{data && <WatchlistTable entries={data} />}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { create } from 'zustand';
export interface AuthState {
token: string | null;
username: string | null;
role: 'admin' | 'user' | null;
login: (token: string) => void;
logout: () => void;
}
function decodeJwtPayload(token: string): { sub?: string; role?: string } {
try {
const base64 = token.split('.')[1];
const json = atob(base64);
return JSON.parse(json);
} catch {
return {};
}
}
export const useAuthStore = create<AuthState>()((set) => ({
token: localStorage.getItem('token'),
username: (() => {
const t = localStorage.getItem('token');
if (!t) return null;
return decodeJwtPayload(t).sub ?? null;
})(),
role: (() => {
const t = localStorage.getItem('token');
if (!t) return null;
const r = decodeJwtPayload(t).role;
return r === 'admin' ? 'admin' : r === 'user' ? 'user' : null;
})(),
login: (token: string) => {
const payload = decodeJwtPayload(token);
localStorage.setItem('token', token);
set({
token,
username: payload.sub ?? null,
role: payload.role === 'admin' ? 'admin' : 'user',
});
},
logout: () => {
localStorage.removeItem('token');
set({ token: null, username: null, role: null });
},
}));

View File

@@ -0,0 +1,134 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
background: #0a0e1a;
min-height: 100vh;
}
/* Mesh gradient background */
#root {
position: relative;
min-height: 100vh;
}
#root::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
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%);
pointer-events: none;
}
}
@layer components {
/* Glass card — the core building block */
.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;
}
.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;
}
.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);
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #38bdf8, #818cf8, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient buttons */
.btn-gradient {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.btn-gradient::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
opacity: 0;
transition: opacity 0.2s ease;
}
.btn-gradient:hover::before {
opacity: 1;
}
.btn-gradient > * {
position: relative;
z-index: 1;
}
/* Glow accent for active states */
.glow-blue {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3), 0 0 60px rgba(59, 130, 246, 0.1);
}
.glow-green {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3), 0 0 60px rgba(16, 185, 129, 0.1);
}
/* Glass input */
.input-glass {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
color: #e2e8f0;
transition: all 0.2s ease;
}
.input-glass::placeholder {
color: rgba(148, 163, 184, 0.5);
}
.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);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />