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

@@ -2,7 +2,9 @@
Timeline Navigator — historical market context browser. Timeline Navigator — historical market context browser.
Prefix: /api/timeline Prefix: /api/timeline
""" """
import json
import logging import logging
import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -23,6 +25,8 @@ class EventCreate(BaseModel):
market_impact: str = "" market_impact: str = ""
affected_assets: List[str] = [] affected_assets: List[str] = []
impact_score: float = 0.5 impact_score: float = 0.5
absorption_pct: Optional[float] = None
relevant_indicators: List[Dict[str, Any]] = []
parent_event_id: Optional[int] = None parent_event_id: Optional[int] = None
@@ -52,11 +56,108 @@ def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]:
return {"status": "updated"} return {"status": "updated"}
@router.delete("/events/{event_id}")
def delete_event(event_id: int) -> Dict[str, Any]:
from services.database import delete_market_event
delete_market_event(event_id)
return {"status": "deleted"}
@router.post("/events/{event_id}/ai-enrich")
def ai_enrich_event(event_id: int) -> Dict[str, Any]:
"""Ask GPT-4o-mini to suggest absorption_pct + relevant_indicators for this event."""
from services.database import get_all_market_events, update_market_event
from datetime import datetime
api_key = os.environ.get("OPENAI_API_KEY", "")
if not api_key:
raise HTTPException(400, "OpenAI API key not configured")
# Fetch event
all_evs = get_all_market_events()
ev = next((e for e in all_evs if e["id"] == event_id), None)
if not ev:
raise HTTPException(404, "Event not found")
from openai import OpenAI
client = OpenAI(api_key=api_key)
today = datetime.utcnow().strftime("%Y-%m-%d")
days_elapsed = max(0, (datetime.strptime(today, "%Y-%m-%d") -
datetime.strptime(ev["start_date"][:10], "%Y-%m-%d")).days)
is_ongoing = not ev.get("end_date")
prompt = f"""Tu es un analyste macro senior spécialisé en options géopolitiques.
Événement à analyser:
- Nom: {ev['name']}
- Niveau: {ev['level']} (long=structurel, medium=régime, short=catalyseur)
- Catégorie: {ev['category']}
- Début: {ev['start_date']}
- Fin: {ev.get('end_date') or 'en cours'}
- Jours écoulés depuis début: {days_elapsed}j
- Description: {ev['description']}
- Impact marché: {ev.get('market_impact', '')}
- Assets affectés: {ev.get('affected_assets', '[]')}
Ta mission:
1. Estime le taux d'absorption (0-100%) de cet événement par le marché à ce jour.
Absorption = à quel point l'impact attendu est déjà "pricé" par les marchés.
Exemples: 0%=pas encore pricé, 50%=à moitié absorbé, 100%=fully priced in.
2. Suggère 3-5 indicateurs techniques PERTINENTS pour suivre cet événement dans le contexte d'un trader d'options.
Pour chaque indicateur:
- symbol: ticker yfinance (ex: "CL=F", "GC=F", "SPY", "^VIX")
- indicator: type ("MA10", "MA20", "MA50", "MA100", "price", "RSI14")
- label: nom affiché (ex: "Brent MA100", "Gold prix spot", "VIX 20j avg")
- rationale: 1 phrase pourquoi cet indicateur est pertinent POUR CET ÉVÉNEMENT PRÉCIS
Règle: choisis les indicateurs selon le niveau temporel:
- Long terme → préfère MA100, MA50 (tendances structurelles)
- Moyen terme → MA20, MA50
- Court terme → MA10, MA20, prix spot
FORMAT JSON STRICT:
{{
"absorption_pct": <0-100>,
"absorption_rationale": "<1 phrase expliquant pourquoi ce niveau>",
"relevant_indicators": [
{{"symbol": "...", "indicator": "...", "label": "...", "rationale": "..."}}
]
}}"""
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.3,
max_tokens=600,
)
result = json.loads(response.choices[0].message.content)
# Update event in DB
ev_update = dict(ev)
ev_update["affected_assets"] = json.loads(ev.get("affected_assets", "[]") or "[]")
ev_update["relevant_indicators"] = result.get("relevant_indicators", [])
ev_update["absorption_pct"] = result.get("absorption_pct")
update_market_event(event_id, ev_update)
return {
"event_id": event_id,
"absorption_pct": result.get("absorption_pct"),
"absorption_rationale": result.get("absorption_rationale", ""),
"relevant_indicators": result.get("relevant_indicators", []),
"status": "enriched",
}
except Exception as e:
logger.error(f"[Timeline] ai_enrich failed for event {event_id}: {e}")
raise HTTPException(500, str(e))
@router.get("/day/{ref_date}") @router.get("/day/{ref_date}")
def get_day_context(ref_date: str) -> Dict[str, Any]: def get_day_context(ref_date: str) -> Dict[str, Any]:
"""Return cached or freshly generated timeline context for a date (YYYY-MM-DD)."""
from services.database import get_events_for_date, get_timeline_context from services.database import get_events_for_date, get_timeline_context
import json
events = get_events_for_date(ref_date) events = get_events_for_date(ref_date)
cached = get_timeline_context(ref_date) cached = get_timeline_context(ref_date)
@@ -85,9 +186,7 @@ def get_day_context(ref_date: str) -> Dict[str, Any]:
@router.post("/generate/{ref_date}") @router.post("/generate/{ref_date}")
def generate_commentary(ref_date: str) -> Dict[str, Any]: def generate_commentary(ref_date: str) -> Dict[str, Any]:
"""Generate (or regenerate) AI commentary for a specific date and cache it."""
from services.timeline_service import get_or_generate_context from services.timeline_service import get_or_generate_context
import json
try: try:
ctx = get_or_generate_context(ref_date) ctx = get_or_generate_context(ref_date)
@@ -96,7 +195,7 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
"long_commentary": ctx.get("long_commentary", ""), "long_commentary": ctx.get("long_commentary", ""),
"medium_commentary": ctx.get("medium_commentary", ""), "medium_commentary": ctx.get("medium_commentary", ""),
"short_commentary": ctx.get("short_commentary", ""), "short_commentary": ctx.get("short_commentary", ""),
"evidence_headlines": json.loads(ctx.get("evidence_headlines", "[]") or "[]") if isinstance(ctx.get("evidence_headlines"), str) else ctx.get("evidence_headlines", []), "evidence_headlines": ctx.get("evidence_headlines", []),
"events": ctx.get("events", {}), "events": ctx.get("events", {}),
"has_commentary": True, "has_commentary": True,
} }
@@ -107,7 +206,6 @@ def generate_commentary(ref_date: str) -> Dict[str, Any]:
@router.post("/bootstrap") @router.post("/bootstrap")
def bootstrap_events() -> Dict[str, Any]: def bootstrap_events() -> Dict[str, Any]:
"""Seed historical market events (idempotent — only runs if table is empty)."""
from services.timeline_service import bootstrap_events as _bootstrap from services.timeline_service import bootstrap_events as _bootstrap
count = _bootstrap() count = _bootstrap()
return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"} return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"}

View File

@@ -746,9 +746,20 @@ def init_db():
market_impact TEXT DEFAULT '', market_impact TEXT DEFAULT '',
affected_assets TEXT DEFAULT '[]', affected_assets TEXT DEFAULT '[]',
impact_score REAL DEFAULT 0.5, impact_score REAL DEFAULT 0.5,
absorption_pct REAL DEFAULT NULL,
relevant_indicators TEXT DEFAULT '[]',
parent_event_id INTEGER REFERENCES market_events(id), parent_event_id INTEGER REFERENCES market_events(id),
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
)""") )""")
# Add columns to existing DBs (idempotent via catch)
for _col, _def in [
("absorption_pct", "REAL DEFAULT NULL"),
("relevant_indicators", "TEXT DEFAULT '[]'"),
]:
try:
c.execute(f"ALTER TABLE market_events ADD COLUMN {_col} {_def}")
except Exception:
pass
c.execute("""CREATE TABLE IF NOT EXISTS timeline_context ( c.execute("""CREATE TABLE IF NOT EXISTS timeline_context (
ref_date TEXT PRIMARY KEY, ref_date TEXT PRIMARY KEY,
@@ -4487,12 +4498,25 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool:
try: try:
conn.execute("""UPDATE market_events SET conn.execute("""UPDATE market_events SET
name=?, start_date=?, end_date=?, level=?, category=?, description=?, name=?, start_date=?, end_date=?, level=?, category=?, description=?,
market_impact=?, affected_assets=?, impact_score=? market_impact=?, affected_assets=?, impact_score=?,
absorption_pct=?, relevant_indicators=?
WHERE id=?""", WHERE id=?""",
(ev["name"], ev["start_date"], ev.get("end_date"), (ev["name"], ev["start_date"], ev.get("end_date"),
ev["level"], ev.get("category", "macro"), ev.get("description", ""), ev["level"], ev.get("category", "macro"), ev.get("description", ""),
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])), ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
ev.get("impact_score", 0.5), event_id)) ev.get("impact_score", 0.5),
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
event_id))
conn.commit()
return True
finally:
conn.close()
def delete_market_event(event_id: int) -> bool:
conn = get_conn()
try:
conn.execute("DELETE FROM market_events WHERE id=?", (event_id,))
conn.commit() conn.commit()
return True return True
finally: finally:

View File

@@ -0,0 +1,434 @@
import { useState } from 'react'
import axios from 'axios'
import clsx from 'clsx'
import { Plus, Edit2, Trash2, Sparkles, X, Save, ChevronDown, ChevronUp, Check } from 'lucide-react'
const api = axios.create({ baseURL: '/api' })
interface Indicator { symbol: string; indicator: string; label: string; rationale?: string }
interface MarketEvent {
id: number; name: string; start_date: string; end_date: string | null
level: string; category: string; description: string; market_impact: string
affected_assets: string; impact_score: number
absorption_pct: number | null; relevant_indicators: string
}
interface Props {
events: MarketEvent[]
onRefresh: () => void
}
const LEVELS = ['long', 'medium', 'short'] as const
const LEVEL_LABELS = { long: 'Long Terme', medium: 'Moyen Terme', short: 'Court Terme' }
const LEVEL_COLORS = {
long: { badge: 'bg-violet-900/60 text-violet-300 border-violet-700/50', dot: 'bg-violet-500' },
medium: { badge: 'bg-blue-900/60 text-blue-300 border-blue-700/50', dot: 'bg-blue-500' },
short: { badge: 'bg-emerald-900/60 text-emerald-300 border-emerald-700/50', dot: 'bg-emerald-500' },
}
const CATEGORIES = ['macro', 'monetary', 'geopolitical', 'market', 'fiscal']
const EMPTY_FORM = {
name: '', start_date: '', end_date: '', level: 'medium', category: 'macro',
description: '', market_impact: '', affected_assets: '', impact_score: 0.5,
absorption_pct: '', relevant_indicators: '',
}
type FormState = typeof EMPTY_FORM
function AbsorptionBadge({ pct }: { pct: number | null }) {
if (pct === null || pct === undefined) return <span className="text-xs text-slate-600"></span>
const color = pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400'
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not yet'
return (
<span className={clsx('text-xs font-medium', color)}>
{pct.toFixed(0)}% <span className="text-slate-600">({label})</span>
</span>
)
}
function fmtDate(d: string | null) {
if (!d) return '—'
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: '2-digit' })
}
function parseIndicators(raw: string | null | undefined): Indicator[] {
try { return JSON.parse(raw || '[]') } catch { return [] }
}
// ── Edit / Add Modal ───────────────────────────────────────────────────────────
function EventModal({
event,
onClose,
onSaved,
}: {
event: MarketEvent | null // null = create new
onClose: () => void
onSaved: () => void
}) {
const [form, setForm] = useState<FormState>(event ? {
name: event.name,
start_date: event.start_date,
end_date: event.end_date ?? '',
level: event.level,
category: event.category,
description: event.description,
market_impact: event.market_impact,
affected_assets: (() => {
try { return JSON.parse(event.affected_assets || '[]').join(', ') } catch { return '' }
})(),
impact_score: event.impact_score,
absorption_pct: event.absorption_pct !== null && event.absorption_pct !== undefined
? String(event.absorption_pct) : '',
relevant_indicators: (() => {
try {
const inds = JSON.parse(event.relevant_indicators || '[]')
return inds.map((i: Indicator) => i.label).join(', ')
} catch { return '' }
})(),
} : { ...EMPTY_FORM })
const [saving, setSaving] = useState(false)
const [enriching, setEnriching] = useState(false)
const [enrichResult, setEnrichResult] = useState<{ absorption_pct: number; absorption_rationale: string; relevant_indicators: Indicator[] } | null>(null)
const set = (k: keyof FormState, v: string | number) =>
setForm(f => ({ ...f, [k]: v }))
async function save() {
setSaving(true)
try {
const payload = {
name: form.name,
start_date: form.start_date,
end_date: form.end_date || null,
level: form.level,
category: form.category,
description: form.description,
market_impact: form.market_impact,
affected_assets: form.affected_assets.split(',').map(s => s.trim()).filter(Boolean),
impact_score: Number(form.impact_score),
absorption_pct: form.absorption_pct ? Number(form.absorption_pct) : null,
relevant_indicators: enrichResult?.relevant_indicators ?? [],
}
if (event) {
await api.put(`/timeline/events/${event.id}`, payload)
} else {
await api.post('/timeline/events', payload)
}
onSaved()
onClose()
} finally {
setSaving(false)
}
}
async function enrich() {
if (!event) return
setEnriching(true)
try {
const res = await api.post(`/timeline/events/${event.id}/ai-enrich`)
const d = res.data
setEnrichResult(d)
set('absorption_pct', String(d.absorption_pct ?? ''))
} finally {
setEnriching(false)
}
}
const indicators = enrichResult?.relevant_indicators ?? parseIndicators(event?.relevant_indicators)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-dark-800 border border-slate-700/40 rounded-xl w-[680px] max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/30">
<div className="text-sm font-semibold text-white">
{event ? `Éditer — ${event.name}` : 'Nouvel événement'}
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="p-5 space-y-4">
{/* Name + Level + Category */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-3">
<label className="text-xs text-slate-500 mb-1 block">Nom *</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Niveau *</label>
<select value={form.level} onChange={e => set('level', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
{LEVELS.map(l => <option key={l} value={l}>{LEVEL_LABELS[l]}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Catégorie</label>
<select value={form.category} onChange={e => set('category', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Impact score (0-1)</label>
<input type="number" min={0} max={1} step={0.05}
value={form.impact_score} onChange={e => set('impact_score', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Date début *</label>
<input type="date" value={form.start_date} onChange={e => set('start_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Date fin (vide = en cours)</label>
<input type="date" value={form.end_date} onChange={e => set('end_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
</div>
{/* Description + Market impact */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)} rows={2}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Impact marché</label>
<textarea value={form.market_impact} onChange={e => set('market_impact', e.target.value)} rows={2}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none resize-none" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Assets affectés (séparés par virgule)</label>
<input value={form.affected_assets} onChange={e => set('affected_assets', e.target.value)}
placeholder="CL, GC, VIX, SPX..."
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
</div>
{/* Absorption */}
<div className="bg-dark-900/40 rounded-lg p-3 border border-slate-700/20 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-slate-400 font-medium">Absorption du marché (%)</label>
{event && (
<button
onClick={enrich}
disabled={enriching}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 rounded transition-colors disabled:opacity-40"
>
<Sparkles className="w-3 h-3" />
{enriching ? 'Analyse IA...' : 'IA — Enrichir'}
</button>
)}
</div>
<input type="number" min={0} max={100} step={1}
value={form.absorption_pct} onChange={e => set('absorption_pct', e.target.value)}
placeholder="0 = non pricé, 100 = fully priced"
className="w-full bg-dark-700 border border-slate-700/40 rounded px-3 py-1.5 text-sm text-white focus:outline-none" />
{enrichResult?.absorption_rationale && (
<p className="text-xs text-slate-500 italic">{enrichResult.absorption_rationale}</p>
)}
</div>
{/* Indicators */}
{indicators.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-slate-400 font-medium">Indicateurs pertinents suggérés</div>
{indicators.map((ind, i) => (
<div key={i} className="flex items-start gap-2 bg-dark-900/30 rounded px-3 py-1.5">
<div className="flex-1">
<span className="text-xs text-white font-medium">{ind.label}</span>
<span className="text-xs text-slate-500 ml-2">({ind.symbol} · {ind.indicator})</span>
</div>
{ind.rationale && (
<span className="text-xs text-slate-600 italic text-right max-w-[200px]">{ind.rationale}</span>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-slate-700/30">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-slate-400 hover:text-white">Annuler</button>
<button
onClick={save}
disabled={saving || !form.name || !form.start_date}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-blue-700/50 hover:bg-blue-600/60 text-blue-200 border border-blue-600/40 rounded-lg disabled:opacity-40"
>
<Save className="w-3.5 h-3.5" />
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
</div>
</div>
</div>
)
}
// ── Event Row ──────────────────────────────────────────────────────────────────
function EventRow({
ev, onEdit, onDelete,
}: {
ev: MarketEvent; onEdit: () => void; onDelete: () => void
}) {
const [deleting, setDeleting] = useState(false)
const [expanded, setExpanded] = useState(false)
const level = ev.level as keyof typeof LEVEL_COLORS
const cfg = LEVEL_COLORS[level] ?? LEVEL_COLORS.medium
const indicators = parseIndicators(ev.relevant_indicators)
async function confirmDelete() {
if (!deleting) { setDeleting(true); return }
await api.delete(`/timeline/events/${ev.id}`)
onDelete()
}
return (
<>
<tr className="border-b border-slate-700/20 hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2">
<span className={clsx('text-xs px-1.5 py-0.5 rounded border font-bold', cfg.badge)}>
{ev.level[0].toUpperCase()}
</span>
</td>
<td className="px-3 py-2">
<div className="text-sm text-white font-medium">{ev.name}</div>
<div className="text-xs text-slate-500">{ev.category}</div>
</td>
<td className="px-3 py-2 text-xs text-slate-400 tabular-nums">{fmtDate(ev.start_date)}</td>
<td className="px-3 py-2 text-xs tabular-nums">
{ev.end_date ? <span className="text-slate-400">{fmtDate(ev.end_date)}</span> : <span className="text-amber-500/70">en cours</span>}
</td>
<td className="px-3 py-2"><AbsorptionBadge pct={ev.absorption_pct} /></td>
<td className="px-3 py-2 text-xs text-slate-500">{indicators.length > 0 ? `${indicators.length} ind.` : '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
<button onClick={() => setExpanded(e => !e)} className="p-1 text-slate-600 hover:text-slate-300">
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
<button onClick={onEdit} className="p-1 text-slate-600 hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
<button
onClick={confirmDelete}
className={clsx('p-1', deleting ? 'text-red-400 animate-pulse' : 'text-slate-600 hover:text-red-400')}
title={deleting ? 'Cliquer à nouveau pour confirmer' : 'Supprimer'}
>
{deleting ? <Check className="w-3.5 h-3.5" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-slate-700/20 bg-dark-900/20">
<td />
<td colSpan={6} className="px-3 py-2 space-y-1.5">
{ev.description && <p className="text-xs text-slate-400">{ev.description}</p>}
{ev.market_impact && <p className="text-xs text-slate-500 italic">{ev.market_impact}</p>}
{indicators.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{indicators.map((ind, i) => (
<span key={i} className="text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-slate-400">
{ind.label} <span className="text-slate-600">({ind.indicator})</span>
</span>
))}
</div>
)}
</td>
</tr>
)}
</>
)
}
// ── Main ───────────────────────────────────────────────────────────────────────
export default function EventManager({ events, onRefresh }: Props) {
const [filter, setFilter] = useState<'all' | 'long' | 'medium' | 'short'>('all')
const [editing, setEditing] = useState<MarketEvent | null | 'new'>('new' as unknown as null)
const [modalTarget, setModalTarget] = useState<MarketEvent | null | false>(false)
const filtered = events.filter(ev => filter === 'all' || ev.level === filter)
.sort((a, b) => b.start_date.localeCompare(a.start_date))
return (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/30">
<span className="text-sm font-semibold text-slate-300">Événements ({events.length})</span>
{/* Level filter */}
<div className="flex items-center gap-1">
{(['all', 'long', 'medium', 'short'] as const).map(l => (
<button key={l}
onClick={() => setFilter(l)}
className={clsx(
'px-2 py-1 text-xs rounded transition-colors',
filter === l ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-300'
)}
>
{l === 'all' ? 'Tous' : LEVEL_LABELS[l]}
</button>
))}
</div>
<button
onClick={() => setModalTarget(null)}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-800/40 hover:bg-blue-700/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Ajouter
</button>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-72 overflow-y-auto">
<table className="w-full text-left">
<thead className="text-xs text-slate-500 uppercase tracking-wider border-b border-slate-700/30 sticky top-0 bg-dark-800">
<tr>
<th className="px-3 py-2 w-10">Niv.</th>
<th className="px-3 py-2">Nom</th>
<th className="px-3 py-2">Début</th>
<th className="px-3 py-2">Fin</th>
<th className="px-3 py-2">Absorption</th>
<th className="px-3 py-2">Indicateurs</th>
<th className="px-3 py-2 w-20" />
</tr>
</thead>
<tbody>
{filtered.map(ev => (
<EventRow
key={ev.id}
ev={ev}
onEdit={() => setModalTarget(ev)}
onDelete={onRefresh}
/>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-3 py-6 text-center text-xs text-slate-600 italic">Aucun événement</td></tr>
)}
</tbody>
</table>
</div>
{/* Modal */}
{modalTarget !== false && (
<EventModal
event={modalTarget}
onClose={() => setModalTarget(false)}
onSaved={onRefresh}
/>
)}
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect, useMemo } from 'react'
import clsx from 'clsx'
interface MarketEvent { interface MarketEvent {
id: number id: number
@@ -17,201 +16,328 @@ interface Props {
onDateChange: (d: string) => void onDateChange: (d: string) => void
} }
const SCALE = 2.2 // pixels per day // ── Layout constants ───────────────────────────────────────────────────────────
const LANE_H = 30 // height of each event lane const SCALE = 2.4 // px per day
const AXIS_H = 24 // height of year axis const SUB_H = 26 // height per sub-lane
const PADDING_TOP = 8 const SUB_GAP = 3 // gap between sub-lanes in same level
const LEVEL_GAP = 14 // gap between levels
const AXIS_H = 28
const PADDING_TOP = 12
const FRISE_START = '2020-02-01' const FRISE_START = '2020-02-01'
const TODAY = new Date().toISOString().split('T')[0]
const LEVEL_COLORS: Record<string, { bg: string; border: string; text: string; track: string }> = { const LEVEL_ORDER = ['long', 'medium', 'short'] as const
long: { bg: '#4c1d95', border: '#7c3aed', text: '#ddd6fe', track: '#1e1b4b' },
medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#bfdbfe', track: '#0f172a' }, const COLORS = {
short: { bg: '#064e3b', border: '#10b981', text: '#a7f3d0', track: '#022c22' }, long: { bg: '#5b21b6', border: '#7c3aed', text: '#ede9fe', track: 'rgba(109,40,217,0.12)', overlap: 'rgba(124,58,237,0.25)' },
medium: { bg: '#1d4ed8', border: '#3b82f6', text: '#dbeafe', track: 'rgba(59,130,246,0.10)', overlap: 'rgba(59,130,246,0.22)' },
short: { bg: '#065f46', border: '#10b981', text: '#d1fae5', track: 'rgba(16,185,129,0.10)', overlap: 'rgba(16,185,129,0.22)' },
} }
const LEVEL_ORDER: Record<string, number> = { long: 0, medium: 1, short: 2 } // ── Helpers ────────────────────────────────────────────────────────────────────
function toDay(date: string): number { function toDay(date: string): number {
const origin = new Date(FRISE_START).getTime() const origin = new Date(FRISE_START).getTime()
const d = new Date(date).getTime() const d = new Date(date + 'T00:00:00Z').getTime()
return Math.floor((d - origin) / 86_400_000) return Math.floor((d - origin) / 86_400_000)
} }
function fmtYear(d: Date): string { function buildYearMarkers(totalDays: number) {
return d.getFullYear().toString()
}
function buildYearMarkers(totalDays: number): { x: number; label: string }[] {
const origin = new Date(FRISE_START) const origin = new Date(FRISE_START)
const markers = [] const out: { day: number; label: string }[] = []
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; y++) { for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 2; y++) {
const jan1 = new Date(y, 0, 1) const jan1 = new Date(y, 0, 1)
const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000) const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000)
if (day >= 0 && day <= totalDays + 30) if (day >= 0 && day <= totalDays + 60) out.push({ day, label: String(y) })
markers.push({ x: day * SCALE, label: fmtYear(jan1) })
} }
return markers return out
} }
// Assign sub-lane index per event within a level (greedy interval scheduling)
function assignSubLanes(evs: MarketEvent[]): Map<number, number> {
const sorted = [...evs].sort((a, b) => a.start_date.localeCompare(b.start_date))
const laneEnds: string[] = []
const result = new Map<number, number>()
for (const ev of sorted) {
const end = ev.end_date ?? '2099-12-31'
let placed = false
for (let i = 0; i < laneEnds.length; i++) {
if (ev.start_date >= laneEnds[i]) {
result.set(ev.id, i)
laneEnds[i] = end
placed = true
break
}
}
if (!placed) {
result.set(ev.id, laneEnds.length)
laneEnds.push(end)
}
}
return result
}
// Find overlapping pairs within a level (A in lane 0, B in lane 1+, B starts before A ends)
function findOverlaps(evs: MarketEvent[], subLanes: Map<number, number>): { a: MarketEvent; b: MarketEvent }[] {
const pairs: { a: MarketEvent; b: MarketEvent }[] = []
for (let i = 0; i < evs.length; i++) {
for (let j = i + 1; j < evs.length; j++) {
const a = evs[i], b = evs[j]
const aEnd = a.end_date ?? TODAY
const bEnd = b.end_date ?? TODAY
const overlapStart = a.start_date > b.start_date ? a.start_date : b.start_date
const overlapEnd = aEnd < bEnd ? aEnd : bEnd
if (overlapStart < overlapEnd) {
const lA = subLanes.get(a.id) ?? 0
const lB = subLanes.get(b.id) ?? 0
if (lA !== lB) pairs.push({ a, b })
}
}
}
return pairs
}
// ── Component ──────────────────────────────────────────────────────────────────
export default function TimelineFrise({ events, refDate, onDateChange }: Props) { export default function TimelineFrise({ events, refDate, onDateChange }: Props) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const totalDays = toDay(new Date().toISOString().split('T')[0]) + 60
const totalDays = toDay(TODAY) + 90
const totalWidth = totalDays * SCALE const totalWidth = totalDays * SCALE
const totalHeight = PADDING_TOP + 3 * LANE_H + AXIS_H + 8 const todayX = toDay(TODAY) * SCALE
const refX = toDay(refDate) * SCALE
const yearMarkers = buildYearMarkers(totalDays) const yearMarkers = buildYearMarkers(totalDays)
const todayX = toDay(new Date().toISOString().split('T')[0]) * SCALE
const refX = toDay(refDate) * SCALE
// Scroll so selected date is centred on mount / when refDate changes // Per-level sub-lane assignments
const byLevel = useMemo(() => {
const g: Record<string, MarketEvent[]> = { long: [], medium: [], short: [] }
for (const ev of events) { if (g[ev.level]) g[ev.level].push(ev) }
return g
}, [events])
const subLanesMap = useMemo(() => {
const m: Record<string, Map<number, number>> = {}
for (const l of LEVEL_ORDER) m[l] = assignSubLanes(byLevel[l] ?? [])
return m
}, [byLevel])
const maxSubLanes = useMemo(() => {
const m: Record<string, number> = {}
for (const l of LEVEL_ORDER) {
const vals = [...(subLanesMap[l]?.values() ?? [])]
m[l] = vals.length ? Math.max(...vals) + 1 : 1
}
return m
}, [subLanesMap])
// Compute Y offset for each level
const levelY = useMemo(() => {
const y: Record<string, number> = {}
let cur = PADDING_TOP
for (const l of LEVEL_ORDER) {
y[l] = cur
cur += maxSubLanes[l] * (SUB_H + SUB_GAP) + LEVEL_GAP
}
return y
}, [maxSubLanes])
const totalHeight = useMemo(() => {
return PADDING_TOP +
LEVEL_ORDER.reduce((acc, l) => acc + maxSubLanes[l] * (SUB_H + SUB_GAP) + LEVEL_GAP, 0) +
AXIS_H
}, [maxSubLanes])
// Auto-scroll to selected date
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el) return if (!el) return
const target = refX - el.clientWidth / 2 el.scrollLeft = Math.max(0, refX - el.clientWidth / 2)
el.scrollLeft = Math.max(0, target)
}, [refDate, refX]) }, [refDate, refX])
// Group events by level, resolve overlaps within a level by stacking sub-rows // Compute overlap zones for background shading
const grouped: Record<string, MarketEvent[]> = { long: [], medium: [], short: [] } const overlapZones = useMemo(() => {
for (const ev of events) { const zones: { x: number; w: number; level: string; laneTop: number; laneBot: number }[] = []
if (grouped[ev.level]) grouped[ev.level].push(ev) for (const l of LEVEL_ORDER) {
} const evs = byLevel[l] ?? []
const sl = subLanesMap[l]
function laneY(level: string): number { const pairs = findOverlaps(evs, sl)
return PADDING_TOP + LEVEL_ORDER[level] * LANE_H for (const { a, b } of pairs) {
} const overlapStart = a.start_date > b.start_date ? a.start_date : b.start_date
const overlapEnd = (a.end_date ?? TODAY) < (b.end_date ?? TODAY)
function evWidth(ev: MarketEvent): number { ? (a.end_date ?? TODAY) : (b.end_date ?? TODAY)
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays if (overlapStart >= overlapEnd) continue
const startDay = toDay(ev.start_date) const lA = sl.get(a.id) ?? 0
const days = Math.max(endDay - startDay, 14) const lB = sl.get(b.id) ?? 0
return days * SCALE const minLane = Math.min(lA, lB)
} const maxLane = Math.max(lA, lB)
zones.push({
x: toDay(overlapStart) * SCALE,
w: toDay(overlapEnd) * SCALE - toDay(overlapStart) * SCALE,
level: l,
laneTop: levelY[l] + minLane * (SUB_H + SUB_GAP),
laneBot: levelY[l] + (maxLane + 1) * (SUB_H + SUB_GAP) - SUB_GAP,
})
}
}
return zones
}, [byLevel, subLanesMap, levelY])
return ( return (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden"> <div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
{/* Level legend */} {/* Legend */}
<div className="flex items-center gap-5 px-4 pt-3 pb-2"> <div className="flex items-center gap-5 px-4 pt-3 pb-2 border-b border-slate-700/30">
{(['long', 'medium', 'short'] as const).map(l => ( {LEVEL_ORDER.map(l => (
<div key={l} className="flex items-center gap-1.5 text-xs"> <div key={l} className="flex items-center gap-1.5 text-xs">
<div className="w-3 h-2 rounded-sm" style={{ background: LEVEL_COLORS[l].border }} /> <div className="w-3 h-2 rounded-sm" style={{ background: COLORS[l].border }} />
<span className="text-slate-400 capitalize"> <span className="text-slate-400">
{l === 'long' ? 'Long terme' : l === 'medium' ? 'Moyen terme' : 'Court terme'} {l === 'long' ? 'Long terme' : l === 'medium' ? 'Moyen terme' : 'Court terme'}
</span> </span>
</div> </div>
))} ))}
<span className="ml-auto text-xs text-slate-600">cliquer sur un événement pour naviguer</span> <div className="ml-auto flex items-center gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-white/20 border border-white/20" />
Zone d'overlap
</span>
<span>— cliquer sur un événement pour naviguer</span>
</div>
</div> </div>
{/* Scrollable frise */} {/* Frise */}
<div ref={scrollRef} className="overflow-x-auto overflow-y-hidden cursor-grab" style={{ height: totalHeight + 'px' }}> <div
<div className="relative" style={{ width: totalWidth + 'px', height: totalHeight + 'px' }}> ref={scrollRef}
className="overflow-x-auto overflow-y-hidden"
style={{ height: totalHeight + 'px', cursor: 'grab' }}
>
<div className="relative select-none" style={{ width: totalWidth + 'px', height: totalHeight + 'px' }}>
{/* Year grid lines */} {/* Year grid lines */}
{yearMarkers.map(m => ( {yearMarkers.map(m => (
<div key={m.label} className="absolute top-0 bottom-0 pointer-events-none" <div key={m.label}
style={{ left: m.x + 'px', borderLeft: '1px dashed rgba(255,255,255,0.07)', top: 0, bottom: 0, height: '100%' }} className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: m.day * SCALE, width: 1, background: 'rgba(255,255,255,0.05)', top: 0, height: '100%' }}
/> />
))} ))}
{/* Lane backgrounds */} {/* Level track backgrounds */}
{(['long', 'medium', 'short'] as const).map(l => ( {LEVEL_ORDER.map(l => (
<div key={l} <div key={l}
className="absolute" className="absolute left-0 right-0"
style={{ style={{
top: laneY(l) + 'px', top: levelY[l],
left: 0, right: 0, height: maxSubLanes[l] * (SUB_H + SUB_GAP) - SUB_GAP,
height: LANE_H + 'px', background: COLORS[l].track,
background: LEVEL_COLORS[l].track, borderRadius: 4,
opacity: 0.5, }}
/>
))}
{/* Overlap zone shading */}
{overlapZones.map((z, i) => (
<div key={i}
className="absolute pointer-events-none"
style={{
left: z.x,
top: z.laneTop,
width: z.w,
height: z.laneBot - z.laneTop,
background: COLORS[z.level as keyof typeof COLORS].overlap,
borderRadius: 3,
border: `1px solid ${COLORS[z.level as keyof typeof COLORS].border}33`,
}} }}
/> />
))} ))}
{/* Events */} {/* Events */}
{events.map(ev => { {events.map(ev => {
const level = ev.level as keyof typeof COLORS
const c = COLORS[level]
const sl = subLanesMap[level]?.get(ev.id) ?? 0
const x = toDay(ev.start_date) * SCALE const x = toDay(ev.start_date) * SCALE
const w = evWidth(ev) const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
const y = laneY(ev.level) const w = Math.max((endDay - toDay(ev.start_date)) * SCALE, 6)
const c = LEVEL_COLORS[ev.level] const y = levelY[level] + sl * (SUB_H + SUB_GAP)
const isSelected = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date) const isSelected = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date)
const isOngoing = !ev.end_date
return ( return (
<div <div
key={ev.id} key={ev.id}
onClick={() => onDateChange(ev.start_date)} onClick={() => onDateChange(ev.start_date)}
title={`${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' (en cours)'}`} title={`[${ev.level.toUpperCase()}] ${ev.name}\n${ev.start_date}${ev.end_date ? ' ' + ev.end_date : ' en cours'}`}
className="absolute flex items-center overflow-hidden rounded cursor-pointer transition-all" className="absolute overflow-hidden cursor-pointer transition-opacity"
style={{ style={{
left: x + 'px', left: x,
top: y + 3 + 'px', top: y + 2,
width: w + 'px', width: w,
height: LANE_H - 6 + 'px', height: SUB_H - 4,
background: isSelected background: isSelected ? c.border : c.bg,
? c.border
: c.bg,
border: `1px solid ${c.border}`, border: `1px solid ${c.border}`,
opacity: isSelected ? 1 : 0.8, borderRadius: 4,
zIndex: isSelected ? 10 : 1, opacity: isSelected ? 1 : 0.82,
zIndex: isSelected ? 10 : 2,
// Pill shape: if event ends cleanly, right end is rounded normally
// If ongoing, indicate continuation visually
backgroundImage: isOngoing && !isSelected
? `linear-gradient(90deg, ${c.bg} 85%, ${c.border}44 100%)`
: undefined,
}} }}
> >
{/* Event name text */}
<span <span
className="px-1.5 text-xs font-medium whitespace-nowrap select-none" className="absolute left-0 top-0 bottom-0 flex items-center px-1.5 font-medium whitespace-nowrap"
style={{ color: c.text, fontSize: '10px' }} style={{ color: c.text, fontSize: 10 }}
> >
{ev.name} {ev.name}
</span> </span>
{/* Ongoing arrow indicator */}
{isOngoing && (
<span
className="absolute right-0 top-0 bottom-0 flex items-center pr-1"
style={{ color: c.border, fontSize: 10 }}
>
</span>
)}
</div> </div>
) )
})} })}
{/* Today line */} {/* Today vertical line */}
<div <div
className="absolute top-0 pointer-events-none z-20" className="absolute pointer-events-none z-20"
style={{ style={{ left: todayX, top: 0, width: 2, height: totalHeight - AXIS_H, background: 'rgba(239,68,68,0.75)' }}
left: todayX + 'px',
top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px',
width: '2px',
background: 'rgba(239,68,68,0.8)',
}}
/> />
{/* Selected date line (if not today) */} {/* Selected date line */}
{refDate !== new Date().toISOString().split('T')[0] && ( {refDate !== TODAY && (
<div <div
className="absolute pointer-events-none z-20" className="absolute pointer-events-none z-20"
style={{ style={{
left: refX + 'px', left: refX,
top: 0, top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px', width: 1,
width: '1px', height: totalHeight - AXIS_H,
background: 'rgba(255,255,255,0.5)', borderLeft: '1.5px dashed rgba(255,255,255,0.45)',
borderLeft: '1px dashed rgba(255,255,255,0.5)',
}} }}
/> />
)} )}
{/* Year axis */} {/* Year axis */}
<div {yearMarkers.map(m => (
className="absolute left-0 right-0 flex" <div
style={{ top: PADDING_TOP + 3 * LANE_H + 2 + 'px', height: AXIS_H + 'px' }} key={m.label + 'lbl'}
> className="absolute text-xs text-slate-600 select-none"
{yearMarkers.map(m => ( style={{ left: m.day * SCALE + 4, top: totalHeight - AXIS_H + 6 }}
<div >
key={m.label} {m.label}
className="absolute text-xs text-slate-500 select-none" </div>
style={{ left: m.x + 4 + 'px', top: '4px' }} ))}
>
{m.label}
</div>
))}
</div>
{/* Today label */} {/* Today label */}
<div <div
className="absolute text-xs text-red-400 font-medium select-none pointer-events-none z-20" className="absolute text-xs text-red-400 font-medium select-none pointer-events-none z-20"
style={{ left: todayX + 4 + 'px', top: PADDING_TOP + 3 * LANE_H + 6 + 'px' }} style={{ left: todayX + 4, top: totalHeight - AXIS_H + 6 }}
> >
▲ aujourd'hui ▲ aujourd'hui
</div> </div>

View File

@@ -104,6 +104,25 @@ function relevantAssets(ev: MarketEvent | null, level: string): string[] {
// ── Cells ────────────────────────────────────────────────────────────────────── // ── 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({ function CellGeo({
ev, level, refDate, ev, level, refDate,
}: { }: {
@@ -116,6 +135,7 @@ function CellGeo({
</div> </div>
) )
const days = daysSince(refDate, ev.start_date) const days = daysSince(refDate, ev.start_date)
const absorption = (ev as any).absorption_pct as number | null
return ( return (
<div className="p-3 space-y-2 h-full"> <div className="p-3 space-y-2 h-full">
<div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div> <div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div>
@@ -129,18 +149,39 @@ function CellGeo({
{ev.market_impact && ( {ev.market_impact && (
<p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p> <p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p>
)} )}
<AbsorptionBar pct={absorption} />
</div> </div>
) )
} }
// MA period per temporal level
const MA_PERIOD: Record<string, number> = { long: 100, medium: 20, short: 10 }
function CellMarkets({ function CellMarkets({
ev, level, quotes, ev, level, quotes, maData,
}: { }: {
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null 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 ( return (
<div className="p-3 space-y-1.5 h-full"> <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 => { {assets.map(sym => {
const q = quotes?.[sym] const q = quotes?.[sym]
if (!q) return ( if (!q) return (
@@ -151,19 +192,32 @@ function CellMarkets({
) )
const up = q.change_pct > 0.1 const up = q.change_pct > 0.1
const dn = 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 ( return (
<div key={sym} className="flex items-center justify-between py-0.5"> <div key={sym} className="py-0.5">
<span className="text-xs text-slate-400 truncate max-w-[6rem]">{q.name || sym}</span> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 shrink-0"> <span className="text-xs text-slate-400 truncate max-w-[7rem]">{q.name || sym}</span>
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span> <div className="flex items-center gap-1.5 shrink-0">
<span className={clsx( <span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
'text-xs flex items-center gap-0.5 tabular-nums w-14 justify-end', <span className={clsx(
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500' '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)}% {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" />}
</span> {q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
</span>
</div>
</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> </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[] { function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
const horizons: Record<string, number> = { long: 90, medium: 30, short: 7 } // Exclusive ranges so each level shows different events:
const horizon = horizons[level] ?? 30 // 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 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)) .sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 4) .slice(0, 4)
} }
@@ -288,7 +353,7 @@ function CellCalendar({
// ── Temporal Row ─────────────────────────────────────────────────────────────── // ── Temporal Row ───────────────────────────────────────────────────────────────
function TemporalRow({ function TemporalRow({
level, ev, quotes, macro, calEvents, refDate, level, ev, quotes, macro, calEvents, refDate, maData,
}: { }: {
level: keyof typeof LEVEL level: keyof typeof LEVEL
ev: MarketEvent | null ev: MarketEvent | null
@@ -296,6 +361,7 @@ function TemporalRow({
macro: MacroRegime | null macro: MacroRegime | null
calEvents: CalEvent[] | null calEvents: CalEvent[] | null
refDate: string refDate: string
maData: Record<string, number | null>
}) { }) {
const cfg = LEVEL[level] const cfg = LEVEL[level]
@@ -345,7 +411,7 @@ function TemporalRow({
<ArrowRight className="w-3 h-3" /> <ArrowRight className="w-3 h-3" />
</Link> </Link>
</div> </div>
<CellMarkets ev={ev} level={level} quotes={quotes} /> <CellMarkets ev={ev} level={level} quotes={quotes} maData={maData} />
</div> </div>
{/* Pilier 3: Régimes Macro */} {/* Pilier 3: Régimes Macro */}
@@ -378,12 +444,23 @@ function TemporalRow({
// ── Main ─────────────────────────────────────────────────────────────────────── // ── 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() { export default function ExternalSnapshot() {
const [refDate, setRefDate] = useState(todayStr()) const [refDate, setRefDate] = useState(todayStr())
const [ctx, setCtx] = useState<DayContext | null>(null) const [ctx, setCtx] = useState<DayContext | null>(null)
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null) const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
const [macro, setMacro] = useState<MacroRegime | null>(null) const [macro, setMacro] = useState<MacroRegime | null>(null)
const [calEvents, setCalEvents] = useState<CalEvent[] | 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(() => { useEffect(() => {
api.get(`/timeline/day/${refDate}`) 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('/market/macro-regime').then(r => setMacro(r.data)).catch(() => {})
api.get('/geo/calendar').then(r => setCalEvents(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) => { const navigate = (days: number) => {
@@ -453,9 +560,9 @@ export default function ExternalSnapshot() {
</div> </div>
{/* 3 temporal rows */} {/* 3 temporal rows */}
<TemporalRow level="long" ev={evLong} 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} /> <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} /> <TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.short} />
{/* Footer links */} {/* Footer links */}
<div className="flex items-center gap-4 text-xs text-slate-600 pt-2"> <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 { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' 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 axios from 'axios'
import clsx from 'clsx' import clsx from 'clsx'
import TimelineFrise from '../components/TimelineFrise' import TimelineFrise from '../components/TimelineFrise'
import EventManager from '../components/EventManager'
const api = axios.create({ baseURL: '/api' }) const api = axios.create({ baseURL: '/api' })
@@ -189,6 +190,7 @@ export default function Timeline() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [bootstrapped, setBootstrapped] = useState(false) const [bootstrapped, setBootstrapped] = useState(false)
const [showManager, setShowManager] = useState(false)
const fetchDay = useCallback(async (d: string) => { const fetchDay = useCallback(async (d: string) => {
setLoading(true) setLoading(true)
@@ -264,12 +266,24 @@ export default function Timeline() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={() => bootstrap()} 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" 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" /> <RefreshCw className="w-3 h-3" />
Réinitialiser événements Réinitialiser
</button> </button>
</div> </div>
</div> </div>
@@ -361,37 +375,9 @@ export default function Timeline() {
<div className="text-center text-slate-500 py-12">Aucun contexte disponible</div> <div className="text-center text-slate-500 py-12">Aucun contexte disponible</div>
)} )}
{/* Event list (compact) */} {/* Event Manager */}
{allEvents.length > 0 && ( {showManager && (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-4"> <EventManager events={allEvents as any} onRefresh={fetchEvents} />
<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>
)} )}
</div> </div>
) )