Files
signal-platform/.kiro/specs/signal-dashboard/design.md
Dennis Thiessen 61ab24490d
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped
first commit
2026-02-20 17:31:01 +01:00

646 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```mermaid
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
1. Component mounts → calls a TanStack Query hook (e.g., `useWatchlist()`)
2. Hook calls an API client function (e.g., `api.watchlist.list()`)
3. Axios sends request with JWT Bearer header from Zustand store
4. Axios response interceptor unwraps `{ status, data, error }` envelope
5. On 401 → Zustand clears token, React Router redirects to `/login`
6. 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:
```typescript
// 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`)
```typescript
interface AuthState {
token: string | null;
username: string | null;
role: 'admin' | 'user' | null;
login: (token: string) => void;
logout: () => void;
}
```
- `login()` decodes the JWT payload to extract `sub` (username) and `role`, stores token in `localStorage`
- `logout()` clears token from state and `localStorage`, TanStack Query cache is cleared on logout
### Protected Route (`src/components/auth/ProtectedRoute.tsx`)
```typescript
// Wraps routes that require authentication
// Props: requireAdmin?: boolean
// If no token → redirect to /login
// If requireAdmin && role !== 'admin' → redirect to /watchlist
```
### Router Layout
```typescript
// 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:
```typescript
// 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`)
```typescript
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`)
```typescript
// 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 `isError` and 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
```typescript
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:
```typescript
// 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.ts` utilities (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, 811, 14, 15, 17)
- **Routing guards**: Protected route behavior based on auth state (Property 4)
### Test Configuration
- Vitest config in `frontend/vitest.config.ts` with jsdom environment
- `fast-check` configured 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}`