feat: upcoming economic releases panel on Calendar page
- eco.py: GET /api/eco/upcoming — estimates next release date per series from last stored date + frequency + typical publication lag; returns status (imminent/due_soon/upcoming/scheduled/overdue/no_data) - CalendarPage.tsx: UpcomingPanel component in right sidebar showing next expected dates, J-N countdown, last value + direction signal; color-coded by urgency (orange=cette semaine, yellow=attendu, blue=ce mois) Dates are approximate (freq + typical lag), not official FRED schedule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,104 @@ def list_eco_events(
|
|||||||
return {"total": total, "offset": offset, "limit": limit, "events": 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 ──────────────────────────────────────────────────────────
|
# ── DB count summary ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<UpcomingEvent[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3 text-orange-400" /> Prochaines publications
|
||||||
|
</div>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-600 py-2 text-center">Aucune donnée</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{visible.map(ev => (
|
||||||
|
<div key={ev.series_id} className={clsx(
|
||||||
|
'flex items-start justify-between gap-1 px-2 py-1 rounded border text-xs',
|
||||||
|
STATUS_STYLE[ev.status] ?? STATUS_STYLE.scheduled,
|
||||||
|
)}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-white truncate">{ev.name}</div>
|
||||||
|
<div className="text-slate-500">{ev.note}</div>
|
||||||
|
{ev.last_value !== null && (
|
||||||
|
<div className="text-slate-500">
|
||||||
|
Dernier : {ev.last_value.toFixed(ev.last_unit === '%' || ev.last_unit === 'pp' ? 2 : 0)} {ev.last_unit}
|
||||||
|
{ev.last_direction && ev.last_direction !== 'neutral' && (
|
||||||
|
<span className={clsx('ml-1', ev.last_direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{ev.last_direction === 'bullish' ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className={clsx(
|
||||||
|
'text-xs font-semibold',
|
||||||
|
STATUS_STYLE[ev.status]?.split(' ')[0],
|
||||||
|
)}>
|
||||||
|
{STATUS_LABEL[ev.status]}
|
||||||
|
</div>
|
||||||
|
{ev.next_expected && (
|
||||||
|
<div className="text-slate-600 font-mono">{ev.next_expected.slice(5)}</div>
|
||||||
|
)}
|
||||||
|
{ev.days_until !== null && ev.days_until >= 0 && (
|
||||||
|
<div className="text-slate-600">J−{ev.days_until}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-slate-700 mt-2">* Dates estimées d'après fréquence + délai typique</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
@@ -645,6 +742,9 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* ── Right sidebar ── */}
|
{/* ── Right sidebar ── */}
|
||||||
<div className="col-span-1 space-y-4">
|
<div className="col-span-1 space-y-4">
|
||||||
|
{/* Upcoming releases */}
|
||||||
|
<UpcomingPanel />
|
||||||
|
|
||||||
{/* Séries disponibles */}
|
{/* Séries disponibles */}
|
||||||
{ecoStatus && ecoStatus.total > 0 && (
|
{ecoStatus && ecoStatus.total > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user