Files
signal-platform/.kiro/specs/score-transparency-trade-overlay/design.md
Dennis Thiessen 181cfe6588
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped
major update
2026-02-27 16:08:09 +01:00

18 KiB

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:

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.pyget_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:

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:

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<string, number>;
  available_dimensions: string[];
  missing_dimensions: string[];
  renormalized_weights: Record<string, number>;
  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})

{
  "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 1Feature: 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 2Feature: 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 3Feature: 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 4Feature: 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 5Feature: 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 6Feature: 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: <title>
  • Each correctness property implemented by a single property-based test