diff --git a/backend/routers/eco.py b/backend/routers/eco.py index 2b19d64..68498d2 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -192,6 +192,104 @@ def list_eco_events( return {"total": total, "offset": offset, "limit": limit, "events": events} +# ── Upcoming releases ──────────────────────────────────────────────────────── + +# Typical publication lag (days) after the end of the reference period +_RELEASE_LAG: Dict[str, Dict[str, Any]] = { + "PAYEMS": {"freq_days": 30, "lag_days": 35, "note": "1er vendredi du mois"}, + "UNRATE": {"freq_days": 30, "lag_days": 35, "note": "Avec NFP"}, + "CPIAUCSL": {"freq_days": 30, "lag_days": 15, "note": "Mi-mois"}, + "CPILFESL": {"freq_days": 30, "lag_days": 15, "note": "Avec CPI"}, + "PCEPILFE": {"freq_days": 30, "lag_days": 30, "note": "Fin de mois"}, + "FEDFUNDS": {"freq_days": 45, "lag_days": 45, "note": "Réunion FOMC (~8/an)"}, + "ICSA": {"freq_days": 7, "lag_days": 4, "note": "Chaque jeudi"}, + "GDPC1": {"freq_days": 90, "lag_days": 30, "note": "Estimation avancée BEA"}, + "BAMLH0A0HYM2": {"freq_days": 7, "lag_days": 1, "note": "Hebdomadaire"}, + "T10Y2Y": {"freq_days": 7, "lag_days": 1, "note": "Hebdomadaire"}, + "T10Y3M": {"freq_days": 7, "lag_days": 1, "note": "Hebdomadaire"}, +} + + +@router.get("/upcoming") +def upcoming_events() -> List[Dict[str, Any]]: + """Estimate next release dates for each series based on last stored date + typical lag.""" + from services.database import get_conn + from services.fred_bootstrap import FRED_SERIES + from datetime import date, timedelta + + conn = get_conn() + today = date.today() + upcoming = [] + + for sid, lag_info in _RELEASE_LAG.items(): + meta = FRED_SERIES.get(sid) + if not meta: + continue + try: + row = conn.execute( + "SELECT MAX(event_date), actual_value, actual_unit, surprise_direction " + "FROM economic_events WHERE series_id=?", + (sid,), + ).fetchone() + if not row or not row[0]: + # No data in DB yet — still show as "not loaded" + upcoming.append({ + "series_id": sid, + "name": meta["name"], + "note": lag_info["note"], + "category": meta["category"], + "last_release": None, + "last_value": None, + "last_unit": meta["unit"], + "last_direction": None, + "next_expected": None, + "days_until": None, + "status": "no_data", + }) + continue + + last_date = date.fromisoformat(row[0]) + freq_days = lag_info["freq_days"] + lag_days = lag_info["lag_days"] + + # Next expected = last ref period end + lag + next_release = last_date + timedelta(days=freq_days + lag_days) + + days_until = (next_release - today).days + + if days_until < -freq_days: + # Very overdue — likely bootstrap missing recent data + status = "overdue" + elif days_until < 0: + status = "due_soon" # probably released, not yet in DB + elif days_until <= 7: + status = "imminent" + elif days_until <= 30: + status = "upcoming" + else: + status = "scheduled" + + upcoming.append({ + "series_id": sid, + "name": meta["name"], + "note": lag_info["note"], + "category": meta["category"], + "last_release": str(last_date), + "last_value": round(float(row[1]), 3) if row[1] is not None else None, + "last_unit": row[2] or meta["unit"], + "last_direction": row[3], + "next_expected": str(next_release), + "days_until": days_until, + "status": status, + }) + except Exception as e: + logger.debug(f"[eco/upcoming] {sid}: {e}") + + conn.close() + upcoming.sort(key=lambda x: (x["next_expected"] or "9999", x["series_id"])) + return upcoming + + # ── DB count summary ────────────────────────────────────────────────────────── @router.get("/status") diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index e19f0ec..ff76826 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -308,6 +308,103 @@ function BootstrapPanel({ onDone }: { onDone: () => void }) { ) } +// ── Upcoming releases panel ─────────────────────────────────────────────────── + +interface UpcomingEvent { + series_id: string + name: string + note: string + category: string + last_release: string | null + last_value: number | null + last_unit: string + last_direction: string | null + next_expected: string | null + days_until: number | null + status: string +} + +const STATUS_STYLE: Record = { + imminent: 'text-orange-400 border-orange-700/40 bg-orange-900/10', + due_soon: 'text-yellow-400 border-yellow-700/40 bg-yellow-900/10', + upcoming: 'text-blue-400 border-blue-700/30', + scheduled: 'text-slate-500 border-slate-700/30', + overdue: 'text-red-400 border-red-700/30', + no_data: 'text-slate-600 border-slate-700/20', +} + +const STATUS_LABEL: Record = { + imminent: 'Cette semaine', + due_soon: 'Attendu', + upcoming: 'Ce mois', + scheduled: 'Planifié', + overdue: 'En retard', + no_data: 'Pas en base', +} + +function UpcomingPanel() { + const [events, setEvents] = useState([]) + + useEffect(() => { + fetch('/api/eco/upcoming') + .then(r => r.json()) + .then(setEvents) + .catch(() => {}) + }, []) + + const visible = events.filter(e => e.status !== 'scheduled' || e.days_until !== null && e.days_until < 45) + + return ( +
+
+ Prochaines publications +
+ {visible.length === 0 ? ( +
Aucune donnée
+ ) : ( +
+ {visible.map(ev => ( +
+
+
{ev.name}
+
{ev.note}
+ {ev.last_value !== null && ( +
+ Dernier : {ev.last_value.toFixed(ev.last_unit === '%' || ev.last_unit === 'pp' ? 2 : 0)} {ev.last_unit} + {ev.last_direction && ev.last_direction !== 'neutral' && ( + + {ev.last_direction === 'bullish' ? '▲' : '▼'} + + )} +
+ )} +
+
+
+ {STATUS_LABEL[ev.status]} +
+ {ev.next_expected && ( +
{ev.next_expected.slice(5)}
+ )} + {ev.days_until !== null && ev.days_until >= 0 && ( +
J−{ev.days_until}
+ )} +
+
+ ))} +
+ )} +
* Dates estimées d'après fréquence + délai typique
+
+ ) +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function CalendarPage() { @@ -645,6 +742,9 @@ export default function CalendarPage() { {/* ── Right sidebar ── */}
+ {/* Upcoming releases */} + + {/* Séries disponibles */} {ecoStatus && ecoStatus.total > 0 && (