mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37: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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user