S/R alerts: nearest zone only, scoped to watchlist + qualified, merged levels
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:
@@ -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.00–185.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"
|
||||
|
||||
Reference in New Issue
Block a user