Refine Telegram alert behavior
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m18s
Deploy / deploy (push) Successful in 39s

This commit is contained in:
2026-07-04 12:37:12 +02:00
parent ce6035ee3c
commit edc1a9757b
3 changed files with 337 additions and 32 deletions
+203 -28
View File
@@ -16,6 +16,7 @@ precedence DB > env; the bot token is write-only (never returned on read).
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
@@ -65,6 +66,11 @@ _BOOL_DEFAULTS = {
CLOSED_LOOKBACK_HOURS = 26 CLOSED_LOOKBACK_HOURS = 26
CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5 CLOSED_ALERT_COOLDOWN_HOURS = 24 * 365 * 5
TRADE_CLOSED_TYPE = "trade_closed" 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) # Tunables (kept as constants for now; promote to settings if needed)
SR_PROXIMITY_PCT = 2.0 # within this % of a strong zone → alert 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 AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
AlertLogRef = tuple[str, str] # alert_type, dedup_key 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: def _as_bool(value: str | None, default: bool) -> bool:
@@ -226,6 +233,19 @@ async def _recently_alerted(
return result.first() is not None 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: def _log_alert(db: AsyncSession, alert_type: str, key: str, value: float | None = None) -> None:
db.add( db.add(
AlertLog( 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}" 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: 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: if from_price is None or to_price is None:
return "n/a" 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}%" 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: def _format_qualified(s: dict) -> str:
prob = best_target_probability(SimpleNamespace(**s)) prob = best_target_probability(SimpleNamespace(**s))
arrow = "🟢" if s["direction"] == "long" else "🔴" 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]]: async def _collect_qualified(db: AsyncSession) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for s in await _qualified_setups(db): 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))) out.append((key, _format_qualified(s)))
return out return out
@@ -428,12 +478,10 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
lines = [f"📊 <b>Daily digest</b> — {now.date().isoformat()}"] lines = [f"📊 <b>Daily digest</b> — {now.date().isoformat()}"]
if qualified: if qualified:
top = sorted(qualified, key=lambda s: s["rr_ratio"], reverse=True)[:5] 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"<b>Qualified setups</b> ({len(qualified)})")
for s in top: for s in top:
lines.append( lines.append(_format_qualified(s))
f"{s['symbol']} {s['direction'].upper()} "
f"R:R {s['rr_ratio']:.1f}:1, conf {(s.get('confidence_score') or 0):.0f}%"
)
else: else:
lines.append("No qualified setups today.") 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") open_trades = await paper_trade_service.list_trades(db, status="open")
if open_trades: if open_trades:
lines.append("") lines.append("")
lines.append(f"💼 <b>{len(open_trades)} open trade(s):</b>") lines.append(f"<b>Open trades</b> ({len(open_trades)})")
for t in open_trades: for t in open_trades:
entry = t["entry_price"] entry = t["entry_price"]
cur = t.get("current_price") cur = t.get("current_price")
@@ -451,7 +499,7 @@ async def _collect_digest(db: AsyncSession) -> tuple[str, str] | None:
if cur and entry: if cur and entry:
gain_pct = (cur - entry) / entry * 100.0 * sign gain_pct = (cur - entry) / entry * 100.0 * sign
gain_usd = (cur - entry) * t["shares"] * 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: else:
gain = "n/a" gain = "n/a"
ts = t.get("trailing_stop") 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 "") stop_txt = f"trail {ts:.2f}" + (f" ({dist:.1f}% away)" if dist is not None else "")
else: else:
stop_txt = f"stop {t['stop_loss']:.2f}" stop_txt = f"stop {t['stop_loss']:.2f}"
lines.append(f"{t['symbol']} {t['direction'].upper()} {gain} · {stop_txt}") lines.append(
f"💼 <b>{t['symbol']} {t['direction'].upper()} open</b> | "
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) 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) # 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: def _format_closed_trade(trade: PaperTrade, symbol: str) -> str:
sign = 1.0 if trade.direction == "long" else -1.0 sign = 1.0 if trade.direction == "long" else -1.0
entry = trade.entry_price entry = trade.entry_price
exit_price = trade.close_price if trade.close_price is not None else entry exit_price = trade.close_price if trade.close_price is not None else entry
per_share = (exit_price - entry) * sign per_share = (exit_price - entry) * sign
pnl_pct = (per_share / entry * 100.0) if entry else 0.0 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) risk = abs(entry - trade.stop_loss)
r_mult = (per_share / risk) if risk > 0 else None r_mult = (per_share / risk) if risk > 0 else None
win = per_share > 0 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 "" 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 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 "" 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" trade.close_reason or "", trade.close_reason or "closed"
) )
return ( 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]]: async def _collect_closed_trades(db: AsyncSession) -> list[ClosedTradeItem]:
"""One alert per auto-closed paper trade (trailing / stop / target). Manual """One alert item per auto-closed paper trade. Manual
closes are skipped — you already know about those. Dedup is by trade id.""" closes are skipped — you already know about those. Dedup is by trade id."""
cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS) cutoff = datetime.now(timezone.utc) - timedelta(hours=CLOSED_LOOKBACK_HOURS)
result = await db.execute( result = await db.execute(
@@ -504,11 +565,63 @@ async def _collect_closed_trades(db: AsyncSession) -> list[tuple[str, str]]:
PaperTrade.status == "closed", PaperTrade.status == "closed",
PaperTrade.closed_at.is_not(None), PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at > cutoff, 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()) .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"💼 <b>Paper trades closed</b> — {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) 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.""" """Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute( result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at) select(AlertLog.dedup_key, AlertLog.created_at)
@@ -549,7 +681,10 @@ async def _last_quadrant(db: AsyncSession) -> tuple[str | None, datetime | None]
.limit(1) .limit(1)
) )
row = result.first() 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]]: 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: if x is None or y is None:
return [] return []
prev, prev_time = await _last_quadrant(db) prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None: 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 [] return []
new_q = _classify_quadrant(x, y, prev) 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): if datetime.now(timezone.utc) - prev_time < timedelta(days=QUAD_COOLDOWN_DAYS):
return [] # genuine change, but inside the cooldown — stay quiet 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 = ( text = (
f"🧭 <b>Regime quadrant change</b>\n" f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\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] = [] signal_outgoing: list[AlertItem] = []
outgoing: list[AlertItem] = [] outgoing: list[AlertItem] = []
closed_outgoing: list[ClosedTradeItem] = []
qualified_inactive: list[str] = []
if cfg["qualified"]: if cfg["qualified"]:
for key, text in await _collect_qualified(db): previous_qualified_states = await _latest_qualified_states(db)
if not await _recently_alerted(db, "qualified", key): 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)) 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"]: if cfg["sr"]:
for key, text in await _collect_sr_proximity(db): 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)) outgoing.append((QUAD_TYPE, key, text))
if cfg["trade_closed"]: 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): 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 sent = 0
candidates = len(signal_outgoing) + len(outgoing) candidates = len(signal_outgoing) + len(outgoing) + len(closed_outgoing)
if signal_outgoing or 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: async with httpx.AsyncClient(timeout=15) as client:
for log_refs, text in _signal_bundle_messages(signal_outgoing): for log_refs, text in _signal_bundle_messages(signal_outgoing):
try: try:
await _send(client, cfg["token"], cfg["chat_id"], text) await _send(client, cfg["token"], cfg["chat_id"], text)
for alert_type, key in log_refs: for alert_type, key in log_refs:
_log_alert(db, alert_type, key) _log_alert(db, alert_type, key)
if alert_type == "qualified":
_log_alert(db, QUALIFIED_STATE_TYPE, key, value=QUALIFIED_ACTIVE)
sent += 1 sent += 1
except Exception: except Exception:
logger.exception("Failed to send signal alert bundle") 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: for alert_type, key, text in outgoing:
try: try:
await _send(client, cfg["token"], cfg["chat_id"], text) await _send(client, cfg["token"], cfg["chat_id"], text)
@@ -702,6 +874,9 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
except Exception: except Exception:
logger.exception("Failed to send alert %s", key) 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 await db.commit() # persist watermark seeds/advances and sent-logs
return {"status": "ok", "sent": sent, "candidates": candidates} return {"status": "ok", "sent": sent, "candidates": candidates}
+126 -3
View File
@@ -134,6 +134,15 @@ def test_format_qualified_includes_current_price_and_target_move():
assert "P(target) 63%" in text 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, async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int: levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1) 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 == [ assert rows == [
("qualified", "qualified:AAPL:long"), ("qualified", "qualified:AAPL:long"),
("qualified", "qualified:TSLA:short"), ("qualified", "qualified:TSLA:short"),
("qualified_state", "qualified:AAPL:long"),
("qualified_state", "qualified:TSLA:short"),
("sr_proximity", "sr:MSFT:resistance"), ("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, "🟢 <b>AAPL LONG</b> | 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, "🟢 <b>AAPL LONG</b> | 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, *, async def _add_closed_trade(session, symbol: str, reason: str, *,
close: float = 110.0, closed_hours_ago: float = 1.0) -> None: close: float = 110.0, closed_hours_ago: float = 1.0) -> None:
if await session.get(User, 1) is 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): 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, "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, "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 await _add_closed_trade(session, "OLD", "stop", close=95.0, closed_hours_ago=100) # too old → skip
out = await svc._collect_closed_trades(session) out = await svc._collect_closed_trades(session)
assert len(out) == 1 assert len(out) == 2
_, text = out[0] texts = [item[1] for item in out]
assert "WIN" in text and "trailing stop" in text 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(): def test_format_closed_trade_win():
@@ -298,3 +376,48 @@ def test_format_closed_trade_win():
assert "+10.0%" in txt assert "+10.0%" in txt
assert "+2.00R" in txt # +10% over a 5% stop assert "+2.00R" in txt # +10% over a 5% stop
assert "held 12d" in txt 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 "<b>Paper trades closed</b> — 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
+8 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations 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 # 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. # A point parked exactly on both dividers keeps whatever quadrant it had.
for q in ("1", "2", "3", "4"): for q in ("1", "2", "3", "4"):
assert _classify_quadrant(40, 60, prev=q) == q 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)