"""S/R Detector service. 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 from sqlalchemy.ext.asyncio import AsyncSession from app.exceptions import NotFoundError, ValidationError 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 # --------------------------------------------------------------------------- # 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: """Look up a ticker by symbol.""" normalised = symbol.strip().upper() result = await db.execute(select(Ticker).where(Ticker.symbol == normalised)) ticker = result.scalar_one_or_none() if ticker is None: raise NotFoundError(f"Ticker not found: {normalised}") return ticker 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, 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. *cooldown* bars after a full rejection are ignored so multi-day chop at a level counts as one test cluster, not N identical rejections. """ 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( highs: list[float], lows: list[float], closes: list[float], volumes: list[int], ) -> list[tuple[float, str]]: """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 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(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((float(price), "volume_profile")) except ValidationError: 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 try: pp = compute_pivot_points(p_h, p_l, p_c, min_prominence=prominence) for price in pp.get("swing_highs", []): candidates.append((float(price), "pivot_point")) for price in pp.get("swing_lows", []): candidates.append((float(price), "pivot_point")) except ValidationError: 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. 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 [] sorted_levels = sorted(levels, key=lambda x: x["price_level"]) merged: list[dict] = [] for level in sorted_levels: if not merged: entry = dict(level) sources = level.get("sources") or [level["detection_method"]] entry["sources"] = sorted(set(sources)) merged.append(entry) continue last = merged[-1] ref_price = last["price_level"] tol = ref_price * tolerance if ref_price != 0 else tolerance if abs(level["price_level"] - ref_price) <= tol: 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 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: entry = dict(level) sources = level.get("sources") or [level["detection_method"]] entry["sources"] = sorted(set(sources)) merged.append(entry) return merged def _tag_levels( levels: list[dict], current_price: float, ) -> list[dict]: """Tag each level as 'support' or 'resistance' relative to current price.""" for level in levels: if level["price_level"] < current_price: level["type"] = "support" else: level["type"] = "resistance" 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 | None = None, max_levels: int = MAX_LEVELS, ) -> list[dict]: """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. """ if not closes: return [] candidates = _extract_candidate_levels(highs, lows, closes, volumes) if not candidates: return [] 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 # Score each candidate on recent rejection-weighted touches raw_levels: list[dict] = [] for price, method in candidates: 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": int(evidence["strength"]), "detection_method": method, "type": "", "sources": [method], "rejection_count": int(evidence["rejection_count"]), "last_rejection_age": evidence["last_rejection_age"], "weighted_respects": float(evidence["weighted_respects"]), }) 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 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. Returns list of zone dicts: { "low": float, "high": float, "midpoint": float, "strength": int, # sum of constituent strengths, capped at 100 "type": "support" | "resistance", "level_count": int, } """ if not levels: return [] if max_zones is not None and max_zones <= 0: return [] # 1. Sort levels by price_level ascending sorted_levels = sorted(levels, key=lambda x: x["price_level"]) # 2. Greedy merge into clusters clusters: list[list[dict]] = [] current_cluster: list[dict] = [sorted_levels[0]] for level in sorted_levels[1:]: # Compute current cluster midpoint prices = [lvl["price_level"] for lvl in current_cluster] cluster_low = min(prices) cluster_high = max(prices) cluster_mid = (cluster_low + cluster_high) / 2.0 # Check if within tolerance of cluster midpoint if cluster_mid != 0: distance_pct = abs(level["price_level"] - cluster_mid) / cluster_mid else: distance_pct = abs(level["price_level"]) if distance_pct <= tolerance: current_cluster.append(level) else: clusters.append(current_cluster) current_cluster = [level] clusters.append(current_cluster) # 3. Compute zone for each cluster zones: list[dict] = [] for cluster in clusters: prices = [lvl["price_level"] for lvl in cluster] low = min(prices) high = max(prices) midpoint = (low + high) / 2.0 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" zones.append({ "low": low, "high": high, "midpoint": midpoint, "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 support_zones = sorted( [z for z in zones if z["type"] == "support"], key=lambda z: z["strength"], reverse=True, ) resistance_zones = sorted( [z for z in zones if z["type"] == "resistance"], key=lambda z: z["strength"], reverse=True, ) # 6. Interleave pick: alternate strongest from each pool selected: list[dict] = [] limit = max_zones if max_zones is not None else len(zones) si, ri = 0, 0 pick_support = True # start with support pool while len(selected) < limit and (si < len(support_zones) or ri < len(resistance_zones)): if pick_support: if si < len(support_zones): selected.append(support_zones[si]) si += 1 elif ri < len(resistance_zones): selected.append(resistance_zones[ri]) ri += 1 else: if ri < len(resistance_zones): selected.append(resistance_zones[ri]) ri += 1 elif si < len(support_zones): selected.append(support_zones[si]) si += 1 pick_support = not pick_support # 7. Sort final selection by strength descending selected.sort(key=lambda z: z["strength"], reverse=True) return selected async def recalculate_sr_levels( db: AsyncSession, symbol: str, tolerance: float | None = None, ) -> list[SRLevel]: """Recalculate S/R levels for a ticker and persist to DB. 1. Fetch OHLCV data 2. Detect levels 3. Delete old levels for ticker 4. Insert new levels 5. Return new levels sorted by strength desc """ ticker = await _get_ticker(db, symbol) records = await query_ohlcv(db, symbol) if not records: # No OHLCV data — clear any existing levels await db.execute( delete(SRLevel).where(SRLevel.ticker_id == ticker.id) ) await db.commit() return [] _, highs, lows, closes, volumes = _extract_ohlcv(records) levels = detect_sr_levels(highs, lows, closes, volumes, tolerance) # Delete old levels await db.execute( delete(SRLevel).where(SRLevel.ticker_id == ticker.id) ) # Insert new levels now = datetime.utcnow() new_models: list[SRLevel] = [] for lvl in levels: model = SRLevel( ticker_id=ticker.id, price_level=lvl["price_level"], type=lvl["type"], strength=lvl["strength"], detection_method=lvl["detection_method"], created_at=now, ) db.add(model) new_models.append(model) await db.commit() # Refresh to get IDs for m in new_models: await db.refresh(m) return new_models async def get_sr_levels( db: AsyncSession, symbol: str, tolerance: float | None = None, ) -> list[SRLevel]: """Get S/R levels for a ticker, recalculating on every request (MVP). Returns levels sorted by strength descending. """ return await recalculate_sr_levels(db, symbol, tolerance)