From 65dd53baa37802ad77628a26feb4e32dec1320f0 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 26 Jun 2026 19:05:01 +0200 Subject: [PATCH] feat: Telegram alert on regime quadrant change (hysteresis + cooldown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/schemas/admin.py | 1 + app/services/alert_service.py | 110 +++++++++++++++++- .../src/components/admin/AlertSettings.tsx | 6 +- frontend/src/lib/types.ts | 1 + tests/unit/test_regime_quadrant_alert.py | 45 +++++++ 5 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_regime_quadrant_alert.py diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 1831fdb..a8883b5 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -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 diff --git a/app/services/alert_service.py b/app/services/alert_service.py index 9b06e60..0b406ed 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -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"🧭 Regime quadrant change\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: diff --git a/frontend/src/components/admin/AlertSettings.tsx b/frontend/src/components/admin/AlertSettings.tsx index cdc8689..038672e 100644 --- a/frontend/src/components/admin/AlertSettings.tsx +++ b/frontend/src/components/admin/AlertSettings.tsx @@ -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 ticker’s 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]); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5cb5ffb..fc5cf05 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 { diff --git a/tests/unit/test_regime_quadrant_alert.py b/tests/unit/test_regime_quadrant_alert.py new file mode 100644 index 0000000..f651fdf --- /dev/null +++ b/tests/unit/test_regime_quadrant_alert.py @@ -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