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

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