Files
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

15 KiB
Raw Permalink Blame History

Design Document: UX Improvements

Overview

This feature addresses three UX pain points across the application: (1) unbalanced S/R zone selection that favors one side based on ticker trend, (2) a Trade Scanner page that lacks explanatory context and detailed R:R analysis, and (3) a Rankings page weights form that requires awkward decimal input. The changes span both backend zone-selection logic and frontend presentation components.

Architecture

graph TD
    subgraph Backend
        A[sr_service.py<br/>cluster_sr_zones] -->|balanced selection| B[sr_levels router]
        B -->|zones + filtered levels| C[API Response]
    end

    subgraph Frontend - Ticker Detail
        C --> D[CandlestickChart]
        C --> E[S/R Levels Table<br/>filtered to chart zones]
    end

    subgraph Frontend - Scanner
        F[ScannerPage] --> G[Explainer Banner]
        F --> H[TradeTable<br/>expanded columns]
    end

    subgraph Frontend - Rankings
        I[RankingsPage] --> J[WeightsForm<br/>slider-based input]
    end

Components and Interfaces

Component 1: Balanced S/R Zone Selection (Backend)

Purpose: Ensure cluster_sr_zones() returns a mix of both support and resistance zones instead of only the strongest zones regardless of type.

Current behavior: Zones are sorted by strength descending and the top N are returned. For a strongly bullish ticker, all top zones may be support; for bearish, all resistance.

Proposed algorithm:

sequenceDiagram
    participant Caller
    participant cluster_sr_zones
    participant ZonePool

    Caller->>cluster_sr_zones: levels, current_price, max_zones=6
    cluster_sr_zones->>ZonePool: Cluster all levels into zones
    cluster_sr_zones->>ZonePool: Split into support[] and resistance[]
    cluster_sr_zones->>ZonePool: Sort each list by strength desc
    cluster_sr_zones->>ZonePool: Interleave pick (round-robin by strength)
    cluster_sr_zones-->>Caller: balanced zones (e.g. 3S + 3R)

Selection rules:

  1. Cluster all levels into zones (existing merge logic unchanged).
  2. Tag each zone as support or resistance (existing logic).
  3. Split zones into two pools: support_zones and resistance_zones, each sorted by strength descending.
  4. Interleave selection: alternate picking the strongest remaining zone from each pool until max_zones is reached.
  5. If one pool is exhausted, fill remaining slots from the other pool.
  6. Final result sorted by strength descending for consistent ordering.

This naturally produces balanced output (e.g., 3+3 for max_zones=6) while gracefully degrading when one side has fewer strong zones (e.g., 1R + 5S if only 1 resistance zone exists).

Interface change to SRLevelResponse:

Add a visible_levels field that contains only the individual S/R levels whose price falls within one of the returned zones. This allows the frontend table to show only what's on the chart.

class SRLevelResponse(BaseModel):
    symbol: str
    levels: list[SRLevelResult]        # all levels (unchanged, for backward compat)
    zones: list[SRZoneResult]          # balanced zones shown on chart
    visible_levels: list[SRLevelResult] # levels within returned zones only
    count: int

Responsibilities:

  • Guarantee both support and resistance representation when both exist
  • Compute visible_levels by filtering levels to those within zone boundaries
  • Maintain backward compatibility (existing levels field unchanged)

Component 2: S/R Levels Table Filtering (Frontend)

Purpose: The S/R levels table below the chart currently shows all detected levels. It should only show levels that correspond to zones visible on the chart.

Current behavior: TickerDetailPage renders sortedLevels from srLevels.data.levels — the full unfiltered list.

Proposed change: Use srLevels.data.visible_levels instead of srLevels.data.levels for the table. The chart continues to receive zones as before.

graph LR
    A[API Response] -->|zones| B[CandlestickChart]
    A -->|visible_levels| C[S/R Levels Table]
    A -->|levels| D[Other consumers<br/>backward compat]

Interface:

// Updated SRLevelResponse type
interface SRLevelResponse {
  symbol: string;
  levels: SRLevel[];          // all levels
  zones: SRZone[];            // balanced zones on chart
  visible_levels: SRLevel[];  // only levels within chart zones
  count: number;
}

Responsibilities:

  • Render only visible_levels in the table
  • Keep table sorted by strength descending
  • Show zone type color coding (green for support, red for resistance)

Component 3: Trade Scanner Explainer & R:R Analysis (Frontend)

Purpose: Add contextual explanation to the Scanner page and surface detailed trade analysis data (entry, stop-loss, target, R:R, risk amount, reward amount) so users can evaluate setups.

Best practices for R:R presentation (informed by trading UX conventions):

  • Show entry price, stop-loss, and take-profit (target) as the core trio
  • Display R:R ratio prominently with color coding (green ≥ 3:1, amber ≥ 2:1, red < 2:1)
  • Show absolute risk and reward amounts (dollar values) so traders can size positions
  • Include percentage distance from entry to stop and entry to target
  • Visual risk/reward bar showing proportional risk vs reward

Explainer banner content: A brief description of what the scanner does — it scans tracked tickers for asymmetric risk-reward trade setups using S/R levels as targets and ATR-based stops.

graph TD
    subgraph ScannerPage
        A[Explainer Banner] --> B[Filter Controls]
        B --> C[TradeTable]
    end

    subgraph TradeTable Columns
        D[Symbol]
        E[Direction]
        F[Entry Price]
        G[Stop Loss]
        H[Target / TP]
        I[Risk $]
        J[Reward $]
        K[R:R Ratio]
        L[% to Stop]
        M[% to Target]
        N[Score]
        O[Detected]
    end

New computed fields (frontend-only, derived from existing data):

// Computed per trade row — no backend changes needed
interface TradeAnalysis {
  risk_amount: number;      // |entry_price - stop_loss|
  reward_amount: number;    // |target - entry_price|
  stop_pct: number;         // risk_amount / entry_price * 100
  target_pct: number;       // reward_amount / entry_price * 100
}

Responsibilities:

  • Render explainer banner at top of ScannerPage
  • Compute risk/reward amounts and percentages client-side
  • Add new columns to TradeTable: Risk , Reward , % to Stop, % to Target
  • Color-code R:R ratio values (green ≥ 3, amber ≥ 2, red < 2)

Component 4: Rankings Weights Slider Form (Frontend)

Purpose: Replace the raw decimal number inputs in WeightsForm with range sliders using whole-number values (0100) for better UX.

Current behavior: Each weight is a <input type="number" step="any"> accepting arbitrary decimals. Users must type values like 0.25 which is error-prone.

Proposed UX:

graph LR
    subgraph Current
        A[Number Input<br/>step=any<br/>e.g. 0.25]
    end

    subgraph Proposed
        B[Range Slider 0-100<br/>with numeric display]
        C[Live value label]
        B --> C
    end

Design:

  • Each weight gets a horizontal range slider (<input type="range" min={0} max={100} step={1}>)
  • Current value displayed next to the slider as a whole number
  • On submit, values are normalized to sum to 1.0 before sending to the API (divide each by total)
  • Visual feedback: slider track colored proportionally
  • Label shows the weight name (humanized) and current value

Normalization logic:

On submit:
  total = sum of all slider values
  if total > 0:
    normalized[key] = slider_value[key] / total
  else:
    normalized[key] = 0

This means a user setting all sliders to 50 gets equal weights (each 1/N), and setting one to 100 and others to 0 gives that dimension full weight. The UX is intuitive — higher number = more importance.

Interface:

interface WeightsFormProps {
  weights: Record<string, number>;  // API values (0-1 decimals)
}

// Internal state uses whole numbers 0-100
// Convert on mount: Math.round(apiWeight * 100)
// Convert on submit: sliderValue / sum(allSliderValues)

Responsibilities:

  • Convert API decimal weights to 0100 scale on mount
  • Render slider per weight dimension with live value display
  • Normalize back to decimal weights on submit
  • Maintain existing mutation hook (useUpdateWeights)

Data Models

Updated SRLevelResponse (Backend)

class SRLevelResponse(BaseModel):
    symbol: str
    levels: list[SRLevelResult]          # all detected levels
    zones: list[SRZoneResult]            # balanced S/R zones for chart
    visible_levels: list[SRLevelResult]  # levels within chart zones
    count: int                           # total level count

Validation Rules:

  • visible_levels is a subset of levels
  • Each entry in visible_levels has a price within the bounds of at least one zone
  • zones contains at most max_zones entries
  • When both support and resistance zones exist, zones contains at least one of each type

TradeAnalysis (Frontend — computed, not persisted)

interface TradeAnalysis {
  risk_amount: number;   // always positive
  reward_amount: number; // always positive
  stop_pct: number;      // percentage, always positive
  target_pct: number;    // percentage, always positive
}

Validation Rules:

  • All values are non-negative
  • risk_amount = Math.abs(entry_price - stop_loss)
  • reward_amount = Math.abs(target - entry_price)

Error Handling

Scenario 1: No zones of one type exist

Condition: All S/R levels cluster on one side of current price (e.g., price at all-time high — no resistance levels). Response: Fill all max_zones slots from the available type. visible_levels reflects only that type. Recovery: No special handling needed — the balanced algorithm gracefully fills from the available pool.

Scenario 2: Zero S/R levels

Condition: No S/R levels detected for a ticker. Response: Return empty zones, empty visible_levels, empty levels. Chart renders without overlays. Table section hidden. Recovery: User can click "Fetch Data" to trigger recalculation.

Scenario 3: Weight sliders all set to zero

Condition: User drags all weight sliders to 0. Response: Disable the submit button and show a validation message ("At least one weight must be greater than zero"). Recovery: User adjusts at least one slider above 0.


Testing Strategy

Unit Testing Approach

  • Test cluster_sr_zones() with balanced selection: verify mixed output when both types exist
  • Test edge cases: all support, all resistance, single zone, empty input
  • Test visible_levels filtering: levels within zone bounds included, others excluded
  • Test weight normalization: verify sum-to-1 property, all-zero guard, single-weight case

Property-Based Testing Approach

Property Test Library: Hypothesis (Python backend), fast-check (frontend)

  • For any input to cluster_sr_zones() with both support and resistance levels present, the output contains at least one of each type (when max_zones ≥ 2)
  • For any set of slider values where at least one > 0, normalized weights sum to 1.0 (within floating-point tolerance)
  • visible_levels is always a subset of levels

Integration Testing Approach

  • E2E test: load ticker detail page, verify chart zones contain both types, verify table matches chart zones
  • E2E test: load scanner page, verify explainer text visible, verify computed columns present
  • E2E test: load rankings page, verify sliders render, adjust slider, submit, verify API call with normalized weights

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: Balanced zone selection guarantees both types

For any set of S/R levels that produce at least one support zone and at least one resistance zone, and any max_zones ≥ 2, the output of cluster_sr_zones() shall contain at least one support zone and at least one resistance zone.

Validates: Requirement 1.1

Property 2: Interleave selection correctness

For any set of S/R levels producing support zones S (sorted by strength desc) and resistance zones R (sorted by strength desc), the zones selected by cluster_sr_zones() shall match the result of round-robin picking from S and R alternately (strongest first from each pool) until max_zones is reached or both pools are exhausted.

Validates: Requirements 1.2, 1.3

Property 3: Zone output is sorted by strength

For any input to cluster_sr_zones(), the returned zones list shall be sorted by strength in descending order.

Validates: Requirement 1.4

Property 4: Visible levels are a subset within zone bounds

For any SRLevelResponse, every entry in visible_levels shall (a) also appear in levels, and (b) have a price_level that falls within the [low, high] range of at least one entry in zones.

Validates: Requirements 2.1, 2.2

Property 5: Trade analysis computation correctness

For any trade setup with positive entry_price, stop_loss, and target, the computed Trade_Analysis values shall satisfy: risk_amount == |entry_price - stop_loss|, reward_amount == |target - entry_price|, stop_pct == risk_amount / entry_price * 100, and target_pct == reward_amount / entry_price * 100.

Validates: Requirements 5.2, 5.3, 5.4, 5.5

Property 6: Weight conversion round-trip

For any decimal weight value w in [0, 1], converting to slider scale via Math.round(w * 100) and then normalizing back (dividing by the sum of all slider values) shall preserve the relative proportions of the original weights within floating-point tolerance.

Validates: Requirement 6.3

Property 7: Normalized weights sum to one

For any set of slider values (integers 0100) where at least one value is greater than zero, the normalized weights (each divided by the sum of all values) shall sum to 1.0 within floating-point tolerance (±1e-9).

Validates: Requirement 7.1


Performance Considerations

  • Balanced zone selection adds negligible overhead — it's a simple split + interleave over an already-small list (typically < 50 zones)
  • visible_levels filtering is O(levels × zones), both small — no concern
  • Frontend computed columns (risk/reward amounts) are derived inline per row — trivial cost
  • Slider rendering uses native <input type="range"> — no performance impact vs current number inputs

Security Considerations

  • No new API endpoints or authentication changes
  • Weight normalization happens client-side before submission; backend should still validate that weights are non-negative and sum to ~1.0
  • No user-generated content introduced (explainer text is static)

Dependencies

  • No new external libraries required
  • Backend: existing FastAPI, Pydantic, SQLAlchemy stack
  • Frontend: existing React, Recharts, TanStack Query stack
  • Slider styling can use Tailwind CSS utilities (already in project) — no additional UI library needed