Files
signal-platform/.kiro/specs/ux-improvements/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

392 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```mermaid
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**:
```mermaid
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.
```python
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.
```mermaid
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**:
```typescript
// 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.
```mermaid
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):
```typescript
// 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**:
```mermaid
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**:
```typescript
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)
```python
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)
```typescript
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