diff --git a/.github/workflows/fetch-rate-data.yml b/.github/workflows/fetch-rate-data.yml index c63e19e..8a9aebf 100644 --- a/.github/workflows/fetch-rate-data.yml +++ b/.github/workflows/fetch-rate-data.yml @@ -5,6 +5,9 @@ on: - cron: '0 * * * *' # toutes les heures workflow_dispatch: # déclenchement manuel +permissions: + contents: write + jobs: fetch: runs-on: ubuntu-latest @@ -12,12 +15,13 @@ jobs: - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true - uses: actions/setup-node@v4 with: node-version: '20' - - name: Fetch rate probabilities from rateprobability.com + - name: Fetch rate probabilities (CME FedWatch / Investing.com / InvestingLive) run: node .github/scripts/fetch-rate-data.mjs - name: Commit updated data (if changed) diff --git a/Market-Signal-Finder b/Market-Signal-Finder deleted file mode 160000 index 961d5ae..0000000 --- a/Market-Signal-Finder +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 961d5ae111071db098500867dc0e177d726760d0 diff --git a/app/page.tsx b/app/page.tsx index c13641b..7fa1765 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -50,7 +50,7 @@ export default function Dashboard() { const [globalMacroSlide, setGlobalMacroSlide] = useState<"mon"|"infl"|"cro"|"empl">("mon"); const [globalCardTab, setGlobalCardTab] = useState<"overview"|"mispricing"|"focus">("overview"); const [globalSignauxSlide, setGlobalSignauxSlide] = useState<"ois"|"cot"|"sent">("ois"); - const [globalOisChartTab, setGlobalOisChartTab] = useState<"curve"|"implied"|"scenarios">("scenarios"); + const [globalOisChartTab, setGlobalOisChartTab] = useState<"curve"|"probas"|"meetings">("curve"); const [macroSyncEnabled, setMacroSyncEnabled] = useState(false); // ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ────── diff --git a/components/CurrencyCard.tsx b/components/CurrencyCard.tsx index 0130ca3..e318d38 100644 --- a/components/CurrencyCard.tsx +++ b/components/CurrencyCard.tsx @@ -62,8 +62,8 @@ interface Props { onCardTabChange?: (id: "overview" | "mispricing" | "focus") => void; syncSignauxSlide?: "ois" | "cot" | "sent"; onSignauxSlideChange?: (id: "ois" | "cot" | "sent") => void; - syncOisChartTab?: "curve" | "implied" | "scenarios"; - onOisChartTabChange?: (id: "curve" | "implied" | "scenarios") => void; + syncOisChartTab?: "curve" | "probas" | "meetings"; + onOisChartTabChange?: (id: "curve" | "probas" | "meetings") => void; isLoading?: boolean; } @@ -477,14 +477,25 @@ function SourcesPopup({ currency, onClose }: { currency: string; onClose: () => // Bloc OIS enrichi : summary (Current Rate → Expected, Next Meeting, Most Likely/Alt) // + 3 onglets graphiques : Rate Curve / Implied Points / Scénarios +const STIR_INSTRUMENT: Partial> = { + USD: { instrument: "SOFR 1M Futures", exchange: "CME", convention: "mensuel" }, + EUR: { instrument: "€STR OIS Swaps", exchange: "ECB / OTC", convention: "meeting-date", note: "Euribor 3M comme proxy liquide" }, + GBP: { instrument: "SONIA 1M Futures", exchange: "ICE", convention: "mensuel" }, + JPY: { instrument: "TONA OIS Swaps", exchange: "OTC", convention: "meeting-date", note: "Faible liquidité — proxy OSE TIBOR" }, + CHF: { instrument: "SARON Futures", exchange: "Eurex", convention: "4×/an (trim.)" }, + CAD: { instrument: "CORRA 1M Futures", exchange: "MX", convention: "mensuel" }, + AUD: { instrument: "ASX 30-Day IB Futures", exchange: "ASX", convention: "mensuel" }, + NZD: { instrument: "OIS NZD (swaps)", exchange: "ASX OTC / Bloomberg NDOIS1M", convention: "maturité exacte / réunion", note: "Pas de futures standardisés. RBNZ publie les probas dans ses MPS." }, +}; + function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: { ratePath: CBRatePath; - syncChartTab?: "curve" | "implied" | "scenarios"; - onChartTabChange?: (id: "curve" | "implied" | "scenarios") => void; + syncChartTab?: "curve" | "probas" | "meetings"; + onChartTabChange?: (id: "curve" | "probas" | "meetings") => void; }) { - const [localChartTab, setLocalChartTab] = useState<"curve" | "implied" | "scenarios">("scenarios"); + const [localChartTab, setLocalChartTab] = useState<"curve" | "probas" | "meetings">("curve"); const chartTab = syncChartTab ?? localChartTab; - const setChartTab = (id: "curve" | "implied" | "scenarios") => { + const setChartTab = (id: "curve" | "probas" | "meetings") => { setLocalChartTab(id); onChartTabChange?.(id); }; @@ -549,71 +560,38 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: { const maxR = Math.max(...allRates); const yMargin = Math.max(0.05, (maxR - minR) * 0.25); + // Shared for Réunions tab + const maxR2 = Math.max(...scenariosData.map(d => d.rate), currentRate); + const minR2 = Math.min(...scenariosData.map(d => d.rate), currentRate) - 0.05; + const range = maxR2 - minR2 || 0.25; + + // Probas tab + const probValues = chartMeetings.map(m => m.probMovePct); + const pMin = Math.max(0, Math.min(...probValues) - 12); + const pMax = Math.min(100, Math.max(...probValues) + 12); + const peakIsCut = !!ratePath.peakMeeting?.probIsCut; + const probGradId = `oisProbGrad_${ratePath.currency}`; + const probGradColor = peakIsCut ? "#38bdf8" : "#f59e0b"; + const probaData = chartMeetings.map(m => ({ label: m.label.slice(0, 6), prob: m.probMovePct, isCut: m.probIsCut, impliedRate: m.impliedRate, bps: m.changeBps })); + return (
- {/* ── Summary compact ───────────────────────────────────────────────── */} -
- {/* Ligne 1 : titre + date */} -
-
- OIS · Futures - LIVE -
- au {ratePath.asOf} -
- - {/* Ligne 2 : taux actuel → résultat attendu prochaine réunion + Δ fin an */} -
- {currentRate.toFixed(2)}% - -
- {moveIcon} {moveLabel} - {m0.label} - {isMoveExpected && ( - - ({m0.changeBps > 0 ? "+" : ""}{Math.round(m0.changeBps)}bps) - - )} -
- {bpsYE !== null && ( -
-
- {bpsYE > 0 ? "+" : ""}{bpsYE}bps -
-
fin an
-
- )} -
- - {/* Ligne 3 : barre proba scénario principal */} -
- - {mlIsMove ? moveLabel : "Hold"} - -
-
-
- {Math.round(mlProb)}% - {mlRate.toFixed(2)}% -
- {/* Scénario alternatif seulement si proba ≥ 15% */} - {altProb >= 15 && ( -
- {altIsMove ? moveLabel : "Hold"} -
-
-
- {Math.round(altProb)}% - {altRate.toFixed(2)}% -
+ {/* ── Summary — ligne unique ────────────────────────────────────────── */} +
+ {moveIcon} {moveLabel} + {Math.round(mlProb)}% + {isMoveExpected && ( + ({m0.changeBps > 0 ? "+" : ""}{Math.round(m0.changeBps)}bps) )} + {/* Alt seulement si scénario différent (évite "Hold 70% · Hold 30%") */} + {altProb >= 15 && altIsMove !== mlIsMove && ( + <> + · + {Math.round(altProb)}% {altIsMove ? moveLabel : "Hold"} + + )} + au {ratePath.asOf}
{/* ── Chart section ─────────────────────────────────────────────────── */} @@ -621,9 +599,9 @@ function OISEnhancedBlock({ ratePath, syncChartTab, onChartTabChange }: { {/* Tab buttons */}
{([ - { id: "scenarios" as const, label: "Probabilités" }, - { id: "curve" as const, label: "Rate Path" }, - { id: "implied" as const, label: "Implied Pts" }, + { id: "curve" as const, label: "Courbe" }, + { id: "probas" as const, label: "Probabilités" }, + { id: "meetings" as const, label: "Réunions" }, ]).map(t => (
- - - - + + + + + + + + + v.toFixed(2)} /> - + tickFormatter={(v: number) => `${v.toFixed(2)}%`} + /> + { if (!payload?.length) return null; + const cur = (payload as {dataKey:string;value:number}[]).find(p => p.dataKey === "current"); + const prev = (payload as {dataKey:string;value:number}[]).find(p => p.dataKey === "weekAgo"); + const delta = cur && prev ? cur.value - prev.value : null; return ( -
-

{label}

- {(payload as {dataKey: string; value: number; stroke: string}[]).map(p => ( -

- {p.dataKey === "current" ? "●" : "···"} {(p.value as number).toFixed(3)}% -

- ))} +
+

{label}

+ {cur && ( +
+ Actuel + {cur.value.toFixed(3)}% +
+ )} + {prev && ( +
+ Sem. préc. + {prev.value.toFixed(3)}% +
+ )} + {delta !== null && ( + <> +
+
+ Δ sem. + 0.0001 ? "#f87171" : delta < -0.0001 ? "#38bdf8" : "#64748b", fontSize: 9, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}> + {delta > 0 ? "+" : ""}{(delta * 100).toFixed(1)} bps + +
+ + )}
); }} /> - + {hasPrevCurve && ( - + )}
)} - {/* Chart 2 — Implied Points (bps cumulatifs par réunion) */} - {chartTab === "implied" && ( + {/* Probas — AreaChart probabilité de move par réunion */} + {chartTab === "probas" && (
- {/* Légende couleurs barres */} -
- Implied Points (bps par réunion) -
- - Hausse - - - Baisse - -
-
- - + + + + + + + + - `${v}bps`} /> - + `${v}%`} /> + + { if (!payload?.length) return null; const v = payload[0]?.value as number; + const m = probaData.find(d => d.label === label); + const bpsRaw = m ? Math.round(m.bps) : 0; + const deltaStr = bpsRaw !== 0 ? `${bpsRaw > 0 ? "+" : ""}${bpsRaw} bps` : null; return ( -
-

{label}

-

- Implied : {v > 0 ? "+" : ""}{typeof v === "number" ? v.toFixed(1) : "—"}bps +

+

{label}

+

+ {v.toFixed(0)}%{" "} + {m?.isCut ? "Baisse" : "Hausse"}

+
+

Taux cible : {m?.impliedRate.toFixed(2)}%

+ {deltaStr &&

Variation : {deltaStr}

}
); }} /> - - {impliedPtsData.map((entry, i) => ( - 0 ? "#4ade80" : "#475569"} fillOpacity={0.8} /> - ))} - - + + {ratePath.peakMeeting && ( +
+ Pic : {ratePath.peakMeeting.label} + + {ratePath.peakMeeting.probMovePct.toFixed(0)}% {peakIsCut ? "Baisse" : "Hausse"} + +
+ )}
)} - {/* Chart 3 — Probabilités : AreaChart auto-scale + résumé + liste réunions */} - {chartTab === "scenarios" && (() => { - const probValues = chartMeetings.map(m => m.probMovePct); - const pMin = Math.max(0, Math.min(...probValues) - 12); - const pMax = Math.min(100, Math.max(...probValues) + 12); - const maxR2 = Math.max(...scenariosData.map(d => d.rate), currentRate); - const minR2 = Math.min(...scenariosData.map(d => d.rate), currentRate) - 0.05; - const range = maxR2 - minR2 || 0.25; - const peakIsCut = !!ratePath.peakMeeting?.probIsCut; - const gradId = `probGrad_${currency}`; - const gradColor = peakIsCut ? "#38bdf8" : "#f59e0b"; - return ( -
- {/* Graphique AreaChart auto-scale */} - - ({ label: m.label, prob: m.probMovePct, isCut: m.probIsCut, impliedRate: m.impliedRate }))} - margin={{ top: 4, right: 4, left: -22, bottom: 0 }} - > - - - - - - - - `${v}%`} /> - - - { - if (!payload?.length) return null; - const v = payload[0]?.value as number; - const m = chartMeetings.find(m => m.label === label); - const bpsCum = m ? Math.round((m.impliedRate - currentRate) * 100) : 0; - return ( -
-

{label}

-

Prob : {v.toFixed(0)}% {m?.probIsCut ? "Cut" : "Hike"}

-

- {m?.impliedRate.toFixed(2)}% {bpsCum !== 0 ? `(${bpsCum > 0 ? "+" : ""}${bpsCum}bps)` : ""} -

-
- ); - }} - /> -
-
- - {/* Résumé compact : pic · proba · bps fin an · delta IL */} - {ratePath.peakMeeting && ( -
- Pic : {ratePath.peakMeeting.label} - - {ratePath.peakMeeting.probMovePct.toFixed(0)}% {peakIsCut ? "Cut" : "Hike"} - - {bpsYE !== null && ( - - {bpsYE > 0 ? "+" : ""}{bpsYE}bps fin an - - )} - {ilDelta && (Math.abs(ilDelta.probDelta) >= 3 || Math.abs(ilDelta.bpsDelta) >= 5) && ( - - vs sem. préc. : - - {ilDelta.bpsDelta !== 0 && ( - - )} - - )} -
- )} - - {/* Liste des réunions */} -
- {scenariosData.map(d => { - const isPeak = ratePath.peakMeeting?.dateIso === d.dateIso; - const isDown = d.rate < currentRate - 0.001; - const isUp = d.rate > currentRate + 0.001; - const barColor = isDown ? "#38bdf8" : isUp ? "#f87171" : "#f59e0b"; - const barW = Math.max(4, Math.min(100, ((d.rate - minR2) / range) * 100)); - const cumBps = d.cumulBps; - const cumStr = cumBps === 0 ? null : `${cumBps > 0 ? "+" : ""}${cumBps}`; - return ( -
- - {d.label} - -
-
-
- - {d.rate.toFixed(2)}% - - {cumStr && ( - - {cumStr}bps - - )} + {/* Réunions — tableau taux implicites par meeting */} + {chartTab === "meetings" && ( +
+
+ Réunions + proba · Δbps · taux +
+
+ {scenariosData.map(d => { + const isDown = d.rate < currentRate - 0.001; + const isUp = d.rate > currentRate + 0.001; + const isFlat = !isDown && !isUp; + const probPct = Math.round(Math.min(100, Math.abs(d.prob))); + const bpsRaw = Math.round(d.cumulBps); + const bpsStr = isFlat ? "Hold" : `${bpsRaw > 0 ? "+" : ""}${bpsRaw}bps`; + const bpsColor = isDown ? "text-sky-400" : isUp ? "text-red-400" : "text-slate-600"; + const barColor = isDown ? "bg-sky-500/55" : isUp ? "bg-red-500/55" : "bg-slate-700/40"; + const isPeak = ratePath.peakMeeting?.dateIso === d.dateIso; + return ( +
+ {d.label} +
+
- ); - })} -
+ {probPct}% + {bpsStr} + {d.rate.toFixed(2)}% +
+ ); + })} +
+
+ )} + +
+ + {/* ── IL footer (analyste InvestingLive) + source STIR ───────────────── */} +
+ {ilCurrent && ( +
+ + Analyste · {ilCurrent.articleDate} ·{" "} + 0 ? "text-red-400" : "text-slate-400"}> + {ilCurrent.bpsYearEnd === 0 + ? "Hold" + : `${ilCurrent.bpsYearEnd > 0 ? "+" : ""}${ilCurrent.bpsYearEnd}bps fin an`} + + + {ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 5) && ( + + Δsem: + + + + )} +
+ )} + {(() => { + const stir = STIR_INSTRUMENT[ratePath.currency]; + if (!stir) return null; + return ( +
+ {stir.instrument} · {stir.exchange} · {stir.convention} + {stir.note && — {stir.note}}
); })()}
- - {/* ── IL footer (analyste InvestingLive) ────────────────────────────── */} - {ilCurrent && ( -
- - Analyste · {ilCurrent.articleDate} ·{" "} - - {ilCurrent.isNoChange ? "Hold" : `${ilCurrent.isCut ? "" : "+"}${ilCurrent.bpsYearEnd}bps`} - - - {ratePath.ilDelta && (Math.abs(ratePath.ilDelta.probDelta) >= 3 || Math.abs(ratePath.ilDelta.bpsDelta) >= 5) && ( - - Δsem: - - - - )} -
- )}
); } @@ -922,21 +880,34 @@ export default function CurrencyCard({ // ── Data fetch ─────────────────────────────────────────────────────────────── const load = useCallback(async () => { - setLoading(true); const cacheKey = `macro_${currency}`; + + // Affiche le cache immédiatement — pas de skeleton si données dispos + const cached = loadCache(cacheKey); + if (cached) { + setData(cached.data); + setFromCache(true); + setCacheAge(formatCacheDate(cached.savedAt)); + setLoading(false); + } else { + setLoading(true); + } + + // Refetch en arrière-plan (10s timeout) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); try { - const res = await fetch(`/api/macro?currency=${currency}`); + const res = await fetch(`/api/macro?currency=${currency}`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json: MacroData = await res.json(); if ("error" in json) throw new Error(String((json as Record).error)); - const prevCache = loadCache(cacheKey); const merged: MacroData = { ...json, indicators: { ...json.indicators, - pmiMfg: json.indicators.pmiMfg ?? prevCache?.data.indicators.pmiMfg ?? null, - pmiServices: json.indicators.pmiServices ?? prevCache?.data.indicators.pmiServices ?? null, + pmiMfg: json.indicators.pmiMfg ?? cached?.data.indicators.pmiMfg ?? null, + pmiServices: json.indicators.pmiServices ?? cached?.data.indicators.pmiServices ?? null, }, }; setData(merged); @@ -944,13 +915,9 @@ export default function CurrencyCard({ setCacheAge(null); saveCache(cacheKey, merged); } catch { - const cached = loadCache(cacheKey); - if (cached) { - setData(cached.data); - setFromCache(true); - setCacheAge(formatCacheDate(cached.savedAt)); - } + // Echec fetch : le cache déjà affiché reste visible } finally { + clearTimeout(timeoutId); setLoading(false); } }, [currency]); @@ -1747,7 +1714,7 @@ export default function CurrencyCard({ {(() => { // OIS est toujours présent (même si données indisponibles) const sigTabs: { id: SignauxSlide; label: string }[] = [ - { id: "ois", label: "OIS" }, + { id: "ois", label: "Taux" }, ]; if (cot) sigTabs.push({ id: "cot", label: "COT" }); if (sentiment) sigTabs.push({ id: "sent", label: "DXM" }); @@ -2045,20 +2012,6 @@ export default function CurrencyCard({
{sentiment.shortPct.toFixed(0)}%
-
-
- {sentDir === "bullish" - ? `Signal contrarian HAUSSIER — majorité short (${sentiment.shortPct.toFixed(0)}%)` - : sentDir === "bearish" - ? `Signal contrarian BAISSIER — majorité long (${sentiment.longPct.toFixed(0)}%)` - : "Sentiment équilibré — pas de signal contrarian"} -
-
- {sentDir === "bullish" ? "Retail survendu → opportunité d'achat" - : sentDir === "bearish" ? "Retail suracheté → opportunité de vente" - : "Attendre un déséquilibre >70/30"} -
-
); })()} diff --git a/lib/rateprobability.ts b/lib/rateprobability.ts index 95201bf..1c6a448 100644 --- a/lib/rateprobability.ts +++ b/lib/rateprobability.ts @@ -225,7 +225,7 @@ function loadCachedRPBody(ccy: string, _slug: string): Record | const entry = parsed.data?.[ccy] as Record | undefined; if (!entry) return null; const ageMs = Date.now() - new Date(parsed.fetchedAt).getTime(); - if (ageMs > 48 * 60 * 60 * 1000) { // ignore si > 48h (GitHub Actions peut ne pas tourner quotidiennement) + if (ageMs > 168 * 60 * 60 * 1000) { // ignore si > 7 jours console.warn(`[rate-prob] cache stale (${Math.round(ageMs / 3600000)}h), skipping`); return null; } @@ -358,11 +358,12 @@ export async function fetchAllCBPaths(): Promise { const path = data[ccy]; if (!path) continue; if (typeof ilEntry.bpsYearEnd !== "number") continue; - if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 10) continue; + if (ilEntry.nextMeetingIsNoChange && Math.abs(ilEntry.bpsYearEnd) < 5) continue; - const yearEndImplied = ccy === "CHF" - ? parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4)) - : path.yearEndImplied; + // Giuseppe lit le STIR complet (toutes les réunions jusqu'en déc) et publie le cumul bps year-end. + // Le Rate Monitor Investing.com ne capture parfois que 2-3 réunions → yearEndImplied partiel. + // → On utilise toujours Giuseppe comme source authoritative pour le cumul fin d'année. + const yearEndImplied = parseFloat((path.currentRate + ilEntry.bpsYearEnd / 100).toFixed(4)); let ilDelta: ILWeeklyDelta | undefined; const prevEntry = ilPrev[ccy]; @@ -370,13 +371,15 @@ export async function fetchAllCBPaths(): Promise { ilDelta = { probDelta: parseFloat((ilEntry.nextMeetingProbPct - prevEntry.nextMeetingProbPct).toFixed(1)), bpsDelta: ilEntry.bpsYearEnd - prevEntry.bpsYearEnd, - isCut: !ilEntry.nextMeetingIsHike && !ilEntry.nextMeetingIsNoChange, + isCut: ilEntry.bpsYearEnd < 0, prevDate, }; } const ilProb = ilEntry.nextMeetingProbPct; - const ilIsCut = !ilEntry.nextMeetingIsHike && !ilEntry.nextMeetingIsNoChange; + // Direction basée sur le signe de bpsYearEnd (plus fiable que nextMeetingIsHike + // qui est faux quand Giuseppe dit "no change" à la prochaine réunion mais hausse year-end) + const ilIsCut = ilEntry.bpsYearEnd < 0; const m0 = path.meetings[0]; const stirProb = m0?.probMovePct ?? 0;