feat: frise sub-lanes + event manager + MA indicators + absorption

Frise chronologique:
- Sub-lane stacking (assignSubLanes) — overlapping events se décalent verticalement
- Zone d'overlap semi-transparente sur la période commune entre 2 événements
- Hauteur dynamique selon nb de sub-lanes par niveau
- Événements en cours avec flèche ▶ à droite, gradient de fin
- Tri par start_date pour placement greedy

Event Manager (composant EventManager.tsx):
- Tableau filtrable par niveau (Long/Moyen/Court)
- Edit modal complet : tous les champs + absorption_pct éditable
- Bouton "IA — Enrichir" par événement → POST /api/timeline/events/{id}/ai-enrich
  → GPT-4o-mini suggère absorption_pct + indicateurs pertinents par niveau temporel
- Delete avec confirmation double-clic
- Expand row pour voir description + indicateurs
- Intégré Timeline page via bouton "Gérer événements"

Backend:
- Nouvelles colonnes market_events: absorption_pct + relevant_indicators (ALTER idempotent)
- DELETE /api/timeline/events/{id}
- POST /api/timeline/events/{id}/ai-enrich

Snapshot Externe:
- AbsorptionBar par événement dans cellule Géopolitique
- MA indicators : fetch 200j history, compute MA10/MA20/MA100 per level (short/med/long)
- Affichage prix vs MA + % écart dans CellMarkets
- Si relevant_indicators configurés sur l'event → utilise ces symbols au lieu des défauts
- Calendar : horizons exclusifs (short 0-7j, medium 8-30j, long 31-90j) — bug corrigé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 20:31:57 +02:00
parent 43b4816596
commit aeb5233deb
6 changed files with 950 additions and 175 deletions

View File

@@ -104,6 +104,25 @@ function relevantAssets(ev: MarketEvent | null, level: string): string[] {
// ── Cells ──────────────────────────────────────────────────────────────────────
function AbsorptionBar({ pct }: { pct: number | null | undefined }) {
if (pct === null || pct === undefined) return null
const color = pct >= 80 ? 'bg-red-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-emerald-500'
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not priced'
return (
<div className="mt-2">
<div className="flex items-center justify-between text-xs mb-0.5">
<span className="text-slate-600">Absorption marché</span>
<span className={clsx('font-medium', pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400')}>
{pct.toFixed(0)}% {label}
</span>
</div>
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
</div>
)
}
function CellGeo({
ev, level, refDate,
}: {
@@ -116,6 +135,7 @@ function CellGeo({
</div>
)
const days = daysSince(refDate, ev.start_date)
const absorption = (ev as any).absorption_pct as number | null
return (
<div className="p-3 space-y-2 h-full">
<div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div>
@@ -129,18 +149,39 @@ function CellGeo({
{ev.market_impact && (
<p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p>
)}
<AbsorptionBar pct={absorption} />
</div>
)
}
// MA period per temporal level
const MA_PERIOD: Record<string, number> = { long: 100, medium: 20, short: 10 }
function CellMarkets({
ev, level, quotes,
ev, level, quotes, maData,
}: {
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null
maData: Record<string, number | null>
}) {
const assets = relevantAssets(ev, level)
// Use event's relevant_indicators if configured
let configuredInds: { symbol: string; indicator: string; label: string }[] = []
if (ev) {
try { configuredInds = JSON.parse((ev as any).relevant_indicators || '[]') } catch { /**/ }
}
const assets = configuredInds.length >= 2
? configuredInds.map(i => i.symbol).slice(0, 4)
: relevantAssets(ev, level)
const maPeriod = MA_PERIOD[level] ?? 20
const maLabel = `MA${maPeriod}`
return (
<div className="p-3 space-y-1.5 h-full">
{/* MA context label */}
<div className="text-xs text-slate-600 mb-1">
{configuredInds.length >= 2 ? 'Indicateurs configurés' : `Prix spot + ${maLabel}`}
</div>
{assets.map(sym => {
const q = quotes?.[sym]
if (!q) return (
@@ -151,19 +192,32 @@ function CellMarkets({
)
const up = q.change_pct > 0.1
const dn = q.change_pct < -0.1
const ma = maData[sym]
const maVsPct = ma && q.price ? ((q.price - ma) / ma * 100) : null
const maUp = (maVsPct ?? 0) > 0
return (
<div key={sym} className="flex items-center justify-between py-0.5">
<span className="text-xs text-slate-400 truncate max-w-[6rem]">{q.name || sym}</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
<span className={clsx(
'text-xs flex items-center gap-0.5 tabular-nums w-14 justify-end',
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
)}>
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
</span>
<div key={sym} className="py-0.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 truncate max-w-[7rem]">{q.name || sym}</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
<span className={clsx(
'text-xs flex items-center gap-0.5 tabular-nums',
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
)}>
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
</span>
</div>
</div>
{ma !== undefined && ma !== null && (
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-slate-600">{maLabel} {ma.toFixed(ma > 100 ? 1 : 2)}</span>
<span className={clsx('text-xs tabular-nums', maUp ? 'text-emerald-500/70' : 'text-red-500/70')}>
{maVsPct !== null ? `${maUp ? '+' : ''}${maVsPct.toFixed(1)}%` : ''}
</span>
</div>
)}
</div>
)
})}
@@ -233,12 +287,23 @@ function CellMacro({
)
}
// Calendar events filtered by temporal horizon relative to refDate
// Calendar events filtered by EXCLUSIVE temporal horizon ranges
function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
const horizons: Record<string, number> = { long: 90, medium: 30, short: 7 }
const horizon = horizons[level] ?? 30
// Exclusive ranges so each level shows different events:
// short = 0-7 days ahead
// medium = 8-30 days ahead
// long = 31-90 days ahead
const ranges: Record<string, [number, number]> = {
short: [0, 7],
medium: [8, 30],
long: [31, 90],
}
const [from, to] = ranges[level] ?? [0, 30]
return events
.filter(e => e.date >= refDate && e.date <= offsetDate(refDate, horizon))
.filter(e => {
const d = e.date
return d >= offsetDate(refDate, from) && d <= offsetDate(refDate, to)
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 4)
}
@@ -288,7 +353,7 @@ function CellCalendar({
// ── Temporal Row ───────────────────────────────────────────────────────────────
function TemporalRow({
level, ev, quotes, macro, calEvents, refDate,
level, ev, quotes, macro, calEvents, refDate, maData,
}: {
level: keyof typeof LEVEL
ev: MarketEvent | null
@@ -296,6 +361,7 @@ function TemporalRow({
macro: MacroRegime | null
calEvents: CalEvent[] | null
refDate: string
maData: Record<string, number | null>
}) {
const cfg = LEVEL[level]
@@ -345,7 +411,7 @@ function TemporalRow({
<ArrowRight className="w-3 h-3" />
</Link>
</div>
<CellMarkets ev={ev} level={level} quotes={quotes} />
<CellMarkets ev={ev} level={level} quotes={quotes} maData={maData} />
</div>
{/* Pilier 3: Régimes Macro */}
@@ -378,12 +444,23 @@ function TemporalRow({
// ── Main ───────────────────────────────────────────────────────────────────────
// Compute simple moving average from array of closes (most recent last)
function computeMA(closes: number[], period: number): number | null {
if (closes.length < period) return null
const slice = closes.slice(-period)
return slice.reduce((a, b) => a + b, 0) / period
}
export default function ExternalSnapshot() {
const [refDate, setRefDate] = useState(todayStr())
const [ctx, setCtx] = useState<DayContext | null>(null)
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
const [macro, setMacro] = useState<MacroRegime | null>(null)
const [calEvents, setCalEvents] = useState<CalEvent[] | null>(null)
// maData[level][symbol] = MA value
const [maData, setMaData] = useState<Record<string, Record<string, number | null>>>({
long: {}, medium: {}, short: {},
})
useEffect(() => {
api.get(`/timeline/day/${refDate}`)
@@ -401,6 +478,36 @@ export default function ExternalSnapshot() {
api.get('/market/macro-regime').then(r => setMacro(r.data)).catch(() => {})
api.get('/geo/calendar').then(r => setCalEvents(r.data)).catch(() => {})
// Fetch MA data for key symbols (200 days history)
const KEY_SYMS = ['SPY', 'GLD', 'USO', 'TLT', 'UUP', 'QQQ']
const MA_PERIODS = { long: 100, medium: 20, short: 10 }
Promise.allSettled(
KEY_SYMS.map(sym =>
api.get(`/market/history/${sym}`, { params: { days: 200 } })
.then(r => {
const candles: { close: number }[] = Array.isArray(r.data) ? r.data : []
const closes = candles.map(c => c.close).filter(Boolean)
const result: Record<string, Record<string, number | null>> = {
long: {}, medium: {}, short: {},
}
for (const [level, period] of Object.entries(MA_PERIODS)) {
result[level][sym] = computeMA(closes, period as number)
}
return result
})
)
).then(results => {
const merged: Record<string, Record<string, number | null>> = { long: {}, medium: {}, short: {} }
for (const res of results) {
if (res.status === 'fulfilled') {
for (const level of ['long', 'medium', 'short']) {
Object.assign(merged[level], res.value[level])
}
}
}
setMaData(merged)
})
}, [])
const navigate = (days: number) => {
@@ -453,9 +560,9 @@ export default function ExternalSnapshot() {
</div>
{/* 3 temporal rows */}
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.long} />
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.medium} />
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.short} />
{/* Footer links */}
<div className="flex items-center gap-4 text-xs text-slate-600 pt-2">

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw } from 'lucide-react'
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List } from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import TimelineFrise from '../components/TimelineFrise'
import EventManager from '../components/EventManager'
const api = axios.create({ baseURL: '/api' })
@@ -189,6 +190,7 @@ export default function Timeline() {
const [loading, setLoading] = useState(false)
const [generating, setGenerating] = useState(false)
const [bootstrapped, setBootstrapped] = useState(false)
const [showManager, setShowManager] = useState(false)
const fetchDay = useCallback(async (d: string) => {
setLoading(true)
@@ -264,12 +266,24 @@ export default function Timeline() {
</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={() => 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 événements
Réinitialiser
</button>
</div>
</div>
@@ -361,37 +375,9 @@ export default function Timeline() {
<div className="text-center text-slate-500 py-12">Aucun contexte disponible</div>
)}
{/* Event list (compact) */}
{allEvents.length > 0 && (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4">
<h2 className="text-sm font-semibold text-slate-300 mb-3">
Catalogue d'événements ({allEvents.length})
</h2>
<div className="space-y-1 max-h-72 overflow-y-auto">
{(['long', 'medium', 'short'] as const).map(lvl => (
<div key={lvl}>
<div className={clsx('text-xs font-semibold uppercase tracking-wider mb-1 mt-2', LEVEL_CONFIG[lvl].color)}>
{LEVEL_CONFIG[lvl].label}
</div>
{allEvents.filter(e => e.level === lvl).map(ev => (
<div
key={ev.id}
onClick={() => setRefDate(ev.start_date)}
className="flex items-center gap-2 text-xs py-1 px-2 rounded hover:bg-dark-700/60 cursor-pointer group"
>
<div className={clsx('w-1.5 h-1.5 rounded-full shrink-0', LEVEL_CONFIG[lvl].dot)} />
<span className="text-slate-300 group-hover:text-white flex-1">{ev.name}</span>
<span className="text-slate-600">{fmtDate(ev.start_date)}</span>
{ev.end_date
? <span className="text-slate-600"> {fmtDate(ev.end_date)}</span>
: <span className="text-amber-600 text-xs">en cours</span>
}
</div>
))}
</div>
))}
</div>
</div>
{/* Event Manager */}
{showManager && (
<EventManager events={allEvents as any} onRefresh={fetchEvents} />
)}
</div>
)