21 KiB
Design Document: Signal Dashboard
Overview
Signal Dashboard is a React 18 + TypeScript SPA that consumes the existing Stock Data Backend REST API (/api/v1/). It provides authenticated users with views for watchlist monitoring, per-ticker analysis, trade setup scanning, composite-score rankings, and admin management.
The frontend lives in frontend/ within the existing project root. Vite builds static assets to frontend/dist/, which Nginx serves on signal.thiessen.io. API requests to /api/v1/ are proxied to the FastAPI backend — no CORS needed.
Key Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Build tool | Vite 5 | Fast HMR, native TS/React support, small output |
| Routing | React Router v6 | Standard, supports layout routes and guards |
| Server state | TanStack Query v5 | Caching, deduplication, background refetch |
| Client state | Zustand | Minimal auth store, no boilerplate |
| Styling | Tailwind CSS v3 | Utility-first, dark mode built-in, small bundle |
| Charts | Recharts | React-native charting, composable, lightweight |
| HTTP | Axios | Interceptors for auth/envelope unwrapping |
| Testing | Vitest + React Testing Library + fast-check | Vite-native test runner, property-based testing |
Architecture
graph TD
subgraph Browser
Router[React Router]
Pages[Page Components]
Hooks[TanStack Query Hooks]
Store[Zustand Auth Store]
API[API Client - Axios]
end
Router --> Pages
Pages --> Hooks
Hooks --> API
API --> Store
Store --> API
subgraph Server
Nginx[Nginx - static files + proxy]
Backend[FastAPI Backend]
end
API -->|/api/v1/*| Nginx
Nginx -->|proxy_pass| Backend
Nginx -->|static| Browser
Request Flow
- Component mounts → calls a TanStack Query hook (e.g.,
useWatchlist()) - Hook calls an API client function (e.g.,
api.watchlist.list()) - Axios sends request with JWT Bearer header from Zustand store
- Axios response interceptor unwraps
{ status, data, error }envelope - On 401 → Zustand clears token, React Router redirects to
/login - TanStack Query caches the result, component renders data
Directory Structure
frontend/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── postcss.config.js
├── src/
│ ├── main.tsx # App entry, providers
│ ├── App.tsx # Router + layout
│ ├── api/
│ │ ├── client.ts # Axios instance, interceptors
│ │ ├── auth.ts # login, register
│ │ ├── watchlist.ts # watchlist CRUD
│ │ ├── tickers.ts # ticker CRUD
│ │ ├── scores.ts # scores, rankings, weights
│ │ ├── trades.ts # trade setups
│ │ ├── ohlcv.ts # OHLCV data
│ │ ├── indicators.ts # technical indicators
│ │ ├── sr-levels.ts # support/resistance
│ │ ├── sentiment.ts # sentiment data
│ │ ├── fundamentals.ts # fundamental data
│ │ ├── ingestion.ts # manual data fetch
│ │ ├── admin.ts # admin endpoints
│ │ └── health.ts # health check
│ ├── hooks/
│ │ ├── useAuth.ts # login/register/logout mutations
│ │ ├── useWatchlist.ts # watchlist queries + mutations
│ │ ├── useTickers.ts # ticker queries + mutations
│ │ ├── useScores.ts # scores, rankings queries
│ │ ├── useTrades.ts # trade setup queries
│ │ ├── useTickerDetail.ts # parallel queries for detail view
│ │ └── useAdmin.ts # admin queries + mutations
│ ├── stores/
│ │ └── authStore.ts # Zustand: token, user, role
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── WatchlistPage.tsx
│ │ ├── TickerDetailPage.tsx
│ │ ├── ScannerPage.tsx
│ │ ├── RankingsPage.tsx
│ │ └── AdminPage.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.tsx # Sidebar + main content
│ │ │ ├── Sidebar.tsx
│ │ │ └── MobileNav.tsx
│ │ ├── auth/
│ │ │ └── ProtectedRoute.tsx
│ │ ├── charts/
│ │ │ └── CandlestickChart.tsx
│ │ ├── ui/
│ │ │ ├── ScoreCard.tsx
│ │ │ ├── Toast.tsx
│ │ │ ├── Skeleton.tsx
│ │ │ ├── Badge.tsx
│ │ │ └── ConfirmDialog.tsx
│ │ ├── watchlist/
│ │ │ ├── WatchlistTable.tsx
│ │ │ └── AddTickerForm.tsx
│ │ ├── scanner/
│ │ │ └── TradeTable.tsx
│ │ ├── rankings/
│ │ │ ├── RankingsTable.tsx
│ │ │ └── WeightsForm.tsx
│ │ ├── ticker/
│ │ │ ├── SentimentPanel.tsx
│ │ │ ├── FundamentalsPanel.tsx
│ │ │ ├── IndicatorSelector.tsx
│ │ │ └── SROverlay.tsx
│ │ └── admin/
│ │ ├── UserTable.tsx
│ │ ├── SettingsForm.tsx
│ │ ├── JobControls.tsx
│ │ └── DataCleanup.tsx
│ ├── lib/
│ │ ├── format.ts # Number/date formatting utilities
│ │ └── types.ts # Shared TypeScript interfaces
│ └── styles/
│ └── globals.css # Tailwind directives + custom vars
└── tests/
├── unit/
└── property/
Components and Interfaces
API Client (src/api/client.ts)
Central Axios instance with interceptors:
// Axios instance configuration
const apiClient = axios.create({
baseURL: '/api/v1/',
timeout: 30_000,
headers: { 'Content-Type': 'application/json' },
});
// Request interceptor: attach JWT
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor: unwrap envelope, handle 401
apiClient.interceptors.response.use(
(response) => {
const envelope = response.data as APIEnvelope;
if (envelope.status === 'error') throw new ApiError(envelope.error);
return envelope.data;
},
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
const msg = error.response?.data?.error ?? error.message ?? 'Network error';
throw new ApiError(msg);
}
);
Auth Store (src/stores/authStore.ts)
interface AuthState {
token: string | null;
username: string | null;
role: 'admin' | 'user' | null;
login: (token: string) => void;
logout: () => void;
}
login()decodes the JWT payload to extractsub(username) androle, stores token inlocalStoragelogout()clears token from state andlocalStorage, TanStack Query cache is cleared on logout
Protected Route (src/components/auth/ProtectedRoute.tsx)
// Wraps routes that require authentication
// Props: requireAdmin?: boolean
// If no token → redirect to /login
// If requireAdmin && role !== 'admin' → redirect to /watchlist
Router Layout
// Route structure
<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>
TanStack Query Hooks Pattern
Each domain has a hook file that exports query/mutation hooks:
// Example: useWatchlist.ts
export function useWatchlist() {
return useQuery({
queryKey: ['watchlist'],
queryFn: () => api.watchlist.list(),
});
}
export function useAddToWatchlist() {
const qc = useQueryClient();
return useMutation({
mutationFn: (symbol: string) => api.watchlist.add(symbol),
onSuccess: () => qc.invalidateQueries({ queryKey: ['watchlist'] }),
});
}
Key UI Components
ScoreCard: Displays composite score with a colored ring/bar (green > 70, yellow 40-70, red < 40) and expandable dimension breakdown.
CandlestickChart: Recharts ComposedChart with custom Bar shapes for OHLCV candles. S/R levels rendered as ReferenceLine components with color coding (green = support, red = resistance).
Toast System: Lightweight toast using React context + portal. Auto-dismiss after 4 seconds. Error toasts in red, success in green.
Skeleton: Tailwind animate-pulse placeholder blocks matching the shape of cards/tables during loading states.
Formatting Utilities (src/lib/format.ts)
formatPrice(n: number): string // "1,234.56"
formatPercent(n: number): string // "12.34%"
formatLargeNumber(n: number): string // "1.23B", "456.7M", "12.3K"
formatDate(d: string): string // "Jan 15, 2025"
formatDateTime(d: string): string // "Jan 15, 2025 2:30 PM"
Data Models
TypeScript Interfaces (src/lib/types.ts)
// API envelope (before unwrapping)
interface APIEnvelope<T = unknown> {
status: 'success' | 'error';
data: T | null;
error: string | null;
}
// Auth
interface TokenResponse {
access_token: string;
token_type: string;
}
// Watchlist
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;
}
interface DimensionScore {
dimension: string;
score: number;
}
interface SRLevelSummary {
price_level: number;
type: 'support' | 'resistance';
strength: number;
}
// OHLCV
interface OHLCVBar {
id: number;
ticker_id: number;
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
created_at: string;
}
// Scores
interface ScoreResponse {
symbol: string;
composite_score: number | null;
composite_stale: boolean;
weights: Record<string, number>;
dimensions: DimensionScoreDetail[];
missing_dimensions: string[];
computed_at: string | null;
}
interface DimensionScoreDetail {
dimension: string;
score: number;
is_stale: boolean;
computed_at: string | null;
}
interface RankingEntry {
symbol: string;
composite_score: number;
dimensions: DimensionScoreDetail[];
}
interface RankingsResponse {
rankings: RankingEntry[];
weights: Record<string, number>;
}
// Trade Setups
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
interface SRLevel {
id: number;
price_level: number;
type: 'support' | 'resistance';
strength: number;
detection_method: string;
created_at: string;
}
interface SRLevelResponse {
symbol: string;
levels: SRLevel[];
count: number;
}
// Sentiment
interface SentimentScore {
id: number;
classification: 'bullish' | 'bearish' | 'neutral';
confidence: number;
source: string;
timestamp: string;
}
interface SentimentResponse {
symbol: string;
scores: SentimentScore[];
count: number;
dimension_score: number | null;
lookback_hours: number;
}
// Fundamentals
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
interface IndicatorResult {
indicator_type: string;
values: Record<string, unknown>;
score: number;
bars_used: number;
}
interface EMACrossResult {
short_ema: number;
long_ema: number;
short_period: number;
long_period: number;
signal: 'bullish' | 'bearish' | 'neutral';
}
// Tickers
interface Ticker {
id: number;
symbol: string;
created_at: string;
}
// Admin
interface AdminUser {
id: number;
username: string;
role: string;
has_access: boolean;
created_at: string | null;
updated_at: string | null;
}
interface SystemSetting {
key: string;
value: string;
updated_at: string | null;
}
Correctness Properties
A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
Property 1: Token storage round-trip
For any valid JWT token string, storing it via authStore.login(token) and then reading authStore.token and localStorage.getItem('token') should both return the original token string.
Validates: Requirements 1.1, 1.6
Property 2: Bearer token attachment
For any non-null token in the auth store, every request made through the API client should include an Authorization header with value Bearer {token}.
Validates: Requirements 1.3, 12.3
Property 3: Registration form validation
For any username string shorter than 1 character or password string shorter than 6 characters, the registration form should reject submission. For any username of length >= 1 and password of length >= 6, the form should allow submission.
Validates: Requirements 1.2
Property 4: Route protection based on auth state
For any protected route path, if no token exists in the auth store, navigation should redirect to /login. If a valid token exists, navigation should render the protected component.
Validates: Requirements 2.1, 2.2
Property 5: API envelope unwrapping
For any API response with status: "success", the API client should return the data field. For any API response with status: "error", the API client should throw an error containing the error field message.
Validates: Requirements 12.2
Property 6: Watchlist entry rendering completeness
For any watchlist entry, the rendered output should contain the symbol, entry type (with a visual badge distinguishing "auto" from "manual"), composite score, dimension scores, R:R ratio, R:R direction, and S/R levels.
Validates: Requirements 3.2, 3.7
Property 7: Symbol click navigation
For any symbol displayed in the watchlist table, scanner table, or rankings table, clicking that symbol should trigger navigation to /ticker/{symbol}.
Validates: Requirements 3.6, 5.6, 6.4
Property 8: Score card rendering
For any score response with a composite score and dimension scores, the ScoreCard component should render the composite score value and one entry per dimension with its name and score.
Validates: Requirements 4.4
Property 9: Sentiment panel rendering
For any sentiment response, the rendered SentimentPanel should display the classification, confidence value, and dimension score.
Validates: Requirements 4.5
Property 10: Fundamentals panel rendering
For any fundamentals response, the rendered FundamentalsPanel should display P/E ratio, revenue growth, earnings surprise, and market cap (or a placeholder for null values).
Validates: Requirements 4.6
Property 11: Trade setup rendering
For any trade setup, the rendered table row should contain the symbol, direction, entry price, stop loss, target, R:R ratio, composite score, and detection timestamp.
Validates: Requirements 5.2
Property 12: Scanner filtering
For any list of trade setups, minimum R:R filter value, and direction filter selection: all displayed setups should have rr_ratio >= minRR and (if direction is not "both") direction === selectedDirection.
Validates: Requirements 5.3, 5.4
Property 13: Scanner sorting
For any list of trade setups and a selected sort column, the displayed rows should be ordered by that column's values (ascending or descending based on sort direction).
Validates: Requirements 5.5
Property 14: Rankings display order
For any rankings response, the rendered list should display entries in descending order by composite score, with each entry showing rank position, symbol, composite score, and all dimension scores.
Validates: Requirements 6.1, 6.2
Property 15: Admin user table rendering
For any admin user record, the rendered table row should contain the username, role, and access status.
Validates: Requirements 7.2
Property 16: Number formatting
For any finite number, formatPrice should produce a string with exactly 2 decimal places. formatPercent should produce a string ending with %. formatLargeNumber should produce a string with an appropriate suffix (K for thousands, M for millions, B for billions) for values >= 1000, and no suffix for smaller values.
Validates: Requirements 13.4
Property 17: Weights form rendering
For any weights map (dimension name → number), the WeightsForm should render one labeled numeric input per dimension key.
Validates: Requirements 11.1
Error Handling
API Client Error Strategy
All errors flow through the Axios response interceptor and are surfaced via the Toast system:
| Error Type | Detection | Behavior |
|---|---|---|
| 401 Unauthorized | error.response.status === 401 |
Clear auth store, redirect to /login |
| API error envelope | envelope.status === 'error' |
Throw ApiError with envelope.error message |
| Network error | No error.response |
Throw ApiError with "Network error — check your connection" |
| Timeout | Axios timeout (30s) | Throw ApiError with "Request timed out" |
| Unknown | Catch-all | Throw ApiError with error.message fallback |
Component-Level Error Handling
- TanStack Query
onError: Each mutation hook passes errors to the toast system - Query error states: Components check
isErrorand render inline error messages - Ticker Detail partial failure: Each data section (scores, sentiment, fundamentals, S/R, OHLCV) is an independent query. If one fails, the others still render. Failed sections show an inline error with a retry button.
- Form validation: Client-side validation before API calls (username length, password length, numeric inputs). Invalid submissions are blocked with inline field errors.
Toast System
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
type: ToastType;
message: string;
}
// Auto-dismiss after 4 seconds
// Max 3 toasts visible at once (oldest dismissed first)
// Error toasts: red accent, Success: green accent, Info: blue accent
Testing Strategy
Testing Stack
| Tool | Purpose |
|---|---|
| Vitest | Test runner (Vite-native, fast) |
| React Testing Library | Component rendering + DOM queries |
| fast-check | Property-based testing |
| MSW (Mock Service Worker) | API mocking for integration tests |
Unit Tests
Unit tests cover specific examples, edge cases, and integration points:
- Auth flow: Login stores token, logout clears token, 401 triggers logout
- API client: Envelope unwrapping for success/error, timeout config, Bearer header
- Routing: Unauthenticated redirect, admin-only route guard, non-admin redirect
- Component rendering: Each page renders with mock data, loading skeletons appear, error states display
- Form validation: Empty username rejected, short password rejected, valid inputs accepted
- Confirmation dialog: Delete ticker shows confirm before API call
- Partial failure: Ticker detail renders available sections when one query fails
Property-Based Tests
Each correctness property maps to a single fast-check property test with minimum 100 iterations. Tests are tagged with the property reference:
// Feature: signal-dashboard, Property 16: Number formatting
test.prop([fc.float({ min: -1e15, max: 1e15, noNaN: true })], (n) => {
const result = formatPrice(n);
expect(result).toMatch(/\.\d{2}$/);
});
Property tests focus on:
- Pure functions:
format.tsutilities (Property 16) - Store logic: Auth store token round-trip (Property 1)
- API client interceptors: Envelope unwrapping (Property 5), Bearer attachment (Property 2)
- Filtering/sorting logic: Scanner filter functions (Properties 12, 13)
- Component rendering: Given generated data, components render required fields (Properties 6, 8–11, 14, 15, 17)
- Routing guards: Protected route behavior based on auth state (Property 4)
Test Configuration
- Vitest config in
frontend/vitest.config.tswith jsdom environment fast-checkconfigured with{ numRuns: 100 }minimum per property- MSW handlers for all API endpoints used in integration tests
- Each property test tagged:
Feature: signal-dashboard, Property {N}: {title}