mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 18:14:42 +02:00
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>
This commit is contained in:
parent
35ad1deb64
commit
b649afd059
39 changed files with 2110 additions and 2 deletions
64
.github/workflows/build-finlab.yml
vendored
Normal file
64
.github/workflows/build-finlab.yml
vendored
Normal 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
11
.mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<nom>.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.
|
||||
|
||||
|
|
|
|||
58
admin/ia/finlab.md
Normal file
58
admin/ia/finlab.md
Normal 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`.
|
||||
24
k8s/apps-of-apps/apps/finlab.yaml
Normal file
24
k8s/apps-of-apps/apps/finlab.yaml
Normal 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
68
k8s/apps/finlab/README.md
Normal 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'
|
||||
```
|
||||
72
k8s/apps/finlab/deployment.yaml
Normal file
72
k8s/apps/finlab/deployment.yaml
Normal 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
|
||||
17
k8s/apps/finlab/ingress.yaml
Normal file
17
k8s/apps/finlab/ingress.yaml
Normal 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
|
||||
11
k8s/apps/finlab/middleware.yaml
Normal file
11
k8s/apps/finlab/middleware.yaml
Normal 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
15
k8s/apps/finlab/pvc.yaml
Normal 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
|
||||
12
k8s/apps/finlab/service.yaml
Normal file
12
k8s/apps/finlab/service.yaml
Normal 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
|
||||
7
tools/finlab/.dockerignore
Normal file
7
tools/finlab/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.venv/
|
||||
.cache/
|
||||
reports/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.git/
|
||||
6
tools/finlab/.gitignore
vendored
Normal file
6
tools/finlab/.gitignore
vendored
Normal 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
|
||||
50
tools/finlab/Dockerfile
Normal file
50
tools/finlab/Dockerfile
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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) + ttyd (terminal web) + git + tini (PID 1 propre).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates git ttyd tini procps \
|
||||
&& 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 purge -y curl \
|
||||
&& 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
51
tools/finlab/README.md
Normal 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
35
tools/finlab/alerts.yaml
Normal 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
|
||||
31
tools/finlab/entrypoint.sh
Normal file
31
tools/finlab/entrypoint.sh
Normal 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
|
||||
6
tools/finlab/finlab/__init__.py
Normal file
6
tools/finlab/finlab/__init__.py
Normal 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"
|
||||
88
tools/finlab/finlab/alerts.py
Normal file
88
tools/finlab/finlab/alerts.py
Normal 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)))
|
||||
97
tools/finlab/finlab/cli.py
Normal file
97
tools/finlab/finlab/cli.py
Normal 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
113
tools/finlab/finlab/data.py
Normal 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)
|
||||
82
tools/finlab/finlab/digest.py
Normal file
82
tools/finlab/finlab/digest.py
Normal 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}]")
|
||||
66
tools/finlab/finlab/fundamental.py
Normal file
66
tools/finlab/finlab/fundamental.py
Normal 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))
|
||||
48
tools/finlab/finlab/indicators.py
Normal file
48
tools/finlab/finlab/indicators.py
Normal 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()
|
||||
98
tools/finlab/finlab/mcp_server.py
Normal file
98
tools/finlab/finlab/mcp_server.py
Normal 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()
|
||||
91
tools/finlab/finlab/paper.py
Normal file
91
tools/finlab/finlab/paper.py
Normal 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)
|
||||
88
tools/finlab/finlab/plan.py
Normal file
88
tools/finlab/finlab/plan.py
Normal 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()
|
||||
121
tools/finlab/finlab/scanner.py
Normal file
121
tools/finlab/finlab/scanner.py
Normal 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))
|
||||
79
tools/finlab/finlab/technical.py
Normal file
79
tools/finlab/finlab/technical.py
Normal 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))
|
||||
90
tools/finlab/finlab/tracker.py
Normal file
90
tools/finlab/finlab/tracker.py
Normal 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())
|
||||
64
tools/finlab/portfolio.yaml
Normal file
64
tools/finlab/portfolio.yaml
Normal 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
|
||||
10
tools/finlab/requirements.txt
Normal file
10
tools/finlab/requirements.txt
Normal 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
|
||||
67
tools/finlab/watchlists.yaml
Normal file
67
tools/finlab/watchlists.yaml
Normal 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
|
||||
51
tools/finlab/webapp/finance_soul.md
Normal file
51
tools/finlab/webapp/finance_soul.md
Normal 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.
|
||||
149
tools/finlab/webapp/server.py
Normal file
149
tools/finlab/webapp/server.py
Normal 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)
|
||||
78
tools/finlab/webapp/static/index.html
Normal file
78
tools/finlab/webapp/static/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// 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>
|
||||
10
tools/finlab/workspace/.claude/settings.json
Normal file
10
tools/finlab/workspace/.claude/settings.json
Normal 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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
tools/finlab/workspace/.mcp.json
Normal file
11
tools/finlab/workspace/.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"finlab": {
|
||||
"command": "/opt/venv/bin/python",
|
||||
"args": ["-m", "finlab.mcp_server"],
|
||||
"env": {
|
||||
"PYTHONPATH": "/home/finlab/workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
tools/finlab/workspace/CLAUDE.md
Normal file
67
tools/finlab/workspace/CLAUDE.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue