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>
This commit is contained in:
alkatrazz 2026-06-30 15:27:01 +02:00
parent b032820f23
commit a1abd36bf7
6 changed files with 207 additions and 1 deletions

View file

@ -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 <email>` / `--once`.
## Accès programmatique
- **MCP dans le repo lab** : `.mcp.json` racine pointe `tools/finlab/.venv` → outils finlab dans

View file

@ -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:

View file

@ -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/

View file

@ -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 -armer une règle, on la -é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()

View file

@ -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" }

View file

@ -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 <email>` (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.