865 lines
27 KiB
Python
865 lines
27 KiB
Python
"""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 _gate_target_range_centers(
|
||
highs: list[float],
|
||
lows: list[float],
|
||
closes: list[float],
|
||
num_bins: int = 20,
|
||
) -> list[float]:
|
||
"""Return the evenly spaced price proposals used by the production GTL."""
|
||
if len(closes) < 20:
|
||
raise ValidationError(
|
||
f"Range grid 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
|
||
return [
|
||
round(price_min + (i + 0.5) * bin_width, 4)
|
||
for i in range(num_bins)
|
||
]
|
||
|
||
|
||
def detect_gate_target_ladder(
|
||
highs: list[float],
|
||
lows: list[float],
|
||
closes: list[float],
|
||
tolerance: float = DEFAULT_TOLERANCE,
|
||
) -> list[dict]:
|
||
"""Build the scanner's internal, volume-free target proposal ladder.
|
||
|
||
This is intentionally not human-facing support/resistance. It builds the
|
||
production gate's broad 20-bin range grid, adds unfiltered pivots, scores
|
||
historical price traffic, and merges nearby proposals. The returned levels
|
||
are transient and must not be persisted as chart S/R.
|
||
"""
|
||
if not closes:
|
||
return []
|
||
|
||
candidates: list[tuple[float, str]] = []
|
||
try:
|
||
candidates.extend(
|
||
(float(price), "range_grid")
|
||
for price in _gate_target_range_centers(highs, lows, closes)
|
||
)
|
||
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)
|