diff --git a/app/services/alert_service.py b/app/services/alert_service.py index d65eb8f..5f5c078 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -16,6 +16,7 @@ precedence DB > env; the bot token is write-only (never returned on read). from __future__ import annotations import logging +import math from datetime import datetime, timedelta, timezone from types import SimpleNamespace @@ -65,6 +66,11 @@ _BOOL_DEFAULTS = { CLOSED_LOOKBACK_HOURS = 26 CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5 TRADE_CLOSED_TYPE = "trade_closed" +PAPER_BOOK_STARTING_CAPITAL = 10_000.0 +QUALIFIED_TARGET_ZONE_PCT = 1.0 +QUALIFIED_STATE_TYPE = "qualified_state" +QUALIFIED_ACTIVE = 1.0 +QUALIFIED_INACTIVE = 0.0 # Tunables (kept as constants for now; promote to settings if needed) SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone โ†’ alert @@ -100,6 +106,7 @@ QUAD_LABELS = { AlertItem = tuple[str, str, str] # alert_type, dedup_key, text AlertLogRef = tuple[str, str] # alert_type, dedup_key +ClosedTradeItem = tuple[str, str, float] # dedup_key, text, pnl_usd def _as_bool(value: str | None, default: bool) -> bool: @@ -226,6 +233,19 @@ async def _recently_alerted( return result.first() is not None +async def _latest_qualified_states(db: AsyncSession) -> dict[str, bool]: + """Latest active/inactive state per qualified setup opportunity.""" + result = await db.execute( + select(AlertLog.dedup_key, AlertLog.value) + .where(AlertLog.alert_type == QUALIFIED_STATE_TYPE) + .order_by(AlertLog.created_at.asc(), AlertLog.id.asc()) + ) + states: dict[str, bool] = {} + for key, value in result.all(): + states[key] = bool(value and value > 0) + return states + + def _log_alert(db: AsyncSession, alert_type: str, key: str, value: float | None = None) -> None: db.add( AlertLog( @@ -275,6 +295,25 @@ def _fmt_price(value: float | int | None) -> str: return "n/a" if value is None else f"{float(value):.2f}" +def _fmt_money(value: float | int | None) -> str: + if value is None: + return "n/a" + return f"${float(value):,.2f}" + + +def _fmt_signed_money(value: float | int | None) -> str: + if value is None: + return "n/a" + amount = float(value) + return f"{'+' if amount >= 0 else '-'}${abs(amount):,.2f}" + + +def _fmt_signed_pct(value: float | int | None) -> str: + if value is None: + return "n/a" + return f"{float(value):+.1f}%" + + def _fmt_signed_move(from_price: float | int | None, to_price: float | int | None) -> str: if from_price is None or to_price is None: return "n/a" @@ -285,6 +324,17 @@ def _fmt_signed_move(from_price: float | int | None, to_price: float | int | Non return f"{pct:+.1f}%" +def _qualified_opportunity_key(s: dict) -> str: + """Stable key for one alert per trade opportunity, not per scanner row.""" + target = s.get("target") + if target is None or float(target) <= 0: + zone = "unknown" + else: + step = math.log1p(QUALIFIED_TARGET_ZONE_PCT / 100.0) + zone = str(round(math.log(float(target)) / step)) + return f"qualified:{s['symbol']}:{s['direction']}:target-zone:{zone}" + + def _format_qualified(s: dict) -> str: prob = best_target_probability(SimpleNamespace(**s)) arrow = "๐ŸŸข" if s["direction"] == "long" else "๐Ÿ”ด" @@ -301,7 +351,7 @@ def _format_qualified(s: dict) -> str: async def _collect_qualified(db: AsyncSession) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] for s in await _qualified_setups(db): - key = f"qualified:{s['symbol']}:{s['direction']}" + key = _qualified_opportunity_key(s) out.append((key, _format_qualified(s))) return out @@ -428,12 +478,10 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None: lines = [f"๐Ÿ“Š Daily digest โ€” {now.date().isoformat()}"] if qualified: top = sorted(qualified, key=lambda s: s["rr_ratio"], reverse=True)[:5] - lines.append(f"{len(qualified)} qualified setup(s):") + lines.append("") + lines.append(f"Qualified setups ({len(qualified)})") for s in top: - lines.append( - f"โ€ข {s['symbol']} {s['direction'].upper()} " - f"R:R {s['rr_ratio']:.1f}:1, conf {(s.get('confidence_score') or 0):.0f}%" - ) + lines.append(_format_qualified(s)) else: lines.append("No qualified setups today.") @@ -443,7 +491,7 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None: open_trades = await paper_trade_service.list_trades(db, status="open") if open_trades: lines.append("") - lines.append(f"๐Ÿ’ผ {len(open_trades)} open trade(s):") + lines.append(f"Open trades ({len(open_trades)})") for t in open_trades: entry = t["entry_price"] cur = t.get("current_price") @@ -451,7 +499,7 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None: if cur and entry: gain_pct = (cur - entry) / entry * 100.0 * sign gain_usd = (cur - entry) * t["shares"] * sign - gain = f"{gain_pct:+.1f}% ({'+' if gain_usd >= 0 else 'โˆ’'}${abs(gain_usd):.0f})" + gain = f"{gain_pct:+.1f}% ({_fmt_signed_money(gain_usd)})" else: gain = "n/a" ts = t.get("trailing_stop") @@ -460,7 +508,12 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None: stop_txt = f"trail {ts:.2f}" + (f" ({dist:.1f}% away)" if dist is not None else "") else: stop_txt = f"stop {t['stop_loss']:.2f}" - lines.append(f"โ€ข {t['symbol']} {t['direction'].upper()} {gain} ยท {stop_txt}") + lines.append( + f"๐Ÿ’ผ {t['symbol']} {t['direction'].upper()} open | " + f"now {_fmt_price(cur)} | entry {_fmt_price(entry)} | " + f"target {_fmt_price(t.get('target'))} ({_fmt_signed_move(cur, t.get('target'))}) | " + f"{stop_txt} | P&L {gain}" + ) return key, "\n".join(lines) @@ -469,21 +522,29 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None: # Paper-trade close trigger (one summary per auto-closed trade) # --------------------------------------------------------------------------- +def _closed_trade_pnl(trade: PaperTrade) -> float: + sign = 1.0 if trade.direction == "long" else -1.0 + entry = trade.entry_price + exit_price = trade.close_price if trade.close_price is not None else entry + per_share = (exit_price - entry) * sign + return per_share * trade.shares + + def _format_closed_trade(trade: PaperTrade, symbol: str) -> str: sign = 1.0 if trade.direction == "long" else -1.0 entry = trade.entry_price exit_price = trade.close_price if trade.close_price is not None else entry per_share = (exit_price - entry) * sign pnl_pct = (per_share / entry * 100.0) if entry else 0.0 - pnl_usd = per_share * trade.shares + pnl_usd = _closed_trade_pnl(trade) risk = abs(entry - trade.stop_loss) r_mult = (per_share / risk) if risk > 0 else None win = per_share > 0 - money = f"{'+' if pnl_usd >= 0 else 'โˆ’'}${abs(pnl_usd):.2f}" + money = _fmt_signed_money(pnl_usd) r_txt = f" ยท {r_mult:+.2f}R" if r_mult is not None else "" days = (trade.closed_at - trade.opened_at).days if (trade.closed_at and trade.opened_at) else None held = f" ยท held {days}d" if days is not None else "" - reason = {"trailing": "trailing stop", "stop": "stop-loss", "target": "target"}.get( + reason = {"trailing": "trailing stop", "stop": "stop-loss", "target": "target", "time": "max hold"}.get( trade.close_reason or "", trade.close_reason or "closed" ) return ( @@ -493,8 +554,8 @@ def _format_closed_trade(trade: PaperTrade, symbol: str) -> str: ) -async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]: - """One alert per auto-closed paper trade (trailing / stop / target). Manual +async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]: + """One alert item per auto-closed paper trade. Manual closes are skipped โ€” you already know about those. Dedup is by trade id.""" cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS) result = await db.execute( @@ -504,11 +565,63 @@ async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]: PaperTrade.status == "closed", PaperTrade.closed_at.is_not(None), PaperTrade.closed_at > cutoff, - PaperTrade.close_reason.in_(("trailing", "stop", "target")), + PaperTrade.close_reason.in_(("trailing", "stop", "target", "time")), ) .order_by(PaperTrade.closed_at.desc()) ) - return [(str(trade.id), _format_closed_trade(trade, symbol)) for trade, symbol in result.all()] + return [ + (str(trade.id), _format_closed_trade(trade, symbol), _closed_trade_pnl(trade)) + for trade, symbol in result.all() + ] + + +async def _paper_book_value(db: AsyncSession) -> float: + """Paper-trade equity: fixed capital plus realized/unrealized P&L.""" + result = await db.execute(select(PaperTrade)) + trades = list(result.scalars().all()) + latest: dict[int, float | None] = {} + for trade in trades: + if trade.status == "open" and trade.ticker_id not in latest: + latest[trade.ticker_id] = await _latest_close(db, trade.ticker_id) + + total_pnl = 0.0 + for trade in trades: + ref = trade.close_price if trade.status == "closed" else latest.get(trade.ticker_id) + if ref is None: + ref = trade.entry_price + sign = 1.0 if trade.direction == "long" else -1.0 + pnl = (float(ref) - trade.entry_price) * trade.shares * sign + total_pnl += pnl + return PAPER_BOOK_STARTING_CAPITAL + total_pnl + + +def _closed_trade_bundle( + items: list[ClosedTradeItem], + *, + current_book_value: float | None, +) -> tuple[list[AlertLogRef], str] | None: + if not items: + return None + total_pnl = sum(item[2] for item in items) + previous_book_value = ( + current_book_value - total_pnl + if current_book_value is not None + else None + ) + change_pct = ( + total_pnl / previous_book_value * 100.0 + if previous_book_value not in (None, 0) + else None + ) + lines = [f"๐Ÿ’ผ Paper trades closed โ€” {len(items)} trade(s)"] + if current_book_value is not None and previous_book_value is not None: + lines.append( + f"Paper book {_fmt_money(previous_book_value)} โ†’ {_fmt_money(current_book_value)} " + f"({_fmt_signed_money(total_pnl)}, {_fmt_signed_pct(change_pct)})" + ) + lines.append("") + lines.append("\n\n".join(item[1] for item in items)) + return ([(TRADE_CLOSED_TYPE, item[0]) for item in items], "\n".join(lines)) # --------------------------------------------------------------------------- @@ -540,7 +653,26 @@ def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUA return _bools_to_quadrant(x_high, y_high) -async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None]: +def _quadrant_log_key(q: str, x: float, y: float) -> str: + return f"{q}:{x:.1f}:{y:.1f}" + + +def _parse_quadrant_log_key(key: str | None) -> tuple[str | None, float | None, float | None]: + if not key: + return None, None, None + parts = key.split(":") + q = parts[0] + if q not in QUAD_LABELS: + return None, None, None + if len(parts) >= 3: + try: + return q, float(parts[1]), float(parts[2]) + except ValueError: + pass + return q, None, None + + +async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, float | 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) @@ -549,7 +681,10 @@ async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None] .limit(1) ) row = result.first() - return (row[0], row[1]) if row else (None, None) + if not row: + return None, None, None, None + prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0]) + return prev_q, prev_x, prev_y, row[1] async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]: @@ -570,9 +705,9 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]: if x is None or y is None: return [] - prev, prev_time = await _last_quadrant(db) + prev, prev_x, prev_y, prev_time = await _last_quadrant(db) if prev is None: - _log_alert(db, QUAD_TYPE, _classify_quadrant(x, y, None)) # seed, no alert + _log_alert(db, QUAD_TYPE, _quadrant_log_key(_classify_quadrant(x, y, None), x, y)) # seed, no alert return [] new_q = _classify_quadrant(x, y, prev) @@ -585,12 +720,19 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]: if datetime.now(timezone.utc) - prev_time < timedelta(days=QUAD_COOLDOWN_DAYS): return [] # genuine change, but inside the cooldown โ€” stay quiet + if prev_x is not None and prev_y is not None: + metrics = ( + f"regime {prev_x:.0f} โ†’ {x:.0f} ({x - prev_x:+.0f}) ยท " + f"early-warning {prev_y:.0f} โ†’ {y:.0f} ({y - prev_y:+.0f})" + ) + else: + metrics = f"regime {x:.0f} ยท early-warning {y:.0f}" 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}" + f"{metrics}" ) - return [(new_q, text)] + return [(_quadrant_log_key(new_q, x, y), text)] # --------------------------------------------------------------------------- @@ -650,11 +792,21 @@ async def dispatch_alerts(db: AsyncSession) -> dict: signal_outgoing: list[AlertItem] = [] outgoing: list[AlertItem] = [] + closed_outgoing: list[ClosedTradeItem] = [] + qualified_inactive: list[str] = [] if cfg["qualified"]: - for key, text in await _collect_qualified(db): - if not await _recently_alerted(db, "qualified", key): + previous_qualified_states = await _latest_qualified_states(db) + qualified_items = await _collect_qualified(db) + current_qualified_keys = {key for key, _ in qualified_items} + for key, text in qualified_items: + if not previous_qualified_states.get(key, False): signal_outgoing.append(("qualified", key, text)) + qualified_inactive = [ + key + for key, active in previous_qualified_states.items() + if active and key not in current_qualified_keys + ] if cfg["sr"]: for key, text in await _collect_sr_proximity(db): @@ -677,23 +829,43 @@ async def dispatch_alerts(db: AsyncSession) -> dict: outgoing.append((QUAD_TYPE, key, text)) if cfg["trade_closed"]: - for key, text in await _collect_closed_trades(db): + for key, text, pnl_usd in await _collect_closed_trades(db): if not await _recently_alerted(db, TRADE_CLOSED_TYPE, key, cooldown_hours=CLOSED_ALERT_COOLDOWN_HOURS): - outgoing.append((TRADE_CLOSED_TYPE, key, text)) + closed_outgoing.append((key, text, pnl_usd)) sent = 0 - candidates = len(signal_outgoing) + len(outgoing) - if signal_outgoing or outgoing: + candidates = len(signal_outgoing) + len(outgoing) + len(closed_outgoing) + closed_bundle = ( + _closed_trade_bundle( + closed_outgoing, + current_book_value=await _paper_book_value(db), + ) + if closed_outgoing + else None + ) + if signal_outgoing or outgoing or closed_bundle: async with httpx.AsyncClient(timeout=15) as client: for log_refs, text in _signal_bundle_messages(signal_outgoing): try: await _send(client, cfg["token"], cfg["chat_id"], text) for alert_type, key in log_refs: _log_alert(db, alert_type, key) + if alert_type == "qualified": + _log_alert(db, QUALIFIED_STATE_TYPE, key, value=QUALIFIED_ACTIVE) sent += 1 except Exception: logger.exception("Failed to send signal alert bundle") + if closed_bundle is not None: + log_refs, text = closed_bundle + try: + await _send(client, cfg["token"], cfg["chat_id"], text) + for alert_type, key in log_refs: + _log_alert(db, alert_type, key) + sent += 1 + except Exception: + logger.exception("Failed to send trade-closed alert bundle") + for alert_type, key, text in outgoing: try: await _send(client, cfg["token"], cfg["chat_id"], text) @@ -702,6 +874,9 @@ async def dispatch_alerts(db: AsyncSession) -> dict: except Exception: logger.exception("Failed to send alert %s", key) + for key in qualified_inactive: + _log_alert(db, QUALIFIED_STATE_TYPE, key, value=QUALIFIED_INACTIVE) + await db.commit() # persist watermark seeds/advances and sent-logs return {"status": "ok", "sent": sent, "candidates": candidates} diff --git a/tests/unit/test_alert_service.py b/tests/unit/test_alert_service.py index 5451e4e..69368e3 100644 --- a/tests/unit/test_alert_service.py +++ b/tests/unit/test_alert_service.py @@ -134,6 +134,15 @@ def test_format_qualified_includes_current_price_and_target_move(): assert "P(target) 63%" in text +def test_qualified_opportunity_key_uses_target_zone_not_setup_id(): + base = {"symbol": "AAPL", "direction": "long", "target": 207.50} + same_zone = {"symbol": "AAPL", "direction": "long", "target": 208.00} + different_zone = {"symbol": "AAPL", "direction": "long", "target": 230.00} + + assert svc._qualified_opportunity_key(base) == svc._qualified_opportunity_key(same_zone) + assert svc._qualified_opportunity_key(base) != svc._qualified_opportunity_key(different_zone) + + async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float, levels: list[tuple[float, str, int]]) -> int: user = await session.get(User, 1) @@ -246,10 +255,77 @@ async def test_dispatch_bundles_discovery_alerts_and_logs_each_item(session, mon assert rows == [ ("qualified", "qualified:AAPL:long"), ("qualified", "qualified:TSLA:short"), + ("qualified_state", "qualified:AAPL:long"), + ("qualified_state", "qualified:TSLA:short"), ("sr_proximity", "sr:MSFT:resistance"), ] +async def test_dispatch_alerts_qualified_once_per_episode(session, monkeypatch): + key = "qualified:AAPL:long:target-zone:537" + current_items = [(key, "๐ŸŸข AAPL LONG | now 196.42 | target 207.50 (+5.6%)")] + + async def fake_collect_qualified(_db): + return list(current_items) + + sent: list[str] = [] + + async def fake_send(_client, _token, _chat_id, text): + sent.append(text) + + monkeypatch.setattr(svc, "_collect_qualified", fake_collect_qualified) + monkeypatch.setattr(svc, "_send", fake_send) + + await svc.update_alert_config( + session, + enabled=True, + bot_token="token", + telegram_chat_id="chat", + sr_proximity_enabled=False, + score_drop_enabled=False, + digest_enabled=False, + regime_quadrant_enabled=False, + trade_closed_enabled=False, + ) + + res = await svc.dispatch_alerts(session) + assert res == {"status": "ok", "sent": 1, "candidates": 1} + assert len(sent) == 1 + + # Still qualified: same opportunity remains active, so no repeat alert. + res = await svc.dispatch_alerts(session) + assert res == {"status": "ok", "sent": 0, "candidates": 0} + assert len(sent) == 1 + + # Drops out of the qualified set: state is marked inactive without a Telegram send. + current_items.clear() + res = await svc.dispatch_alerts(session) + assert res == {"status": "ok", "sent": 0, "candidates": 0} + assert len(sent) == 1 + + # Re-enters later: alert once again for the new qualified episode. + current_items.append((key, "๐ŸŸข AAPL LONG | now 196.42 | target 207.50 (+5.6%)")) + res = await svc.dispatch_alerts(session) + assert res == {"status": "ok", "sent": 1, "candidates": 1} + assert len(sent) == 2 + + qualified_alerts = ( + await session.execute( + select(AlertLog.id) + .where(AlertLog.alert_type == "qualified", AlertLog.dedup_key == key) + ) + ).all() + state_values = ( + await session.execute( + select(AlertLog.value) + .where(AlertLog.alert_type == svc.QUALIFIED_STATE_TYPE, AlertLog.dedup_key == key) + .order_by(AlertLog.created_at.asc(), AlertLog.id.asc()) + ) + ).scalars().all() + assert len(qualified_alerts) == 2 + assert state_values == [svc.QUALIFIED_ACTIVE, svc.QUALIFIED_INACTIVE, svc.QUALIFIED_ACTIVE] + + async def _add_closed_trade(session, symbol: str, reason: str, *, close: float = 110.0, closed_hours_ago: float = 1.0) -> None: if await session.get(User, 1) is None: @@ -277,13 +353,15 @@ async def test_config_includes_trade_closed_toggle(session): async def test_collect_closed_trades_filters_manual_and_old(session): await _add_closed_trade(session, "WIN", "trailing", close=110.0, closed_hours_ago=1) + await _add_closed_trade(session, "TIME", "time", close=104.0, closed_hours_ago=1) await _add_closed_trade(session, "MAN", "manual", close=110.0, closed_hours_ago=1) # manual โ†’ skip await _add_closed_trade(session, "OLD", "stop", close=95.0, closed_hours_ago=100) # too old โ†’ skip out = await svc._collect_closed_trades(session) - assert len(out) == 1 - _, text = out[0] - assert "WIN" in text and "trailing stop" in text + assert len(out) == 2 + texts = [item[1] for item in out] + assert any("WIN" in text and "trailing stop" in text for text in texts) + assert any("TIME" in text and "max hold" in text for text in texts) def test_format_closed_trade_win(): @@ -298,3 +376,48 @@ def test_format_closed_trade_win(): assert "+10.0%" in txt assert "+2.00R" in txt # +10% over a 5% stop assert "held 12d" in txt + + +async def test_dispatch_bundles_trade_closed_alerts_with_book_change(session, monkeypatch): + await _add_closed_trade(session, "OLDGAIN", "target", close=150.0, closed_hours_ago=100) + await _add_closed_trade(session, "WIN", "trailing", close=110.0, closed_hours_ago=1) + await _add_closed_trade(session, "TIME", "time", close=104.0, closed_hours_ago=1) + + sent: list[str] = [] + + async def fake_send(_client, _token, _chat_id, text): + sent.append(text) + + monkeypatch.setattr(svc, "_send", fake_send) + await svc.update_alert_config( + session, + enabled=True, + bot_token="token", + telegram_chat_id="chat", + qualified_enabled=False, + sr_proximity_enabled=False, + score_drop_enabled=False, + digest_enabled=False, + regime_quadrant_enabled=False, + trade_closed_enabled=True, + ) + + res = await svc.dispatch_alerts(session) + + assert res == {"status": "ok", "sent": 1, "candidates": 2} + assert len(sent) == 1 + assert "Paper trades closed โ€” 2 trade(s)" in sent[0] + assert "Paper book $10,500.00 โ†’ $10,640.00 (+$140.00, +1.3%)" in sent[0] + assert "WIN LONG closed" in sent[0] + assert "TIME LONG closed" in sent[0] + assert "OLDGAIN" not in sent[0] + assert "max hold" in sent[0] + + rows = ( + await session.execute( + select(AlertLog.alert_type, AlertLog.dedup_key) + .where(AlertLog.alert_type == svc.TRADE_CLOSED_TYPE) + .order_by(AlertLog.dedup_key) + ) + ).all() + assert len(rows) == 2 diff --git a/tests/unit/test_regime_quadrant_alert.py b/tests/unit/test_regime_quadrant_alert.py index f651fdf..71eae9b 100644 --- a/tests/unit/test_regime_quadrant_alert.py +++ b/tests/unit/test_regime_quadrant_alert.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.services.alert_service import _classify_quadrant +from app.services.alert_service import _classify_quadrant, _parse_quadrant_log_key, _quadrant_log_key # Quadrant ids: 1=โ‘  hot&brittle (regime low, warning high), 2=โ‘ก transition @@ -43,3 +43,10 @@ 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 + + +def test_quadrant_log_key_keeps_previous_values(): + key = _quadrant_log_key("3", 32.4, 54.6) + assert _parse_quadrant_log_key(key) == ("3", 32.4, 54.6) + # Existing pre-value keys still parse so old installs do not need migration. + assert _parse_quadrant_log_key("3") == ("3", None, None)