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
+126 -3
View File
@@ -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, "🟢 <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, *,
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 "<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 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)