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:
@@ -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"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
434
frontend/src/components/EventManager.tsx
Normal file
434
frontend/src/components/EventManager.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user