"""Watcher d'alertes de prix → email. Boucle toutes les WATCH_INTERVAL secondes : lit `price_alerts.yaml` (workspace), récupère le cours live de chaque ticker, et envoie un email quand un seuil est franchi. L'email part vers le **relais postfix du lab** (storage-01:25) qui réécrit l'expéditeur en Gmail — aucun secret ici (les pods k8s sont dans `mynetworks`). État dans `.watcher-state.json` (workspace) : un seuil franchi ne renvoie qu'**un** mail (pas de spam). Pour ré-armer une règle, on la ré-édite (ou on vide l'état). Usage : python -m finlab.watcher # boucle (conteneur sidecar) python -m finlab.watcher --once # une seule évaluation puis sort python -m finlab.watcher --test x@y # envoie un email de test et sort """ from __future__ import annotations import json import os import smtplib import sys import time from email.message import EmailMessage from pathlib import Path import yaml from . import data HOME = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent) ALERTS_FILE = HOME / "price_alerts.yaml" STATE_FILE = HOME / ".watcher-state.json" INTERVAL = int(os.environ.get("WATCH_INTERVAL", "300")) SMTP_HOST = os.environ.get("SMTP_HOST", "192.168.10.1") SMTP_PORT = int(os.environ.get("SMTP_PORT", "25")) SENDER = os.environ.get("SMTP_SENDER", "finlab@funk.lab") # réécrit par le relais postfix def _log(msg: str) -> None: print(f"[watcher] {msg}", flush=True) def _load_state() -> set[str]: try: return set(json.load(open(STATE_FILE))) except Exception: return set() def _save_state(s: set[str]) -> None: try: json.dump(sorted(s), open(STATE_FILE, "w")) except Exception as e: _log(f"état non sauvegardé: {e}") def send_email(to: str, subject: str, body: str) -> None: msg = EmailMessage() msg["From"] = SENDER msg["To"] = to msg["Subject"] = subject msg.set_content(body) with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s: s.send_message(msg) def _rule_key(r: dict) -> str: return f"{r.get('ticker')}|{r.get('above')}|{r.get('below')}" def check_once() -> None: """Évalue toutes les règles une fois ; envoie les emails dus.""" if not ALERTS_FILE.exists(): return cfg = yaml.safe_load(open(ALERTS_FILE, encoding="utf-8")) or {} rules = cfg.get("rules") or [] to = cfg.get("email_to") if not to or not rules: return state = _load_state() valid_keys = {_rule_key(r) for r in rules} fired = 0 for r in rules: key = _rule_key(r) if key in state: continue try: price, cur = data.last_price(r["ticker"], fresh=True) except Exception as e: _log(f"{r.get('ticker')}: cours indisponible ({e})") continue hit = None if r.get("above") is not None and price >= r["above"]: hit = f"≥ {r['above']}" elif r.get("below") is not None and price <= r["below"]: hit = f"≤ {r['below']}" if not hit: continue note = f" — {r['note']}" if r.get("note") else "" try: send_email( to, f"🔔 FinLab : {r['ticker']} à {price:.2f} {cur} (seuil {hit}){note}", f"{r['ticker']} a atteint {price:.2f} {cur} (seuil {hit}).{note}\n\n" f"Alerte de prix configurée dans price_alerts.yaml.\n— FinLab", ) state.add(key) fired += 1 _log(f"ALERTE envoyée: {r['ticker']} {price:.2f} {cur} ({hit}) → {to}") except Exception as e: _log(f"échec envoi {r.get('ticker')}: {e}") # purge les états de règles supprimées (ré-armement propre) state &= valid_keys _save_state(state) if fired: _log(f"{fired} alerte(s) envoyée(s).") def main() -> None: args = sys.argv[1:] if args and args[0] == "--test": to = args[1] if len(args) > 1 else os.environ.get("ALERT_EMAIL", "") send_email(to, "✅ FinLab — email de test", "Le relais email fonctionne. — FinLab") _log(f"email de test envoyé à {to}") return if args and args[0] == "--once": check_once() return _log(f"démarrage — intervalle {INTERVAL}s, relais {SMTP_HOST}:{SMTP_PORT}, règles: {ALERTS_FILE}") while True: try: check_once() except Exception as e: _log(f"erreur boucle: {e}") time.sleep(INTERVAL) if __name__ == "__main__": main()