- Extract TradeCard/TradeRow/IBKRTicket + TradeIdeasTab self-contained component to frontend/src/components/TradeIdeas.tsx - Dashboard: remove trade ideas section, MtM section, all related hooks/ state/computations; replace Signaux Géo mini-card with Trades du cycle; add News géo link on Risque Géopolitique card - JournalDeBord: add Idées de trade tab (left of Ouverts) using TradeIdeasTab - Config: reorganize into 5 tabs — IA & Analyse, Auto-Cycle & Logging, Sources, Journal & Sortie, Profils de risque; expose min_score_threshold as a prominent slider in the Auto-Cycle tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1709 lines
82 KiB
TypeScript
1709 lines
82 KiB
TypeScript
import { useState, useEffect, useRef, Fragment } from 'react'
|
||
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock, ShieldAlert, PieChart, Target } from 'lucide-react'
|
||
import { TradeIdeasTab } from '../components/TradeIdeas'
|
||
import clsx from 'clsx'
|
||
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, useSimPortfolioRisk, useSkippedTrades, api } from '../hooks/useApi'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
import { format } from 'date-fns'
|
||
import { fr } from 'date-fns/locale'
|
||
|
||
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
|
||
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
|
||
desinflation: { label: 'Désinflation', color: '#06b6d4', emoji: '❄️' },
|
||
soft_landing: { label: 'Soft Landing', color: '#3b82f6', emoji: '🛬' },
|
||
reflation: { label: 'Reflation', color: '#f97316', emoji: '🔥' },
|
||
stagflation: { label: 'Stagflation', color: '#eab308', emoji: '⚡' },
|
||
inflation_shock: { label: 'Inflation Shock', color: '#ef4444', emoji: '💥' },
|
||
recession: { label: 'Récession', color: '#8b5cf6', emoji: '📉' },
|
||
crise_liquidite: { label: 'Crise Liquidité', color: '#ec4899', emoji: '🚨' },
|
||
incertain: { label: 'Incertain', color: '#64748b', emoji: '❓' },
|
||
}
|
||
|
||
function ScenarioBadge({ dominant, size = 'sm' }: { dominant: string; size?: 'sm' | 'lg' }) {
|
||
const m = SCENARIO_META[dominant] ?? SCENARIO_META.incertain
|
||
return (
|
||
<span
|
||
className={clsx('inline-flex items-center gap-1 rounded font-semibold',
|
||
size === 'lg' ? 'px-2.5 py-1 text-sm' : 'px-1.5 py-0.5 text-[11px]')}
|
||
style={{ background: `${m.color}22`, color: m.color, border: `1px solid ${m.color}44` }}
|
||
>
|
||
{m.emoji} {m.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function PnlBadge({ pnl }: { pnl: number | null | undefined }) {
|
||
if (pnl == null) return <span className="text-slate-600 text-xs">—</span>
|
||
const pos = pnl >= 0
|
||
return (
|
||
<span className={clsx('inline-flex items-center gap-0.5 text-xs font-bold font-mono',
|
||
pos ? 'text-emerald-400' : 'text-red-400')}>
|
||
{pos ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||
{pos ? '+' : ''}{pnl.toFixed(2)}%
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ScoreDelta({ entry, latest }: { entry: number | null; latest: number | null }) {
|
||
if (entry == null || latest == null) return <span className="text-slate-600 text-[10px]">—</span>
|
||
const delta = latest - entry
|
||
return (
|
||
<span className="flex flex-col items-end gap-0.5">
|
||
<span className={clsx('font-bold font-mono text-xs',
|
||
latest >= 50 ? 'text-emerald-400' : latest >= 25 ? 'text-yellow-400' : 'text-slate-500')}>
|
||
{latest}
|
||
</span>
|
||
{delta !== 0 && (
|
||
<span className={clsx('text-[10px] font-mono', delta > 0 ? 'text-emerald-600' : 'text-red-600')}>
|
||
{delta > 0 ? '+' : ''}{delta}
|
||
</span>
|
||
)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── Section 1 : Historique des régimes macro ──────────────────────────────────
|
||
|
||
function IvRankCell({ underlying }: { underlying: string }) {
|
||
const { data, isLoading } = useIvForTrade(underlying)
|
||
if (isLoading) return <span className="text-slate-700 text-[10px]">…</span>
|
||
const rank = data?.iv_rank
|
||
const iv = data?.iv_current_pct
|
||
if (rank == null) return <span className="text-slate-700 text-[10px]">—</span>
|
||
|
||
const color = rank >= 80 ? 'text-red-400' : rank >= 50 ? 'text-amber-400' : rank >= 20 ? 'text-emerald-400' : 'text-blue-400'
|
||
const signal = rank >= 80 ? '↓vol' : rank < 20 ? '↑vol' : ''
|
||
|
||
return (
|
||
<div className="flex flex-col items-end gap-0.5">
|
||
<span className={clsx('text-[10px] font-bold font-mono', color)}>
|
||
{rank}%
|
||
</span>
|
||
<span className="text-[8px] text-slate-600">{iv != null ? `IV ${iv}%` : ''}</span>
|
||
{signal && <span className={clsx('text-[8px] font-semibold', color)}>{signal}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function KellyCell({ patternId, capital = 10000 }: { patternId: string; capital?: number }) {
|
||
const { data, isLoading } = useKellySizing(patternId, capital)
|
||
if (!patternId || isLoading) return <span className="text-slate-700 text-[10px]">…</span>
|
||
if (!data || data.error) return <span className="text-slate-700 text-[10px]">—</span>
|
||
const pct = data.suggested_capital_pct
|
||
const eur = data.suggested_capital_eur
|
||
const color = pct <= 0 ? 'text-slate-600' : pct < 5 ? 'text-amber-400' : 'text-emerald-400'
|
||
const adjusted = data.cluster_adjustment_reason || data.reliability_adjustment
|
||
return (
|
||
<div className="flex flex-col items-end gap-0.5">
|
||
<span className={clsx('text-[10px] font-bold font-mono', color)}>{pct?.toFixed(1)}%</span>
|
||
<span className="text-[8px] text-slate-600">{eur != null ? `${eur}€` : ''}</span>
|
||
{adjusted && <span className="text-[8px] text-orange-400" title={adjusted}>⚠ ajusté</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Close Trade Modal ─────────────────────────────────────────────────────────
|
||
|
||
const CLOSE_REASONS = [
|
||
{ value: 'target', label: '🎯 Objectif atteint' },
|
||
{ value: 'stop_loss', label: '🛑 Stop-loss déclenché' },
|
||
{ value: 'signal_reversal', label: '🔄 Signal IA retourné' },
|
||
{ value: 'manual', label: '✍️ Fermeture manuelle' },
|
||
]
|
||
|
||
function CloseTradeModal({ trade, onClose }: { trade: any; onClose: () => void }) {
|
||
const { mutate, isPending, isError } = useCloseTrade()
|
||
const [closePrice, setClosePrice] = useState(String(trade.current_price ?? trade.entry_price ?? ''))
|
||
const [reason, setReason] = useState(trade.alert_type === 'target_reached' ? 'target' :
|
||
trade.alert_type === 'stop_loss' ? 'stop_loss' : 'manual')
|
||
const [note, setNote] = useState('')
|
||
|
||
const ep = parseFloat(closePrice) || 0
|
||
const isBearish = trade.direction === 'bearish'
|
||
const pnlPreview = trade.entry_price && ep > 0
|
||
? (() => {
|
||
const raw = (ep - trade.entry_price) / trade.entry_price * 100
|
||
return round2(isBearish ? -raw : raw)
|
||
})()
|
||
: null
|
||
|
||
function round2(n: number) { return Math.round(n * 100) / 100 }
|
||
|
||
const handleSubmit = () => {
|
||
mutate({
|
||
id: trade.id,
|
||
body: { close_price: ep, close_reason: reason, close_note: note }
|
||
}, { onSuccess: onClose })
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||
<div className="bg-dark-800 border border-slate-700/60 rounded-xl w-full max-w-md p-6 space-y-5 shadow-2xl">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="text-sm font-bold text-white flex items-center gap-2">
|
||
<Lock className="w-4 h-4 text-amber-400" /> Fermer le trade
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-0.5">
|
||
{trade.underlying} · {trade.strategy} · entrée {trade.entry_price?.toFixed(2) ?? '—'}
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-600 hover:text-slate-400"><X className="w-4 h-4" /></button>
|
||
</div>
|
||
|
||
{trade.alert_type && (
|
||
<div className={clsx('text-xs rounded px-3 py-2 font-semibold border',
|
||
trade.alert_type === 'target_reached'
|
||
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
|
||
: 'bg-red-900/30 border-red-700/40 text-red-300')}>
|
||
{trade.alert_type === 'target_reached' ? '🎯 Objectif atteint !' : '🛑 Stop-loss atteint'}
|
||
{' — P&L actuel '}
|
||
<span className="font-mono">{trade.pnl_pct >= 0 ? '+' : ''}{trade.pnl_pct?.toFixed(2)}%</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Prix de clôture</label>
|
||
<input
|
||
type="number" step="0.01"
|
||
value={closePrice}
|
||
onChange={e => setClosePrice(e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white font-mono"
|
||
placeholder="Prix actuel du sous-jacent"
|
||
/>
|
||
{pnlPreview !== null && (
|
||
<div className={clsx('text-xs mt-1 font-mono font-bold',
|
||
pnlPreview >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
P&L réalisé : {pnlPreview >= 0 ? '+' : ''}{pnlPreview}%
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Motif de fermeture</label>
|
||
<select
|
||
value={reason}
|
||
onChange={e => setReason(e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white"
|
||
>
|
||
{CLOSE_REASONS.map(r => (
|
||
<option key={r.value} value={r.value}>{r.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Note (optionnel)</label>
|
||
<input
|
||
type="text"
|
||
value={note}
|
||
onChange={e => setNote(e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white"
|
||
placeholder="Contexte, raison…"
|
||
maxLength={200}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isError && <div className="text-xs text-red-400">Erreur lors de la fermeture du trade.</div>}
|
||
|
||
<div className="flex gap-3 pt-1">
|
||
<button onClick={onClose}
|
||
className="flex-1 px-4 py-2 rounded border border-slate-700/50 text-slate-400 hover:text-slate-200 text-sm">
|
||
Annuler
|
||
</button>
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={isPending || ep === 0}
|
||
className="flex-1 px-4 py-2 rounded bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white text-sm font-semibold flex items-center justify-center gap-2"
|
||
>
|
||
<Lock className="w-3.5 h-3.5" />
|
||
{isPending ? 'Fermeture…' : 'Confirmer la fermeture'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Closed Trades Section ─────────────────────────────────────────────────────
|
||
|
||
function ClosedTradesSection({ days }: { days: number }) {
|
||
const { data, isLoading, refetch, isFetching } = useClosedTrades(days)
|
||
const trades: any[] = (data as any)?.trades ?? []
|
||
const stats: any = (data as any)?.stats ?? {}
|
||
|
||
const REASON_META: Record<string, { label: string; color: string }> = {
|
||
target: { label: '🎯 Objectif', color: 'text-emerald-400' },
|
||
stop_loss: { label: '🛑 Stop-loss', color: 'text-red-400' },
|
||
signal_reversal: { label: '🔄 Signal IA', color: 'text-blue-400' },
|
||
manual: { label: '✍️ Manuel', color: 'text-slate-400' },
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Stats banner */}
|
||
{trades.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{[
|
||
{ label: 'Trades fermés', value: stats.total ?? 0, sub: '', color: 'text-white' },
|
||
{ label: 'Taux de succès', value: stats.win_rate != null ? `${stats.win_rate}%` : '—', sub: '', color: stats.win_rate >= 50 ? 'text-emerald-400' : 'text-red-400' },
|
||
{ label: 'P&L moyen', value: stats.avg_pnl != null ? `${stats.avg_pnl >= 0 ? '+' : ''}${stats.avg_pnl?.toFixed(1)}%` : '—', sub: '', color: (stats.avg_pnl ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400' },
|
||
{ label: 'P&L total', value: stats.total_pnl != null ? `${stats.total_pnl >= 0 ? '+' : ''}${stats.total_pnl?.toFixed(1)}%` : '—', sub: `meilleur ${stats.best?.toFixed(1) ?? '—'}%`, color: (stats.total_pnl ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400' },
|
||
].map(s => (
|
||
<div key={s.label} className="card text-center py-3">
|
||
<div className={clsx('text-lg font-bold font-mono', s.color)}>{s.value}</div>
|
||
<div className="text-[11px] text-slate-600">{s.label}</div>
|
||
{s.sub && <div className="text-[10px] text-slate-700 mt-0.5">{s.sub}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
{trades.length} trades clôturés · {days} derniers jours
|
||
</div>
|
||
<button onClick={() => refetch()} disabled={isFetching}
|
||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="card h-32 animate-pulse bg-dark-700" />
|
||
) : trades.length === 0 ? (
|
||
<div className="card text-center py-12 text-slate-600 text-sm">
|
||
<Lock className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
Aucun trade clôturé dans les {days} derniers jours
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-slate-600 border-b border-slate-700/40">
|
||
<th className="text-left px-3 py-2 font-medium">Pattern</th>
|
||
<th className="text-left px-3 py-2 font-medium">Stratégie</th>
|
||
<th className="text-left px-3 py-2 font-medium">Ticker</th>
|
||
<th className="text-right px-3 py-2 font-medium">Entrée</th>
|
||
<th className="text-right px-3 py-2 font-medium">Sortie</th>
|
||
<th className="text-right px-3 py-2 font-medium">Date entrée</th>
|
||
<th className="text-right px-3 py-2 font-medium">Date sortie</th>
|
||
<th className="text-right px-3 py-2 font-medium">Durée</th>
|
||
<th className="text-right px-3 py-2 font-medium">P&L réalisé</th>
|
||
<th className="text-left px-3 py-2 font-medium">Motif</th>
|
||
<th className="text-left px-3 py-2 font-medium">Note</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/60">
|
||
{trades.map((t: any) => {
|
||
const meta = REASON_META[t.close_reason] ?? REASON_META.manual
|
||
const daysHeld = t.entry_date && t.closed_at
|
||
? Math.round((new Date(t.closed_at).getTime() - new Date(t.entry_date).getTime()) / 86400000)
|
||
: null
|
||
return (
|
||
<tr key={t.id} className="hover:bg-dark-700/30 transition-colors">
|
||
<td className="px-3 py-2 text-slate-300 max-w-[100px] truncate">{t.pattern_name || t.pattern_id}</td>
|
||
<td className="px-3 py-2">
|
||
<span className={clsx('badge text-[10px]',
|
||
t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
|
||
? 'badge-red' : 'badge-green')}>
|
||
{t.strategy || '—'}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-slate-300">{t.underlying}</td>
|
||
<td className="px-3 py-2 text-right font-mono text-slate-500 text-[11px]">
|
||
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right font-mono text-slate-400 text-[11px]">
|
||
{t.close_price != null ? t.close_price.toFixed(2) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">{t.entry_date ?? '—'}</td>
|
||
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">
|
||
{t.closed_at ? t.closed_at.slice(0, 10) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-600 text-[11px] font-mono">
|
||
{daysHeld != null ? `${daysHeld}j` : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{t.pnl_realized != null ? (
|
||
<span className={clsx('font-bold font-mono text-xs',
|
||
t.pnl_realized >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
{t.pnl_realized >= 0 ? '+' : ''}{t.pnl_realized.toFixed(2)}%
|
||
</span>
|
||
) : <span className="text-slate-600">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span className={clsx('text-[10px] font-medium', meta.color)}>{meta.label}</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-slate-600 text-[10px] max-w-[120px] truncate" title={t.close_note}>
|
||
{t.close_note || '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── IBKR Ticket helpers ───────────────────────────────────────────────────────
|
||
|
||
function computeStrikeDollars(price: number, guidance: string, strategy: string): number {
|
||
const g = guidance.toUpperCase()
|
||
const match = g.match(/(\d+)\s*%\s*OTM/)
|
||
if (!match) return price
|
||
const pct = parseInt(match[1]) / 100
|
||
const isBearish = /put|bear/i.test(strategy)
|
||
const raw = isBearish ? price * (1 - pct) : price * (1 + pct)
|
||
const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5
|
||
return Math.round(raw / inc) * inc
|
||
}
|
||
|
||
function estimateExpiryDate(horizonDays: number): Date {
|
||
const d = new Date()
|
||
d.setDate(d.getDate() + horizonDays)
|
||
const dow = d.getDay()
|
||
if (dow !== 5) d.setDate(d.getDate() + ((5 - dow + 7) % 7))
|
||
return d
|
||
}
|
||
|
||
function buildLegs(strategy: string, strike: number | null, price: number | null) {
|
||
const s = strategy.toLowerCase()
|
||
const atm = price ? (() => {
|
||
const inc = price > 1000 ? 10 : price > 200 ? 5 : price > 50 ? 1 : 0.5
|
||
return Math.round(price / inc) * inc
|
||
})() : null
|
||
if (s.includes('bull call spread')) return [
|
||
{ action: 'BUY', type: 'CALL', strike: atm },
|
||
{ action: 'SELL', type: 'CALL', strike },
|
||
]
|
||
if (s.includes('bear put spread')) return [
|
||
{ action: 'BUY', type: 'PUT', strike: atm },
|
||
{ action: 'SELL', type: 'PUT', strike },
|
||
]
|
||
if (s.includes('straddle')) return [
|
||
{ action: 'BUY', type: 'CALL', strike: atm ?? strike },
|
||
{ action: 'BUY', type: 'PUT', strike: atm ?? strike },
|
||
]
|
||
if (s.includes('strangle')) return [
|
||
{ action: 'BUY', type: 'CALL', strike: price ? Math.round(price * 1.05) : strike },
|
||
{ action: 'BUY', type: 'PUT', strike: price ? Math.round(price * 0.95) : strike },
|
||
]
|
||
if (s.includes('call')) return [{ action: 'BUY', type: 'CALL', strike }]
|
||
if (s.includes('put')) return [{ action: 'BUY', type: 'PUT', strike }]
|
||
return [{ action: 'BUY', type: strategy.toUpperCase(), strike }]
|
||
}
|
||
|
||
function IBKRTicket({ strategy, underlying, strikeGuidance, expiryDays, entryPrice, maxLoss, targetGain }: {
|
||
strategy: string
|
||
underlying: string
|
||
strikeGuidance?: string | null
|
||
expiryDays?: number | null
|
||
entryPrice?: number | null
|
||
maxLoss?: number | null
|
||
targetGain?: number | null
|
||
}) {
|
||
const strike = entryPrice && strikeGuidance
|
||
? computeStrikeDollars(entryPrice, strikeGuidance, strategy)
|
||
: (entryPrice ?? null)
|
||
const expiryDate = expiryDays ? estimateExpiryDate(expiryDays) : null
|
||
const legs = buildLegs(strategy, strike, entryPrice ?? null)
|
||
const fmtStrike = (s: number | null | undefined) =>
|
||
s == null ? '—' : s >= 100 ? `$${s.toFixed(0)}` : `$${s.toFixed(2)}`
|
||
|
||
return (
|
||
<div className="bg-blue-950/20 border border-blue-700/30 rounded p-3 space-y-2.5">
|
||
<div className="text-[10px] font-bold text-blue-300 uppercase tracking-wide flex items-center gap-1.5">
|
||
<Terminal className="w-3 h-3" /> Ticket IBKR
|
||
{!entryPrice && <span className="ml-auto text-slate-600 font-normal normal-case">Prix non disponible — vérifier dans IBKR</span>}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 text-[10px]">
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Sous-jacent</div>
|
||
<div className="text-white font-mono font-bold text-sm">{underlying}</div>
|
||
<div className="text-slate-600 text-[9px]">Security Type: OPT</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Stratégie</div>
|
||
<div className="text-slate-200 font-semibold">{strategy}</div>
|
||
{entryPrice && <div className="text-slate-600 text-[9px]">Sous-jacent: ${entryPrice.toFixed(2)}</div>}
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Expiration</div>
|
||
<div className="text-slate-200 font-mono">
|
||
{expiryDate ? format(expiryDate, 'd MMM yyyy', { locale: fr }) : expiryDays ? `~${expiryDays}j` : '—'}
|
||
</div>
|
||
<div className="text-slate-600 text-[9px]">{expiryDays ? `${expiryDays}j DTE` : ''}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
{legs.map((leg, i) => (
|
||
<div key={i} className="flex items-center gap-2 bg-slate-900/70 rounded px-2.5 py-1.5 font-mono text-[10px]">
|
||
<span className={clsx('w-8 font-bold', leg.action === 'BUY' ? 'text-emerald-400' : 'text-red-400')}>{leg.action}</span>
|
||
<span className="text-slate-400">1 contrat</span>
|
||
<span className={clsx('font-bold w-10 text-center', leg.type === 'CALL' ? 'text-blue-300' : leg.type === 'PUT' ? 'text-orange-300' : 'text-slate-300')}>{leg.type}</span>
|
||
<span className="text-slate-600">Strike</span>
|
||
<span className="text-yellow-300 font-bold">{fmtStrike(leg.strike)}</span>
|
||
{leg.strike === null && strikeGuidance && (
|
||
<span className="text-slate-500">({strikeGuidance})</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 text-[10px]">
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Type d'ordre</div>
|
||
<div className="text-slate-300 font-semibold">LIMIT</div>
|
||
<div className="text-slate-600 text-[9px]">Prix = prime dans IBKR</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Budget max</div>
|
||
<div className="text-slate-300 font-mono">{maxLoss ? `${Math.abs(maxLoss).toFixed(0)}€` : '~1 000€'}</div>
|
||
<div className="text-slate-600 text-[9px]">Perte max estimée</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-slate-600 mb-0.5">Cible</div>
|
||
<div className="text-emerald-400 font-mono">{targetGain ? `+${targetGain.toFixed(0)}€` : '—'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MacroHistorySection({ days }: { days: number }) {
|
||
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
|
||
const history: any[] = (data as any)?.history ?? []
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
{history.length} snapshots · {days} derniers jours
|
||
</div>
|
||
<button onClick={() => refetch()} disabled={isFetching}
|
||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-14 animate-pulse bg-dark-700" />)}</div>
|
||
) : history.length === 0 ? (
|
||
<div className="card text-center py-10 text-slate-600 text-sm">
|
||
<Activity className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
Aucun snapshot — cliquer "Ré-analyser" dans Régime Macro pour commencer à logger
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{history.map((entry: any, i: number) => {
|
||
const scores: Record<string, number> = entry.scores ?? {}
|
||
const maxScore = Math.max(...Object.values(scores), 1)
|
||
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
|
||
const reasons: string[] = entry.reasons?.[entry.dominant] ?? []
|
||
const isTransition = i > 0 && history[i - 1].dominant !== entry.dominant
|
||
|
||
return (
|
||
<div key={entry.id} className={clsx('card', isTransition && 'border-amber-700/30')}>
|
||
<div className="flex items-center gap-3 mb-2">
|
||
{isTransition && (
|
||
<span className="text-[10px] bg-amber-900/30 text-amber-400 border border-amber-700/30 rounded px-1.5 py-0.5 shrink-0">
|
||
↕ Transition
|
||
</span>
|
||
)}
|
||
<ScenarioBadge dominant={entry.dominant} size="lg" />
|
||
<span className="text-xs text-slate-600 ml-auto shrink-0">{ts} UTC</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mb-2">
|
||
{Object.entries(scores)
|
||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||
.slice(0, 8)
|
||
.map(([key, score]) => {
|
||
const m = SCENARIO_META[key] ?? SCENARIO_META.incertain
|
||
return (
|
||
<div key={key} className="flex items-center gap-1">
|
||
<div className="w-16 text-[10px] text-slate-600 truncate">{m.label}</div>
|
||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full transition-all"
|
||
style={{ width: `${((score as number) / maxScore) * 100}%`, background: m.color }} />
|
||
</div>
|
||
<span className="text-[10px] font-mono text-slate-500 w-6 text-right">{score}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{reasons.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{reasons.slice(0, 4).map((r: string, ri: number) => (
|
||
<span key={ri} className="text-[10px] text-slate-500 bg-dark-700 px-1.5 py-0.5 rounded">{r}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Post-mortem panel ─────────────────────────────────────────────────────────
|
||
|
||
function PostmortemPanel({ tradeId, onClose }: { tradeId: number; onClose: () => void }) {
|
||
const { data, isLoading } = useTradePostmortem(tradeId)
|
||
const { mutate: analyze, isPending: analyzing, data: analysisData } = useAnalyzePostmortem()
|
||
const [showScoring, setShowScoring] = useState(true)
|
||
const [showSuggestion, setShowSuggestion] = useState(false)
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="card mt-3 p-4 border border-blue-700/30 animate-pulse">
|
||
<div className="h-4 bg-dark-700 rounded w-48 mb-3" />
|
||
<div className="h-3 bg-dark-700 rounded w-full mb-2" />
|
||
<div className="h-3 bg-dark-700 rounded w-3/4" />
|
||
</div>
|
||
)
|
||
}
|
||
if (!data) return null
|
||
|
||
const { trade, scoring_context: sc, suggestion_context: sg, score_history } = data as any
|
||
const scOut = sc?.output ?? {}
|
||
const sgOut = sg?.output ?? {}
|
||
const scCtx = sc?.input_context ?? {}
|
||
const buckets: any[] = scOut.buckets ?? []
|
||
const analysis: any = analysisData?.analysis ?? null
|
||
|
||
return (
|
||
<div className="mt-3 rounded-lg border border-blue-700/30 bg-dark-900/80 p-4 space-y-4 text-xs">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Brain className="w-4 h-4 text-blue-400" />
|
||
<span className="font-semibold text-blue-300">Post-mortem — {trade?.pattern_name}</span>
|
||
<span className="text-slate-500">|</span>
|
||
<span className="font-mono text-slate-400">{trade?.underlying} {trade?.strategy}</span>
|
||
</div>
|
||
<button onClick={onClose} className="text-slate-600 hover:text-slate-400">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Score history sparkline */}
|
||
{score_history?.length > 0 && (
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-slate-600 shrink-0">Historique :</span>
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{[...score_history].reverse().map((h: any, i: number) => (
|
||
<div key={i} className="flex flex-col items-center gap-0.5">
|
||
<div
|
||
className="w-6 rounded-sm"
|
||
style={{
|
||
height: `${Math.max(4, (h.score ?? 0) / 2)}px`,
|
||
background: (h.score ?? 0) >= 60 ? '#22c55e' : (h.score ?? 0) >= 40 ? '#eab308' : '#64748b',
|
||
}}
|
||
/>
|
||
<span className="text-[9px] font-mono text-slate-600">{h.score ?? '?'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{score_history.length > 0 && (
|
||
<span className="text-slate-600 ml-auto shrink-0">
|
||
{score_history[0]?.macro_dominant ?? ''} — géo {score_history[0]?.geo_score ?? '?'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Scoring context */}
|
||
<div>
|
||
<button
|
||
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
|
||
onClick={() => setShowScoring(v => !v)}
|
||
>
|
||
{showScoring ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||
Pourquoi noté {scOut.score ?? '?'}/100
|
||
{scOut.key_catalyst && <span className="text-slate-600 font-normal ml-1">· {scOut.key_catalyst}</span>}
|
||
</button>
|
||
{showScoring && (
|
||
<div className="pl-5 space-y-2">
|
||
{/* Regime / bias */}
|
||
<div className="flex flex-wrap gap-3 text-[11px] text-slate-500">
|
||
{sc?.macro_dominant && (
|
||
<span>Régime : <ScenarioBadge dominant={sc.macro_dominant} /></span>
|
||
)}
|
||
{scCtx.asset_bias && (
|
||
<span>Biais asset : <span className="text-slate-300">{scCtx.asset_bias}</span></span>
|
||
)}
|
||
{sc?.geo_score != null && (
|
||
<span>Géo : <span className="font-mono text-slate-300">{sc.geo_score}/100</span></span>
|
||
)}
|
||
</div>
|
||
{/* Buckets */}
|
||
{buckets.length > 0 && (
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{buckets.map((b: any, i: number) => {
|
||
const pct = b.max ? Math.round((b.score / b.max) * 100) : 0
|
||
const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444'
|
||
return (
|
||
<div key={i} className="flex flex-col gap-0.5 bg-dark-800 rounded p-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-slate-400 font-medium truncate mr-2">{b.label ?? b.id}</span>
|
||
<span className="font-mono shrink-0" style={{ color }}>{b.score}/{b.max}</span>
|
||
</div>
|
||
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
|
||
</div>
|
||
{b.comment && (
|
||
<span className="text-[10px] text-slate-600 line-clamp-2">{b.comment}</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{scOut.summary && (
|
||
<p className="text-slate-500 italic">{scOut.summary}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Suggestion context */}
|
||
{sg && (
|
||
<div>
|
||
<button
|
||
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
|
||
onClick={() => setShowSuggestion(v => !v)}
|
||
>
|
||
{showSuggestion ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||
Pourquoi ce pattern a été créé
|
||
</button>
|
||
{showSuggestion && (
|
||
<div className="pl-5 space-y-1.5 text-[11px] text-slate-400">
|
||
{sgOut.macro_fit && <p className="italic">{sgOut.macro_fit}</p>}
|
||
{sgOut.description && <p className="text-slate-500">{sgOut.description}</p>}
|
||
{sg.macro_dominant && (
|
||
<div className="flex gap-3 mt-1">
|
||
<span>Créé sous : <ScenarioBadge dominant={sg.macro_dominant} /></span>
|
||
{sg.geo_score != null && <span>Géo : <span className="font-mono text-slate-300">{sg.geo_score}/100</span></span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* GPT-4o analysis */}
|
||
<div className="border-t border-slate-800 pt-3">
|
||
{!analysis ? (
|
||
<button
|
||
onClick={() => analyze(tradeId)}
|
||
disabled={analyzing}
|
||
className="flex items-center gap-2 px-3 py-1.5 rounded bg-blue-900/30 border border-blue-700/40 text-blue-300 hover:bg-blue-900/50 disabled:opacity-50 text-xs font-medium"
|
||
>
|
||
<Brain className={clsx('w-3.5 h-3.5', analyzing && 'animate-pulse')} />
|
||
{analyzing ? 'Analyse GPT-4o en cours…' : 'Analyser avec GPT-4o'}
|
||
</button>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 text-blue-400 font-semibold">
|
||
<Brain className="w-3.5 h-3.5" />
|
||
Analyse GPT-4o
|
||
</div>
|
||
{[
|
||
{ key: 'diagnostic', label: 'Diagnostic', color: 'text-slate-200' },
|
||
{ key: 'what_worked', label: 'Ce qui a marché', color: 'text-emerald-400' },
|
||
{ key: 'what_missed', label: 'Ce qui a manqué', color: 'text-red-400' },
|
||
{ key: 'regime_alignment', label: 'Alignement régime', color: 'text-yellow-400' },
|
||
{ key: 'contra_assessment', label: 'Contra-signals', color: 'text-orange-400' },
|
||
{ key: 'lesson', label: 'Leçon', color: 'text-blue-300' },
|
||
{ key: 'next_cycle', label: 'Prochain cycle', color: 'text-purple-400' },
|
||
].map(({ key, label, color }) =>
|
||
analysis[key] ? (
|
||
<div key={key}>
|
||
<span className={clsx('font-semibold', color)}>{label} : </span>
|
||
<span className="text-slate-400">{analysis[key]}</span>
|
||
</div>
|
||
) : null
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Section 2 : Mark-to-Market des trades ─────────────────────────────────────
|
||
|
||
function TradeMtmSection({ days }: { days: number }) {
|
||
const { data, isLoading, refetch, isFetching } = useTradeMtm(days)
|
||
const [minScoreFilter, setMinScoreFilter] = useState(0)
|
||
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null)
|
||
const [closingTrade, setClosingTrade] = useState<any | null>(null)
|
||
const allTrades: any[] = (data as any)?.trades ?? []
|
||
const trades = allTrades.filter((t: any) => (t.latest_score ?? t.score_at_entry ?? 0) >= minScoreFilter)
|
||
|
||
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
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
{trades.length}/{allTrades.length} trades · {withPnl.length} pricés
|
||
{avgPnl != null && (
|
||
<span className={clsx('ml-2 font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||
· moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* Score filter */}
|
||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||
<span className="shrink-0">Score ≥</span>
|
||
<input type="range" min="0" max="80" step="5"
|
||
value={minScoreFilter}
|
||
onChange={e => setMinScoreFilter(parseInt(e.target.value))}
|
||
className="w-24 accent-blue-500" />
|
||
<span className="w-5 font-mono text-blue-400">{minScoreFilter}</span>
|
||
</div>
|
||
<button onClick={() => refetch()} disabled={isFetching}
|
||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{closingTrade && (
|
||
<CloseTradeModal trade={closingTrade} onClose={() => setClosingTrade(null)} />
|
||
)}
|
||
|
||
{isLoading ? (
|
||
<div className="card h-32 animate-pulse bg-dark-700" />
|
||
) : trades.length === 0 ? (
|
||
<div className="card text-center py-10 text-slate-600 text-sm">
|
||
<TrendingUp className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
{allTrades.length === 0
|
||
? 'Aucun trade logué — scorer des patterns pour commencer le suivi'
|
||
: `Aucun trade avec score ≥ ${minScoreFilter}`}
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-slate-600 border-b border-slate-700/40">
|
||
<th className="text-left px-3 py-2 font-medium">Pattern</th>
|
||
<th className="text-left px-3 py-2 font-medium">Profil</th>
|
||
<th className="text-left px-3 py-2 font-medium">Stratégie</th>
|
||
<th className="text-right px-3 py-2 font-medium">Strike</th>
|
||
<th className="text-right px-3 py-2 font-medium">DTE</th>
|
||
<th className="text-left px-3 py-2 font-medium">Ticker</th>
|
||
<th className="text-right px-3 py-2 font-medium">Score</th>
|
||
<th className="text-right px-3 py-2 font-medium">Trade Score</th>
|
||
<th className="text-right px-3 py-2 font-medium">EV nette</th>
|
||
<th className="text-right px-3 py-2 font-medium">Gain prévu</th>
|
||
<th className="text-right px-3 py-2 font-medium">Date</th>
|
||
<th className="text-right px-3 py-2 font-medium">Prix entrée</th>
|
||
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
|
||
<th className="text-right px-3 py-2 font-medium">Cible/Stop</th>
|
||
<th className="text-right px-3 py-2 font-medium">Maturité</th>
|
||
<th className="text-right px-3 py-2 font-medium">IV Rank</th>
|
||
<th className="text-right px-3 py-2 font-medium">Kelly</th>
|
||
<th className="text-right px-3 py-2 font-medium">P&L</th>
|
||
<th className="px-3 py-2 font-medium w-8"></th>
|
||
<th className="px-3 py-2 font-medium w-8"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/60">
|
||
{trades.map((t: any) => {
|
||
const evNet = t.ev_net ?? null
|
||
const tradeScore = t.trade_score ?? null
|
||
const isExpanded = selectedTradeId === t.id
|
||
return (
|
||
<Fragment key={t.id}>
|
||
<tr
|
||
className={clsx('hover:bg-dark-700/30 transition-colors cursor-pointer', isExpanded && 'bg-dark-700/40 border-b-0')}
|
||
onClick={() => setSelectedTradeId(isExpanded ? null : t.id)}
|
||
>
|
||
<td className="px-3 py-2 text-slate-300 max-w-[110px] truncate">{t.pattern_name || t.pattern_id}</td>
|
||
<td className="px-3 py-2">
|
||
{t.matched_profile ? (
|
||
<span className="text-[10px] font-semibold text-slate-400 bg-dark-700 px-1.5 py-0.5 rounded border border-slate-700/50">
|
||
{t.matched_profile}
|
||
</span>
|
||
) : <span className="text-slate-700 text-[10px]">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span className={clsx('badge text-[10px]',
|
||
t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
|
||
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-right font-mono text-[11px] text-slate-400">
|
||
{t.strike_guidance ?? '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right font-mono text-[11px] text-slate-400">
|
||
{t.expiry_days_at_entry != null ? `${t.expiry_days_at_entry}j` : t.horizon_days != null ? `${t.horizon_days}j` : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 font-mono">
|
||
<div className="flex items-center gap-1">
|
||
<span className={clsx(t.price_warning ? 'text-amber-300' : 'text-slate-300')}>{t.underlying}</span>
|
||
{t.price_warning && (
|
||
<span
|
||
className="text-[8px] font-bold bg-amber-900/40 border border-amber-600/40 text-amber-400 rounded px-1 py-0.5 leading-none shrink-0"
|
||
title={t.price_warning === 'no_price_data' ? 'Aucune donnée prix — monitoring impossible' : t.price_warning === 'no_entry_price' ? 'Prix d\'entrée manquant' : 'Prix live indisponible'}
|
||
>
|
||
⚠ {t.price_warning === 'no_price_data' ? 'NO DATA' : t.price_warning === 'no_entry_price' ? 'NO ENTRY' : 'NO LIVE'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{tradeScore != null ? (
|
||
<span className={clsx('font-bold font-mono text-xs',
|
||
tradeScore >= 55 ? 'text-emerald-400' : tradeScore >= 45 ? 'text-yellow-400' : 'text-slate-500')}>
|
||
{tradeScore.toFixed(1)}
|
||
</span>
|
||
) : <span className="text-slate-700 text-xs">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{evNet != null ? (
|
||
<span className={clsx('font-mono text-[11px]',
|
||
evNet > 0.05 ? 'text-emerald-400' : evNet >= -0.01 ? 'text-yellow-400' : 'text-slate-600')}>
|
||
{evNet > 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
|
||
</span>
|
||
) : <span className="text-slate-700 text-xs">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{t.expected_move_pct != null ? (
|
||
<span className="font-mono text-[11px] text-slate-500">{t.expected_move_pct.toFixed(0)}%</span>
|
||
) : <span className="text-slate-700 text-xs">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-right text-slate-600 whitespace-nowrap text-[11px]">{t.entry_date}</td>
|
||
<td className={clsx('px-3 py-2 text-right font-mono text-[11px]', t.price_warning === 'no_entry_price' || t.price_warning === 'no_price_data' ? 'text-amber-600' : 'text-slate-400')}>
|
||
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
|
||
</td>
|
||
<td className={clsx('px-3 py-2 text-right font-mono text-[11px]', t.price_warning === 'no_live_price' || t.price_warning === 'no_price_data' ? 'text-amber-600' : 'text-slate-300')}>
|
||
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{t.pnl_pct != null ? (
|
||
<div className="flex flex-col items-end gap-1">
|
||
<div className="flex items-center gap-1.5">
|
||
{t.alert_type === 'target_reached' && (
|
||
<span className="text-[9px] font-bold text-emerald-400 bg-emerald-900/30 border border-emerald-700/30 rounded px-1">🎯</span>
|
||
)}
|
||
{t.alert_type === 'stop_loss' && (
|
||
<span className="text-[9px] font-bold text-red-400 bg-red-900/30 border border-red-700/30 rounded px-1">🛑</span>
|
||
)}
|
||
</div>
|
||
{/* progress bar: stop_loss (negative) to target (positive) */}
|
||
<div className="w-16 relative">
|
||
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||
<div
|
||
className={clsx('h-full rounded-full',
|
||
t.pnl_pct >= 0 ? 'bg-emerald-500' : 'bg-red-500')}
|
||
style={{
|
||
width: `${Math.min(
|
||
100,
|
||
t.pnl_pct >= 0
|
||
? (t.pnl_pct / (t.target_pct || 30)) * 100
|
||
: (Math.abs(t.pnl_pct) / Math.abs(t.stop_loss_pct || 50)) * 100
|
||
)}%`
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between text-[8px] text-slate-700 mt-0.5 font-mono">
|
||
<span>{t.stop_loss_pct ?? -50}%</span>
|
||
<span>+{t.target_pct ?? 30}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : <span className="text-slate-700 text-[10px]">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
{t.maturity ? (
|
||
<div className="flex flex-col items-end gap-0.5">
|
||
<span className={clsx('text-[10px] font-semibold',
|
||
t.maturity.status === 'trop_tot' ? 'text-slate-500' :
|
||
t.maturity.status === 'debut' ? 'text-yellow-400' :
|
||
t.maturity.status === 'mature' ? 'text-emerald-400' :
|
||
'text-orange-400'
|
||
)}>
|
||
{t.maturity.emoji} {t.maturity.label}
|
||
</span>
|
||
<span className="text-[9px] text-slate-600 font-mono">
|
||
{t.days_held ?? 0}j / {t.horizon_days ?? 90}j ({t.maturity.ratio_pct}%)
|
||
</span>
|
||
<div className="w-16 h-1 bg-slate-700 rounded-full overflow-hidden">
|
||
<div
|
||
className={clsx('h-full rounded-full',
|
||
t.maturity.status === 'trop_tot' ? 'bg-slate-600' :
|
||
t.maturity.status === 'debut' ? 'bg-yellow-500' :
|
||
t.maturity.status === 'mature' ? 'bg-emerald-500' :
|
||
'bg-orange-500'
|
||
)}
|
||
style={{ width: `${Math.min(t.maturity.ratio_pct, 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span className="text-slate-700 text-[11px]">
|
||
{t.days_held != null ? `${t.days_held}j` : '—'}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<IvRankCell underlying={t.underlying} />
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<KellyCell patternId={t.pattern_id} />
|
||
</td>
|
||
<td className="px-3 py-2 text-right">
|
||
<PnlBadge pnl={t.pnl_pct} />
|
||
</td>
|
||
<td className="px-2 py-2" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => setSelectedTradeId(isExpanded ? null : t.id)}
|
||
className={clsx(
|
||
'p-1 rounded transition-colors',
|
||
isExpanded
|
||
? 'text-blue-400 bg-blue-900/30'
|
||
: 'text-slate-600 hover:text-blue-400 hover:bg-blue-900/20'
|
||
)}
|
||
title="Post-mortem IA"
|
||
>
|
||
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <Search className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</td>
|
||
<td className="px-2 py-2" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => setClosingTrade(t)}
|
||
className={clsx(
|
||
'p-1 rounded transition-colors',
|
||
t.alert_type
|
||
? 'text-amber-400 bg-amber-900/30 hover:bg-amber-900/50'
|
||
: 'text-slate-700 hover:text-amber-400 hover:bg-amber-900/20'
|
||
)}
|
||
title="Fermer le trade"
|
||
>
|
||
<Lock className="w-3.5 h-3.5" />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{isExpanded && (
|
||
<tr className="bg-dark-900/60">
|
||
<td colSpan={20} className="px-4 py-3 border-b border-blue-700/20 space-y-3">
|
||
<IBKRTicket
|
||
strategy={t.strategy ?? ''}
|
||
underlying={t.underlying}
|
||
strikeGuidance={t.strike_guidance}
|
||
expiryDays={t.expiry_days_at_entry ?? t.horizon_days}
|
||
entryPrice={t.entry_price}
|
||
maxLoss={t.max_loss_eur}
|
||
targetGain={t.target_gain_eur}
|
||
/>
|
||
<PostmortemPanel tradeId={t.id} onClose={() => setSelectedTradeId(null)} />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Section 3 : Historique du score géopolitique ──────────────────────────────
|
||
|
||
function GeoHistorySection({ days }: { days: number }) {
|
||
const { data, isLoading, refetch, isFetching } = useGeoHistory(days)
|
||
const history: any[] = (data as any)?.history ?? []
|
||
|
||
const maxScore = Math.max(...history.map((h: any) => h.geo_score), 1)
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
{history.length} alertes · {days} derniers jours
|
||
</div>
|
||
<button onClick={() => refetch()} disabled={isFetching}
|
||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-2">{[1,2].map(i => <div key={i} className="card h-12 animate-pulse bg-dark-700" />)}</div>
|
||
) : history.length === 0 ? (
|
||
<div className="card text-center py-10 text-slate-600 text-sm">
|
||
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
Aucune alerte — scorer des patterns pour commencer le suivi
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{history.map((entry: any) => {
|
||
const score = entry.geo_score ?? 0
|
||
const level = score >= 70 ? 'extreme' : score >= 50 ? 'high' : score >= 30 ? 'medium' : 'low'
|
||
const barColor = { low: '#22c55e', medium: '#eab308', high: '#f97316', extreme: '#ef4444' }[level]
|
||
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
|
||
const patterns: any[] = entry.top_patterns ?? []
|
||
|
||
return (
|
||
<div key={entry.id} className="card">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold"
|
||
style={{ background: `${barColor}22`, color: barColor, border: `1px solid ${barColor}44` }}>
|
||
{score}
|
||
</div>
|
||
<div>
|
||
<div className="text-[10px] text-slate-600 uppercase">{level}</div>
|
||
<div className="text-[10px] text-slate-700">{ts}</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
||
<div className="h-full rounded-full" style={{ width: `${(score / 100) * 100}%`, background: barColor }} />
|
||
</div>
|
||
</div>
|
||
<span className="text-[10px] text-slate-700 shrink-0">{entry.news_count} news</span>
|
||
</div>
|
||
{patterns.length > 0 && (
|
||
<div className="flex flex-wrap gap-1">
|
||
{patterns.slice(0, 5).map((p: any, i: number) => (
|
||
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-dark-700 text-slate-400 rounded px-1.5 py-0.5">
|
||
<span className="font-mono text-slate-500">{p.score}</span>
|
||
<span className="max-w-[120px] truncate">{p.name || p.pattern_id}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Section 4 : Cycles d'intelligence ────────────────────────────────────────
|
||
|
||
function CyclesSection() {
|
||
const { data: histData, isLoading, refetch, isFetching } = useCycleHistory(20)
|
||
const { data: statusData } = useCycleStatus()
|
||
const { mutate: triggerCycle, isPending: triggering } = useTriggerCycle()
|
||
const runs: any[] = (histData as any)?.runs ?? []
|
||
const cs = statusData as any
|
||
|
||
// Auto-refetch history list when the running cycle finishes
|
||
const wasRunning = useRef(false)
|
||
useEffect(() => {
|
||
if (cs?.running) { wasRunning.current = true }
|
||
else if (wasRunning.current) { wasRunning.current = false; refetch() }
|
||
}, [cs?.running])
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||
{runs.length} cycles · {cs?.enabled ? `auto ${cs.interval_hours}h` : 'manuel uniquement'}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => triggerCycle(undefined, { onSuccess: () => setTimeout(() => refetch(), 3000) })}
|
||
disabled={triggering}
|
||
className="flex items-center gap-1 text-xs border border-blue-500/40 text-blue-400 hover:bg-blue-900/20 px-2.5 py-1 rounded disabled:opacity-40">
|
||
<Zap className={clsx('w-3 h-3', triggering && 'animate-pulse')} />
|
||
{triggering ? 'Lancement...' : 'Lancer un cycle'}
|
||
</button>
|
||
<button onClick={() => refetch()} disabled={isFetching}
|
||
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-20 animate-pulse bg-dark-700" />)}</div>
|
||
) : runs.length === 0 ? (
|
||
<div className="card text-center py-10 text-slate-600 text-sm">
|
||
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
Aucun cycle — activer l'auto-cycle dans Configuration ou lancer manuellement
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{runs.map((run: any) => {
|
||
const commentary = run.commentary_parsed
|
||
const ok = run.status === 'completed'
|
||
const ts = run.started_at?.slice(0, 16).replace('T', ' ') ?? ''
|
||
const duration = run.completed_at
|
||
? Math.round((new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000)
|
||
: null
|
||
|
||
return (
|
||
<div key={run.id} className={clsx('card', ok ? 'border-slate-700/40' : 'border-red-800/30')}>
|
||
<div className="flex items-start gap-3 mb-2">
|
||
{ok
|
||
? <CheckCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||
: <XCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-slate-300 font-semibold">{ts} UTC</span>
|
||
<span className={clsx('badge text-[10px]', run.trigger === 'manual' ? 'badge-blue' : 'badge-purple')}>
|
||
{run.trigger === 'manual' ? 'Manuel' : `Auto ${cs?.interval_hours ?? ''}h`}
|
||
</span>
|
||
{run.dominant_regime && <ScenarioBadge dominant={run.dominant_regime} />}
|
||
{duration != null && <span className="text-[10px] text-slate-600">{duration}s</span>}
|
||
</div>
|
||
<div className="flex gap-3 mt-1 text-[11px] text-slate-500 flex-wrap">
|
||
<span>💡 {run.patterns_suggested} suggérés</span>
|
||
<span>✚ {run.patterns_added} ajoutés</span>
|
||
<span>🎯 {run.patterns_scored} scorés</span>
|
||
{run.geo_score != null && <span>🌐 Géo {run.geo_score}/100</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{commentary && (
|
||
<div className="bg-blue-900/10 border border-blue-700/20 rounded p-3 space-y-2">
|
||
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 font-semibold uppercase tracking-wide">
|
||
<Brain className="w-3 h-3" /> Commentaire IA
|
||
</div>
|
||
<p className="text-xs text-slate-300 leading-relaxed">{commentary.commentary}</p>
|
||
{commentary.key_risk && (
|
||
<div className="flex items-start gap-1.5 text-[11px] text-orange-400/80 bg-orange-900/10 border border-orange-700/20 rounded px-2 py-1">
|
||
<span className="shrink-0">⚠</span> {commentary.key_risk}
|
||
</div>
|
||
)}
|
||
{commentary.top_pattern && (
|
||
<div className="text-[11px] text-emerald-400/80">
|
||
🎯 Pattern clé : <span className="font-semibold">{commentary.top_pattern}</span>
|
||
</div>
|
||
)}
|
||
{commentary.lessons_from_report ? (
|
||
<div className="flex items-center gap-1.5 text-[10px] text-purple-400/80 bg-purple-900/10 border border-purple-700/20 rounded px-2 py-1">
|
||
<BookOpen className="w-3 h-3 shrink-0" />
|
||
Leçons du rapport du {commentary.lessons_from_report} intégrées dans ce cycle
|
||
{commentary.lessons_headline && (
|
||
<span className="text-slate-600 ml-1">· {commentary.lessons_headline}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1.5 text-[10px] text-slate-700 italic">
|
||
<BookOpen className="w-3 h-3 shrink-0" />
|
||
Aucun rapport de performance disponible — générer un rapport dans "Rapport IA"
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!commentary && ok && (
|
||
<div className="text-[11px] text-slate-700 italic">Aucun commentaire IA généré pour ce cycle</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Page principale ────────────────────────────────────────────────────────────
|
||
|
||
const ASSET_CLASS_COLORS: Record<string, string> = {
|
||
energy: '#f97316', metals: '#eab308', agriculture: '#84cc16',
|
||
equities: '#22c55e', indices: '#3b82f6', forex: '#8b5cf6', rates: '#06b6d4',
|
||
unknown: '#64748b',
|
||
}
|
||
|
||
function SimPortfolioRiskPanel() {
|
||
const { data, isLoading, refetch } = useSimPortfolioRisk()
|
||
const risk = data as any
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="card animate-pulse">
|
||
<div className="h-5 bg-dark-700 rounded w-48 mb-4" />
|
||
<div className="space-y-2">{[1, 2, 3].map(i => <div key={i} className="h-8 bg-dark-700 rounded" />)}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!risk || risk.open_count === 0) {
|
||
return (
|
||
<div className="card text-center py-8 text-slate-500">
|
||
<PieChart className="w-8 h-8 mx-auto mb-2 opacity-20" />
|
||
<div className="text-sm">Aucune position ouverte dans le portefeuille simulé</div>
|
||
<div className="text-xs mt-1">Les trades logués apparaîtront ici après le prochain cycle</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const concentration: Record<string, any> = risk.concentration ?? {}
|
||
const alerts: any[] = risk.alerts ?? []
|
||
const conflicts: any[] = risk.conflicts ?? []
|
||
const aiMonitor: any = risk.ai_monitor
|
||
const aiTs: string = risk.ai_monitor_ts
|
||
|
||
const dangers = alerts.filter((a: any) => a.level === 'danger')
|
||
const warnings = alerts.filter((a: any) => a.level === 'warning')
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<PieChart className="w-4 h-4 text-blue-400" />
|
||
<span className="text-sm font-semibold text-white">Portefeuille Simulé — Analyse de Risque</span>
|
||
<span className="text-xs text-slate-500">{risk.open_count} positions ouvertes</span>
|
||
{dangers.length > 0 && (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-red-900/30 border border-red-600/40 text-red-400 rounded px-1.5 py-0.5">
|
||
<ShieldAlert className="w-3 h-3" /> {dangers.length} CONFLIT{dangers.length > 1 ? 'S' : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button onClick={() => refetch()} className="text-slate-600 hover:text-slate-400 transition-colors">
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Asset class concentration bars */}
|
||
<div className="card">
|
||
<div className="text-xs text-slate-500 mb-3 font-semibold uppercase tracking-wide">Répartition par classe d'actif</div>
|
||
<div className="space-y-2">
|
||
{Object.entries(concentration)
|
||
.sort(([, a]: any, [, b]: any) => b.pct - a.pct)
|
||
.map(([ac, data]: [string, any]) => {
|
||
const color = ASSET_CLASS_COLORS[ac] ?? ASSET_CLASS_COLORS.unknown
|
||
const exposure = risk.direction_exposure?.[ac] ?? {}
|
||
return (
|
||
<div key={ac}>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold w-20 capitalize" style={{ color }}>{ac}</span>
|
||
<span className="text-[10px] text-slate-500">{data.tickers?.slice(0, 3).join(', ')}{data.tickers?.length > 3 ? '…' : ''}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px]">
|
||
{exposure.bullish > 0 && (
|
||
<span className="text-emerald-400 flex items-center gap-0.5">
|
||
<TrendingUp className="w-2.5 h-2.5" /> {exposure.bullish}
|
||
</span>
|
||
)}
|
||
{exposure.bearish > 0 && (
|
||
<span className="text-red-400 flex items-center gap-0.5">
|
||
<TrendingDown className="w-2.5 h-2.5" /> {exposure.bearish}
|
||
</span>
|
||
)}
|
||
<span className="text-slate-400 font-mono font-bold">{data.pct}%</span>
|
||
<span className="text-slate-600">({data.count})</span>
|
||
</div>
|
||
</div>
|
||
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full transition-all"
|
||
style={{ width: `${data.pct}%`, background: `${color}cc` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Alerts */}
|
||
{alerts.length > 0 && (
|
||
<div className="space-y-2">
|
||
{/* Conflicts */}
|
||
{conflicts.map((c: any, i: number) => (
|
||
<div key={i} className="card border-red-700/40 bg-red-900/10">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldAlert className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-semibold text-red-300 mb-1">
|
||
Conflit directionnel — {c.underlying}
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
{c.trades.map((t: any) => (
|
||
<div key={t.id} className="flex items-center gap-2 text-[11px]">
|
||
<span className={clsx('font-bold font-mono',
|
||
t.direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
|
||
{t.direction === 'bullish' ? '▲' : '▼'} #{t.id}
|
||
</span>
|
||
<span className="text-slate-400">{t.strategy}</span>
|
||
<span className="text-slate-600">{t.entry_date}</span>
|
||
{t.pattern_name && (
|
||
<span className="text-slate-600 truncate max-w-[180px]">{t.pattern_name}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Concentration & overweight warnings */}
|
||
{warnings.length > 0 && (
|
||
<div className="card border-amber-700/30 bg-amber-900/10">
|
||
<div className="text-xs font-semibold text-amber-300 mb-1.5 flex items-center gap-1">
|
||
<AlertTriangle className="w-3.5 h-3.5" /> Alertes de concentration
|
||
</div>
|
||
<div className="space-y-1">
|
||
{warnings.map((a: any, i: number) => (
|
||
<div key={i} className="text-[11px] text-amber-200/80 flex items-start gap-1.5">
|
||
<span className="text-amber-500 shrink-0">•</span>
|
||
{a.message}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* AI Monitor recommendations */}
|
||
{aiMonitor && (
|
||
<div className="card border-blue-700/30 bg-blue-900/10">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
|
||
<Brain className="w-3.5 h-3.5" /> Recommandations IA — Moniteur de portefeuille
|
||
</div>
|
||
{aiTs && (
|
||
<span className="text-[10px] text-slate-600">{aiTs.slice(0, 16).replace('T', ' ')}</span>
|
||
)}
|
||
</div>
|
||
{aiMonitor.assessment && (
|
||
<p className="text-xs text-slate-300 mb-2">{aiMonitor.assessment}</p>
|
||
)}
|
||
{aiMonitor.actions?.length > 0 && (
|
||
<div className="space-y-1 mb-2">
|
||
{aiMonitor.actions.map((action: any, i: number) => (
|
||
<div key={i} className={clsx(
|
||
'flex items-start gap-2 text-[11px] rounded px-2 py-1',
|
||
action.priority === 'high'
|
||
? 'bg-red-900/20 border border-red-700/20 text-red-300'
|
||
: 'bg-slate-800/40 border border-slate-700/20 text-slate-300'
|
||
)}>
|
||
<span className="shrink-0 font-bold">
|
||
{action.type === 'close_trade' ? '🔒' : action.type === 'rebalance' ? '⚖️' : '👁'}
|
||
</span>
|
||
<div>
|
||
{action.underlying && <span className="font-mono font-bold mr-1">{action.underlying}</span>}
|
||
{action.trade_id && <span className="text-slate-500 mr-1">#{action.trade_id}</span>}
|
||
{action.reason}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{aiMonitor.rebalance_suggestion && (
|
||
<div className="text-[11px] text-blue-300/70 border-t border-blue-700/20 pt-2 mt-1">
|
||
⚖️ {aiMonitor.rebalance_suggestion}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{alerts.length === 0 && (
|
||
<div className="card border-emerald-700/30 bg-emerald-900/10 text-center py-3">
|
||
<CheckCircle className="w-5 h-5 text-emerald-400 mx-auto mb-1" />
|
||
<div className="text-xs text-emerald-300">Portefeuille équilibré — aucun conflit ni sur-concentration détectés</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const TABS = [
|
||
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
|
||
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
|
||
{ key: 'ideas', label: 'Idées de trade', icon: Target },
|
||
{ key: 'mtm', label: 'Ouverts', icon: TrendingUp },
|
||
{ key: 'closed', label: 'Fermés', icon: Lock },
|
||
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
|
||
{ key: 'skipped', label: 'Non loggés', icon: XCircle },
|
||
] as const
|
||
|
||
function SkippedTradesSection({ days }: { days: number }) {
|
||
const { data, isLoading } = useSkippedTrades(days)
|
||
const trades: any[] = (data as any)?.trades ?? []
|
||
|
||
const ASSET_CLASS_COLORS: Record<string, string> = {
|
||
energy: 'bg-orange-900/30 text-orange-300 border-orange-700/30',
|
||
metals: 'bg-yellow-900/30 text-yellow-300 border-yellow-700/30',
|
||
agriculture: 'bg-green-900/30 text-green-300 border-green-700/30',
|
||
indices: 'bg-blue-900/30 text-blue-300 border-blue-700/30',
|
||
forex: 'bg-purple-900/30 text-purple-300 border-purple-700/30',
|
||
rates: 'bg-slate-800/60 text-slate-300 border-slate-600/30',
|
||
equities: 'bg-cyan-900/30 text-cyan-300 border-cyan-700/30',
|
||
}
|
||
|
||
if (isLoading) return <div className="card animate-pulse h-24" />
|
||
|
||
if (trades.length === 0) {
|
||
return (
|
||
<div className="card text-center py-8 text-slate-500 text-sm">
|
||
<XCircle className="w-6 h-6 mx-auto mb-2 text-slate-600" />
|
||
Aucune suggestion non loggée sur {days}j — tous les trades scorés passent au moins un profil de risque.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const byReason = trades.reduce((acc: Record<string, number>, t: any) => {
|
||
acc[t.skip_reason] = (acc[t.skip_reason] ?? 0) + 1
|
||
return acc
|
||
}, {})
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="card">
|
||
<div className="section-title flex items-center gap-1 mb-3">
|
||
<XCircle className="w-3 h-3 text-amber-400" />
|
||
Trades suggérés non loggés — {trades.length} sur {days}j
|
||
<span className="ml-2 text-slate-600 font-normal text-xs">
|
||
(score ou gain insuffisant pour tout profil actif)
|
||
</span>
|
||
</div>
|
||
<div className="flex gap-3 mb-4 flex-wrap">
|
||
{Object.entries(byReason).map(([reason, count]) => (
|
||
<div key={reason} className="badge badge-yellow text-[10px]">
|
||
{reason}: {count}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-slate-700/60 text-slate-600 text-[10px] uppercase tracking-wide">
|
||
<th className="pl-3 py-2 text-left">Pattern</th>
|
||
<th className="px-2 py-2 text-left">Ticker</th>
|
||
<th className="px-2 py-2 text-left">Stratégie</th>
|
||
<th className="px-2 py-2 text-right">Score</th>
|
||
<th className="px-2 py-2 text-right">Gain attendu</th>
|
||
<th className="px-2 py-2 text-left">Classe</th>
|
||
<th className="pr-3 py-2 text-left">Raison du skip</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/40">
|
||
{trades.map((t: any) => (
|
||
<tr key={t.id} className="hover:bg-dark-700/30">
|
||
<td className="pl-3 py-2 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
|
||
<td className="px-2 py-2 font-mono text-slate-400">{t.underlying}</td>
|
||
<td className="px-2 py-2 text-slate-400">{t.strategy || '—'}</td>
|
||
<td className={clsx('px-2 py-2 text-right font-bold font-mono',
|
||
t.score >= 50 ? 'text-emerald-400' : t.score >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
|
||
{t.score ?? '—'}
|
||
</td>
|
||
<td className="px-2 py-2 text-right text-slate-400">
|
||
{t.expected_move_pct != null ? `${t.expected_move_pct.toFixed(0)}%` : '—'}
|
||
</td>
|
||
<td className="px-2 py-2">
|
||
{t.asset_class ? (
|
||
<span className={clsx('badge text-[10px] border', ASSET_CLASS_COLORS[t.asset_class] ?? 'badge-blue')}>
|
||
{t.asset_class}
|
||
</span>
|
||
) : <span className="text-slate-600">—</span>}
|
||
</td>
|
||
<td className="pr-3 py-2 text-slate-600 max-w-[200px] truncate" title={t.skip_detail}>
|
||
{t.skip_detail || t.skip_reason}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function JournalDeBord() {
|
||
const [tab, setTab] = useState<'cycles' | 'macro' | 'ideas' | 'mtm' | 'closed' | 'geo' | 'skipped'>('cycles')
|
||
const [days, setDays] = useState(15)
|
||
const [confirmReset, setConfirmReset] = useState(false)
|
||
const [resetting, setResetting] = useState(false)
|
||
const [resetMsg, setResetMsg] = useState('')
|
||
const { data: summary, refetch: refetchSummary } = useJournalSummary()
|
||
const { data: riskData } = useSimPortfolioRisk()
|
||
const riskAlertCount = (riskData as any)?.alerts?.filter((a: any) => a.level === 'danger').length ?? 0
|
||
const qc = useQueryClient()
|
||
|
||
const s = summary as any
|
||
|
||
const handleReset = async () => {
|
||
if (!confirmReset) {
|
||
setConfirmReset(true)
|
||
return
|
||
}
|
||
setResetting(true)
|
||
try {
|
||
await api.delete('/journal/reset')
|
||
setResetMsg('Journal réinitialisé')
|
||
setConfirmReset(false)
|
||
// Invalidate all journal queries
|
||
await qc.invalidateQueries({ queryKey: ['journal-summary'] })
|
||
await qc.invalidateQueries({ queryKey: ['macro-history'] })
|
||
await qc.invalidateQueries({ queryKey: ['geo-history'] })
|
||
await qc.invalidateQueries({ queryKey: ['trade-mtm'] })
|
||
await qc.invalidateQueries({ queryKey: ['cycle-history'] })
|
||
setTimeout(() => setResetMsg(''), 3000)
|
||
} catch {
|
||
setResetMsg('Erreur lors du reset')
|
||
setTimeout(() => setResetMsg(''), 3000)
|
||
} finally {
|
||
setResetting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="p-6 space-y-5">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||
<BookOpen className="w-5 h-5 text-blue-400" /> Journal de Bord
|
||
</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
Historique des régimes macro · Mark-to-market des trades · Évolution du risque géopolitique
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* Reset button */}
|
||
<div className="flex items-center gap-2">
|
||
{resetMsg && (
|
||
<span className={clsx('text-xs', resetMsg.includes('Erreur') ? 'text-red-400' : 'text-emerald-400')}>
|
||
{resetMsg}
|
||
</span>
|
||
)}
|
||
{confirmReset && !resetting && (
|
||
<span className="text-xs text-amber-400">Confirmer ?</span>
|
||
)}
|
||
<button
|
||
onClick={confirmReset ? handleReset : () => setConfirmReset(true)}
|
||
disabled={resetting}
|
||
onBlur={() => setTimeout(() => setConfirmReset(false), 300)}
|
||
className={clsx(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold border transition-all disabled:opacity-40',
|
||
confirmReset
|
||
? 'bg-red-900/30 border-red-600/50 text-red-400 hover:bg-red-900/50'
|
||
: 'border-slate-700/40 text-slate-600 hover:text-slate-400 hover:border-slate-600'
|
||
)}>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
{resetting ? 'Réinit...' : confirmReset ? 'Effacer tout' : 'Reset journal'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Period selector */}
|
||
<div className="flex gap-1 bg-dark-700 p-1 rounded text-xs">
|
||
{[15, 30, 60, 90].map(d => (
|
||
<button key={d} onClick={() => setDays(d)}
|
||
className={clsx('px-2.5 py-1 rounded transition-colors', {
|
||
'bg-blue-600 text-white': days === d,
|
||
'text-slate-400 hover:text-slate-200': days !== d,
|
||
})}>
|
||
{d}j
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary cards */}
|
||
{s && (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
<div className="card text-center">
|
||
<div className="text-2xl font-bold text-white">{s.macro_snapshots ?? 0}</div>
|
||
<div className="text-xs text-slate-600 mt-0.5">snapshots macro</div>
|
||
{s.current_dominant && (
|
||
<div className="mt-1"><ScenarioBadge dominant={s.current_dominant} /></div>
|
||
)}
|
||
</div>
|
||
<div className="card text-center">
|
||
<div className="text-2xl font-bold text-white">{s.regime_transitions?.length ?? 0}</div>
|
||
<div className="text-xs text-slate-600 mt-0.5">transitions de régime</div>
|
||
{s.regime_transitions?.length > 0 && (
|
||
<div className="text-[10px] text-amber-400 mt-1">
|
||
↕ {s.regime_transitions[0].from} → {s.regime_transitions[0].to}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="card text-center">
|
||
<div className={clsx('text-2xl font-bold',
|
||
(s.avg_geo_score ?? 0) >= 70 ? 'text-red-400' : (s.avg_geo_score ?? 0) >= 40 ? 'text-orange-400' : 'text-emerald-400')}>
|
||
{s.avg_geo_score != null ? s.avg_geo_score.toFixed(0) : '—'}
|
||
</div>
|
||
<div className="text-xs text-slate-600 mt-0.5">score géo moyen</div>
|
||
{s.max_geo_score != null && (
|
||
<div className="text-[10px] text-slate-600 mt-1">max {s.max_geo_score}</div>
|
||
)}
|
||
</div>
|
||
<div className="card text-center">
|
||
<div className="text-2xl font-bold text-white">{s.trade_entries_logged ?? 0}</div>
|
||
<div className="text-xs text-slate-600 mt-0.5">trades logués</div>
|
||
</div>
|
||
{riskAlertCount > 0 && (
|
||
<div className="card text-center border-red-700/40 bg-red-900/10 col-span-full">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<ShieldAlert className="w-4 h-4 text-red-400" />
|
||
<span className="text-sm font-semibold text-red-300">
|
||
{riskAlertCount} conflit{riskAlertCount > 1 ? 's' : ''} directionnel{riskAlertCount > 1 ? 's' : ''} détecté{riskAlertCount > 1 ? 's' : ''}
|
||
</span>
|
||
<span className="text-xs text-slate-500">— voir Risk Dashboard → Simulé</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||
{TABS.map(({ key, label, icon: Icon }) => (
|
||
<button key={key} onClick={() => setTab(key as any)}
|
||
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': tab === key,
|
||
'text-slate-400 hover:text-slate-200': tab !== key,
|
||
})}>
|
||
<Icon className="w-3.5 h-3.5" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{tab === 'cycles' && <CyclesSection />}
|
||
{tab === 'macro' && <MacroHistorySection days={days} />}
|
||
{tab === 'ideas' && <TradeIdeasTab />}
|
||
{tab === 'mtm' && <TradeMtmSection days={days} />}
|
||
{tab === 'closed' && <ClosedTradesSection days={days} />}
|
||
{tab === 'geo' && <GeoHistorySection days={days} />}
|
||
{tab === 'skipped' && <SkippedTradesSection days={days} />}
|
||
</div>
|
||
)
|
||
}
|