mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:24:43 +02:00
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:
parent
b032820f23
commit
a1abd36bf7
6 changed files with 207 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
141
tools/finlab/finlab/watcher.py
Normal file
141
tools/finlab/finlab/watcher.py
Normal 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 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()
|
||||
15
tools/finlab/price_alerts.yaml
Normal file
15
tools/finlab/price_alerts.yaml
Normal 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" }
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue