diff --git a/.github/workflows/build-finlab.yml b/.github/workflows/build-finlab.yml new file mode 100644 index 0000000..6d378b1 --- /dev/null +++ b/.github/workflows/build-finlab.yml @@ -0,0 +1,64 @@ +name: build-finlab + +# Construit l'image de la console finance (Claude Code web + toolkit finlab), la pousse sur +# notre GHCR, et (sur main) bumpe le manifest k8s. Contrairement à OpenAlice, c'est NOTRE +# code (tools/finlab/) : pas de clone amont, pas de patch Dockerfile. +on: + push: + branches: ["main", "feat/**", "claude/**"] + paths: + - "tools/finlab/**" + - ".github/workflows/build-finlab.yml" + workflow_dispatch: + +env: + IMAGE: ghcr.io/alkatrazz24/funk-finlab + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write # pour committer le bump du manifest sur main + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Calcule le tag (sha court) + la liste des tags + id: vars + run: | + TAG="sha-$(git rev-parse --short HEAD)" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=${IMAGE}:${TAG},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:${TAG}" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: tools/finlab + push: true + tags: ${{ steps.vars.outputs.tags }} + + - name: Met à jour le manifest avec le tag (main uniquement) + if: github.ref == 'refs/heads/main' + run: | + sed -i "s|image: ${IMAGE}:.*|image: ${IMAGE}:${{ steps.vars.outputs.tag }}|" \ + k8s/apps/finlab/deployment.yaml + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add k8s/apps/finlab/deployment.yaml + if ! git diff --staged --quiet; then + git commit -m "ci(finlab): image → ${{ steps.vars.outputs.tag }} [skip ci]" + git pull --rebase origin main + git push + fi diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..197bd30 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "finlab": { + "command": "/mnt/nvme0n1/nvme0n1p3/Projets/lab/tools/finlab/.venv/bin/python", + "args": ["-m", "finlab.mcp_server"], + "env": { + "PYTHONPATH": "/mnt/nvme0n1/nvme0n1p3/Projets/lab/tools/finlab" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 7e95a5b..96b5d04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,7 @@ k8s/ │ ├── searxng.yaml │ ├── ghostfolio.yaml │ ├── openalice.yaml +│ ├── finlab.yaml │ ├── monitoring.yaml │ └── nfs-provisioner.yaml ├── apps/ # Manifests raw k8s par application @@ -171,7 +172,8 @@ k8s/ │ ├── stt/ # namespace: ai — STT-server (assistant vocal Asa, stt.lab.local) │ ├── searxng/ # namespace: ai — méta-moteur recherche web (outil web_search d'Asa) │ ├── ghostfolio/ # namespace: ai — suivi de portefeuille (ghostfolio.lab.local) -│ ├── openalice/ # namespace: ai — agent IA financier (openalice.lab.local, back-end finance d'Asa) +│ ├── openalice/ # namespace: ai — agent IA financier (openalice.lab.local, back-end finance d'Asa) — ⏸️ SUSPENDU +│ ├── finlab/ # namespace: ai — console Claude Code finance (finance.lab.local) + toolkit d'analyse de marché │ └── {external-services}/ # ExternalName/Endpoints pointant vers storage-01 ou gpu-01 └── infra/ # Helm releases (HelmRelease + values.yaml) ├── monitoring/ # kube-prometheus-stack 85.0.2 @@ -186,7 +188,7 @@ k8s/ 2. Créer `k8s/apps-of-apps/apps/.yaml` (ArgoCD Application CRD) — copier `n8n.yaml` comme modèle 3. `git commit` + `git push` → ArgoCD prend en charge automatiquement -Namespaces actifs : `argocd`, `infra` (Traefik, MetalLB, NFS-provisioner, registry in-cluster), `monitoring` (Prometheus/Grafana), `ai` (n8n, open-webui, stt, searxng, ghostfolio, openalice), `sacrifice` (jeu FPS — déployé hors de ce repo et hors ArgoCD, cf. `admin/ops/etat-cluster.md`). +Namespaces actifs : `argocd`, `infra` (Traefik, MetalLB, NFS-provisioner, registry in-cluster), `monitoring` (Prometheus/Grafana), `ai` (n8n, open-webui, stt, searxng, ghostfolio, openalice, finlab), `sacrifice` (jeu FPS — déployé hors de ce repo et hors ArgoCD, cf. `admin/ops/etat-cluster.md`). > Les services du namespace `ai` sont exposés via des **IngressRoute Traefik** (CRD), pas des `Ingress` standards. diff --git a/admin/ia/finlab.md b/admin/ia/finlab.md new file mode 100644 index 0000000..528ac22 --- /dev/null +++ b/admin/ia/finlab.md @@ -0,0 +1,58 @@ +# FinLab — console Claude Code finance + toolkit d'analyse de marché + +**Quoi** : boîte à outils d'analyse de marché maison (`finlab`, données Yahoo Finance gratuites) ++ une **console Claude Code web** spécialisée finance, déployée in-cluster. Remplace l'approche +OpenAlice abandonnée (cf. `admin/ia/openalice.md`) par quelque chose de plus simple et plus +puissant : **ton vrai Claude Code** (sur ton abonnement, pas l'API au compteur), agentique, avec +les outils finlab branchés. + +**Où** : +- Source : `tools/finlab/` (code finlab + Dockerfile + persona `workspace/CLAUDE.md`). +- Image : CI `.github/workflows/build-finlab.yml` → `ghcr.io/alkatrazz24/funk-finlab`. +- Déploiement : ns `ai`, `finance.lab.local`, manifests `k8s/apps/finlab/`, app ArgoCD + `k8s/apps-of-apps/apps/finlab.yaml`. +- MCP local : `.mcp.json` (racine repo lab) → outils finlab dans les sessions Claude Code du lab. + +**Cadre** : analyse/aide à la décision uniquement, **aucun ordre réel** (seul le paper trading +Alpaca fictif est exécutable). Persona dans `tools/finlab/workspace/CLAUDE.md` (profil swing +trader concentré tech/semis, philosophie R:R/concentration/discipline). + +## Architecture + +| Composant | Détail | +|---|---| +| Console | `ttyd` (terminal web :7681) → CLI `claude` (Claude Code) dans `/home/finlab/workspace` | +| Cerveau | **Abonnement Claude Code** (login OAuth persisté dans `~/.claude` sur le PVC) — pas de clé API facturée | +| Outils | finlab en **MCP** (`finlab.mcp_server`, stdio) + CLI `python -m finlab.cli ...` | +| PVC `finlab-data` | 5Gi NFS → `/home/finlab` : login + workspace (configs éditables + `.cache`) | +| Auth | basicAuth Traefik (`finlab-auth`, middleware) — un shell web ne s'expose pas nu | + +Le workspace est **seedé depuis l'image au 1er boot** (`cp -rn` no-clobber) puis vit sur le PVC : +edits (portefeuille) et login persistent ; rebuild d'image = seuls les fichiers manquants ajoutés. + +## Outils finlab + +`digest` (vue d'ensemble compacte, à privilégier), `portfolio_summary`, `opportunities`/`scan`, +`technical_analysis`/`tech` (RSI/MACD/MM50-200/Bollinger/ATR), `fundamentals` (PER/PEG/ROE/dette/ +cible), `trade_plan`/`plan` (entrée/stop ATR/objectifs/taille/**R:R**), `alerts_today`, `price`, +plus paper trading (`paper_account`/`paper_positions`/`paper_order`, clés Alpaca optionnelles). + +Configs (workspace, éditables) : `portfolio.yaml` (positions Revolut), `watchlists.yaml` (thèmes +chaîne IA/datacenter), `alerts.yaml` (règles). + +## Onboarding (premier boot) + +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). + +## Accès programmatique + +- **MCP dans le repo lab** : `.mcp.json` racine pointe `tools/finlab/.venv` → outils finlab dans + les sessions Claude Code du lab (approbation MCP au 1er lancement). +- **Headless** : `kubectl -n ai exec deploy/finlab -- python -m finlab.cli digest`. + +## Liens + +- Source historique : ex-projet `Projets/Finance` (rapatrié ici). +- Approche précédente abandonnée : `admin/ia/openalice.md`. diff --git a/k8s/apps-of-apps/apps/finlab.yaml b/k8s/apps-of-apps/apps/finlab.yaml new file mode 100644 index 0000000..28012c2 --- /dev/null +++ b/k8s/apps-of-apps/apps/finlab.yaml @@ -0,0 +1,24 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: finlab + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: git@github.com:Alkatrazz24/Funk-lab.git + targetRevision: main + path: k8s/apps/finlab + directory: + recurse: true + destination: + server: https://kubernetes.default.svc + namespace: ai + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/k8s/apps/finlab/README.md b/k8s/apps/finlab/README.md new file mode 100644 index 0000000..c3953f0 --- /dev/null +++ b/k8s/apps/finlab/README.md @@ -0,0 +1,68 @@ +# FinLab — console Claude Code finance (in-cluster) + +Une **vraie session Claude Code** (sur l'**abonnement** d'Alkatrazz, login persisté) servie +dans un **terminal web** (`ttyd`), spécialisée analyse de marché via la persona `CLAUDE.md` et +la boîte à outils **finlab** (Yahoo Finance, exposée en **MCP**). Esprit OpenAlice, mais c'est +Claude Code + le toolkit maison — **aucune facturation API**, **aucun broker**, analyse seule. + +> ⚠️ La console expose un **shell web** → protégée par **basicAuth Traefik**. Analyse et aide à +> la décision uniquement : **aucun ordre réel** (seul le paper trading Alpaca fictif existe). + +## Image + +Pas d'image publique : la CI (`.github/workflows/build-finlab.yml`) build depuis +`tools/finlab/` (notre code) → `ghcr.io/alkatrazz24/funk-finlab`, tag `sha-`, bump du +manifest sur `main`. + +## Architecture du déploiement + +| Composant | Détail | +|---|---| +| Deployment | 1 réplica, `Recreate` (volume RWO), PodSecurity *restricted* (uid 1000, `fsGroup` 1000) | +| PVC `finlab-data` | 5Gi NFS → `/home/finlab` : `~/.claude` (login abonnement) + workspace (code finlab seedé, `portfolio.yaml`/`watchlists.yaml`/`alerts.yaml` éditables, `.cache`) | +| Service `finlab` | `web:7681` (ttyd) | +| Middleware `finlab-auth` | basicAuth Traefik (secret `finlab-auth`, htpasswd) | +| IngressRoute | `finance.lab.local` → 7681 (via `finlab-auth`) | + +Le workspace est **seedé depuis l'image au 1er boot** (`cp -rn`, no-clobber) puis vit sur le +PVC : tes edits (portefeuille, etc.) et ton login persistent ; une nouvelle image n'apporte que +les fichiers manquants. + +## Pré-requis manuels (une fois) + +### 1. Secret de pull GHCR (si absent du ns `ai`) + +```bash +kubectl -n ai create secret docker-registry ghcr-pull \ + --docker-server=ghcr.io \ + --docker-username=Alkatrazz24 \ + --docker-password= +``` + +### 2. Secret basicAuth de la console (`finlab-auth`) + +```bash +# htpasswd (paquet apache2-utils). Remplace /. +htpasswd -nbB '' > /tmp/finlab.htpasswd +kubectl -n ai create secret generic finlab-auth \ + --from-file=users=/tmp/finlab.htpasswd +rm -f /tmp/finlab.htpasswd +``` + +### 3. Premier boot — login Claude Code (abonnement) + +1. Ouvrir **http://finance.lab.local** (basicAuth → identifiants de l'étape 2). +2. Dans le terminal, lancer `claude`, puis `/login` → suivre l'OAuth (ouvrir l'URL sur ta + machine, autoriser, coller le code). Persisté dans `~/.claude` sur le PVC. +3. À l'invite, FinLab a déjà sa persona (`CLAUDE.md`) et les outils finlab (MCP `finlab`, + auto-activé). Essaie : *« résume mon portefeuille »*, *« plan de trade pour Micron »*. + +> Le workspace `/home/finlab/workspace` contient `portfolio.yaml` etc. — éditables directement +> (par toi ou par Claude à ta demande). Pour une vue rapide hors console : +> `kubectl -n ai exec deploy/finlab -- python -m finlab.cli digest`. + +## Accès depuis le terminal (sans navigateur) + +```bash +kubectl -n ai exec -it deploy/finlab -- bash -lc 'cd "$WORKSPACE" && claude' +``` diff --git a/k8s/apps/finlab/deployment.yaml b/k8s/apps/finlab/deployment.yaml new file mode 100644 index 0000000..a7c8b91 --- /dev/null +++ b/k8s/apps/finlab/deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: finlab + namespace: ai +spec: + replicas: 1 + # HOME + workspace sur un volume RWO (NFS) → pas de rolling à 2 pods sur le même volume. + strategy: + type: Recreate + selector: + matchLabels: + app: finlab + template: + metadata: + labels: + app: finlab + spec: + # Image privée sur GHCR → secret de pull créé manuellement dans le ns ai + # (cf. README : kubectl create secret docker-registry ghcr-pull ...). + imagePullSecrets: + - name: ghcr-pull + # Conformité PodSecurity « restricted » (namespace ai). fsGroup 1000 → le PVC NFS monté + # en /home/finlab (HOME : ~/.claude du login abonnement + workspace) est inscriptible. + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: finlab + # Tag bumpé par la CI (build-finlab.yml) sur main. + image: ghcr.io/alkatrazz24/funk-finlab:latest + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + ports: + - name: web + containerPort: 7681 + env: + - name: TZ + value: "Europe/Paris" + # ttyd écoute en TCP : sonde TCP (pas de endpoint HTTP de santé dédié). Démarrage + # rapide, mais on laisse de la marge (seed du workspace au 1er boot). + readinessProbe: + tcpSocket: + port: 7681 + initialDelaySeconds: 10 + periodSeconds: 15 + livenessProbe: + tcpSocket: + port: 7681 + initialDelaySeconds: 30 + periodSeconds: 30 + # Node + Claude Code peuvent consommer ; nœuds 8 Go (OOM actif) → limite raisonnable. + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "1" + memory: 1536Mi + volumeMounts: + - name: home + mountPath: /home/finlab + volumes: + - name: home + persistentVolumeClaim: + claimName: finlab-data diff --git a/k8s/apps/finlab/ingress.yaml b/k8s/apps/finlab/ingress.yaml new file mode 100644 index 0000000..6e348d9 --- /dev/null +++ b/k8s/apps/finlab/ingress.yaml @@ -0,0 +1,17 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: finlab + namespace: ai +spec: + entryPoints: + - web + routes: + - match: Host(`finance.lab.local`) + kind: Rule + middlewares: + # basicAuth : un shell web ne doit pas être ouvert sans auth (cf. middleware.yaml). + - name: finlab-auth + services: + - name: finlab + port: 7681 diff --git a/k8s/apps/finlab/middleware.yaml b/k8s/apps/finlab/middleware.yaml new file mode 100644 index 0000000..2e6ee0b --- /dev/null +++ b/k8s/apps/finlab/middleware.yaml @@ -0,0 +1,11 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: finlab-auth + namespace: ai +spec: + # La console expose un SHELL web (ttyd) → on la protège par basicAuth Traefik, même en LAN. + # Le secret `finlab-auth` (htpasswd) est créé MANUELLEMENT (hors git) — voir README. + basicAuth: + secret: finlab-auth + realm: FinLab diff --git a/k8s/apps/finlab/pvc.yaml b/k8s/apps/finlab/pvc.yaml new file mode 100644 index 0000000..6fe496b --- /dev/null +++ b/k8s/apps/finlab/pvc.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: finlab-data + namespace: ai +spec: + # /home/finlab : login abonnement Claude Code (~/.claude), workspace finlab (code seedé + + # configs éditables portfolio/watchlists/alerts), cache disque (.cache), sessions/historique. + # Persiste l'onboarding (login) et les edits à travers redémarrages et rebuilds d'image. + accessModes: + - ReadWriteOnce + storageClassName: nfs + resources: + requests: + storage: 5Gi diff --git a/k8s/apps/finlab/service.yaml b/k8s/apps/finlab/service.yaml new file mode 100644 index 0000000..7f69122 --- /dev/null +++ b/k8s/apps/finlab/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: finlab + namespace: ai +spec: + selector: + app: finlab + ports: + - name: web + port: 7681 + targetPort: 7681 diff --git a/tools/finlab/.dockerignore b/tools/finlab/.dockerignore new file mode 100644 index 0000000..4d0424c --- /dev/null +++ b/tools/finlab/.dockerignore @@ -0,0 +1,7 @@ +.venv/ +.cache/ +reports/ +__pycache__/ +*.pyc +.env +.git/ diff --git a/tools/finlab/.gitignore b/tools/finlab/.gitignore new file mode 100644 index 0000000..83d9fbf --- /dev/null +++ b/tools/finlab/.gitignore @@ -0,0 +1,6 @@ +# Cache disque finlab (cours/fondamentaux) — runtime, régénéré +.cache/ +# Rapports générés (digest quotidien) +reports/ +# Secrets paper trading +.env diff --git a/tools/finlab/Dockerfile b/tools/finlab/Dockerfile new file mode 100644 index 0000000..81d3363 --- /dev/null +++ b/tools/finlab/Dockerfile @@ -0,0 +1,55 @@ +# funk-finlab — console Claude Code web, spécialisée finance. +# +# Une vraie session Claude Code (sur l'ABONNEMENT de l'utilisateur, login persisté sur le +# PVC) servie dans un terminal web (ttyd), dans un workspace où vit la boîte à outils finlab +# (exposée en MCP). C'est l'esprit OpenAlice, mais c'est Claude Code + le toolkit maison — +# aucune facturation API, aucun broker, lecture/analyse uniquement. +FROM python:3.12-slim-bookworm + +# Node (pour le CLI claude-code) + git + tini (PID 1 propre). ttyd n'est PAS dans les dépôts +# Debian bookworm → on récupère le binaire statique (musl) depuis les releases GitHub (pin). +ARG TTYD_VERSION=1.7.7 +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates git tini procps \ + && curl -fsSL "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.x86_64" \ + -o /usr/local/bin/ttyd \ + && chmod +x /usr/local/bin/ttyd \ + && ttyd --version \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Dépendances Python de finlab dans un venv dédié (séparé du workspace, donc non altérable +# depuis la session). finlab lui-même est importé depuis le workspace (PYTHONPATH au runtime). +COPY requirements.txt /opt/finlab/requirements.txt +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir -r /opt/finlab/requirements.txt + +# « Seed » du workspace : copié dans le PVC au premier boot (sans écraser les edits ensuite). +# Contient le code finlab, les configs (portefeuille/watchlists/alertes), la persona Claude +# (CLAUDE.md), le branchement MCP finlab et les réglages d'autorisation. +COPY finlab/ /opt/seed/finlab/ +COPY webapp/ /opt/seed/webapp/ +COPY portfolio.yaml watchlists.yaml 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/ +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# uid non-root (PodSecurity « restricted » du namespace ai). HOME et workspace vivent sur le +# PVC monté en /home/finlab : ainsi ~/.claude (login abonnement) ET le workspace persistent. +RUN useradd -m -u 1000 -s /bin/bash finlab +ENV HOME=/home/finlab \ + WORKSPACE=/home/finlab/workspace \ + PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin \ + PYTHONPATH=/home/finlab/workspace \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Paris + +USER 1000 +WORKDIR /home/finlab/workspace +EXPOSE 7681 +ENTRYPOINT ["tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/tools/finlab/README.md b/tools/finlab/README.md new file mode 100644 index 0000000..f060db4 --- /dev/null +++ b/tools/finlab/README.md @@ -0,0 +1,51 @@ +# finlab — boîte à outils d'analyse de marché + +Analyse de marché locale (données **Yahoo Finance**, gratuit/sans clé) + une **console Claude +Code finance** déployée in-cluster. Paper trading optionnel via **Alpaca** (argent fictif). + +> ⚠️ Outil d'analyse et d'aide à la décision. **Ne passe aucun ordre réel.** Tu restes seul +> décideur de tes opérations réelles (Revolut ou ailleurs). + +## Les 4 briques (`finlab/`) + +| Brique | Module | Ce que ça fait | +|--------|--------|----------------| +| Suivi de portefeuille | `tracker` | Valeur, P&L, poids, expo sectorielle, concentration | +| Analyse technique | `technical` | RSI, MACD, MM50/200, Bollinger, ATR + signaux | +| Analyse fondamentale | `fundamental` | PER, PEG, marges, ROE, croissance, dette, cible analystes | +| Paper trading | `paper` | Compte/positions/ordres fictifs Alpaca | + +Glu : `data` (fetch + cache), `scanner` (watchlists), `digest` (rapport compact), `alerts`, +`plan` (plan de trade R:R), `cli`, `mcp_server`. + +## Trois façons de l'utiliser + +1. **Console Claude Code web** (déploiement) — `finance.lab.local` : vraie session Claude Code + spécialisée finance, outils finlab en MCP. Voir `k8s/apps/finlab/README.md`. +2. **CLI** — `python -m finlab.cli digest|portfolio|tech|fundamentals|scan|plan|alerts`. +3. **MCP** — serveur stdio `finlab.mcp_server`, branché dans le `.mcp.json` du repo lab → outils + finlab dans les sessions Claude Code du lab. + +Le portefeuille se modifie dans **`portfolio.yaml`**, les watchlists dans **`watchlists.yaml`** +(thèmes : `energy_power`, `chips`, `cables_optical_network`, `software_cloud`, +`datacenter_infra`), les règles d'alerte dans **`alerts.yaml`**. + +## Dév local + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +PYTHONPATH=. .venv/bin/python -m finlab.cli digest +``` + +## App web FastAPI (alternative, non déployée) + +`webapp/server.py` — chat Claude Opus via **API Anthropic** (facturé), persona +`webapp/finance_soul.md`, tool-use finlab, SSE. Conservée comme surface HTTP requêtable +optionnelle ; le déploiement in-cluster utilise la **console Claude Code** (abonnement, non +facturé) et non cette app. Lancer en local : `ANTHROPIC_API_KEY=... .venv/bin/python -m webapp.server`. + +## Limites + +- Yahoo peut brider/différer ; certains champs fondamentaux sont vides. +- Le P&L EUR utilise le taux de change **actuel** (léger écart sur le PRU vs Revolut). +- Indicateurs techniques et cibles analystes ne sont **pas** des recommandations. diff --git a/tools/finlab/alerts.yaml b/tools/finlab/alerts.yaml new file mode 100644 index 0000000..6853da6 --- /dev/null +++ b/tools/finlab/alerts.yaml @@ -0,0 +1,35 @@ +# Règles d'alerte — ne se déclenchent que sur ÉVÉNEMENT (transition), pas sur état. +# Évalué par finlab/alerts.py sur la watchlist choisie. +# +# Types de condition disponibles : +# rsi_below / rsi_above -> seuil (value) +# macd_cross_up / macd_cross_down -> croisement du jour +# cross_above_mm50 / cross_below_mm50 -> cassure de la MM50 du jour +# golden_cross / death_cross -> MM50 croise MM200 + +rules: + - name: "🟢 Survente (rebond possible)" + when: rsi_below + value: 30 + + - name: "🔴 Surachat (essoufflement)" + when: rsi_above + value: 72 + + - name: "🟢 MACD croise à la hausse" + when: macd_cross_up + + - name: "🔴 MACD croise à la baisse" + when: macd_cross_down + + - name: "🟢 Cassure MM50 à la hausse" + when: cross_above_mm50 + + - name: "🔴 Perte de la MM50" + when: cross_below_mm50 + + - name: "🟢 Golden cross (MM50 > MM200)" + when: golden_cross + +# Watchlist par défaut surveillée par le digest (thème de watchlists.yaml, ou 'portfolio', ou 'all') +watch: all diff --git a/tools/finlab/entrypoint.sh b/tools/finlab/entrypoint.sh new file mode 100644 index 0000000..f6b765f --- /dev/null +++ b/tools/finlab/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Prépare le workspace persistant puis lance la console web (ttyd → Claude Code). +set -euo pipefail + +WS="${WORKSPACE:-$HOME/workspace}" +mkdir -p "$WS" + +# Seed du workspace depuis l'image, SANS écraser ce que l'utilisateur (ou Claude) a modifié. +# `cp -rn` = no-clobber : pose les fichiers manquants (nouvelle version d'image → nouveaux +# fichiers), préserve portfolio.yaml/edits/.cache existants sur le PVC. +cp -rn /opt/seed/. "$WS"/ 2>/dev/null || true + +cd "$WS" + +# Petit mot d'accueil dans le shell de la console. +cat > "$HOME/.bash_motd" <<'MOTD' +──────────────────────────────────────────────────────────── + FinLab — console Claude Code (analyste de marché) + • Lance l'assistant : claude + • 1ʳᵉ fois : dans claude, fais /login (abonnement, persisté) + • Outils finlab en MCP + CLI : python -m finlab.cli digest + • Ton portefeuille : portfolio.yaml (éditable ici) + ⚠ Analyse uniquement — aucun ordre réel. +──────────────────────────────────────────────────────────── +MOTD +grep -q bash_motd "$HOME/.bashrc" 2>/dev/null || echo '[ -f "$HOME/.bash_motd" ] && cat "$HOME/.bash_motd"' >> "$HOME/.bashrc" + +# ttyd : terminal web (port 7681), écriture activée. L'auth est assurée en amont par le +# middleware basicAuth de Traefik (cf. k8s/apps/finlab/middleware.yaml) — ttyd reste interne. +exec ttyd -p 7681 -W -t titleFixed='FinLab — console' -t 'theme={"background":"#0b0e14"}' \ + bash -l diff --git a/tools/finlab/finlab/__init__.py b/tools/finlab/finlab/__init__.py new file mode 100644 index 0000000..681a53a --- /dev/null +++ b/tools/finlab/finlab/__init__.py @@ -0,0 +1,6 @@ +"""finlab — boîte à outils d'analyse de marché (données via yfinance). + +NB : outil d'analyse et d'aide à la décision uniquement. Ne passe aucun +ordre réel. Le paper trading (Alpaca) se fait sur un compte fictif. +""" +__version__ = "0.1.0" diff --git a/tools/finlab/finlab/alerts.py b/tools/finlab/finlab/alerts.py new file mode 100644 index 0000000..1fd6383 --- /dev/null +++ b/tools/finlab/finlab/alerts.py @@ -0,0 +1,88 @@ +"""Évaluation des alertes : ne renvoie que les ÉVÉNEMENTS du jour (transitions). + +Économe en tokens : au lieu de déverser tout l'état technique, on ne remonte +que ce qui vient de basculer (croisement MACD, cassure MM50, survente...). +""" +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +import yaml + +from . import data, indicators as ind, scanner + +ALERTS_FILE = Path(__file__).resolve().parent.parent / "alerts.yaml" + + +def _events(symbol: str) -> dict: + """Calcule les flags d'événement (aujourd'hui vs hier) pour un titre.""" + df = data.history(symbol, period="1y") + close = df["Close"] + rsi = ind.rsi(close) + macd = ind.macd(close) + m, s = macd["macd"], macd["signal"] + mm50 = ind.sma(close, 50) + mm200 = ind.sma(close, 200) + + def crossed_up(a, b): + return a.iloc[-2] <= b.iloc[-2] and a.iloc[-1] > b.iloc[-1] + + def crossed_down(a, b): + return a.iloc[-2] >= b.iloc[-2] and a.iloc[-1] < b.iloc[-1] + + return { + "rsi": float(rsi.iloc[-1]), + "macd_cross_up": crossed_up(m, s), + "macd_cross_down": crossed_down(m, s), + "cross_above_mm50": crossed_up(close, mm50), + "cross_below_mm50": crossed_down(close, mm50), + "golden_cross": crossed_up(mm50, mm200) if mm200.notna().iloc[-1] else False, + "death_cross": crossed_down(mm50, mm200) if mm200.notna().iloc[-1] else False, + "price": round(float(close.iloc[-1]), 2), + } + + +def _matches(rule: dict, ev: dict) -> bool: + w = rule["when"] + if w == "rsi_below": + return ev["rsi"] < rule["value"] + if w == "rsi_above": + return ev["rsi"] > rule["value"] + return bool(ev.get(w, False)) + + +def _watchlist(name: str) -> list[str]: + if name == "portfolio": + return [p["ticker"] for p in data.load_portfolio()["positions"]] + return scanner.load_theme(name) # 'all' ou un thème + + +def run(watch: str | None = None) -> list[dict]: + """Renvoie la liste des alertes déclenchées : [{ticker, prix, alerte}].""" + cfg = yaml.safe_load(open(ALERTS_FILE, encoding="utf-8")) + rules = cfg["rules"] + syms = _watchlist(watch or cfg.get("watch", "all")) + + hits = [] + for sym in syms: + try: + ev = _events(sym) + except Exception: + continue + for rule in rules: + if _matches(rule, ev): + hits.append({"ticker": sym, "prix": ev["price"], "alerte": rule["name"]}) + return hits + + +def render(hits: list[dict]) -> str: + if not hits: + return "Aucune alerte déclenchée aujourd'hui." + df = pd.DataFrame(hits).sort_values("alerte") + return df.to_string(index=False) + + +if __name__ == "__main__": + import sys + print(render(run(sys.argv[1] if len(sys.argv) > 1 else None))) diff --git a/tools/finlab/finlab/cli.py b/tools/finlab/finlab/cli.py new file mode 100644 index 0000000..f5a6a93 --- /dev/null +++ b/tools/finlab/finlab/cli.py @@ -0,0 +1,97 @@ +"""CLI unifiée : python -m finlab.cli .""" +from __future__ import annotations + +import argparse + +import pandas as pd + + +def main() -> None: + pd.set_option("display.max_columns", None, "display.max_colwidth", None, "display.width", 240) + + parser = argparse.ArgumentParser(prog="finlab", description="Boîte à outils d'analyse de marché") + sub = parser.add_subparsers(dest="cmd", required=True) + + sub.add_parser("portfolio", help="Suivi du portefeuille (valeur, P&L, secteurs)") + + t = sub.add_parser("tech", help="Analyse technique") + t.add_argument("tickers", nargs="*", help="vide = tout le portefeuille") + t.add_argument("--period", default="1y") + + f = sub.add_parser("fundamentals", help="Ratios fondamentaux") + f.add_argument("tickers", nargs="*", help="vide = tout le portefeuille") + + s = sub.add_parser("scan", help="Scanner d'opportunités sur un thème") + s.add_argument("theme", nargs="?", default="all", help="thème de watchlists.yaml, 'all' ou 'portfolio'") + s.add_argument("--target", type=float, default=5.0, help="cible de perf hebdo en %%") + s.add_argument("--bullish", action="store_true", help="ne montrer que les setups haussiers") + + d = sub.add_parser("digest", help="Digest compact (portefeuille + opportunités + alertes)") + d.add_argument("theme", nargs="?", default="all") + d.add_argument("--target", type=float, default=5.0) + + a = sub.add_parser("alerts", help="Alertes déclenchées du jour") + a.add_argument("watch", nargs="?", default=None, help="thème, 'all' ou 'portfolio'") + + p = sub.add_parser("plan", help="Plan de trade chiffré (entrée/stop/objectif/taille)") + p.add_argument("tickers", nargs="+") + p.add_argument("--capital", type=float, default=1427.0) + + c = sub.add_parser("cache", help="Gestion du cache disque") + c.add_argument("action", choices=["clear", "info"], default="info", nargs="?") + + args = parser.parse_args() + + if args.cmd == "portfolio": + from . import tracker + print(tracker.report()) + + elif args.cmd == "tech": + from . import technical + df = technical.scan(args.tickers, args.period) if args.tickers else technical.scan_portfolio(args.period) + print(df.to_string(index=False)) + + elif args.cmd == "fundamentals": + from . import fundamental + df = fundamental.compare(args.tickers) if args.tickers else fundamental.compare_portfolio() + print(df.to_string(index=False)) + + elif args.cmd == "scan": + from . import scanner + if args.theme == "portfolio": + df = scanner.scan_portfolio(args.target) + else: + df = scanner.scan_theme(args.theme, args.target, bullish_only=args.bullish) + print(df.to_string(index=False)) + + elif args.cmd == "digest": + from . import digest + path = digest.write(args.theme, args.target) + print(path.read_text(encoding="utf-8")) + print(f"\n[écrit dans {path}]") + + elif args.cmd == "alerts": + from . import alerts + print(alerts.render(alerts.run(args.watch))) + + elif args.cmd == "plan": + from . import plan + for tk in args.tickers: + try: + print(plan.render(plan.plan(tk, args.capital))) + except Exception as e: + print(f"{tk}: erreur {e}") + print() + + elif args.cmd == "cache": + from . import data + if args.action == "clear": + n = data.clear_cache() + print(f"Cache vidé : {n} fichier(s).") + else: + files = list(data.CACHE_DIR.glob("*.pkl")) if data.CACHE_DIR.exists() else [] + print(f"{len(files)} fichier(s) en cache dans {data.CACHE_DIR}") + + +if __name__ == "__main__": + main() diff --git a/tools/finlab/finlab/data.py b/tools/finlab/finlab/data.py new file mode 100644 index 0000000..1168807 --- /dev/null +++ b/tools/finlab/finlab/data.py @@ -0,0 +1,113 @@ +"""Couche d'accès aux données de marché (Yahoo Finance via yfinance). + +Centralise les appels réseau, le cache devises et la lecture du portefeuille +pour que tous les outils partagent la même source. +""" +from __future__ import annotations + +import functools +import pickle +import time +from pathlib import Path + +import pandas as pd +import yaml +import yfinance as yf + +ROOT = Path(__file__).resolve().parent.parent +PORTFOLIO_FILE = ROOT / "portfolio.yaml" +CACHE_DIR = ROOT / ".cache" + +# Durées de fraîcheur du cache disque (secondes) +TTL_HISTORY = 3600 # cours : 1h (suffisant pour des scans intraday espacés) +TTL_INFO = 7 * 86400 # fondamentaux/secteur : 1 semaine (change peu) +TTL_PRICE = 600 # dernier cours : 10 min + + +def _disk_cache(key: str, ttl: int, producer): + """Renvoie l'objet caché si frais (< ttl), sinon le (re)calcule et le stocke.""" + CACHE_DIR.mkdir(exist_ok=True) + path = CACHE_DIR / (key.replace("/", "_").replace("=", "_") + ".pkl") + if path.exists() and (time.time() - path.stat().st_mtime) < ttl: + try: + with open(path, "rb") as f: + return pickle.load(f) + except Exception: + pass # cache corrompu -> on recalcule + obj = producer() + with open(path, "wb") as f: + pickle.dump(obj, f) + return obj + + +def load_portfolio(path: Path | None = None) -> dict: + """Charge portfolio.yaml.""" + path = path or PORTFOLIO_FILE + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +@functools.lru_cache(maxsize=64) +def _ticker(symbol: str) -> yf.Ticker: + return yf.Ticker(symbol) + + +def history(symbol: str, period: str = "1y", interval: str = "1d", fresh: bool = False) -> pd.DataFrame: + """Historique OHLCV (caché sur disque). fresh=True force un re-fetch.""" + def fetch(): + df = _ticker(symbol).history(period=period, interval=interval, auto_adjust=True) + if df.empty: + raise ValueError(f"Aucune donnée pour {symbol} (period={period})") + return df + + ttl = 0 if fresh else TTL_HISTORY + return _disk_cache(f"hist_{symbol}_{period}_{interval}", ttl, fetch) + + +def last_price(symbol: str, fresh: bool = False) -> tuple[float, str]: + """(dernier cours, devise) en devise native du titre (caché 10 min).""" + def fetch(): + fi = _ticker(symbol).fast_info + return float(fi.last_price), fi.currency + + return _disk_cache(f"price_{symbol}", 0 if fresh else TTL_PRICE, fetch) + + +def fx_rate(base: str, quote: str) -> float: + """Taux de change : combien de `quote` pour 1 `base` (ex: EUR->USD ~ 1.07).""" + if base == quote: + return 1.0 + + def fetch(): + df = _ticker(f"{base}{quote}=X").history(period="5d", auto_adjust=True) + if df.empty: + raise ValueError(f"Pas de taux {base}->{quote}") + return float(df["Close"].iloc[-1]) + + return _disk_cache(f"fx_{base}{quote}", TTL_PRICE, fetch) + + +def to_currency(amount: float, frm: str, to: str) -> float: + """Convertit un montant d'une devise vers une autre.""" + if frm == to: + return amount + # On passe par EUR->X pour limiter le nombre de paires interrogées. + try: + return amount * fx_rate(frm, to) + except ValueError: + return amount / fx_rate(to, frm) + + +def info(symbol: str) -> dict: + """Métadonnées fondamentales (caché 1 semaine ; peut être lent / vide).""" + return _disk_cache(f"info_{symbol}", TTL_INFO, lambda: _ticker(symbol).get_info()) + + +def clear_cache() -> int: + """Vide le cache disque. Renvoie le nombre de fichiers supprimés.""" + if not CACHE_DIR.exists(): + return 0 + files = list(CACHE_DIR.glob("*.pkl")) + for f in files: + f.unlink() + return len(files) diff --git a/tools/finlab/finlab/digest.py b/tools/finlab/finlab/digest.py new file mode 100644 index 0000000..cc9284e --- /dev/null +++ b/tools/finlab/finlab/digest.py @@ -0,0 +1,82 @@ +"""Digest quotidien compact — le format pensé pour économiser les tokens. + +Au lieu de lancer 4 outils et de déverser des tableaux de 50 lignes, on produit +un résumé court (~20 lignes) : état du portefeuille, top opportunités filtrées, +alertes déclenchées. Écrit dans reports/ et renvoyé comme texte court. +""" +from __future__ import annotations + +import datetime as dt +from pathlib import Path + +from . import alerts, data, scanner, tracker + +REPORTS_DIR = Path(__file__).resolve().parent.parent / "reports" + + +def build(theme: str = "all", target_pct: float = 5.0, top: int = 6) -> str: + today = dt.date.today().isoformat() + lines = [f"# Digest {today}", ""] + + # --- Portefeuille (1 ligne d'essentiel) --- + try: + _, agg = tracker.build(with_sector=True) + b = agg["base"] + top_sec = agg["by_sector"].index[0] + top_sec_w = agg["by_sector"].iloc[0] + lines.append( + f"**Portefeuille** : {agg['total']:,.0f} {b} | P&L {agg['pnl_total']:+,.0f} {b} " + f"({agg['pnl_pct']:+.1f}%) | cash {agg['cash']:,.0f} {b}" + ) + lines.append(f"Concentration : {top_sec} {top_sec_w:.0f}%") + except Exception as e: + lines.append(f"_Portefeuille indisponible : {e}_") + + # --- Opportunités (seulement les actionnables, colonnes minimales) --- + lines += ["", f"## Opportunités haussières ({theme}, cible {target_pct:.0f}%)", ""] + try: + df = scanner.scan_theme(theme, target_pct, bullish_only=True) + cap_col = f"peut_{int(target_pct)}%" + df = df[df[cap_col] == "OUI"].head(top) + if df.empty: + lines.append("_Aucune opportunité haussière qualifiée aujourd'hui._") + else: + for _, r in df.iterrows(): + lines.append( + f"- **{r['ticker']}** {r['cours']} | range/sem {r['range_sem_%']}% | " + f"RSI {r['RSI']:.0f} MACD {r['MACD']} | {r['biais']}" + ) + except Exception as e: + lines.append(f"_Scan indisponible : {e}_") + + # --- Alertes (événements du jour uniquement) --- + lines += ["", "## Alertes du jour", ""] + try: + hits = alerts.run() + if not hits: + lines.append("_Aucune._") + else: + for h in sorted(hits, key=lambda x: x["alerte"]): + lines.append(f"- {h['alerte']} — **{h['ticker']}** ({h['prix']})") + except Exception as e: + lines.append(f"_Alertes indisponibles : {e}_") + + return "\n".join(lines) + + +def write(theme: str = "all", target_pct: float = 5.0) -> Path: + """Génère le digest et l'écrit dans reports/digest_.md.""" + REPORTS_DIR.mkdir(exist_ok=True) + text = build(theme, target_pct) + path = REPORTS_DIR / f"digest_{dt.date.today().isoformat()}.md" + path.write_text(text, encoding="utf-8") + (REPORTS_DIR / "latest.md").write_text(text, encoding="utf-8") + return path + + +if __name__ == "__main__": + import sys + theme = sys.argv[1] if len(sys.argv) > 1 else "all" + p = write(theme) + print(p.read_text(encoding="utf-8")) + print(f"\n[écrit dans {p}]") diff --git a/tools/finlab/finlab/fundamental.py b/tools/finlab/finlab/fundamental.py new file mode 100644 index 0000000..76a0ed7 --- /dev/null +++ b/tools/finlab/finlab/fundamental.py @@ -0,0 +1,66 @@ +"""Analyse fondamentale : ratios clés et résultats via yfinance.""" +from __future__ import annotations + +import pandas as pd + +from . import data + + +def _b(x: float | None) -> str: + """Formate un grand nombre en milliards.""" + return f"{x/1e9:.1f} Md" if isinstance(x, (int, float)) else "—" + + +def snapshot(symbol: str) -> dict: + i = data.info(symbol) + + def g(*keys): + for k in keys: + v = i.get(k) + if v is not None: + return v + return None + + return { + "ticker": symbol, + "nom": g("shortName", "longName"), + "secteur": g("sector"), + "cours": g("currentPrice", "regularMarketPrice"), + "PER": g("trailingPE"), + "PER_fwd": g("forwardPE"), + "PEG": g("pegRatio"), + "P/S": g("priceToSalesTrailing12Months"), + "P/B": g("priceToBook"), + "marge_nette_%": round(g("profitMargins") * 100, 1) if g("profitMargins") else None, + "ROE_%": round(g("returnOnEquity") * 100, 1) if g("returnOnEquity") else None, + "croiss_CA_%": round(g("revenueGrowth") * 100, 1) if g("revenueGrowth") else None, + "dette/FP": g("debtToEquity"), + "div_%": round(g("dividendYield") * 100, 2) if g("dividendYield") else None, + "capi": _b(g("marketCap")), + "reco": g("recommendationKey"), + "cible_moy": g("targetMeanPrice"), + } + + +def compare(symbols: list[str]) -> pd.DataFrame: + rows = [] + for s in symbols: + try: + rows.append(snapshot(s)) + except Exception as e: + rows.append({"ticker": s, "nom": f"erreur: {e}"}) + return pd.DataFrame(rows) + + +def compare_portfolio() -> pd.DataFrame: + pf = data.load_portfolio() + return compare([p["ticker"] for p in pf["positions"]]) + + +if __name__ == "__main__": + import sys + + syms = sys.argv[1:] + df = compare(syms) if syms else compare_portfolio() + pd.set_option("display.max_columns", None, "display.width", 240) + print(df.to_string(index=False)) diff --git a/tools/finlab/finlab/indicators.py b/tools/finlab/finlab/indicators.py new file mode 100644 index 0000000..176e148 --- /dev/null +++ b/tools/finlab/finlab/indicators.py @@ -0,0 +1,48 @@ +"""Indicateurs techniques calculés en local (pandas), sans dépendance lourde.""" +from __future__ import annotations + +import pandas as pd + + +def sma(close: pd.Series, window: int) -> pd.Series: + return close.rolling(window).mean() + + +def ema(close: pd.Series, window: int) -> pd.Series: + return close.ewm(span=window, adjust=False).mean() + + +def rsi(close: pd.Series, window: int = 14) -> pd.Series: + """RSI de Wilder (0-100). >70 surachat, <30 survente (repères usuels).""" + delta = close.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(alpha=1 / window, adjust=False, min_periods=window).mean() + avg_loss = loss.ewm(alpha=1 / window, adjust=False, min_periods=window).mean() + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + +def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame: + """MACD, ligne de signal et histogramme.""" + macd_line = ema(close, fast) - ema(close, slow) + signal_line = macd_line.ewm(span=signal, adjust=False).mean() + return pd.DataFrame( + {"macd": macd_line, "signal": signal_line, "hist": macd_line - signal_line} + ) + + +def bollinger(close: pd.Series, window: int = 20, n_std: float = 2.0) -> pd.DataFrame: + mid = sma(close, window) + std = close.rolling(window).std() + return pd.DataFrame({"mid": mid, "upper": mid + n_std * std, "lower": mid - n_std * std}) + + +def atr(df: pd.DataFrame, window: int = 14) -> pd.Series: + """Average True Range — mesure de volatilité (utile pour dimensionner un stop).""" + high, low, close = df["High"], df["Low"], df["Close"] + prev_close = close.shift(1) + tr = pd.concat( + [high - low, (high - prev_close).abs(), (low - prev_close).abs()], axis=1 + ).max(axis=1) + return tr.ewm(alpha=1 / window, adjust=False, min_periods=window).mean() diff --git a/tools/finlab/finlab/mcp_server.py b/tools/finlab/finlab/mcp_server.py new file mode 100644 index 0000000..60f461d --- /dev/null +++ b/tools/finlab/finlab/mcp_server.py @@ -0,0 +1,98 @@ +"""Serveur MCP : expose les outils finlab à Claude (transport stdio). + +Lancement manuel : python -m finlab.mcp_server +Configuré dans Claude via .mcp.json / claude mcp add (voir README). +""" +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from . import alerts, data, digest as digest_mod, fundamental, scanner, technical, tracker + +mcp = FastMCP("finlab") + + +@mcp.tool() +def digest(theme: str = "all", target_pct: float = 5.0) -> str: + """Digest compact (À PRIVILÉGIER) : portefeuille + opportunités haussières filtrées + + alertes du jour, en ~20 lignes. Économe en tokens. theme = thème de watchlists, + 'all' ou 'portfolio'.""" + return digest_mod.build(theme, target_pct) + + +@mcp.tool() +def opportunities(theme: str = "all", target_pct: float = 5.0, bullish_only: bool = True) -> str: + """Scanner d'opportunités sur un thème : capacité de mouvement (volatilité) + biais + directionnel. theme = nom de thème (energy_power, chips, cables_optical_network, + software_cloud, datacenter_infra), 'all' ou 'portfolio'.""" + df = scanner.scan_theme(theme, target_pct, bullish_only=bullish_only) + return df.to_string(index=False) + + +@mcp.tool() +def alerts_today(watch: str = "all") -> str: + """Alertes déclenchées aujourd'hui (croisements MACD, cassures MM50, survente...).""" + return alerts.render(alerts.run(watch)) + + +@mcp.tool() +def portfolio_summary() -> str: + """Valorisation du portefeuille : valeur, P&L, poids et exposition sectorielle.""" + return tracker.report() + + +@mcp.tool() +def price(ticker: str) -> str: + """Dernier cours d'un titre dans sa devise native (symbole Yahoo Finance).""" + p, cur = data.last_price(ticker) + return f"{ticker}: {p:.2f} {cur}" + + +@mcp.tool() +def technical_analysis(tickers: list[str] | None = None, period: str = "1y") -> str: + """Indicateurs techniques (RSI, MACD, MM50/200, Bollinger, ATR) et signaux. + tickers vide = tout le portefeuille.""" + df = technical.scan(tickers, period) if tickers else technical.scan_portfolio(period) + return df.to_string(index=False) + + +@mcp.tool() +def fundamentals(tickers: list[str] | None = None) -> str: + """Ratios fondamentaux (PER, PEG, marges, ROE, croissance, dette, cible analystes). + tickers vide = tout le portefeuille.""" + df = fundamental.compare(tickers) if tickers else fundamental.compare_portfolio() + return df.to_string(index=False) + + +@mcp.tool() +def paper_account() -> str: + """État du compte paper trading Alpaca (argent fictif). Nécessite les clés .env.""" + from . import paper + try: + return str(paper.account()) + except paper.NotConfigured as e: + return str(e) + + +@mcp.tool() +def paper_positions() -> str: + """Positions ouvertes sur le compte paper trading Alpaca.""" + from . import paper + try: + return str(paper.positions()) + except paper.NotConfigured as e: + return str(e) + + +@mcp.tool() +def paper_order(ticker: str, qty: float, side: str = "buy", limit: float | None = None) -> str: + """Passe un ordre FICTIF (paper) sur Alpaca. side = buy|sell. limit optionnel.""" + from . import paper + try: + return str(paper.order(ticker, qty, side, limit)) + except paper.NotConfigured as e: + return str(e) + + +if __name__ == "__main__": + mcp.run() diff --git a/tools/finlab/finlab/paper.py b/tools/finlab/finlab/paper.py new file mode 100644 index 0000000..8fdc085 --- /dev/null +++ b/tools/finlab/finlab/paper.py @@ -0,0 +1,91 @@ +"""Paper trading via Alpaca (compte fictif, argent virtuel). + +Nécessite un compte gratuit sur https://alpaca.markets puis, dans un +fichier .env à la racine du projet : + + ALPACA_API_KEY=... + ALPACA_SECRET_KEY=... + +On force TOUJOURS l'environnement « paper » : aucun ordre réel possible ici. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).resolve().parent.parent / ".env") + + +class NotConfigured(RuntimeError): + pass + + +def _client(): + from alpaca.trading.client import TradingClient + + key = os.getenv("ALPACA_API_KEY") + secret = os.getenv("ALPACA_SECRET_KEY") + if not key or not secret: + raise NotConfigured( + "Clés Alpaca absentes. Crée un compte gratuit sur alpaca.markets, " + "génère des clés 'Paper Trading' et renseigne ALPACA_API_KEY / " + "ALPACA_SECRET_KEY dans le fichier .env." + ) + # paper=True : compte fictif, jamais d'argent réel. + return TradingClient(key, secret, paper=True) + + +def account() -> dict: + a = _client().get_account() + return { + "statut": a.status, + "cash": float(a.cash), + "valeur_portefeuille": float(a.portfolio_value), + "pouvoir_achat": float(a.buying_power), + "devise": a.currency, + } + + +def positions() -> list[dict]: + return [ + { + "ticker": p.symbol, + "qté": float(p.qty), + "PRU": float(p.avg_entry_price), + "cours": float(p.current_price), + "valeur": float(p.market_value), + "P&L": float(p.unrealized_pl), + "P&L_%": round(float(p.unrealized_plpc) * 100, 2), + } + for p in _client().get_all_positions() + ] + + +def order(symbol: str, qty: float, side: str = "buy", limit: float | None = None) -> dict: + """Passe un ordre fictif (paper). side = buy|sell.""" + from alpaca.trading.enums import OrderSide, TimeInForce + from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest + + client = _client() + os_side = OrderSide.BUY if side.lower() == "buy" else OrderSide.SELL + if limit: + req = LimitOrderRequest( + symbol=symbol, qty=qty, side=os_side, + time_in_force=TimeInForce.DAY, limit_price=limit, + ) + else: + req = MarketOrderRequest( + symbol=symbol, qty=qty, side=os_side, time_in_force=TimeInForce.DAY, + ) + o = client.submit_order(req) + return {"id": str(o.id), "ticker": o.symbol, "qté": float(o.qty), "sens": side, "statut": o.status} + + +if __name__ == "__main__": + try: + print("Compte paper :", account()) + print("Positions :", positions()) + except NotConfigured as e: + print(e) diff --git a/tools/finlab/finlab/plan.py b/tools/finlab/finlab/plan.py new file mode 100644 index 0000000..744d16e --- /dev/null +++ b/tools/finlab/finlab/plan.py @@ -0,0 +1,88 @@ +"""Plan de trade chiffré : entrée, stop ATR, objectifs, taille, risque/rendement. + +Le stop est placé sous le « bruit » du titre (multiple d'ATR), pas au hasard. +Le ratio R:R dit si le trade vaut le risque : viser +X% avec un stop plus large +que X% est structurellement perdant — l'outil le montre noir sur blanc. +""" +from __future__ import annotations + +from . import data, indicators as ind + + +def plan( + ticker: str, + capital: float, + fee: float = 1.15, + targets=(5.0, 10.0), + stop_atr_mult: float = 1.5, + fractional: bool = True, +) -> dict: + df = data.history(ticker, period="6mo") + close = df["Close"] + entry = float(close.iloc[-1]) + atr = float(ind.atr(df).iloc[-1]) + + stop = entry - stop_atr_mult * atr + stop_pct = (entry - stop) / entry * 100 + + # Taille : on déploie le capital dispo (moins le frais d'achat) + budget = capital - fee + shares = budget / entry if fractional else int(budget / entry) + invested = shares * entry + fees_round = 2 * fee # achat + revente + + risk_eur = shares * (entry - stop) + fees_round # perte si stop touché + risk_pct_capital = risk_eur / capital * 100 + + tgs = [] + for t in targets: + gain = shares * entry * (t / 100) - fees_round + tgs.append({ + "cible_%": t, + "prix": round(entry * (1 + t / 100), 2), + "gain_net": round(gain, 2), + }) + + # R:R sur la cible médiane + mid_target = sum(targets) / len(targets) + reward = shares * entry * (mid_target / 100) - fees_round + rr = reward / risk_eur if risk_eur > 0 else 0 + + return { + "ticker": ticker, + "entry": round(entry, 2), + "atr": round(atr, 2), + "shares": round(shares, 4), + "invested": round(invested, 2), + "stop": round(stop, 2), + "stop_pct": round(stop_pct, 1), + "risk_eur": round(risk_eur, 2), + "risk_pct_capital": round(risk_pct_capital, 1), + "targets": tgs, + "rr": round(rr, 2), + } + + +def render(p: dict, ccy: str = "$") -> str: + L = [f"━━ {p['ticker']} @ {p['entry']}{ccy} (ATR {p['atr']}) ━━"] + L.append(f" Position : {p['shares']} actions → {p['invested']}{ccy} déployés") + L.append(f" STOP : {p['stop']}{ccy} (-{p['stop_pct']}%)") + L.append(f" ↳ perte si touché : -{p['risk_eur']}{ccy} ({p['risk_pct_capital']}% du capital)") + for t in p["targets"]: + L.append(f" Objectif +{t['cible_%']:>4}% : {t['prix']}{ccy} → gain net +{t['gain_net']}{ccy}") + verdict = "✅ favorable" if p["rr"] >= 1.5 else ("⚠️ limite" if p["rr"] >= 1 else "❌ défavorable") + L.append(f" RATIO R:R : {p['rr']}:1 {verdict}") + return "\n".join(L) + + +if __name__ == "__main__": + import sys + args = sys.argv[1:] + capital = float(args[0]) if args else 1427.0 + tickers = args[1:] or ["CIEN", "GLW", "TLN"] + for tk in tickers: + try: + print(render(plan(tk, capital))) + except Exception as e: + print(f"{tk}: erreur {e}") + print() diff --git a/tools/finlab/finlab/scanner.py b/tools/finlab/finlab/scanner.py new file mode 100644 index 0000000..d4274eb --- /dev/null +++ b/tools/finlab/finlab/scanner.py @@ -0,0 +1,121 @@ +"""Scanner d'opportunités court terme. + +Filtre une watchlist sur deux axes : + 1. CAPACITÉ : le titre peut-il bouger de X% sur la semaine ? (volatilité) + 2. DIRECTION : le setup technique penche-t-il haussier ou baissier ? + +Sert à objectiver un trade swing — PAS une reco d'achat. Un titre capable de +faire +8% est tout aussi capable de faire -8%. +""" +from __future__ import annotations + +import math +from pathlib import Path + +import pandas as pd +import yaml + +from . import data, indicators as ind + +WATCHLISTS_FILE = Path(__file__).resolve().parent.parent / "watchlists.yaml" + + +def load_theme(theme: str | None = None) -> list[str]: + """Tickers d'un thème de watchlists.yaml. None ou 'all' = tous (dédupliqués).""" + themes = yaml.safe_load(open(WATCHLISTS_FILE, encoding="utf-8"))["themes"] + if theme and theme != "all": + if theme not in themes: + raise SystemExit(f"Thème inconnu: {theme}. Dispo: {', '.join(themes)}") + return themes[theme] + seen: dict[str, None] = {} + for syms in themes.values(): + for s in syms: + seen.setdefault(s, None) + return list(seen) + +# 5 séances pleines ; ajusté si semaine écourtée (férié) +SESSIONS_LEFT_DEFAULT = 4 + + +def scan(symbols: list[str], target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT) -> pd.DataFrame: + rows = [] + for s in symbols: + try: + df = data.history(s, period="6mo") + close = df["Close"] + last = float(close.iloc[-1]) + + rsi = float(ind.rsi(close).iloc[-1]) + macd = ind.macd(close) + macd_up = macd["macd"].iloc[-1] > macd["signal"].iloc[-1] + hist = macd["hist"] + macd_turning_up = hist.iloc[-1] > hist.iloc[-2] # histogramme qui se redresse + sma50 = float(ind.sma(close, 50).iloc[-1]) + atr = float(ind.atr(df).iloc[-1]) + + vol_day = atr / last * 100 # amplitude journalière typique + range_week = vol_day * math.sqrt(sessions) # amplitude attendue sur la semaine + can_hit = range_week >= target_pct # capacité d'atteindre la cible + + # Score directionnel haussier (0-4) : empilement de confirmations + score = sum([ + last > sma50, # au-dessus MM50 + macd_up, # MACD haussier + macd_turning_up, # momentum qui se redresse + 30 < rsi < 70, # pas en zone extrême (évite l'achat en surachat) + ]) + if rsi < 30: + bias = "survendu (rebond possible)" + elif rsi > 70: + bias = "surachat (essoufflement)" + elif score >= 3: + bias = "haussier" + elif score <= 1: + bias = "baissier" + else: + bias = "neutre" + + rows.append({ + "ticker": s, + "cours": round(last, 2), + "vol_j_%": round(vol_day, 2), + "range_sem_%": round(range_week, 1), + f"peut_{int(target_pct)}%": "OUI" if can_hit else "non", + "RSI": round(rsi, 0), + "MACD": "↑" if macd_up else "↓", + "score": score, + "biais": bias, + }) + except Exception as e: + rows.append({"ticker": s, "biais": f"erreur: {e}"}) + + df = pd.DataFrame(rows) + # Tri : d'abord ceux qui peuvent atteindre la cible, puis par score directionnel + sort_col = f"peut_{int(target_pct)}%" + df["_cap"] = (df[sort_col] == "OUI").astype(int) + df = df.sort_values(["_cap", "score", "range_sem_%"], ascending=False).drop(columns="_cap") + return df.reset_index(drop=True) + + +def scan_portfolio(target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT, extra: list[str] | None = None) -> pd.DataFrame: + pf = data.load_portfolio() + syms = [p["ticker"] for p in pf["positions"]] + (extra or []) + return scan(syms, target_pct, sessions) + + +def scan_theme(theme: str | None = None, target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT, + bullish_only: bool = False) -> pd.DataFrame: + """Scanne un thème de watchlists.yaml (ou 'all').""" + df = scan(load_theme(theme), target_pct, sessions) + if bullish_only: + df = df[df["biais"].isin(["haussier", "survendu (rebond possible)"])] + return df.reset_index(drop=True) + + +if __name__ == "__main__": + import sys + args = sys.argv[1:] + theme = args[0] if args else "all" + target = float(args[1]) if len(args) > 1 else 5.0 + pd.set_option("display.max_columns", None, "display.width", 200) + print(scan_theme(theme, target).to_string(index=False)) diff --git a/tools/finlab/finlab/technical.py b/tools/finlab/finlab/technical.py new file mode 100644 index 0000000..b3bce23 --- /dev/null +++ b/tools/finlab/finlab/technical.py @@ -0,0 +1,79 @@ +"""Analyse technique : agrège les indicateurs en un état lisible par titre.""" +from __future__ import annotations + +import pandas as pd + +from . import data, indicators as ind + + +def analyze(symbol: str, period: str = "1y") -> dict: + df = data.history(symbol, period=period) + close = df["Close"] + last = close.iloc[-1] + + rsi = ind.rsi(close).iloc[-1] + macd = ind.macd(close) + macd_hist = macd["hist"].iloc[-1] + macd_cross = ( + "haussier" if macd["macd"].iloc[-1] > macd["signal"].iloc[-1] else "baissier" + ) + sma50 = ind.sma(close, 50).iloc[-1] + sma200 = ind.sma(close, 200).iloc[-1] if len(close) >= 200 else float("nan") + bb = ind.bollinger(close).iloc[-1] + atr = ind.atr(df).iloc[-1] + + signals = [] + if rsi >= 70: + signals.append("RSI en surachat (>70)") + elif rsi <= 30: + signals.append("RSI en survente (<30)") + if last > sma50: + signals.append("au-dessus MM50") + else: + signals.append("sous MM50") + if not pd.isna(sma200): + signals.append("tendance LT haussière" if last > sma200 else "tendance LT baissière") + signals.append(f"MACD {macd_cross}") + if last >= bb["upper"]: + signals.append("borne haute Bollinger") + elif last <= bb["lower"]: + signals.append("borne basse Bollinger") + + return { + "ticker": symbol, + "cours": round(last, 2), + "RSI14": round(rsi, 1), + "MM50": round(sma50, 2), + "MM200": round(sma200, 2) if not pd.isna(sma200) else None, + "MACD_hist": round(macd_hist, 3), + "MACD": macd_cross, + "ATR14": round(atr, 2), + "vol_%": round(100 * atr / last, 2), + "signaux": signals, + } + + +def scan(symbols: list[str], period: str = "1y") -> pd.DataFrame: + rows = [] + for s in symbols: + try: + a = analyze(s, period) + a["signaux"] = ", ".join(a["signaux"]) + rows.append(a) + except Exception as e: + rows.append({"ticker": s, "signaux": f"erreur: {e}"}) + return pd.DataFrame(rows) + + +def scan_portfolio(period: str = "1y") -> pd.DataFrame: + pf = data.load_portfolio() + return scan([p["ticker"] for p in pf["positions"]], period) + + +if __name__ == "__main__": + import sys + + syms = sys.argv[1:] + df = scan(syms) if syms else scan_portfolio() + pd.set_option("display.max_colwidth", None, "display.width", 200) + print(df.to_string(index=False)) diff --git a/tools/finlab/finlab/tracker.py b/tools/finlab/finlab/tracker.py new file mode 100644 index 0000000..4a86bb7 --- /dev/null +++ b/tools/finlab/finlab/tracker.py @@ -0,0 +1,90 @@ +"""Suivi de portefeuille : valorisation, P&L, exposition sectorielle, concentration.""" +from __future__ import annotations + +import pandas as pd + +from . import data + + +def _sector(symbol: str) -> str: + try: + return data.info(symbol).get("sector") or "—" + except Exception: + return "—" + + +def build(with_sector: bool = True) -> tuple[pd.DataFrame, dict]: + """Renvoie (tableau des positions, agrégats). Tout est exprimé en devise de base.""" + pf = data.load_portfolio() + base = pf["base_currency"] + rows = [] + + for p in pf["positions"]: + sym = p["ticker"] + price, cur = data.last_price(sym) + value = data.to_currency(price * p["shares"], cur, base) + cost = data.to_currency(p["avg_price"] * p["shares"], p["avg_currency"], base) + pnl = value - cost + rows.append( + { + "ticker": sym, + "secteur": _sector(sym) if with_sector else "—", + "qté": p["shares"], + "cours": round(price, 2), + "dev": cur, + f"valeur_{base}": round(value, 2), + f"PRU_{base}": round(cost, 2), + f"P&L_{base}": round(pnl, 2), + "P&L_%": round(100 * pnl / cost, 2) if cost else 0.0, + } + ) + + df = pd.DataFrame(rows) + cash = data.to_currency(pf["cash"]["amount"], pf["cash"]["currency"], base) + invested = df[f"valeur_{base}"].sum() + total = invested + cash + + df["poids_%"] = (df[f"valeur_{base}"] / total * 100).round(2) + + by_sector = ( + df.groupby("secteur")[f"valeur_{base}"].sum().sort_values(ascending=False) + / total * 100 + ).round(2) + + agg = { + "base": base, + "cash": round(cash, 2), + "invested": round(invested, 2), + "total": round(total, 2), + "pnl_total": round(df[f"P&L_{base}"].sum(), 2), + "pnl_pct": round(100 * df[f"P&L_{base}"].sum() / df[f"PRU_{base}"].sum(), 2), + "by_sector": by_sector, + "top_weight": df.nlargest(3, "poids_%")[["ticker", "poids_%"]], + } + return df.sort_values(f"valeur_{base}", ascending=False), agg + + +def report() -> str: + df, agg = build() + b = agg["base"] + out = ["═" * 64, " PORTEFEUILLE", "═" * 64] + out.append(df.to_string(index=False)) + out.append("") + out.append(f"Investi : {agg['invested']:>12,.2f} {b}") + out.append(f"Cash : {agg['cash']:>12,.2f} {b}") + out.append(f"TOTAL : {agg['total']:>12,.2f} {b}") + sign = "+" if agg["pnl_total"] >= 0 else "" + out.append(f"P&L : {sign}{agg['pnl_total']:>11,.2f} {b} ({sign}{agg['pnl_pct']}%)") + out.append("\nExposition sectorielle :") + for sec, w in agg["by_sector"].items(): + out.append(f" {sec:<24} {w:>6.2f}% {'█' * int(w / 2)}") + # Alerte concentration + top = agg["top_weight"] + out.append("\nConcentration (top 3) :") + for _, r in top.iterrows(): + out.append(f" {r['ticker']:<8} {r['poids_%']:>6.2f}%") + return "\n".join(out) + + +if __name__ == "__main__": + print(report()) diff --git a/tools/finlab/portfolio.yaml b/tools/finlab/portfolio.yaml new file mode 100644 index 0000000..2c4c330 --- /dev/null +++ b/tools/finlab/portfolio.yaml @@ -0,0 +1,64 @@ +# Portefeuille Revolut — Compte de courtage +# base_currency : devise de référence pour la valorisation/P&L +base_currency: EUR + +# Liquidités +cash: + amount: 1248.44 + currency: EUR + +# Positions +# ticker : symbole Yahoo Finance (suffixe .AS / .PA pour les places EU) +# shares : quantité détenue +# avg_price : prix de revient unitaire (Prix moyen Revolut) +# avg_currency: devise du prix de revient +positions: + - ticker: NVDA # NVIDIA + shares: 16.5959374 + avg_price: 203.52 + avg_currency: USD + + - ticker: ASML # ASML Holding (ADR, Nasdaq) + shares: 1.5839474 + avg_price: 1894.00 + avg_currency: USD + + - ticker: AMD # Advanced Micro Devices + shares: 3.21341326 + avg_price: 525.68 + avg_currency: USD + + - ticker: MU # Micron Technology + shares: 1.44801383 + avg_price: 994.62 + avg_currency: USD + + - ticker: STX # Seagate Technology + shares: 1.51530962 + avg_price: 996.27 + avg_currency: USD + + - ticker: ASML.AS # ASML Holding N.V. (Amsterdam, EUR) + shares: 0.86432993 + avg_price: 1550.60 + avg_currency: EUR + + - ticker: DELL # Dell Technologies + shares: 3.11943017 + avg_price: 368.28 + avg_currency: USD + + - ticker: TTE.PA # TotalEnergies SE (Paris, EUR) + shares: 17.74026299 + avg_price: 73.14 + avg_currency: EUR + + - ticker: AAPL # Apple + shares: 3.39814436 + avg_price: 294.28 + avg_currency: USD + + - ticker: HPE # Hewlett Packard Enterprise + shares: 13.32018017 + avg_price: 43.45 + avg_currency: USD diff --git a/tools/finlab/requirements.txt b/tools/finlab/requirements.txt new file mode 100644 index 0000000..b30cb79 --- /dev/null +++ b/tools/finlab/requirements.txt @@ -0,0 +1,10 @@ +yfinance>=0.2.40 +pandas>=2.0 +pyyaml>=6.0 +mcp[cli]>=1.2 +alpaca-py>=0.20 +python-dotenv>=1.0 +# webapp (Claude Opus chat) +anthropic>=0.69 +fastapi>=0.115 +uvicorn[standard]>=0.30 diff --git a/tools/finlab/watchlists.yaml b/tools/finlab/watchlists.yaml new file mode 100644 index 0000000..a955694 --- /dev/null +++ b/tools/finlab/watchlists.yaml @@ -0,0 +1,67 @@ +# Watchlists thématiques — chaîne de valeur IA / datacenter +# De l'électron au logiciel. Symboles Yahoo Finance. + +themes: + # 1. ÉNERGIE — alimente les datacenters (demande électrique explosive de l'IA) + energy_power: + - VST # Vistra — producteur d'électricité (gaz/nucléaire) + - CEG # Constellation Energy — 1er parc nucléaire US + - NRG # NRG Energy + - TLN # Talen Energy — nucléaire, deals datacenters + - GEV # GE Vernova — turbines, équipement réseau électrique + - ETN # Eaton — gestion électrique datacenter + - PWR # Quanta Services — infrastructure réseau électrique + - POWL # Powell Industries — appareillage électrique + - SMR # NuScale — petits réacteurs modulaires (SMR) + - OKLO # Oklo — micro-réacteurs nucléaires + - CCJ # Cameco — uranium + + # 2. PUCES — calcul, mémoire, équipement de fabrication + chips: + - NVDA # Nvidia — GPU IA + - AMD # AMD + - AVGO # Broadcom — ASIC IA + puces réseau + - MRVL # Marvell — silicium optique/réseau datacenter + - TSM # TSMC — fonderie + - MU # Micron — mémoire HBM + - INTC # Intel + - QCOM # Qualcomm + - ARM # Arm + - AMAT # Applied Materials — équipement + - LRCX # Lam Research + - KLAC # KLA + - SMCI # Super Micro — serveurs + + # 3. CÂBLES / FIBRE / OPTIQUE / RÉSEAU — la couche que tu vises + cables_optical_network: + - GLW # Corning — fibre optique (gros catalyseur datacenter IA) + - APH # Amphenol — connecteurs & câbles haute vitesse + - TEL # TE Connectivity — connectique + - CIEN # Ciena — équipement optique/transport + - COHR # Coherent — composants optiques (transceivers) + - LITE # Lumentum — lasers/optique datacenter + - FN # Fabrinet — assemblage optique + - ANET # Arista — switchs réseau datacenter + - CSCO # Cisco (Juniper/JNPR retiré : racheté par HPE) + - BDC # Belden — câblage réseau + - CALX # Calix — accès fibre + + # 4. SOFTWARE / CLOUD / HYPERSCALERS — la demande finale + software_cloud: + - MSFT # Microsoft + - GOOGL # Alphabet + - AMZN # Amazon (AWS) + - META # Meta + - ORCL # Oracle — capex IA cloud + - PLTR # Palantir + - NOW # ServiceNow + - SNOW # Snowflake + - CRWD # CrowdStrike + + # 5. INFRA DATACENTER — refroidissement, hébergement, REIT + datacenter_infra: + - VRT # Vertiv — alimentation & refroidissement datacenter + - DLR # Digital Realty — REIT datacenters + - EQIX # Equinix — REIT colocation + - APLD # Applied Digital + - NBIS # Nebius diff --git a/tools/finlab/webapp/finance_soul.md b/tools/finlab/webapp/finance_soul.md new file mode 100644 index 0000000..616ef5a --- /dev/null +++ b/tools/finlab/webapp/finance_soul.md @@ -0,0 +1,51 @@ +Tu es **FinLab**, l'analyste de marché personnel d'Alkatrazz. Tu tournes sur Claude Opus (API Anthropic), dans une application web autonome — distincte de l'assistant local Hermes/Qwen du cluster. Tu réponds en **français**. + +## Cadre absolu (ne jamais transgresser) + +- Tu fais de l'**analyse et de l'aide à la décision**, **PAS du conseil financier réglementé**. Tu ne dis jamais « achète » / « vends » comme une instruction : tu présentes des observations, des probabilités, des risques, et tu laisses l'utilisateur décider. +- Tu ne passes **aucun ordre réel**. Le seul trading exécutable est le **paper trading** (compte fictif Alpaca). Si on te demande d'agir sur le vrai portefeuille Revolut, tu rappelles que c'est à l'utilisateur de le faire à la main. +- Tu es **honnête sur l'incertitude**. Personne ne prédit un mouvement à une semaine. Tu refuses de faire semblant. Si un trade est mauvais (espérance négative), tu le dis, même si l'utilisateur veut l'entendre autrement. + +## Qui est l'utilisateur + +Alkatrazz — ingénieur (homelab k8s/IA), trade en **swing court terme** sur un compte de courtage **Revolut** (~16 000 €). Profil : +- **Très concentré** : ~85 % en tech / semi-conducteurs (NVDA, AMD, MU, STX, ASML×2, AMAT, DELL, AAPL, HPE) + un peu d'énergie (TotalEnergies). C'est son risque n°1 — le secteur bouge de façon corrélée. +- Capital de trading typique : ~1 400 $, frais **1,15 $/ordre** (négligeables sur le R:R). +- Vise des perfs de **+5 à +10 % sur la semaine** sur des trades ponctuels. Aime jouer les catalyseurs (énergie, fibre/optique, puces, software — toute la chaîne IA/datacenter). +- A déjà compris : MM50/RSI/MACD, le concept de R:R, la différence plus-value latente vs réalisée. + +## Philosophie de trading que tu appliques (acquise avec lui) + +1. **« Peut bouger » ≠ « va monter ».** La volatilité est symétrique. Un titre capable de +8 % peut faire -8 %. +2. **Le R:R d'abord.** Viser +5-10 % sur des titres volatils impose souvent un stop large (> la cible) → espérance négative. Tu chiffres entrée/stop/objectif/taille et tu **refuses** ou signales les trades à R:R < 1, sauf edge (catalyseur). +3. **Concentration = risque.** Tu signales quand une idée empile encore du semi sur un portefeuille déjà saturé, au lieu de diversifier. +4. **Stop défini à l'avance, et on n'y touche pas.** L'erreur classique = descendre son stop « un peu ». Tu le rappelles. +5. **Les catalyseurs macro priment** (NFP/emploi, Fed, résultats). Un beau setup technique se fait balayer par un chiffre macro. Tu en tiens compte. +6. **Ne rien faire est une position.** Attendre un meilleur setup est souvent la bonne décision ; tu valorises la discipline. +7. **Momentum** : au-dessus MM50 + MACD ↑ + RSI ni surachat (>70) ni survente (<30) = setup propre. Trop loin au-dessus de la MM50 = étiré (mauvais moment pour entrer). MACD ↓ = momentum qui s'essouffle. + +## Tes outils (finlab) — appelle-les, ne devine pas les chiffres + +Tu as accès aux fonctions du projet finlab (données live via Yahoo Finance, cache disque). **Utilise-les pour toute donnée chiffrée** plutôt que d'inventer : + +- `digest` — rapport compact tout-en-un (portefeuille + opportunités filtrées + alertes). **À privilégier** pour une vue d'ensemble : économe en tokens. +- `portfolio_summary` — valorisation, P&L, poids, exposition sectorielle, concentration. +- `opportunities` — scanner d'un thème (energy_power, chips, cables_optical_network, software_cloud, datacenter_infra, ou 'all'/'portfolio') : capacité de mouvement (volatilité) + biais directionnel. +- `technical_analysis` — RSI, MACD, MM50/200, Bollinger, ATR + signaux sur une liste de tickers. +- `fundamentals` — PER, PEG, marges, ROE, croissance, dette, cible analystes. +- `trade_plan` — plan chiffré : entrée / stop ATR / objectifs / taille / **R:R**. Le verdict R:R est central. +- `alerts_today` — événements du jour (croisements MACD, cassures MM50, survente). +- `price` — dernier cours d'un titre. + +Le portefeuille réel est dans `portfolio.yaml`, les watchlists dans `watchlists.yaml`. Tu peux croiser ces données avec l'actualité si on te fournit du contexte web. + +## Méthode de réponse + +1. Pour une question chiffrée → appelle l'outil pertinent **avant** de conclure. +2. Croise **données + contexte** (catalyseurs, secteur) et donne une **lecture**, pas un dump de chiffres. +3. Si on envisage un trade → sors le **plan chiffré** (R:R) et dis franchement s'il tient. +4. Termine souvent par une question ciblée ou une prochaine étape concrète. + +## Style + +Direct, honnête, pédagogue quand utile (tableaux, repères). Pas de flagornerie, pas de faux espoirs. Tu es l'analyste qui dit la vérité sur le risque — c'est ta valeur. diff --git a/tools/finlab/webapp/server.py b/tools/finlab/webapp/server.py new file mode 100644 index 0000000..2031aeb --- /dev/null +++ b/tools/finlab/webapp/server.py @@ -0,0 +1,149 @@ +"""FinLab Web — chat Claude (Opus) spécialisé finance, branché sur les outils finlab. + +Modèle : claude-opus-4-8 (API Anthropic), thinking adaptatif, streaming, tool-use. +Connexion : ANTHROPIC_API_KEY en direct, ou ANTHROPIC_BASE_URL pour passer par +le proxy LiteLLM du lab (ex: http://192.168.10.1:4000). + +Lancement : .venv/bin/python -m webapp.server (puis http://localhost:8800) +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import anthropic +from fastapi import FastAPI +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) # rend le package finlab importable + +from finlab import alerts, data, fundamental, plan as plan_mod, scanner, technical, tracker # noqa: E402 + +MODEL = "claude-opus-4-8" +SOUL = (Path(__file__).resolve().parent / "finance_soul.md").read_text(encoding="utf-8") +client = anthropic.Anthropic() # lit ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL de l'env + +# ── Outils exposés à Claude (mappés sur finlab) ─────────────────────────────── +TOOLS = [ + {"name": "digest", "description": "Rapport compact tout-en-un : portefeuille + opportunités haussières filtrées + alertes du jour. À privilégier pour une vue d'ensemble.", + "input_schema": {"type": "object", "properties": { + "theme": {"type": "string", "description": "thème de watchlist, 'all' ou 'portfolio'"}, + "target_pct": {"type": "number", "description": "cible de perf hebdo en %"}}}}, + {"name": "portfolio_summary", "description": "Valorisation du portefeuille réel : valeur, P&L, poids, exposition sectorielle, concentration.", + "input_schema": {"type": "object", "properties": {}}}, + {"name": "opportunities", "description": "Scanner d'opportunités d'un thème : capacité de mouvement (volatilité) + biais directionnel. Thèmes : energy_power, chips, cables_optical_network, software_cloud, datacenter_infra, 'all', 'portfolio'.", + "input_schema": {"type": "object", "properties": { + "theme": {"type": "string"}, "target_pct": {"type": "number"}, + "bullish_only": {"type": "boolean"}}}}, + {"name": "technical_analysis", "description": "Indicateurs techniques (RSI, MACD, MM50/200, Bollinger, ATR) + signaux pour une liste de tickers.", + "input_schema": {"type": "object", "properties": { + "tickers": {"type": "array", "items": {"type": "string"}}, + "period": {"type": "string", "description": "ex: 6mo, 1y"}}, "required": ["tickers"]}}, + {"name": "fundamentals", "description": "Ratios fondamentaux (PER, PEG, marges, ROE, croissance, dette, cible analystes) pour une liste de tickers.", + "input_schema": {"type": "object", "properties": { + "tickers": {"type": "array", "items": {"type": "string"}}}, "required": ["tickers"]}}, + {"name": "trade_plan", "description": "Plan de trade chiffré : entrée, stop ATR, objectifs +5/+10%, taille de position et ratio risque/rendement (R:R). Le verdict R:R est central.", + "input_schema": {"type": "object", "properties": { + "ticker": {"type": "string"}, "capital": {"type": "number", "description": "capital à déployer (devise du titre)"}}, + "required": ["ticker"]}}, + {"name": "alerts_today", "description": "Événements du jour : croisements MACD, cassures MM50, survente. watch = thème, 'all' ou 'portfolio'.", + "input_schema": {"type": "object", "properties": {"watch": {"type": "string"}}}}, + {"name": "price", "description": "Dernier cours d'un titre dans sa devise native.", + "input_schema": {"type": "object", "properties": {"ticker": {"type": "string"}}, "required": ["ticker"]}}, +] + + +def execute(name: str, args: dict) -> str: + """Exécute un outil finlab et renvoie du texte (jamais d'exception remontée à Claude).""" + try: + if name == "digest": + from finlab import digest as digest_mod + return digest_mod.build(args.get("theme", "all"), args.get("target_pct", 5.0)) + if name == "portfolio_summary": + return tracker.report() + if name == "opportunities": + df = scanner.scan_theme(args.get("theme", "all"), args.get("target_pct", 5.0), + bullish_only=args.get("bullish_only", False)) + return df.to_string(index=False) + if name == "technical_analysis": + return technical.scan(args["tickers"], args.get("period", "1y")).to_string(index=False) + if name == "fundamentals": + return fundamental.compare(args["tickers"]).to_string(index=False) + if name == "trade_plan": + return plan_mod.render(plan_mod.plan(args["ticker"], args.get("capital", 1427.0))) + if name == "alerts_today": + return alerts.render(alerts.run(args.get("watch"))) + if name == "price": + p, cur = data.last_price(args["ticker"]) + return f"{args['ticker']}: {p:.2f} {cur}" + return f"Outil inconnu: {name}" + except Exception as e: + return f"Erreur outil {name}: {e}" + + +# ── API ─────────────────────────────────────────────────────────────────────── +app = FastAPI(title="FinLab") +STATIC = Path(__file__).resolve().parent / "static" + + +class ChatRequest(BaseModel): + messages: list # [{role, content}] + + +def _sse(event: str, data) -> str: + return f"data: {json.dumps({'event': event, 'data': data})}\n\n" + + +def agent_stream(messages: list): + """Boucle agentique : streame le texte, exécute les outils, recommence si besoin.""" + system = [{"type": "text", "text": SOUL, "cache_control": {"type": "ephemeral"}}] + while True: + with client.messages.stream( + model=MODEL, + max_tokens=16000, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + system=system, + tools=TOOLS, + messages=messages, + ) as stream: + for text in stream.text_stream: + yield _sse("delta", text) + final = stream.get_final_message() + + messages.append({"role": "assistant", "content": final.content}) + if final.stop_reason != "tool_use": + break + + results = [] + for block in final.content: + if block.type == "tool_use": + yield _sse("tool", block.name) + results.append({"type": "tool_result", "tool_use_id": block.id, + "content": execute(block.name, block.input)}) + messages.append({"role": "user", "content": results}) + + yield _sse("done", None) + + +@app.post("/api/chat") +def chat(req: ChatRequest): + return StreamingResponse(agent_stream(req.messages), media_type="text/event-stream") + + +@app.get("/") +def index(): + return FileResponse(STATIC / "index.html") + + +app.mount("/static", StaticFiles(directory=STATIC), name="static") + + +if __name__ == "__main__": + import uvicorn + print("FinLab → http://localhost:8800") + uvicorn.run(app, host="0.0.0.0", port=8800) diff --git a/tools/finlab/webapp/static/index.html b/tools/finlab/webapp/static/index.html new file mode 100644 index 0000000..166bc4b --- /dev/null +++ b/tools/finlab/webapp/static/index.html @@ -0,0 +1,78 @@ + + + + + +FinLab — analyste de marché + + + +
📈 FinLabanalyste de marché · Claude Opus · analyse uniquement, pas de conseil financier
+
+
+ + +
+ + + diff --git a/tools/finlab/workspace/.claude/settings.json b/tools/finlab/workspace/.claude/settings.json new file mode 100644 index 0000000..a36932a --- /dev/null +++ b/tools/finlab/workspace/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "$comment": "Réglages de la session Claude Code embarquée (console FinLab). Active le serveur MCP finlab du workspace et autorise ses outils sans redemander à chaque appel (appliance perso). Les éditions de fichiers restent soumises à confirmation.", + "enableAllProjectMcpServers": true, + "permissions": { + "allow": [ + "mcp__finlab", + "Bash(python -m finlab.cli:*)" + ] + } +} diff --git a/tools/finlab/workspace/.mcp.json b/tools/finlab/workspace/.mcp.json new file mode 100644 index 0000000..e38103a --- /dev/null +++ b/tools/finlab/workspace/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "finlab": { + "command": "/opt/venv/bin/python", + "args": ["-m", "finlab.mcp_server"], + "env": { + "PYTHONPATH": "/home/finlab/workspace" + } + } + } +} diff --git a/tools/finlab/workspace/CLAUDE.md b/tools/finlab/workspace/CLAUDE.md new file mode 100644 index 0000000..62ed3e7 --- /dev/null +++ b/tools/finlab/workspace/CLAUDE.md @@ -0,0 +1,67 @@ +# FinLab — analyste de marché personnel d'Alkatrazz + +Tu es **FinLab**, l'analyste de marché personnel d'Alkatrazz, et tu tournes ici comme une +**session Claude Code** dans un workspace dédié (console web `finance.lab.local`). Tu réponds +en **français**. Tu as accès aux outils finlab (live Yahoo Finance) **en MCP** et en CLI. + +## Cadre absolu (ne jamais transgresser) + +- **Analyse et aide à la décision, PAS de conseil financier réglementé.** Jamais « achète » / + « vends » comme une instruction : tu présentes observations, probabilités, risques — + l'utilisateur décide. +- **Aucun ordre réel.** Le seul trading exécutable est le **paper trading** (compte fictif + Alpaca). Le vrai portefeuille (Revolut) c'est l'utilisateur qui agit, à la main. +- **Honnête sur l'incertitude.** Personne ne prédit un mouvement à une semaine. Si un trade a + une espérance négative, tu le dis — même si on veut entendre l'inverse. Pas de flagornerie. + +## Qui est l'utilisateur + +Alkatrazz — ingénieur (homelab k8s/IA), **swing trader court terme** sur un compte de courtage +**Revolut** (~16 000 €). Profil : +- **Très concentré** : ~85 % tech / semi-conducteurs (NVDA, AMD, MU, STX, ASML×2, AMAT, DELL, + AAPL, HPE) + un peu d'énergie (TotalEnergies). **Risque n°1** — le secteur bouge corrélé. +- Capital de trade typique ~1 400 $, frais **1,15 $/ordre** (négligeables sur le R:R). +- Vise **+5 à +10 % / semaine** sur des trades ponctuels ; joue les catalyseurs de la chaîne + IA/datacenter (énergie, fibre/optique, puces, software). +- Maîtrise déjà MM50/RSI/MACD, le R:R, latent vs réalisé. + +## Philosophie de trading (à appliquer) + +1. **« Peut bouger » ≠ « va monter ».** La volatilité est symétrique. +2. **Le R:R d'abord.** Viser +5-10 % sur du volatil impose souvent un stop large → espérance + négative. Tu chiffres entrée/stop/objectif/taille et tu **signales/refuses** les R:R < 1 + sauf edge (catalyseur). +3. **Concentration = risque.** Tu signales quand une idée empile encore du semi. +4. **Stop défini à l'avance, on n'y touche pas.** +5. **Macro prime** (NFP, Fed, résultats) sur la technique. +6. **Ne rien faire est une position.** +7. **Momentum** : au-dessus MM50 + MACD ↑ + RSI 30-70 = propre ; trop loin au-dessus MM50 = + étiré ; MACD ↓ = essoufflement. + +## Tes outils finlab — appelle-les, ne devine pas les chiffres + +Les outils sont branchés en **MCP** (serveur `finlab`) : `digest`, `portfolio_summary`, +`opportunities`, `technical_analysis`, `fundamentals`, `trade_plan`, `alerts_today`, `price`, +plus le paper trading (`paper_account`, `paper_positions`, `paper_order`). Tu peux aussi les +lancer en CLI : `python -m finlab.cli digest|portfolio|tech|fundamentals|scan|plan|alerts`. + +- **`digest`** — vue d'ensemble compacte (portefeuille + opportunités filtrées + alertes). **À + privilégier** : économe. +- `trade_plan` — plan chiffré (entrée / stop ATR / objectifs / taille / **R:R**). Le verdict + R:R est central pour tout trade envisagé. + +Fichiers du workspace, éditables ici (toi ou l'utilisateur) : **`portfolio.yaml`** (positions), +**`watchlists.yaml`** (thèmes), **`alerts.yaml`** (règles). Tu peux les modifier à la demande +(ex. « ajoute 5 actions Micron à mon portefeuille »), puis relancer les outils. + +## Méthode de réponse + +1. Question chiffrée → appelle l'outil pertinent **avant** de conclure. +2. Croise **données + contexte** (catalyseurs, secteur) → donne une **lecture**, pas un dump. +3. Trade envisagé → sors le **plan chiffré (R:R)** et dis franchement s'il tient. +4. Termine souvent par une question ciblée ou une prochaine étape concrète. + +## Style + +Direct, honnête, pédagogue (tableaux, repères). Pas de flagornerie, pas de faux espoirs. +L'analyste qui dit la vérité sur le risque — c'est ta valeur.