major update
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions

View File

@@ -0,0 +1 @@
{"specId": "b047fbd7-17a8-437c-8c32-ebc8482b2aba", "workflowType": "design-first", "specType": "feature"}

View File

@@ -0,0 +1,391 @@
# 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

View File

@@ -0,0 +1,107 @@
# Requirements Document
## Introduction
This specification covers three UX improvements to the stock signal platform: (1) balanced support/resistance zone selection that ensures both zone types are represented on the chart, with a filtered levels table; (2) a Trade Scanner page enhanced with an explainer banner and detailed risk/reward analysis columns; and (3) a Rankings page weights form that replaces decimal number inputs with intuitive range sliders and automatic normalization.
## Glossary
- **SR_Service**: The backend service (`sr_service.py`) containing `cluster_sr_zones()` that clusters, scores, and selects S/R zones.
- **SR_API**: The FastAPI router endpoint (`/sr-levels/{symbol}`) that returns S/R levels and zones for a ticker.
- **SRLevelResponse**: The Pydantic response model returned by the SR_API containing levels, zones, and metadata.
- **Zone_Selector**: The interleave-based selection logic within `cluster_sr_zones()` that picks zones from support and resistance pools alternately.
- **Visible_Levels**: The subset of all detected S/R levels whose price falls within the bounds of at least one returned zone.
- **Ticker_Detail_Page**: The frontend page (`TickerDetailPage.tsx`) displaying chart, scores, sentiment, fundamentals, and S/R data for a single ticker.
- **SR_Levels_Table**: The HTML table on the Ticker_Detail_Page that lists individual S/R levels sorted by strength.
- **Scanner_Page**: The frontend page (`ScannerPage.tsx`) displaying trade setups with filtering and sorting.
- **Trade_Table**: The table component (`TradeTable.tsx`) rendering trade setup rows on the Scanner_Page.
- **Explainer_Banner**: A static informational banner at the top of the Scanner_Page describing what the scanner does.
- **Trade_Analysis**: A set of computed fields (risk amount, reward amount, stop percentage, target percentage) derived client-side from each trade setup row.
- **Rankings_Page**: The frontend page displaying ticker rankings with configurable scoring weights.
- **Weights_Form**: The form component (`WeightsForm.tsx`) on the Rankings_Page for adjusting scoring dimension weights.
- **Weight_Slider**: A range input (0100) replacing the current decimal number input for each scoring weight dimension.
- **Normalization**: The process of dividing each slider value by the sum of all slider values to produce decimal weights that sum to 1.0.
## Requirements
### Requirement 1: Balanced Zone Selection
**User Story:** As a trader, I want the S/R zone selection to include both support and resistance zones when both exist, so that I get a balanced view of key price levels regardless of the ticker's trend direction.
#### Acceptance Criteria
1. WHEN both support and resistance zones exist and `max_zones` is 2 or greater, THE Zone_Selector SHALL return at least one support zone and at least one resistance zone.
2. THE Zone_Selector SHALL select zones by alternating picks from the support pool and the resistance pool, each sorted by strength descending, until `max_zones` is reached.
3. WHEN one pool is exhausted before `max_zones` is reached, THE Zone_Selector SHALL fill the remaining slots from the other pool in strength-descending order.
4. THE Zone_Selector SHALL sort the final selected zones by strength descending before returning them.
5. WHEN no S/R levels are provided, THE SR_Service SHALL return an empty zones list.
6. WHEN `max_zones` is zero or negative, THE SR_Service SHALL return an empty zones list.
### Requirement 2: Visible Levels Filtering
**User Story:** As a trader, I want the API to provide a filtered list of S/R levels that correspond to the zones shown on the chart, so that the levels table only shows relevant data.
#### Acceptance Criteria
1. THE SRLevelResponse SHALL include a `visible_levels` field containing only the S/R levels whose price falls within the bounds of at least one returned zone.
2. THE `visible_levels` field SHALL be a subset of the `levels` field in the same response.
3. THE SRLevelResponse SHALL continue to include the full `levels` field for backward compatibility.
4. WHEN the zones list is empty, THE SRLevelResponse SHALL return an empty `visible_levels` list.
### Requirement 3: S/R Levels Table Filtering
**User Story:** As a trader, I want the S/R levels table below the chart to show only levels that correspond to zones visible on the chart, so that the table and chart are consistent.
#### Acceptance Criteria
1. THE SR_Levels_Table SHALL render levels from the `visible_levels` field of the API response instead of the full `levels` field.
2. THE SR_Levels_Table SHALL sort displayed levels by strength descending.
3. THE SR_Levels_Table SHALL color-code each level row green for support and red for resistance.
4. WHEN `visible_levels` is empty, THE Ticker_Detail_Page SHALL hide the SR_Levels_Table section.
### Requirement 4: Trade Scanner Explainer Banner
**User Story:** As a user, I want to see a brief explanation of what the Trade Scanner does when I visit the page, so that I understand the purpose and methodology of the displayed trade setups.
#### Acceptance Criteria
1. THE Scanner_Page SHALL display an Explainer_Banner above the filter controls.
2. THE Explainer_Banner SHALL contain static text describing that the scanner identifies asymmetric risk-reward trade setups using S/R levels as targets and ATR-based stops.
3. THE Explainer_Banner SHALL be visible on initial page load without requiring user interaction.
### Requirement 5: Trade Scanner R:R Analysis Columns
**User Story:** As a trader, I want to see detailed risk/reward analysis data (risk amount, reward amount, percentage distances, and color-coded R:R ratio) for each trade setup, so that I can evaluate and compare setups at a glance.
#### Acceptance Criteria
1. THE Trade_Table SHALL display the following additional columns: Risk $ (absolute risk amount), Reward $ (absolute reward amount), % to Stop (percentage distance from entry to stop-loss), and % to Target (percentage distance from entry to target).
2. THE Trade_Analysis risk_amount SHALL be computed as the absolute difference between entry_price and stop_loss.
3. THE Trade_Analysis reward_amount SHALL be computed as the absolute difference between target and entry_price.
4. THE Trade_Analysis stop_pct SHALL be computed as risk_amount divided by entry_price multiplied by 100.
5. THE Trade_Analysis target_pct SHALL be computed as reward_amount divided by entry_price multiplied by 100.
6. WHEN the R:R ratio is 3.0 or greater, THE Trade_Table SHALL display the R:R value with green color coding.
7. WHEN the R:R ratio is 2.0 or greater but less than 3.0, THE Trade_Table SHALL display the R:R value with amber color coding.
8. WHEN the R:R ratio is less than 2.0, THE Trade_Table SHALL display the R:R value with red color coding.
### Requirement 6: Rankings Weight Slider Input
**User Story:** As a user, I want to adjust scoring weights using range sliders with whole-number values instead of typing decimal numbers, so that the input is intuitive and less error-prone.
#### Acceptance Criteria
1. THE Weights_Form SHALL render each weight dimension as a Weight_Slider with a range of 0 to 100 and a step of 1.
2. THE Weights_Form SHALL display the current whole-number value next to each Weight_Slider.
3. WHEN the Weights_Form receives API weight values (decimals between 0 and 1), THE Weights_Form SHALL convert each value to the 0100 scale by multiplying by 100 and rounding to the nearest integer.
4. THE Weights_Form SHALL display a humanized label for each weight dimension by replacing underscores with spaces.
### Requirement 7: Weight Normalization on Submit
**User Story:** As a user, I want my slider values to be automatically normalized to valid decimal weights when I submit, so that I don't need to manually ensure they sum to 1.0.
#### Acceptance Criteria
1. WHEN the user submits the Weights_Form and at least one slider value is greater than zero, THE Weights_Form SHALL normalize each slider value by dividing it by the sum of all slider values.
2. WHEN all slider values are zero, THE Weights_Form SHALL disable the submit button.
3. WHEN all slider values are zero, THE Weights_Form SHALL display a validation message stating that at least one weight must be greater than zero.
4. THE Weights_Form SHALL send the normalized decimal weights to the API using the existing mutation hook.

View File

@@ -0,0 +1,124 @@
# Implementation Plan: UX Improvements
## Overview
Implement four UX improvements: balanced S/R zone selection in the backend, visible levels filtering (backend + frontend), Trade Scanner explainer banner and R:R analysis columns, and Rankings weight slider form. Backend changes use Python (FastAPI/Pydantic), frontend changes use TypeScript (React).
## Tasks
- [x] 1. Implement balanced S/R zone selection in `cluster_sr_zones()`
- [x] 1.1 Refactor `cluster_sr_zones()` to use interleave-based balanced selection
- In `app/services/sr_service.py`, after clustering and computing zones, split zones into `support_zones` and `resistance_zones` pools sorted by strength descending
- Implement round-robin interleave picking: alternate strongest from each pool until `max_zones` is reached or both pools exhausted
- If one pool is exhausted, fill remaining slots from the other pool
- Sort final selected zones by strength descending before returning
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [ ]* 1.2 Write property test: balanced zone selection guarantees both types
- **Property 1: Balanced zone selection guarantees both types**
- **Validates: Requirement 1.1**
- In `tests/unit/test_cluster_sr_zones.py`, use Hypothesis to generate sets of levels with at least one support and one resistance zone, verify output contains at least one of each type when `max_zones >= 2`
- [ ]* 1.3 Write property test: interleave selection correctness
- **Property 2: Interleave selection correctness**
- **Validates: Requirements 1.2, 1.3**
- Verify the selected zones match the expected round-robin interleave from support and resistance pools
- [ ]* 1.4 Write property test: zone output sorted by strength
- **Property 3: Zone output is sorted by strength**
- **Validates: Requirement 1.4**
- For any input, verify returned zones are sorted by strength descending
- [x] 1.5 Update existing unit tests for balanced selection behavior
- Update `tests/unit/test_cluster_sr_zones.py` to add tests for: mixed support/resistance input produces balanced output, all-support input fills from support only, all-resistance input fills from resistance only, single zone edge case
- _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6_
- [x] 2. Implement `visible_levels` filtering in backend
- [x] 2.1 Add `visible_levels` field to `SRLevelResponse` schema
- In `app/schemas/sr_level.py`, add `visible_levels: list[SRLevelResult] = []` to `SRLevelResponse`
- _Requirements: 2.1, 2.3_
- [x] 2.2 Compute `visible_levels` in the SR levels router
- In `app/routers/sr_levels.py`, after computing zones, filter `level_results` to only those whose `price_level` falls within the `[low, high]` range of at least one zone
- Set the filtered list as `visible_levels` on the `SRLevelResponse`
- When zones list is empty, `visible_levels` should be empty
- _Requirements: 2.1, 2.2, 2.4_
- [ ]* 2.3 Write property test: visible levels subset within zone bounds
- **Property 4: Visible levels are a subset within zone bounds**
- **Validates: Requirements 2.1, 2.2**
- Verify every entry in `visible_levels` appears in `levels` and has a price within at least one zone's `[low, high]` range
- [x] 2.4 Update router unit tests for `visible_levels`
- In `tests/unit/test_sr_levels_router.py`, add tests verifying: `visible_levels` is present in response, `visible_levels` contains only levels within zone bounds, `visible_levels` is empty when zones are empty
- _Requirements: 2.1, 2.2, 2.4_
- [x] 3. Checkpoint - Ensure all backend tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. Update frontend types and S/R levels table filtering
- [x] 4.1 Add `visible_levels` to frontend `SRLevelResponse` type
- In `frontend/src/lib/types.ts`, add `visible_levels: SRLevel[]` to the `SRLevelResponse` interface
- _Requirements: 2.1_
- [x] 4.2 Update `TickerDetailPage` to use `visible_levels` for the S/R table
- In `frontend/src/pages/TickerDetailPage.tsx`, change `sortedLevels` to derive from `srLevels.data.visible_levels` instead of `srLevels.data.levels`
- Keep sorting by strength descending
- Hide the S/R Levels Table section when `visible_levels` is empty
- Maintain existing color coding (green for support, red for resistance)
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 5. Add Trade Scanner explainer banner and R:R analysis columns
- [x] 5.1 Add explainer banner to `ScannerPage`
- In `frontend/src/pages/ScannerPage.tsx`, add a static informational banner above the filter controls
- Banner text: describe that the scanner identifies asymmetric risk-reward trade setups using S/R levels as targets and ATR-based stops
- Banner should be visible on initial page load without user interaction
- _Requirements: 4.1, 4.2, 4.3_
- [x] 5.2 Add R:R analysis columns to `TradeTable`
- In `frontend/src/components/scanner/TradeTable.tsx`, add computed columns: Risk $ (`|entry_price - stop_loss|`), Reward $ (`|target - entry_price|`), % to Stop (`risk / entry * 100`), % to Target (`reward / entry * 100`)
- Color-code the existing R:R ratio column: green for ≥ 3.0, amber for ≥ 2.0, red for < 2.0
- Update the `SortColumn` type and `columns` array to include the new columns
- Update `sortTrades` in `ScannerPage.tsx` to handle sorting by new computed columns
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
- [ ]* 5.3 Write property test: trade analysis computation correctness
- **Property 5: Trade analysis computation correctness**
- **Validates: Requirements 5.2, 5.3, 5.4, 5.5**
- Using fast-check, for any trade with positive entry_price, stop_loss, and target, verify `risk_amount == |entry_price - stop_loss|`, `reward_amount == |target - entry_price|`, `stop_pct == risk_amount / entry_price * 100`, `target_pct == reward_amount / entry_price * 100`
- [x] 6. Convert Rankings weight inputs to sliders
- [x] 6.1 Replace number inputs with range sliders in `WeightsForm`
- In `frontend/src/components/rankings/WeightsForm.tsx`, replace `<input type="number">` with `<input type="range" min={0} max={100} step={1}>`
- On mount, convert API decimal weights to 0100 scale: `Math.round(w * 100)`
- Display current whole-number value next to each slider
- Show humanized label (replace underscores with spaces)
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 6.2 Implement weight normalization on submit
- On submit, normalize slider values: divide each by the sum of all values
- Disable submit button when all sliders are zero
- Show validation message "At least one weight must be greater than zero" when all are zero
- Send normalized decimal weights via existing `useUpdateWeights` mutation
- _Requirements: 7.1, 7.2, 7.3, 7.4_
- [ ]* 6.3 Write property test: weight conversion round-trip
- **Property 6: Weight conversion round-trip**
- **Validates: Requirement 6.3**
- Using fast-check, verify that converting decimal weights to slider scale and normalizing back preserves relative proportions within floating-point tolerance
- [ ]* 6.4 Write property test: normalized weights sum to one
- **Property 7: Normalized weights sum to one**
- **Validates: Requirement 7.1**
- Using fast-check, for any set of slider values (integers 0100) where at least one > 0, verify normalized weights sum to 1.0 within ±1e-9
- [x] 7. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Backend uses Python (Hypothesis for property tests), frontend uses TypeScript/React (fast-check for property tests)
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation after backend and full implementation phases
- Property tests validate universal correctness properties from the design document