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

21 KiB
Raw Blame History

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

  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:

// 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 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)

// 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 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

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.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}