# R:R Scanner Target Quality Bugfix Design ## Overview The `scan_ticker` function in `app/services/rr_scanner_service.py` selects trade setup targets by iterating candidate S/R levels and picking the one with the highest R:R ratio. Because risk is fixed (ATR × multiplier), R:R is a monotonically increasing function of distance from entry price. This means the scanner always selects the most distant S/R level, producing unrealistic trade setups. The fix replaces the `max(rr)` selection with a quality score that balances three factors: R:R ratio, S/R level strength (0–100), and proximity to current price. The quality score is computed as a weighted sum of normalized components, and the candidate with the highest quality score is selected as the target. ## Glossary - **Bug_Condition (C)**: Multiple candidate S/R levels exist in the target direction, and the current code selects the most distant one purely because it has the highest R:R ratio, ignoring strength and proximity - **Property (P)**: The scanner should select the candidate with the highest quality score (a weighted combination of R:R ratio, strength, and proximity) rather than the highest raw R:R ratio - **Preservation**: All behavior for single-candidate scenarios, no-candidate scenarios, R:R threshold filtering, database persistence, and `get_trade_setups` sorting must remain unchanged - **scan_ticker**: The function in `app/services/rr_scanner_service.py` that scans a single ticker for long and short trade setups - **SRLevel.strength**: An integer 0–100 representing how many times price has touched this level relative to total bars (computed by `sr_service._strength_from_touches`) - **quality_score**: New scoring metric: `w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity` ## Bug Details ### Fault Condition The bug manifests when multiple S/R levels exist in the target direction (above entry for longs, below entry for shorts) and the scanner selects the most distant level because it has the highest R:R ratio, even though a closer, stronger level would be a more realistic target. **Formal Specification:** ``` FUNCTION isBugCondition(input) INPUT: input of type {entry_price, risk, candidate_levels: list[{price_level, strength}]} OUTPUT: boolean candidates := [lv for lv in candidate_levels where reward(lv) / risk >= rr_threshold] IF len(candidates) < 2 THEN RETURN false max_rr_level := argmax(candidates, key=lambda lv: reward(lv) / risk) max_quality_level := argmax(candidates, key=lambda lv: quality_score(lv, entry_price, risk)) RETURN max_rr_level != max_quality_level END FUNCTION ``` ### Examples - **Long, 2 resistance levels**: Entry=100, ATR-stop=97 (risk=3). Level A: price=103, strength=80 (R:R=1.0). Level B: price=115, strength=10 (R:R=5.0). Current code picks B (highest R:R). Expected: picks A (strong, nearby, realistic). - **Long, 3 resistance levels**: Entry=50, risk=2. Level A: price=53, strength=90 (R:R=1.5). Level B: price=58, strength=40 (R:R=4.0). Level C: price=70, strength=5 (R:R=10.0). Current code picks C. Expected: picks A or B depending on quality weights. - **Short, 2 support levels**: Entry=200, risk=5. Level A: price=192, strength=70 (R:R=1.6). Level B: price=170, strength=15 (R:R=6.0). Current code picks B. Expected: picks A. - **Single candidate (no bug)**: Entry=100, risk=3. Only Level A: price=106, strength=50 (R:R=2.0). Both old and new code select A — no divergence. ## Expected Behavior ### Preservation Requirements **Unchanged Behaviors:** - When no S/R levels exist in the target direction, no setup is produced for that direction - When no candidate level meets the R:R threshold, no setup is produced - When only one S/R level exists in the target direction, it is evaluated against the R:R threshold and used if it qualifies - `scan_all_tickers` processes each ticker independently; one failure does not stop others - `get_trade_setups` returns results sorted by R:R ratio descending with composite score as secondary sort - Database persistence: old setups are deleted and new ones inserted per ticker - ATR computation, OHLCV fetching, and stop-loss calculation remain unchanged - The TradeSetup model fields and their rounding (4 decimal places) remain unchanged **Scope:** All inputs where only zero or one candidate S/R levels exist in the target direction are completely unaffected by this fix. The fix only changes the selection logic when multiple qualifying candidates exist. ## Hypothesized Root Cause Based on the bug description, the root cause is straightforward: 1. **Selection by max R:R only**: The inner loop in `scan_ticker` tracks `best_rr` and `best_target`, selecting whichever level produces the highest `rr = reward / risk`. Since `risk` is constant (ATR-based), `rr` is proportional to distance. The code has no mechanism to factor in `SRLevel.strength` or proximity. 2. **No quality scoring exists**: The `SRLevel.strength` field (0–100) is available in the database and loaded by the query, but the selection loop never reads it. There is no quality score computation anywhere in the codebase. 3. **No proximity normalization**: Distance from entry is used only to compute reward, never as a penalty. Closer levels are always disadvantaged. ## Correctness Properties Property 1: Fault Condition - Quality Score Selection Replaces Max R:R _For any_ input where multiple candidate S/R levels exist in the target direction and meet the R:R threshold, the fixed `scan_ticker` function SHALL select the candidate with the highest quality score (weighted combination of normalized R:R, normalized strength, and normalized proximity) rather than the candidate with the highest raw R:R ratio. **Validates: Requirements 2.1, 2.2, 2.3, 2.4** Property 2: Preservation - Single/Zero Candidate Behavior Unchanged _For any_ input where zero or one candidate S/R levels exist in the target direction, the fixed `scan_ticker` function SHALL produce the same result as the original function, preserving the existing filtering, persistence, and output behavior. **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** ## Fix Implementation ### Changes Required Assuming our root cause analysis is correct: **File**: `app/services/rr_scanner_service.py` **Function**: `scan_ticker` **Specific Changes**: 1. **Add `_compute_quality_score` helper function**: A new module-level function that computes the quality score for a candidate S/R level given entry price, risk, and configurable weights. ```python def _compute_quality_score( rr: float, strength: int, distance: float, entry_price: float, *, w_rr: float = 0.35, w_strength: float = 0.35, w_proximity: float = 0.30, rr_cap: float = 10.0, ) -> float: norm_rr = min(rr / rr_cap, 1.0) norm_strength = strength / 100.0 norm_proximity = 1.0 - min(distance / entry_price, 1.0) return w_rr * norm_rr + w_strength * norm_strength + w_proximity * norm_proximity ``` - `norm_rr`: R:R capped at `rr_cap` (default 10) and divided to get 0–1 range - `norm_strength`: Strength divided by 100 (already 0–100 integer) - `norm_proximity`: `1 - (distance / entry_price)`, so closer levels score higher - Default weights: 0.35 R:R, 0.35 strength, 0.30 proximity (sum = 1.0) 2. **Replace long setup selection loop**: Instead of tracking `best_rr` / `best_target`, iterate candidates, compute quality score for each, and track `best_quality` / `best_candidate`. Still filter by `rr >= rr_threshold` before scoring. Store the selected level's R:R in the TradeSetup (not the quality score — R:R remains the reported metric). 3. **Replace short setup selection loop**: Same change as longs but for levels below entry. 4. **Pass `SRLevel` object through selection**: The loop already has access to `lv.strength` from the query. No additional DB queries needed. 5. **No changes to `get_trade_setups`**: Sorting by `rr_ratio` descending remains. The `rr_ratio` stored in TradeSetup is the actual R:R of the selected level, not the quality score. ## Testing Strategy ### Validation Approach The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the bug on unfixed code, then verify the fix works correctly and preserves existing behavior. ### Exploratory Fault Condition Checking **Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm or refute the root cause analysis. If we refute, we will need to re-hypothesize. **Test Plan**: Create mock scenarios with multiple S/R levels of varying strength and distance. Run `scan_ticker` on unfixed code and assert that the selected target is NOT the most distant level. These tests will fail on unfixed code, confirming the bug. **Test Cases**: 1. **Long with strong-near vs weak-far**: Entry=100, risk=3. Near level (103, strength=80) vs far level (115, strength=10). Assert selected target != 115 (will fail on unfixed code) 2. **Short with strong-near vs weak-far**: Entry=200, risk=5. Near level (192, strength=70) vs far level (170, strength=15). Assert selected target != 170 (will fail on unfixed code) 3. **Three candidates with varying profiles**: Entry=50, risk=2. Three levels at different distances/strengths. Assert selection is not purely distance-based (will fail on unfixed code) **Expected Counterexamples**: - The unfixed code always selects the most distant level regardless of strength - Root cause confirmed: selection loop only tracks `best_rr` which is proportional to distance ### Fix Checking **Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior. **Pseudocode:** ``` FOR ALL input WHERE isBugCondition(input) DO result := scan_ticker_fixed(input) selected_level := result.target ASSERT selected_level == argmax(candidates, key=quality_score) ASSERT quality_score(selected_level) >= quality_score(any_other_candidate) END FOR ``` ### Preservation Checking **Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function. **Pseudocode:** ``` FOR ALL input WHERE NOT isBugCondition(input) DO ASSERT scan_ticker_original(input) == scan_ticker_fixed(input) END FOR ``` **Testing Approach**: Property-based testing is recommended for preservation checking because: - It generates many test cases automatically across the input domain - It catches edge cases that manual unit tests might miss - It provides strong guarantees that behavior is unchanged for all non-buggy inputs **Test Plan**: Observe behavior on UNFIXED code first for zero-candidate and single-candidate scenarios, then write property-based tests capturing that behavior. **Test Cases**: 1. **Zero candidates preservation**: Generate random tickers with no S/R levels in target direction. Verify no setup is produced (same as original). 2. **Single candidate preservation**: Generate random tickers with exactly one qualifying S/R level. Verify same setup is produced as original. 3. **Below-threshold preservation**: Generate random tickers where all candidates have R:R below threshold. Verify no setup is produced. 4. **Database persistence preservation**: Verify old setups are deleted and new ones inserted identically. ### Unit Tests - Test `_compute_quality_score` with known inputs and verify output matches expected formula - Test that quality score components are properly normalized to 0–1 range - Test that `rr_cap` correctly caps the R:R normalization - Test edge cases: strength=0, strength=100, distance=0, single candidate ### Property-Based Tests - Generate random sets of S/R levels with varying strengths and distances; verify the selected target always has the highest quality score among candidates - Generate random single-candidate scenarios; verify output matches what the original function would produce - Generate random inputs with all candidates below R:R threshold; verify no setup is produced ### Integration Tests - Test full `scan_ticker` flow with mocked DB containing multiple S/R levels of varying quality - Test `scan_all_tickers` still processes each ticker independently - Test that `get_trade_setups` returns correct sorting after fix