auto: sync 2026-06-03 01:04

This commit is contained in:
Capucine Gest
2026-06-03 01:04:07 +02:00
parent 34cd885497
commit e11d61eb4d
6 changed files with 505 additions and 153 deletions
+26 -2
View File
@@ -5,7 +5,7 @@ import cpiOverridesRaw from "@/data/cpi_overrides.json";
import rateDecisionsRaw from "@/data/rate_decisions.json"; import rateDecisionsRaw from "@/data/rate_decisions.json";
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory"; import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
import type { FFEvent } from "@/lib/forexfactory"; import type { FFEvent } from "@/lib/forexfactory";
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate } from "@/lib/tecpi"; import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate } from "@/lib/tecpi";
import { fetchTEInflationForecasts } from "@/lib/tradingeconomics"; import { fetchTEInflationForecasts } from "@/lib/tradingeconomics";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations"; const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
@@ -1025,7 +1025,7 @@ export async function GET(req: NextRequest) {
// Remplace séries FRED stale/erronées. // Remplace séries FRED stale/erronées.
// Nouvelles données : cpiYoY headline, cpiCoreMoM (pages individuelles), ppiMoM // Nouvelles données : cpiYoY headline, cpiCoreMoM (pages individuelles), ppiMoM
{ {
const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap] = await Promise.all([ const [teCoreMap, teMoMMap, teYoYMap, teCoreMoMMap, teCoreIdxMap, tePPIMap, teCorePages, teYoYPages, teAUDComm, teGDPMap, teUneMap, teSTIRMap] = await Promise.all([
fetchTECoreInflation(), fetchTECoreInflation(),
fetchTEMoMInflation(), fetchTEMoMInflation(),
fetchTEInflationYoY(), fetchTEInflationYoY(),
@@ -1037,6 +1037,7 @@ export async function GET(req: NextRequest) {
currency === "AUD" ? fetchTEAUDCommodityYoY() : Promise.resolve(null), currency === "AUD" ? fetchTEAUDCommodityYoY() : Promise.resolve(null),
fetchTEGDPGrowthRate(), fetchTEGDPGrowthRate(),
fetchTEUnemploymentRate(), fetchTEUnemploymentRate(),
fetchTESTIRRate(),
]); ]);
const teCore = teCoreMap[currency]; const teCore = teCoreMap[currency];
@@ -1136,6 +1137,29 @@ export async function GET(req: NextRequest) {
}; };
} }
// STIR 3M — Taux interbancaire 3 mois (SOFR 3M, EURIBOR 3M, SONIA 3M, TIBOR 3M…)
// Signal clé : STIR > policyRate = marché price des hausses (hawkish)
// STIR < policyRate = marché price des baisses (dovish)
// Tendance ↑ = conditions crédit plus restrictives (prêter plus risqué/cher)
// Tendance ↓ = conditions crédit plus souples (prêter plus sûr/moins cher)
{
const teSTIR = teSTIRMap[currency];
if (teSTIR) {
const surprise = teSTIR.prev !== null
? parseFloat((teSTIR.value - teSTIR.prev).toFixed(4))
: null;
indicators.stir3m = {
value: teSTIR.value,
prev: teSTIR.prev,
surprise,
trend: teSTIR.prev !== null
? (teSTIR.value > teSTIR.prev ? "up" : teSTIR.value < teSTIR.prev ? "down" : "flat")
: null,
lastUpdated: teSTIR.refMonth,
};
}
}
// GDP Growth Rate QoQ% — TE country-list (source autoritaire, remplace FRED stale) // GDP Growth Rate QoQ% — TE country-list (source autoritaire, remplace FRED stale)
// FRED : retourne souvent l'indice niveau brut → QoQ% faux ou en retard de plusieurs trimestres // FRED : retourne souvent l'indice niveau brut → QoQ% faux ou en retard de plusieurs trimestres
// TE country-list/gdp-growth-rate : QoQ% publié directement, mis à jour à chaque release // TE country-list/gdp-growth-rate : QoQ% publié directement, mis à jour à chaque release
+53
View File
@@ -779,6 +779,59 @@ export default function CurrencyCard({
</span> </span>
</div> </div>
)} )}
{/* STIR 3M — Taux interbancaire court terme */}
{inds?.stir3m?.value != null && (() => {
const stir = inds.stir3m!.value!;
const stirPrev = inds.stir3m!.prev;
const stirTrend = inds.stir3m!.trend;
// Spread STIR - Taux directeur
const stirSpread = policyRateValue !== null
? parseFloat((stir - policyRateValue).toFixed(2)) : null;
// Signal : spread positif = marché price des hausses (hawkish)
const stirSig: SignalDir =
stirTrend === "up" ? "bearish" // hausse STIR = crédit plus cher/risqué
: stirTrend === "down" ? "bullish" // baisse STIR = crédit plus sûr/souple
: "neutral";
const spreadSig: SignalDir =
stirSpread !== null && stirSpread > 0.1 ? "bullish" // STIR > CT = hawkish bias
: stirSpread !== null && stirSpread < -0.1 ? "bearish" // STIR < CT = dovish bias
: "neutral";
return (
<>
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5">
<span className="text-slate-600"></span>
<span className="text-slate-400">STIR 3M</span>
</div>
<div className="flex items-center gap-1.5">
<span className={`font-semibold tabular-nums ${sigColor(stirSig)}`}>
{stir.toFixed(2)}%
</span>
{stirTrend && (
<span className={`text-[10px] ${sigColor(stirSig)}`}>
{stirTrend === "up" ? "↑" : stirTrend === "down" ? "↓" : "→"}
{stirPrev !== null ? ` ${stirPrev.toFixed(2)}%` : ""}
</span>
)}
</div>
</div>
{stirSpread !== null && (
<div className="flex items-center justify-between text-[12px]">
<div className="flex items-center gap-1.5">
<span className="text-slate-600"></span>
<span className="text-slate-400">Spread STIRCT</span>
</div>
<span
className={`font-semibold tabular-nums ${sigColor(spreadSig)}`}
title={stirSpread > 0 ? "STIR > taux directeur → marché price des hausses" : stirSpread < 0 ? "STIR < taux directeur → marché price des baisses" : "STIR ≈ taux directeur"}
>
{stirSpread > 0 ? "+" : ""}{Math.round(stirSpread * 100)}bps
</span>
</div>
)}
</>
);
})()}
{(() => { {(() => {
// Taux réel = Taux directeur CPI YoY headline (= Inflation Rate YoY) // Taux réel = Taux directeur CPI YoY headline (= Inflation Rate YoY)
// On n'utilise PAS Core CPI comme fallback : Core < Headline → gonflerait le taux réel // On n'utilise PAS Core CPI comme fallback : Core < Headline → gonflerait le taux réel
+227 -148
View File
@@ -1,40 +1,55 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus, Loader2, Zap } from "lucide-react"; import {
ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus,
Loader2, Radio, AlertTriangle, Landmark, Globe, BarChart2, Zap,
} from "lucide-react";
import type { NewsItem } from "@/app/api/news/route"; import type { NewsItem } from "@/app/api/news/route";
import type { Currency } from "@/lib/types"; import type { Currency } from "@/lib/types";
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Constantes ────────────────────────────────────────────────────────────────
const CCY_FLAGS: Record<Currency, string> = { const CCY_FLAGS: Record<Currency, string> = {
USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵", USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵",
CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿", CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿",
}; };
const CCY_LIST: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; const CCY_LIST: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
const SOURCE_COLORS: Record<string, string> = { // Catégories prioritaires avec icône et couleur
"InvestingLive": "bg-amber-500/20 text-amber-400 border-amber-500/30", const CATEGORY_META: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
"Reuters": "bg-orange-500/20 text-orange-400 border-orange-500/30", "Discours BC": { icon: <Landmark size={11} />, color: "bg-blue-500/20 text-blue-400 border-blue-500/30", label: "Discours BC" },
"Bloomberg Economics": "bg-sky-500/20 text-sky-400 border-sky-500/30", "Chef d'État": { icon: <Globe size={11} />, color: "bg-violet-500/20 text-violet-400 border-violet-500/30", label: "Chef d'État" },
"Bloomberg CB": "bg-sky-500/20 text-sky-400 border-sky-500/30", "Décision Taux": { icon: <Radio size={11} />, color: "bg-amber-500/20 text-amber-400 border-amber-500/30", label: "Décision Taux" },
"Bloomberg FX": "bg-sky-500/20 text-sky-400 border-sky-500/30", "Probabilités Taux": { icon: <BarChart2 size={11} />, color: "bg-sky-500/20 text-sky-400 border-sky-500/30", label: "OIS / Proba Taux" },
"Données Clés": { icon: <BarChart2 size={11} />, color: "bg-slate-400/20 text-slate-300 border-slate-500/30", label: "Données Clés" },
"Emploi": { icon: <BarChart2 size={11} />, color: "bg-slate-400/20 text-slate-300 border-slate-500/30", label: "Emploi" },
"Inflation": { icon: <Zap size={11} />, color: "bg-orange-500/20 text-orange-400 border-orange-500/30", label: "Inflation" },
"Crise": { icon: <AlertTriangle size={11} />, color: "bg-red-600/20 text-red-400 border-red-500/30", label: "Crise" },
"Guerre": { icon: <AlertTriangle size={11} />, color: "bg-red-700/20 text-red-400 border-red-600/30", label: "Guerre" },
"Géopolitique": { icon: <Globe size={11} />, color: "bg-purple-500/20 text-purple-400 border-purple-500/30", label: "Géopolitique" },
"Risk-Off": { icon: <TrendingDown size={11} />, color: "bg-red-500/20 text-red-400 border-red-500/30", label: "Risk-Off" },
"Risk-On": { icon: <TrendingUp size={11} />, color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", label: "Risk-On" },
"Énergie": { icon: <Zap size={11} />, color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", label: "Énergie" },
"Banque Centrale": { icon: <Landmark size={11} />, color: "bg-blue-500/20 text-blue-400 border-blue-500/30", label: "Banque Centrale" },
"Commodités": { icon: <BarChart2 size={11} />, color: "bg-orange-500/20 text-orange-400 border-orange-500/30", label: "Commodités" },
"Chine": { icon: <Globe size={11} />, color: "bg-red-600/20 text-red-400 border-red-600/30", label: "Chine" },
}; };
const CAT_COLORS: Record<string, string> = { // Ordre d'affichage des filtres catégorie (par priorité signal)
"Risk-Off": "bg-red-500/15 text-red-400", const PRIORITY_CATS = [
"Risk-On": "bg-emerald-500/15 text-emerald-400", "Discours BC", "Décision Taux", "Probabilités Taux",
"Géopolitique": "bg-purple-500/15 text-purple-400", "Chef d'État", "Données Clés", "Emploi", "Inflation",
"Énergie": "bg-yellow-500/15 text-yellow-400", "Crise", "Guerre", "Géopolitique", "Risk-Off", "Risk-On",
"Banque Centrale": "bg-blue-500/15 text-blue-400", "Énergie", "Commodités", "Chine",
"Commodités": "bg-orange-500/15 text-orange-400", ];
"Chine": "bg-red-600/15 text-red-400",
"Données Macro": "bg-slate-500/15 text-slate-400", const SOURCE_COLORS: Record<string, string> = {
"Commerce": "bg-indigo-500/15 text-indigo-400", "InvestingLive": "bg-amber-500/20 text-amber-400",
"Agriculture": "bg-green-500/15 text-green-400", "Reuters": "bg-orange-500/20 text-orange-400",
"Or": "bg-yellow-600/15 text-yellow-400", "Bloomberg Economics": "bg-sky-500/20 text-sky-400",
"Métaux": "bg-gray-400/15 text-gray-300", "Bloomberg CB": "bg-sky-500/20 text-sky-400",
"Bloomberg FX": "bg-sky-500/20 text-sky-400",
}; };
function formatRelativeTime(isoDate: string): string { function formatRelativeTime(isoDate: string): string {
@@ -44,41 +59,48 @@ function formatRelativeTime(isoDate: string): string {
if (mins < 60) return `il y a ${mins}min`; if (mins < 60) return `il y a ${mins}min`;
const hrs = Math.floor(mins / 60); const hrs = Math.floor(mins / 60);
if (hrs < 24) return `il y a ${hrs}h`; if (hrs < 24) return `il y a ${hrs}h`;
const days = Math.floor(hrs / 24); return `il y a ${Math.floor(hrs / 24)}j`;
return `il y a ${days}j`;
} }
// ── Composant principal ─────────────────────────────────────────────────────── // ── Composant principal ───────────────────────────────────────────────────────
interface Props { interface Props {
items: NewsItem[]; items: NewsItem[];
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
} }
export default function NewsTab({ items, loading, onRefresh }: Props) { export default function NewsTab({ items, loading, onRefresh }: Props) {
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL"); const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
const [filterCat, setFilterCat] = useState<string | "ALL">("ALL");
const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all"); const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all");
const filtered = useMemo(() => { const filtered = useMemo(() => items.filter(item => {
return items.filter(item => { if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false;
if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false; if (filterCat !== "ALL" && !item.categories.includes(filterCat)) return false;
if (filterDir !== "all") { if (filterDir !== "all") {
const hasDir = filterCcy === "ALL" const hasDir = filterCcy === "ALL"
? item.impacts.some(i => i.direction === filterDir) ? item.impacts.some(i => i.direction === filterDir)
: item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir); : item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir);
if (!hasDir) return false; if (!hasDir) return false;
} }
return true; return true;
}); }), [items, filterCcy, filterCat, filterDir]);
}, [items, filterCcy, filterDir]);
// Compter les impacts par devise pour les badges de filtre // Catégories présentes dans le feed actuel
const activeCats = useMemo(() => {
const set = new Set<string>();
for (const item of items) item.categories.forEach(c => set.add(c));
return PRIORITY_CATS.filter(c => set.has(c));
}, [items]);
// Comptage par devise
const ccyCount = useMemo(() => { const ccyCount = useMemo(() => {
const counts: Partial<Record<Currency, { bull: number; bear: number }>> = {}; const counts: Partial<Record<Currency, { bull: number; bear: number; total: number }>> = {};
for (const item of items) { for (const item of items) {
for (const imp of item.impacts) { for (const imp of item.impacts) {
if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0 }; if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0, total: 0 };
counts[imp.ccy]!.total++;
if (imp.direction === "bullish") counts[imp.ccy]!.bull++; if (imp.direction === "bullish") counts[imp.ccy]!.bull++;
if (imp.direction === "bearish") counts[imp.ccy]!.bear++; if (imp.direction === "bearish") counts[imp.ccy]!.bear++;
} }
@@ -86,79 +108,133 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
return counts; return counts;
}, [items]); }, [items]);
// Résumé par devise (pour headline)
const ccySummary = useMemo(() => {
const summary: Partial<Record<Currency, "bullish" | "bearish" | "mixed" | "neutral">> = {};
for (const [ccy, cnt] of Object.entries(ccyCount) as [Currency, { bull: number; bear: number }][]) {
if (cnt.bull > cnt.bear * 1.5) summary[ccy] = "bullish";
else if (cnt.bear > cnt.bull * 1.5) summary[ccy] = "bearish";
else if (cnt.bull > 0 && cnt.bear > 0) summary[ccy] = "mixed";
else summary[ccy] = "neutral";
}
return summary;
}, [ccyCount]);
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Barre de filtres */} {/* ── Headline résumé par devise ──────────────────────────────────────── */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3"> {!loading && items.length > 0 && (
<div className="flex items-center justify-between mb-3"> <div className="bg-slate-900 border border-slate-800 rounded-xl p-3">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold"> <div className="flex items-center justify-between mb-2">
Filtrer par devise <span className="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">
</span> Biais actualités par devise
<div className="flex items-center gap-2"> </span>
<button onClick={onRefresh} disabled={loading}
className="flex items-center gap-1 text-[9px] text-slate-600 hover:text-slate-400 disabled:opacity-50">
<RefreshCw size={9} className={loading ? "animate-spin" : ""} />
Actualiser
</button>
</div>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{CCY_LIST.map(ccy => {
const cnt = ccyCount[ccy];
const summary = ccySummary[ccy] ?? "neutral";
const bgCls = summary === "bullish" ? "bg-emerald-500/10 border-emerald-500/20"
: summary === "bearish" ? "bg-red-500/10 border-red-500/20"
: summary === "mixed" ? "bg-amber-500/10 border-amber-500/20"
: "bg-slate-800/40 border-slate-700/30";
const arrow = summary === "bullish" ? "↑" : summary === "bearish" ? "↓"
: summary === "mixed" ? "↕" : "→";
const arrowCls = summary === "bullish" ? "text-emerald-400" : summary === "bearish" ? "text-red-400"
: summary === "mixed" ? "text-amber-400" : "text-slate-600";
return (
<button key={ccy} onClick={() => setFilterCcy(filterCcy === ccy ? "ALL" : ccy)}
className={`flex flex-col items-center gap-0.5 p-2 rounded-lg border transition-all ${bgCls} ${filterCcy === ccy ? "ring-1 ring-amber-500/50" : ""}`}>
<span className="text-base">{CCY_FLAGS[ccy]}</span>
<span className="text-[10px] font-bold text-slate-300">{ccy}</span>
<span className={`text-[11px] font-bold ${arrowCls}`}>{arrow}</span>
{cnt && (
<span className="text-[8px] text-slate-600">{cnt.bull} {cnt.bear}</span>
)}
</button>
);
})}
</div>
</div>
)}
{/* ── Filtres ─────────────────────────────────────────────────────────── */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-3 space-y-2">
{/* Direction */}
<div className="flex items-center gap-2">
<span className="text-[9px] text-slate-600 uppercase tracking-wider w-16 shrink-0">Direction</span>
<div className="flex gap-1.5">
{(["all", "bullish", "bearish"] as const).map(d => ( {(["all", "bullish", "bearish"] as const).map(d => (
<button <button key={d} onClick={() => setFilterDir(d)}
key={d} className={`text-[10px] px-2.5 py-1 rounded-full font-semibold transition-colors border ${
onClick={() => setFilterDir(d)}
className={`text-[10px] px-2 py-0.5 rounded-full font-semibold transition-colors ${
filterDir === d filterDir === d
? d === "bullish" ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" ? d === "bullish" ? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
: d === "bearish" ? "bg-red-500/20 text-red-400 border border-red-500/30" : d === "bearish" ? "bg-red-500/20 text-red-400 border-red-500/30"
: "bg-slate-700 text-slate-300" : "bg-slate-700 text-slate-300 border-slate-600"
: "text-slate-500 hover:text-slate-300" : "text-slate-500 border-slate-700/40 hover:text-slate-300"
}`} }`}>
>
{d === "all" ? "Tous" : d === "bullish" ? "↑ Haussier" : "↓ Baissier"} {d === "all" ? "Tous" : d === "bullish" ? "↑ Haussier" : "↓ Baissier"}
</button> </button>
))} ))}
<button
onClick={onRefresh}
disabled={loading}
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-slate-300 disabled:opacity-50"
>
<RefreshCw size={10} className={loading ? "animate-spin" : ""} />
{loading ? "..." : "Actualiser"}
</button>
</div> </div>
</div> </div>
<div className="flex gap-2 flex-wrap"> {/* Catégories */}
<button {activeCats.length > 0 && (
onClick={() => setFilterCcy("ALL")} <div className="flex items-start gap-2">
className={`text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${ <span className="text-[9px] text-slate-600 uppercase tracking-wider w-16 shrink-0 pt-1">Catégorie</span>
filterCcy === "ALL" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300" <div className="flex gap-1.5 flex-wrap">
}`} <button onClick={() => setFilterCat("ALL")}
> className={`text-[10px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
Toutes ({items.length}) filterCat === "ALL" ? "bg-slate-700 text-slate-300 border-slate-600" : "text-slate-600 border-slate-700/40 hover:text-slate-400"
</button> }`}>
{CCY_LIST.map(ccy => { Toutes
const cnt = ccyCount[ccy];
if (!cnt) return null;
const isActive = filterCcy === ccy;
return (
<button
key={ccy}
onClick={() => setFilterCcy(ccy === filterCcy ? "ALL" : ccy)}
className={`flex items-center gap-1.5 text-[11px] px-3 py-1 rounded-full font-semibold transition-colors ${
isActive ? "bg-amber-500/20 text-amber-400 border border-amber-500/30" : "text-slate-500 hover:text-slate-300 border border-slate-700/50"
}`}
>
<span>{CCY_FLAGS[ccy]}</span>
<span>{ccy}</span>
<span className="flex items-center gap-0.5 text-[9px]">
{cnt.bull > 0 && <span className="text-emerald-400">{cnt.bull}</span>}
{cnt.bear > 0 && <span className="text-red-400">{cnt.bear}</span>}
</span>
</button> </button>
); {activeCats.map(cat => {
})} const meta = CATEGORY_META[cat];
</div> const isActive = filterCat === cat;
return (
<button key={cat} onClick={() => setFilterCat(filterCat === cat ? "ALL" : cat)}
className={`flex items-center gap-1 text-[10px] px-2.5 py-1 rounded-full font-semibold border transition-colors ${
isActive ? (meta?.color ?? "bg-slate-700 text-slate-300 border-slate-600")
: "text-slate-600 border-slate-700/40 hover:text-slate-400"
}`}>
{meta?.icon}
{meta?.label ?? cat}
</button>
);
})}
</div>
</div>
)}
</div> </div>
{/* Liste de news */} {/* ── Compteur résultats ──────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-1">
<span className="text-[11px] text-slate-600">
{loading ? "Chargement…" : `${filtered.length} article${filtered.length > 1 ? "s" : ""}`}
{filterCcy !== "ALL" && ` · ${CCY_FLAGS[filterCcy]} ${filterCcy}`}
{filterCat !== "ALL" && ` · ${filterCat}`}
</span>
{(filterCcy !== "ALL" || filterCat !== "ALL" || filterDir !== "all") && (
<button onClick={() => { setFilterCcy("ALL"); setFilterCat("ALL"); setFilterDir("all"); }}
className="text-[10px] text-slate-600 hover:text-slate-400">
Effacer filtres ×
</button>
)}
</div>
{/* ── Liste ───────────────────────────────────────────────────────────── */}
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12 text-slate-600"> <div className="flex flex-col items-center justify-center py-16 text-slate-600 gap-3">
<Loader2 size={20} className="animate-spin mr-2" /> <Loader2 size={24} className="animate-spin" />
<span className="text-sm">Chargement des actualités</span> <span className="text-sm">Chargement des actualités</span>
<span className="text-[11px] text-slate-700">InvestingLive · Reuters · Bloomberg</span>
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="text-center py-12 text-slate-600 text-sm"> <div className="text-center py-12 text-slate-600 text-sm">
@@ -167,7 +243,11 @@ export default function NewsTab({ items, loading, onRefresh }: Props) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filtered.map(item => ( {filtered.map(item => (
<NewsCard key={item.id} item={item} activeCcy={filterCcy === "ALL" ? null : filterCcy} /> <NewsCard
key={item.id}
item={item}
activeCcy={filterCcy === "ALL" ? null : filterCcy}
/>
))} ))}
</div> </div>
)} )}
@@ -184,98 +264,97 @@ function NewsCard({ item, activeCcy }: { item: NewsItem; activeCcy: Currency | n
? item.impacts.filter(i => i.ccy === activeCcy) ? item.impacts.filter(i => i.ccy === activeCcy)
: item.impacts; : item.impacts;
const overallDir = visibleImpacts.some(i => i.direction === "bullish") && visibleImpacts.some(i => i.direction === "bearish") const overallDir =
? "mixed" visibleImpacts.some(i => i.direction === "bullish") && visibleImpacts.some(i => i.direction === "bearish") ? "mixed"
: visibleImpacts.some(i => i.direction === "bullish") ? "bullish" : visibleImpacts.some(i => i.direction === "bullish") ? "bullish"
: visibleImpacts.some(i => i.direction === "bearish") ? "bearish" : visibleImpacts.some(i => i.direction === "bearish") ? "bearish"
: "neutral"; : "neutral";
const borderColor = // Détecter les catégories prioritaires
overallDir === "bullish" ? "border-emerald-500/20" : const isPriority = item.categories.some(c =>
overallDir === "bearish" ? "border-red-500/20" : ["Discours BC", "Décision Taux", "Crise", "Guerre", "Chef d'État"].includes(c)
);
const borderCls =
overallDir === "bullish" ? "border-emerald-500/25" :
overallDir === "bearish" ? "border-red-500/25" :
overallDir === "mixed" ? "border-amber-500/20" : overallDir === "mixed" ? "border-amber-500/20" :
"border-slate-700/30"; "border-slate-700/30";
const bgColor = const bgCls =
overallDir === "bullish" ? "bg-emerald-500/5" : overallDir === "bullish" ? "bg-emerald-500/5" :
overallDir === "bearish" ? "bg-red-500/5" : overallDir === "bearish" ? "bg-red-500/5" :
"bg-slate-800/30"; "bg-slate-800/30";
// Catégorie la plus prioritaire
const topCat = PRIORITY_CATS.find(p => item.categories.includes(p));
const topMeta = topCat ? CATEGORY_META[topCat] : null;
return ( return (
<div className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden`}> <div className={`rounded-xl border ${borderCls} ${bgCls} overflow-hidden ${isPriority ? "ring-1 ring-offset-0 ring-amber-500/20" : ""}`}>
<div className="p-3"> <div className="p-3">
{/* Header : source + date */} {/* Header */}
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center gap-2 mb-1.5 flex-wrap">
<div className="flex items-center gap-2 flex-wrap"> <span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400"}`}>
<span className={`text-[9px] font-semibold px-2 py-0.5 rounded-full border ${SOURCE_COLORS[item.source] ?? "bg-slate-700/40 text-slate-400 border-slate-700/30"}`}> {item.source}
{item.source} </span>
{topMeta && topCat && (
<span className={`flex items-center gap-1 text-[9px] font-semibold px-2 py-0.5 rounded-full border ${topMeta.color}`}>
{topMeta.icon}
{topMeta.label}
</span> </span>
{item.categories.slice(0, 2).map(cat => ( )}
<span key={cat} className={`text-[9px] px-1.5 py-0.5 rounded-full ${CAT_COLORS[cat] ?? "bg-slate-700/20 text-slate-500"}`}> {isPriority && (
{cat} <span className="text-[9px] text-amber-500 font-bold"> Prioritaire</span>
</span> )}
))} <span className="ml-auto text-[10px] text-slate-600 shrink-0">{formatRelativeTime(item.publishedAt)}</span>
</div>
<span className="text-[10px] text-slate-600 shrink-0">{formatRelativeTime(item.publishedAt)}</span>
</div> </div>
{/* Titre */} {/* Titre */}
<a <a href={item.url} target="_blank" rel="noopener noreferrer"
href={item.url} className="group flex items-start gap-1.5 mb-1.5">
target="_blank" <span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white transition-colors">
rel="noopener noreferrer"
className="group flex items-start gap-1.5 hover:text-white transition-colors"
>
<span className="text-[12px] text-slate-200 leading-snug font-medium group-hover:text-white">
{item.title} {item.title}
</span> </span>
<ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" /> <ExternalLink size={10} className="text-slate-600 group-hover:text-slate-400 shrink-0 mt-0.5" />
</a> </a>
{/* Summary si dispo */} {/* Résumé */}
{item.summary && ( {item.summary && (
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed line-clamp-2"> <p className="text-[11px] text-slate-500 mb-1.5 leading-relaxed line-clamp-2">
{item.summary} {item.summary}
</p> </p>
)} )}
{/* Impact badges */} {/* Impact badges + bouton détail */}
{visibleImpacts.length > 0 && ( {visibleImpacts.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap items-center gap-1.5">
{visibleImpacts.map(imp => ( {visibleImpacts.map(imp => (
<div <div key={imp.ccy} title={imp.reason}
key={imp.ccy}
className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-semibold ${ className={`flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full font-semibold ${
imp.direction === "bullish" ? "bg-emerald-500/15 text-emerald-400" : imp.direction === "bullish" ? "bg-emerald-500/15 text-emerald-400" :
imp.direction === "bearish" ? "bg-red-500/15 text-red-400" : imp.direction === "bearish" ? "bg-red-500/15 text-red-400" :
"bg-slate-700/40 text-slate-400" "bg-slate-700/40 text-slate-400"
}`} }`}>
title={imp.reason}
>
{CCY_FLAGS[imp.ccy]} {imp.ccy} {CCY_FLAGS[imp.ccy]} {imp.ccy}
{imp.direction === "bullish" ? <TrendingUp size={9} /> : {imp.direction === "bullish" ? <TrendingUp size={9} /> :
imp.direction === "bearish" ? <TrendingDown size={9} /> : imp.direction === "bearish" ? <TrendingDown size={9} /> :
<Minus size={9} />} <Minus size={9} />}
</div> </div>
))} ))}
<button onClick={() => setExpanded(e => !e)}
{visibleImpacts.length > 0 && ( className="text-[9px] text-slate-600 hover:text-slate-400 px-1.5 py-0.5 rounded-full border border-slate-700/30 ml-auto">
<button {expanded ? "▲" : "▼ Analyse"}
onClick={() => setExpanded(e => !e)} </button>
className="text-[9px] text-slate-600 hover:text-slate-400 px-1.5 py-0.5 rounded-full border border-slate-700/30"
>
{expanded ? "Masquer" : "Détail"}
</button>
)}
</div> </div>
)} )}
{/* Détail des raisons */} {/* Détail des raisons */}
{expanded && visibleImpacts.length > 0 && ( {expanded && visibleImpacts.length > 0 && (
<div className="mt-2 space-y-1 border-t border-slate-700/30 pt-2"> <div className="mt-2 space-y-1.5 border-t border-slate-700/30 pt-2">
{visibleImpacts.map(imp => ( {visibleImpacts.map(imp => (
<div key={imp.ccy} className="flex items-start gap-1.5 text-[10px]"> <div key={imp.ccy} className="flex items-start gap-2 text-[10px]">
<span className={`shrink-0 font-bold ${ <span className={`shrink-0 font-bold whitespace-nowrap ${
imp.direction === "bullish" ? "text-emerald-400" : imp.direction === "bullish" ? "text-emerald-400" :
imp.direction === "bearish" ? "text-red-400" : "text-slate-500" imp.direction === "bearish" ? "text-red-400" : "text-slate-500"
}`}> }`}>
+166 -2
View File
@@ -35,7 +35,167 @@ interface ImpactRule {
reason: string; reason: string;
} }
// ── Noms des gouverneurs de banques centrales ─────────────────────────────────
// Mis à jour juin 2026
const CB_GOVERNORS: Record<Currency, string[]> = {
USD: ["Powell", "Jerome Powell", "FOMC chair"],
EUR: ["Lagarde", "Christine Lagarde", "ECB president", "ECB chief"],
GBP: ["Bailey", "Andrew Bailey", "BoE governor"],
JPY: ["Ueda", "Kazuo Ueda", "BoJ governor", "Bank of Japan governor"],
CHF: ["Schlegel", "Martin Schlegel", "SNB chairman", "SNB chair"],
CAD: ["Macklem", "Tiff Macklem", "BoC governor"],
AUD: ["Bullock", "Michele Bullock", "RBA governor"],
NZD: ["Orr", "Adrian Orr", "RBNZ governor"],
};
// ── Noms des chefs d'État pertinents (sauf EUR : uniquement Lagarde) ──────────
const HEADS_OF_STATE: Record<string, { ccy: Currency; names: string[] }> = {
USD: { ccy: "USD", names: ["Trump", "Biden", "US president", "White House", "US treasury", "Bessent", "Yellen", "Scott Bessent"] },
GBP: { ccy: "GBP", names: ["Starmer", "Keir Starmer", "UK prime minister", "UK chancellor", "Rachel Reeves"] },
JPY: { ccy: "JPY", names: ["Ishiba", "Shigeru Ishiba", "Japan prime minister", "Japan PM", "Japanese PM", "Akazawa"] },
CAD: { ccy: "CAD", names: ["Carney", "Mark Carney", "Canada prime minister", "Canadian PM"] },
AUD: { ccy: "AUD", names: ["Albanese", "Anthony Albanese", "Australia prime minister", "Australian PM"] },
NZD: { ccy: "NZD", names: ["Luxon", "Christopher Luxon", "New Zealand PM", "NZ prime minister"] },
CHF: { ccy: "CHF", names: ["Swiss Federal Council", "Swiss government", "Swiss president"] },
};
// Génère les règles pour les gouverneurs et chefs d'État
function buildPersonRules(): ImpactRule[] {
const rules: ImpactRule[] = [];
// Gouverneurs CB
for (const [ccyStr, names] of Object.entries(CB_GOVERNORS)) {
const ccy = ccyStr as Currency;
const pattern = new RegExp(`\\b(${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "i");
rules.push({
pattern,
impacts: [{ ccy, dir: "neutral" }],
categories: ["Discours BC"],
reason: `Déclaration gouverneur ${ccy}`,
});
}
// Chefs d'État
for (const { ccy, names } of Object.values(HEADS_OF_STATE)) {
const pattern = new RegExp(`\\b(${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "i");
rules.push({
pattern,
impacts: [{ ccy, dir: "neutral" }],
categories: ["Chef d'État"],
reason: `Déclaration politique officielle ${ccy}`,
});
}
return rules;
}
const IMPACT_RULES: ImpactRule[] = [ const IMPACT_RULES: ImpactRule[] = [
// ── Décisions de taux (priorité maximale) ─────────────────────────────────
{
pattern: /\b(rate (decision|cut|hike|hold|change|increase|decrease)|interest rate (cut|hike|hold|decision|change)|rate (unchanged|on hold)|basis point(s)?|bps (cut|hike))\b/i,
impacts: [],
categories: ["Décision Taux"],
reason: "Décision de taux directeur",
},
// ── Presse/Conférences CB ─────────────────────────────────────────────────
{
pattern: /\b(press conference|monetary policy (statement|meeting|decision|minutes)|forward guidance|dot plot|summary of projections|soe|statement on (monetary|rates))\b/i,
impacts: [],
categories: ["Discours BC"],
reason: "Communication banque centrale officielle",
},
// ── Données clés emploi ────────────────────────────────────────────────────
{
pattern: /\b(unemployment (rate|data|report|figures)|non.?farm payrolls|nfp|jobs (report|data|figures)|jobless (rate|claims)|employment (change|data|report|figures)|labor market|labour market|payroll(s)?)\b/i,
impacts: [],
categories: ["Données Clés", "Emploi"],
reason: "Publication données emploi",
},
// ── Données clés inflation ─────────────────────────────────────────────────
{
pattern: /\b(cpi (data|release|report|print|reading|figure)|inflation (data|report|release|print|reading|figure)|consumer price index (release|data|report)|pce deflator)\b/i,
impacts: [],
categories: ["Données Clés", "Inflation"],
reason: "Publication données inflation",
},
// ── Crises financières ─────────────────────────────────────────────────────
{
pattern: /\b(banking (crisis|collapse|failure|run|contagion)|financial (crisis|contagion|meltdown|turmoil)|bank (collapse|bail.?out|default|insolvency)|systemic (risk|crisis)|credit (crisis|crunch|event))\b/i,
impacts: [
{ ccy: "JPY", dir: "bullish" },
{ ccy: "CHF", dir: "bullish" },
{ ccy: "USD", dir: "bullish" },
{ ccy: "AUD", dir: "bearish" },
{ ccy: "NZD", dir: "bearish" },
],
categories: ["Crise", "Risk-Off"],
reason: "Crise financière → refuges (JPY/CHF/USD) haussiers",
},
// ── Guerre Ukraine ─────────────────────────────────────────────────────────
{
pattern: /\b(ukraine|zelenskyy|zelensky|kyiv|russia (attack|invade|shelling|drone|missile)|nato (ukraine|summit|support)|war in europe|eastern europe (conflict|war|tension))\b/i,
impacts: [
{ ccy: "EUR", dir: "bearish" },
{ ccy: "JPY", dir: "bullish" },
{ ccy: "CHF", dir: "bullish" },
{ ccy: "USD", dir: "bullish" },
{ ccy: "AUD", dir: "bullish" }, // commodity/wheat exporter
],
categories: ["Géopolitique", "Guerre"],
reason: "Guerre Ukraine → EUR baissier (proximité), refuges (JPY/CHF/USD) haussiers, AUD haussier (blé/gaz)",
},
// ── Détroit d'Hormuz / Moyen-Orient ───────────────────────────────────────
{
pattern: /\b(strait of hormuz|hormuz|houthi(s)?|red sea (attack|blockade|shipping)|iran (attack|sanction|nuclear|strait)|middle east (conflict|escalat|war|tension)|israel.?(hamas|hezbollah|iran)|persian gulf (tension|conflict))\b/i,
impacts: [
{ ccy: "JPY", dir: "bearish" }, // Japon importe ~90% énergie via Hormuz
{ ccy: "CAD", dir: "bullish" }, // Pétrole monte
{ ccy: "CHF", dir: "bullish" },
{ ccy: "USD", dir: "bullish" },
],
categories: ["Géopolitique", "Énergie", "Guerre"],
reason: "Tensions Hormuz → pétrole hausse (CAD bullish), JPY baissier (90% énergie importée via Hormuz)",
},
// ── Tensions Taiwan / Asie du Pacifique ───────────────────────────────────
{
pattern: /\b(taiwan (strait|tension|conflict|independence|invasion|crisis|blockade)|china (taiwan|military|invasion)|south china sea (tension|dispute|conflict))\b/i,
impacts: [
{ ccy: "AUD", dir: "bearish" },
{ ccy: "NZD", dir: "bearish" },
{ ccy: "JPY", dir: "bullish" },
{ ccy: "USD", dir: "bullish" },
],
categories: ["Géopolitique", "Guerre"],
reason: "Tensions Taiwan → AUD/NZD baissiers (exposition Chine), JPY/USD haussiers (refuges/proxémité)",
},
// ── Russie / Sanctions ─────────────────────────────────────────────────────
{
pattern: /\b(russia (sanction|oil cap|energy|export ban|gas supply|lng)|(new )?(sanction|embargo) (on|against) russia|russian (oil|gas|energy|export))\b/i,
impacts: [
{ ccy: "EUR", dir: "bearish" },
{ ccy: "CAD", dir: "bullish" },
{ ccy: "AUD", dir: "bullish" },
],
categories: ["Géopolitique", "Énergie"],
reason: "Sanctions Russie → EUR baissier (dépendance gaz), CAD/AUD haussiers (exportateurs énergie alternatifs)",
},
// ── Press release probabilité de taux ─────────────────────────────────────
{
pattern: /\b(rate (cut|hike) (probability|odds|chance(s)?|expectation(s)?|pricing)|market (prices? in|expects?|anticipates?) (cut|hike|pause|hold)|(ois|swap|futures?) (price(s)?|imply|signal) (cut|hike|easing|tightening)|probability of (rate|cut|hike) (change|move|rise|fall))\b/i,
impacts: [],
categories: ["Probabilités Taux"],
reason: "Repricing des probabilités de taux OIS/futures",
},
// ── Fed / USD ────────────────────────────────────────────────────────────── // ── Fed / USD ──────────────────────────────────────────────────────────────
{ {
pattern: /\b(hawkish fed|fed hike|fed rate (up|rise)|us inflation (surge|higher|above)|fomc (hike|tighten))\b/i, pattern: /\b(hawkish fed|fed hike|fed rate (up|rise)|us inflation (surge|higher|above)|fomc (hike|tighten))\b/i,
@@ -320,12 +480,16 @@ const IMPACT_RULES: ImpactRule[] = [
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
// Règles dynamiques gouverneurs/chefs d'État (construites une seule fois)
const PERSON_RULES: ImpactRule[] = buildPersonRules();
const ALL_RULES: ImpactRule[] = [...IMPACT_RULES, ...PERSON_RULES];
function applyRules(text: string): { impacts: NewsImpact[]; categories: string[] } { function applyRules(text: string): { impacts: NewsImpact[]; categories: string[] } {
const impacts: NewsImpact[] = []; const impacts: NewsImpact[] = [];
const categories = new Set<string>(); const categories = new Set<string>();
const seenCcy = new Set<Currency>(); const seenCcy = new Set<Currency>();
for (const rule of IMPACT_RULES) { for (const rule of ALL_RULES) {
if (!rule.pattern.test(text)) continue; if (!rule.pattern.test(text)) continue;
for (const cat of rule.categories) categories.add(cat); for (const cat of rule.categories) categories.add(cat);
@@ -339,7 +503,7 @@ function applyRules(text: string): { impacts: NewsImpact[]; categories: string[]
if (seenCcy.size >= 8) break; // toutes les devises couvertes if (seenCcy.size >= 8) break; // toutes les devises couvertes
} }
return { impacts, categories: [...categories] }; return { impacts, categories: Array.from(categories) };
} }
function parseDate(raw: string): string { function parseDate(raw: string): string {
+32
View File
@@ -638,6 +638,38 @@ export async function fetchTEUnemploymentRate(): Promise<CoreCPIMap> {
} }
} }
// ── STIR — Taux interbancaire 3 mois ─────────────────────────────────────────
// Source : https://tradingeconomics.com/country-list/3-month-interbank-rate
// Donne le taux de marché à 3 mois (SOFR 3M, EURIBOR 3M, SONIA 3M, TIBOR 3M…)
// pour les 8 devises en une seule requête.
// Interprétation :
// STIR > taux directeur → marché price des hausses → signal hawkish
// STIR < taux directeur → marché price des baisses → signal dovish
// STIR en hausse → conditions de crédit plus restrictives (prêter = plus risqué/cher)
// STIR en baisse → conditions de crédit plus souples (prêter = plus sûr/moins cher)
export async function fetchTESTIRRate(): Promise<CoreCPIMap> {
try {
const res = await fetch(
"https://tradingeconomics.com/country-list/3-month-interbank-rate?continent=world",
{
next: { revalidate: 21600 },
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
}
);
if (!res.ok) { console.warn("[tecpi-stir] HTTP", res.status); return {}; }
const html = await res.text();
return parseCoreInflationHTML(html);
} catch (err) {
console.error("[tecpi-stir] error:", err);
return {};
}
}
// ── GDP Growth Rate QoQ% ────────────────────────────────────────────────────── // ── GDP Growth Rate QoQ% ──────────────────────────────────────────────────────
// Source : https://tradingeconomics.com/country-list/gdp-growth-rate // Source : https://tradingeconomics.com/country-list/gdp-growth-rate
// Une seule requête HTTP (cache 6h) couvre les 8 devises. // Une seule requête HTTP (cache 6h) couvre les 8 devises.
+1 -1
View File
File diff suppressed because one or more lines are too long