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>
This commit is contained in:
caty21
2026-07-08 22:12:30 +02:00
co-authored by Claude Sonnet 5
parent 274c7f534e
commit f5417dec17
4 changed files with 320 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
// app/api/scenario-spreads/route.ts
// Spreads EFFR/SOFR utilisés par l'onglet Scénario (simulateur type CME
// SOFRWatch) pour traduire une fourchette cible Fed hypothétique en niveau de
// SOFR implicite : SOFR ≈ lowerBound + (EFFR lowerBound) + (SOFR EFFR).
//
// CME SOFRWatch calcule ces deux spreads à partir des prix des futures SOFR
// 1 mois vs Fed Funds (SR1ZQ), des données de marché propriétaires. On les
// approxime ici avec la moyenne réalisée historique EFFR/SOFR (FRED, gratuit),
// ce qui est un proxy raisonnable tant que la Fed n'a pas bougé récemment
// (spread stable), mais reste un proxy — pas un prix forward-looking.
import { NextResponse } from "next/server";
import rateDecisionsData from "@/data/rate_decisions.json";
export const dynamic = "force-dynamic";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
const REVALIDATE = 6 * 3600;
interface FredObs { date: string; value: number; }
async function fredObs(seriesId: string, apiKey: string, limit = 60): Promise<FredObs[]> {
const url = `${FRED_BASE}?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=${limit}`;
try {
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const json = await res.json();
return (json.observations ?? [])
.filter((o: { value: string }) => o.value !== ".")
.map((o: { date: string; value: string }) => ({ date: o.date, value: parseFloat(o.value) }));
} catch { return []; }
}
export interface ScenarioSpreads {
asOf: string;
lowerBound: number | null;
upperBound: number | null;
effrSpreadBps: number | null; // EFFR lowerBound, moyenne réalisée
sofrEffrSpreadBps: number | null; // SOFR EFFR, moyenne réalisée (apparié par date)
windowDays: number;
note: string;
}
export async function GET() {
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
const [effr, sofr] = await Promise.all([
fredObs("EFFR", key, 60),
fredObs("SOFR", key, 60),
]);
if (!effr.length || !sofr.length) {
return NextResponse.json({ error: "Données FRED EFFR/SOFR indisponibles" }, { status: 502 });
}
const rateDec = (rateDecisionsData as Array<{ decisions: Record<string, { current: number }> }>)[0];
const upperBound = rateDec?.decisions?.USD?.current ?? null;
const lowerBound = upperBound !== null ? parseFloat((upperBound - 0.25).toFixed(2)) : null;
const effrSpread = lowerBound !== null
? effr.reduce((s, o) => s + (o.value - lowerBound), 0) / effr.length
: null;
const effrByDate = new Map(effr.map(o => [o.date, o.value]));
const pairedDiffs = sofr
.filter(o => effrByDate.has(o.date))
.map(o => o.value - effrByDate.get(o.date)!);
const sofrEffrSpread = pairedDiffs.length
? pairedDiffs.reduce((s, d) => s + d, 0) / pairedDiffs.length
: null;
const data: ScenarioSpreads = {
asOf: new Date().toISOString().slice(0, 10),
lowerBound,
upperBound,
effrSpreadBps: effrSpread !== null ? Math.round(effrSpread * 100) : null,
sofrEffrSpreadBps: sofrEffrSpread !== null ? Math.round(sofrEffrSpread * 100) : null,
windowDays: effr.length,
note: "Spreads = moyennes réalisées historiques EFFR/SOFR (FRED, fenêtre glissante ~60j) — approximation du spread forward-looking SR1ZQ utilisé par CME SOFRWatch (données de marché propriétaires non accessibles gratuitement).",
};
return NextResponse.json(data, { headers: { "Cache-Control": "s-maxage=21600, stale-while-revalidate=43200" } });
}