Files
OpenFin/frontend/src/pages/Timeline.tsx
OpenSquared edfa90c9b7 feat: vertical 3-column timeline + MA regime bootstrap engine
- 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>
2026-06-24 21:01:01 +02:00

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>
)
}