Add S/R v2 research and validation harness
This commit is contained in:
+556
-74
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user