15 KiB
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:
- Cluster all levels into zones (existing merge logic unchanged).
- Tag each zone as support or resistance (existing logic).
- Split zones into two pools:
support_zonesandresistance_zones, each sorted by strength descending. - Interleave selection: alternate picking the strongest remaining zone from each pool until
max_zonesis reached. - If one pool is exhausted, fill remaining slots from the other pool.
- 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_levelsby filteringlevelsto those within zone boundaries - Maintain backward compatibility (existing
levelsfield 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_levelsin 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 (0–100) 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 0–100 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_levelsis a subset oflevels- Each entry in
visible_levelshas a price within the bounds of at least one zone zonescontains at mostmax_zonesentries- When both support and resistance zones exist,
zonescontains 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_levelsfiltering: 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_levelsis always a subset oflevels
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 0–100) 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_levelsfiltering 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