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