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)