feat: enrich Dashboard cockpit cards with data + view toggles

- PnL card: rename to P&L, add Simulé/Portfolio toggle (localStorage),
  simulated shows target/stop alert counts, portfolio shows invested € + P&L €
- Risk card: add asset class allocation horizontal bars (bullish=color/bearish=red)
  from simPortfolioRisk.concentration, conflicts badge
- Dernier Cycle: full bilan layout — patterns added / scored / trades logged
  in 3 mini-stat boxes
- Trades du cycle: show direction, ticker, strategy, PnL%, score for each trade
- Top Patterns: replaces Meilleur Pattern — shows top 4 scored with rank + ticker
- Top News: replaces Patterns Actifs — useGeoNews sorted by impact_score,
  top 3 with title + impact bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 20:15:48 +02:00
parent f2b020ee4b
commit c415248bb1

View File

@@ -1,11 +1,11 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import {
useGeoRiskScore, useAllQuotes,
useCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
useTradeMtm, useRiskDashboard,
useTradeMtm, useRiskDashboard, useGeoNews,
useSimPortfolioRisk, useCycleStatus, useKnowledgeState,
} from '../hooks/useApi'
import { Clock, Globe, ShieldAlert, ArrowUpRight, Brain } from 'lucide-react'
import { Clock, Globe, ShieldAlert, ArrowUpRight, Brain, Newspaper } from 'lucide-react'
import { Link } from 'react-router-dom'
import clsx from 'clsx'
import type { Quote } from '../types'
@@ -26,6 +26,32 @@ const assetEmoji: Record<string, string> = {
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
}
const ASSET_COLORS: Record<string, string> = {
energy: '#f97316', metals: '#eab308', agriculture: '#22c55e',
indices: '#3b82f6', equities: '#06b6d4', forex: '#8b5cf6',
rates: '#64748b', unknown: '#334155',
}
function ViewToggle({ value, onChange }: { value: 'simulated' | 'portfolio'; onChange: (v: 'simulated' | 'portfolio') => void }) {
const click = (v: 'simulated' | 'portfolio') => (e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onChange(v)
}
return (
<div className="flex gap-0.5 bg-dark-800 rounded p-0.5 text-[9px]">
<button onClick={click('simulated')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'simulated' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Simulé
</button>
<button onClick={click('portfolio')}
className={clsx('px-1.5 py-0.5 rounded transition-colors',
value === 'portfolio' ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-400')}>
Portfolio
</button>
</div>
)
}
function QuoteRow({ q }: { q: Quote }) {
if (!q.price) return null
const pos = q.change_pct >= 0
@@ -55,6 +81,21 @@ export default function Dashboard() {
const { data: simRisk } = useSimPortfolioRisk()
const { data: cycleStatusData } = useCycleStatus()
const { data: knowledgeState } = useKnowledgeState()
const { data: geoNews } = useGeoNews()
const [pnlView, setPnlView] = useState<'simulated' | 'portfolio'>(() =>
(localStorage.getItem('dash_pnl_view') as any) ?? 'simulated'
)
const [riskView, setRiskView] = useState<'simulated' | 'portfolio'>(() =>
(localStorage.getItem('dash_risk_view') as any) ?? 'simulated'
)
const setPnlViewPersist = (v: 'simulated' | 'portfolio') => {
setPnlView(v); localStorage.setItem('dash_pnl_view', v)
}
const setRiskViewPersist = (v: 'simulated' | 'portfolio') => {
setRiskView(v); localStorage.setItem('dash_risk_view', v)
}
const allPatterns: any[] = allPatternsData ?? []
@@ -80,6 +121,27 @@ export default function Dashboard() {
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
: []
// Last cycle run_id for filtering trades
const lastRunId: string | null = (cycleStatusData as any)?.last_cycle?.run_id ?? null
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
const cycleTradesAll = lastRunId ? mtmTrades.filter((t: any) => t.run_id === lastRunId) : []
const cycleTrades = cycleTradesAll.length > 0 ? cycleTradesAll : mtmTrades.slice(0, 4)
// Top scored patterns
const topScoredPatterns = useMemo(() =>
[...allPatterns]
.filter(p => scoreMap[p.id]?.score != null)
.sort((a, b) => (scoreMap[b.id]?.score ?? 0) - (scoreMap[a.id]?.score ?? 0))
.slice(0, 4)
, [allPatterns, scoreMap])
// Top impactful news
const topNews = useMemo(() =>
[...(geoNews ?? [])]
.sort((a, b) => b.impact_score - a.impact_score)
.slice(0, 3)
, [geoNews])
return (
<div className="p-6 space-y-6">
{/* Header */}
@@ -231,57 +293,137 @@ export default function Dashboard() {
{/* ── Command Center: Résumé Opérationnel ── */}
<div className="grid grid-cols-4 gap-3">
{/* PnL Simulé */}
{/* PnL */}
{(() => {
const trades: any[] = (tradeMtmData as any)?.trades ?? []
const trades: any[] = mtmTrades
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
const avgPnl = withPnl.length
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
: null
const winners = withPnl.filter((t: any) => t.pnl_pct > 0).length
const losers = withPnl.filter((t: any) => t.pnl_pct < 0).length
const targetHit = trades.filter((t: any) => t.alert_type === 'target_reached').length
const stopHit = trades.filter((t: any) => t.alert_type === 'stop_loss').length
const pf = portfolio as any
const pfPnlPct = pf?.unrealized_pnl_pct ?? null
const pfPnl = pf?.unrealized_pnl ?? null
const pfInvest = pf?.total_invested ?? null
const pfReal = pf?.realized_pnl ?? null
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📊 PnL Simulé</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold font-mono mt-1',
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{trades.length} trades · <span className="text-emerald-500">{winners}</span>{' '}
<span className="text-red-400">{losers}</span>
<span className="section-title mb-0 text-[10px]">📊 P&L</span>
<div className="flex items-center gap-1.5">
<ViewToggle value={pnlView} onChange={setPnlViewPersist} />
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
</div>
{pnlView === 'simulated' ? (
<div className="flex items-end justify-between mt-1">
<div>
<div className={clsx('text-2xl font-bold font-mono',
avgPnl === null ? 'text-slate-500' : avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{trades.length} trades · <span className="text-emerald-500">{winners}</span>{' '}
<span className="text-red-400">{losers}</span>
</div>
</div>
<div className="text-right space-y-0.5">
{targetHit > 0 && <div className="text-[10px] text-emerald-400">🎯 {targetHit} cible{targetHit > 1 ? 's' : ''}</div>}
{stopHit > 0 && <div className="text-[10px] text-red-400"> {stopHit} stop</div>}
<div className="text-[10px] text-slate-600">{withPnl.length} pricés</div>
</div>
</div>
) : (
<div className="flex items-end justify-between mt-1">
<div>
<div className={clsx('text-2xl font-bold font-mono',
pfPnlPct === null ? 'text-slate-500' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{pf?.open_positions ?? 0} positions ouvertes
</div>
</div>
<div className="text-right space-y-0.5">
{pfInvest != null && <div className="text-[10px] text-slate-400 font-mono">{pfInvest.toFixed(0)} investi</div>}
{pfPnl != null && <div className={clsx('text-[10px] font-mono font-bold', pfPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pfPnl >= 0 ? '+' : ''}{pfPnl.toFixed(0)}
</div>}
{pfReal != null && pfReal !== 0 && <div className={clsx('text-[10px] font-mono', pfReal >= 0 ? 'text-emerald-600' : 'text-red-600')}>
réal. {pfReal >= 0 ? '+' : ''}{pfReal.toFixed(0)}
</div>}
</div>
</div>
)}
</Link>
)
})()}
{/* Risque Simulé */}
{/* Risk */}
{(() => {
const risk = simRisk as any
const alertCount: number = risk?.alerts?.length ?? 0
const conflictCount: number = risk?.conflicts?.length ?? 0
const openCount: number = risk?.open_count ?? 0
const concentration: Record<string, any> = risk?.concentration ?? {}
const sortedClasses = Object.entries(concentration)
.sort(([, a], [, b]) => (b as any).pct - (a as any).pct)
.slice(0, 5)
const pf = portfolio as any
return (
<Link to="/risk" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🛡 Risque Simulé</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
<span className="section-title mb-0 text-[10px]">🛡 Risque</span>
<div className="flex items-center gap-1.5">
<ViewToggle value={riskView} onChange={setRiskViewPersist} />
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
</div>
<div className={clsx('text-2xl font-bold mt-1', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
<div className={clsx('text-lg font-bold mt-0.5', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
</div>
<div className="text-[10px] text-slate-500 mt-1">
{openCount} positions
{conflictCount > 0 && <span className="text-red-400 ml-1">· {conflictCount} conflit{conflictCount > 1 ? 's' : ''}</span>}
</div>
{/* Allocation bars */}
{sortedClasses.length > 0 && (
<div className="mt-2 space-y-1">
{sortedClasses.map(([cls, data]: [string, any]) => (
<div key={cls} className="flex items-center gap-1.5">
<span className="text-[9px] text-slate-500 w-12 truncate capitalize">{cls}</span>
<div className="flex-1 h-2 bg-dark-700 rounded-full overflow-hidden flex">
{data.bullish > 0 && (
<div className="h-full rounded-l-full" title={`${data.bullish} haussier`}
style={{ width: `${(data.bullish / openCount) * 100}%`, background: ASSET_COLORS[cls] ?? '#3b82f6', opacity: 0.9 }} />
)}
{data.bearish > 0 && (
<div className="h-full rounded-r-full" title={`${data.bearish} baissier`}
style={{ width: `${(data.bearish / openCount) * 100}%`, background: '#ef4444', opacity: 0.7 }} />
)}
</div>
<span className="text-[9px] text-slate-600 w-7 text-right font-mono">{data.pct}%</span>
</div>
))}
<div className="flex items-center gap-1 text-[9px] text-slate-700 pt-0.5">
<span className="inline-block w-2 h-1.5 rounded-sm bg-blue-500/60"></span>haussier
<span className="inline-block w-2 h-1.5 rounded-sm bg-red-500/60 ml-1"></span>baissier
{conflictCount > 0 && <span className="ml-auto text-red-400">{conflictCount} conflit{conflictCount > 1 ? 's' : ''}</span>}
</div>
</div>
)}
{sortedClasses.length === 0 && (
<div className="text-[10px] text-slate-600 mt-1">
{openCount > 0 ? `${openCount} positions` : 'Aucune position'}
</div>
)}
</Link>
)
})()}
{/* Dernier Cycle */}
{/* Dernier Cycle — bilan complet */}
{(() => {
const last = (cycleStatusData as any)?.last_cycle
const ts = last?.ts
@@ -292,18 +434,36 @@ export default function Dashboard() {
: elapsed < 60 ? `${elapsed}min`
: elapsed < 1440 ? `${Math.round(elapsed / 60)}h`
: `${Math.round(elapsed / 1440)}j`
const patternsAdded: number = last?.patterns_added ?? 0
const patternsScored: number = last?.patterns_scored ?? 0
const tradesLogged: number = lastRunId
? mtmTrades.filter((t: any) => t.run_id === lastRunId).length
: 0
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">🔄 Dernier Cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-2xl font-bold text-blue-400 mt-1">{elapsedStr}</div>
<div className="text-[10px] text-slate-500 mt-1">
{last
? `${last.patterns_added ?? 0} loggés · géo ${last.geo_score ?? '—'}`
: 'Aucun cycle enregistré'}
</div>
<div className="text-xl font-bold text-blue-400 mt-0.5 font-mono">{elapsedStr}</div>
{last ? (
<div className="mt-1.5 grid grid-cols-3 gap-1 text-center">
<div className="bg-dark-700/60 rounded px-1 py-1">
<div className="text-sm font-bold text-slate-200">+{patternsAdded}</div>
<div className="text-[9px] text-slate-600 leading-tight">patterns</div>
</div>
<div className="bg-dark-700/60 rounded px-1 py-1">
<div className="text-sm font-bold text-emerald-400">{patternsScored}</div>
<div className="text-[9px] text-slate-600 leading-tight">scorés</div>
</div>
<div className="bg-dark-700/60 rounded px-1 py-1">
<div className="text-sm font-bold text-blue-400">{tradesLogged}</div>
<div className="text-[9px] text-slate-600 leading-tight">loggés</div>
</div>
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Aucun cycle enregistré</div>
)}
</Link>
)
})()}
@@ -364,83 +524,116 @@ export default function Dashboard() {
)
})()}
{/* Trades du dernier cycle */}
{/* Trades du dernier cycle — détaillé */}
{(() => {
const last = (cycleStatusData as any)?.last_cycle
const added: number = last?.patterns_added ?? 0
const scored: number = last?.patterns_scored ?? 0
const geoScore: number | null = last?.geo_score ?? null
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
const recent = mtmTrades.slice(0, 3)
return (
<Link to="/journal" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📥 Trades du cycle</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-2xl font-bold text-emerald-400 mt-1">+{added}</div>
{recent.length > 0 ? (
<div className="space-y-0.5 mt-1">
{recent.map((t: any) => (
<div key={t.id} className="flex justify-between text-[10px]">
<span className="text-slate-500 truncate font-mono">{t.underlying ?? '—'}</span>
<span className="text-slate-400 shrink-0 ml-1">
{t.direction === 'bearish' ? '🐻' : '🐂'}
</span>
</div>
))}
<div className="text-xl font-bold text-emerald-400 font-mono">+{added}</div>
{cycleTrades.length > 0 ? (
<div className="space-y-1 mt-1.5">
{cycleTrades.slice(0, 3).map((t: any, i) => {
const pnl: number | null = t.pnl_pct ?? null
const isBear = t.direction === 'bearish'
return (
<div key={i} className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
<span className="font-mono text-slate-300 font-semibold w-10 truncate">{t.underlying ?? '—'}</span>
<span className="text-slate-600 truncate flex-1">{t.strategy ?? ''}</span>
{pnl !== null && (
<span className={clsx('font-mono font-bold shrink-0', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
</span>
)}
{t.score_at_entry != null && (
<span className={clsx('font-mono shrink-0 text-[9px]', scoreColor(t.score_at_entry))}>
{t.score_at_entry}
</span>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">
{scored > 0 ? `${scored} scorés` : 'Aucun cycle enregistré'}
{geoScore !== null && ` · géo ${geoScore}`}
</div>
<div className="text-[10px] text-slate-600 mt-1">Aucun trade loggé</div>
)}
</Link>
)
})()}
{/* Meilleur Pattern scoré */}
{/* Top patterns scorés du cycle */}
{(() => {
const best = [...allPatterns]
.map(p => ({ p, sp: scoreMap[p.id] }))
.filter(x => x.sp?.score != null)
.sort((a, b) => (b.sp.score ?? 0) - (a.sp.score ?? 0))[0]
const score = best?.sp?.score ?? null
const name = best?.p?.name ?? null
const ticker = best?.sp?.recommended_trade?.underlying ?? null
return (
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]"> Meilleur Pattern</span>
<span className="section-title mb-0 text-[10px]"> Top Patterns</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className={clsx('text-2xl font-bold font-mono mt-1', score !== null ? scoreColor(score) : 'text-slate-500')}>
{score ?? '—'}
</div>
<div className="text-[10px] text-slate-500 mt-1 line-clamp-1">
{name ?? 'Aucun scoré'}{ticker ? ` · ${ticker}` : ''}
</div>
{topScoredPatterns.length > 0 ? (
<div className="space-y-1.5 mt-1">
{topScoredPatterns.map((p, i) => {
const sp = scoreMap[p.id]
const score = sp?.score ?? null
const ticker = sp?.recommended_trade?.underlying ?? null
return (
<div key={p.id} className="flex items-center gap-1.5">
<span className="text-[10px] text-slate-700 w-3 shrink-0 font-mono">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-slate-300 truncate leading-tight">{p.name}</div>
{ticker && <div className="text-[9px] text-slate-600 font-mono">{ticker}</div>}
</div>
{score !== null && (
<span className={clsx('text-sm font-bold font-mono shrink-0', scoreColor(score))}>{score}</span>
)}
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Aucun scoré lancer le scoring IA</div>
)}
</Link>
)
})()}
{/* Patterns Actifs */}
{/* Top news impactantes */}
{(() => {
const total = allPatterns.length
const scored = allPatterns.filter(p => scoreMap[p.id]).length
const unscored = total - scored
return (
<Link to="/patterns" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<Link to="/geo" className="card block hover:border-slate-600/60 transition-all cursor-pointer">
<div className="flex items-center justify-between mb-1">
<span className="section-title mb-0 text-[10px]">📋 Patterns Actifs</span>
<span className="section-title mb-0 text-[10px] flex items-center gap-1">
<Newspaper className="w-2.5 h-2.5" /> Top News
</span>
<ArrowUpRight className="w-3 h-3 text-slate-600" />
</div>
<div className="text-2xl font-bold text-slate-200 mt-1">{total}</div>
<div className="text-[10px] text-slate-500 mt-1">
<span className="text-emerald-500">{scored} scorés</span>
{unscored > 0 && <span className="text-slate-600"> · {unscored} à scorer</span>}
</div>
{topNews.length > 0 ? (
<div className="space-y-2 mt-1">
{topNews.map((n, i) => {
const impact = Math.round(n.impact_score * 100)
const impactColor = impact >= 70 ? 'text-red-400' : impact >= 40 ? 'text-orange-400' : 'text-yellow-400'
const barColor = impact >= 70 ? 'bg-red-500' : impact >= 40 ? 'bg-orange-500' : 'bg-yellow-500'
return (
<div key={i} className="space-y-0.5">
<div className="flex items-start gap-1.5">
<span className="text-[9px] text-slate-700 font-mono mt-0.5 shrink-0">{i + 1}</span>
<p className="text-[10px] text-slate-300 leading-tight line-clamp-2 flex-1">{n.title}</p>
<span className={clsx('text-[10px] font-bold font-mono shrink-0', impactColor)}>{impact}</span>
</div>
<div className="ml-3 bg-dark-700 rounded-full h-0.5">
<div className={clsx('h-0.5 rounded-full', barColor)} style={{ width: `${impact}%` }} />
</div>
</div>
)
})}
</div>
) : (
<div className="text-[10px] text-slate-600 mt-1">Chargement des news</div>
)}
</Link>
)
})()}