get_trade_entry_prices filtre WHERE status='open', les trades fermés n'y apparaissaient jamais. Réalisé branche maintenant sur useClosedTrades(90) avec calcul EUR = capital * pnl_realized% / 100 et affichage avg %. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
993 lines
51 KiB
TypeScript
993 lines
51 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import {
|
||
useGeoRiskScore, useAllQuotes,
|
||
useCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
|
||
useTradeMtm, useRiskDashboard, useGeoNews,
|
||
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
||
} from '../hooks/useApi'
|
||
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'
|
||
import { format } from 'date-fns'
|
||
import { fr } from 'date-fns/locale'
|
||
import {
|
||
RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer,
|
||
AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid,
|
||
} from 'recharts'
|
||
import { scoreColor } from '../components/TradeIdeas'
|
||
|
||
const riskGauge = (score: number) => {
|
||
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'FAIBLE' }
|
||
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODÉRÉ' }
|
||
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'ÉLEVÉ' }
|
||
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTRÊME' }
|
||
}
|
||
|
||
const assetEmoji: Record<string, string> = {
|
||
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
|
||
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',
|
||
}
|
||
|
||
const REGIME_LABELS: Record<string, string> = {
|
||
goldilocks: 'Goldilocks', desinflation: 'Désinflation', stagflation: 'Stagflation',
|
||
recession: 'Récession', crise_liquidite: 'Crise liq.', reflation: 'Reflation',
|
||
soft_landing: 'Soft Landing', inflation_shock: 'Infl. Choc', incertain: 'Incertain',
|
||
}
|
||
|
||
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
|
||
return (
|
||
<div className="flex items-center justify-between py-1.5 border-b border-slate-700/20 last:border-0">
|
||
<span className="text-xs text-white truncate max-w-[140px]">{q.name || q.symbol}</span>
|
||
<div className="text-right ml-2">
|
||
<div className="text-xs text-white font-mono">{q.price.toFixed(2)}</div>
|
||
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
|
||
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Dashboard() {
|
||
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
|
||
const { data: allQuotes } = useAllQuotes()
|
||
const { data: calendar } = useCalendar()
|
||
const { data: portfolio } = usePortfolioSummary()
|
||
const { data: lastScoresData } = useLastScores()
|
||
const { data: allPatternsData } = useAllPatterns()
|
||
const { data: macroData } = useMacroRegime()
|
||
const { data: tradeMtmData } = useTradeMtm(30)
|
||
const { data: closedTradesData } = useClosedTrades(90)
|
||
const { data: riskDashboard } = useRiskDashboard()
|
||
const { data: simRisk } = useSimPortfolioRisk()
|
||
const { data: cycleStatusData } = useCycleStatus()
|
||
const { data: geoNews } = useGeoNews()
|
||
|
||
// Cycle history (last 5 for "Derniers Cycles" card)
|
||
const { data: cycleHistoryData } = useQuery({
|
||
queryKey: ['dash-cycle-history'],
|
||
queryFn: () => fetch('/api/cycle/history?limit=5').then(r => r.ok ? r.json() : { runs: [] }),
|
||
staleTime: 60_000,
|
||
retry: 1,
|
||
})
|
||
|
||
// Historical PnL curve + latest VaR snapshot
|
||
const { data: pnlHistoryData } = useQuery({
|
||
queryKey: ['dash-pnl-history'],
|
||
queryFn: () => fetch('/api/var/pnl/snapshots?limit=200').then(r => r.ok ? r.json() : { snapshots: [] }),
|
||
staleTime: 120_000,
|
||
retry: 1,
|
||
})
|
||
const { data: latestVarData } = useQuery({
|
||
queryKey: ['dash-var-latest'],
|
||
queryFn: () => fetch('/api/var/latest').then(r => r.ok ? r.json() : { snapshot: null }),
|
||
staleTime: 120_000,
|
||
retry: 1,
|
||
})
|
||
|
||
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 ?? []
|
||
|
||
const scoreMap = useMemo(() => {
|
||
const map: Record<string, any> = {}
|
||
for (const sp of (lastScoresData?.scored_patterns ?? [])) {
|
||
if (sp.pattern_id) map[sp.pattern_id] = sp
|
||
}
|
||
return map
|
||
}, [lastScoresData])
|
||
|
||
const macroInfo = useMemo(() => {
|
||
if (!macroData?.scenarios) return null
|
||
const sc = macroData.scenarios
|
||
const dom = sc.dominant ?? 'incertain'
|
||
const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' }
|
||
const assetBias: Record<string, string> = sc.asset_bias?.[dom] ?? {}
|
||
// Top 3 scenario scores (excluding dominant, sorted desc)
|
||
const ranked: [string, number][] = (sc.ranked ?? []).slice(0, 4)
|
||
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias, ranked }
|
||
}, [macroData])
|
||
|
||
const gauge = riskScore ? riskGauge(riskScore.score) : null
|
||
const radarData = riskScore?.breakdown
|
||
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
|
||
: []
|
||
|
||
// Cycle trades: use scoring_run_id (which differs from cycle run_id)
|
||
const lastCycle = (cycleStatusData as any)?.last_cycle ?? null
|
||
const scoringRunId: string | null = lastCycle?.scoring_run_id ?? null
|
||
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
|
||
const cycleTrades = scoringRunId ? mtmTrades.filter((t: any) => t.run_id === scoringRunId) : []
|
||
|
||
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
|
||
const cyclePatterns = useMemo(() => {
|
||
const started = lastCycle?.started_at ?? null
|
||
if (!started) return []
|
||
return [...allPatterns]
|
||
.filter(p => p.created_at && p.created_at >= started)
|
||
.sort((a, b) => (scoreMap[b.id]?.score ?? -1) - (scoreMap[a.id]?.score ?? -1))
|
||
.slice(0, 4)
|
||
}, [allPatterns, scoreMap, lastCycle])
|
||
|
||
// Trades grouped by run_id for cycle history card
|
||
const tradesByRun = useMemo(() => {
|
||
const map: Record<string, { logged: number; closed: number }> = {}
|
||
for (const t of mtmTrades) {
|
||
if (!t.run_id) continue
|
||
if (!map[t.run_id]) map[t.run_id] = { logged: 0, closed: 0 }
|
||
map[t.run_id].logged++
|
||
if (t.status === 'closed') map[t.run_id].closed++
|
||
}
|
||
return map
|
||
}, [mtmTrades])
|
||
|
||
// 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 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white">Cockpit GeoOptions</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
{format(new Date(), "EEEE d MMMM yyyy · HH:mm", { locale: fr })}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{portfolio && portfolio.open_positions > 0 && (
|
||
<div className={clsx('text-sm font-bold', portfolio.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
Portfolio: {portfolio.unrealized_pnl >= 0 ? '+' : ''}{portfolio.unrealized_pnl?.toFixed(0)}€
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
|
||
Live
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Risk concentration banner */}
|
||
{(riskDashboard as any)?.concentration_alerts?.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
{((riskDashboard as any).concentration_alerts as any[]).slice(0, 2).map((alert: any, i: number) => (
|
||
<div key={i} className={clsx('flex items-center gap-2 text-xs px-3 py-2 rounded border', {
|
||
'bg-red-900/20 border-red-700/30 text-red-300': alert.level === 'high',
|
||
'bg-amber-900/20 border-amber-700/30 text-amber-300': alert.level === 'warning',
|
||
})}>
|
||
<ShieldAlert className="w-3 h-3 shrink-0" />
|
||
{alert.message}
|
||
<a href="/risk" className="ml-auto text-slate-500 hover:text-slate-300 underline">Risk Dashboard →</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Top row */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{/* Geo Risk */}
|
||
<div className="card col-span-1">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="section-title flex items-center gap-1 mb-0">
|
||
<Globe className="w-3 h-3" /> Risque Géopolitique
|
||
</div>
|
||
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
||
News géo <ArrowUpRight className="w-2.5 h-2.5" />
|
||
</Link>
|
||
</div>
|
||
{riskLoading ? (
|
||
<div className="animate-pulse h-16 bg-dark-600 rounded"></div>
|
||
) : riskScore && gauge ? (
|
||
<>
|
||
<div className={clsx('text-5xl font-bold', gauge.color)}>{riskScore.score}</div>
|
||
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
|
||
<div className="mt-3 bg-dark-700 rounded-full h-2">
|
||
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
|
||
</div>
|
||
<div className="mt-2 space-y-1">
|
||
{riskScore.top_risks?.map(([cat, val]) => (
|
||
<div key={cat} className="flex justify-between text-xs">
|
||
<span className="text-slate-500 capitalize">{(cat as string).replace('_', ' ')}</span>
|
||
<span className="text-slate-300">{Math.round((val as number) * 100)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : <div className="text-slate-500 text-xs">Backend requis</div>}
|
||
</div>
|
||
|
||
{/* Radar */}
|
||
<div className="card col-span-1">
|
||
<div className="section-title">Cartographie risques</div>
|
||
{radarData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<RadarChart data={radarData}>
|
||
<PolarGrid stroke="#1e2d4d" />
|
||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
|
||
<Radar dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
) : <div className="h-40 flex items-center justify-center text-slate-600 text-xs">Chargement...</div>}
|
||
</div>
|
||
|
||
{/* Patterns — AI scores */}
|
||
<div className="card col-span-1 overflow-y-auto max-h-64">
|
||
<div className="section-title flex items-center gap-1">
|
||
<Brain className="w-3 h-3" /> Scores patterns
|
||
{allPatterns.length > 0 && <span className="text-slate-600 text-xs ml-auto font-normal">{allPatterns.length} patterns</span>}
|
||
</div>
|
||
<div className="space-y-1.5 mt-1">
|
||
{allPatterns.length === 0 ? (
|
||
<div className="text-slate-600 text-xs">Backend requis</div>
|
||
) : (
|
||
[...allPatterns]
|
||
.sort((a, b) => {
|
||
const spA = scoreMap[a.id], spB = scoreMap[b.id]
|
||
const effA = spA ? Math.max(...[0, ...(spA.trade_rankings ?? []).map((r: any) => (spA.score ?? 0) + (r.score_delta ?? 0))]) : -1
|
||
const effB = spB ? Math.max(...[0, ...(spB.trade_rankings ?? []).map((r: any) => (spB.score ?? 0) + (r.score_delta ?? 0))]) : -1
|
||
return effB - effA
|
||
})
|
||
.map(p => {
|
||
const sp = scoreMap[p.id]
|
||
const rawScore = sp?.score ?? null
|
||
const bestEff = sp
|
||
? Math.max(rawScore ?? 0, ...(sp.trade_rankings ?? []).map((r: any) =>
|
||
Math.max(0, Math.min(100, (rawScore ?? 0) + (r.score_delta ?? 0)))))
|
||
: null
|
||
const addedDate = p.created_at ? p.created_at.slice(0, 10) : null
|
||
return (
|
||
<div key={p.id} className="flex items-center gap-2">
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-xs text-slate-300 truncate block">{p.name}</span>
|
||
{addedDate && <span className="text-[9px] text-slate-700 font-mono">{addedDate}</span>}
|
||
</div>
|
||
{bestEff !== null ? (
|
||
<span className={clsx('text-xs font-bold shrink-0', scoreColor(bestEff))}>{bestEff}</span>
|
||
) : (
|
||
<span className="text-xs text-slate-600 shrink-0">—</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendrier */}
|
||
<div className="card col-span-1 space-y-3">
|
||
<div className="section-title flex items-center gap-1"><Clock className="w-3 h-3" /> Prochains catalyseurs</div>
|
||
<div className="space-y-1.5">
|
||
{calendar?.slice(0, 4).map((ev, i) => (
|
||
<div key={i} className="flex items-center gap-2 text-xs">
|
||
<span className={clsx('badge', {
|
||
'badge-red': ev.importance === 'high',
|
||
'badge-yellow': ev.importance === 'medium',
|
||
'badge-blue': ev.importance === 'low',
|
||
})}>
|
||
{'!'.repeat(ev.importance === 'high' ? 3 : ev.importance === 'medium' ? 2 : 1)}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-white line-clamp-1">{ev.title}</div>
|
||
<div className="text-slate-600">{ev.date?.slice(0, 10)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Command Center: Résumé Opérationnel ── */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
|
||
{/* PnL */}
|
||
{(() => {
|
||
// Unrealized — open trades only (get_trade_entry_prices excludes closed)
|
||
const openTrades = mtmTrades
|
||
const openWithPnl = openTrades.filter((t: any) => t.pnl_pct != null)
|
||
const openCapital = openTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
|
||
const openProfit = openWithPnl.reduce((s: number, t: any) => s + ((t.capital_invested ?? t.entry_price ?? 0) * t.pnl_pct / 100), 0)
|
||
const openPnlPct = openCapital > 0 ? openProfit / openCapital * 100 : null
|
||
const openWinners = openWithPnl.filter((t: any) => t.pnl_pct > 0).length
|
||
const openLosers = openWithPnl.filter((t: any) => t.pnl_pct < 0).length
|
||
const targetHit = openTrades.filter((t: any) => t.alert_type === 'target_reached').length
|
||
const stopHit = openTrades.filter((t: any) => t.alert_type === 'stop_loss').length
|
||
|
||
// Realized — from dedicated closed-trades endpoint (pnl_realized in %, capital for EUR)
|
||
const closedTrades = (closedTradesData as any)?.trades ?? []
|
||
const closedCapital = closedTrades.reduce((s: number, t: any) => s + (t.capital_invested ?? t.entry_price ?? 0), 0)
|
||
const closedProfitEur = closedTrades.reduce((s: number, t: any) => {
|
||
const cap = t.capital_invested ?? t.entry_price ?? 0
|
||
return s + (cap * (t.pnl_realized ?? 0) / 100)
|
||
}, 0)
|
||
const closedWithPnl = closedTrades.filter((t: any) => t.pnl_realized != null)
|
||
const avgClosedPct = closedWithPnl.length > 0
|
||
? closedWithPnl.reduce((s: number, t: any) => s + t.pnl_realized, 0) / closedWithPnl.length
|
||
: null
|
||
const closedWinners = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) > 0).length
|
||
const closedLosers = closedTrades.filter((t: any) => (t.pnl_realized ?? 0) < 0).length
|
||
|
||
const isEstimated = openTrades.some((t: any) => t.capital_invested == null && t.entry_price != null)
|
||
const totalCapital = openCapital + closedCapital
|
||
const totalProfit = openProfit + closedProfitEur
|
||
|
||
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
|
||
const pfNet = pf?.net_pnl ?? null
|
||
|
||
return (
|
||
<Link to="/journal" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<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>
|
||
|
||
{/* ── Side-by-side: Unrealized | Realized ── */}
|
||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||
{/* Left: Unrealized */}
|
||
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
|
||
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Ouvertes</div>
|
||
{pnlView === 'simulated' ? (
|
||
<>
|
||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||
openPnlPct === null ? 'text-slate-600' : openPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{openPnlPct !== null ? `${openPnlPct >= 0 ? '+' : ''}${openPnlPct.toFixed(1)}%` : '—'}
|
||
</div>
|
||
<div className={clsx('text-xs font-mono mt-0.5',
|
||
openProfit >= 0 ? 'text-emerald-500' : 'text-red-400')}>
|
||
{openCapital > 0 ? `${openProfit >= 0 ? '+' : ''}${openProfit.toFixed(0)}€` : '—'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{openTrades.length} trades · <span className="text-emerald-500">{openWinners}✓</span>{' '}
|
||
<span className="text-red-400">{openLosers}✗</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||
pfPnlPct === null ? 'text-slate-600' : pfPnlPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{pfPnlPct !== null ? `${pfPnlPct >= 0 ? '+' : ''}${pfPnlPct.toFixed(1)}%` : '—'}
|
||
</div>
|
||
<div className={clsx('text-xs font-mono mt-0.5', (pfPnl ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-400')}>
|
||
{pfPnl != null ? `${pfPnl >= 0 ? '+' : ''}${pfPnl.toFixed(0)}€` : '—'}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{pf?.open_positions ?? 0} positions
|
||
</div>
|
||
</>
|
||
)}
|
||
{(targetHit > 0 || stopHit > 0) && (
|
||
<div className="mt-1 text-[9px] space-x-1">
|
||
{targetHit > 0 && <span className="text-emerald-400">🎯{targetHit}</span>}
|
||
{stopHit > 0 && <span className="text-red-400">⛔{stopHit}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right: Realized */}
|
||
<div className="bg-dark-700/40 rounded px-2.5 py-2 border border-slate-700/30">
|
||
<div className="text-[9px] text-slate-600 uppercase tracking-wide mb-1">Réalisé</div>
|
||
{pnlView === 'simulated' ? (
|
||
<>
|
||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||
avgClosedPct === null ? 'text-slate-600'
|
||
: avgClosedPct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{avgClosedPct !== null
|
||
? `${avgClosedPct >= 0 ? '+' : ''}${avgClosedPct.toFixed(2)}%`
|
||
: closedTrades.length === 0 ? '—' : '+0.00%'}
|
||
</div>
|
||
<div className={clsx('text-xs font-mono mt-0.5',
|
||
closedProfitEur >= 0 ? 'text-emerald-500' : 'text-red-400')}>
|
||
{closedCapital > 0
|
||
? `${closedProfitEur >= 0 ? '+' : ''}${closedProfitEur.toFixed(0)}€`
|
||
: closedTrades.length > 0 ? 'sans capital' : ''}
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{closedTrades.length} fermés · <span className="text-emerald-500">{closedWinners}✓</span>{' '}
|
||
<span className="text-red-400">{closedLosers}✗</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className={clsx('text-xl font-bold font-mono leading-none',
|
||
pfReal == null || pfReal === 0 ? 'text-slate-600'
|
||
: pfReal > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{pfReal != null && pfReal !== 0
|
||
? `${pfReal >= 0 ? '+' : ''}${pfReal.toFixed(0)}€`
|
||
: '—'}
|
||
</div>
|
||
{pfNet != null && (
|
||
<div className={clsx('text-xs font-mono mt-0.5', pfNet >= 0 ? 'text-emerald-500/70' : 'text-red-400/70')}>
|
||
net {pfNet >= 0 ? '+' : ''}{pfNet.toFixed(0)}€
|
||
</div>
|
||
)}
|
||
<div className="text-[10px] text-slate-500 mt-1">
|
||
{pf?.closed_positions ?? 0} fermées
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Total line */}
|
||
<div className="mt-1.5 flex items-center justify-between text-[10px] flex-shrink-0">
|
||
<span className="text-slate-600">
|
||
Investi{isEstimated ? ' ~' : ''} {pnlView === 'simulated'
|
||
? (totalCapital > 0 ? `${totalCapital.toFixed(0)}€` : '—')
|
||
: (pfInvest != null ? `${pfInvest.toFixed(0)}€` : '—')}
|
||
</span>
|
||
<span className={clsx('font-mono font-bold',
|
||
(pnlView === 'simulated' ? totalProfit : (pfNet ?? 0)) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
Total {pnlView === 'simulated'
|
||
? (totalCapital > 0 ? `${totalProfit >= 0 ? '+' : ''}${totalProfit.toFixed(0)}€` : '—')
|
||
: (pfNet != null ? `${pfNet >= 0 ? '+' : ''}${pfNet.toFixed(0)}€` : '—')}
|
||
</span>
|
||
</div>
|
||
{/* PnL historical sparkline */}
|
||
{(() => {
|
||
const snaps: any[] = [...(pnlHistoryData?.snapshots ?? [])].reverse()
|
||
if (snaps.length < 2) return null
|
||
const chartData = snaps.map(s => ({
|
||
date: s.snapped_at?.slice(5, 10),
|
||
pnl: s.total_pnl_pct ?? 0,
|
||
}))
|
||
const minVal = Math.min(...chartData.map(d => d.pnl))
|
||
const maxVal = Math.max(...chartData.map(d => d.pnl))
|
||
const isPositive = chartData[chartData.length - 1]?.pnl >= 0
|
||
const strokeColor = isPositive ? '#34d399' : '#f87171'
|
||
const gradId = 'pnlGrad'
|
||
return (
|
||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30">
|
||
<div className="text-[9px] text-slate-600 mb-0.5">Courbe PnL historique ({snaps.length} pts)</div>
|
||
<ResponsiveContainer width="100%" height={58}>
|
||
<AreaChart data={chartData} margin={{ top: 2, right: 0, left: -38, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.25} />
|
||
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||
<XAxis dataKey="date" tick={{ fontSize: 8, fill: '#475569' }}
|
||
interval={Math.max(0, Math.floor(chartData.length / 5) - 1)} />
|
||
<YAxis domain={[minVal - 0.1, maxVal + 0.1]} tick={{ fontSize: 8, fill: '#475569' }} unit="%" />
|
||
<Tooltip
|
||
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
|
||
labelStyle={{ color: '#64748b' }}
|
||
formatter={(v: any) => [`${v >= 0 ? '+' : ''}${Number(v).toFixed(3)}%`, 'PnL']}
|
||
/>
|
||
<Area type="monotone" dataKey="pnl" stroke={strokeColor} strokeWidth={1.5}
|
||
fill={`url(#${gradId})`} dot={false} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)
|
||
})()}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* 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)
|
||
|
||
return (
|
||
<Link to="/risk" className="card flex flex-col h-[288px] overflow-hidden 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</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-lg font-bold mt-0.5', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
|
||
{alertCount > 0 ? `${alertCount} alerte${alertCount > 1 ? 's' : ''}` : 'OK'}
|
||
</div>
|
||
{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>
|
||
)}
|
||
{/* Latest VaR snapshot */}
|
||
{(() => {
|
||
const snap = latestVarData?.snapshot
|
||
if (!snap) return null
|
||
const hv = snap.hist_var_1d_pct
|
||
const cv = snap.hist_cvar_pct
|
||
const mv = snap.mc_var_1d_pct
|
||
const ts = snap.computed_at?.slice(0, 16).replace('T', ' ')
|
||
return (
|
||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30">
|
||
<div className="text-[9px] text-slate-600 mb-1">VaR 95% · {ts} UTC</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{[
|
||
{ label: 'Hist.', val: hv, color: 'text-blue-400' },
|
||
{ label: 'CVaR', val: cv, color: 'text-orange-400' },
|
||
{ label: 'MC×1.5', val: mv, color: 'text-red-400' },
|
||
].map(({ label, val, color }) => (
|
||
<div key={label} className="bg-dark-700/60 rounded px-2 py-1 text-center">
|
||
<div className="text-[8px] text-slate-600 mb-0.5">{label}</div>
|
||
<div className={clsx('text-xs font-bold font-mono', color)}>
|
||
{val != null ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : '—'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Derniers Cycles — liste */}
|
||
{(() => {
|
||
const runs: any[] = cycleHistoryData?.runs ?? []
|
||
return (
|
||
<Link to="/journal" className="card flex flex-col h-[288px] overflow-hidden hover:border-slate-600/60 transition-all cursor-pointer">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="section-title mb-0 text-[10px]">🔄 Derniers Cycles</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
{/* Column headers */}
|
||
<div className="flex items-center gap-1 mb-1 pb-1 border-b border-slate-700/30">
|
||
<span className="text-[8px] text-slate-700 w-20 shrink-0">Date</span>
|
||
<span className="text-[8px] text-slate-700 w-8 shrink-0 text-right">Pat.</span>
|
||
<span className="text-[8px] text-slate-700 w-8 shrink-0 text-right">Log.</span>
|
||
<span className="text-[8px] text-slate-700 w-8 shrink-0 text-right">Clos</span>
|
||
<span className="text-[8px] text-slate-700 flex-1 truncate">Régime</span>
|
||
</div>
|
||
{runs.length > 0 ? (
|
||
<div className="space-y-2.5 flex-1 overflow-hidden">
|
||
{runs.map((r: any, i: number) => {
|
||
const dt = r.started_at ? new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z') : null
|
||
const dateStr = dt ? `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')} ${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}` : '—'
|
||
const pat: number = r.patterns_added ?? 0
|
||
const tStats = tradesByRun[r.run_id] ?? { logged: 0, closed: 0 }
|
||
const regime = r.dominant_regime ? (REGIME_LABELS[r.dominant_regime] ?? r.dominant_regime) : '—'
|
||
const ok = r.status === 'completed'
|
||
return (
|
||
<div key={r.run_id ?? i} className="flex items-center gap-1">
|
||
<span className="text-[9px] text-slate-400 font-mono w-20 shrink-0">{dateStr}</span>
|
||
<span className={clsx('text-[9px] font-mono font-bold w-8 shrink-0 text-right', pat > 0 ? 'text-blue-400' : 'text-slate-600')}>
|
||
+{pat}
|
||
</span>
|
||
<span className={clsx('text-[9px] font-mono w-8 shrink-0 text-right', tStats.logged > 0 ? 'text-emerald-400' : 'text-slate-600')}>
|
||
{tStats.logged}
|
||
</span>
|
||
<span className={clsx('text-[9px] font-mono w-8 shrink-0 text-right', tStats.closed > 0 ? 'text-slate-400' : 'text-slate-600')}>
|
||
{tStats.closed}
|
||
</span>
|
||
<span className="text-[8px] text-slate-600 flex-1 truncate">{regime}</span>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', ok ? 'bg-emerald-500' : 'bg-slate-700')} />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-slate-600 mt-1">Aucun cycle enregistré</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Régime Macro */}
|
||
{(() => {
|
||
const dom = macroInfo?.dominant
|
||
const colorClass = dom === 'growth' || dom === 'goldilocks' || dom === 'soft_landing' ? 'text-emerald-400'
|
||
: dom === 'stagflation' || dom === 'recession' || dom === 'crise_liquidite' ? 'text-red-400'
|
||
: dom === 'deflation' || dom === 'desinflation' ? 'text-blue-300'
|
||
: dom === 'inflation_shock' ? 'text-orange-400'
|
||
: 'text-slate-400'
|
||
const ranked = macroInfo?.ranked ?? []
|
||
|
||
// Key gauges from macroData
|
||
const gauges = (macroData as any)?.gauges ?? {}
|
||
const reasons: string[] = dom ? ((macroData as any)?.scenarios?.reasons?.[dom] ?? []) : []
|
||
|
||
// 5 key indicators that explain the regime
|
||
const KEY_GAUGES = [
|
||
{
|
||
key: 'vix', label: 'VIX',
|
||
color: (v: number) => v < 18 ? 'text-emerald-400' : v < 25 ? 'text-yellow-400' : 'text-red-400',
|
||
fmt: (v: number) => v.toFixed(1),
|
||
hint: (v: number) => v < 18 ? '↓ risque bas' : v < 25 ? '↑ alerte' : '↑ crise',
|
||
},
|
||
{
|
||
key: 'spx_vs_200d', label: 'S&P/200j',
|
||
color: (v: number) => v >= 0 ? 'text-emerald-400' : 'text-red-400',
|
||
fmt: (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`,
|
||
hint: (v: number) => v >= 5 ? 'bull fort' : v >= 0 ? 'bull mod.' : 'bear',
|
||
},
|
||
{
|
||
key: 'slope_10y3m', label: 'Pente 10Y-3M',
|
||
color: (v: number) => v >= 0.5 ? 'text-emerald-400' : v >= 0 ? 'text-yellow-400' : 'text-red-400',
|
||
fmt: (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(2)}`,
|
||
hint: (v: number) => v >= 0.5 ? 'normale' : v >= 0 ? 'plate' : 'inversée',
|
||
},
|
||
{
|
||
key: 'copper', label: 'Cuivre',
|
||
color: (v: number, chg: number) => chg >= 0 ? 'text-emerald-400' : 'text-red-400',
|
||
fmt: (v: number, chg: number) => `${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%`,
|
||
hint: (v: number, chg: number) => chg >= 0 ? '↑ croissance' : '↓ ralentis.',
|
||
},
|
||
{
|
||
key: 'gold', label: 'Or',
|
||
color: (v: number, chg: number) => chg >= 1 ? 'text-amber-400' : chg <= -1 ? 'text-emerald-400' : 'text-slate-400',
|
||
fmt: (v: number, chg: number) => `${chg >= 0 ? '+' : ''}${chg.toFixed(2)}%`,
|
||
hint: (v: number, chg: number) => chg >= 1 ? '↑ refuge' : chg <= -1 ? '↓ risk-on' : '≈ neutre',
|
||
},
|
||
]
|
||
|
||
return (
|
||
<Link to="/macro" className="card flex flex-col h-[288px] overflow-hidden 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]">🌐 Régime Macro</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
||
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
||
</div>
|
||
{ranked.length > 0 && (
|
||
<div className="mt-1.5 space-y-1">
|
||
{ranked.slice(0, 4).map(([key, score]: [string, number]) => {
|
||
const maxScore = ranked[0]?.[1] ?? 1
|
||
const pct = Math.round((score / Math.max(maxScore, 1)) * 100)
|
||
const isDom = key === dom
|
||
return (
|
||
<div key={key} className="flex items-center gap-1.5">
|
||
<span className={clsx('text-[9px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-500')}>
|
||
{REGIME_LABELS[key] ?? key}
|
||
</span>
|
||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||
<div
|
||
className={clsx('h-full rounded-full transition-all', isDom ? 'bg-blue-500' : 'bg-slate-600')}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className={clsx('text-[9px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-600')}>
|
||
{score}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Key macro gauges */}
|
||
{Object.keys(gauges).length > 0 && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
|
||
{KEY_GAUGES.map(({ key, label, color, fmt, hint }) => {
|
||
const g = gauges[key]
|
||
if (!g) return null
|
||
const val: number = g.value ?? 0
|
||
const chg: number = g.change_pct ?? 0
|
||
const c = color(val, chg)
|
||
const h = hint(val, chg)
|
||
return (
|
||
<div key={key} className="flex items-center gap-1.5">
|
||
<span className="text-[9px] text-slate-600 w-16 shrink-0">{label}</span>
|
||
<span className={clsx('text-[9px] font-mono font-semibold shrink-0', c)}>
|
||
{fmt(val, chg)}
|
||
</span>
|
||
<span className="text-[8px] text-slate-700 truncate">{h}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Trigger signals for dominant regime */}
|
||
{reasons.length > 0 && (
|
||
<div className="mt-1 flex flex-wrap gap-1">
|
||
{reasons.slice(0, 4).map((r: string, i: number) => (
|
||
<span key={i} className="text-[8px] bg-blue-900/20 text-blue-400/80 border border-blue-800/30 px-1.5 py-0.5 rounded">
|
||
{r}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* ── Command Center: Intelligence & Contexte ── */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
|
||
{/* Top 5 trades loggés */}
|
||
{(() => {
|
||
const top5 = [...mtmTrades]
|
||
.sort((a, b) => (b.pnl_pct ?? -999) - (a.pnl_pct ?? -999))
|
||
.slice(0, 5)
|
||
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]">🏆 Top Trades</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
{top5.length > 0 ? (
|
||
<div className="space-y-1.5 mt-1">
|
||
{top5.map((t: any, i: number) => {
|
||
const pnl: number | null = t.pnl_pct ?? null
|
||
const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
|
||
const entryDate = t.entry_date ? t.entry_date.slice(0, 10) : null
|
||
return (
|
||
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
||
<span className="text-[9px] text-slate-700 font-mono w-3 shrink-0">{i + 1}</span>
|
||
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
|
||
<span className="font-mono text-slate-200 font-bold shrink-0">{t.underlying ?? '—'}</span>
|
||
<span className="text-slate-500 truncate flex-1 text-[9px]">{t.strategy ?? ''}</span>
|
||
{entryDate && <span className="text-[8px] text-slate-700 shrink-0 font-mono">{entryDate}</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>
|
||
) : (
|
||
<span className="text-slate-700 shrink-0 text-[9px]">—</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-slate-600 mt-1">Aucun trade loggé</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Derniers trades loggés */}
|
||
{(() => {
|
||
const recent = [...mtmTrades]
|
||
.filter((t: any) => t.entry_date)
|
||
.sort((a: any, b: any) => (b.entry_date ?? '').localeCompare(a.entry_date ?? ''))
|
||
.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]">📥 Derniers trades loggés</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
{recent.length > 0 ? (
|
||
<div className="space-y-2.5 mt-1">
|
||
{recent.map((t: any, i: number) => {
|
||
const pnl: number | null = t.pnl_pct ?? null
|
||
const isBear = t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
|
||
const entryDate = t.entry_date ? t.entry_date.slice(0, 10) : null
|
||
const score: number | null = t.score_at_entry ?? null
|
||
return (
|
||
<div key={i}>
|
||
<div className="flex items-center gap-1.5 text-[10px]">
|
||
<span className="shrink-0">{isBear ? '🐻' : '🐂'}</span>
|
||
<span className="font-mono text-slate-200 font-bold w-10 truncate shrink-0">{t.underlying ?? '—'}</span>
|
||
<span className="text-slate-500 truncate flex-1 text-[9px]">{t.strategy ?? ''}</span>
|
||
{score !== null && (
|
||
<span className={clsx('font-mono font-bold shrink-0 text-[9px]', scoreColor(score))}>{score}</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>
|
||
) : (
|
||
<span className="text-slate-700 shrink-0 text-[9px]">en cours</span>
|
||
)}
|
||
</div>
|
||
{entryDate && (
|
||
<div className="text-[8px] text-slate-700 mt-0.5 ml-4 font-mono">{entryDate} · {t.pattern_name ? <span className="italic">{t.pattern_name}</span> : ''}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-slate-600 mt-1">Aucun trade loggé</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Derniers Patterns ajoutés */}
|
||
{(() => {
|
||
const recent = [...allPatterns]
|
||
.filter((p: any) => p.created_at)
|
||
.sort((a: any, b: any) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
||
.slice(0, 3)
|
||
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]">⭐ Derniers Patterns ajoutés</span>
|
||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||
</div>
|
||
{recent.length > 0 ? (
|
||
<div className="space-y-2.5 mt-1">
|
||
{recent.map((p: any, i: number) => {
|
||
const sp = scoreMap[p.id]
|
||
const score: number | null = sp?.score ?? null
|
||
const ticker: string | null = sp?.recommended_trade?.underlying ?? null
|
||
const dateStr = p.created_at ? p.created_at.slice(0, 10) : null
|
||
return (
|
||
<div key={p.id}>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[9px] 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>
|
||
</div>
|
||
{score !== null ? (
|
||
<span className={clsx('text-sm font-bold font-mono shrink-0', scoreColor(score))}>{score}</span>
|
||
) : (
|
||
<span className="text-[9px] text-slate-700 shrink-0">—</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[8px] text-slate-700 mt-0.5 ml-4 font-mono">
|
||
{ticker && <span className="text-slate-500">{ticker}</span>}
|
||
{ticker && dateStr && ' · '}
|
||
{dateStr}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-slate-600 mt-1">Aucun pattern enregistré</div>
|
||
)}
|
||
</Link>
|
||
)
|
||
})()}
|
||
|
||
{/* Top news impactantes */}
|
||
{(() => {
|
||
return (
|
||
<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] 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>
|
||
{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>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
{/* Markets mini overview */}
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{['energy', 'metals', 'indices', 'forex'].map(cls => (
|
||
<div key={cls} className="card">
|
||
<div className="section-title">{assetEmoji[cls]} {cls}</div>
|
||
{allQuotes?.[cls]?.map(q => <QuoteRow key={q.symbol} q={q} />) ?? (
|
||
<div className="text-xs text-slate-600">Chargement...</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|