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
+51 -1
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
import pytest
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.user import User
from app.models.watchlist import WatchlistEntry
@@ -109,6 +111,54 @@ async def test_score_drop_seeds_then_alerts(session):
assert await svc._watermark(session, "AAA") == 60.0
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1)
if user is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
if watchlisted:
session.add(WatchlistEntry(user_id=1, ticker_id=t.id, entry_type="manual",
added_at=datetime.now(timezone.utc)))
session.add(OHLCVRecord(ticker_id=t.id, date=date.today(),
open=close, high=close, low=close, close=close, volume=1))
for price, kind, strength in levels:
session.add(SRLevel(ticker_id=t.id, price_level=price, type=kind,
strength=strength, detection_method="test"))
await session.commit()
return t.id
async def test_sr_proximity_merges_close_levels_to_one_alert(session):
# Two resistance levels ~1% apart just above price → one merged zone, one alert
await _add_ticker(session, "CVX", watchlisted=True, close=182.0,
levels=[(183.0, "resistance", 60), (185.0, "resistance", 60)])
msgs = await svc._collect_sr_proximity(session)
cvx = [m for m in msgs if "CVX" in m[1]]
assert len(cvx) == 1
assert "183.00185.00" in cvx[0][1]
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
await _add_ticker(session, "ZZZ", watchlisted=False, close=182.0,
levels=[(183.0, "resistance", 80)])
msgs = await svc._collect_sr_proximity(session)
assert not any("ZZZ" in m[1] for m in msgs)
async def test_sr_proximity_only_nearest_zone(session):
# A near resistance (~1%) and a far one (~10%); only the near one alerts
await _add_ticker(session, "AAA", watchlisted=True, close=100.0,
levels=[(101.0, "resistance", 70), (110.0, "resistance", 90)])
msgs = await svc._collect_sr_proximity(session)
aaa = [m for m in msgs if "AAA" in m[1]]
assert len(aaa) == 1
assert "101.00" in aaa[0][1]
async def test_dispatch_disabled_short_circuits(session):
res = await svc.dispatch_alerts(session)
assert res["status"] == "disabled"