first commit
This commit is contained in:
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal 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
83
frontend/src/api/admin.ts
Normal 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
14
frontend/src/api/auth.ts
Normal 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);
|
||||
}
|
||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal 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;
|
||||
8
frontend/src/api/fundamentals.ts
Normal file
8
frontend/src/api/fundamentals.ts
Normal 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);
|
||||
}
|
||||
5
frontend/src/api/health.ts
Normal file
5
frontend/src/api/health.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export function check() {
|
||||
return apiClient.get<{ status: string }>('health').then((r) => r.data);
|
||||
}
|
||||
24
frontend/src/api/indicators.ts
Normal file
24
frontend/src/api/indicators.ts
Normal 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);
|
||||
}
|
||||
7
frontend/src/api/ingestion.ts
Normal file
7
frontend/src/api/ingestion.ts
Normal 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);
|
||||
}
|
||||
6
frontend/src/api/ohlcv.ts
Normal file
6
frontend/src/api/ohlcv.ts
Normal 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);
|
||||
}
|
||||
18
frontend/src/api/scores.ts
Normal file
18
frontend/src/api/scores.ts
Normal 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);
|
||||
}
|
||||
8
frontend/src/api/sentiment.ts
Normal file
8
frontend/src/api/sentiment.ts
Normal 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);
|
||||
}
|
||||
8
frontend/src/api/sr-levels.ts
Normal file
8
frontend/src/api/sr-levels.ts
Normal 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);
|
||||
}
|
||||
16
frontend/src/api/tickers.ts
Normal file
16
frontend/src/api/tickers.ts
Normal 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);
|
||||
}
|
||||
6
frontend/src/api/trades.ts
Normal file
6
frontend/src/api/trades.ts
Normal 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);
|
||||
}
|
||||
18
frontend/src/api/watchlist.ts
Normal file
18
frontend/src/api/watchlist.ts
Normal 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);
|
||||
}
|
||||
36
frontend/src/components/admin/DataCleanup.tsx
Normal file
36
frontend/src/components/admin/DataCleanup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/admin/JobControls.tsx
Normal file
82
frontend/src/components/admin/JobControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/admin/SettingsForm.tsx
Normal file
78
frontend/src/components/admin/SettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/admin/TickerManagement.tsx
Normal file
92
frontend/src/components/admin/TickerManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/admin/UserTable.tsx
Normal file
132
frontend/src/components/admin/UserTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
21
frontend/src/components/auth/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
232
frontend/src/components/charts/CandlestickChart.tsx
Normal file
232
frontend/src/components/charts/CandlestickChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/layout/AppShell.tsx
Normal file
17
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/layout/MobileNav.tsx
Normal file
88
frontend/src/components/layout/MobileNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/layout/Sidebar.tsx
Normal file
90
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/rankings/RankingsTable.tsx
Normal file
57
frontend/src/components/rankings/RankingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/rankings/WeightsForm.tsx
Normal file
50
frontend/src/components/rankings/WeightsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/scanner/TradeTable.tsx
Normal file
81
frontend/src/components/scanner/TradeTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ticker/FundamentalsPanel.tsx
Normal file
34
frontend/src/components/ticker/FundamentalsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/ticker/IndicatorSelector.tsx
Normal file
133
frontend/src/components/ticker/IndicatorSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ticker/SROverlay.tsx
Normal file
32
frontend/src/components/ticker/SROverlay.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/ticker/SentimentPanel.tsx
Normal file
46
frontend/src/components/ticker/SentimentPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/ui/Badge.tsx
Normal file
18
frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal file
35
frontend/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/ui/ScoreCard.tsx
Normal file
89
frontend/src/components/ui/ScoreCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/Skeleton.tsx
Normal file
23
frontend/src/components/ui/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/ui/Toast.tsx
Normal file
87
frontend/src/components/ui/Toast.tsx
Normal 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;
|
||||
}
|
||||
33
frontend/src/components/watchlist/AddTickerForm.tsx
Normal file
33
frontend/src/components/watchlist/AddTickerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/watchlist/WatchlistTable.tsx
Normal file
131
frontend/src/components/watchlist/WatchlistTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
frontend/src/hooks/useAdmin.ts
Normal file
148
frontend/src/hooks/useAdmin.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
22
frontend/src/hooks/useAuth.ts
Normal file
22
frontend/src/hooks/useAuth.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
26
frontend/src/hooks/useScores.ts
Normal file
26
frontend/src/hooks/useScores.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
40
frontend/src/hooks/useTickerDetail.ts
Normal file
40
frontend/src/hooks/useTickerDetail.ts
Normal 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 };
|
||||
}
|
||||
40
frontend/src/hooks/useTickers.ts
Normal file
40
frontend/src/hooks/useTickers.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
9
frontend/src/hooks/useTrades.ts
Normal file
9
frontend/src/hooks/useTrades.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
40
frontend/src/hooks/useWatchlist.ts
Normal file
40
frontend/src/hooks/useWatchlist.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
74
frontend/src/lib/format.ts
Normal file
74
frontend/src/lib/format.ts
Normal 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
172
frontend/src/lib/types.ts
Normal 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
21
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
48
frontend/src/pages/AdminPage.tsx
Normal file
48
frontend/src/pages/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/LoginPage.tsx
Normal file
91
frontend/src/pages/LoginPage.tsx
Normal 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't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300 transition-colors duration-200">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/pages/RankingsPage.tsx
Normal file
41
frontend/src/pages/RankingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/pages/RegisterPage.tsx
Normal file
122
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/pages/ScannerPage.tsx
Normal file
125
frontend/src/pages/ScannerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
frontend/src/pages/TickerDetailPage.tsx
Normal file
260
frontend/src/pages/TickerDetailPage.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/pages/WatchlistPage.tsx
Normal file
30
frontend/src/pages/WatchlistPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/stores/authStore.ts
Normal file
49
frontend/src/stores/authStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
134
frontend/src/styles/globals.css
Normal file
134
frontend/src/styles/globals.css
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user