feat(finlab): console Claude Code finance in-cluster + toolkit d'analyse (#64)

* feat(finlab): console Claude Code finance in-cluster + toolkit d'analyse

Intègre finlab (ex-projet Projets/Finance) au lab comme une console Claude Code
web spécialisée finance — l'esprit OpenAlice, mais c'est le vrai Claude Code sur
l'abonnement (login persisté, pas d'API facturée), agentique, avec la boîte à
outils finlab (Yahoo Finance) branchée en MCP.

- tools/finlab/ : source finlab rapatriée + Dockerfile (Python 3.12 + Node +
  claude-code + ttyd) + persona workspace/CLAUDE.md + branchement MCP + entrypoint
  (seed du workspace no-clobber sur le PVC)
- .github/workflows/build-finlab.yml : build GHCR funk-finlab + bump manifest (main)
- k8s/apps/finlab/ : Deployment/Service/PVC/IngressRoute (finance.lab.local) +
  Middleware basicAuth (shell web protégé) ; PVC = HOME (login) + workspace
- k8s/apps-of-apps/apps/finlab.yaml : Application ArgoCD
- .mcp.json (racine) : outils finlab dans les sessions Claude Code du lab
- admin/ia/finlab.md + READMEs + CLAUDE.md : doc + enregistrement

Analyse/aide à la décision uniquement — aucun ordre réel (paper trading Alpaca
fictif seul exécutable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(finlab): ttyd absent des dépôts bookworm → binaire statique GitHub

Le build amont échouait (`E: Package 'ttyd' has no installation candidate`) :
ttyd n'est pas packagé dans Debian bookworm. On récupère le binaire statique
(musl, pin TTYD_VERSION=1.7.7) depuis les releases GitHub. Build complet validé
en local (podman) : ttyd 1.7.7, claude-code 2.1.195, import finlab + seed OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-29 23:26:09 +02:00 committed by GitHub
parent 35ad1deb64
commit 54f6f7c634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2115 additions and 2 deletions

64
.github/workflows/build-finlab.yml vendored Normal file
View file

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

11
.mcp.json Normal file
View file

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

View file

@ -163,6 +163,7 @@ k8s/
│ ├── searxng.yaml │ ├── searxng.yaml
│ ├── ghostfolio.yaml │ ├── ghostfolio.yaml
│ ├── openalice.yaml │ ├── openalice.yaml
│ ├── finlab.yaml
│ ├── monitoring.yaml │ ├── monitoring.yaml
│ └── nfs-provisioner.yaml │ └── nfs-provisioner.yaml
├── apps/ # Manifests raw k8s par application ├── apps/ # Manifests raw k8s par application
@ -171,7 +172,8 @@ k8s/
│ ├── stt/ # namespace: ai — STT-server (assistant vocal Asa, stt.lab.local) │ ├── stt/ # namespace: ai — STT-server (assistant vocal Asa, stt.lab.local)
│ ├── searxng/ # namespace: ai — méta-moteur recherche web (outil web_search d'Asa) │ ├── searxng/ # namespace: ai — méta-moteur recherche web (outil web_search d'Asa)
│ ├── ghostfolio/ # namespace: ai — suivi de portefeuille (ghostfolio.lab.local) │ ├── 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 │ └── {external-services}/ # ExternalName/Endpoints pointant vers storage-01 ou gpu-01
└── infra/ # Helm releases (HelmRelease + values.yaml) └── infra/ # Helm releases (HelmRelease + values.yaml)
├── monitoring/ # kube-prometheus-stack 85.0.2 ├── monitoring/ # kube-prometheus-stack 85.0.2
@ -186,7 +188,7 @@ k8s/
2. Créer `k8s/apps-of-apps/apps/<nom>.yaml` (ArgoCD Application CRD) — copier `n8n.yaml` comme modèle 2. Créer `k8s/apps-of-apps/apps/<nom>.yaml` (ArgoCD Application CRD) — copier `n8n.yaml` comme modèle
3. `git commit` + `git push` → ArgoCD prend en charge automatiquement 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. > Les services du namespace `ai` sont exposés via des **IngressRoute Traefik** (CRD), pas des `Ingress` standards.

58
admin/ia/finlab.md Normal file
View file

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

View file

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

68
k8s/apps/finlab/README.md Normal file
View file

@ -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-<court>`, 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=<GHCR_PAT_read_packages>
```
### 2. Secret basicAuth de la console (`finlab-auth`)
```bash
# htpasswd (paquet apache2-utils). Remplace <user>/<pass>.
htpasswd -nbB <user> '<pass>' > /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'
```

View file

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

View file

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

View file

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

15
k8s/apps/finlab/pvc.yaml Normal file
View file

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

View file

@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: finlab
namespace: ai
spec:
selector:
app: finlab
ports:
- name: web
port: 7681
targetPort: 7681

View file

@ -0,0 +1,7 @@
.venv/
.cache/
reports/
__pycache__/
*.pyc
.env
.git/

6
tools/finlab/.gitignore vendored Normal file
View file

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

55
tools/finlab/Dockerfile Normal file
View file

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

51
tools/finlab/README.md Normal file
View file

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

35
tools/finlab/alerts.yaml Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,97 @@
"""CLI unifiée : python -m finlab.cli <commande>."""
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()

113
tools/finlab/finlab/data.py Normal file
View file

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

View file

@ -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_<date>.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}]")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,78 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FinLab — analyste de marché</title>
<style>
:root { --bg:#0d1117; --panel:#161b22; --line:#30363d; --txt:#e6edf3; --mut:#8b949e; --accent:#2f81f7; --green:#3fb950; }
* { box-sizing:border-box; }
body { margin:0; font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--txt); height:100vh; display:flex; flex-direction:column; }
header { padding:12px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:10px; }
header b { font-size:16px; } header span { color:var(--mut); font-size:13px; }
#log { flex:1; overflow-y:auto; padding:20px; max-width:900px; width:100%; margin:0 auto; }
.msg { margin:0 0 18px; }
.msg .who { font-size:12px; color:var(--mut); margin-bottom:4px; }
.msg.user .bubble { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:10px 14px; }
.bubble { white-space:pre-wrap; word-wrap:break-word; }
.bubble pre { background:#0a0e14; border:1px solid var(--line); border-radius:8px; padding:10px; overflow-x:auto; font:12.5px/1.4 ui-monospace,Menlo,monospace; }
.tool { color:var(--mut); font-size:12px; font-style:italic; margin:4px 0; }
form { border-top:1px solid var(--line); padding:14px; display:flex; gap:10px; max-width:900px; width:100%; margin:0 auto; }
textarea { flex:1; background:var(--panel); border:1px solid var(--line); border-radius:10px; color:var(--txt); padding:10px 14px; resize:none; font:15px inherit; max-height:160px; }
button { background:var(--accent); border:0; color:#fff; border-radius:10px; padding:0 18px; font-weight:600; cursor:pointer; }
button:disabled { opacity:.5; cursor:default; }
</style>
</head>
<body>
<header><b>📈 FinLab</b><span>analyste de marché · Claude Opus · analyse uniquement, pas de conseil financier</span></header>
<div id="log"></div>
<form id="f">
<textarea id="q" rows="1" placeholder="Ex : fais le digest · scanne l'énergie · plan de trade sur TLN · faut-il s'inquiéter pour Micron ?"></textarea>
<button id="send">Envoyer</button>
</form>
<script>
const log = document.getElementById('log'), q = document.getElementById('q'), f = document.getElementById('f'), btn = document.getElementById('send');
let history = [];
function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// rendu léger : blocs ``` → <pre>, gras **x**, reste en texte (white-space:pre-wrap gère les sauts de ligne)
function render(t){
const parts = t.split(/```/);
return parts.map((p,i)=> i%2 ? '<pre>'+esc(p.replace(/^\w*\n/,''))+'</pre>'
: esc(p).replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')).join('');
}
function add(role, html){
const d = document.createElement('div'); d.className='msg '+role;
d.innerHTML = '<div class="who">'+(role==='user'?'Toi':'FinLab')+'</div><div class="bubble">'+html+'</div>';
log.appendChild(d); log.scrollTop = log.scrollHeight; return d.querySelector('.bubble');
}
f.onsubmit = async (e) => {
e.preventDefault();
const text = q.value.trim(); if(!text) return;
q.value=''; btn.disabled=true;
add('user', esc(text));
history.push({role:'user', content:text});
const bubble = add('assistant',''); let raw='';
const res = await fetch('/api/chat', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({messages:history})});
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf='';
while(true){
const {value,done} = await reader.read(); if(done) break;
buf += dec.decode(value,{stream:true});
const lines = buf.split('\n\n'); buf = lines.pop();
for(const line of lines){
if(!line.startsWith('data:')) continue;
const {event,data} = JSON.parse(line.slice(5));
if(event==='delta'){ raw+=data; bubble.innerHTML=render(raw); }
else if(event==='tool'){ bubble.insertAdjacentHTML('beforeend','<div class="tool">⚙︎ '+data+'…</div>'); }
log.scrollTop = log.scrollHeight;
}
}
history.push({role:'assistant', content:raw});
btn.disabled=false; q.focus();
};
q.addEventListener('keydown', e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); f.requestSubmit(); }});
</script>
</body>
</html>

View file

@ -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:*)"
]
}
}

View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"finlab": {
"command": "/opt/venv/bin/python",
"args": ["-m", "finlab.mcp_server"],
"env": {
"PYTHONPATH": "/home/finlab/workspace"
}
}
}
}

View file

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