Add S/R v2 research and validation harness

This commit is contained in:
2026-07-12 21:15:18 +02:00
parent 57ac1d2cdd
commit 19b81c169d
19 changed files with 1575 additions and 117 deletions
+556 -74
View File
@@ -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 0100 (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 0100 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).