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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user