mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 04:34:41 +02:00
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:
parent
607e5e6c0a
commit
56cb32eacc
19 changed files with 700 additions and 149 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,5 +8,8 @@ spec:
|
|||
app: finlab
|
||||
ports:
|
||||
- name: web
|
||||
port: 8800
|
||||
targetPort: 8800
|
||||
- name: console
|
||||
port: 7681
|
||||
targetPort: 7681
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`**.
|
||||
|
|
|
|||
0
tools/finlab/dashboard/__init__.py
Normal file
0
tools/finlab/dashboard/__init__.py
Normal file
150
tools/finlab/dashboard/server.py
Normal file
150
tools/finlab/dashboard/server.py
Normal 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)
|
||||
329
tools/finlab/dashboard/static/index.html
Normal file
329
tools/finlab/dashboard/static/index.html
Normal 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&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> <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 < 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>
|
||||
25
tools/finlab/entrypoint-console.sh
Normal file
25
tools/finlab/entrypoint-console.sh
Normal 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
|
||||
19
tools/finlab/entrypoint-seed.sh
Normal file
19
tools/finlab/entrypoint-seed.sh
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue