"""
Strategy signal notification service.
This module implements per-strategy notification channels based on the frontend schema:
notification_config = {
"channels": ["browser", "email", "phone", "telegram", "discord", "webhook"],
"targets": {
"email": "foo@example.com",
"phone": "+15551234567",
"telegram": "12345678 or @username",
"discord": "https://discord.com/api/webhooks/...",
"webhook": "https://example.com/webhook"
}
}
"""
from __future__ import annotations
import html
import hmac
import hashlib
import json
import os
import smtplib
import time
import traceback
from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple
import requests
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
def _as_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, (list, tuple)):
return [str(x).strip() for x in value if str(x).strip()]
s = str(value).strip()
if not s:
return []
# Allow comma-separated inputs.
if "," in s:
return [x.strip() for x in s.split(",") if x.strip()]
return [s]
def _safe_json(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
obj = json.loads(value)
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
return {}
def _signal_meta(signal_type: str) -> Dict[str, str]:
st = (signal_type or "").strip().lower()
action = "signal"
if st.startswith("open_"):
action = "open"
elif st.startswith("add_"):
action = "add"
elif st.startswith("close_"):
action = "close"
elif st.startswith("reduce_"):
action = "reduce"
side = "long" if "long" in st else ("short" if "short" in st else "")
return {"action": action, "side": side, "type": st}
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
try:
v = float(value or 0.0)
except Exception:
v = 0.0
s = f"{v:.{int(max_decimals)}f}"
s = s.rstrip("0").rstrip(".")
return s or "0"
class SignalNotifier:
"""
Notify signal events across channels.
通知配置说明:
- 用户在个人中心配置自己的通知设置(telegram_bot_token, telegram_chat_id, email 等)
- 创建策略/监控时,系统自动使用用户配置的通知目标
公共服务配置(管理员在系统设置中配置):
- SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_USE_TLS
(邮件服务,所有用户共用)
- TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER
(短信服务,所有用户共用)
可选的环境变量:
- SIGNAL_NOTIFY_TIMEOUT_SEC: HTTP timeout (default: 6)
"""
def __init__(self) -> None:
try:
self.timeout_sec = float(os.getenv("SIGNAL_NOTIFY_TIMEOUT_SEC") or "6")
except Exception:
self.timeout_sec = 6.0
# 公共 SMTP 配置(管理员在系统设置中配置)
self.smtp_host = (os.getenv("SMTP_HOST") or "").strip()
try:
self.smtp_port = int(os.getenv("SMTP_PORT") or "587")
except Exception:
self.smtp_port = 587
self.smtp_user = (os.getenv("SMTP_USER") or "").strip()
self.smtp_password = (os.getenv("SMTP_PASSWORD") or "").strip()
self.smtp_from = (os.getenv("SMTP_FROM") or self.smtp_user or "").strip()
self.smtp_use_tls = (os.getenv("SMTP_USE_TLS") or "true").strip().lower() == "true"
# Some providers require implicit SSL (port 465). Support it via SMTP_USE_SSL.
self.smtp_use_ssl = (os.getenv("SMTP_USE_SSL") or "").strip().lower() == "true"
self.twilio_sid = (os.getenv("TWILIO_ACCOUNT_SID") or "").strip()
self.twilio_token = (os.getenv("TWILIO_AUTH_TOKEN") or "").strip()
self.twilio_from = (os.getenv("TWILIO_FROM_NUMBER") or "").strip()
def notify_signal(
self,
*,
strategy_id: int,
strategy_name: str,
symbol: str,
signal_type: str,
price: float = 0.0,
stake_amount: float = 0.0,
direction: str = "long",
notification_config: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Dict[str, Any]]:
cfg = _safe_json(notification_config or {})
channels = _as_list(cfg.get("channels"))
if not channels:
channels = ["browser"]
targets = _safe_json(cfg.get("targets") or {})
extra = extra if isinstance(extra, dict) else {}
payload = self._build_payload(
strategy_id=strategy_id,
strategy_name=strategy_name,
symbol=symbol,
signal_type=signal_type,
price=price,
stake_amount=stake_amount,
direction=direction,
extra=extra,
)
rendered = self._render_messages(payload)
title = rendered.get("title") or ""
message_plain = rendered.get("plain") or ""
results: Dict[str, Dict[str, Any]] = {}
for ch in channels:
c = (ch or "").strip().lower()
if not c:
continue
try:
if c == "browser":
ok, err = self._notify_browser(
strategy_id=strategy_id,
symbol=symbol,
signal_type=signal_type,
channels=channels,
title=title,
message=message_plain,
payload=payload,
)
elif c == "webhook":
url = (targets.get("webhook") or "").strip()
ok, err = self._notify_webhook(
url=url,
payload=payload,
headers_override=(targets.get("webhook_headers") or targets.get("webhookHeaders") or None),
token_override=(targets.get("webhook_token") or targets.get("webhookToken") or None),
signing_secret_override=(
targets.get("webhook_signing_secret")
or targets.get("webhookSigningSecret")
or None
),
)
elif c == "discord":
url = (targets.get("discord") or "").strip()
ok, err = self._notify_discord(url=url, payload=payload, fallback_text=message_plain)
elif c == "telegram":
chat_id = (targets.get("telegram") or "").strip()
# User's token takes priority, then falls back to env TELEGRAM_BOT_TOKEN.
token_override = ""
try:
token_override = str(
targets.get("telegram_bot_token")
or targets.get("telegram_token")
or cfg.get("telegram_bot_token")
or cfg.get("telegram_token")
or ""
).strip()
except Exception:
token_override = ""
ok, err = self._notify_telegram(
chat_id=chat_id,
text=rendered.get("telegram_html") or message_plain,
token_override=token_override,
parse_mode="HTML",
)
elif c == "email":
to_email = (targets.get("email") or "").strip()
ok, err = self._notify_email(
to_email=to_email,
subject=title,
body_text=message_plain,
body_html=rendered.get("email_html") or "",
)
elif c == "phone":
to_phone = (targets.get("phone") or "").strip()
ok, err = self._notify_phone(to_phone=to_phone, body=message_plain)
else:
ok, err = False, f"unsupported_channel:{c}"
except Exception as e:
ok, err = False, str(e)
results[c] = {"ok": bool(ok), "error": (err or "")}
if not ok and c in ("webhook", "discord"):
# Keep logs high-signal and avoid leaking full URLs (webhook URLs contain secrets).
logger.info(
f"notify failed: channel={c} strategy_id={strategy_id} symbol={symbol} signal={signal_type} err={err}"
)
return results
def _build_payload(
self,
*,
strategy_id: int,
strategy_name: str,
symbol: str,
signal_type: str,
price: float,
stake_amount: float,
direction: str,
extra: Dict[str, Any],
) -> Dict[str, Any]:
now = int(time.time())
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
meta = _signal_meta(signal_type)
pending_id = None
try:
pending_id = int((extra or {}).get("pending_order_id") or 0) or None
except Exception:
pending_id = None
return {
"event": "qd.signal",
"version": 1,
"timestamp": now,
"timestamp_iso": iso,
"strategy": {
"id": int(strategy_id),
"name": str(strategy_name or ""),
},
"instrument": {
"symbol": str(symbol or ""),
},
"signal": {
"type": meta.get("type") or str(signal_type or ""),
"action": meta.get("action") or "signal",
"side": meta.get("side") or "",
"direction": str(direction or ""),
},
"order": {
"ref_price": float(price or 0.0),
"stake_amount": float(stake_amount or 0.0),
},
"trace": {
"pending_order_id": pending_id,
"mode": str((extra or {}).get("mode") or ""),
},
"extra": extra or {},
}
def _render_messages(self, payload: Dict[str, Any]) -> Dict[str, str]:
strategy = (payload or {}).get("strategy") or {}
instrument = (payload or {}).get("instrument") or {}
sig = (payload or {}).get("signal") or {}
order = (payload or {}).get("order") or {}
trace = (payload or {}).get("trace") or {}
symbol = str(instrument.get("symbol") or "")
stype = str(sig.get("type") or "")
action = str(sig.get("action") or "").upper()
side = str(sig.get("side") or "").upper()
title = f"QD Signal | {symbol} | {action} {side}".strip()
price_s = _fmt_float(order.get("ref_price") or 0.0, max_decimals=10)
stake_s = _fmt_float(order.get("stake_amount") or 0.0, max_decimals=12)
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
mode = str(trace.get("mode") or "")
ts_iso = str(payload.get("timestamp_iso") or "")
plain_lines = [
"QuantDinger Signal",
f"Strategy: {strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})",
f"Symbol: {symbol}",
f"Signal: {stype}",
f"Price: {price_s}",
f"Stake: {stake_s}",
]
if pending_id:
plain_lines.append(f"PendingOrder: {pending_id}")
if mode:
plain_lines.append(f"Mode: {mode}")
if ts_iso:
plain_lines.append(f"Time(UTC): {ts_iso}")
# Telegram (HTML) message. Escape all dynamic values.
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
telegram_lines = [
"QuantDinger Signal",
"",
f"Strategy: {html.escape(str(t_strategy))}",
f"Symbol: {html.escape(symbol)}",
f"Signal: {html.escape(stype)}",
f"Price: {html.escape(price_s)}",
f"Stake: {html.escape(stake_s)}",
]
if pending_id:
telegram_lines.append(f"PendingOrder: {pending_id}")
if mode:
telegram_lines.append(f"Mode: {html.escape(mode)}")
if ts_iso:
telegram_lines.append(f"Time (UTC): {html.escape(ts_iso)}")
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
# Email (HTML) message. Keep inline CSS for maximum compatibility.
email_html = self._build_email_html(
title_text="QuantDinger Signal",
strategy_text=t_strategy,
symbol=symbol,
signal_type=stype,
price_text=price_s,
stake_text=stake_s,
pending_id=pending_id or None,
mode_text=mode or "",
timestamp_iso=ts_iso or "",
)
return {
"title": title,
"plain": "\n".join(plain_lines),
"telegram_html": telegram_html,
"email_html": email_html,
}
def _build_email_html(
self,
*,
title_text: str,
strategy_text: str,
symbol: str,
signal_type: str,
price_text: str,
stake_text: str,
pending_id: Optional[int],
mode_text: str,
timestamp_iso: str,
) -> str:
def esc(s: Any) -> str:
return html.escape(str(s or ""))
rows: List[Tuple[str, str]] = [
("Strategy", strategy_text),
("Symbol", symbol),
("Signal", signal_type),
("Price", price_text),
("Stake", stake_text),
]
if pending_id:
rows.append(("PendingOrder", str(int(pending_id))))
if mode_text:
rows.append(("Mode", mode_text))
if timestamp_iso:
rows.append(("Time (UTC)", timestamp_iso))
tr_html = "\n".join(
[
(
"