From 19b81c169dcb5e6012195b100e3f73207d6a775a Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 12 Jul 2026 21:15:18 +0200 Subject: [PATCH] Add S/R v2 research and validation harness --- README.md | 4 + app/routers/sr_levels.py | 7 +- app/schemas/sr_level.py | 4 +- app/services/backtest_service.py | 201 ++++++- app/services/indicator_service.py | 61 ++- app/services/recommendation_service.py | 78 ++- app/services/rr_scanner_service.py | 21 + app/services/sr_service.py | 630 +++++++++++++++++++--- docs/research/sr-levels-and-exits.md | 55 +- scripts/compare_sr_variants.py | 125 +++++ scripts/run_backtest_snapshot.py | 32 ++ scripts/run_sr_v2_training_matrix.ps1 | 32 ++ scripts/run_sr_v2_validation.ps1 | 34 ++ tests/conftest.py | 6 +- tests/unit/test_backtest_service.py | 17 + tests/unit/test_cluster_sr_zones.py | 27 + tests/unit/test_detect_sr_levels.py | 244 +++++++++ tests/unit/test_indicator_service.py | 48 ++ tests/unit/test_recommendation_service.py | 66 ++- 19 files changed, 1575 insertions(+), 117 deletions(-) create mode 100644 scripts/compare_sr_variants.py create mode 100644 scripts/run_sr_v2_training_matrix.ps1 create mode 100644 scripts/run_sr_v2_validation.ps1 create mode 100644 tests/unit/test_detect_sr_levels.py diff --git a/README.md b/README.md index 1422c3a..6c38fe2 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,10 @@ Research-only flags, all off by default (the default report is byte-identical to |---|---| | `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books | | `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample | +| `BACKTEST_SR_VARIANT=` | Research-only S/R arm: `production_control`, `rr_aligned_control`, `rewrite`, `soft_zones`, `confirmed_rounds`, or `gate_v2` | +| `BACKTEST_ENTRY_START=YYYY-MM-DD` | Restrict candidate entry dates to a validation window | +| `BACKTEST_ENTRY_END=YYYY-MM-DD` | Restrict candidate entry dates to a training window | +| `BACKTEST_SR_AUDIT=1` | Add momentum-slice candidate rows for paired S/R cohort comparison | | `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison | | `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none | | `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead | diff --git a/app/routers/sr_levels.py b/app/routers/sr_levels.py index a0767da..716649e 100644 --- a/app/routers/sr_levels.py +++ b/app/routers/sr_levels.py @@ -15,7 +15,12 @@ router = APIRouter(tags=["sr-levels"]) @router.get("/sr-levels/{symbol}", response_model=APIEnvelope) async def read_sr_levels( symbol: str, - tolerance: float = Query(0.005, ge=0, le=0.1, description="Merge tolerance (default 0.5%)"), + tolerance: float | None = Query( + None, + ge=0, + le=0.1, + description="Merge tolerance as fraction of price; omit for ATR-adaptive default", + ), max_zones: int = Query(6, ge=0, description="Max S/R zones to return (default 6)"), _user=Depends(require_access), db: AsyncSession = Depends(get_db), diff --git a/app/schemas/sr_level.py b/app/schemas/sr_level.py index 246c5ac..49461fe 100644 --- a/app/schemas/sr_level.py +++ b/app/schemas/sr_level.py @@ -15,7 +15,9 @@ class SRLevelResult(BaseModel): price_level: float type: Literal["support", "resistance"] strength: int = Field(ge=0, le=100) - detection_method: Literal["volume_profile", "pivot_point", "merged"] + detection_method: Literal[ + "volume_profile", "pivot_point", "merged", "round_number" + ] created_at: datetime diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 420f5e7..91883de 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -67,6 +67,7 @@ from app.services.qualification import ( from app.services.recommendation_service import ( _choose_recommended_action, _classify_by_probability, + _gate_eligible_levels, _prune_floor_pinned_targets, _risk_level_from_conflicts, _select_primary_target, @@ -81,7 +82,7 @@ from app.services.scoring_service import ( compute_momentum_from_closes, compute_technical_from_arrays, ) -from app.services.sr_service import detect_sr_levels +from app.services.sr_service import MAX_LEVELS, detect_sr_levels, detect_sr_levels_legacy logger = logging.getLogger(__name__) @@ -120,11 +121,58 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]: price_level=float(d["price_level"]), type=d["type"], strength=int(d["strength"]), + detection_method=d.get("detection_method", "unknown"), + sources=list(d.get("sources") or [d.get("detection_method", "unknown")]), + rejection_count=int(d.get("rejection_count", 0) or 0), + last_rejection_age=d.get("last_rejection_age"), ) for i, d in enumerate(level_dicts) ] +SR_RESEARCH_VARIANTS = { + "production_control", + "rr_aligned_control", + "rewrite", + "soft_zones", + "confirmed_rounds", + "gate_v2", +} + + +def _sr_research_variant() -> str: + """S/R policy arm for local research; never read by the live scanner.""" + value = os.getenv("BACKTEST_SR_VARIANT", "rewrite").strip().lower() + if value not in SR_RESEARCH_VARIANTS: + allowed = ", ".join(sorted(SR_RESEARCH_VARIANTS)) + raise ValueError(f"Unknown BACKTEST_SR_VARIANT={value!r}; expected one of {allowed}") + return value + + +def _backtest_entry_bounds() -> tuple[date | None, date | None]: + """Optional research-only entry bounds used to protect validation data.""" + parsed: list[date | None] = [] + for key in ("BACKTEST_ENTRY_START", "BACKTEST_ENTRY_END"): + raw = os.getenv(key, "").strip() + if not raw: + parsed.append(None) + continue + try: + parsed.append(date.fromisoformat(raw)) + except ValueError as exc: + raise ValueError(f"{key} must be YYYY-MM-DD, got {raw!r}") from exc + start, end = parsed + if start is not None and end is not None and start > end: + raise ValueError("BACKTEST_ENTRY_START must be on or before BACKTEST_ENTRY_END") + return start, end + + +def _sr_audit_enabled() -> bool: + return os.getenv("BACKTEST_SR_AUDIT", "").strip().lower() in { + "1", "true", "yes", "on", + } + + def _atr_target_fallback_k() -> float | None: """Research ablation: k for a synthetic k*ATR target when a direction has no S/R level to aim at. Off (None) by default, which is production behavior — @@ -219,10 +267,28 @@ def _window_setups( if atr <= 0: return [] - sr_levels = _wrap_levels(detect_sr_levels(highs, lows, closes, volumes)) + sr_variant = _sr_research_variant() + if sr_variant in {"production_control", "rr_aligned_control"}: + detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes) + else: + detector_cap = 0 if sr_variant == "gate_v2" else MAX_LEVELS + detected_levels = detect_sr_levels( + highs, lows, closes, volumes, max_levels=detector_cap + ) + sr_levels = _wrap_levels(detected_levels) if not sr_levels: return [] + gate_levels = _gate_eligible_levels( + sr_levels, + confirmed_rounds_only=sr_variant in {"confirmed_rounds", "gate_v2"}, + ) + zone_strength_mode = ( + "soft" + if sr_variant in {"soft_zones", "confirmed_rounds", "gate_v2"} + else "sum" + ) + technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0 momentum = (compute_momentum_from_closes(closes)[0]) or 50.0 dim_scores = {"technical": technical, "momentum": momentum} @@ -237,7 +303,11 @@ def _window_setups( per_dir: dict[str, dict] = {} for direction in ("long", "short"): stop = entry - atr * ATR_MULTIPLIER if direction == "long" else entry + atr * ATR_MULTIPLIER - zone_levels = _zone_representative_levels(sr_levels, entry) + zone_levels = _zone_representative_levels( + gate_levels, + entry, + strength_mode=zone_strength_mode, + ) targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr) if not targets: fallback_k = _atr_target_fallback_k() @@ -254,7 +324,15 @@ def _window_setups( # Collapse duplicate floor-pinned lottery targets (parity with # enhance_trade_setup). targets = _prune_floor_pinned_targets(targets) - primary = _select_primary_target(targets) + primary_min_rr = ( + 1.5 + if sr_variant == "production_control" + else float(activation.get("min_rr", 0.0)) + ) + primary = _select_primary_target( + targets, + min_rr=primary_min_rr, + ) if primary is None: continue # Flag the primary so qualification's EV uses the primary target's @@ -309,6 +387,18 @@ def _window_setups( "meets_core": meets_core, "action": action, "risk_level": risk_level, + "sr_variant": sr_variant, + "primary_sources": list(primary.get("sr_sources") or []), + "primary_strength": float(primary.get("sr_strength", 0.0)), + "primary_rejection_count": int( + primary.get("sr_rejection_count", 0) or 0 + ), + "primary_last_rejection_age": primary.get("sr_last_rejection_age"), + "primary_distance_atr": float( + primary.get("distance_atr_multiple", 0.0) + ), + "raw_level_count": len(sr_levels), + "gate_level_count": len(gate_levels), }) return out @@ -399,7 +489,13 @@ def _replay_ticker( if n < MIN_LOOKBACK + HORIZON: return candidates + entry_start, entry_end = _backtest_entry_bounds() for i in range(MIN_LOOKBACK - 1, n - HORIZON, STEP_DAYS): + as_of = records[i].date + if entry_start is not None and as_of < entry_start: + continue + if entry_end is not None and as_of > entry_end: + continue window = records[: i + 1] forward = records[i + 1 :] forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward] @@ -458,6 +554,14 @@ def _replay_ticker( # every candidate looks NEUTRAL and the ablation rows collapse. "action": s["action"], "risk_level": s["risk_level"], + "sr_variant": s["sr_variant"], + "primary_sources": s["primary_sources"], + "primary_strength": s["primary_strength"], + "primary_rejection_count": s["primary_rejection_count"], + "primary_last_rejection_age": s["primary_last_rejection_age"], + "primary_distance_atr": s["primary_distance_atr"], + "raw_level_count": s["raw_level_count"], + "gate_level_count": s["gate_level_count"], "outcome": outcome, "target_hit": target_hit, "realized_r": realized_r, @@ -518,6 +622,84 @@ def _robustness_stats(net_rs: list[float]) -> dict: } +def _sr_variant_diagnostics(candidates: list[dict]) -> dict: + """Compact evidence audit for the active local S/R research arm.""" + source_counts: dict[str, int] = defaultdict(int) + round_only = 0 + strengths: list[float] = [] + distances: list[float] = [] + rejections: list[int] = [] + raw_counts: list[int] = [] + gate_counts: list[int] = [] + for cand in candidates: + sources = list(cand.get("primary_sources") or []) + for source in sources: + source_counts[str(source)] += 1 + if set(sources) == {"round_number"}: + round_only += 1 + strengths.append(float(cand.get("primary_strength", 0.0))) + distances.append(float(cand.get("primary_distance_atr", 0.0))) + rejections.append(int(cand.get("primary_rejection_count", 0) or 0)) + raw_counts.append(int(cand.get("raw_level_count", 0) or 0)) + gate_counts.append(int(cand.get("gate_level_count", 0) or 0)) + + def avg(values: list[float] | list[int]) -> float | None: + return round(sum(values) / len(values), 3) if values else None + + return { + "variant": _sr_research_variant(), + "candidate_count": len(candidates), + "primary_source_counts": dict(sorted(source_counts.items())), + "primary_round_only": round_only, + "primary_strength_100": sum(1 for value in strengths if value >= 100.0), + "avg_primary_strength": avg(strengths), + "avg_primary_distance_atr": avg(distances), + "avg_primary_rejection_count": avg(rejections), + "avg_raw_level_count": avg(raw_counts), + "avg_gate_level_count": avg(gate_counts), + } + + +def _sr_candidate_audit(candidates: list[dict], min_percentile: float) -> list[dict] | None: + """Candidate-level audit for paired S/R variant comparisons. + + Limit the sidecar population to the long momentum slice that could reach + production qualification. This keeps reports reviewable while retaining + gate failures, additions, removals, and portfolio-relevant near misses. + """ + if not _sr_audit_enabled(): + return None + rows: list[dict] = [] + for cand in candidates: + percentile = cand.get(PRODUCTION_PERCENTILE_KEY) + if cand.get("direction") != "long" or percentile is None: + continue + if float(percentile) < min_percentile: + continue + rows.append({ + "symbol": cand["symbol"], + "date": cand["date"], + "direction": cand["direction"], + "qualified": bool(cand.get("qualified")), + "meets_core": bool(cand.get("meets_core")), + "momentum_percentile": round(float(percentile), 6), + "strategy_rank": round(float(cand.get(RESIDUAL_HIGH_VOL_BLEND_KEY, 0.0) or 0.0), 6), + "rr": round(float(cand.get("rr", 0.0)), 6), + "primary_prob": round(float(cand.get("primary_prob", 0.0)), 6), + "primary_sources": list(cand.get("primary_sources") or []), + "primary_strength": round(float(cand.get("primary_strength", 0.0)), 3), + "primary_rejection_count": int(cand.get("primary_rejection_count", 0) or 0), + "primary_distance_atr": round(float(cand.get("primary_distance_atr", 0.0)), 6), + "raw_level_count": int(cand.get("raw_level_count", 0) or 0), + "gate_level_count": int(cand.get("gate_level_count", 0) or 0), + "outcome": cand.get("outcome"), + "net_r": round(float(cand.get("realized_r", 0.0)) - _cost_r(cand), 6), + "hold30_r": round(float((cand.get("time_r") or {}).get(30, 0.0)), 6), + }) + rows.sort(key=lambda row: (row["date"], row["symbol"], row["direction"])) + return rows + + # The fixed take-profit and trailing-stop sweeps were retired 2026-07: swept # TPs never found an interior optimum (momentum's edge lives in the right tail) # and wide trails converged to the hold-to-horizon exit, so the time-exit sweep @@ -2851,6 +3033,15 @@ async def run_backtest( "horizon_days": HORIZON, "min_lookback": MIN_LOOKBACK, "cost_per_side_pct": round(COST_PER_SIDE * 100, 3), + "sr_variant": _sr_research_variant(), + "entry_start": ( + _backtest_entry_bounds()[0].isoformat() + if _backtest_entry_bounds()[0] is not None else None + ), + "entry_end": ( + _backtest_entry_bounds()[1].isoformat() + if _backtest_entry_bounds()[1] is not None else None + ), }, "activation": activation, "overall_qualified": _bucket_stats(qualified), @@ -2914,6 +3105,8 @@ async def run_backtest( "portfolio_monitor": portfolio_monitor_report, "holdout": holdout_report, "min_rr_sweep": min_rr_sweep_report, + "sr_variant_diagnostics": _sr_variant_diagnostics(candidates), + "sr_candidate_audit": _sr_candidate_audit(candidates, current_min_pct), "signal_eval": _signal_evaluation(collected), "signal_eval_note": ( "Cross-sectional rank-IC of price-only signals vs the forward " diff --git a/app/services/indicator_service.py b/app/services/indicator_service.py index 2e73e66..267e96a 100644 --- a/app/services/indicator_service.py +++ b/app/services/indicator_service.py @@ -256,6 +256,12 @@ def compute_volume_profile( ) -> dict[str, Any]: """Compute Volume Profile: POC, Value Area, HVN, LVN. + Volume is assigned to the bin containing each bar's **close** (no + double-counting across the high–low span). + + HVN = local peaks in the volume histogram (not every bin above mean). + LVN = local valleys in the histogram. + Score: proximity of latest close to POC (closer = higher). """ n = len(closes) @@ -275,14 +281,18 @@ def compute_volume_profile( price_min + (i + 0.5) * bin_width for i in range(num_bins) ] + # Assign each bar's full volume to the close's bin only. for i in range(n): - # Distribute volume across bins the bar spans - bar_low, bar_high = lows[i], highs[i] - for b in range(num_bins): - bl = price_min + b * bin_width - bh = bl + bin_width - if bar_high >= bl and bar_low <= bh: - bins[b] += volumes[i] + c = closes[i] + if c <= price_min: + b = 0 + elif c >= price_max: + b = num_bins - 1 + else: + b = int((c - price_min) / bin_width) + if b >= num_bins: + b = num_bins - 1 + bins[b] += volumes[i] total_vol = sum(bins) if total_vol == 0: @@ -304,10 +314,17 @@ def compute_volume_profile( va_low = round(price_min + min(va_indices) * bin_width, 4) va_high = round(price_min + (max(va_indices) + 1) * bin_width, 4) - # HVN / LVN: bins above/below average volume + # HVN / LVN: local peaks / valleys (require above/below mean to skip noise) avg_vol = total_vol / num_bins - hvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] > avg_vol] - lvn = [round(bin_prices[i], 4) for i in range(num_bins) if bins[i] < avg_vol] + hvn: list[float] = [] + lvn: list[float] = [] + for i in range(num_bins): + left = bins[i - 1] if i > 0 else bins[i] + right = bins[i + 1] if i < num_bins - 1 else bins[i] + if bins[i] > left and bins[i] > right and bins[i] > avg_vol: + hvn.append(round(bin_prices[i], 4)) + elif bins[i] < left and bins[i] < right and bins[i] < avg_vol: + lvn.append(round(bin_prices[i], 4)) # Score: proximity of latest close to POC latest = closes[-1] @@ -333,10 +350,14 @@ def compute_pivot_points( lows: list[float], closes: list[float], window: int = 2, + min_prominence: float | None = None, ) -> dict[str, Any]: """Detect swing highs/lows as pivot points. A swing high at index *i* means highs[i] >= all highs in [i-window, i+window]. + When *min_prominence* is set, only keep swings whose window range + (max high − min low) is at least that amount — filters tiny noise fractals. + Score: based on number of pivots near current price. """ n = len(closes) @@ -349,12 +370,24 @@ def compute_pivot_points( swing_lows: list[float] = [] for i in range(window, n - window): + lo = i - window + hi = i + window + 1 # Swing high - if all(highs[i] >= highs[j] for j in range(i - window, i + window + 1)): - swing_highs.append(round(highs[i], 4)) + if all(highs[i] >= highs[j] for j in range(lo, hi)): + if min_prominence is None or min_prominence <= 0: + swing_highs.append(round(highs[i], 4)) + else: + depth = highs[i] - min(lows[j] for j in range(lo, hi)) + if depth >= min_prominence: + swing_highs.append(round(highs[i], 4)) # Swing low - if all(lows[i] <= lows[j] for j in range(i - window, i + window + 1)): - swing_lows.append(round(lows[i], 4)) + if all(lows[i] <= lows[j] for j in range(lo, hi)): + if min_prominence is None or min_prominence <= 0: + swing_lows.append(round(lows[i], 4)) + else: + depth = max(highs[j] for j in range(lo, hi)) - lows[i] + if depth >= min_prominence: + swing_lows.append(round(lows[i], 4)) all_pivots = swing_highs + swing_lows latest = closes[-1] diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 6099c59..0e89fec 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -56,7 +56,40 @@ def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) -def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> list[Any]: +def _gate_eligible_levels( + sr_levels: list[Any], + *, + confirmed_rounds_only: bool = False, + min_round_rejections: int = 2, +) -> list[Any]: + """Return structures allowed to influence entry qualification. + + Round numbers remain useful visual landmarks, but an untouched standalone + round number is not observed market structure. Research variants can require + either confluence with a pivot/volume source or distinct rejection clusters + before such a level is allowed to manufacture a gate target. + """ + if not confirmed_rounds_only: + return list(sr_levels) + + eligible: list[Any] = [] + for level in sr_levels: + sources = set(getattr(level, "sources", None) or [ + getattr(level, "detection_method", "unknown") + ]) + is_round_only = sources == {"round_number"} + rejections = int(getattr(level, "rejection_count", 0) or 0) + if not is_round_only or rejections >= min_round_rejections: + eligible.append(level) + return eligible + + +def _zone_representative_levels( + sr_levels: list[SRLevel], + entry_price: float, + *, + strength_mode: str = "sum", +) -> list[Any]: """Collapse near-duplicate S/R levels into one representative per zone. Targets are generated from these representatives, so a clustered wall (e.g. @@ -71,11 +104,25 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> if not sr_levels or entry_price <= 0: return list(sr_levels) - level_dicts = [ - {"price_level": float(lv.price_level), "strength": int(lv.strength), "type": lv.type} - for lv in sr_levels - ] - zones = cluster_sr_zones(level_dicts, entry_price, tolerance=_SR_ZONE_TOLERANCE) + level_dicts = [] + for lv in sr_levels: + level_dicts.append({ + "price_level": float(lv.price_level), + "strength": int(lv.strength), + "type": lv.type, + "detection_method": getattr(lv, "detection_method", "unknown"), + "sources": list(getattr(lv, "sources", None) or [ + getattr(lv, "detection_method", "unknown") + ]), + "rejection_count": int(getattr(lv, "rejection_count", 0) or 0), + "last_rejection_age": getattr(lv, "last_rejection_age", None), + }) + zones = cluster_sr_zones( + level_dicts, + entry_price, + tolerance=_SR_ZONE_TOLERANCE, + strength_mode=strength_mode, + ) reps: list[Any] = [] for zone in zones: @@ -94,6 +141,10 @@ def _zone_representative_levels(sr_levels: list[SRLevel], entry_price: float) -> price_level=float(near_edge), type=zone["type"], strength=int(zone["strength"]), + detection_method=getattr(strongest, "detection_method", "unknown"), + sources=list(zone.get("sources") or []), + rejection_count=int(zone.get("rejection_count", 0)), + last_rejection_age=zone.get("last_rejection_age"), ) ) return reps @@ -312,6 +363,15 @@ class TargetGenerator: "classification": "Moderate", "sr_level_id": int(level.id), "sr_strength": float(level.strength), + "sr_sources": list(getattr(level, "sources", None) or [ + getattr(level, "detection_method", "unknown") + ]), + "sr_rejection_count": int( + getattr(level, "rejection_count", 0) or 0 + ), + "sr_last_rejection_age": getattr( + level, "last_rejection_age", None + ), "quality": float(quality), } ) @@ -581,7 +641,6 @@ def build_recommendation_snapshot( } -PRIMARY_TARGET_MIN_RR = 1.5 # Below this the target is a lottery ticket. Shared with the activation gate # (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate # agree on what counts as a probability-backed target. @@ -610,7 +669,7 @@ def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]: def _select_primary_target( targets: list[dict], - min_rr: float = PRIMARY_TARGET_MIN_RR, + min_rr: float, min_probability: float = PRIMARY_TARGET_MIN_PROBABILITY, ) -> dict | None: """Primary = the most LIKELY target that still offers real asymmetry. @@ -651,6 +710,7 @@ async def enhance_trade_setup( sr_levels: list[SRLevel], sentiment_classification: str | None, atr_value: float, + primary_min_rr: float, available_directions: set[str] | None = None, ) -> TradeSetup: config = await get_recommendation_config(db) @@ -698,7 +758,7 @@ async def enhance_trade_setup( # _select_primary_target), not the old quality-score pick that ignored # probability. Sync the setup's headline target/rr_ratio so the chart, gate # and outcome eval all agree with the table's starred row. - primary = _select_primary_target(targets) + primary = _select_primary_target(targets, min_rr=primary_min_rr) if primary is not None: for target in targets: target["is_primary"] = target is primary diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 1884777..ce68d09 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -412,6 +412,7 @@ async def scan_ticker( momentum_percentile: float | None = None, strategy_rank: float | None = None, volatility_percentile: float | None = None, + primary_min_rr: float | None = None, ) -> list[TradeSetup]: """Scan a single ticker for trade setups meeting the R:R threshold. @@ -421,6 +422,14 @@ async def scan_ticker( production ordering score used for top-pick ranking.""" ticker = await _get_ticker(db, symbol) + if primary_min_rr is None: + # Direct single-ticker scans still use the same activation threshold as + # qualification. scan_all_tickers resolves this once for the universe. + from app.services.admin_service import get_activation_config + + activation = await get_activation_config(db) + primary_min_rr = float(activation.get("min_rr", rr_threshold)) + records = await query_ohlcv(db, symbol) if not records or len(records) < 15: logger.info( @@ -558,6 +567,7 @@ async def scan_ticker( sr_levels=sr_levels, sentiment_classification=sentiment_classification, atr_value=atr_value, + primary_min_rr=primary_min_rr, available_directions=available_directions, ) enhanced_setups.append(enhanced) @@ -610,6 +620,16 @@ async def scan_all_tickers( logger.exception("Activation ranking refresh failed") ranks = {} + try: + from app.services.admin_service import get_activation_config + + activation = await get_activation_config(db) + primary_min_rr = float(activation.get("min_rr", rr_threshold)) + except Exception: + await db.rollback() + logger.exception("Activation config load failed; using scanner R:R floor") + primary_min_rr = rr_threshold + all_setups: list[TradeSetup] = [] for index, symbol in enumerate(symbols): if progress_callback is not None: @@ -641,6 +661,7 @@ async def scan_all_tickers( momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"), strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"), volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"), + primary_min_rr=primary_min_rr, ) all_setups.extend(setups) except Exception: diff --git a/app/services/sr_service.py b/app/services/sr_service.py index 733c765..b9a9098 100644 --- a/app/services/sr_service.py +++ b/app/services/sr_service.py @@ -1,12 +1,15 @@ """S/R Detector service. -Detects support/resistance levels from Volume Profile (HVN/LVN) and -Pivot Points (swing highs/lows), assigns strength scores, merges nearby -levels, tags as support/resistance, and persists to DB. +Detects support/resistance levels from Volume Profile (POC/VA/HVN peaks) +and Pivot Points (prominent swing highs/lows), plus light psychological +round numbers. Scores by rejection-weighted recent touches, merges nearby +levels with ATR-adaptive tolerance, tags support/resistance, caps count, +and persists to DB. """ from __future__ import annotations +import math from datetime import datetime from sqlalchemy import delete, select @@ -17,12 +20,43 @@ from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.services.indicator_service import ( _extract_ohlcv, + compute_atr, compute_pivot_points, compute_volume_profile, ) from app.services.price_service import query_ohlcv -DEFAULT_TOLERANCE = 0.005 # 0.5% +# --------------------------------------------------------------------------- +# Tunable constants (keep detection pure / deterministic) +# --------------------------------------------------------------------------- + +DEFAULT_TOLERANCE = 0.005 # fallback when ATR unavailable; also API legacy default + +VP_LOOKBACK = 252 +TOUCH_LOOKBACK = 252 +PIVOT_LOOKBACK = 504 +PIVOT_PROMINENCE_ATR = 0.75 +PIVOT_PROMINENCE_PCT = 0.006 + +MERGE_TOL_ATR_MULT = 0.35 +MERGE_TOL_MIN = 0.004 # 0.4% +MERGE_TOL_MAX = 0.015 # 1.5% + +MAX_LEVELS = 16 +STRENGTH_HALF_LIFE = 60 # bars +# Raw respect score is soft-mapped to 0–100 (see _raw_to_strength). +STRENGTH_SCALE = 8.0 +STRENGTH_SOFT_K = 35.0 # higher → slower approach to 100 + +ROUND_NUMBER_RANGE = 0.15 # ±15% of spot +ROUND_NUMBER_MAX = 8 + +# Base strength seed before touch scoring (method priors) +_METHOD_BASE_STRENGTH = { + "volume_profile": 12, + "pivot_point": 8, + "round_number": 4, +} async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: @@ -35,36 +69,235 @@ async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: return ticker -def _count_price_touches( +def _slice_tail( + highs: list[float], + lows: list[float], + closes: list[float], + volumes: list[int], + lookback: int, +) -> tuple[list[float], list[float], list[float], list[int]]: + """Return the last *lookback* bars (or all if shorter).""" + n = len(closes) + if lookback <= 0 or n <= lookback: + return highs, lows, closes, volumes + start = n - lookback + return highs[start:], lows[start:], closes[start:], volumes[start:] + + +def _atr_pct( + highs: list[float], + lows: list[float], + closes: list[float], +) -> float | None: + """ATR as a fraction of last close, or None if insufficient data.""" + try: + result = compute_atr(highs, lows, closes) + except ValidationError: + return None + atr = result["atr"] + last = closes[-1] + if last == 0: + return None + return atr / last + + +def _merge_tolerance( + highs: list[float], + lows: list[float], + closes: list[float], + tolerance: float | None, +) -> float: + """Resolve merge tolerance: explicit value or ATR-adaptive clamp.""" + if tolerance is not None: + return tolerance + atr_frac = _atr_pct(highs, lows, closes) + if atr_frac is None: + return DEFAULT_TOLERANCE + return max(MERGE_TOL_MIN, min(MERGE_TOL_MAX, MERGE_TOL_ATR_MULT * atr_frac)) + + +def _bar_respect_weight( + price_level: float, + high: float, + low: float, + close: float, + prev_close: float | None, + tolerance: float, +) -> float: + """Weight for how much a bar *respects* a level (not mere occupancy). + + Only bars whose high/low **probes near the level** and closes away from + that extreme count as rejections. Full-range pass-throughs score near zero. + """ + tol = price_level * tolerance if price_level != 0 else tolerance + if tol <= 0: + tol = abs(price_level) * DEFAULT_TOLERANCE if price_level else DEFAULT_TOLERANCE + # Tight probe band: ~0.4% of price (capped), not 2× merge tolerance + band = min(max(abs(price_level) * 0.004, tol * 0.35), abs(price_level) * 0.008) + if band <= 0: + band = abs(price_level) * 0.004 if price_level else 0.01 + + if high + band < price_level or low - band > price_level: + return 0.0 + + bar_range = high - low + # Support test: low probes near level, close recovers above + support_test = abs(low - price_level) <= band and close > price_level + if support_test and bar_range > 0: + support_test = (close - low) >= 0.25 * bar_range + # Resistance test: high probes near level, close rejects below + resist_test = abs(high - price_level) <= band and close < price_level + if resist_test and bar_range > 0: + resist_test = (high - close) >= 0.25 * bar_range + + if support_test or resist_test: + return 1.0 + + # Clear directional pass-through — barely counts + if ( + prev_close is not None + and (prev_close - price_level) * (close - price_level) < 0 + and low < price_level - tol + and high > price_level + tol + ): + return 0.1 + + return 0.0 + + +def _raw_to_strength(raw: float) -> int: + """Map unbounded raw score to 0–100 with soft saturation (no hard pin).""" + if raw <= 0: + return 0 + # 1 - e^(-raw/k): raw=k → ~63, 2k → ~86, 3k → ~95 + return max(0, min(100, int(round(100.0 * (1.0 - math.exp(-raw / STRENGTH_SOFT_K)))))) + + +def _respect_evidence( price_level: float, highs: list[float], lows: list[float], closes: list[float], tolerance: float = DEFAULT_TOLERANCE, -) -> int: - """Count how many bars touched/respected a price level within tolerance.""" - count = 0 - tol = price_level * tolerance if price_level != 0 else tolerance - for i in range(len(closes)): - # A bar "touches" the level if the level is within the bar's range - # (within tolerance) - if lows[i] - tol <= price_level <= highs[i] + tol: - count += 1 - return count + base: int = 0, + half_life: float = STRENGTH_HALF_LIFE, + lookback: int = TOUCH_LOOKBACK, + cooldown: int = 3, +) -> dict[str, float | int | None]: + """Return rejection evidence and its soft-mapped strength. - -def _strength_from_touches(touches: int, total_bars: int) -> int: - """Convert touch count to a 0-100 strength score. - - More touches relative to total bars = higher strength. - Cap at 100. + *cooldown* bars after a full rejection are ignored so multi-day chop at a + level counts as one test cluster, not N identical rejections. """ - if total_bars == 0: - return 0 - # Scale: each touch contributes proportionally, with a multiplier - # so that a level touched ~20% of bars gets score ~100 - raw = (touches / total_bars) * 500.0 - return max(0, min(100, int(round(raw)))) + n = len(closes) + if n == 0: + return { + "strength": _raw_to_strength(float(base)), + "rejection_count": 0, + "last_rejection_age": None, + "weighted_respects": 0.0, + } + + start = max(0, n - lookback) if lookback > 0 else 0 + weighted = 0.0 + rejection_count = 0 + last_rejection_age: int | None = None + next_ok = start + for i in range(start, n): + age = n - 1 - i + decay = 0.5 ** (age / half_life) if half_life > 0 else 1.0 + prev = closes[i - 1] if i > 0 else None + w = _bar_respect_weight( + price_level, highs[i], lows[i], closes[i], prev, tolerance + ) + if w >= 0.9: + if i < next_ok: + continue + weighted += decay * w + rejection_count += 1 + if last_rejection_age is None or age < last_rejection_age: + last_rejection_age = age + next_ok = i + max(cooldown, 1) + elif w > 0: + weighted += decay * w + + raw = float(base) + weighted * STRENGTH_SCALE + return { + "strength": _raw_to_strength(raw), + "rejection_count": rejection_count, + "last_rejection_age": last_rejection_age, + "weighted_respects": round(weighted, 6), + } + + +def _strength_from_respects( + price_level: float, + highs: list[float], + lows: list[float], + closes: list[float], + tolerance: float = DEFAULT_TOLERANCE, + base: int = 0, + half_life: float = STRENGTH_HALF_LIFE, + lookback: int = TOUCH_LOOKBACK, + cooldown: int = 3, +) -> int: + """Compatibility wrapper returning only the evidence-derived strength.""" + return int(_respect_evidence( + price_level, + highs, + lows, + closes, + tolerance, + base, + half_life, + lookback, + cooldown, + )["strength"]) + + +def _round_number_candidates( + current_price: float, + range_pct: float = ROUND_NUMBER_RANGE, + max_count: int = ROUND_NUMBER_MAX, +) -> list[float]: + """Psychological round levels near spot (cheap order-magnet candidates).""" + if current_price <= 0: + return [] + + if current_price < 5: + steps = [0.5, 1.0] + elif current_price < 20: + steps = [1.0, 5.0] + elif current_price < 100: + steps = [5.0, 10.0, 25.0] + elif current_price < 500: + steps = [10.0, 25.0, 50.0, 100.0] + else: + steps = [25.0, 50.0, 100.0, 250.0] + + lo = current_price * (1.0 - range_pct) + hi = current_price * (1.0 + range_pct) + found: set[float] = set() + + for step in steps: + if step <= 0: + continue + # Start at first multiple at or below lo + k = math.floor(lo / step) + while True: + level = round(k * step, 4) + if level > hi + step: + break + if lo <= level <= hi and level > 0: + # Skip levels that are essentially current price + if abs(level - current_price) / current_price > 0.001: + found.add(level) + k += 1 + if k > 1_000_000: # safety + break + + ordered = sorted(found, key=lambda p: abs(p - current_price)) + return ordered[:max_count] def _extract_candidate_levels( @@ -73,55 +306,194 @@ def _extract_candidate_levels( closes: list[float], volumes: list[int], ) -> list[tuple[float, str]]: - """Extract candidate S/R levels from Volume Profile and Pivot Points. + """Extract candidate S/R levels from VP nodes, prominent pivots, rounds. Returns list of (price_level, detection_method) tuples. """ candidates: list[tuple[float, str]] = [] + if not closes: + return candidates - # Volume Profile: HVN and LVN as candidate levels + current_price = closes[-1] + + # --- Volume profile on recent window --- + vp_h, vp_l, vp_c, vp_v = _slice_tail( + highs, lows, closes, volumes, VP_LOOKBACK + ) try: - vp = compute_volume_profile(highs, lows, closes, volumes) + vp = compute_volume_profile(vp_h, vp_l, vp_c, vp_v) + # Structural VP levels: POC, value-area edges, local HVN peaks. + # LVN intentionally omitted (rejection voids ≠ support/resistance lines). + for key in ("poc", "value_area_low", "value_area_high"): + price = vp.get(key) + if price is not None and price > 0: + candidates.append((float(price), "volume_profile")) for price in vp.get("hvn", []): - candidates.append((price, "volume_profile")) - for price in vp.get("lvn", []): - candidates.append((price, "volume_profile")) + candidates.append((float(price), "volume_profile")) except ValidationError: - pass # Not enough data for volume profile + pass + + # --- Prominent pivots on pivot lookback --- + p_h, p_l, p_c, _ = _slice_tail(highs, lows, closes, volumes, PIVOT_LOOKBACK) + atr_frac = _atr_pct(p_h, p_l, p_c) + last = p_c[-1] if p_c else current_price + if atr_frac is not None and last > 0: + prominence = max(PIVOT_PROMINENCE_ATR * atr_frac * last, PIVOT_PROMINENCE_PCT * last) + else: + prominence = PIVOT_PROMINENCE_PCT * last if last > 0 else None - # Pivot Points: swing highs and lows try: - pp = compute_pivot_points(highs, lows, closes) + pp = compute_pivot_points(p_h, p_l, p_c, min_prominence=prominence) for price in pp.get("swing_highs", []): - candidates.append((price, "pivot_point")) + candidates.append((float(price), "pivot_point")) for price in pp.get("swing_lows", []): - candidates.append((price, "pivot_point")) + candidates.append((float(price), "pivot_point")) except ValidationError: - pass # Not enough data for pivot points + pass + + # --- Psychological round numbers near spot --- + for price in _round_number_candidates(current_price): + candidates.append((price, "round_number")) return candidates +def _legacy_volume_profile_nodes( + highs: list[float], + lows: list[float], + closes: list[float], + volumes: list[int], + num_bins: int = 20, +) -> list[float]: + """Reproduce the deployed pre-rewrite HVN/LVN grid for a control arm. + + This intentionally retains the old span-volume double counting. It exists + only so a local research report can prove that its control still reproduces + the frozen production baseline while the live detector is being rewritten. + """ + if len(closes) < 20: + raise ValidationError( + f"Volume Profile requires at least 20 bars, got {len(closes)}" + ) + price_min = min(lows) + price_max = max(highs) + if price_max == price_min: + price_max = price_min + 1.0 + bin_width = (price_max - price_min) / num_bins + bins: list[float] = [0.0] * num_bins + prices = [price_min + (i + 0.5) * bin_width for i in range(num_bins)] + for i in range(len(closes)): + for b in range(num_bins): + low_edge = price_min + b * bin_width + high_edge = low_edge + bin_width + if highs[i] >= low_edge and lows[i] <= high_edge: + bins[b] += volumes[i] + average = sum(bins) / num_bins + hvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] > average] + lvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] < average] + return hvn + lvn + + +def detect_sr_levels_legacy( + highs: list[float], + lows: list[float], + closes: list[float], + volumes: list[int], + tolerance: float = DEFAULT_TOLERANCE, +) -> list[dict]: + """Exact research control for the deployed pre-rewrite detector.""" + if not closes: + return [] + + candidates: list[tuple[float, str]] = [] + try: + for price in _legacy_volume_profile_nodes(highs, lows, closes, volumes): + candidates.append((float(price), "volume_profile")) + except ValidationError: + pass + try: + pivots = compute_pivot_points(highs, lows, closes) + candidates.extend( + (float(price), "pivot_point") + for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", []) + ) + except ValidationError: + pass + if not candidates: + return [] + + total_bars = len(closes) + raw: list[dict] = [] + for price, method in candidates: + tol = price * tolerance if price != 0 else tolerance + touches = sum( + 1 for low, high in zip(lows, highs, strict=False) + if low - tol <= price <= high + tol + ) + strength = max(0, min(100, int(round((touches / total_bars) * 500.0)))) + raw.append({ + "price_level": price, + "strength": strength, + "detection_method": method, + "type": "", + "sources": [method], + "rejection_count": touches, + "last_rejection_age": None, + "weighted_respects": float(touches), + }) + + merged: list[dict] = [] + for level in sorted(raw, key=lambda row: row["price_level"]): + if not merged: + merged.append(dict(level)) + continue + last = merged[-1] + ref = last["price_level"] + tol = ref * tolerance if ref != 0 else tolerance + if abs(level["price_level"] - ref) > tol: + merged.append(dict(level)) + continue + last["price_level"] = round( + (last["price_level"] + level["price_level"]) / 2.0, 4 + ) + last["strength"] = min(100, last["strength"] + level["strength"]) + sources = set(last.get("sources") or [last["detection_method"]]) + sources |= set(level.get("sources") or [level["detection_method"]]) + last["sources"] = sorted(sources) + last["detection_method"] = ( + next(iter(sources)) if len(sources) == 1 else "merged" + ) + last["rejection_count"] = max( + int(last.get("rejection_count", 0)), + int(level.get("rejection_count", 0)), + ) + + _tag_levels(merged, closes[-1]) + merged.sort(key=lambda row: row["strength"], reverse=True) + return merged + + def _merge_levels( levels: list[dict], tolerance: float = DEFAULT_TOLERANCE, ) -> list[dict]: """Merge levels within tolerance into consolidated levels. - Levels from different methods within tolerance are merged. - Merged levels combine strength scores (capped at 100) and get - detection_method = "merged". + Strength combines via max + partial min (avoids instant saturation) with + a confluence bonus when detection methods differ. Price is strength-weighted. """ if not levels: return [] - # Sort by price sorted_levels = sorted(levels, key=lambda x: x["price_level"]) merged: list[dict] = [] for level in sorted_levels: if not merged: - merged.append(dict(level)) + entry = dict(level) + sources = level.get("sources") or [level["detection_method"]] + entry["sources"] = sorted(set(sources)) + merged.append(entry) continue last = merged[-1] @@ -129,19 +501,52 @@ def _merge_levels( tol = ref_price * tolerance if ref_price != 0 else tolerance if abs(level["price_level"] - ref_price) <= tol: - # Merge: average price, combine strength, mark as merged - combined_strength = min(100, last["strength"] + level["strength"]) - avg_price = (last["price_level"] + level["price_level"]) / 2.0 - method = ( - "merged" - if last["detection_method"] != level["detection_method"] - else last["detection_method"] - ) + s1 = last["strength"] + s2 = level["strength"] + # Soft combine — avoid merge math pinning everything at 100 + combined = int(round(0.85 * max(s1, s2) + 0.15 * min(s1, s2))) + sources = set(last.get("sources") or [last["detection_method"]]) + sources |= set(level.get("sources") or [level["detection_method"]]) + if len(sources) > 1: + combined = min(100, combined + 5) + else: + combined = min(100, combined) + + w1, w2 = max(s1, 1), max(s2, 1) + avg_price = (last["price_level"] * w1 + level["price_level"] * w2) / (w1 + w2) + + if len(sources) == 1: + method = next(iter(sources)) + else: + method = "merged" + last["price_level"] = round(avg_price, 4) - last["strength"] = combined_strength + last["strength"] = combined last["detection_method"] = method + last["sources"] = sorted(sources) + # Nearby candidates often describe the same price reaction, so do + # not add their rejection counts and double-count one market event. + last["rejection_count"] = max( + int(last.get("rejection_count", 0)), + int(level.get("rejection_count", 0)), + ) + ages = [ + age for age in ( + last.get("last_rejection_age"), + level.get("last_rejection_age"), + ) + if age is not None + ] + last["last_rejection_age"] = min(ages) if ages else None + last["weighted_respects"] = max( + float(last.get("weighted_respects", 0.0)), + float(level.get("weighted_respects", 0.0)), + ) else: - merged.append(dict(level)) + entry = dict(level) + sources = level.get("sources") or [level["detection_method"]] + entry["sources"] = sorted(set(sources)) + merged.append(entry) return merged @@ -159,14 +564,66 @@ def _tag_levels( return levels +def _cap_levels( + levels: list[dict], + max_levels: int = MAX_LEVELS, +) -> list[dict]: + """Keep up to *max_levels* levels, interleaving support/resistance by strength.""" + if max_levels <= 0 or len(levels) <= max_levels: + return levels + + support = sorted( + [lvl for lvl in levels if lvl.get("type") == "support"], + key=lambda x: x["strength"], + reverse=True, + ) + resistance = sorted( + [lvl for lvl in levels if lvl.get("type") != "support"], + key=lambda x: x["strength"], + reverse=True, + ) + + selected: list[dict] = [] + si, ri = 0, 0 + pick_support = True + while len(selected) < max_levels and (si < len(support) or ri < len(resistance)): + if pick_support: + if si < len(support): + selected.append(support[si]) + si += 1 + elif ri < len(resistance): + selected.append(resistance[ri]) + ri += 1 + else: + if ri < len(resistance): + selected.append(resistance[ri]) + ri += 1 + elif si < len(support): + selected.append(support[si]) + si += 1 + pick_support = not pick_support + + selected.sort(key=lambda x: x["strength"], reverse=True) + return selected + + def detect_sr_levels( highs: list[float], lows: list[float], closes: list[float], volumes: list[int], - tolerance: float = DEFAULT_TOLERANCE, + tolerance: float | None = None, + max_levels: int = MAX_LEVELS, ) -> list[dict]: - """Detect, score, merge, and tag S/R levels from OHLCV data. + """Detect, score, merge, tag, and cap S/R levels from OHLCV data. + + Parameters + ---------- + tolerance: + Relative merge tolerance. ``None`` (default) uses ATR-adaptive + tolerance clamped to [0.4%, 1.5%]. Pass an explicit fraction to override. + max_levels: + Hard cap after merge (balanced support/resistance). 0 = no cap. Returns list of dicts with keys: price_level, type, strength, detection_method — sorted by strength descending. @@ -178,37 +635,42 @@ def detect_sr_levels( if not candidates: return [] - total_bars = len(closes) current_price = closes[-1] + merge_tol = _merge_tolerance(highs, lows, closes, tolerance) + # Touch tolerance for strength: use merge tol (same price scale) + touch_tol = merge_tol - # Build level dicts with strength scores + # Score each candidate on recent rejection-weighted touches raw_levels: list[dict] = [] for price, method in candidates: - touches = _count_price_touches(price, highs, lows, closes, tolerance) - strength = _strength_from_touches(touches, total_bars) + base = _METHOD_BASE_STRENGTH.get(method, 0) + evidence = _respect_evidence( + price, highs, lows, closes, touch_tol, base=base + ) raw_levels.append({ "price_level": price, - "strength": strength, + "strength": int(evidence["strength"]), "detection_method": method, - "type": "", # will be tagged after merge + "type": "", + "sources": [method], + "rejection_count": int(evidence["rejection_count"]), + "last_rejection_age": evidence["last_rejection_age"], + "weighted_respects": float(evidence["weighted_respects"]), }) - # Merge nearby levels - merged = _merge_levels(raw_levels, tolerance) - - # Tag as support/resistance + merged = _merge_levels(raw_levels, merge_tol) tagged = _tag_levels(merged, current_price) + capped = _cap_levels(tagged, max_levels=max_levels) + capped.sort(key=lambda x: x["strength"], reverse=True) + return capped - # Sort by strength descending - tagged.sort(key=lambda x: x["strength"], reverse=True) - - return tagged def cluster_sr_zones( levels: list[dict], current_price: float, tolerance: float = 0.02, max_zones: int | None = None, + strength_mode: str = "sum", ) -> list[dict]: """Cluster nearby S/R levels into zones. @@ -263,8 +725,26 @@ def cluster_sr_zones( low = min(prices) high = max(prices) midpoint = (low + high) / 2.0 - strength = min(100, sum(lvl["strength"] for lvl in cluster)) + if strength_mode == "soft": + strongest = max(int(lvl["strength"]) for lvl in cluster) + all_sources = { + source + for lvl in cluster + for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")]) + } + strength = min(100, strongest + (5 if len(all_sources) > 1 else 0)) + elif strength_mode == "sum": + strength = min(100, sum(int(lvl["strength"]) for lvl in cluster)) + all_sources = { + source + for lvl in cluster + for source in (lvl.get("sources") or [lvl.get("detection_method", "unknown")]) + } + else: + raise ValueError(f"Unsupported S/R zone strength mode: {strength_mode}") level_count = len(cluster) + rejection_count = max(int(lvl.get("rejection_count", 0)) for lvl in cluster) + ages = [lvl.get("last_rejection_age") for lvl in cluster if lvl.get("last_rejection_age") is not None] # 4. Tag zone type zone_type = "support" if midpoint < current_price else "resistance" @@ -276,6 +756,9 @@ def cluster_sr_zones( "strength": strength, "type": zone_type, "level_count": level_count, + "sources": sorted(all_sources), + "rejection_count": rejection_count, + "last_rejection_age": min(ages) if ages else None, }) # 5. Split into support and resistance pools, each sorted by strength desc @@ -319,11 +802,10 @@ def cluster_sr_zones( return selected - async def recalculate_sr_levels( db: AsyncSession, symbol: str, - tolerance: float = DEFAULT_TOLERANCE, + tolerance: float | None = None, ) -> list[SRLevel]: """Recalculate S/R levels for a ticker and persist to DB. @@ -380,7 +862,7 @@ async def recalculate_sr_levels( async def get_sr_levels( db: AsyncSession, symbol: str, - tolerance: float = DEFAULT_TOLERANCE, + tolerance: float | None = None, ) -> list[SRLevel]: """Get S/R levels for a ticker, recalculating on every request (MVP). diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index 9506976..5dd3f01 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -13,14 +13,24 @@ net-positive is the open question, tracked below. ## 1. How the levels are built today -`app/services/sr_service.py::detect_sr_levels`, over **all stored history** -(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker): +> **Update (2026-07-12 detector rewrite):** several gaps below were addressed in +> `sr_service` / `indicator_service` — close-bin VP, local-peak HVN, POC/VAH/VAL +> as candidates, LVN dropped from S/R, pivot prominence + lookbacks, rejection- +> weighted recency strength, ATR-adaptive merge, hard cap, round numbers. The +> table documents the *pre-rewrite* failure modes measured on the snapshot; keep +> it for historical context. Re-measure density on prod after deploy if gate +> rates shift. -1. Candidates = volume-profile **HVN and LVN** bins + **pivot** swing highs/lows. -2. Strength = share of bars that "touched" the level, scaled so ~20% of bars → 100. -3. Nearby levels merged within 0.5%; tagged `support` if below spot, else `resistance`. +`app/services/sr_service.py::detect_sr_levels` (post-rewrite): -### Where that departs from best practice +1. Candidates = VP **POC / VAH / VAL / local HVN peaks** (lookback 252) + + **prominent** swing pivots (lookback 504) + nearby **round numbers**. +2. Strength = rejection-weighted touches on last 252 bars with recency decay + (pass-throughs down-weighted); method base + confluence on merge. +3. Nearby levels merged with **ATR-adaptive** tolerance (clamped ~0.4–1.5%); + capped (~16, interleaved S/R); tagged `support` if below spot, else `resistance`. + +### Where the pre-rewrite detector departed from best practice Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63): @@ -35,8 +45,8 @@ Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63): | **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented | Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it -lacks is a **prominence filter** — AAPL yields 338 pivots over 1261 bars, one every -~3.7 bars. +lacked pre-rewrite is a **prominence filter** — AAPL yielded 338 pivots over 1261 +bars, one every ~3.7 bars. ### The structural problem: resistance famine @@ -383,6 +393,35 @@ Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/ large, consistent across five nested windows — and still didn't survive a holdout. Nested lookbacks are not out-of-sample. Split by entry date before believing anything. +## 7. S/R v2 research harness (implementation started 2026-07-12) + +The detector rewrite is decomposed into causal, research-only arms. The live +scanner does not read `BACKTEST_SR_VARIANT`; these switches exist only in the +offline snapshot harness: + +| arm | behavior | +|---|---| +| `production_control` | deployed detector plus legacy 1.5 primary selection | +| `rr_aligned_control` | deployed detector; primary selection uses activation `min_rr` | +| `rewrite` | rewritten detector with activation-aligned primary selection | +| `soft_zones` | rewrite plus max-strength/confluence zone aggregation | +| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters | +| `gate_v2` | confirmed rounds plus uncapped gate evidence | + +Detector evidence (`sources`, rejection count, last rejection age) stays in the +pure backtest objects. It is deliberately not migrated into the production DB +schema until a variant passes validation. + +Run the training matrix with `--entry-end 2024-06-30 --sr-audit`, choose one arm, +and record that lock before running exactly control and the locked arm with +`--entry-start 2024-07-01`. Use `scripts/compare_sr_variants.py` to produce the +paired cohort CSV and summary JSON. + +The post-2024 interval has informed earlier research, so this is validation rather +than a pristine holdout; do not sweep variants on it. No deployment follows +automatically. A lower validation Sharpe or higher drawdown remains a no-ship +result even when CAGR rises. + **Next runs, if picked back up:** - A **per-name target model** for clear-air setups instead of a constant k×ATR. This diff --git a/scripts/compare_sr_variants.py b/scripts/compare_sr_variants.py new file mode 100644 index 0000000..9911f83 --- /dev/null +++ b/scripts/compare_sr_variants.py @@ -0,0 +1,125 @@ +"""Compare two audited local S/R backtest reports by setup identity. + +Reports must be generated with ``--sr-audit``. The comparison is read-only +apart from its explicit CSV/JSON outputs under the caller-selected paths. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +from pathlib import Path + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("control") + parser.add_argument("variant") + parser.add_argument("--out-csv", required=True) + parser.add_argument("--out-json", required=True) + return parser.parse_args() + + +def _load(path: str) -> dict: + with Path(path).open(encoding="utf-8") as handle: + report = json.load(handle) + if report.get("sr_candidate_audit") is None: + raise SystemExit(f"Report lacks sr_candidate_audit; rerun with --sr-audit: {path}") + return report + + +def _key(row: dict) -> tuple[str, str, str]: + return row["symbol"], row["date"], row["direction"] + + +def _cohort_stats(rows: list[dict]) -> dict: + net = [float(row.get("net_r", 0.0)) for row in rows] + hold = [float(row.get("hold30_r", 0.0)) for row in rows] + trimmed = sorted(net, reverse=True)[math.ceil(len(net) * 0.05):] + return { + "count": len(rows), + "net_avg_r": round(sum(net) / len(net), 4) if net else None, + "net_avg_r_ex_top5": round(sum(trimmed) / len(trimmed), 4) if trimmed else None, + "hold30_avg_r": round(sum(hold) / len(hold), 4) if hold else None, + } + + +def _production_book(report: dict) -> dict | None: + runs = ((report.get("portfolio_monitor") or {}).get("runs") or []) + row = next( + ( + run for run in runs + if run.get("is_production") and run.get("lookback") == "all" + ), + None, + ) + if row is None: + return None + return { + key: row.get(key) + for key in ("sharpe", "cagr_pct", "max_drawdown_pct", "trades", "skipped_book_full") + } + + +def main() -> None: + args = _args() + control = _load(args.control) + variant = _load(args.variant) + control_rows = {_key(row): row for row in control["sr_candidate_audit"]} + variant_rows = {_key(row): row for row in variant["sr_candidate_audit"]} + control_q = {key for key, row in control_rows.items() if row.get("qualified")} + variant_q = {key for key, row in variant_rows.items() if row.get("qualified")} + + retained = control_q & variant_q + added = variant_q - control_q + removed = control_q - variant_q + union = sorted(control_q | variant_q, key=lambda key: (key[1], key[0], key[2])) + + csv_path = Path(args.out_csv) + csv_path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "symbol", "date", "direction", "cohort", + "control_rr", "variant_rr", "control_prob", "variant_prob", + "control_sources", "variant_sources", "control_net_r", "variant_net_r", + "control_hold30_r", "variant_hold30_r", + ] + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + for key in union: + c = control_rows.get(key) or {} + v = variant_rows.get(key) or {} + cohort = "retained" if key in retained else "added" if key in added else "removed" + writer.writerow({ + "symbol": key[0], "date": key[1], "direction": key[2], "cohort": cohort, + "control_rr": c.get("rr"), "variant_rr": v.get("rr"), + "control_prob": c.get("primary_prob"), "variant_prob": v.get("primary_prob"), + "control_sources": "+".join(c.get("primary_sources") or []), + "variant_sources": "+".join(v.get("primary_sources") or []), + "control_net_r": c.get("net_r"), "variant_net_r": v.get("net_r"), + "control_hold30_r": c.get("hold30_r"), "variant_hold30_r": v.get("hold30_r"), + }) + + summary = { + "control_report": str(Path(args.control)), + "variant_report": str(Path(args.variant)), + "control_variant": (control.get("params") or {}).get("sr_variant"), + "variant": (variant.get("params") or {}).get("sr_variant"), + "retained": _cohort_stats([variant_rows[key] for key in retained]), + "added": _cohort_stats([variant_rows[key] for key in added]), + "removed": _cohort_stats([control_rows[key] for key in removed]), + "control_book": _production_book(control), + "variant_book": _production_book(variant), + } + json_path = Path(args.out_json) + json_path.parent.mkdir(parents=True, exist_ok=True) + with json_path.open("w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=2) + handle.write("\n") + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_backtest_snapshot.py b/scripts/run_backtest_snapshot.py index d084ef9..859d280 100644 --- a/scripts/run_backtest_snapshot.py +++ b/scripts/run_backtest_snapshot.py @@ -46,6 +46,30 @@ def _parse_args() -> argparse.Namespace: help="Allow spawn multiprocessing for offline CLI runs, useful on Windows.", ) parser.add_argument("--quiet", action="store_true", help="Hide progress output.") + parser.add_argument( + "--sr-variant", + choices=( + "production_control", "rr_aligned_control", "rewrite", + "soft_zones", "confirmed_rounds", "gate_v2", + ), + default=None, + help="Research-only S/R detector/gate arm.", + ) + parser.add_argument( + "--entry-start", + default=None, + help="Include entries on/after YYYY-MM-DD.", + ) + parser.add_argument( + "--entry-end", + default=None, + help="Include entries on/before YYYY-MM-DD.", + ) + parser.add_argument( + "--sr-audit", + action="store_true", + help="Include candidate-level S/R audit rows for paired comparison.", + ) return parser.parse_args() @@ -138,6 +162,14 @@ async def _main() -> None: os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1" if args.allow_spawn: os.environ["BACKTEST_ALLOW_SPAWN"] = "1" + if args.sr_variant: + os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant + if args.entry_start: + os.environ["BACKTEST_ENTRY_START"] = args.entry_start + if args.entry_end: + os.environ["BACKTEST_ENTRY_END"] = args.entry_end + if args.sr_audit: + os.environ["BACKTEST_SR_AUDIT"] = "1" from app.config import settings from app.services.backtest_service import run_backtest diff --git a/scripts/run_sr_v2_training_matrix.ps1 b/scripts/run_sr_v2_training_matrix.ps1 new file mode 100644 index 0000000..066c169 --- /dev/null +++ b/scripts/run_sr_v2_training_matrix.ps1 @@ -0,0 +1,32 @@ +param( + [string]$Snapshot = "backtest_snapshots\prod.sqlite", + [string]$Python = ".venv\Scripts\python.exe", + [int]$Workers = 7 +) + +$ErrorActionPreference = "Stop" +$arms = @( + "production_control", + "rr_aligned_control", + "rewrite", + "soft_zones", + "confirmed_rounds", + "gate_v2" +) + +foreach ($arm in $arms) { + $output = "reports\backtest-sr-v2-train-$arm.json" + Write-Host "Running S/R training arm: $arm" + & $Python scripts\run_backtest_snapshot.py $Snapshot ` + --workers $Workers ` + --allow-spawn ` + --sr-variant $arm ` + --entry-end 2024-06-30 ` + --sr-audit ` + --out $output + if ($LASTEXITCODE -ne 0) { + throw "S/R training arm failed: $arm" + } +} + +Write-Host "Training matrix complete. Lock one arm before running validation." diff --git a/scripts/run_sr_v2_validation.ps1 b/scripts/run_sr_v2_validation.ps1 new file mode 100644 index 0000000..0c49b74 --- /dev/null +++ b/scripts/run_sr_v2_validation.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("rr_aligned_control", "rewrite", "soft_zones", "confirmed_rounds", "gate_v2")] + [string]$LockedArm, + [string]$Snapshot = "backtest_snapshots\prod.sqlite", + [string]$Python = ".venv\Scripts\python.exe", + [int]$Workers = 7 +) + +$ErrorActionPreference = "Stop" +$arms = @("production_control", $LockedArm) +foreach ($arm in $arms) { + $output = "reports\backtest-sr-v2-validation-$arm.json" + Write-Host "Running locked S/R validation arm: $arm" + & $Python scripts\run_backtest_snapshot.py $Snapshot ` + --workers $Workers ` + --allow-spawn ` + --sr-variant $arm ` + --entry-start 2024-07-01 ` + --sr-audit ` + --out $output + if ($LASTEXITCODE -ne 0) { + throw "S/R validation arm failed: $arm" + } +} + +& $Python scripts\compare_sr_variants.py ` + reports\backtest-sr-v2-validation-production_control.json ` + "reports\backtest-sr-v2-validation-$LockedArm.json" ` + --out-csv reports\sr-v2-validation-cohorts.csv ` + --out-json reports\sr-v2-validation-comparison.json +if ($LASTEXITCODE -ne 0) { + throw "S/R validation comparison failed" +} diff --git a/tests/conftest.py b/tests/conftest.py index a4307f0..90be116 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -213,7 +213,11 @@ def sr_levels(draw: st.DrawFn) -> dict[str, Any]: "price_level": draw(st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)), "type": draw(st.sampled_from(["support", "resistance"])), "strength": draw(st.integers(min_value=0, max_value=100)), - "detection_method": draw(st.sampled_from(["volume_profile", "pivot_point", "merged"])), + "detection_method": draw( + st.sampled_from( + ["volume_profile", "pivot_point", "merged", "round_number"] + ) + ), } diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index abde56f..b2dfd36 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -730,6 +730,23 @@ def test_window_setups_too_short_returns_empty(): assert bt._window_setups([], {}, {}) == [] +def test_sr_research_variant_is_explicit_and_validated(monkeypatch): + monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control") + assert bt._sr_research_variant() == "production_control" + monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant") + with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"): + bt._sr_research_variant() + + +def test_backtest_entry_bounds_validate_dates(monkeypatch): + monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01") + monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31") + assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31)) + monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01") + with pytest.raises(ValueError, match="on or before"): + bt._backtest_entry_bounds() + + def test_replay_ticker_candidates_carry_gate_fields(): """The ablation recomputes floors from candidate fields — a candidate missing action/risk_level silently zeroes the ablation rows (July 2026 regression).""" diff --git a/tests/unit/test_cluster_sr_zones.py b/tests/unit/test_cluster_sr_zones.py index c8e6f21..8eafd1a 100644 --- a/tests/unit/test_cluster_sr_zones.py +++ b/tests/unit/test_cluster_sr_zones.py @@ -85,6 +85,33 @@ class TestClusterSrZonesStrength: zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02) assert zones[0]["strength"] == 30 + def test_soft_strength_uses_max_plus_confluence(self): + levels = [ + { + "price_level": 100.0, + "strength": 60, + "detection_method": "pivot_point", + "sources": ["pivot_point"], + "rejection_count": 3, + }, + { + "price_level": 100.5, + "strength": 60, + "detection_method": "round_number", + "sources": ["round_number"], + "rejection_count": 1, + }, + ] + zones = cluster_sr_zones( + levels, + current_price=200.0, + tolerance=0.02, + strength_mode="soft", + ) + assert zones[0]["strength"] == 65 + assert set(zones[0]["sources"]) == {"pivot_point", "round_number"} + assert zones[0]["rejection_count"] == 3 + class TestClusterSrZonesTypeTagging: """Support vs resistance tagging.""" diff --git a/tests/unit/test_detect_sr_levels.py b/tests/unit/test_detect_sr_levels.py new file mode 100644 index 0000000..cbc65c4 --- /dev/null +++ b/tests/unit/test_detect_sr_levels.py @@ -0,0 +1,244 @@ +"""Unit tests for detect_sr_levels and related pure helpers.""" + +from __future__ import annotations + +from app.services.sr_service import ( + MAX_LEVELS, + _bar_respect_weight, + _cap_levels, + _merge_levels, + _round_number_candidates, + _strength_from_respects, + detect_sr_levels, + detect_sr_levels_legacy, +) + + +def _make_series( + n: int = 300, + *, + base: float = 100.0, + support: float = 95.0, + resistance: float = 110.0, +) -> tuple[list[float], list[float], list[float], list[int]]: + """Synthetic OHLCV that repeatedly tests support/resistance.""" + highs: list[float] = [] + lows: list[float] = [] + closes: list[float] = [] + volumes: list[int] = [] + + price = base + for i in range(n): + phase = i % 40 + if phase < 15: + # Drift down toward support, bounce + target = support + price = price + (target - price) * 0.25 + low = min(price, support) - 0.3 + high = price + 1.0 + close = max(price, support + 0.5) if phase > 12 else price + elif phase < 30: + # Drift up toward resistance, reject + target = resistance + price = price + (target - price) * 0.25 + high = max(price, resistance) + 0.3 + low = price - 1.0 + close = min(price, resistance - 0.5) if phase > 27 else price + else: + price = base + (i % 7) * 0.2 + high = price + 1.0 + low = price - 1.0 + close = price + + # Occasional clear swing extremes + if i % 55 == 25: + high = resistance + 1.0 + close = resistance - 1.0 + low = close - 1.0 + if i % 55 == 50: + low = support - 1.0 + close = support + 1.0 + high = close + 1.0 + + highs.append(high) + lows.append(low) + closes.append(close) + volumes.append(1000 + (i % 10) * 50) + price = close + + return highs, lows, closes, volumes + + +class TestBarRespectWeight: + def test_no_interaction(self): + assert _bar_respect_weight(100.0, 90.0, 85.0, 88.0, 87.0, 0.005) == 0.0 + + def test_support_rejection(self): + # Low probes at 100, closes above with recovery wick + w = _bar_respect_weight(100.0, 103.0, 99.8, 102.0, 101.0, 0.005) + assert w >= 0.9 + + def test_resistance_rejection(self): + # High probes at 100, closes below + w = _bar_respect_weight(100.0, 100.2, 97.0, 98.0, 99.0, 0.005) + assert w >= 0.9 + + def test_pass_through_lower_weight(self): + # Prev below, close above, bar spans through without probing extremes at level + w = _bar_respect_weight(100.0, 105.0, 95.0, 104.0, 96.0, 0.005) + assert w < 0.5 + + +class TestStrengthFromRespects: + def test_pass_through_not_maximal(self): + """Central pass-through levels should not pin at strength 100.""" + n = 200 + # Trending series that passes through 100 many times + closes = [80.0 + i * 0.25 for i in range(n)] + highs = [c + 1.0 for c in closes] + lows = [c - 1.0 for c in closes] + strength = _strength_from_respects(100.0, highs, lows, closes, 0.005) + assert strength < 100 + + def test_repeated_rejection_stronger_than_no_touch(self): + n = 120 + level = 100.0 + # Bars that repeatedly probe support (low near level) and close above + highs = [103.0] * n + lows = [99.8] * n + closes = [102.0] * n + strong = _strength_from_respects(level, highs, lows, closes, 0.01, base=10) + + far_highs = [120.0] * n + far_lows = [118.0] * n + far_closes = [119.0] * n + weak = _strength_from_respects(level, far_highs, far_lows, far_closes, 0.01, base=10) + assert strong > weak + + +class TestRoundNumbers: + def test_near_spot(self): + levels = _round_number_candidates(103.0) + assert levels + assert all(abs(p - 103.0) / 103.0 <= 0.15 + 1e-9 for p in levels) + assert len(levels) <= 8 + + def test_non_positive_price(self): + assert _round_number_candidates(0.0) == [] + assert _round_number_candidates(-5.0) == [] + + +class TestCapLevels: + def test_interleaves_sides(self): + levels = [ + {"price_level": 90.0, "type": "support", "strength": 80, "detection_method": "x"}, + {"price_level": 91.0, "type": "support", "strength": 70, "detection_method": "x"}, + {"price_level": 92.0, "type": "support", "strength": 60, "detection_method": "x"}, + {"price_level": 110.0, "type": "resistance", "strength": 50, "detection_method": "x"}, + {"price_level": 111.0, "type": "resistance", "strength": 40, "detection_method": "x"}, + ] + capped = _cap_levels(levels, max_levels=4) + assert len(capped) == 4 + types = {lvl["type"] for lvl in capped} + assert "support" in types + assert "resistance" in types + + +class TestLevelEvidence: + def test_merge_preserves_sources_and_rejection_evidence(self): + levels = [ + { + "price_level": 100.0, + "type": "", + "strength": 55, + "detection_method": "pivot_point", + "sources": ["pivot_point"], + "rejection_count": 3, + "last_rejection_age": 12, + "weighted_respects": 1.5, + }, + { + "price_level": 100.3, + "type": "", + "strength": 40, + "detection_method": "round_number", + "sources": ["round_number"], + "rejection_count": 1, + "last_rejection_age": 4, + "weighted_respects": 0.5, + }, + ] + merged = _merge_levels(levels, tolerance=0.005) + assert len(merged) == 1 + assert set(merged[0]["sources"]) == {"pivot_point", "round_number"} + assert merged[0]["rejection_count"] == 3 + assert merged[0]["last_rejection_age"] == 4 + + +class TestDetectSrLevels: + def test_returns_capped_tagged_levels(self): + highs, lows, closes, volumes = _make_series() + levels = detect_sr_levels(highs, lows, closes, volumes) + assert levels + assert len(levels) <= MAX_LEVELS + for lvl in levels: + assert lvl["type"] in ("support", "resistance") + assert 0 <= lvl["strength"] <= 100 + assert lvl["detection_method"] in ( + "volume_profile", + "pivot_point", + "merged", + "round_number", + ) + assert lvl["price_level"] > 0 + assert lvl["sources"] + assert lvl["rejection_count"] >= 0 + # Sorted by strength desc + strengths = [lvl["strength"] for lvl in levels] + assert strengths == sorted(strengths, reverse=True) + + def test_far_fewer_than_old_grid(self): + """Should not produce a near-1%-spacing grid of ~70 levels.""" + highs, lows, closes, volumes = _make_series(n=500) + levels = detect_sr_levels(highs, lows, closes, volumes) + assert len(levels) <= MAX_LEVELS + + def test_empty_input(self): + assert detect_sr_levels([], [], [], []) == [] + + def test_explicit_tolerance(self): + highs, lows, closes, volumes = _make_series() + tight = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.001) + wide = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.05) + # Wider merge should not produce more levels + assert len(wide) <= len(tight) + 2 # allow small jitter from scoring + + def test_levels_near_structural_areas(self): + """At least some levels should land near the synthetic S/R band.""" + highs, lows, closes, volumes = _make_series( + n=400, support=95.0, resistance=110.0 + ) + levels = detect_sr_levels(highs, lows, closes, volumes) + prices = [lvl["price_level"] for lvl in levels] + near_support = any(abs(p - 95.0) / 95.0 < 0.05 for p in prices) + near_resist = any(abs(p - 110.0) / 110.0 < 0.05 for p in prices) + # Round numbers / VP may dominate; require at least one structural band hit + assert near_support or near_resist or any( + abs(p - 100.0) / 100.0 < 0.08 for p in prices + ) + + def test_strength_not_all_pinned_at_100(self): + highs, lows, closes, volumes = _make_series(n=400) + levels = detect_sr_levels(highs, lows, closes, volumes) + if len(levels) >= 3: + pinned = sum(1 for lvl in levels if lvl["strength"] == 100) + assert pinned < len(levels) + + def test_legacy_control_retains_old_uncapped_grid(self): + highs, lows, closes, volumes = _make_series(n=500) + levels = detect_sr_levels_legacy(highs, lows, closes, volumes) + assert levels + assert all(level["sources"] for level in levels) + # The research control intentionally keeps the deployed detector's much + # denser output instead of borrowing the rewrite's presentation cap. + assert len(levels) > MAX_LEVELS diff --git a/tests/unit/test_indicator_service.py b/tests/unit/test_indicator_service.py index 7fd6db2..6d0fb0b 100644 --- a/tests/unit/test_indicator_service.py +++ b/tests/unit/test_indicator_service.py @@ -164,6 +164,34 @@ class TestComputeVolumeProfile: with pytest.raises(ValidationError, match="Volume Profile requires"): compute_volume_profile(highs, lows, closes, volumes) + def test_close_bin_volume_no_double_count(self): + """Each bar's volume is counted once (close bin), not per span.""" + # Wide bars that would span many bins under the old algorithm + n = 25 + closes = [100.0 + (i % 5) for i in range(n)] + highs = [c + 20 for c in closes] # wide range + lows = [c - 20 for c in closes] + volumes = [1000] * n + result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20) + # Binned total equals true volume (close-bin assignment) + # We only expose poc/hvn; reconstruct by checking score fields exist + assert result["poc"] > 0 + # With volume concentrated on a few close prices, HVNs should be few local peaks + assert len(result["hvn"]) < 20 + + def test_hvn_are_local_peaks_not_all_above_mean(self): + """HVN should be local histogram peaks, not every above-mean bin.""" + # Two clusters of closes → two volume peaks + closes = [80.0] * 10 + [120.0] * 10 + [100.0] * 5 + highs = [c + 1 for c in closes] + lows = [c - 1 for c in closes] + volumes = [1000] * len(closes) + result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20) + # At most a handful of local peaks (not ~half of 20 bins) + assert len(result["hvn"]) <= 6 + # POC should land near one of the high-volume clusters + assert result["poc"] < 95 or result["poc"] > 105 + # --------------------------------------------------------------------------- # Pivot Points @@ -184,6 +212,26 @@ class TestComputePivotPoints: with pytest.raises(ValidationError, match="Pivot Points requires"): compute_pivot_points([1, 2], [0, 1], [0.5, 1.5]) + def test_prominence_filters_tiny_swings(self): + # Mix of a large swing (depth ~10) and tiny fractal noise (depth ~1) + closes = [ + 10, 10.2, 10.5, 10.2, 10, # tiny high around idx 2 + 10, 15, 20, 15, 10, # large high around idx 7 + 10, 10.3, 10.6, 10.3, 10, # tiny high around idx 12 + ] + highs = list(closes) + lows = [c - 0.5 for c in closes] + highs[2] = 10.8 + highs[7] = 20.5 + highs[12] = 10.9 + lows[7] = 10.0 # large window range at major swing + unfiltered = compute_pivot_points(highs, lows, closes, min_prominence=None) + filtered = compute_pivot_points(highs, lows, closes, min_prominence=5.0) + assert unfiltered["pivot_count"] > 0 + assert filtered["pivot_count"] < unfiltered["pivot_count"] + # Major swing high should survive + assert any(h >= 20.0 for h in filtered["swing_highs"]) + # --------------------------------------------------------------------------- # EMA Cross diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index 1ade99e..11e4ccf 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from app.services.recommendation_service import ( _build_reasoning, _choose_recommended_action, + _gate_eligible_levels, _prune_floor_pinned_targets, _select_primary_target, direction_analyzer, @@ -110,7 +111,7 @@ def test_primary_target_is_most_likely_worthwhile_not_lottery(): {"price": 120.0, "rr_ratio": 3.5, "probability": 50.0}, {"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen ] - primary = _select_primary_target(targets) + primary = _select_primary_target(targets, min_rr=1.5) assert primary is not None assert primary["price"] == 110.0 @@ -120,13 +121,13 @@ def test_primary_target_skips_sub_threshold_rr(): {"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped {"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary ] - primary = _select_primary_target(targets) + primary = _select_primary_target(targets, min_rr=1.5) assert primary is not None assert primary["price"] == 115.0 def test_primary_target_none_when_empty(): - assert _select_primary_target([]) is None + assert _select_primary_target([], min_rr=1.5) is None def test_primary_target_never_headlines_a_lottery(): @@ -138,7 +139,7 @@ def test_primary_target_never_headlines_a_lottery(): {"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry {"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery ] - primary = _select_primary_target(targets) + primary = _select_primary_target(targets, min_rr=1.5) assert primary is not None assert primary["price"] == 101.0 @@ -150,7 +151,17 @@ def test_primary_target_requires_probability_floor(): {"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely {"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary ] - primary = _select_primary_target(targets) + primary = _select_primary_target(targets, min_rr=1.5) + assert primary is not None + assert primary["price"] == 112.0 + + +def test_primary_target_uses_activation_rr_not_scanner_floor(): + targets = [ + {"price": 108.0, "rr_ratio": 1.6, "probability": 60.0}, + {"price": 112.0, "rr_ratio": 2.2, "probability": 35.0}, + ] + primary = _select_primary_target(targets, min_rr=2.0) assert primary is not None assert primary["price"] == 112.0 @@ -318,3 +329,48 @@ def test_zone_representative_levels_singletons_unchanged(): reps = _zone_representative_levels(levels, entry_price=100.0) assert len(reps) == 2 assert {round(r.price_level) for r in reps} == {120, 150} + + +def test_zone_representative_levels_soft_strength_avoids_resaturation(): + from types import SimpleNamespace + from app.services.recommendation_service import _zone_representative_levels + + levels = [ + SimpleNamespace( + id=1, price_level=183.0, type="resistance", strength=60, + detection_method="pivot_point", sources=["pivot_point"], + rejection_count=3, last_rejection_age=5, + ), + SimpleNamespace( + id=2, price_level=185.0, type="resistance", strength=60, + detection_method="round_number", sources=["round_number"], + rejection_count=1, last_rejection_age=10, + ), + ] + reps = _zone_representative_levels( + levels, entry_price=180.0, strength_mode="soft" + ) + assert len(reps) == 1 + assert reps[0].strength == 65 + assert set(reps[0].sources) == {"pivot_point", "round_number"} + assert reps[0].rejection_count == 3 + + +def test_gate_requires_confirmation_for_standalone_round_number(): + from types import SimpleNamespace + + untouched = SimpleNamespace( + detection_method="round_number", sources=["round_number"], + rejection_count=1, + ) + confirmed = SimpleNamespace( + detection_method="round_number", sources=["round_number"], + rejection_count=2, + ) + confluent = SimpleNamespace( + detection_method="merged", sources=["round_number", "pivot_point"], + rejection_count=0, + ) + assert _gate_eligible_levels( + [untouched, confirmed, confluent], confirmed_rounds_only=True + ) == [confirmed, confluent]