feat: Phase 4 — Moteur Probabiliste & Apprentissage Automatique

Sprint 4.1 — Bayesian Updating
- database.py: update_bayesian_posteriors() — Beta(α,β) posteriors sur trades matures
- database.py: get_bayesian_posteriors() — posteriors + IC 95% + dérive prior GPT vs posterior
- Colonnes Bayésiennes ajoutées : bayesian_alpha, bayesian_beta, bayesian_win_rate, bayesian_sample_size
- auto_cycle.py: appel update_bayesian_posteriors() en Step 5.5 (après scoring)

Sprint 4.2 — Détection Automatique de Régimes (K-Means numpy pur)
- database.py: detect_and_save_regime_clusters() — K-Means sur 7 gauges macro (VIX, slope, DXY…)
- database.py: get_regime_cluster_history() — timeline des clusters
- database.py: get_regime_transition_matrix() — P(cluster j | cluster i) sur N transitions
- Table regime_clusters avec anomaly_flag (points > 3σ)
- auto_cycle.py: appel detect_and_save_regime_clusters() en Step 5.6

Sprint 4.3 — Embeddings Sémantiques (remplace Jaccard)
- database.py: get_or_create_pattern_embedding() — OpenAI text-embedding-3-small, stocké en DB
- database.py: max_cosine_similarity_vs_existing() — similarité cosinus vs patterns existants
- Table pattern_embeddings avec vecteur JSON + model_version
- auto_cycle.py: _is_duplicate_pattern() — cosinus seuil 0.75 avec fallback Jaccard automatique

Sprint 4.4 — Tableau de Bord Analytique Avancé
- AnalyticsAdvanced.tsx: nouvelle page /analytics-advanced
  • BayesianTable : prior GPT vs WR bayésien ± IC 95%, dérive, niveau de confiance
  • ClusterTimeline : timeline colorée des clusters + anomalies
  • TransitionMatrix : heatmap P(j|i) avec diagonale auto-transition
  • EmbeddingsSummary : liste des patterns vectorisés
  • Boutons "Bayesian update" et "Détecter régime" avec mutation React Query
- analytics.py router : 5 nouveaux endpoints (bayesian, regime-clusters, transitions, detect, embeddings)
- useApi.ts : 4 nouveaux hooks (useBayesianPosteriors, useRegimeClusters, useRegimeTransitions, usePatternEmbeddings)
- App.tsx + Sidebar.tsx : route /analytics-advanced + entrée menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-17 17:46:34 +02:00
parent e44c8799b9
commit 246deaf631
7 changed files with 1065 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ import RapportIA from './pages/RapportIA'
import SuperContexte from './pages/SuperContexte'
import Config from './pages/Config'
import Analytics from './pages/Analytics'
import AnalyticsAdvanced from './pages/AnalyticsAdvanced'
import RiskDashboard from './pages/RiskDashboard'
import { useCycleWatcher } from './hooks/useApi'
@@ -44,6 +45,7 @@ export default function App() {
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
</Routes>
</main>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -18,6 +18,7 @@ const nav = [
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
{ to: '/analytics', icon: FlaskConical, label: 'Analytics' },
{ to: '/analytics-advanced', icon: Microscope, label: 'Analytics Avancées' },
{ to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },

View File

@@ -614,6 +614,36 @@ export const useIvForTrade = (underlying: string) =>
staleTime: 60 * 60_000,
})
// ── Analytics Phase 4 — Bayesian + Clustering + Embeddings ───────────────────
export const useBayesianPosteriors = () =>
useQuery({
queryKey: ['bayesian-posteriors'],
queryFn: () => api.get('/analytics/bayesian').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useRegimeClusters = (days = 90) =>
useQuery({
queryKey: ['regime-clusters', days],
queryFn: () => api.get('/analytics/regime-clusters', { params: { days } }).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useRegimeTransitions = (days = 180) =>
useQuery({
queryKey: ['regime-transitions', days],
queryFn: () => api.get('/analytics/regime-transitions', { params: { days } }).then(r => r.data),
staleTime: 10 * 60_000,
})
export const usePatternEmbeddings = () =>
useQuery({
queryKey: ['pattern-embeddings'],
queryFn: () => api.get('/analytics/embeddings').then(r => r.data),
staleTime: 10 * 60_000,
})
// ── Analytics (Phase 2) ───────────────────────────────────────────────────────
export const usePatternReliability = () =>

View File

@@ -0,0 +1,450 @@
import { useState } from 'react'
import { useBayesianPosteriors, useRegimeClusters, useRegimeTransitions, usePatternEmbeddings } from '../hooks/useApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import clsx from 'clsx'
import { Brain, GitBranch, Layers, Cpu, RefreshCw, AlertTriangle } from 'lucide-react'
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
const api = axios.create({ baseURL: API_BASE })
// ── Bayesian Posteriors ───────────────────────────────────────────────────────
function BayesianTable({ data }: { data: any[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center py-10 text-slate-500">
<Brain className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Pas encore de données bayésiennes</div>
<div className="text-xs mt-1">Les posteriors se calculent après des trades matures (35% de l'horizon)</div>
</div>
)
}
const withData = data.filter(d => d.sample_size > 0)
const withoutData = data.filter(d => d.sample_size === 0)
return (
<div className="space-y-4">
{withData.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/30">
<th className="text-left py-2 pr-3 font-medium">Pattern</th>
<th className="text-right py-2 px-2 font-medium">Prior GPT</th>
<th className="text-right py-2 px-2 font-medium">WR Bayésien</th>
<th className="text-right py-2 px-2 font-medium">IC 95%</th>
<th className="text-right py-2 px-2 font-medium">Trades matures</th>
<th className="text-right py-2 px-2 font-medium">Dérive</th>
<th className="text-center py-2 px-2 font-medium">Confiance</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{withData.map((d: any) => {
const wr = d.bayesian_win_rate_pct
const wrColor = wr >= 60 ? 'text-emerald-400' : wr >= 40 ? 'text-amber-400' : 'text-red-400'
const drift = d.prior_vs_posterior_drift
const driftColor = Math.abs(drift) < 0.05 ? 'text-slate-400'
: drift > 0 ? 'text-emerald-400' : 'text-red-400'
const confColor = d.confidence_level === 'haute' ? 'text-emerald-400'
: d.confidence_level === 'moyenne' ? 'text-amber-400' : 'text-slate-500'
return (
<tr key={d.pattern_id} className="hover:bg-dark-700/30 transition-colors">
<td className="py-2 pr-3">
<div className="text-white font-medium truncate max-w-[180px]">{d.pattern_name}</div>
<div className="text-slate-600 font-mono text-[10px]">{d.asset_class ?? ''}</div>
</td>
<td className="text-right py-2 px-2 font-mono text-slate-400">
{Math.round(d.prior_probability * 100)}%
</td>
<td className={clsx('text-right py-2 px-2 font-mono font-bold', wrColor)}>
{wr}%
</td>
<td className="text-right py-2 px-2 font-mono text-slate-500 text-[10px]">
[{Math.round(d.lower_ci_95 * 100)}%{Math.round(d.upper_ci_95 * 100)}%]
</td>
<td className="text-right py-2 px-2 text-slate-300 font-mono">
{d.sample_size}
</td>
<td className={clsx('text-right py-2 px-2 font-mono', driftColor)}>
{drift >= 0 ? '+' : ''}{Math.round(drift * 100)}%
</td>
<td className={clsx('text-center py-2 px-2 text-[10px] font-bold uppercase', confColor)}>
{d.confidence_level}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{withoutData.length > 0 && (
<div className="text-xs text-slate-600 mt-1">
{withoutData.length} pattern(s) sans trades matures — prior GPT uniquement
</div>
)}
</div>
)
}
// ── Regime Transition Matrix ──────────────────────────────────────────────────
function TransitionMatrix({ data }: { data: any }) {
if (!data || !data.matrix || Object.keys(data.matrix).length === 0) {
return (
<div className="text-center py-8 text-slate-500">
<GitBranch className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Pas encore de transitions détectées</div>
<div className="text-xs mt-1">Les clusters se remplissent au fil des cycles</div>
</div>
)
}
const { matrix, labels, cluster_ids } = data
const ids: number[] = cluster_ids ?? Object.keys(matrix).map(Number)
return (
<div className="overflow-x-auto">
<div className="text-xs text-slate-500 mb-2">
Probabilité de transition d'un régime à l'autre (lignes = source, colonnes = destination)
· {data.n_transitions} transitions totales
</div>
<table className="text-xs border-collapse">
<thead>
<tr>
<th className="text-slate-500 p-2 font-normal text-right pr-3">De ↓ / Vers →</th>
{ids.map(dst => (
<th key={dst} className="p-2 font-medium text-slate-300 text-center min-w-[90px]">
<div className="text-[10px] text-slate-500">C{dst}</div>
<div className="truncate max-w-[88px]">{(labels?.[dst] ?? `Cluster ${dst}`).split(' ').slice(0, 2).join(' ')}</div>
</th>
))}
</tr>
</thead>
<tbody>
{ids.map(src => (
<tr key={src} className="border-t border-slate-800/40">
<td className="p-2 text-right pr-3 font-medium text-slate-300">
<div className="text-[10px] text-slate-500">C{src}</div>
<div className="text-xs truncate max-w-[120px]">{(labels?.[src] ?? `Cluster ${src}`).split(' ').slice(0, 2).join(' ')}</div>
</td>
{ids.map(dst => {
const prob = matrix?.[src]?.[dst] ?? 0
const pct = Math.round(prob * 100)
const bg = src === dst
? 'bg-blue-500/20 text-blue-300'
: pct >= 50 ? 'bg-emerald-500/20 text-emerald-300'
: pct >= 25 ? 'bg-amber-500/10 text-amber-300'
: 'text-slate-600'
return (
<td key={dst} className={clsx('p-2 text-center font-mono font-bold rounded', bg)}>
{pct > 0 ? `${pct}%` : ''}
</td>
)
})}
</tr>
))}
</tbody>
</table>
<div className="text-[10px] text-slate-600 mt-2">
Diagonal = reste dans le même cluster · Vert = transition dominante · Bleu = auto-transition
</div>
</div>
)
}
// ── Cluster History Timeline ──────────────────────────────────────────────────
const CLUSTER_COLORS: Record<number, string> = {
0: 'bg-red-500',
1: 'bg-emerald-500',
2: 'bg-orange-500',
3: 'bg-yellow-500',
4: 'bg-slate-400',
}
function ClusterTimeline({ data }: { data: any[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center py-6 text-slate-500 text-xs">
Aucun cluster détecté encore — lancez un cycle pour démarrer
</div>
)
}
// Afficher les 30 derniers points
const recent = [...data].reverse().slice(0, 30)
return (
<div className="space-y-2">
<div className="flex gap-1 flex-wrap">
{Object.entries(CLUSTER_COLORS).map(([k, col]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-slate-400">
<span className={clsx('w-2 h-2 rounded-full inline-block', col)} />
C{k}
</span>
))}
</div>
<div className="flex gap-0.5 flex-wrap">
{recent.map((r: any, i: number) => (
<div
key={i}
title={`${r.timestamp?.slice(0, 16)} · ${r.cluster_label} · ${r.dominant_regime}${r.anomaly_flag ? ' anomalie' : ''}`}
className={clsx(
'w-5 h-5 rounded cursor-default',
CLUSTER_COLORS[r.cluster_id] ?? 'bg-slate-600',
r.anomaly_flag ? 'ring-2 ring-white/50' : ''
)}
/>
))}
</div>
<div className="text-[10px] text-slate-600">
Chaque carré = 1 snapshot · Blanc cerclé = anomalie détectée · {data.length} entrées au total
</div>
{/* Latest cluster detail */}
{data[0] && (
<div className="mt-3 p-3 rounded bg-dark-700/60 border border-slate-700/30 text-xs">
<div className="text-slate-400 mb-1">Cluster actuel</div>
<div className="flex items-center gap-2">
<span className={clsx('w-3 h-3 rounded-full', CLUSTER_COLORS[data[0].cluster_id])} />
<span className="text-white font-semibold">{data[0].cluster_label}</span>
<span className="text-slate-500">({data[0].dominant_regime})</span>
{data[0].anomaly_flag ? (
<span className="flex items-center gap-1 text-amber-400 font-bold">
<AlertTriangle className="w-3 h-3" /> Anomalie
</span>
) : null}
</div>
<div className="text-slate-600 mt-0.5">{data[0].timestamp?.slice(0, 16)}</div>
</div>
)}
</div>
)
}
// ── Embeddings Summary ────────────────────────────────────────────────────────
function EmbeddingsSummary({ data }: { data: any[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center py-6 text-slate-500 text-xs">
<Cpu className="w-6 h-6 mx-auto mb-1 opacity-20" />
Aucun embedding disponible — ils se génèrent automatiquement lors du filtrage des patterns
</div>
)
}
return (
<div className="space-y-2">
<div className="text-xs text-slate-500">{data.length} patterns vectorisés</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/30">
<th className="text-left py-1.5 pr-3 font-medium">Pattern</th>
<th className="text-left py-1.5 px-2 font-medium">Classe</th>
<th className="text-right py-1.5 px-2 font-medium">Modèle</th>
<th className="text-right py-1.5 px-2 font-medium">Mis à jour</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/30">
{data.slice(0, 20).map((e: any, i: number) => (
<tr key={i} className="hover:bg-dark-700/20">
<td className="py-1.5 pr-3 text-white truncate max-w-[200px]">{e.name ?? e.pattern_id}</td>
<td className="py-1.5 px-2 text-slate-400">{e.asset_class ?? ''}</td>
<td className="py-1.5 px-2 text-right font-mono text-slate-500">{e.model_version}</td>
<td className="py-1.5 px-2 text-right text-slate-600">{e.updated_at?.slice(0, 10)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function AnalyticsAdvanced() {
const [regimeDays, setRegimeDays] = useState(90)
const qc = useQueryClient()
const { data: bayesData, isLoading: loadingBayes } = useBayesianPosteriors()
const { data: clustersData, isLoading: loadingClusters } = useRegimeClusters(regimeDays)
const { data: transData, isLoading: loadingTrans } = useRegimeTransitions(180)
const { data: embData, isLoading: loadingEmb } = usePatternEmbeddings()
const posteriors: any[] = (bayesData as any)?.posteriors ?? []
const clusters: any[] = (clustersData as any)?.clusters ?? []
const embeddings: any[] = (embData as any)?.embeddings ?? []
const bayesUpdate = useMutation({
mutationFn: () => api.post('/api/analytics/bayesian/update').then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['bayesian-posteriors'] }),
})
const regimeDetect = useMutation({
mutationFn: () => api.post('/api/analytics/regime-detect').then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['regime-clusters'] })
qc.invalidateQueries({ queryKey: ['regime-transitions'] })
},
})
const patternsWithData = posteriors.filter((p: any) => p.sample_size > 0)
const avgBayesWR = patternsWithData.length
? Math.round(patternsWithData.reduce((s: number, p: any) => s + p.bayesian_win_rate_pct, 0) / patternsWithData.length)
: null
const latestCluster = clusters[0]
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Brain className="w-5 h-5 text-purple-400" /> Analytics Avancées — Phase 4
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Bayesian updating · Clustering de régimes · Embeddings sémantiques
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => bayesUpdate.mutate()}
disabled={bayesUpdate.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-purple-600/20 hover:bg-purple-600/30 border border-purple-500/30 text-purple-300 rounded transition-colors disabled:opacity-50"
>
<RefreshCw className={clsx('w-3 h-3', bayesUpdate.isPending && 'animate-spin')} />
Bayesian update
</button>
<button
onClick={() => regimeDetect.mutate()}
disabled={regimeDetect.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 text-blue-300 rounded transition-colors disabled:opacity-50"
>
<RefreshCw className={clsx('w-3 h-3', regimeDetect.isPending && 'animate-spin')} />
Détecter régime
</button>
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="card">
<div className="text-2xl font-bold text-purple-400 font-mono">
{patternsWithData.length}
</div>
<div className="text-xs text-slate-500 mt-1">Patterns bayésiens actifs</div>
</div>
<div className="card">
<div className="text-2xl font-bold text-white font-mono">
{avgBayesWR !== null ? `${avgBayesWR}%` : ''}
</div>
<div className="text-xs text-slate-500 mt-1">Win rate bayésien moyen</div>
</div>
<div className="card">
<div className={clsx('text-2xl font-bold font-mono', latestCluster ? CLUSTER_COLORS[latestCluster.cluster_id]?.replace('bg-', 'text-') ?? 'text-slate-400' : 'text-slate-500')}>
{latestCluster ? `C${latestCluster.cluster_id}` : ''}
</div>
<div className="text-xs text-slate-500 mt-1">
{latestCluster?.cluster_label ?? 'Aucun cluster'}
</div>
</div>
<div className="card">
<div className="text-2xl font-bold text-blue-400 font-mono">{embeddings.length}</div>
<div className="text-xs text-slate-500 mt-1">Patterns vectorisés</div>
</div>
</div>
{/* Bayesian Posteriors */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Brain className="w-4 h-4 text-purple-400" />
Posteriors bayésiens par pattern
<span className="text-xs text-slate-500 font-normal">Beta(α,β) · IC 95%</span>
</div>
{bayesUpdate.data && (
<span className="text-xs text-emerald-400">{bayesUpdate.data.message}</span>
)}
</div>
{loadingBayes ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="h-8 bg-dark-700 animate-pulse rounded" />)}</div>
) : (
<BayesianTable data={posteriors} />
)}
<div className="mt-3 p-3 bg-dark-700/40 rounded text-xs text-slate-500 border border-slate-700/20">
<strong className="text-slate-400">Lecture :</strong> Prior GPT = probabilité annoncée par GPT-4o ·
WR Bayésien = win rate observé avec lissage Laplace (β a priori faible) ·
Dérive = écart posterior prior · IC 95% basé sur la distribution Beta
</div>
</div>
{/* Regime Clustering */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cluster timeline */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Layers className="w-4 h-4 text-blue-400" />
Historique des clusters
</div>
<select
value={regimeDays}
onChange={e => setRegimeDays(Number(e.target.value))}
className="bg-dark-700 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
>
<option value={30}>30 jours</option>
<option value={90}>90 jours</option>
<option value={180}>180 jours</option>
</select>
</div>
{loadingClusters ? (
<div className="h-20 bg-dark-700 animate-pulse rounded" />
) : (
<ClusterTimeline data={clusters} />
)}
</div>
{/* Transition matrix */}
<div className="card">
<div className="text-sm font-semibold text-white flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-blue-400" />
Matrice de transition
<span className="text-xs text-slate-500 font-normal">P(régime j | régime i)</span>
</div>
{loadingTrans ? (
<div className="h-32 bg-dark-700 animate-pulse rounded" />
) : (
<TransitionMatrix data={transData} />
)}
</div>
</div>
{/* Embeddings */}
<div className="card">
<div className="text-sm font-semibold text-white flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-emerald-400" />
Embeddings sémantiques
<span className="text-xs text-slate-500 font-normal">
text-embedding-3-small · Similarité cosinus remplace Jaccard
</span>
</div>
{loadingEmb ? (
<div className="h-20 bg-dark-700 animate-pulse rounded" />
) : (
<EmbeddingsSummary data={embeddings} />
)}
<div className="mt-3 p-3 bg-dark-700/40 rounded text-xs text-slate-500 border border-slate-700/20">
<strong className="text-slate-400">Comment ça marche :</strong> Chaque nouveau pattern suggéré est vectorisé et comparé
aux patterns existants via similarité cosinus (seuil : 0.75). Si la similarité est &gt; 0.75,
le pattern est rejeté comme doublon sémantique plus précis que Jaccard qui ne voit que les mots communs.
</div>
</div>
</div>
)
}