feat: Telegram alert on regime quadrant change (hysteresis + cooldown)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 27s

Fires once when the regime monitor shifts quadrant (regime index x early
warning), so you don't have to watch the tab. Two guards against spam:

- Hysteresis: each axis only flips once the value crosses its divider by a
  margin, so a point parked on a boundary keeps its quadrant instead of
  flip-flopping day to day.
- Cooldown: a genuine change stays quiet for a few days after the last alert.

Seeds the baseline silently on first run; reuses the existing Telegram dispatch
+ AlertLog. New per-trigger toggle in Admin → Alerts (on by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:05:01 +02:00
parent e683513857
commit 65dd53baa3
5 changed files with 161 additions and 2 deletions
+1
View File
@@ -98,3 +98,4 @@ class AlertConfigUpdate(BaseModel):
sr_proximity_enabled: bool | None = None
score_drop_enabled: bool | None = None
digest_enabled: bool | None = None
regime_quadrant_enabled: bool | None = None
+109 -1
View File
@@ -46,6 +46,7 @@ KEY_QUALIFIED = "alerts_qualified_enabled"
KEY_SR = "alerts_sr_proximity_enabled"
KEY_SCORE_DROP = "alerts_score_drop_enabled"
KEY_DIGEST = "alerts_digest_enabled"
KEY_REGIME_QUADRANT = "alerts_regime_quadrant_enabled"
_BOOL_DEFAULTS = {
KEY_ENABLED: False,
@@ -53,6 +54,7 @@ _BOOL_DEFAULTS = {
KEY_SR: True,
KEY_SCORE_DROP: True,
KEY_DIGEST: True,
KEY_REGIME_QUADRANT: True,
}
# Tunables (kept as constants for now; promote to settings if needed)
@@ -65,6 +67,21 @@ DIGEST_HOUR_UTC = 22 # send the daily digest on the first run at/after th
WATERMARK_TYPE = "score_watermark"
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant)
QUAD_Y_DIV = 60.0 # early-warning divider
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = {
"1": "① Hot & brittle",
"2": "② Transition",
"3": "③ Healthy & broad",
"4": "④ Real downturn",
}
def _as_bool(value: str | None, default: bool) -> bool:
if value is None:
@@ -73,7 +90,7 @@ def _as_bool(value: str | None, default: bool) -> bool:
async def _resolve(db: AsyncSession) -> dict:
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST]
keys = [KEY_ENABLED, KEY_TOKEN, KEY_CHAT_ID, KEY_QUALIFIED, KEY_SR, KEY_SCORE_DROP, KEY_DIGEST, KEY_REGIME_QUADRANT]
stored = await settings_store.get_map(db, keys)
db_token = (stored.get(KEY_TOKEN) or "").strip()
@@ -95,6 +112,7 @@ async def _resolve(db: AsyncSession) -> dict:
"sr": _as_bool(stored.get(KEY_SR), _BOOL_DEFAULTS[KEY_SR]),
"score_drop": _as_bool(stored.get(KEY_SCORE_DROP), _BOOL_DEFAULTS[KEY_SCORE_DROP]),
"digest": _as_bool(stored.get(KEY_DIGEST), _BOOL_DEFAULTS[KEY_DIGEST]),
"regime_quadrant": _as_bool(stored.get(KEY_REGIME_QUADRANT), _BOOL_DEFAULTS[KEY_REGIME_QUADRANT]),
}
@@ -110,6 +128,7 @@ async def get_alert_config(db: AsyncSession) -> dict:
"sr_proximity_enabled": r["sr"],
"score_drop_enabled": r["score_drop"],
"digest_enabled": r["digest"],
"regime_quadrant_enabled": r["regime_quadrant"],
}
@@ -123,6 +142,7 @@ async def update_alert_config(
sr_proximity_enabled: bool | None = None,
score_drop_enabled: bool | None = None,
digest_enabled: bool | None = None,
regime_quadrant_enabled: bool | None = None,
) -> dict:
"""Persist config. An empty/omitted bot_token leaves the stored token intact."""
bool_updates = {
@@ -131,6 +151,7 @@ async def update_alert_config(
KEY_SR: sr_proximity_enabled,
KEY_SCORE_DROP: score_drop_enabled,
KEY_DIGEST: digest_enabled,
KEY_REGIME_QUADRANT: regime_quadrant_enabled,
}
for key, val in bool_updates.items():
if val is not None:
@@ -358,6 +379,88 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
return key, "\n".join(lines)
# ---------------------------------------------------------------------------
# Regime quadrant-change trigger (hysteresis + cooldown)
# ---------------------------------------------------------------------------
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
return "4" if x_high else "3" # ④ Real downturn / ③ Healthy & broad
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str:
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
Each axis only flips once the value crosses its divider by ``margin`` in the
new direction, so a point parked on a divider keeps its current quadrant
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
"""
if prev is None:
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV)
px, py = _quadrant_to_bools(prev)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
return _bools_to_quadrant(x_high, y_high)
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None]:
"""Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at)
.where(AlertLog.alert_type == QUAD_TYPE)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return (row[0], row[1]) if row else (None, None)
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
"""Alert once when the regime quadrant changes (hysteresis + cooldown).
Seeds silently on first run. Thereafter alerts only when the
hysteresis-confirmed quadrant differs from the last logged one AND the
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
becomes the next baseline and resets the cooldown clock.
"""
from app.services.regime_monitor_service import get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
x = data.get("total_score")
y = (data.get("early_warning") or {}).get("score")
if x is None or y is None:
return []
prev, prev_time = await _last_quadrant(db)
if prev is None:
_log_alert(db, QUAD_TYPE, _classify_quadrant(x, y, None)) # seed, no alert
return []
new_q = _classify_quadrant(x, y, prev)
if new_q == prev:
return []
if prev_time is not None:
if prev_time.tzinfo is None:
prev_time = prev_time.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - prev_time < timedelta(days=QUAD_COOLDOWN_DAYS):
return [] # genuine change, but inside the cooldown — stay quiet
text = (
f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"regime {x:.0f} · early-warning {y:.0f}"
)
return [(new_q, text)]
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@@ -392,6 +495,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
if digest is not None:
outgoing.append(("digest", digest[0], digest[1]))
if cfg["regime_quadrant"]:
# cooldown/hysteresis handled in the collector (like score drops)
for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text))
sent = 0
if outgoing:
async with httpx.AsyncClient(timeout=15) as client:
@@ -12,13 +12,15 @@ type TriggerKey =
| 'qualified_enabled'
| 'sr_proximity_enabled'
| 'score_drop_enabled'
| 'digest_enabled';
| 'digest_enabled'
| 'regime_quadrant_enabled';
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
{ key: 'regime_quadrant_enabled', label: 'Regime quadrant change', hint: 'the regime monitor shifts quadrant (hysteresis + cooldown)' },
];
function Toggle({ checked, onChange, label, hint }: {
@@ -56,6 +58,7 @@ export function AlertSettings() {
sr_proximity_enabled: true,
score_drop_enabled: true,
digest_enabled: true,
regime_quadrant_enabled: true,
});
useEffect(() => {
@@ -67,6 +70,7 @@ export function AlertSettings() {
sr_proximity_enabled: data.sr_proximity_enabled,
score_drop_enabled: data.score_drop_enabled,
digest_enabled: data.digest_enabled,
regime_quadrant_enabled: data.regime_quadrant_enabled,
});
}
}, [data]);
+1
View File
@@ -380,6 +380,7 @@ export interface AlertConfig {
sr_proximity_enabled: boolean;
score_drop_enabled: boolean;
digest_enabled: boolean;
regime_quadrant_enabled: boolean;
}
export interface AlertTestResult {
+45
View File
@@ -0,0 +1,45 @@
"""Tests for the regime quadrant classification + hysteresis (anti-flicker)."""
from __future__ import annotations
from app.services.alert_service import _classify_quadrant
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low).
# Dividers: regime 40, early-warning 60; margin 5.
def test_fresh_classification():
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning
assert _classify_quadrant(70, 90, None) == "2" # both high
assert _classify_quadrant(20, 30, None) == "3" # both low
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning
def test_hysteresis_holds_inside_deadband():
# From ③ (both low): early-warning nudging just past 60 stays ③ until it
# clears 60 + margin (65).
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ①
def test_hysteresis_sticky_when_already_high():
# From ① (warning high): a dip below 60 keeps ① until it drops past 60 - margin (55).
assert _classify_quadrant(20, 58, prev="1") == "1" # still high (deadband)
assert _classify_quadrant(20, 54, prev="1") == "3" # drops past 55 → back to ③
def test_hysteresis_on_regime_axis():
# From ③: regime rising past 40 stays ③ until it clears 45.
assert _classify_quadrant(43, 30, prev="3") == "3"
assert _classify_quadrant(46, 30, prev="3") == "4"
# From ④: regime easing keeps ④ until below 35.
assert _classify_quadrant(37, 30, prev="4") == "4"
assert _classify_quadrant(34, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip():
# A point parked exactly on both dividers keeps whatever quadrant it had.
for q in ("1", "2", "3", "4"):
assert _classify_quadrant(40, 60, prev=q) == q