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>
901 lines
43 KiB
TypeScript
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 é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 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>
|
|
)
|
|
}
|