18 KiB
Design Document: Score Transparency & Trade Overlay
Overview
This feature extends the stock signal platform in two areas:
-
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.
-
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_*_scorefunction is refactored to return aScoreBreakdowndict 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
CandlestickChartcomponent receives an optionaltradeSetupprop. The chart draws overlay elements using the existing canvas rendering pipeline — no new library needed. - Trade data reuse: The frontend reuses the existing
useTradeshook and trades API. TheTickerDetailPagefilters 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.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:
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:
DimensionScoreDetailgainsbreakdown?: ScoreBreakdownScoreResponsegainscomposite_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
DimensionBreakdownPanelwhen 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
tradeSetupprop toCandlestickChart - 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_weightsthat 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_*_scorefunction 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
DimensionBreakdownPanelrenders correctly for each dimension type with known breakdown data. Test expand/collapse behavior. Test unavailable sub-score rendering. - Trade overlay: Test
CandlestickChartdraws overlay elements whentradeSetupprop 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
ScoreCardrenders 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-scoresGenerate 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 correctnessGenerate random subsets of 5 dimensions (1-5 available), assign random weights, compute re-normalized weights, and verify they sum to 1.0 and each equalsoriginal / sum(available). -
Property 3 —
Feature: score-transparency-trade-overlay, Property 3: Dimension breakdown UI rendering completenessGenerate randomScoreBreakdownobjects with 1-5 sub-scores, renderDimensionBreakdownPanel, 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 displayGenerate random score responses with random available/missing dimension combinations, renderScoreCard, 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 levelsGenerate 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 symbolGenerate 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:
hypothesislibrary,@settings(max_examples=100) - TypeScript property tests:
fast-checklibrary,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