Files
OpenFin/frontend/src/pages/JournalDeBord.tsx
OpenSquared 9a6b6f70b1 feat: Phase 1 — IV Rank, Term Structure, Skew, Options Flow (Sprint 1.1/1.2/1.3)
Backend:
- iv_engine.py: ATM IV, term structure (30/60/90/180j), put/call skew,
  options flow (P/C OI ratio, unusual strikes, gamma bias), proxy map for futures→ETFs
- database.py: iv_history table + save_iv_snapshot, get_iv_rank_percentile, get_iv_history
- routers/options_vol.py: /api/options-vol/ endpoints (snapshot, batch, watchlist, history)
- auto_cycle.py: inject IV context string into scoring prompt (step 3.5)
- ai_analyzer.py: score_patterns_with_context accepts iv_context param
- main.py: register options_vol router

Frontend:
- pages/OptionsLab.tsx: full IV dashboard (watchlist by IVR, term structure, skew, flow, sparkline)
- pages/JournalDeBord.tsx: IvRankCell component + IV Rank column per trade
- hooks/useApi.ts: useIvSnapshot, useIvWatchlist, useIvBatch, useIvHistory, useIvForTrade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:29:33 +02:00

901 lines
43 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
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 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 é 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 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>
{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-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">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">P&L th.</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
return (
<tr key={t.id} className="hover:bg-dark-700/30 transition-colors">
<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 font-mono text-slate-300">{t.underlying}</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="px-3 py-2 text-right font-mono text-slate-400 text-[11px]">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right font-mono text-slate-300 text-[11px]">
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
</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">
<PnlBadge pnl={t.pnl_pct} />
</td>
<td className="px-2 py-2">
<button
onClick={() => setSelectedTradeId(selectedTradeId === t.id ? null : t.id)}
className={clsx(
'p-1 rounded transition-colors',
selectedTradeId === t.id
? 'text-blue-400 bg-blue-900/30'
: 'text-slate-600 hover:text-blue-400 hover:bg-blue-900/20'
)}
title="Post-mortem IA"
>
<Search className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{selectedTradeId !== null && (
<PostmortemPanel
tradeId={selectedTradeId}
onClose={() => setSelectedTradeId(null)}
/>
)}
</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 TABS = [
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
{ key: 'mtm', label: 'Mark-to-Market', icon: TrendingUp },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'geo'>('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 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">
{[7, 15, 30].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>
</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)}
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 === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}
</div>
)
}