Files
signal-platform/tests/unit/test_alert_service.py
T
dennisthiessen 1566b84379
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 33s
feat: trailing-stop auto-exit for paper trades + close/digest alerts
Applies the backtest-validated trailing stop to live paper trading, and surfaces
it transparently.

Exit (A):
- New paper-trade exit policy (paper_exit_mode=trailing, paper_trailing_pct=12),
  tunable in Admin → Paper-Trade Exit. resolve_open_trades runs a trailing stop
  (initial stop as floor, ratchets up from the peak; target ignored — the
  validated rule) and records close_reason (trailing|stop|target|manual; +migration
  013).
- list_trades enriches open trades with the live trailing-stop level + distance %.
  Open Trades panel shows the active tactic and a Trail Stop column.

Alerts (B):
- Daily digest now lists open trades with unrealized gain, trailing stop, and how
  far away it is.
- New "trade closed" alert: one summary per auto-close (trailing/target/stop, not
  manual) — direction, reason, days held, P&L abs+%/R — covering wins AND
  stop-loss losses. Deduped by trade id; toggle in Admin alerts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:48:05 +02:00

223 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the Telegram alert service: config, dedup, watermark, dispatch."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from app.models.alert import AlertLog
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.score import CompositeScore
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker
from app.models.user import User
from app.models.watchlist import WatchlistEntry
from app.services import alert_service as svc
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def test_config_defaults(session):
cfg = await svc.get_alert_config(session)
assert cfg["enabled"] is False
assert cfg["bot_token_configured"] is False
assert cfg["bot_token_source"] == "none"
# trigger toggles default on
assert cfg["qualified_enabled"] is True
assert cfg["digest_enabled"] is True
async def test_update_config_token_write_only(session):
cfg = await svc.update_alert_config(
session, enabled=True, bot_token="secret123", telegram_chat_id="42",
)
assert cfg["enabled"] is True
assert cfg["telegram_chat_id"] == "42"
assert cfg["bot_token_configured"] is True
assert cfg["bot_token_source"] == "database"
# raw token never surfaced
assert "bot_token" not in cfg
assert "secret123" not in str(cfg)
async def test_update_empty_token_keeps_existing(session):
await svc.update_alert_config(session, bot_token="keepme", telegram_chat_id="1")
cfg = await svc.update_alert_config(session, bot_token="") # empty → keep
assert cfg["bot_token_configured"] is True
async def test_recently_alerted_cooldown(session):
assert await svc._recently_alerted(session, "qualified", "AAA:long") is False
svc._log_alert(session, "qualified", "AAA:long")
await session.commit()
assert await svc._recently_alerted(session, "qualified", "AAA:long") is True
# different key is independent
assert await svc._recently_alerted(session, "qualified", "BBB:short") is False
async def test_recently_alerted_expires(session):
old = datetime.now(timezone.utc) - timedelta(hours=100)
session.add(AlertLog(alert_type="qualified", dedup_key="old", created_at=old))
await session.commit()
# default cooldown 72h → the 100h-old entry no longer suppresses
assert await svc._recently_alerted(session, "qualified", "old") is False
async def _seed_watchlisted_ticker(session, symbol: str, score: float) -> None:
user = await session.get(User, 1)
if user is None:
user = User(id=1, username="u", password_hash="x", role="user", has_access=True)
session.add(user)
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
session.add(WatchlistEntry(user_id=1, ticker_id=t.id, entry_type="manual",
added_at=datetime.now(timezone.utc)))
session.add(CompositeScore(ticker_id=t.id, score=score, is_stale=False,
weights_json="{}", computed_at=datetime.now(timezone.utc)))
await session.commit()
async def test_score_drop_seeds_then_alerts(session):
await _seed_watchlisted_ticker(session, "AAA", 80.0)
# First pass seeds the watermark, no alert
msgs = await svc._collect_score_drops(session)
await session.commit()
assert msgs == []
assert await svc._watermark(session, "AAA") == 80.0
# Drop the composite well past the threshold
row = (await session.execute(
CompositeScore.__table__.update().values(score=60.0)
))
await session.commit()
assert row.rowcount == 1
msgs = await svc._collect_score_drops(session)
await session.commit()
assert len(msgs) == 1
key, text = msgs[0]
assert key == "scoredrop:AAA"
assert "AAA" in text
# rebaselined to the new (lower) level
assert await svc._watermark(session, "AAA") == 60.0
async def _add_ticker(session, symbol: str, *, watchlisted: bool, close: float,
levels: list[tuple[float, str, int]]) -> int:
user = await session.get(User, 1)
if user is None:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
if watchlisted:
session.add(WatchlistEntry(user_id=1, ticker_id=t.id, entry_type="manual",
added_at=datetime.now(timezone.utc)))
session.add(OHLCVRecord(ticker_id=t.id, date=date.today(),
open=close, high=close, low=close, close=close, volume=1))
for price, kind, strength in levels:
session.add(SRLevel(ticker_id=t.id, price_level=price, type=kind,
strength=strength, detection_method="test"))
await session.commit()
return t.id
async def test_sr_proximity_merges_close_levels_to_one_alert(session):
# Two resistance levels ~1% apart just above price → one merged zone, one alert
await _add_ticker(session, "CVX", watchlisted=True, close=182.0,
levels=[(183.0, "resistance", 60), (185.0, "resistance", 60)])
msgs = await svc._collect_sr_proximity(session)
cvx = [m for m in msgs if "CVX" in m[1]]
assert len(cvx) == 1
assert "183.00185.00" in cvx[0][1]
async def test_sr_proximity_skips_non_watchlist_unqualified(session):
await _add_ticker(session, "ZZZ", watchlisted=False, close=182.0,
levels=[(183.0, "resistance", 80)])
msgs = await svc._collect_sr_proximity(session)
assert not any("ZZZ" in m[1] for m in msgs)
async def test_sr_proximity_only_nearest_zone(session):
# A near resistance (~1%) and a far one (~10%); only the near one alerts
await _add_ticker(session, "AAA", watchlisted=True, close=100.0,
levels=[(101.0, "resistance", 70), (110.0, "resistance", 90)])
msgs = await svc._collect_sr_proximity(session)
aaa = [m for m in msgs if "AAA" in m[1]]
assert len(aaa) == 1
assert "101.00" in aaa[0][1]
async def test_dispatch_disabled_short_circuits(session):
res = await svc.dispatch_alerts(session)
assert res["status"] == "disabled"
async def test_dispatch_no_credentials(session):
await svc.update_alert_config(session, enabled=True) # enabled but no token/chat
res = await svc.dispatch_alerts(session)
assert res["status"] == "no_credentials"
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:
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
await session.flush()
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
now = datetime.now(timezone.utc)
session.add(PaperTrade(
user_id=1, ticker_id=t.id, direction="long",
entry_price=100.0, shares=10.0, stop_loss=95.0, target=120.0,
status="closed", opened_at=now - timedelta(days=5),
close_price=close, closed_at=now - timedelta(hours=closed_hours_ago),
close_reason=reason,
))
await session.commit()
async def test_config_includes_trade_closed_toggle(session):
assert (await svc.get_alert_config(session))["trade_closed_enabled"] is True
cfg = await svc.update_alert_config(session, trade_closed_enabled=False)
assert cfg["trade_closed_enabled"] is False
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, "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
def test_format_closed_trade_win():
now = datetime.now(timezone.utc)
trade = SimpleNamespace(
direction="long", entry_price=100.0, close_price=110.0, shares=10.0,
stop_loss=95.0, opened_at=now - timedelta(days=12), closed_at=now,
close_reason="trailing",
)
txt = svc._format_closed_trade(trade, "AAA")
assert "✅" in txt # win
assert "+10.0%" in txt
assert "+2.00R" in txt # +10% over a 5% stop
assert "held 12d" in txt