feat(finlab): dashboard web (graphiques/portefeuille/actions) + fix seed NFS (#65)

Ajoute une interface graphique à finlab, en plus de la console Claude Code (les
deux cohabitent sur finance.lab.local) :

- dashboard/ : backend FastAPI (sans LLM/clé) exposant les données finlab en JSON
  (/api/portfolio, /api/scan, /api/ohlc, /api/plan, /api/alerts) + front statique
  (lightweight-charts) : graphiques chandeliers MM50/200 + volume, portefeuille
  (P&L/poids/secteurs), watchlists thématiques scannées, alertes, plan de trade R:R
- Découplage code/data : finlab lit ses configs depuis FINLAB_HOME si défini
  (workspace persistant) ; repli sur le dossier du package en dév local
- Image double usage (un build, deux conteneurs) : code → /opt/app, seed minimal
  (configs + persona + MCP) → workspace
- Fix seed NFS (cause racine du workspace vide) : WORKDIR n'est plus sur le volume
  (le runtime le créait en root sur NFS) ; seed déplacé dans un initContainer
  robuste (recrée le dossier s'il est non-inscriptible)
- Deployment : initContainer seed + 2 conteneurs (console ttyd -b /console, dashboard
  uvicorn) ; Service 2 ports ; IngressRoute 2 routes (/console + dashboard), basicAuth

Validé en local (podman) : seed OK, dashboard /api/portfolio → 16k€/10 positions
via FINLAB_HOME, endpoints ohlc/scan/plan/alerts OK, ttyd/claude/finlab présents.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-30 00:57:23 +02:00 committed by GitHub
parent 607e5e6c0a
commit 56cb32eacc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 700 additions and 149 deletions

View file

@ -173,7 +173,7 @@ k8s/
│ ├── 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) — ⏸️ SUSPENDU
│ ├── finlab/ # namespace: ai — console Claude Code finance (finance.lab.local) + toolkit d'analyse de marché
│ ├── finlab/ # namespace: ai — dashboard finance + console Claude Code (finance.lab.local, /console) + toolkit
│ └── {external-services}/ # ExternalName/Endpoints pointant vers storage-01 ou gpu-01
└── infra/ # Helm releases (HelmRelease + values.yaml)
├── monitoring/ # kube-prometheus-stack 85.0.2

View file

@ -1,10 +1,16 @@
# FinLab — console Claude Code finance + toolkit d'analyse de marché
# FinLab — dashboard finance + console Claude Code + toolkit d'analyse
**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.
déployée in-cluster sous **deux faces** (une seule image) : un **dashboard web** (graphiques,
portefeuille, watchlists, actions) et une **console Claude Code** finance. Remplace l'approche
OpenAlice abandonnée (cf. `admin/ia/openalice.md`) par plus simple et plus puissant : **ton vrai
Claude Code** (sur ton abonnement, pas l'API au compteur) + une UI graphique gratuite.
- **Dashboard** (`finance.lab.local/`) — graphiques chandeliers (MM50/200 + volume), portefeuille
(P&L/poids/secteurs), watchlists thématiques scannées, alertes du jour, plan de trade R:R.
FastAPI `dashboard/server.py`, **aucun LLM/clé** (analyse technique pure).
- **Console** (`finance.lab.local/console/`) — terminal web (`ttyd`) → `claude` agentique,
persona + outils finlab en MCP, login abonnement persisté.
**Où** :
- Source : `tools/finlab/` (code finlab + Dockerfile + persona `workspace/CLAUDE.md`).
@ -21,14 +27,18 @@ trader concentré tech/semis, philosophie R:R/concentration/discipline).
| 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 |
| initContainer `seed` | Peuple le workspace (`cp -rn`), robuste au piège NFS (fsGroup non honoré → recrée le dossier s'il est root/non-inscriptible). Tourne avant les conteneurs |
| container `dashboard` | `uvicorn dashboard.server:app` :8800 — UI + API JSON finlab |
| container `console` | `ttyd -b /console` :7681 → `claude` dans le workspace |
| Cerveau (console) | **Abonnement Claude Code** (login OAuth persisté dans `~/.claude` sur le PVC) — pas de clé API |
| Code vs data | Code (`finlab` + `dashboard`) dans l'image `/opt/app` (maj avec l'image) ; configs éditables via **`FINLAB_HOME=/home/finlab/workspace`** (PVC) |
| PVC `finlab-data` | 5Gi NFS → `/home/finlab` : login + workspace (configs + `.cache`) |
| Auth | basicAuth Traefik (`finlab-auth`) sur les deux routes — un shell web ne s'expose pas nu |
| Routes | `/console` → 7681 · reste → 8800 |
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.
**Découplage code/data** : `finlab` lit ses configs depuis `FINLAB_HOME` si défini (sinon repli
sur le dossier du package → dév local inchangé). Le workspace est seedé au 1er boot puis vit sur
le PVC (edits + login persistent ; une nouvelle image n'ajoute que les fichiers manquants).
## Outils finlab

View file

@ -1,32 +1,34 @@
# FinLab — console Claude Code finance (in-cluster)
# FinLab — dashboard + console Claude Code (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.
Deux usages, **une image**, un workspace partagé :
> ⚠️ 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).
- **Dashboard** (`finance.lab.local/`) — interface web : graphiques chandeliers (MM50/200 +
volume), portefeuille (P&L, poids, secteurs), watchlists thématiques scannées, alertes du
jour, actions (plan de trade R:R). Données finlab (Yahoo Finance) — **aucun LLM, aucune clé**.
- **Console** (`finance.lab.local/console/`) — vraie session **Claude Code** dans un terminal
web (`ttyd`), sur ton **abonnement** (login persisté), persona finance + outils finlab en MCP.
## Image
> ⚠️ La console expose un **shell web** → tout est derrière **basicAuth Traefik**. Analyse et
> aide à la décision uniquement : **aucun ordre réel** (paper trading Alpaca fictif seul).
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`.
## Image & code
## Architecture du déploiement
CI (`.github/workflows/build-finlab.yml`) build depuis `tools/finlab/``ghcr.io/alkatrazz24/funk-finlab`.
Le **code** (package `finlab` + backend `dashboard`) vit dans l'image (`/opt/app`) et se met à
jour avec elle. Les **configs éditables** (portefeuille/watchlists/alertes) + la persona + le
login vivent dans le **workspace persistant** (`FINLAB_HOME=/home/finlab/workspace`, sur le PVC).
| Composant | Détail |
## Architecture du pod
| Élément | Rôle |
|---|---|
| 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.
| initContainer `seed` | Peuple le workspace depuis l'image (`cp -rn`), robuste au piège NFS (recrée le dossier si root/non-inscriptible). Tourne **avant** les conteneurs |
| container `console` | `ttyd -b /console``claude` dans le workspace (port 7681) |
| container `dashboard` | `uvicorn dashboard.server:app` (port 8800), lit `FINLAB_HOME` |
| PVC `finlab-data` (5Gi NFS) | `/home/finlab` : `~/.claude` (login) + workspace (configs + `.cache`) |
| Service `finlab` | `web:8800` + `console:7681` |
| Middleware `finlab-auth` | basicAuth Traefik (secret htpasswd) |
| IngressRoute | `/console` → 7681 · reste → 8800 (les deux derrière `finlab-auth`) |
## Pré-requis manuels (une fois)
@ -34,35 +36,21 @@ les fichiers manquants.
```bash
kubectl -n ai create secret docker-registry ghcr-pull \
--docker-server=ghcr.io \
--docker-username=Alkatrazz24 \
--docker-server=ghcr.io --docker-username=Alkatrazz24 \
--docker-password=<GHCR_PAT_read_packages>
```
### 2. Secret basicAuth de la console (`finlab-auth`)
### 2. Secret basicAuth `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
--from-literal=users="alkatrazz:$(openssl passwd -apr1 'MOT_DE_PASSE')"
```
### 3. Premier boot — login Claude Code (abonnement)
### 3. Premier boot — login Claude Code (pour la console)
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 »*.
1. Ouvrir **http://finance.lab.local/console/** (basicAuth), lancer `claude`, puis `/login`.
2. Le dashboard est sur **http://finance.lab.local/** (rien à configurer).
> 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'
```
> Configs éditables dans le workspace (`portfolio.yaml`…) : depuis la console, ou
> `kubectl -n ai exec deploy/finlab -c console -- python -m finlab.cli digest`.

View file

@ -16,12 +16,9 @@ spec:
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.
# PodSecurity « restricted » (ns ai). fsGroup 1000 → PVC NFS monté en /home/finlab.
securityContext:
runAsNonRoot: true
runAsUser: 1000
@ -29,40 +26,72 @@ spec:
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: finlab
# Tag bumpé par la CI (build-finlab.yml) sur main.
# initContainer : prépare le workspace (seed robuste NFS) AVANT console + dashboard,
# pour qu'il soit peuplé et inscriptible quand les deux conteneurs démarrent.
initContainers:
- name: seed
image: ghcr.io/alkatrazz24/funk-finlab:sha-54f6f7c
command: ["/usr/local/bin/entrypoint-seed.sh"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: home
mountPath: /home/finlab
containers:
# ── CONSOLE : Claude Code dans un terminal web (ttyd), sous /console ──
- name: console
image: ghcr.io/alkatrazz24/funk-finlab:sha-54f6f7c
command: ["/usr/local/bin/entrypoint-console.sh"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
ports:
- name: console
containerPort: 7681
readinessProbe:
tcpSocket: { port: 7681 }
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
tcpSocket: { port: 7681 }
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 150m, memory: 512Mi }
limits: { cpu: "1", memory: 1536Mi }
volumeMounts:
- name: home
mountPath: /home/finlab
# ── DASHBOARD : interface web (FastAPI), graphiques + données finlab ──
- name: dashboard
image: ghcr.io/alkatrazz24/funk-finlab:sha-54f6f7c
command:
- /opt/venv/bin/uvicorn
- dashboard.server:app
- --host=0.0.0.0
- --port=8800
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).
containerPort: 8800
# FINLAB_HOME/PYTHONPATH viennent de l'image ; FINLAB_HOME = workspace (configs live).
readinessProbe:
tcpSocket:
port: 7681
initialDelaySeconds: 10
httpGet: { path: /healthz, port: 8800 }
initialDelaySeconds: 8
periodSeconds: 15
livenessProbe:
tcpSocket:
port: 7681
initialDelaySeconds: 30
httpGet: { path: /healthz, port: 8800 }
initialDelaySeconds: 20
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
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 768Mi }
volumeMounts:
- name: home
mountPath: /home/finlab

View file

@ -7,11 +7,21 @@ spec:
entryPoints:
- web
routes:
- match: Host(`finance.lab.local`)
# /console → console Claude Code (ttyd sert sous ce préfixe via -b /console).
- match: Host(`finance.lab.local`) && PathPrefix(`/console`)
kind: Rule
priority: 20
middlewares:
# basicAuth : un shell web ne doit pas être ouvert sans auth (cf. middleware.yaml).
- name: finlab-auth
services:
- name: finlab
port: 7681
# Tout le reste → dashboard web (graphiques, portefeuille, watchlists, actions).
- match: Host(`finance.lab.local`)
kind: Rule
priority: 10
middlewares:
- name: finlab-auth
services:
- name: finlab
port: 8800

View file

@ -8,5 +8,8 @@ spec:
app: finlab
ports:
- name: web
port: 8800
targetPort: 8800
- name: console
port: 7681
targetPort: 7681

View file

@ -1,13 +1,11 @@
# 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.
# funk-finlab — image double usage (un seul build, deux conteneurs en déploiement) :
# • CONSOLE : session Claude Code dans un terminal web (ttyd), abonnement persisté.
# • DASHBOARD : interface web (graphiques marché / portefeuille / watchlists / actions).
# Les deux partagent le workspace persistant (PVC) où vivent les CONFIGS éditables (portefeuille,
# watchlists, alertes) ; le CODE finlab vit dans l'image (/opt/app) et se met à jour avec elle.
FROM python:3.12-slim-bookworm
# Node (pour le CLI claude-code) + git + tini (PID 1 propre). ttyd n'est PAS dans les dépôts
# Debian bookworm → on récupère le binaire statique (musl) depuis les releases GitHub (pin).
# Node (CLI claude-code) + git + tini. ttyd n'est pas packagé sur bookworm → binaire statique.
ARG TTYD_VERSION=1.7.7
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git tini procps \
@ -21,35 +19,39 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& npm cache clean --force \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Dépendances Python de finlab dans un venv dédié (séparé du workspace, donc non altérable
# depuis la session). finlab lui-même est importé depuis le workspace (PYTHONPATH au runtime).
# Dépendances Python (venv dédié, hors du workspace).
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/
# CODE applicatif (mis à jour avec l'image) : package finlab + backend dashboard + webapp (option).
COPY finlab/ /opt/app/finlab/
COPY dashboard/ /opt/app/dashboard/
COPY webapp/ /opt/app/webapp/
# « Seed » du workspace : UNIQUEMENT ce que l'utilisateur édite/possède (pas de code).
# Configs finlab + persona Claude + branchement MCP + autorisations. Copié dans le PVC au
# 1er boot par l'initContainer (cp -rn no-clobber → edits préservés ensuite).
COPY portfolio.yaml watchlists.yaml alerts.yaml /opt/seed/
COPY 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
COPY entrypoint-seed.sh entrypoint-console.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint-seed.sh /usr/local/bin/entrypoint-console.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.
# uid non-root (PodSecurity « restricted »). HOME = PVC (login abonnement + workspace).
# WORKDIR reste sur /opt/app (PAS sur le volume) : sinon le runtime crée le dossier du workspace
# en root sur le NFS avant le process → seed cassé (bug corrigé). Le seed/console font le cd.
RUN useradd -m -u 1000 -s /bin/bash finlab
ENV HOME=/home/finlab \
WORKSPACE=/home/finlab/workspace \
FINLAB_HOME=/home/finlab/workspace \
PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin \
PYTHONPATH=/home/finlab/workspace \
PYTHONPATH=/opt/app \
PYTHONUNBUFFERED=1 \
TZ=Europe/Paris
USER 1000
WORKDIR /home/finlab/workspace
EXPOSE 7681
ENTRYPOINT ["tini", "--", "/usr/local/bin/entrypoint.sh"]
WORKDIR /opt/app
# Pas d'ENTRYPOINT unique : le Deployment fixe la commande par conteneur
# (entrypoint-seed.sh en initContainer, entrypoint-console.sh + uvicorn en conteneurs).

View file

@ -18,14 +18,20 @@ Code finance** déployée in-cluster. Paper trading optionnel via **Alpaca** (ar
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
## Quatre 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
1. **Dashboard web** (déploiement) — `finance.lab.local/` : graphiques chandeliers, portefeuille,
watchlists, alertes, plan de trade. Backend `dashboard/server.py` (FastAPI, sans LLM).
2. **Console Claude Code web** (déploiement) — `finance.lab.local/console/` : vraie session Claude
Code spécialisée finance, outils finlab en MCP. Voir `k8s/apps/finlab/README.md`.
3. **CLI**`python -m finlab.cli digest|portfolio|tech|fundamentals|scan|plan|alerts`.
4. **MCP** — serveur stdio `finlab.mcp_server`, branché dans le `.mcp.json` du repo lab → outils
finlab dans les sessions Claude Code du lab.
> Le code lit ses configs depuis `FINLAB_HOME` si défini (déploiement : workspace persistant),
> sinon depuis le dossier du package (dév local). Le dashboard se lance en local :
> `FINLAB_HOME=$PWD PYTHONPATH=$PWD .venv/bin/uvicorn dashboard.server:app --port 8800`.
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`**.

View file

View file

@ -0,0 +1,150 @@
"""FinLab Dashboard — interface web (graphiques marché, portefeuille, watchlists, actions).
Backend FastAPI exposant les données finlab en JSON. **Aucun LLM, aucune clé API** : c'est de
la donnée et de l'analyse technique pure. La partie conversationnelle/agentique vit dans la
console Claude Code (bouton « Console IA » /console).
Lancement : uvicorn dashboard.server:app --host 0.0.0.0 --port 8800
"""
from __future__ import annotations
import math
from pathlib import Path
import pandas as pd
import yaml
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from finlab import (
alerts,
data,
indicators as ind,
plan as plan_mod,
scanner,
technical,
tracker,
)
app = FastAPI(title="FinLab Dashboard")
STATIC = Path(__file__).resolve().parent / "static"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _clean(obj):
"""Rend un objet JSON-safe (NaN/inf → None, types numpy → natifs)."""
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, dict):
return {k: _clean(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_clean(v) for v in obj]
if hasattr(obj, "item"): # numpy scalar
return _clean(obj.item())
return obj
def _records(df: pd.DataFrame):
return _clean(df.where(pd.notna(df), None).to_dict(orient="records"))
@app.exception_handler(Exception)
async def _on_error(request: Request, exc: Exception):
# Un échec ponctuel (ticker introuvable, Yahoo bridé...) ne doit pas casser le front.
return JSONResponse(status_code=500, content={"error": str(exc)})
# ── API ───────────────────────────────────────────────────────────────────────
@app.get("/healthz")
def healthz():
return {"ok": True}
@app.get("/api/portfolio")
def api_portfolio():
df, agg = tracker.build(with_sector=True)
base = agg["base"]
positions = [
{
"ticker": r["ticker"], "secteur": r["secteur"], "qte": r["qté"],
"cours": r["cours"], "dev": r["dev"],
"valeur": r[f"valeur_{base}"], "pru": r[f"PRU_{base}"],
"pnl": r[f"P&L_{base}"], "pnl_pct": r["P&L_%"], "poids": r["poids_%"],
}
for r in df.to_dict(orient="records")
]
return _clean({
"base": base, "total": agg["total"], "cash": agg["cash"],
"invested": agg["invested"], "pnl_total": agg["pnl_total"], "pnl_pct": agg["pnl_pct"],
"by_sector": agg["by_sector"].to_dict(),
"positions": positions,
})
@app.get("/api/themes")
def api_themes():
themes = yaml.safe_load(open(scanner.WATCHLISTS_FILE, encoding="utf-8"))["themes"]
return {"themes": themes}
@app.get("/api/scan")
def api_scan(theme: str = "all", target: float = 5.0, bullish: bool = False):
df = scanner.scan_theme(theme, target, bullish_only=bullish)
return {"theme": theme, "target": target, "rows": _records(df)}
@app.get("/api/ohlc")
def api_ohlc(ticker: str, period: str = "6mo"):
df = data.history(ticker, period=period)
idx = [d.strftime("%Y-%m-%d") for d in df.index]
candles = [
{"time": t, "open": round(float(o), 2), "high": round(float(h), 2),
"low": round(float(l), 2), "close": round(float(c), 2)}
for t, o, h, l, c in zip(idx, df["Open"], df["High"], df["Low"], df["Close"])
]
volume = [
{"time": t, "value": float(v), "color": "#2a9d8f88" if c >= o else "#e76f5188"}
for t, v, o, c in zip(idx, df["Volume"], df["Open"], df["Close"])
]
def line(series):
return [
{"time": t, "value": round(float(x), 2)}
for t, x in zip(idx, series) if pd.notna(x)
]
try:
tech = technical.analyze(ticker, period="1y")
except Exception:
tech = None
return _clean({
"ticker": ticker, "period": period, "candles": candles, "volume": volume,
"ma50": line(ind.sma(df["Close"], 50)), "ma200": line(ind.sma(df["Close"], 200)),
"technical": tech,
})
@app.get("/api/alerts")
def api_alerts(watch: str = "all"):
return {"watch": watch, "hits": _clean(alerts.run(watch))}
@app.get("/api/plan")
def api_plan(ticker: str, capital: float = 1427.0):
p = plan_mod.plan(ticker, capital)
return _clean({"plan": p, "render": plan_mod.render(p)})
# ── Statique ──────────────────────────────────────────────────────────────────
@app.get("/")
def index():
return FileResponse(STATIC / "index.html")
app.mount("/static", StaticFiles(directory=STATIC), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8800)

View file

@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FinLab — Dashboard</title>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<style>
:root{
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
--txt:#c8d0e0; --mut:#7a869e; --acc:#5b8cff;
--up:#2a9d8f; --down:#e76f51; --warn:#e9c46a;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 system-ui,Segoe UI,Roboto,sans-serif}
a{color:var(--acc);text-decoration:none}
h1,h2,h3{margin:0;font-weight:600}
.topbar{display:flex;align-items:center;gap:18px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:5}
.topbar .brand{font-size:18px;font-weight:700;color:#fff;letter-spacing:.5px}
.topbar .brand small{color:var(--mut);font-weight:400;font-size:12px}
.kpi{display:flex;gap:16px;align-items:center;margin-left:auto}
.kpi .v{font-size:15px;font-weight:600}
.kpi .l{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.4px}
.badge{padding:2px 8px;border-radius:6px;font-weight:600}
.pos{color:var(--up)} .neg{color:var(--down)}
.btn{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:6px 12px;border-radius:7px;cursor:pointer;font-size:13px}
.btn:hover{border-color:var(--acc);color:#fff}
.btn.acc{background:var(--acc);border-color:var(--acc);color:#fff}
.grid{display:grid;grid-template-columns:300px 1fr 300px;gap:12px;padding:12px;min-height:calc(100vh - 53px)}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
.panel h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
.row{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;align-items:center}
.row:hover{background:var(--panel2)}
.row.sel{background:#1b2334;outline:1px solid var(--acc)}
.row .t{font-weight:600;color:#fff}
.row .s{font-size:12px;color:var(--mut)}
.mono{font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th,td{text-align:right;padding:5px 6px;border-bottom:1px solid var(--line)}
th{color:var(--mut);font-weight:500;text-transform:uppercase;font-size:10.5px;letter-spacing:.4px}
th:first-child,td:first-child{text-align:left}
tbody tr{cursor:pointer}
tbody tr:hover{background:var(--panel2)}
.controls{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
select,input{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:6px;padding:5px 7px;font-size:12.5px}
input[type=number]{width:64px}
#chart{height:420px;width:100%}
.chiprow{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:3px 10px;font-size:12px}
.chip b{color:#fff}
.signals{margin-top:8px;font-size:12.5px;color:var(--mut)}
.perbtn{padding:4px 9px}
.perbtn.on{background:var(--acc);border-color:var(--acc);color:#fff}
.alert{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid var(--line);font-size:12.5px}
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
.muted{color:var(--mut)}
.modal{position:fixed;inset:0;background:#000a;display:none;align-items:center;justify-content:center;z-index:20}
.modal.on{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;min-width:380px;max-width:90vw}
.modal pre{background:var(--bg);padding:12px;border-radius:8px;overflow:auto;white-space:pre-wrap;font-size:13px;color:var(--txt)}
.err{color:var(--down);font-size:12px}
.loading{color:var(--mut);font-size:12px;padding:8px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Fin<span style="color:var(--acc)">Lab</span> <small>dashboard</small></div>
<div class="kpi">
<div><div class="l">Portefeuille</div><div class="v mono" id="kTotal"></div></div>
<div><div class="l">P&amp;L</div><div class="v mono" id="kPnl"></div></div>
<div><div class="l">Cash</div><div class="v mono" id="kCash"></div></div>
<div><div class="l">Concentration</div><div class="v" id="kConc"></div></div>
<button class="btn" onclick="refreshAll()">↻ Rafraîchir</button>
<a class="btn acc" href="/console/" target="_blank">⌨ Console IA ↗</a>
</div>
</div>
<div class="grid">
<!-- Colonne gauche : portefeuille + watchlists -->
<div>
<div class="panel">
<h3>Mon portefeuille <span class="muted" id="pfCount"></span></h3>
<div id="pfList"><div class="loading">Chargement…</div></div>
</div>
<div class="panel">
<h3>Watchlists</h3>
<div class="controls">
<select id="theme" onchange="loadScan()"></select>
<label class="muted">cible%<input type="number" id="target" value="5" step="0.5" onchange="loadScan()"></label>
<label class="muted"><input type="checkbox" id="bullish" onchange="loadScan()"> haussiers</label>
</div>
<div id="scanWrap"><div class="loading"></div></div>
</div>
</div>
<!-- Colonne centre : graphique -->
<div>
<div class="panel">
<h3>
<span id="chartTitle">Graphique</span>
<span>
<button class="btn perbtn" data-p="1mo" onclick="setPeriod('1mo')">1M</button>
<button class="btn perbtn" data-p="3mo" onclick="setPeriod('3mo')">3M</button>
<button class="btn perbtn on" data-p="6mo" onclick="setPeriod('6mo')">6M</button>
<button class="btn perbtn" data-p="1y" onclick="setPeriod('1y')">1A</button>
</span>
</h3>
<div id="chart"></div>
<div id="legend" class="muted" style="font-size:12px;margin-top:6px">
<span style="color:#f4a261">— MM50</span> &nbsp; <span style="color:#5b8cff">— MM200</span>
</div>
<div class="chiprow" id="techChips"></div>
<div class="signals" id="techSignals"></div>
<div class="controls" style="margin-top:12px">
<button class="btn acc" onclick="openPlan()">📐 Plan de trade</button>
<button class="btn" onclick="loadAlerts()">🔔 Recharger alertes</button>
</div>
</div>
</div>
<!-- Colonne droite : alertes -->
<div>
<div class="panel">
<h3>Alertes du jour <select id="alertWatch" style="font-size:11px" onchange="loadAlerts()">
<option value="all">tous</option><option value="portfolio">portefeuille</option></select></h3>
<div id="alertList"><div class="loading"></div></div>
</div>
<div class="panel">
<h3>Exposition sectorielle</h3>
<div id="sectors"><div class="loading"></div></div>
</div>
</div>
</div>
<!-- Modal plan de trade -->
<div class="modal" id="planModal" onclick="if(event.target===this)this.classList.remove('on')">
<div class="box">
<h3 style="margin-bottom:10px">Plan de trade — <span id="planTicker"></span></h3>
<div class="controls">
<label class="muted">Capital<input type="number" id="planCapital" value="1427" step="50"></label>
<button class="btn acc" onclick="runPlan()">Calculer</button>
<button class="btn" onclick="document.getElementById('planModal').classList.remove('on')">Fermer</button>
</div>
<pre id="planOut"></pre>
<div class="muted" style="font-size:11px">Analyse — pas une recommandation. R:R &lt; 1 = trade structurellement perdant.</div>
</div>
</div>
<script>
let CHART, S = {}, SEL=null, PERIOD='6mo', CCY='$';
const fmt = (n,d=0)=> n==null? '—' : Number(n).toLocaleString('fr-FR',{minimumFractionDigits:d,maximumFractionDigits:d});
const cls = n => n==null?'' : (n>=0?'pos':'neg');
const sign = n => (n>=0?'+':'')+fmt(n,0);
async function api(path){
const r = await fetch(path);
const j = await r.json();
if(j && j.error) throw new Error(j.error);
return j;
}
// ── Graphique ──────────────────────────────────────────────
function initChart(){
const el = document.getElementById('chart');
CHART = LightweightCharts.createChart(el,{
width: el.clientWidth, height: 420,
layout:{background:{color:'#11151f'},textColor:'#7a869e'},
grid:{vertLines:{color:'#1a2030'},horzLines:{color:'#1a2030'}},
rightPriceScale:{borderColor:'#222a3a'},
timeScale:{borderColor:'#222a3a'},
crosshair:{mode:0},
});
S.candle = CHART.addCandlestickSeries({upColor:'#2a9d8f',downColor:'#e76f51',borderVisible:false,wickUpColor:'#2a9d8f',wickDownColor:'#e76f51'});
S.ma50 = CHART.addLineSeries({color:'#f4a261',lineWidth:1,priceLineVisible:false,lastValueVisible:false});
S.ma200= CHART.addLineSeries({color:'#5b8cff',lineWidth:1,priceLineVisible:false,lastValueVisible:false});
S.vol = CHART.addHistogramSeries({priceScaleId:'',priceFormat:{type:'volume'}});
S.vol.priceScale().applyOptions({scaleMargins:{top:0.82,bottom:0}});
window.addEventListener('resize',()=>CHART.applyOptions({width:el.clientWidth}));
}
async function loadChart(ticker){
if(!ticker) return;
SEL = ticker;
document.querySelectorAll('.row.sel,tr.sel').forEach(e=>e.classList.remove('sel'));
document.querySelectorAll('[data-tk="'+ticker+'"]').forEach(e=>e.classList.add('sel'));
document.getElementById('chartTitle').textContent = ticker + ' · ' + PERIOD;
document.getElementById('techChips').innerHTML='<span class="loading">Chargement…</span>';
document.getElementById('techSignals').textContent='';
try{
const d = await api('/api/ohlc?ticker='+encodeURIComponent(ticker)+'&period='+PERIOD);
S.candle.setData(d.candles);
S.ma50.setData(d.ma50); S.ma200.setData(d.ma200);
S.vol.setData(d.volume);
CHART.timeScale().fitContent();
renderTech(d.technical);
}catch(e){
document.getElementById('techChips').innerHTML='<span class="err">'+e.message+'</span>';
}
}
function renderTech(t){
const c = document.getElementById('techChips');
if(!t){c.innerHTML='<span class="muted">pas de synthèse technique</span>';return;}
const macdCls = t.MACD==='haussier'?'pos':'neg';
const rsiCls = t.RSI14>=70?'neg':(t.RSI14<=30?'pos':'');
c.innerHTML = [
`<span class="chip">Cours <b>${t.cours}</b></span>`,
`<span class="chip">RSI <b class="${rsiCls}">${t.RSI14}</b></span>`,
`<span class="chip">MACD <b class="${macdCls}">${t.MACD}</b></span>`,
`<span class="chip">MM50 <b>${t.MM50}</b></span>`,
`<span class="chip">MM200 <b>${t.MM200??'—'}</b></span>`,
`<span class="chip">Volatilité <b>${t['vol_%']}%</b></span>`,
].join('');
document.getElementById('techSignals').textContent = '▸ ' + (t.signaux||[]).join(' · ');
}
function setPeriod(p){
PERIOD=p;
document.querySelectorAll('.perbtn').forEach(b=>b.classList.toggle('on',b.dataset.p===p));
if(SEL) loadChart(SEL);
}
// ── Portefeuille ───────────────────────────────────────────
async function loadPortfolio(){
try{
const d = await api('/api/portfolio');
CCY = d.base==='EUR'?'€':d.base;
document.getElementById('kTotal').textContent = fmt(d.total)+' '+CCY;
const pnlEl = document.getElementById('kPnl');
pnlEl.textContent = sign(d.pnl_total)+' '+CCY+' ('+sign(d.pnl_pct)+'%)';
pnlEl.className = 'v mono '+cls(d.pnl_total);
document.getElementById('kCash').textContent = fmt(d.cash)+' '+CCY;
const sectors = Object.entries(d.by_sector).sort((a,b)=>b[1]-a[1]);
document.getElementById('kConc').textContent = sectors.length?`${sectors[0][0]} ${fmt(sectors[0][1])}%`:'—';
document.getElementById('pfCount').textContent = d.positions.length+' lignes';
document.getElementById('pfList').innerHTML = d.positions.map(p=>`
<div class="row" data-tk="${p.ticker}" onclick="loadChart('${p.ticker}')">
<div><div class="t">${p.ticker}</div><div class="s">${p.secteur} · ${fmt(p.poids,1)}%</div></div>
<div style="text-align:right"><div class="mono">${fmt(p.valeur)} ${CCY}</div>
<div class="s mono ${cls(p.pnl)}">${sign(p.pnl_pct)}%</div></div>
</div>`).join('');
document.getElementById('sectors').innerHTML = sectors.map(([s,w])=>`
<div class="alert"><div class="dot" style="background:var(--acc)"></div>
<div style="flex:1">${s}</div><b class="mono">${fmt(w,1)}%</b></div>`).join('');
if(!SEL && d.positions.length) loadChart(d.positions[0].ticker);
}catch(e){ document.getElementById('pfList').innerHTML='<div class="err">'+e.message+'</div>'; }
}
// ── Watchlists / scan ──────────────────────────────────────
async function loadThemes(){
const d = await api('/api/themes');
const sel = document.getElementById('theme');
sel.innerHTML = '<option value="all">— tous —</option><option value="portfolio">portefeuille</option>'
+ Object.keys(d.themes).map(t=>`<option value="${t}">${t}</option>`).join('');
loadScan();
}
async function loadScan(){
const theme=document.getElementById('theme').value;
const target=document.getElementById('target').value;
const bullish=document.getElementById('bullish').checked;
const wrap=document.getElementById('scanWrap');
wrap.innerHTML='<div class="loading">Scan…</div>';
try{
const d = await api(`/api/scan?theme=${theme}&target=${target}&bullish=${bullish}`);
if(!d.rows.length){wrap.innerHTML='<div class="muted">Aucun résultat.</div>';return;}
const tcol = Object.keys(d.rows[0]).find(k=>k.startsWith('peut_'))||'';
wrap.innerHTML = `<table><thead><tr><th>Tkr</th><th>Cours</th><th>Sem%</th><th>RSI</th><th>M</th><th>Biais</th></tr></thead><tbody>`
+ d.rows.map(r=>`<tr data-tk="${r.ticker}" onclick="loadChart('${r.ticker}')">
<td class="t">${r.ticker}</td><td class="mono">${r.cours??'—'}</td>
<td class="mono">${r['range_sem_%']??'—'}</td><td class="mono">${r.RSI??'—'}</td>
<td class="${r.MACD==='↑'?'pos':'neg'}">${r.MACD??''}</td>
<td style="text-align:left;font-size:11px">${biasFmt(r.biais)}</td></tr>`).join('')
+ '</tbody></table>';
}catch(e){ wrap.innerHTML='<div class="err">'+e.message+'</div>'; }
}
function biasFmt(b){
if(!b) return '';
if(b.includes('haussier')||b.includes('survendu')) return '<span class="pos">'+b+'</span>';
if(b.includes('baissier')||b.includes('surachat')) return '<span class="neg">'+b+'</span>';
return '<span class="muted">'+b+'</span>';
}
// ── Alertes ────────────────────────────────────────────────
async function loadAlerts(){
const w=document.getElementById('alertWatch').value;
const el=document.getElementById('alertList');
el.innerHTML='<div class="loading"></div>';
try{
const d = await api('/api/alerts?watch='+w);
if(!d.hits.length){el.innerHTML='<div class="muted">Aucune alerte aujourd\'hui.</div>';return;}
el.innerHTML = d.hits.map(h=>{
const up = /hausse|survente|rebond|haussier|golden/i.test(h.alerte);
return `<div class="alert" data-tk="${h.ticker}" style="cursor:pointer" onclick="loadChart('${h.ticker}')">
<div class="dot" style="background:${up?'var(--up)':'var(--down)'}"></div>
<div style="flex:1"><b>${h.ticker}</b> <span class="muted">${h.alerte}</span></div>
<span class="mono">${h.prix}</span></div>`;
}).join('');
}catch(e){ el.innerHTML='<div class="err">'+e.message+'</div>'; }
}
// ── Plan de trade ──────────────────────────────────────────
function openPlan(){
if(!SEL){alert('Sélectionne un titre');return;}
document.getElementById('planTicker').textContent=SEL;
document.getElementById('planOut').textContent='—';
document.getElementById('planModal').classList.add('on');
}
async function runPlan(){
const cap=document.getElementById('planCapital').value;
const out=document.getElementById('planOut');
out.textContent='Calcul…';
try{
const d = await api(`/api/plan?ticker=${SEL}&capital=${cap}`);
out.textContent = d.render;
}catch(e){ out.textContent='Erreur: '+e.message; }
}
// ── Init ───────────────────────────────────────────────────
function refreshAll(){ loadPortfolio(); loadScan(); loadAlerts(); if(SEL) loadChart(SEL); }
initChart();
loadPortfolio();
loadThemes();
loadAlerts();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Conteneur CONSOLE : terminal web (ttyd) → Claude Code dans le workspace.
# Le workspace est déjà seedé par l'initContainer ; on se contente d'y entrer.
set -euo pipefail
WS="${WORKSPACE:-/home/finlab/workspace}"
cd "$WS"
# Mot d'accueil du shell de la console.
cat > "$HOME/.bash_motd" <<'MOTD'
────────────────────────────────────────────────────────────
FinLab — console Claude Code (analyste de marché)
• Lance l'assistant : claude (1ʳᵉ fois : /login dans claude)
• Outils finlab (MCP) + CLI : python -m finlab.cli digest
• Dashboard graphique : retire /console de l'URL
• Tes configs (éditables) : portfolio.yaml · watchlists.yaml · alerts.yaml
⚠ 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 sert sous le préfixe /console (Traefik y route). Auth assurée en amont (basicAuth).
exec ttyd -p 7681 -W -b /console -t titleFixed='FinLab — console' \
-t 'theme={"background":"#0b0e14"}' bash -l

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# initContainer : prépare le workspace persistant (PVC) AVANT le démarrage des conteneurs.
# Tourne en uid 1000. Robuste au piège NFS : si le point de montage / le dossier workspace a
# été créé root et non-inscriptible (fsGroup non honoré par le provisioner NFS), on le recrée
# (le parent /home/finlab est world-writable côté provisioner).
set -euo pipefail
WS="${WORKSPACE:-/home/finlab/workspace}"
if [ -d "$WS" ] && [ ! -w "$WS" ]; then
echo "seed: workspace présent mais non inscriptible → recréation"
rmdir "$WS" 2>/dev/null || true
fi
mkdir -p "$WS"
# cp no-clobber : pose les fichiers manquants (nouvelle image), préserve les edits existants.
cp -rn /opt/seed/. "$WS"/ 2>/dev/null || true
echo "seed: OK — $(ls -A "$WS" 2>/dev/null | wc -l) entrées dans $WS"

View file

@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Prépare le workspace persistant puis lance la console web (ttyd → Claude Code).
set -euo pipefail
WS="${WORKSPACE:-$HOME/workspace}"
mkdir -p "$WS"
# Seed du workspace depuis l'image, SANS écraser ce que l'utilisateur (ou Claude) a modifié.
# `cp -rn` = no-clobber : pose les fichiers manquants (nouvelle version d'image → nouveaux
# fichiers), préserve portfolio.yaml/edits/.cache existants sur le PVC.
cp -rn /opt/seed/. "$WS"/ 2>/dev/null || true
cd "$WS"
# Petit mot d'accueil dans le shell de la console.
cat > "$HOME/.bash_motd" <<'MOTD'
────────────────────────────────────────────────────────────
FinLab — console Claude Code (analyste de marché)
• Lance l'assistant : claude
• 1ʳᵉ fois : dans claude, fais /login (abonnement, persisté)
• Outils finlab en MCP + CLI : python -m finlab.cli digest
• Ton portefeuille : portfolio.yaml (éditable ici)
⚠ Analyse uniquement — aucun ordre réel.
────────────────────────────────────────────────────────────
MOTD
grep -q bash_motd "$HOME/.bashrc" 2>/dev/null || echo '[ -f "$HOME/.bash_motd" ] && cat "$HOME/.bash_motd"' >> "$HOME/.bashrc"
# ttyd : terminal web (port 7681), écriture activée. L'auth est assurée en amont par le
# middleware basicAuth de Traefik (cf. k8s/apps/finlab/middleware.yaml) — ttyd reste interne.
exec ttyd -p 7681 -W -t titleFixed='FinLab — console' -t 'theme={"background":"#0b0e14"}' \
bash -l

View file

@ -5,6 +5,7 @@ que ce qui vient de basculer (croisement MACD, cassure MM50, survente...).
"""
from __future__ import annotations
import os
from pathlib import Path
import pandas as pd
@ -12,7 +13,8 @@ import yaml
from . import data, indicators as ind, scanner
ALERTS_FILE = Path(__file__).resolve().parent.parent / "alerts.yaml"
# Config lue depuis FINLAB_HOME (workspace persistant) si défini — cf. data.ROOT.
ALERTS_FILE = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent) / "alerts.yaml"
def _events(symbol: str) -> dict:

View file

@ -6,6 +6,7 @@ pour que tous les outils partagent la même source.
from __future__ import annotations
import functools
import os
import pickle
import time
from pathlib import Path
@ -14,7 +15,10 @@ import pandas as pd
import yaml
import yfinance as yf
ROOT = Path(__file__).resolve().parent.parent
# FINLAB_HOME découple le CODE (package) de la DONNÉE (configs éditables + cache). En
# déploiement, le code vit dans l'image (/opt/app) et les configs dans le workspace persistant
# (PVC). Non défini → repli sur le dossier parent du package (dév local inchangé).
ROOT = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent)
PORTFOLIO_FILE = ROOT / "portfolio.yaml"
CACHE_DIR = ROOT / ".cache"

View file

@ -7,11 +7,13 @@ alertes déclenchées. Écrit dans reports/ et renvoyé comme texte court.
from __future__ import annotations
import datetime as dt
import os
from pathlib import Path
from . import alerts, data, scanner, tracker
REPORTS_DIR = Path(__file__).resolve().parent.parent / "reports"
# Écrit dans FINLAB_HOME/reports si défini (workspace inscriptible) — cf. data.ROOT.
REPORTS_DIR = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent) / "reports"
def build(theme: str = "all", target_pct: float = 5.0, top: int = 6) -> str:

View file

@ -10,6 +10,7 @@ faire +8% est tout aussi capable de faire -8%.
from __future__ import annotations
import math
import os
from pathlib import Path
import pandas as pd
@ -17,7 +18,8 @@ import yaml
from . import data, indicators as ind
WATCHLISTS_FILE = Path(__file__).resolve().parent.parent / "watchlists.yaml"
# Config lue depuis FINLAB_HOME (workspace persistant) si défini — cf. data.ROOT.
WATCHLISTS_FILE = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent) / "watchlists.yaml"
def load_theme(theme: str | None = None) -> list[str]:

View file

@ -4,7 +4,8 @@
"command": "/opt/venv/bin/python",
"args": ["-m", "finlab.mcp_server"],
"env": {
"PYTHONPATH": "/home/finlab/workspace"
"PYTHONPATH": "/opt/app",
"FINLAB_HOME": "/home/finlab/workspace"
}
}
}