Funk-lab/tools/finlab/finlab/watcher.py
alkatrazz a1abd36bf7 feat(finlab): alertes de prix par email (watcher sidecar, boucle 5 min)
Permet de configurer des alertes « préviens-moi par mail quand NVDA atteint 200$ »
depuis la Console IA. Un sidecar surveille les cours en continu.

- finlab/watcher.py : boucle toutes les 5 min, lit price_alerts.yaml (workspace),
  évalue les seuils above/below, envoie un email au franchissement. Anti-spam
  (1 mail/seuil, état .watcher-state.json, ré-armé en ré-éditant). Modes --once / --test
- Email via le relais postfix du lab (storage-01:25, SMTP_HOST) qui réécrit
  l'expéditeur en Gmail — AUCUN secret (pods k8s dans mynetworks)
- price_alerts.yaml : config seedée dans le workspace (email_to + rules), éditée
  par la Console IA sur demande
- deployment : conteneur sidecar `watcher` (boucle 5 min, ressources minimes)
- Dockerfile : seed price_alerts.yaml ; persona console + admin/ia/finlab.md documentés

Logique testée en local (SMTP mocké) : franchissement → envoi, anti-spam au 2e run,
seuil non atteint → rien.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:27:01 +02:00

141 lines
4.5 KiB
Python

"""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()