Files
forex-dashboard/components/ScenarioTab.tsx
T
caty21andClaude Sonnet 5 f5417dec17 feat: onglet Scénario — simulateur type CME SOFRWatch (taux → SOFR implicite)
Nouvel onglet principal "Scénario", logique inversée par rapport aux autres
onglets Taux : au lieu de déduire une probabilité depuis les prix de marché,
l'utilisateur choisit lui-même un scénario de hikes/cuts à chaque réunion FOMC
et l'outil déduit où devrait se situer le SOFR implicite si ce scénario se
réalisait — exactement la logique de CME SOFRWatch.

Méthodologie (2 spreads, comme SOFRWatch) :
  SOFR implicite ≈ borne basse Fed + (EFFR − borne basse) + (SOFR − EFFR)
CME calcule ces spreads depuis les prix forward des futures SOFR 1 mois vs Fed
Funds (SR1−ZQ, données de marché propriétaires). On les approxime avec la
moyenne réalisée historique EFFR/SOFR (FRED, gratuit) — limite clairement
documentée dans l'UI et le code, ce n'est pas un prix forward-looking.

- app/api/scenario-spreads/route.ts : calcule les 2 spreads depuis FRED
  (EFFR, SOFR, fenêtre ~60j) + la fourchette Fed actuelle (rate_decisions.json).
- components/ScenarioTab.tsx : constructeur de scénario par réunion FOMC
  (réutilise les dates déjà maintenues dans rate-probabilities.json), chart
  comparatif "Votre scénario" vs "Marché (OIS)" sur l'échelle SOFR.

Vérifié en direct : les spreads calculés (EFFR +13bps, SOFR-EFFR -1bps)
donnent un SOFR implicite actuel de 3.62%, qui correspond exactement au SOFR
réel du jour — et les clics sur le constructeur de scénario cumulent
correctement (step-line vérifié visuellement sur 2 hikes successifs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:12:30 +02:00

232 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// components/ScenarioTab.tsx
// Simulateur "scénario → prix implicite", façon CME SOFRWatch — logique
// INVERSÉE par rapport aux autres onglets Taux (qui déduisent une probabilité
// depuis les prix de marché) : ici, l'utilisateur choisit lui-même un scénario
// de hikes/cuts à chaque réunion FOMC, et l'outil en déduit où devrait se
// situer le SOFR implicite si ce scénario se réalisait.
//
// Lien taux cible Fed → SOFR (méthodologie CME SOFRWatch, en 2 spreads) :
// 1. EFFR borne basse de la fourchette cible (observé)
// 2. SOFR EFFR (observé)
// SOFR implicite ≈ borne basse + spread 1 + spread 2
// CME calcule ces spreads à partir des prix forward des futures SOFR 1 mois
// vs Fed Funds (SR1ZQ) — données de marché propriétaires. On les approxime
// ici avec la moyenne réalisée historique (FRED, gratuit) : voir /api/scenario-spreads.
import { useEffect, useState, useMemo } from "react";
import { RefreshCw, RotateCcw, Info } from "lucide-react";
import {
LineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Legend,
} from "recharts";
interface ScenarioSpreads {
asOf: string;
lowerBound: number | null;
upperBound: number | null;
effrSpreadBps: number | null;
sofrEffrSpreadBps: number | null;
windowDays: number;
note: string;
}
interface UsdMeeting {
dateIso: string;
label: string;
impliedRate: number; // convention upper bound, cf. rate_decisions.json
}
const STEPS = [-50, -25, 0, 25, 50] as const;
function fmtDate(iso: string): string {
const d = new Date(iso + "T12:00:00Z");
return d.toLocaleDateString("fr-FR", { month: "short", day: "numeric" });
}
export default function ScenarioTab() {
const [spreads, setSpreads] = useState<ScenarioSpreads | null>(null);
const [meetings, setMeetings] = useState<UsdMeeting[]>([]);
const [deltas, setDeltas] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const [spreadsRes, rpRes] = await Promise.all([
fetch("/api/scenario-spreads", { cache: "no-store" }).then(r => r.json()),
fetch("/api/rate-probabilities", { cache: "no-store" }).then(r => r.json()),
]);
if (spreadsRes?.error) throw new Error(spreadsRes.error);
setSpreads(spreadsRes as ScenarioSpreads);
const usd = rpRes?.data?.USD;
const ms: UsdMeeting[] = (usd?.meetings ?? []).map((m: { dateIso: string; label: string; impliedRate: number }) => ({
dateIso: m.dateIso, label: m.label, impliedRate: m.impliedRate,
}));
setMeetings(ms);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur de chargement");
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const setDelta = (dateIso: string, bps: number) => setDeltas(prev => ({ ...prev, [dateIso]: bps }));
const resetAll = () => setDeltas({});
const chartData = useMemo(() => {
if (!spreads || spreads.lowerBound === null || !meetings.length) return [];
const spreadTotal = (spreads.effrSpreadBps ?? 0) / 100 + (spreads.sofrEffrSpreadBps ?? 0) / 100;
let cumulLower = spreads.lowerBound;
return meetings.map(m => {
const delta = deltas[m.dateIso] ?? 0;
cumulLower = parseFloat((cumulLower + delta / 100).toFixed(4));
const scenarioSofr = parseFloat((cumulLower + spreadTotal).toFixed(4));
// Marché (OIS) : impliedRate suit la convention upper bound → on
// convertit en borne basse pour rester sur la même échelle SOFR.
const marketLower = parseFloat((m.impliedRate - 0.25).toFixed(4));
const marketSofr = parseFloat((marketLower + spreadTotal).toFixed(4));
return {
label: m.label,
dateIso: m.dateIso,
scenario: scenarioSofr,
marche: marketSofr,
cumulBps: Math.round((cumulLower - spreads.lowerBound!) * 100),
};
});
}, [spreads, meetings, deltas]);
const spreadTotalBps = spreads ? (spreads.effrSpreadBps ?? 0) + (spreads.sofrEffrSpreadBps ?? 0) : null;
const currentImpliedSofr = spreads?.lowerBound !== null && spreadTotalBps !== null && spreads
? parseFloat((spreads.lowerBound + spreadTotalBps / 100).toFixed(2))
: null;
return (
<div className="space-y-3">
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-3">
<div className="flex items-center justify-between mb-1">
<h2 className="text-sm font-semibold text-slate-200">Scénario simulateur taux SOFR implicite</h2>
<div className="flex items-center gap-2">
<button onClick={resetAll} className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-800 hover:border-slate-600 rounded-md px-2 py-1 transition-colors">
<RotateCcw size={11} /> Réinitialiser
</button>
<button onClick={load} disabled={loading} className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-800 hover:border-slate-600 rounded-md px-2 py-1 transition-colors disabled:opacity-50">
<RefreshCw size={11} className={loading ? "animate-spin" : ""} /> Rafraîchir
</button>
</div>
</div>
<p className="text-[10px] text-slate-600 leading-relaxed">
Logique inversée par rapport aux autres onglets Taux (qui déduisent une probabilité depuis les prix de marché) :
choisissez vous-même un scénario de hikes/cuts à chaque réunion FOMC, l&apos;outil déduit devrait se situer le SOFR
implicite si ce scénario se réalisait façon CME SOFRWatch.
</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-[12px] text-red-400">
{error}
</div>
)}
{loading && !spreads ? (
<div className="flex items-center justify-center py-16 text-slate-600 text-sm">Chargement</div>
) : spreads && meetings.length > 0 ? (
<>
{/* Spreads utilisés */}
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-3">
<div className="flex items-center gap-1.5 mb-2">
<Info size={11} className="text-slate-600" />
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Fourchette Fed actuelle SOFR</span>
</div>
<div className="flex items-center gap-4 flex-wrap text-[11px]">
<span className="text-slate-400">Fourchette : <span className="text-slate-200 font-semibold font-mono">{spreads.lowerBound?.toFixed(2)}{spreads.upperBound?.toFixed(2)}%</span></span>
<span className="text-slate-400">Spread EFFRborne basse : <span className="text-amber-400 font-semibold font-mono">{spreads.effrSpreadBps! >= 0 ? "+" : ""}{spreads.effrSpreadBps}bps</span></span>
<span className="text-slate-400">Spread SOFREFFR : <span className="text-sky-400 font-semibold font-mono">{spreads.sofrEffrSpreadBps! >= 0 ? "+" : ""}{spreads.sofrEffrSpreadBps}bps</span></span>
<span className="text-slate-400">SOFR implicite actuel : <span className="text-slate-100 font-bold font-mono">{currentImpliedSofr?.toFixed(2)}%</span></span>
</div>
<p className="text-[9px] text-slate-700 mt-2 leading-snug">{spreads.note}</p>
</div>
{/* Constructeur de scénario */}
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-3">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Votre scénario par réunion FOMC</span>
<div className="mt-2 space-y-1.5">
{meetings.map(m => {
const delta = deltas[m.dateIso] ?? 0;
return (
<div key={m.dateIso} className="flex items-center gap-2">
<span className="text-[11px] text-slate-400 w-[52px] shrink-0 font-mono">{fmtDate(m.dateIso)}</span>
<div className="flex gap-1">
{STEPS.map(s => (
<button
key={s}
onClick={() => setDelta(m.dateIso, s)}
className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border transition-colors ${
delta === s
? s < 0 ? "bg-sky-500/20 border-sky-500/50 text-sky-400"
: s > 0 ? "bg-red-500/20 border-red-500/50 text-red-400"
: "bg-slate-700 border-slate-500 text-white"
: "border-slate-700/60 text-slate-500 hover:text-white"
}`}
>
{s === 0 ? "Hold" : `${s > 0 ? "+" : ""}${s}`}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
{/* Chart comparatif */}
<div className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-3">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">SOFR implicite votre scénario vs marché (OIS)</span>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData} margin={{ top: 12, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#1e293b" strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 9, fill: "#64748b" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: "#64748b" }} axisLine={false} tickLine={false} width={44}
tickFormatter={(v: number) => `${v.toFixed(2)}%`} domain={["dataMin - 0.1", "dataMax + 0.1"]} />
<Tooltip
content={({ label, payload }) => {
if (!payload?.length) return null;
const items = payload as { dataKey: string; value: number; color: string }[];
return (
<div style={{ background: "rgba(8,14,28,0.97)", border: "1px solid #1e293b", borderRadius: 10, padding: "8px 12px" }}>
<p style={{ color: "#475569", fontSize: 9, margin: "0 0 6px", fontWeight: 700, textTransform: "uppercase" }}>{label}</p>
{items.map(it => (
<p key={it.dataKey} style={{ color: it.color, fontSize: 11, margin: "2px 0", fontWeight: 700 }}>
{it.dataKey === "scenario" ? "Votre scénario" : "Marché (OIS)"} : {it.value.toFixed(3)}%
</p>
))}
</div>
);
}}
/>
<Legend
formatter={(value: string) => (
<span style={{ fontSize: 10, color: "#94a3b8" }}>{value === "scenario" ? "Votre scénario" : "Marché (OIS)"}</span>
)}
/>
<Line type="stepAfter" dataKey="marche" stroke="#64748b" strokeWidth={1.5} strokeDasharray="5 3" dot={{ r: 2.5, fill: "#1e293b", stroke: "#64748b" }} />
<Line type="stepAfter" dataKey="scenario" stroke="#f59e0b" strokeWidth={2} dot={{ r: 3, fill: "#1e293b", stroke: "#f59e0b" }} />
</LineChart>
</ResponsiveContainer>
</div>
</>
) : (
<div className="text-center py-12 text-slate-600 text-sm">Données indisponibles pour le moment.</div>
)}
</div>
);
}