S/R alerts: nearest zone only, scoped to watchlist + qualified, merged levels
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 24s

Three fixes to over-firing S/R proximity alerts:
- Route through cluster_sr_zones (the same merger the chart uses) instead of raw
  SRLevel rows, so near-duplicate levels (e.g. CVX 183 + 185) collapse into one
  zone and one alert.
- Alert only the single NEAREST strong zone per ticker, not every nearby level.
- Scope to watchlist + qualified-setup tickers via _alert_scope_tickers (was
  iterating all watchlist entries only; qualified setups are now included too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:07:06 +02:00
parent f24e5070ee
commit 88239e6ef8
2 changed files with 111 additions and 18 deletions
+60 -17
View File
@@ -34,6 +34,7 @@ from app.models.watchlist import WatchlistEntry
from app.services.admin_service import get_activation_config, update_setting
from app.services.qualification import best_target_probability, setup_qualifies
from app.services.rr_scanner_service import get_trade_setups
from app.services.sr_service import cluster_sr_zones
logger = logging.getLogger(__name__)
@@ -55,8 +56,9 @@ _BOOL_DEFAULTS = {
}
# Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong level → alert
SR_MIN_STRENGTH = 60 # only strong levels are alert-worthy
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert
SR_MIN_STRENGTH = 60 # only strong zones are alert-worthy
SR_CLUSTER_TOLERANCE = 0.02 # merge levels within 2% into one zone (matches chart)
SCORE_DROP_POINTS = 15.0 # composite drop vs watermark that triggers an alert
COOLDOWN_HOURS = 72 # don't re-send the same key within this window
DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after this hour
@@ -222,6 +224,25 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
return [(tid, sym) for tid, sym in result.all()]
async def _alert_scope_tickers(db: AsyncSession) -> list[tuple[int, str]]:
"""Tickers eligible for S/R-proximity alerts: watchlist qualified setups.
A ticker only matters for a proximity heads-up if you're watching it or it
has an actionable setup — not the whole universe.
"""
scope: dict[int, str] = {tid: sym for tid, sym in await _watchlist_tickers(db)}
qualified_symbols = {s["symbol"] for s in await _qualified_setups(db)}
missing = qualified_symbols - set(scope.values())
if missing:
result = await db.execute(
select(Ticker.id, Ticker.symbol).where(Ticker.symbol.in_(missing))
)
for tid, sym in result.all():
scope[tid] = sym
return list(scope.items())
async def _qualified_setups(db: AsyncSession) -> list[dict]:
setups = await get_trade_setups(db)
config = await get_activation_config(db)
@@ -259,26 +280,48 @@ async def _latest_close(db: AsyncSession, ticker_id: int) -> float | None:
async def _collect_sr_proximity(db: AsyncSession) -> list[tuple[str, str]]:
"""One alert per ticker for the NEAREST strong S/R zone within range.
Levels are merged into zones with the same clusterer the chart uses, so a
cluster of near-duplicate levels (e.g. 183 + 185) is a single zone and a
single alert. Scoped to watchlist qualified tickers.
"""
out: list[tuple[str, str]] = []
for tid, symbol in await _watchlist_tickers(db):
for tid, symbol in await _alert_scope_tickers(db):
price = await _latest_close(db, tid)
if not price:
continue
levels_result = await db.execute(
select(SRLevel).where(
SRLevel.ticker_id == tid,
SRLevel.strength >= SR_MIN_STRENGTH,
)
levels_result = await db.execute(select(SRLevel).where(SRLevel.ticker_id == tid))
levels = [
{"price_level": lv.price_level, "strength": lv.strength, "type": lv.type}
for lv in levels_result.scalars().all()
]
if not levels:
continue
zones = cluster_sr_zones(levels, price, tolerance=SR_CLUSTER_TOLERANCE)
strong = [z for z in zones if z["strength"] >= SR_MIN_STRENGTH]
if not strong:
continue
# Nearest strong zone only.
nearest = min(strong, key=lambda z: abs(price - z["midpoint"]))
dist_pct = abs(price - nearest["midpoint"]) / price * 100
if dist_pct > SR_PROXIMITY_PCT:
continue
label = (
f"{nearest['low']:.2f}{nearest['high']:.2f}"
if nearest["level_count"] > 1
else f"{nearest['midpoint']:.2f}"
)
for lv in levels_result.scalars().all():
dist_pct = abs(price - lv.price_level) / price * 100
if dist_pct <= SR_PROXIMITY_PCT:
key = f"sr:{symbol}:{lv.price_level:.2f}"
out.append((
key,
f"📍 <b>{symbol}</b> approaching {lv.type} at {lv.price_level:.2f} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
key = f"sr:{symbol}:{nearest['type']}" # one per side per ticker per cooldown
out.append((
key,
f"📍 <b>{symbol}</b> approaching {nearest['type']} {label} "
f"(now {price:.2f}, {dist_pct:.1f}% away)",
))
return out