major update
This commit is contained in:
209
.kiro/specs/rr-scanner-target-quality/design.md
Normal file
209
.kiro/specs/rr-scanner-target-quality/design.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user