From a1abd36bf7d057a6b7a17038dc56ea7ef108cb9b Mon Sep 17 00:00:00 2001 From: alkatrazz Date: Tue, 30 Jun 2026 15:27:01 +0200 Subject: [PATCH] feat(finlab): alertes de prix par email (watcher sidecar, boucle 5 min) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/ia/finlab.md | 11 +++ k8s/apps/finlab/deployment.yaml | 19 +++++ tools/finlab/Dockerfile | 2 +- tools/finlab/finlab/watcher.py | 141 +++++++++++++++++++++++++++++++ tools/finlab/price_alerts.yaml | 15 ++++ tools/finlab/workspace/CLAUDE.md | 20 +++++ 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 tools/finlab/finlab/watcher.py create mode 100644 tools/finlab/price_alerts.yaml diff --git a/admin/ia/finlab.md b/admin/ia/finlab.md index adfad81..2023b34 100644 --- a/admin/ia/finlab.md +++ b/admin/ia/finlab.md @@ -30,6 +30,7 @@ trader concentré tech/semis, philosophie R:R/concentration/discipline). | initContainer `seed` | Peuple le workspace (`cp -rn`), robuste au piège NFS (fsGroup non honoré → recrée le dossier s'il est root/non-inscriptible). Tourne avant les conteneurs | | container `dashboard` | `uvicorn dashboard.server:app` :8800 — UI + API JSON finlab | | container `console` | `ttyd -b /console` :7681 → `claude` dans le workspace | +| container `watcher` | `python -m finlab.watcher` — alertes de prix par email, boucle **5 min** | | Cerveau (console) | **Abonnement Claude Code** (login OAuth persisté dans `~/.claude` sur le PVC) — pas de clé API | | Code vs data | Code (`finlab` + `dashboard`) dans l'image `/opt/app` (maj avec l'image) ; configs éditables via **`FINLAB_HOME=/home/finlab/workspace`** (PVC) | | PVC `finlab-data` | 5Gi NFS → `/home/finlab` : login + workspace (configs + `.cache`) | @@ -57,6 +58,16 @@ Checklist détaillée : `k8s/apps/finlab/README.md`. 1. Secrets manuels dans `ai` : `ghcr-pull` (pull GHCR) + `finlab-auth` (htpasswd basicAuth). 2. Ouvrir `finance.lab.local` → `claude` → `/login` (abonnement, persisté sur le PVC). +## Alertes de prix par email + +Sidecar **`watcher`** (`finlab/watcher.py`) : boucle **toutes les 5 min**, lit +**`price_alerts.yaml`** (workspace), et envoie un email quand un seuil (`above`/`below`) est +franchi. Envoi via le **relais postfix du lab** (`storage-01:25`, `SMTP_HOST`) qui réécrit +l'expéditeur en Gmail — **aucun secret** (les pods k8s sont dans `mynetworks`, cf. +`admin/infra/email.md`). Anti-spam : un seuil ne notifie qu'**une** fois (état `.watcher-state.json`), +ré-armé en ré-éditant la règle. La **Console IA édite `price_alerts.yaml`** sur demande +(« mets un mail quand NVDA atteint 200$ »). Test : `python -m finlab.watcher --test ` / `--once`. + ## Accès programmatique - **MCP dans le repo lab** : `.mcp.json` racine pointe `tools/finlab/.venv` → outils finlab dans diff --git a/k8s/apps/finlab/deployment.yaml b/k8s/apps/finlab/deployment.yaml index ae4331b..9d70789 100644 --- a/k8s/apps/finlab/deployment.yaml +++ b/k8s/apps/finlab/deployment.yaml @@ -99,6 +99,25 @@ spec: volumeMounts: - name: home mountPath: /home/finlab + # ── WATCHER : alertes de prix par email (boucle 5 min → relais postfix storage-01) ── + - name: watcher + image: ghcr.io/alkatrazz24/funk-finlab:sha-73e2ad3 + command: ["/opt/venv/bin/python", "-m", "finlab.watcher"] + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + env: + # Relais SMTP du lab (storage-01) — accepte les pods k8s (mynetworks), pas de secret. + - { name: SMTP_HOST, value: "192.168.10.1" } + - { name: SMTP_PORT, value: "25" } + - { name: WATCH_INTERVAL, value: "300" } + resources: + requests: { cpu: 25m, memory: 192Mi } + limits: { cpu: 250m, memory: 384Mi } + volumeMounts: + - name: home + mountPath: /home/finlab volumes: - name: home persistentVolumeClaim: diff --git a/tools/finlab/Dockerfile b/tools/finlab/Dockerfile index 31fe1aa..c0ef5a4 100644 --- a/tools/finlab/Dockerfile +++ b/tools/finlab/Dockerfile @@ -32,7 +32,7 @@ COPY webapp/ /opt/app/webapp/ # « Seed » du workspace : UNIQUEMENT ce que l'utilisateur édite/possède (pas de code). # Configs finlab + persona Claude + branchement MCP + autorisations. Copié dans le PVC au # 1er boot par l'initContainer (cp -rn no-clobber → edits préservés ensuite). -COPY portfolio.yaml watchlists.yaml alerts.yaml /opt/seed/ +COPY portfolio.yaml watchlists.yaml alerts.yaml price_alerts.yaml /opt/seed/ COPY workspace/CLAUDE.md /opt/seed/CLAUDE.md COPY workspace/.mcp.json /opt/seed/.mcp.json COPY workspace/.claude/ /opt/seed/.claude/ diff --git a/tools/finlab/finlab/watcher.py b/tools/finlab/finlab/watcher.py new file mode 100644 index 0000000..8c17658 --- /dev/null +++ b/tools/finlab/finlab/watcher.py @@ -0,0 +1,141 @@ +"""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() diff --git a/tools/finlab/price_alerts.yaml b/tools/finlab/price_alerts.yaml new file mode 100644 index 0000000..5e003be --- /dev/null +++ b/tools/finlab/price_alerts.yaml @@ -0,0 +1,15 @@ +# Alertes de prix par email — évaluées toutes les 5 min par le watcher finlab. +# Édité par la Console IA sur demande (« mets un mail quand NVDA atteint 200$ »). +# +# email_to : destinataire des alertes. +# rules : liste de règles. Chacune : +# ticker : symbole Yahoo Finance +# above : déclenche quand le cours est >= cette valeur (devise native du titre) +# below : déclenche quand le cours est <= cette valeur +# note : texte libre ajouté au mail (optionnel) +# Un seuil franchi n'envoie qu'UN mail (anti-spam). Ré-éditer une règle la ré-arme. +email_to: aliyesilkaya93@gmail.com + +rules: [] + # - { ticker: NVDA, above: 200, note: "objectif de vente" } + # - { ticker: MU, below: 100, note: "zone d'achat" } diff --git a/tools/finlab/workspace/CLAUDE.md b/tools/finlab/workspace/CLAUDE.md index 8875053..1ee7769 100644 --- a/tools/finlab/workspace/CLAUDE.md +++ b/tools/finlab/workspace/CLAUDE.md @@ -80,6 +80,26 @@ relevé (« importe le relevé imports/… et mets à jour mon portefeuille ») > C'est `portfolio.yaml` du **workspace** (PVC) qui fait foi au runtime — c'est lui que tu édites. +## Alertes de prix par email + +Un **watcher** (sidecar) évalue **`price_alerts.yaml`** (workspace) **toutes les 5 min** et envoie +un email via le relais du lab quand un seuil est franchi (un seul mail par seuil). Quand on te +demande une alerte (« mets un mail quand NVDA atteint 200$ », « préviens-moi si Micron passe +sous 100 ») : + +1. **Vérifie le ticker** Yahoo et son cours actuel (outil `price`) — confirme le bon symbole. +2. **Édite `price_alerts.yaml`** : ajoute une règle sous `rules:` — + `{ ticker: NVDA, above: 200, note: "..." }` (`above` = ≥, `below` = ≤ ; devise native du titre). + Garde `email_to` (par défaut l'email de l'utilisateur). +3. **Confirme** la règle posée et rappelle : vérifié toutes les **5 min**, **un** mail par + franchissement (ré-édite pour ré-armer). +4. Pour **tester** l'envoi : `python -m finlab.watcher --test ` (mail immédiat) ou + `python -m finlab.watcher --once` (évalue tout de suite). Pour **lister/retirer** une alerte, + édite simplement `price_alerts.yaml`. + +> Seuils en **devise native** du titre (NVDA en USD, AI.PA en EUR). C'est de la surveillance — +> aucun ordre n'est passé. + ## Méthode de réponse 1. Question chiffrée → appelle l'outil pertinent **avant** de conclure.