Initial commit — GeoOptions Intelligence Cockpit v2.0
Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
867
frontend/src/pages/Dashboard.tsx
Normal file
867
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,867 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import {
|
||||
useGeoRiskScore, useAllQuotes,
|
||||
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
|
||||
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
|
||||
usePortfolioPositions, useTradeMtm, useRiskProfiles,
|
||||
} from '../hooks/useApi'
|
||||
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react'
|
||||
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 } from 'recharts'
|
||||
|
||||
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 scoreColor = (s: number) => {
|
||||
if (s >= 70) return 'text-emerald-400'
|
||||
if (s >= 50) return 'text-yellow-400'
|
||||
return 'text-slate-400'
|
||||
}
|
||||
const scoreBg = (s: number) => {
|
||||
if (s >= 70) return 'bg-emerald-500'
|
||||
if (s >= 50) return 'bg-yellow-500'
|
||||
return 'bg-slate-600'
|
||||
}
|
||||
|
||||
const BUCKET_ICONS: Record<string, string> = {
|
||||
actualites: '📰', calendrier: '📅', prix: '📈', rr: '⚖️',
|
||||
geo: '🌍', eco: '🌐', flux: '📡',
|
||||
banques: '🏦', macro_cal: '📋',
|
||||
taux: '📉', energie: '⛽', forex_sig: '💱', actions: '📊', vix: '⚡',
|
||||
asymetrie: '⚖️', timing_rr: '⏱️',
|
||||
}
|
||||
|
||||
function BucketBar({ score, max }: { score: number; max: number }) {
|
||||
const pct = max > 0 ? (score / max) * 100 : 0
|
||||
const color = pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500/70'
|
||||
return (
|
||||
<div className="w-12 bg-dark-600 rounded-full h-1 shrink-0">
|
||||
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BucketBreakdown({ buckets }: { buckets: any[] }) {
|
||||
const [openBucket, setOpenBucket] = useState<string | null>(null)
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
{buckets.map((b: any) => {
|
||||
const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0
|
||||
const isOpen = openBucket === b.id
|
||||
return (
|
||||
<div key={b.id} className="bg-dark-700/60 rounded overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-dark-600/60 transition-colors text-left"
|
||||
onClick={() => setOpenBucket(isOpen ? null : b.id)}
|
||||
>
|
||||
<span>{BUCKET_ICONS[b.id] ?? '•'}</span>
|
||||
<span className="text-slate-400 flex-1 truncate">{b.label}</span>
|
||||
<BucketBar score={b.score} max={b.max} />
|
||||
<span className={clsx('font-mono w-9 text-right shrink-0', pct >= 75 ? 'text-emerald-400' : pct >= 50 ? 'text-yellow-400' : 'text-red-400')}>
|
||||
{b.score}/{b.max}
|
||||
</span>
|
||||
{isOpen ? <ChevronUp className="w-2.5 h-2.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-2.5 h-2.5 text-slate-600 shrink-0" />}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-2 pb-2 border-t border-slate-700/30 space-y-2 pt-1.5">
|
||||
{b.comment && (
|
||||
<p className="text-slate-500 italic">{b.comment}</p>
|
||||
)}
|
||||
{b.subs?.map((sub: any) => {
|
||||
const subPct = sub.max > 0 ? Math.round((sub.score / sub.max) * 100) : 0
|
||||
return (
|
||||
<div key={sub.id} className="pl-1 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs">{BUCKET_ICONS[sub.id] ?? '›'}</span>
|
||||
<span className="text-slate-500 flex-1 truncate">{sub.label}</span>
|
||||
<BucketBar score={sub.score} max={sub.max} />
|
||||
<span className={clsx('font-mono w-7 text-right shrink-0', subPct >= 75 ? 'text-emerald-400' : subPct >= 50 ? 'text-yellow-400' : 'text-slate-600')}>
|
||||
{sub.score}/{sub.max}
|
||||
</span>
|
||||
</div>
|
||||
{sub.comment && (
|
||||
<p className="text-slate-600 italic ml-4">{sub.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'all', label: 'Tous' },
|
||||
{ key: 'energy', label: '⛽ Énergie' },
|
||||
{ key: 'metals', label: '🥇 Métaux' },
|
||||
{ key: 'agriculture', label: '🌾 Agri' },
|
||||
{ key: 'indices', label: '📊 Indices' },
|
||||
{ key: 'equities', label: '📈 Actions' },
|
||||
{ key: 'forex', label: '💱 Forex' },
|
||||
]
|
||||
|
||||
interface TradeItem {
|
||||
trade: any
|
||||
patternName: string
|
||||
patternId: string
|
||||
assetClass: string
|
||||
score: number | null
|
||||
scoreInfo: any | null
|
||||
scoreDelta: number | null
|
||||
rankRationale: string | null
|
||||
expectedMovePct: number // from original pattern definition
|
||||
}
|
||||
|
||||
const BIAS_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||
'bullish+': { label: '★★ Compatible', color: '#10b981' },
|
||||
'bullish': { label: '★ Compatible', color: '#34d399' },
|
||||
'neutral': { label: '→ Neutre', color: '#64748b' },
|
||||
'bearish': { label: '✗ Défavorable', color: '#f97316' },
|
||||
'bearish+': { label: '✗✗ Contra', color: '#ef4444' },
|
||||
'defensive':{ label: '⚠ Défensif', color: '#f59e0b' },
|
||||
}
|
||||
|
||||
function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
|
||||
item: TradeItem
|
||||
onAdd: (item: TradeItem) => void
|
||||
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
|
||||
addedInfo?: { entry_date: string } | null
|
||||
profiles?: any[]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
|
||||
const effectiveScore = score !== null
|
||||
? Math.max(0, Math.min(100, score + (scoreDelta ?? 0)))
|
||||
: null
|
||||
|
||||
// EV calculation: ev_net = p×G - (1-p) where p=score/100, G=gain/100
|
||||
const gainPct = expectedMovePct ?? 0
|
||||
const evNet = effectiveScore !== null && gainPct > 0
|
||||
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
|
||||
: null
|
||||
|
||||
// Which profile matches this trade (if any)
|
||||
const matchedProfile = useMemo(() => {
|
||||
if (effectiveScore === null || !profiles || profiles.length === 0) return undefined
|
||||
return profiles.find(prof =>
|
||||
prof.enabled && effectiveScore >= prof.min_score && gainPct >= prof.min_gain_pct
|
||||
) ?? null
|
||||
}, [effectiveScore, gainPct, profiles])
|
||||
const breakdown = scoreInfo?.score_breakdown ?? {}
|
||||
const rationale = scoreInfo?.summary ?? trade.rationale ?? scoreInfo?.key_catalyst ?? ''
|
||||
const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur
|
||||
// Cible dérivée de la formule (cohérent avec Gain%) — fallback sur estimation GPT-4o
|
||||
const target = maxLoss != null && gainPct > 0
|
||||
? Math.round(Math.abs(maxLoss) * gainPct / 100)
|
||||
: (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur)
|
||||
const timing = trade.timing_note ?? scoreInfo?.recommended_trade?.timing_note
|
||||
|
||||
return (
|
||||
<div className={clsx('card transition-all', {
|
||||
'border-emerald-700/50': effectiveScore !== null && effectiveScore >= 70,
|
||||
'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70,
|
||||
'border-slate-700/20': effectiveScore === null,
|
||||
})}>
|
||||
{/* Pattern name (tiny, above) */}
|
||||
<div className="text-xs text-slate-600 line-clamp-1 mb-1 font-mono">{patternName}</div>
|
||||
|
||||
{/* Trade + score */}
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="badge badge-blue text-xs">{assetClass}</span>
|
||||
{trade.underlying && (
|
||||
<span className="text-sm text-white font-semibold font-mono">{trade.underlying}</span>
|
||||
)}
|
||||
{trade.isRecommended && effectiveScore !== null && (
|
||||
<span className="text-xs text-yellow-400 bg-yellow-400/10 border border-yellow-400/30 rounded px-1">★ IA</span>
|
||||
)}
|
||||
</div>
|
||||
{trade.strategy && (
|
||||
<span className="badge badge-green text-xs mt-0.5">{trade.strategy}</span>
|
||||
)}
|
||||
</div>
|
||||
{effectiveScore !== null ? (
|
||||
<div className="ml-2 shrink-0 text-center min-w-[48px]">
|
||||
<div className={clsx('text-2xl font-bold leading-none', scoreColor(effectiveScore))}>{effectiveScore}</div>
|
||||
<div className="text-xs text-slate-600 flex items-center justify-center gap-0.5">
|
||||
<span>/100</span>
|
||||
{scoreDelta !== null && scoreDelta !== 0 && (
|
||||
<span className={clsx('text-[10px] font-mono font-bold', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{scoreInfo?.score_trend != null && (
|
||||
<div className={clsx('text-[10px] font-mono font-bold mt-0.5', scoreInfo.score_trend > 0 ? 'text-emerald-400' : scoreInfo.score_trend < 0 ? 'text-red-400' : 'text-slate-600')}>
|
||||
{scoreInfo.score_trend > 0 ? '↑+' : scoreInfo.score_trend < 0 ? '↓' : '→'}{scoreInfo.score_trend !== 0 ? Math.abs(scoreInfo.score_trend) : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-2 shrink-0 text-xs text-slate-500 bg-dark-600 border border-slate-700/40 rounded px-1.5 py-0.5 whitespace-nowrap">
|
||||
à scorer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score bar */}
|
||||
{effectiveScore !== null && (
|
||||
<div className="bg-dark-600 rounded-full h-1.5 mb-2">
|
||||
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EV / Profile match row — always shown when scored, helps understand why trade is/isn't logged */}
|
||||
{effectiveScore !== null && (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[10px] flex-wrap">
|
||||
<span className="text-slate-600">
|
||||
Gain:{' '}
|
||||
<span className={gainPct > 0 ? 'text-slate-300' : 'text-orange-400/80'}>
|
||||
{gainPct > 0 ? `${gainPct}%` : '?'}
|
||||
</span>
|
||||
</span>
|
||||
{evNet !== null ? (
|
||||
<>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span className={clsx('font-mono font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>
|
||||
EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
|
||||
</span>
|
||||
</>
|
||||
) : gainPct === 0 ? (
|
||||
<>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span className="text-orange-400/70">EV ?</span>
|
||||
</>
|
||||
) : null}
|
||||
{matchedProfile !== undefined && (
|
||||
<>
|
||||
<span className="text-slate-700">·</span>
|
||||
{matchedProfile ? (
|
||||
<span className="font-semibold" style={{ color: matchedProfile.color }}>
|
||||
● {matchedProfile.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-400/80">
|
||||
✗ {gainPct === 0 ? 'Gain non défini' : 'Aucun profil'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank rationale (why this trade differs from pattern average) */}
|
||||
{rankRationale && (
|
||||
<div className="text-[10px] text-slate-600 italic mb-1.5 line-clamp-1">{rankRationale}</div>
|
||||
)}
|
||||
|
||||
{/* Macro scenario compatibility */}
|
||||
{macroInfo && macroInfo.dominant !== 'incertain' && (() => {
|
||||
const bias = macroInfo.assetBias[assetClass] ?? 'neutral'
|
||||
const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral']
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[10px]">
|
||||
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span style={{ color: bd.color }}>{bd.label}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Rationale */}
|
||||
{rationale && <p className="text-xs text-slate-400 line-clamp-2 mb-2">{rationale}</p>}
|
||||
|
||||
{/* timing */}
|
||||
{timing && <div className="text-xs text-yellow-400/80 mb-2">⏱ {timing}</div>}
|
||||
|
||||
{/* max/target */}
|
||||
{(maxLoss != null || target != null) && (
|
||||
<div className="flex gap-2 text-xs mb-2">
|
||||
{maxLoss != null && <span className="text-red-400">Max -{Math.abs(maxLoss)}€</span>}
|
||||
{target != null && <span className="text-emerald-400">Cible +{target}€</span>}
|
||||
{scoreInfo?.confidence && <span className="text-slate-600 ml-auto">conf. {scoreInfo.confidence}%</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable score breakdown */}
|
||||
{effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && (
|
||||
<>
|
||||
<button onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-1.5">
|
||||
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
Détail du score par pilier
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mb-2">
|
||||
{scoreInfo?.buckets?.length > 0 ? (
|
||||
<BucketBreakdown buckets={scoreInfo.buckets} />
|
||||
) : (
|
||||
<div className="space-y-1 bg-dark-700/50 rounded p-2">
|
||||
{Object.entries(breakdown).map(([k, v]: [string, any]) => (
|
||||
<div key={k} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500 capitalize">{k.replace(/_/g, ' ')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-14 bg-dark-600 rounded-full h-1">
|
||||
<div className="bg-blue-500 h-1 rounded-full" style={{ width: `${(v / 25) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-300 w-5 text-right text-xs">{v}/25</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{addedInfo && (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
|
||||
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||
<span>Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}</span>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => onAdd(item)}
|
||||
className={clsx(
|
||||
'w-full flex items-center justify-center gap-1 text-xs rounded py-1 transition-all border',
|
||||
addedInfo
|
||||
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
|
||||
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
|
||||
)}>
|
||||
<Plus className="w-3 h-3" />
|
||||
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
|
||||
</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: aiStatus } = useAiStatus()
|
||||
const { data: portfolio } = usePortfolioSummary()
|
||||
const { data: lastScoresData } = useLastScores()
|
||||
const { data: allPatternsData } = useAllPatterns()
|
||||
const { data: macroData } = useMacroRegime()
|
||||
const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open')
|
||||
const { data: tradeMtmData } = useTradeMtm(30)
|
||||
const { data: riskProfilesData } = useRiskProfiles()
|
||||
const { mutate: scorePatterns, isPending: scoring } = useScorePatterns()
|
||||
const { mutate: addPos } = useAddPosition()
|
||||
|
||||
const riskProfiles: any[] = (riskProfilesData as any)?.profiles ?? []
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||
const [topN, setTopN] = useState(10)
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return
|
||||
const t = setTimeout(() => setToast(null), 3500)
|
||||
return () => clearTimeout(t)
|
||||
}, [toast])
|
||||
|
||||
// Build two keys per position so we match regardless of ticker normalization:
|
||||
// key1 = geo_trigger (pattern name) + strategy → survives underlying normalization
|
||||
// key2 = underlying + strategy → direct ticker match fallback
|
||||
const addedMap = useMemo(() => {
|
||||
const map: Record<string, { entry_date: string }> = {}
|
||||
const upsert = (key: string, entry_date: string) => {
|
||||
if (!map[key] || entry_date > map[key].entry_date) map[key] = { entry_date }
|
||||
}
|
||||
for (const pos of (positions as any[] ?? [])) {
|
||||
const strategy = (pos.strategy ?? '').toLowerCase()
|
||||
const trigger = (pos.geo_trigger ?? '').toLowerCase()
|
||||
const underly = (pos.underlying ?? '').toLowerCase()
|
||||
if (trigger) upsert(`trigger:${trigger}:${strategy}`, pos.entry_date ?? '')
|
||||
if (underly) upsert(`ticker:${underly}:${strategy}`, pos.entry_date ?? '')
|
||||
}
|
||||
return map
|
||||
}, [positions])
|
||||
|
||||
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] ?? {}
|
||||
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias }
|
||||
}, [macroData])
|
||||
|
||||
const gauge = riskScore ? riskGauge(riskScore.score) : null
|
||||
const radarData = riskScore?.breakdown
|
||||
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
|
||||
: []
|
||||
|
||||
// Map of last AI scores by pattern_id
|
||||
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 allPatterns: any[] = allPatternsData ?? []
|
||||
const scoredAt: string | null = lastScoresData?.scored_at ?? null
|
||||
|
||||
// Build flat list of TradeItems
|
||||
const { topScored, allUnscored } = useMemo(() => {
|
||||
const filtered = allPatterns.filter(p =>
|
||||
categoryFilter === 'all' || p.asset_class === categoryFilter
|
||||
)
|
||||
|
||||
const scored: TradeItem[] = []
|
||||
const unscored: TradeItem[] = []
|
||||
|
||||
for (const p of filtered) {
|
||||
const sp = scoreMap[p.id]
|
||||
if (sp) {
|
||||
// Scored: show ALL suggested_trades from pattern, annotated with score
|
||||
const recUnderlying = sp.recommended_trade?.underlying
|
||||
const trades: any[] = p.suggested_trades ?? []
|
||||
const tradesOrFallback = trades.length > 0 ? trades : [sp.recommended_trade ?? {}]
|
||||
const rankings: any[] = sp.trade_rankings ?? []
|
||||
for (const t of tradesOrFallback) {
|
||||
const isRecommended = recUnderlying && t.underlying === recUnderlying
|
||||
// Match this trade in rankings by underlying (+ strategy if available)
|
||||
const ranking = rankings.find(r =>
|
||||
r.underlying === t.underlying &&
|
||||
(!r.strategy || !t.strategy || r.strategy === t.strategy)
|
||||
)
|
||||
scored.push({
|
||||
trade: { ...t, isRecommended },
|
||||
patternName: p.name,
|
||||
patternId: p.id,
|
||||
assetClass: t.asset_class ?? p.asset_class,
|
||||
score: sp.score,
|
||||
scoreInfo: sp,
|
||||
scoreDelta: ranking?.score_delta ?? null,
|
||||
rankRationale: ranking?.rationale ?? null,
|
||||
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Unscored: one card per suggested trade
|
||||
const trades: any[] = p.suggested_trades ?? []
|
||||
if (trades.length === 0) {
|
||||
unscored.push({ trade: {}, patternName: p.name, patternId: p.id, assetClass: p.asset_class, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: p.expected_move_pct ?? 0 })
|
||||
} else {
|
||||
for (const t of trades) {
|
||||
unscored.push({
|
||||
trade: t,
|
||||
patternName: p.name,
|
||||
patternId: p.id,
|
||||
assetClass: t.asset_class ?? p.asset_class,
|
||||
score: null,
|
||||
scoreInfo: null,
|
||||
scoreDelta: null,
|
||||
rankRationale: null,
|
||||
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effScore = (item: TradeItem) =>
|
||||
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
|
||||
scored.sort((a, b) => effScore(b) - effScore(a))
|
||||
return { topScored: scored.slice(0, topN), allUnscored: unscored }
|
||||
}, [allPatterns, scoreMap, categoryFilter, topN])
|
||||
|
||||
const handleAdd = (item: TradeItem) => {
|
||||
const t = item.trade
|
||||
const sp = item.scoreInfo
|
||||
addPos({
|
||||
title: `${t.strategy ?? ''} ${t.underlying ?? ''} — ${item.patternName}`.trim(),
|
||||
underlying: t.underlying ?? item.patternName,
|
||||
strategy: t.strategy ?? '',
|
||||
asset_class: item.assetClass,
|
||||
expiry_days: t.expiry_days ?? sp?.recommended_trade?.expiry_days ?? 90,
|
||||
capital_invested: Math.abs(t.max_loss_eur ?? sp?.recommended_trade?.max_loss_eur ?? 1000),
|
||||
geo_trigger: item.patternName,
|
||||
rationale: t.rationale ?? sp?.key_catalyst ?? '',
|
||||
legs: [{
|
||||
option_type: (t.strategy ?? '').toLowerCase().includes('put') ? 'put' : 'call',
|
||||
quantity: 1,
|
||||
position: 'long',
|
||||
}],
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
refetchPositions()
|
||||
setToast({
|
||||
title: 'Ajouté au portefeuille',
|
||||
sub: `${t.strategy ?? ''} ${t.underlying ?? ''} · ${item.patternName}`.trim(),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getAddedInfo = (item: TradeItem) => {
|
||||
const strategy = (item.trade.strategy ?? '').toLowerCase()
|
||||
const trigger = (item.patternName ?? '').toLowerCase()
|
||||
const underly = (item.trade.underlying ?? '').toLowerCase()
|
||||
return (
|
||||
addedMap[`trigger:${trigger}:${strategy}`] ??
|
||||
addedMap[`ticker:${underly}:${strategy}`] ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Top row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* Geo Risk */}
|
||||
<div className="card col-span-1">
|
||||
<div className="section-title flex items-center gap-1"><Globe className="w-3 h-3" /> Risque Géopolitique</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
|
||||
{scoredAt && <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
|
||||
// Show best effective score across all trades (consistent with card display)
|
||||
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
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-slate-300 truncate flex-1 min-w-0">{p.name}</span>
|
||||
{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>
|
||||
|
||||
{/* ── Trade ideas scorées par IA ── */}
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="section-title flex items-center gap-1 mb-0">
|
||||
<Target className="w-3 h-3" /> Idées de trade
|
||||
</h2>
|
||||
{scoredAt && (
|
||||
<span className="text-xs text-slate-600">
|
||||
— scoré le {format(new Date(scoredAt), "d MMM à HH:mm", { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
{topScored.length > 0 && (
|
||||
<span className="text-xs text-emerald-500">
|
||||
{topScored.length} scoré{topScored.length > 1 ? 's' : ''} · {allUnscored.length} à scorer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Category filter */}
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{CATEGORIES.map(c => (
|
||||
<button key={c.key} onClick={() => setCategoryFilter(c.key)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-blue-600 text-white': categoryFilter === c.key,
|
||||
'text-slate-400 hover:text-slate-200': categoryFilter !== c.key,
|
||||
})}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Top N */}
|
||||
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
|
||||
{[5, 10, 20].map(n => (
|
||||
<button key={n} onClick={() => setTopN(n)}
|
||||
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
|
||||
'bg-slate-600 text-white': topN === n,
|
||||
'text-slate-400 hover:text-slate-200': topN !== n,
|
||||
})}>
|
||||
Top {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Score button */}
|
||||
{aiStatus?.enabled ? (
|
||||
<button onClick={() => scorePatterns({})}
|
||||
disabled={scoring}
|
||||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-all">
|
||||
{scoring
|
||||
? <><RefreshCw className="w-3 h-3 animate-spin" /> Scoring GPT-4o...</>
|
||||
: <><Brain className="w-3 h-3" /> Scorer les patterns</>}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600 border border-slate-700/30 rounded px-2 py-1">Clé OpenAI requise</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scored trade cards */}
|
||||
{topScored.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-3 mb-5">
|
||||
{topScored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unscored trade cards */}
|
||||
{allUnscored.length > 0 && (
|
||||
<>
|
||||
{topScored.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600 mb-3">
|
||||
<span className="border-b border-slate-700/30 flex-1"></span>
|
||||
{allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} à scorer
|
||||
<span className="border-b border-slate-700/30 flex-1"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{allUnscored.map((item, i) => (
|
||||
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allPatterns.length === 0 && (
|
||||
<div className="card text-center py-8 text-slate-500 text-sm">
|
||||
Démarrer le backend — patterns en cours de chargement…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Suivi M2M des trades logués par le système ── */}
|
||||
{(() => {
|
||||
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
|
||||
const withPnl = mtmTrades.filter(t => t.pnl_pct != null)
|
||||
if (mtmTrades.length === 0) return null
|
||||
const winners = withPnl.filter(t => t.pnl_pct > 0).length
|
||||
const losers = withPnl.filter(t => t.pnl_pct < 0).length
|
||||
const avgPnl = withPnl.length ? withPnl.reduce((s, t) => s + t.pnl_pct, 0) / withPnl.length : null
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-title flex items-center gap-1 mb-0">
|
||||
<Brain className="w-3 h-3 text-blue-400" /> Suivi M2M système ({mtmTrades.length} trades)
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{avgPnl != null && (
|
||||
<span className={clsx('font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="text-emerald-400">{winners}✓</span>
|
||||
<span className="text-red-400">{losers}✗</span>
|
||||
<span className="text-slate-600">{withPnl.length - winners - losers} flat</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-600 border-b border-slate-800">
|
||||
<th className="text-left py-1.5 pr-3">Pattern</th>
|
||||
<th className="text-left py-1.5 pr-3">Stratégie</th>
|
||||
<th className="text-left py-1.5 pr-3 font-mono">Ticker</th>
|
||||
<th className="text-right py-1.5 pr-3">Score</th>
|
||||
<th className="text-right py-1.5 pr-3">Entrée</th>
|
||||
<th className="text-right py-1.5 pr-3">Actuel</th>
|
||||
<th className="text-right py-1.5 pr-3">J</th>
|
||||
<th className="text-right py-1.5">P&L th.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/40">
|
||||
{mtmTrades.slice(0, 15).map((t: any) => {
|
||||
const pnl: number | null = t.pnl_pct
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-dark-700/30">
|
||||
<td className="py-1.5 pr-3 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={clsx('badge text-[10px]', t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
|
||||
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 font-mono text-slate-400">{t.underlying}</td>
|
||||
<td className={clsx('py-1.5 pr-3 text-right font-bold font-mono',
|
||||
t.score_at_entry >= 50 ? 'text-emerald-400' : t.score_at_entry >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
|
||||
{t.score_at_entry}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-slate-500">
|
||||
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-slate-300">
|
||||
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right text-slate-600">{t.days_held ?? '—'}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{pnl != null ? (
|
||||
<span className={clsx('font-bold font-mono', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
|
||||
</span>
|
||||
) : <span className="text-slate-600">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-start gap-3 bg-emerald-950 border border-emerald-600/50 text-emerald-200 rounded-xl px-4 py-3 shadow-2xl animate-fade-in min-w-[260px]">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-emerald-300">{toast.title}</div>
|
||||
<div className="text-xs text-emerald-500 mt-0.5 line-clamp-2">{toast.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user