- TimelineVertical: replace horizontal frise with Y=time vertical layout, 3 columns (Long/Medium/Short), auto-scroll to selected date, sub-columns for overlapping events, today/selected-date horizontal lines - ma_analyzer.py: detect MA50/MA200 crossovers + MA100 slope changes + MA20 direction swings on EUR/USD, Brent, Gold, S&P500, US10Y (5y history) with 5-bar confirmation, dedup, GPT-4o-mini enrichment, idempotent DB save - POST /api/timeline/bootstrap-ma endpoint to trigger analysis - Bootstrap MA button in Timeline page with loading state + result count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List, Cpu } from 'lucide-react'
|
|
import axios from 'axios'
|
|
import clsx from 'clsx'
|
|
import TimelineVertical from '../components/TimelineVertical'
|
|
import EventManager from '../components/EventManager'
|
|
|
|
const api = axios.create({ baseURL: '/api' })
|
|
|
|
interface MarketEvent {
|
|
id: number
|
|
name: string
|
|
start_date: string
|
|
end_date: string | null
|
|
level: 'long' | 'medium' | 'short'
|
|
category: string
|
|
description: string
|
|
market_impact: string
|
|
affected_assets: string
|
|
impact_score: number
|
|
}
|
|
|
|
interface DayContext {
|
|
ref_date: string
|
|
events: {
|
|
long: MarketEvent | null
|
|
medium: MarketEvent | null
|
|
short: MarketEvent | null
|
|
}
|
|
long_commentary: string
|
|
medium_commentary: string
|
|
short_commentary: string
|
|
evidence_headlines: string[]
|
|
generated_at: string | null
|
|
has_commentary: boolean
|
|
}
|
|
|
|
const LEVEL_CONFIG = {
|
|
long: {
|
|
label: 'Long Terme',
|
|
sublabel: 'Régime structurel',
|
|
color: 'text-violet-400',
|
|
border: 'border-violet-700/50',
|
|
bg: 'bg-violet-950/30',
|
|
dot: 'bg-violet-500',
|
|
badge: 'bg-violet-900/50 text-violet-300 border-violet-700/50',
|
|
},
|
|
medium: {
|
|
label: 'Moyen Terme',
|
|
sublabel: 'Régime courant',
|
|
color: 'text-blue-400',
|
|
border: 'border-blue-700/50',
|
|
bg: 'bg-blue-950/30',
|
|
dot: 'bg-blue-500',
|
|
badge: 'bg-blue-900/50 text-blue-300 border-blue-700/50',
|
|
},
|
|
short: {
|
|
label: 'Court Terme',
|
|
sublabel: 'Catalyseur récent',
|
|
color: 'text-emerald-400',
|
|
border: 'border-emerald-700/50',
|
|
bg: 'bg-emerald-950/30',
|
|
dot: 'bg-emerald-500',
|
|
badge: 'bg-emerald-900/50 text-emerald-300 border-emerald-700/50',
|
|
},
|
|
}
|
|
|
|
function daysSince(refDate: string, startDate: string): number {
|
|
const d0 = new Date(startDate)
|
|
const d1 = new Date(refDate)
|
|
return Math.floor((d1.getTime() - d0.getTime()) / 86_400_000)
|
|
}
|
|
|
|
function fmtDate(d: string): string {
|
|
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
}
|
|
|
|
function offsetDate(d: string, days: number): string {
|
|
const dt = new Date(d)
|
|
dt.setDate(dt.getDate() + days)
|
|
return dt.toISOString().split('T')[0]
|
|
}
|
|
|
|
function todayStr(): string {
|
|
return new Date().toISOString().split('T')[0]
|
|
}
|
|
|
|
|
|
function ContextPanel({
|
|
level,
|
|
event,
|
|
commentary,
|
|
refDate,
|
|
}: {
|
|
level: 'long' | 'medium' | 'short'
|
|
event: MarketEvent | null
|
|
commentary: string
|
|
refDate: string
|
|
}) {
|
|
const cfg = LEVEL_CONFIG[level]
|
|
|
|
return (
|
|
<div className={clsx('rounded-xl border p-5 flex flex-col gap-3', cfg.bg, cfg.border)}>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2">
|
|
<div className={clsx('w-2.5 h-2.5 rounded-full', cfg.dot)} />
|
|
<div>
|
|
<div className={clsx('text-sm font-semibold', cfg.color)}>{cfg.label}</div>
|
|
<div className="text-xs text-slate-500">{cfg.sublabel}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{event ? (
|
|
<>
|
|
{/* Event card */}
|
|
<div className="bg-dark-800/60 rounded-lg p-3 border border-slate-700/30">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className={clsx('text-sm font-bold', cfg.color)}>{event.name}</div>
|
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded border shrink-0', cfg.badge)}>
|
|
{event.category}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-400">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
J+{daysSince(refDate, event.start_date)} ({fmtDate(event.start_date)})
|
|
</span>
|
|
{event.end_date && (
|
|
<span className="text-slate-600">→ {fmtDate(event.end_date)}</span>
|
|
)}
|
|
{!event.end_date && (
|
|
<span className="text-amber-500/70 text-xs">en cours</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-400 mt-2 leading-relaxed">{event.description}</p>
|
|
{event.market_impact && (
|
|
<p className="text-xs text-slate-500 mt-1.5 italic">{event.market_impact}</p>
|
|
)}
|
|
{/* Affected assets */}
|
|
{event.affected_assets && (() => {
|
|
try {
|
|
const assets = JSON.parse(event.affected_assets)
|
|
if (assets.length > 0) return (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{assets.map((a: string) => (
|
|
<span key={a} className="text-xs px-1.5 py-0.5 bg-dark-700 rounded text-slate-500 border border-slate-700/30">
|
|
{a}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
} catch { /* ignore */ }
|
|
return null
|
|
})()}
|
|
</div>
|
|
|
|
{/* AI Commentary */}
|
|
{commentary ? (
|
|
<div className="text-sm text-slate-300 leading-relaxed bg-dark-900/40 rounded-lg p-3 border border-slate-700/20">
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-2">
|
|
<Sparkles className="w-3 h-3" />
|
|
Analyse IA
|
|
</div>
|
|
{commentary}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-slate-600 italic text-center py-2">
|
|
Commentaire IA non généré — cliquez "Générer IA"
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm italic py-4">
|
|
Aucun événement {cfg.label.toLowerCase()} actif à cette date
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Timeline() {
|
|
const [searchParams] = useSearchParams()
|
|
const [refDate, setRefDate] = useState(() => {
|
|
const p = searchParams.get('date')
|
|
return p && p >= '2020-01-01' && p <= todayStr() ? p : todayStr()
|
|
})
|
|
const [ctx, setCtx] = useState<DayContext | null>(null)
|
|
const [allEvents, setAllEvents] = useState<MarketEvent[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [bootstrapped, setBootstrapped] = useState(false)
|
|
const [showManager, setShowManager] = useState(false)
|
|
const [bootstrappingMA, setBootstrappingMA] = useState(false)
|
|
const [maResult, setMaResult] = useState<{ detected: number; saved: number } | null>(null)
|
|
|
|
const fetchDay = useCallback(async (d: string) => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await api.get(`/timeline/day/${d}`)
|
|
setCtx(res.data)
|
|
} catch { /* ignore */ } finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const fetchEvents = useCallback(async () => {
|
|
try {
|
|
const res = await api.get('/timeline/events')
|
|
setAllEvents(res.data)
|
|
} catch { /* ignore */ }
|
|
}, [])
|
|
|
|
const bootstrap = useCallback(async () => {
|
|
try {
|
|
await api.post('/timeline/bootstrap')
|
|
setBootstrapped(true)
|
|
await fetchEvents()
|
|
await fetchDay(refDate)
|
|
} catch { /* ignore */ }
|
|
}, [refDate, fetchDay, fetchEvents])
|
|
|
|
const generateAI = useCallback(async () => {
|
|
setGenerating(true)
|
|
try {
|
|
const res = await api.post(`/timeline/generate/${refDate}`)
|
|
const data = res.data
|
|
setCtx(prev => prev ? {
|
|
...prev,
|
|
long_commentary: data.long_commentary,
|
|
medium_commentary: data.medium_commentary,
|
|
short_commentary: data.short_commentary,
|
|
has_commentary: true,
|
|
} : prev)
|
|
} catch { /* ignore */ } finally {
|
|
setGenerating(false)
|
|
}
|
|
}, [refDate])
|
|
|
|
useEffect(() => {
|
|
fetchEvents()
|
|
}, [fetchEvents])
|
|
|
|
useEffect(() => {
|
|
fetchDay(refDate)
|
|
}, [refDate, fetchDay])
|
|
|
|
// Auto-bootstrap on first load if no events
|
|
useEffect(() => {
|
|
if (!bootstrapped && allEvents.length === 0) {
|
|
bootstrap()
|
|
}
|
|
}, [allEvents.length, bootstrapped, bootstrap])
|
|
|
|
const bootstrapMA = useCallback(async () => {
|
|
setBootstrappingMA(true)
|
|
setMaResult(null)
|
|
try {
|
|
const res = await api.post('/timeline/bootstrap-ma')
|
|
setMaResult({ detected: res.data.detected, saved: res.data.saved })
|
|
await fetchEvents()
|
|
} catch { /* ignore */ } finally {
|
|
setBootstrappingMA(false)
|
|
}
|
|
}, [fetchEvents])
|
|
|
|
const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days))
|
|
|
|
return (
|
|
<div className="p-6 space-y-5 max-w-6xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
|
<Layers className="w-5 h-5 text-violet-400" />
|
|
Timeline Navigator
|
|
</h1>
|
|
<p className="text-sm text-slate-500 mt-0.5">
|
|
Contexte historique 3 temporalités — COVID (2020) → aujourd'hui
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowManager(s => !s)}
|
|
className={clsx(
|
|
'flex items-center gap-1.5 px-3 py-1.5 text-xs border rounded-lg transition-colors',
|
|
showManager
|
|
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
|
|
: 'text-slate-400 border-slate-700/40 hover:bg-dark-700'
|
|
)}
|
|
>
|
|
<List className="w-3 h-3" />
|
|
Gérer événements
|
|
</button>
|
|
<button
|
|
onClick={bootstrapMA}
|
|
disabled={bootstrappingMA}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-emerald-400 border border-emerald-700/40 rounded-lg hover:bg-emerald-900/20 transition-colors disabled:opacity-40"
|
|
title="Analyser EUR/USD, Brent, Gold, S&P500, US10Y via MAs → générer les périodes via GPT"
|
|
>
|
|
<Cpu className="w-3 h-3" />
|
|
{bootstrappingMA ? 'Analyse MA...' : 'Bootstrap MA'}
|
|
</button>
|
|
{maResult && (
|
|
<span className="text-xs text-emerald-400/70">
|
|
{maResult.saved} périodes ajoutées
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => bootstrap()}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
|
|
>
|
|
<RefreshCw className="w-3 h-3" />
|
|
Réinitialiser
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Date Navigator */}
|
|
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
|
|
<div className="flex items-center gap-4">
|
|
{/* Prev buttons */}
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-1M</button>
|
|
<button onClick={() => navigate(-7)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">-7J</button>
|
|
<button onClick={() => navigate(-1)} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors">
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Date input */}
|
|
<div className="flex-1 flex items-center justify-center gap-3">
|
|
<Calendar className="w-4 h-4 text-slate-500" />
|
|
<input
|
|
type="date"
|
|
value={refDate}
|
|
min="2020-01-01"
|
|
max={todayStr()}
|
|
onChange={e => setRefDate(e.target.value)}
|
|
className="bg-dark-700 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
|
/>
|
|
<span className="text-sm text-slate-400">{fmtDate(refDate)}</span>
|
|
<button
|
|
onClick={() => setRefDate(todayStr())}
|
|
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
|
>
|
|
Aujourd'hui
|
|
</button>
|
|
</div>
|
|
|
|
{/* Next buttons */}
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="p-1.5 text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => navigate(7)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+7J</button>
|
|
<button onClick={() => navigate(30)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded transition-colors disabled:opacity-30">+1M</button>
|
|
</div>
|
|
|
|
{/* Generate AI */}
|
|
<button
|
|
onClick={generateAI}
|
|
disabled={generating || loading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-violet-700/30 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
|
|
>
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
{generating ? 'Génération...' : 'Générer IA'}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Vertical timeline */}
|
|
{allEvents.length > 0 && (
|
|
<TimelineVertical events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
|
)}
|
|
|
|
{/* 3 context panels */}
|
|
{loading ? (
|
|
<div className="text-center text-slate-500 py-12">Chargement du contexte...</div>
|
|
) : ctx ? (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<ContextPanel
|
|
level="long"
|
|
event={ctx.events?.long ?? null}
|
|
commentary={ctx.long_commentary}
|
|
refDate={refDate}
|
|
/>
|
|
<ContextPanel
|
|
level="medium"
|
|
event={ctx.events?.medium ?? null}
|
|
commentary={ctx.medium_commentary}
|
|
refDate={refDate}
|
|
/>
|
|
<ContextPanel
|
|
level="short"
|
|
event={ctx.events?.short ?? null}
|
|
commentary={ctx.short_commentary}
|
|
refDate={refDate}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-slate-500 py-12">Aucun contexte disponible</div>
|
|
)}
|
|
|
|
{/* Event Manager */}
|
|
{showManager && (
|
|
<EventManager events={allEvents as any} onRefresh={fetchEvents} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|