mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-08-08 18:27:45 +00:00
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:
@@ -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 (SR1−ZQ), 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 SR1−ZQ 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" } });
|
||||
}
|
||||
+6
-2
@@ -13,6 +13,7 @@ import NewsTab from "@/components/NewsTab";
|
||||
import CotTab from "@/components/CotTab";
|
||||
import IdeesTab from "@/components/IdeesTab";
|
||||
import CentralBankSourcesTab from "@/components/CentralBankSourcesTab";
|
||||
import ScenarioTab from "@/components/ScenarioTab";
|
||||
import { TvAdvancedChart } from "@/components/TvChart";
|
||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||
import type { NewsItem } from "@/app/api/news/route";
|
||||
@@ -28,7 +29,7 @@ export default function Dashboard() {
|
||||
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
||||
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
||||
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "news" | "cot" | "markets" | "idees" | "cbsources">("dashboard");
|
||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "news" | "cot" | "markets" | "idees" | "cbsources" | "scenario">("dashboard");
|
||||
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [cotHistory, setCotHistory] = useState<CotHistory | null>(null);
|
||||
@@ -341,7 +342,7 @@ export default function Dashboard() {
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-0 border-b border-slate-800 mb-4">
|
||||
{(["dashboard", "markets", "idees", "calendar", "cbsources", "news", "cot"] as const).map((tab) => (
|
||||
{(["dashboard", "markets", "idees", "calendar", "cbsources", "scenario", "news", "cot"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -356,6 +357,7 @@ export default function Dashboard() {
|
||||
: tab === "idees" ? "💡 Idées"
|
||||
: tab === "calendar" ? "📅 Calendrier"
|
||||
: tab === "cbsources" ? "🏛️ Banques centrales"
|
||||
: tab === "scenario" ? "🎛️ Scénario"
|
||||
: tab === "news" ? "📰 Actualités"
|
||||
: "📊 COT"}
|
||||
</button>
|
||||
@@ -613,6 +615,8 @@ export default function Dashboard() {
|
||||
|
||||
{activeTab === "cbsources" && <CentralBankSourcesTab />}
|
||||
|
||||
{activeTab === "scenario" && <ScenarioTab />}
|
||||
|
||||
{activeTab === "news" && (
|
||||
<NewsTab items={newsItems} loading={newsLoading} onRefresh={refreshNews} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"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 (SR1−ZQ) — 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'outil déduit où 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 EFFR−borne basse : <span className="text-amber-400 font-semibold font-mono">{spreads.effrSpreadBps! >= 0 ? "+" : ""}{spreads.effrSpreadBps}bps</span></span>
|
||||
<span className="text-slate-400">Spread SOFR−EFFR : <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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user