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>
451 lines
20 KiB
TypeScript
451 lines
20 KiB
TypeScript
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 > 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>
|
||
)
|
||
}
|