Refine Telegram alert behavior
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user