# Design Document: Score Transparency & Trade Overlay ## Overview This feature extends the stock signal platform in two areas: 1. **Score Transparency** — The scoring API and UI are enhanced to expose the full breakdown of how each dimension score and the composite score are calculated. Each dimension returns its sub-scores, raw input values, weights, and formula descriptions. The frontend renders expandable panels showing this detail. 2. **Trade Setup Chart Overlay** — When a trade setup exists for a ticker (from the R:R scanner), the candlestick chart renders colored zones for entry, stop-loss, and take-profit levels. The ticker detail page fetches trade data and passes it to the chart. Both features are additive — they extend existing API responses and UI components without breaking current behavior. ## Architecture The changes follow the existing layered architecture: ``` ┌─────────────────────────────────────────────────────┐ │ Frontend (React) │ │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ │ │ ScoreCard │ │ Dimension │ │Candlestick│ │ │ │ (composite │ │ Panel │ │Chart │ │ │ │ weights) │ │ (breakdowns) │ │(trade │ │ │ └──────┬───────┘ └──────┬───────┘ │ overlay) │ │ │ │ │ └─────┬─────┘ │ │ ┌──────┴─────────────────┴────────────────┴─────┐ │ │ │ useTickerDetail + useTrades │ │ │ └──────────────────┬────────────────────────────┘ │ └─────────────────────┼───────────────────────────────┘ │ HTTP ┌─────────────────────┼───────────────────────────────┐ │ Backend (FastAPI) │ │ ┌──────────────────┴────────────────────────────┐ │ │ │ scores router │ │ │ │ GET /api/v1/scores/{symbol} │ │ │ │ (extended response with breakdowns) │ │ │ └──────────────────┬────────────────────────────┘ │ │ ┌──────────────────┴────────────────────────────┐ │ │ │ scoring_service.py │ │ │ │ _compute_*_score → returns ScoreBreakdown │ │ │ │ get_score → assembles full breakdown response │ │ │ └───────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` Key design decisions: - **Backend-driven breakdowns**: Each `_compute_*_score` function is refactored to return a `ScoreBreakdown` dict alongside the numeric score, rather than computing breakdowns separately. This ensures the breakdown always matches the actual score. - **Single API call**: The existing `GET /api/v1/scores/{symbol}` endpoint is extended (not a new endpoint) to include breakdowns in the response. This avoids extra round-trips. - **Trade overlay via props**: The `CandlestickChart` component receives an optional `tradeSetup` prop. The chart draws overlay elements using the existing canvas rendering pipeline — no new library needed. - **Trade data reuse**: The frontend reuses the existing `useTrades` hook and trades API. The `TickerDetailPage` filters for the current symbol client-side. ## Components and Interfaces ### Backend #### Modified: `scoring_service.py` — Dimension compute functions Each `_compute_*_score` function changes from returning `float | None` to returning a tuple `(float | None, ScoreBreakdown | None)` where `ScoreBreakdown` is a typed dict: ```python class SubScoreDetail(TypedDict): name: str score: float weight: float raw_value: float | str | None description: str class ScoreBreakdown(TypedDict): sub_scores: list[SubScoreDetail] formula: str unavailable: list[dict[str, str]] # [{"name": ..., "reason": ...}] ``` - `_compute_technical_score` → returns sub-scores for ADX (0.4), EMA (0.3), RSI (0.3) with raw indicator values - `_compute_sentiment_score` → returns record count, decay rate, lookback window, weighted average formula - `_compute_fundamental_score` → returns PE Ratio, Revenue Growth, Earnings Surprise sub-scores with raw values - `_compute_momentum_score` → returns 5-day ROC (0.5), 20-day ROC (0.5) with raw percentages - `_compute_sr_quality_score` → returns strong count (max 40), proximity (max 30), avg strength (max 30) with inputs #### Modified: `scoring_service.py` — `get_score` Assembles the full response including breakdowns per dimension and composite weight info (available vs missing dimensions, re-normalized weights). #### Modified: `app/schemas/score.py` New Pydantic models: ```python class SubScoreResponse(BaseModel): name: str score: float weight: float raw_value: float | str | None = None description: str = "" class ScoreBreakdownResponse(BaseModel): sub_scores: list[SubScoreResponse] formula: str unavailable: list[dict[str, str]] = [] class DimensionScoreResponse(BaseModel): # extended dimension: str score: float is_stale: bool computed_at: datetime | None = None breakdown: ScoreBreakdownResponse | None = None # NEW class CompositeBreakdownResponse(BaseModel): weights: dict[str, float] available_dimensions: list[str] missing_dimensions: list[str] renormalized_weights: dict[str, float] formula: str class ScoreResponse(BaseModel): # extended # ... existing fields ... composite_breakdown: CompositeBreakdownResponse | None = None # NEW ``` #### Modified: `app/routers/scores.py` The `read_score` endpoint populates the new breakdown fields from the service response. ### Frontend #### Modified: `frontend/src/lib/types.ts` New TypeScript types: ```typescript interface SubScore { name: string; score: number; weight: number; raw_value: number | string | null; description: string; } interface ScoreBreakdown { sub_scores: SubScore[]; formula: string; unavailable: { name: string; reason: string }[]; } interface CompositeBreakdown { weights: Record; available_dimensions: string[]; missing_dimensions: string[]; renormalized_weights: Record; formula: string; } ``` Extended existing types: - `DimensionScoreDetail` gains `breakdown?: ScoreBreakdown` - `ScoreResponse` gains `composite_breakdown?: CompositeBreakdown` #### New: `frontend/src/components/ticker/DimensionBreakdownPanel.tsx` An expandable panel component that renders inside the ScoreCard for each dimension. Shows: - Chevron toggle for expand/collapse - Sub-score rows: name, bar visualization, score value, weight badge, raw input value - Formula description text - Muted "unavailable" labels for missing sub-scores #### Modified: `frontend/src/components/ui/ScoreCard.tsx` - Each dimension row becomes clickable/expandable, rendering `DimensionBreakdownPanel` when expanded - Composite score section shows dimension weights next to each bar - Missing dimensions shown with muted styling and "redistributed" indicator - Tooltip/inline text explaining weighted average with re-normalization #### Modified: `frontend/src/components/charts/CandlestickChart.tsx` New optional prop: `tradeSetup?: TradeSetup` When provided, the chart draws: - Entry price: dashed horizontal line (blue/white) spanning full width - Stop-loss zone: red semi-transparent rectangle between entry and stop-loss - Take-profit zone: green semi-transparent rectangle between entry and target - Price labels on y-axis for entry, stop, target - All three price levels included in y-axis range calculation - Hover tooltip showing direction, entry, stop, target, R:R ratio #### Modified: `frontend/src/pages/TickerDetailPage.tsx` - Calls `useTrades()` to fetch all trade setups - Filters for current symbol, picks latest by `detected_at` - Passes `tradeSetup` prop to `CandlestickChart` - Renders a trade setup summary card below the chart when a setup exists - Handles trades API failure gracefully (chart renders without overlay, error logged) #### Modified: `frontend/src/hooks/useTickerDetail.ts` Adds trades query to the hook return value so the page has access to trade data. ## Data Models ### Backend Schema Changes No new database tables. The breakdown data is computed on-the-fly from existing data and returned in the API response only. ### API Response Shape (extended `GET /api/v1/scores/{symbol}`) ```json { "status": "success", "data": { "symbol": "AAPL", "composite_score": 72.5, "composite_stale": false, "weights": { "technical": 0.25, "sr_quality": 0.20, "sentiment": 0.15, "fundamental": 0.20, "momentum": 0.20 }, "composite_breakdown": { "weights": { "technical": 0.25, "sr_quality": 0.20, "sentiment": 0.15, "fundamental": 0.20, "momentum": 0.20 }, "available_dimensions": ["technical", "sr_quality", "fundamental", "momentum"], "missing_dimensions": ["sentiment"], "renormalized_weights": { "technical": 0.294, "sr_quality": 0.235, "fundamental": 0.235, "momentum": 0.235 }, "formula": "Weighted average of available dimensions with re-normalized weights: sum(weight_i * score_i) / sum(weight_i)" }, "dimensions": [ { "dimension": "technical", "score": 68.2, "is_stale": false, "computed_at": "2024-01-15T10:30:00Z", "breakdown": { "sub_scores": [ { "name": "ADX", "score": 72.0, "weight": 0.4, "raw_value": 72.0, "description": "ADX value (0-100). Higher = stronger trend." }, { "name": "EMA", "score": 65.0, "weight": 0.3, "raw_value": 1.5, "description": "Price 1.5% above EMA(20). Score: 50 + pct_diff * 10." }, { "name": "RSI", "score": 62.0, "weight": 0.3, "raw_value": 62.0, "description": "RSI(14) value. Score equals RSI." } ], "formula": "Weighted average: 0.4*ADX + 0.3*EMA + 0.3*RSI, re-normalized if any sub-score unavailable.", "unavailable": [] } } ], "missing_dimensions": ["sentiment"], "computed_at": "2024-01-15T10:30:00Z" } } ``` ### Trade Setup Data (existing, no changes) The `TradeSetup` type already exists in `frontend/src/lib/types.ts` with all needed fields: `symbol`, `direction`, `entry_price`, `stop_loss`, `target`, `rr_ratio`, `detected_at`. ## 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: Dimension breakdown contains correct sub-scores *For any* dimension type (technical, sentiment, fundamental, momentum, sr_quality) and any valid input data sufficient to compute that dimension, the returned `ScoreBreakdown` shall contain exactly the expected sub-score names with the correct weights for that dimension type, and each sub-score's `raw_value` shall be non-null. Specifically: - technical → ADX (0.4), EMA (0.3), RSI (0.3) - sentiment → record_count, decay_rate, lookback_window as sub-score metadata - fundamental → PE Ratio, Revenue Growth, Earnings Surprise (equal weight) - momentum → 5-day ROC (0.5), 20-day ROC (0.5) - sr_quality → Strong Count (max 40), Proximity (max 30), Avg Strength (max 30) **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6** ### Property 2: Composite re-normalization correctness *For any* set of dimension scores where at least one dimension is available and zero or more are missing, the composite breakdown shall: - List exactly the available dimensions in `available_dimensions` - List exactly the missing dimensions in `missing_dimensions` - Have `renormalized_weights` that sum to 1.0 (within floating-point tolerance) - Have each renormalized weight equal to `original_weight / sum(available_original_weights)` **Validates: Requirements 1.7, 3.2** ### Property 3: Dimension breakdown UI rendering completeness *For any* `ScoreBreakdown` object with N sub-scores, the `DimensionBreakdownPanel` component shall render exactly N sub-score rows, each containing the sub-score name, numeric score value, weight, and raw input value. **Validates: Requirements 2.1** ### Property 4: Composite weight display *For any* score response with K dimensions (some available, some missing), the `ScoreCard` component shall render the weight value next to each dimension bar, and missing dimensions shall be rendered with a visually distinct (muted/dimmed) style. **Validates: Requirements 3.1, 3.2** ### Property 5: Trade overlay y-axis range includes all trade levels *For any* OHLCV dataset and any `TradeSetup` (with entry_price, stop_loss, target), the chart's computed y-axis range `[lo, hi]` shall satisfy: `lo <= min(entry_price, stop_loss, target)` and `hi >= max(entry_price, stop_loss, target)`. **Validates: Requirements 4.4** ### Property 6: Trade setup selection picks latest matching symbol *For any* non-empty list of `TradeSetup` objects and any symbol string, filtering for that symbol and selecting by latest `detected_at` shall return the setup with the maximum `detected_at` among all setups matching that symbol. If no setups match, the result shall be null/undefined. **Validates: Requirements 5.1, 5.5** ## Error Handling | Scenario | Behavior | |---|---| | Dimension computation fails (insufficient data) | Score returns `None`, breakdown includes unavailable sub-scores with reason strings (Req 1.8) | | Individual sub-score fails (e.g., ADX needs 28 bars but only 20 available) | Sub-score omitted from breakdown, added to `unavailable` list with reason. Remaining sub-scores re-normalized (Req 1.8) | | Trades API request fails on TickerDetailPage | Chart renders without trade overlay. Error logged to console. Page remains functional (Req 5.4) | | No trade setup exists for current symbol | Chart renders normally without any overlay elements (Req 4.6) | | Score breakdown data is null (stale or never computed) | DimensionPanel shows score without expandable breakdown section | | Composite has zero available dimensions | `composite_score` is `null`, `composite_breakdown` shows all dimensions as missing | ## Testing Strategy ### Unit Tests Unit tests cover specific examples and edge cases: - **Backend**: Test each `_compute_*_score` function returns correct breakdown structure for known input data. Test edge cases: missing sub-scores, all sub-scores missing, single sub-score available. - **Frontend components**: Test `DimensionBreakdownPanel` renders correctly for each dimension type with known breakdown data. Test expand/collapse behavior. Test unavailable sub-score rendering. - **Trade overlay**: Test `CandlestickChart` draws overlay elements when `tradeSetup` prop is provided. Test no overlay when prop is absent. Test tooltip content on hover. - **Trade setup selection**: Test filtering and latest-selection logic with specific examples including edge cases (no matches, single match, multiple matches with same timestamp). - **Composite display**: Test `ScoreCard` renders weights, missing dimension indicators, and re-normalization explanation. ### Property-Based Tests Property-based tests use `hypothesis` (Python backend) and `fast-check` (TypeScript frontend) with minimum 100 iterations per property. Each property test references its design document property: - **Property 1** — `Feature: score-transparency-trade-overlay, Property 1: Dimension breakdown contains correct sub-scores` Generate random valid indicator data for each dimension type, compute the score, and verify the breakdown structure matches the expected sub-score names and weights. - **Property 2** — `Feature: score-transparency-trade-overlay, Property 2: Composite re-normalization correctness` Generate random subsets of 5 dimensions (1-5 available), assign random weights, compute re-normalized weights, and verify they sum to 1.0 and each equals `original / sum(available)`. - **Property 3** — `Feature: score-transparency-trade-overlay, Property 3: Dimension breakdown UI rendering completeness` Generate random `ScoreBreakdown` objects with 1-5 sub-scores, render `DimensionBreakdownPanel`, and verify the DOM contains exactly N sub-score rows with all required fields. - **Property 4** — `Feature: score-transparency-trade-overlay, Property 4: Composite weight display` Generate random score responses with random available/missing dimension combinations, render `ScoreCard`, and verify weight labels are present and missing dimensions are visually distinct. - **Property 5** — `Feature: score-transparency-trade-overlay, Property 5: Trade overlay y-axis range includes all trade levels` Generate random OHLCV data and random trade setups, compute the chart y-axis range, and verify all three trade levels fall within `[lo, hi]`. - **Property 6** — `Feature: score-transparency-trade-overlay, Property 6: Trade setup selection picks latest matching symbol` Generate random lists of trade setups with random symbols and timestamps, apply the selection logic, and verify the result is the latest setup for the target symbol. ### Test Configuration - Python property tests: `hypothesis` library, `@settings(max_examples=100)` - TypeScript property tests: `fast-check` library, `fc.assert(property, { numRuns: 100 })` - Each property test tagged with a comment: `Feature: score-transparency-trade-overlay, Property N: ` - Each correctness property implemented by a single property-based test