From c415248bb1a3c3a010143167712b240c510e2afc Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 19 Jun 2026 20:15:48 +0200 Subject: [PATCH] feat: enrich Dashboard cockpit cards with data + view toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/pages/Dashboard.tsx | 345 ++++++++++++++++++++++++------- 1 file changed, 269 insertions(+), 76 deletions(-) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f4eba01..e6a2f75 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 = { indices: '📊', forex: '💱', crypto: '₿', rates: '📉', } +const ASSET_COLORS: Record = { + 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 ( +
+ + +
+ ) +} + 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 (
{/* Header */} @@ -231,57 +293,137 @@ export default function Dashboard() { {/* ── Command Center: Résumé Opérationnel ── */}
- {/* 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 (
- 📊 PnL Simulé - -
-
= 0 ? 'text-emerald-400' : 'text-red-400')}> - {avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'} -
-
- {trades.length} trades · {winners}✓{' '} - {losers}✗ + 📊 P&L +
+ + +
+ {pnlView === 'simulated' ? ( +
+
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {avgPnl !== null ? `${avgPnl >= 0 ? '+' : ''}${avgPnl.toFixed(1)}%` : '—'} +
+
+ {trades.length} trades · {winners}✓{' '} + {losers}✗ +
+
+
+ {targetHit > 0 &&
🎯 {targetHit} cible{targetHit > 1 ? 's' : ''}
} + {stopHit > 0 &&
⛔ {stopHit} stop
} +
{withPnl.length} pricés
+
+
+ ) : ( +
+
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'} +
+
+ {pf?.open_positions ?? 0} positions ouvertes +
+
+
+ {pfInvest != null &&
{pfInvest.toFixed(0)}€ investi
} + {pfPnl != null &&
= 0 ? 'text-emerald-400' : 'text-red-400')}> + {pfPnl >= 0 ? '+' : ''}{pfPnl.toFixed(0)}€ +
} + {pfReal != null && pfReal !== 0 &&
= 0 ? 'text-emerald-600' : 'text-red-600')}> + réal. {pfReal >= 0 ? '+' : ''}{pfReal.toFixed(0)}€ +
} +
+
+ )} ) })()} - {/* 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 = 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 (
- 🛡️ Risque Simulé - + 🛡️ Risque +
+ + +
-
0 ? 'text-red-400' : 'text-emerald-400')}> +
0 ? 'text-red-400' : 'text-emerald-400')}> {alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
-
- {openCount} positions - {conflictCount > 0 && · {conflictCount} conflit{conflictCount > 1 ? 's' : ''}} -
+ {/* Allocation bars */} + {sortedClasses.length > 0 && ( +
+ {sortedClasses.map(([cls, data]: [string, any]) => ( +
+ {cls} +
+ {data.bullish > 0 && ( +
+ )} + {data.bearish > 0 && ( +
+ )} +
+ {data.pct}% +
+ ))} +
+ haussier + baissier + {conflictCount > 0 && {conflictCount} conflit{conflictCount > 1 ? 's' : ''}} +
+
+ )} + {sortedClasses.length === 0 && ( +
+ {openCount > 0 ? `${openCount} positions` : 'Aucune position'} +
+ )} ) })()} - {/* 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 (
🔄 Dernier Cycle
-
{elapsedStr}
-
- {last - ? `${last.patterns_added ?? 0} loggés · géo ${last.geo_score ?? '—'}` - : 'Aucun cycle enregistré'} -
+
{elapsedStr}
+ {last ? ( +
+
+
+{patternsAdded}
+
patterns
+
+
+
{patternsScored}
+
scorés
+
+
+
{tradesLogged}
+
loggés
+
+
+ ) : ( +
Aucun cycle enregistré
+ )} ) })()} @@ -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 (
📥 Trades du cycle
-
+{added}
- {recent.length > 0 ? ( -
- {recent.map((t: any) => ( -
- {t.underlying ?? '—'} - - {t.direction === 'bearish' ? '🐻' : '🐂'} - -
- ))} +
+{added}
+ {cycleTrades.length > 0 ? ( +
+ {cycleTrades.slice(0, 3).map((t: any, i) => { + const pnl: number | null = t.pnl_pct ?? null + const isBear = t.direction === 'bearish' + return ( +
+ {isBear ? '🐻' : '🐂'} + {t.underlying ?? '—'} + {t.strategy ?? ''} + {pnl !== null && ( + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}% + + )} + {t.score_at_entry != null && ( + + {t.score_at_entry} + + )} +
+ ) + })}
) : ( -
- {scored > 0 ? `${scored} scorés` : 'Aucun cycle enregistré'} - {geoScore !== null && ` · géo ${geoScore}`} -
+
Aucun trade loggé
)} ) })()} - {/* 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 (
- ⭐ Meilleur Pattern + ⭐ Top Patterns
-
- {score ?? '—'} -
-
- {name ?? 'Aucun scoré'}{ticker ? ` · ${ticker}` : ''} -
+ {topScoredPatterns.length > 0 ? ( +
+ {topScoredPatterns.map((p, i) => { + const sp = scoreMap[p.id] + const score = sp?.score ?? null + const ticker = sp?.recommended_trade?.underlying ?? null + return ( +
+ {i + 1} +
+
{p.name}
+ {ticker &&
{ticker}
} +
+ {score !== null && ( + {score} + )} +
+ ) + })} +
+ ) : ( +
Aucun scoré — lancer le scoring IA
+ )} ) })()} - {/* Patterns Actifs */} + {/* Top news impactantes */} {(() => { - const total = allPatterns.length - const scored = allPatterns.filter(p => scoreMap[p.id]).length - const unscored = total - scored return ( - +
- 📋 Patterns Actifs + + Top News +
-
{total}
-
- {scored} scorés - {unscored > 0 && · {unscored} à scorer} -
+ {topNews.length > 0 ? ( +
+ {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 ( +
+
+ {i + 1} +

{n.title}

+ {impact} +
+
+
+
+
+ ) + })} +
+ ) : ( +
Chargement des news…
+ )} ) })()}