mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
auto: sync 2026-06-01 23:46
This commit is contained in:
@@ -5,6 +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 } from "@/lib/tecpi";
|
||||||
|
|
||||||
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
|
||||||
const REVALIDATE = 86400; // cache 24h
|
const REVALIDATE = 86400; // cache 24h
|
||||||
@@ -1002,6 +1003,27 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TE Core CPI (priorité maximale — données du jour, toutes devises) ────────
|
||||||
|
// Remplace les séries FRED erronées :
|
||||||
|
// GBP GBRCPIALLMINMEI = All Items (headline), pas core → écart ~1%
|
||||||
|
// JPY PCPI total (3.6%) ≠ core (1.4%) → erreur massive
|
||||||
|
// USD CPILFESL retard 1 mois → écart 0.2%
|
||||||
|
// AUD/NZD trimestriels → retard jusqu'à 3 mois
|
||||||
|
{
|
||||||
|
const teCpi = await fetchTECoreInflation();
|
||||||
|
const te = teCpi[currency];
|
||||||
|
if (te) {
|
||||||
|
const surprise = parseFloat((te.value - te.prev).toFixed(3));
|
||||||
|
indicators.cpiCore = {
|
||||||
|
value: te.value,
|
||||||
|
prev: te.prev,
|
||||||
|
surprise,
|
||||||
|
trend: surprise > 0 ? "up" : surprise < 0 ? "down" : "flat",
|
||||||
|
lastUpdated: te.refMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stale-if-error
|
// Stale-if-error
|
||||||
const hasAnyValue = Object.values(indicators).some((v) => v !== null);
|
const hasAnyValue = Object.values(indicators).some((v) => v !== null);
|
||||||
if (!hasAnyValue && staleCache) {
|
if (!hasAnyValue && staleCache) {
|
||||||
|
|||||||
+12
-4
@@ -10,6 +10,7 @@ import CurrencyCard from "@/components/CurrencyCard";
|
|||||||
import DriversBar from "@/components/DriversBar";
|
import DriversBar from "@/components/DriversBar";
|
||||||
import CalendarTab from "@/components/CalendarTab";
|
import CalendarTab from "@/components/CalendarTab";
|
||||||
import SentimentPairsTab from "@/components/SentimentPairsTab";
|
import SentimentPairsTab from "@/components/SentimentPairsTab";
|
||||||
|
import YieldsTab from "@/components/YieldsTab";
|
||||||
import type { CalendarEvent } from "@/app/api/calendar/route";
|
import type { CalendarEvent } from "@/app/api/calendar/route";
|
||||||
|
|
||||||
const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000");
|
const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000");
|
||||||
@@ -17,12 +18,12 @@ const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [drivers, setDrivers] = useState<DriverData | null>(null);
|
const [drivers, setDrivers] = useState<DriverData | null>(null);
|
||||||
const [expectations, setExpectations] = useState<Record<string, unknown> | null>(null);
|
const [expectations, setExpectations] = useState<Record<string, unknown> | null>(null);
|
||||||
const [yields, setYields] = useState<{ yields: Record<string, number | null>; spreads: Record<string, number | null> } | null>(null);
|
const [yields, setYields] = useState<{ yields: Record<string, number | null>; spreads: Record<string, number | null>; dayDeltas?: Record<string, number | null> } | null>(null);
|
||||||
const [sentiment, setSentiment] = useState<Record<string, SentimentEntry> | null>(null);
|
const [sentiment, setSentiment] = useState<Record<string, SentimentEntry> | null>(null);
|
||||||
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
|
||||||
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
const [nextWeekAvail, setNextWeekAvail] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs">("dashboard");
|
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields">("dashboard");
|
||||||
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
|
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; totalPositions: number }> | null>(null);
|
||||||
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
|
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||||
@@ -264,7 +265,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
<div className="flex gap-0 border-b border-gray-200 mb-4">
|
<div className="flex gap-0 border-b border-gray-200 mb-4">
|
||||||
{(["dashboard", "calendar", "pairs"] as const).map((tab) => (
|
{(["dashboard", "calendar", "pairs", "yields"] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -274,7 +275,10 @@ export default function Dashboard() {
|
|||||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab === "dashboard" ? "Dashboard" : tab === "calendar" ? "📅 Calendrier" : "↕ Paires"}
|
{tab === "dashboard" ? "Dashboard"
|
||||||
|
: tab === "calendar" ? "📅 Calendrier"
|
||||||
|
: tab === "pairs" ? "↕ Paires"
|
||||||
|
: "📈 Yields 10Y"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,6 +335,10 @@ export default function Dashboard() {
|
|||||||
<SentimentPairsTab symbols={rawSymbols} />
|
<SentimentPairsTab symbols={rawSymbols} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "yields" && (
|
||||||
|
<YieldsTab yieldsData={yields} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-6 text-center text-xs text-gray-400 space-y-1">
|
<footer className="mt-6 text-center text-xs text-gray-400 space-y-1">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// YieldsTab — Rendements souverains 10Y : classement, opportunités carry, divergences
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { TrendingUp, TrendingDown, Minus, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface YieldsData {
|
||||||
|
yields: Record<string, number | null>;
|
||||||
|
spreads: Record<string, number | null>;
|
||||||
|
dayDeltas?: Record<string, number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
yieldsData: YieldsData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CCY_FLAGS: Record<string, string> = {
|
||||||
|
USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵",
|
||||||
|
AUD: "🇦🇺", CAD: "🇨🇦", CHF: "🇨🇭", NZD: "🇳🇿",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CCY_COUNTRY: Record<string, string> = {
|
||||||
|
USD: "États-Unis", EUR: "Zone Euro", GBP: "Royaume-Uni",
|
||||||
|
JPY: "Japon", AUD: "Australie", CAD: "Canada",
|
||||||
|
CHF: "Suisse", NZD: "Nvl-Zélande",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(n: number | null, dec = 3): string {
|
||||||
|
return n !== null ? n.toFixed(dec) + "%" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaColor(d: number | null) {
|
||||||
|
if (d === null || d === 0) return "text-gray-400";
|
||||||
|
return d > 0 ? "text-green-600" : "text-red-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaIcon(d: number | null) {
|
||||||
|
if (d === null || Math.abs(d) < 0.001) return <Minus size={10} />;
|
||||||
|
return d > 0 ? <TrendingUp size={10} /> : <TrendingDown size={10} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Niveau d'écart en bps → label d'opportunité
|
||||||
|
function carryLabel(bps: number): { label: string; color: string } {
|
||||||
|
if (bps >= 400) return { label: "Très fort", color: "text-emerald-700 bg-emerald-50 border-emerald-200" };
|
||||||
|
if (bps >= 200) return { label: "Fort", color: "text-green-700 bg-green-50 border-green-200" };
|
||||||
|
if (bps >= 100) return { label: "Modéré", color: "text-yellow-700 bg-yellow-50 border-yellow-200" };
|
||||||
|
return { label: "Faible", color: "text-gray-600 bg-gray-50 border-gray-200" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function YieldsTab({ yieldsData }: Props) {
|
||||||
|
if (!yieldsData) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-48 text-gray-400 text-sm">
|
||||||
|
Chargement des rendements…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { yields, dayDeltas = {} } = yieldsData;
|
||||||
|
|
||||||
|
// Filtre les devises avec données, tri décroissant
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return (Object.entries(yields) as [string, number | null][])
|
||||||
|
.filter(([, v]) => v !== null)
|
||||||
|
.sort(([, a], [, b]) => (b as number) - (a as number)) as [string, number][];
|
||||||
|
}, [yields]);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-48 text-gray-400 text-sm">
|
||||||
|
Données indisponibles
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxYield = sorted[0][1];
|
||||||
|
const minYield = sorted[sorted.length - 1][1];
|
||||||
|
|
||||||
|
// Top carry pairs (toutes combinaisons triées par écart décroissant)
|
||||||
|
const carryPairs = useMemo(() => {
|
||||||
|
const pairs: { long: string; short: string; bps: number }[] = [];
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
const [cLong, yLong] = sorted[i];
|
||||||
|
const [cShort, yShort] = sorted[j];
|
||||||
|
pairs.push({ long: cLong, short: cShort, bps: Math.round((yLong - yShort) * 100) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs.sort((a, b) => b.bps - a.bps);
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
const topCarry = carryPairs.slice(0, 6);
|
||||||
|
const tightPairs = [...carryPairs].sort((a, b) => a.bps - b.bps).slice(0, 4);
|
||||||
|
|
||||||
|
// Divergences : devises dont la direction journalière est opposée à la majorité
|
||||||
|
const divergences = useMemo(() => {
|
||||||
|
const deltas = sorted.map(([ccy]) => ({ ccy, d: dayDeltas[ccy] ?? 0 }));
|
||||||
|
const rising = deltas.filter(x => x.d > 0.001).length;
|
||||||
|
const falling = deltas.filter(x => x.d < -0.001).length;
|
||||||
|
const majority = rising >= falling ? "up" : "down";
|
||||||
|
|
||||||
|
return deltas
|
||||||
|
.filter(x => {
|
||||||
|
if (majority === "up" && x.d < -0.001) return true;
|
||||||
|
if (majority === "down" && x.d > 0.001) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.sort((a, b) => Math.abs(b.d) - Math.abs(a.d));
|
||||||
|
}, [sorted, dayDeltas]);
|
||||||
|
|
||||||
|
// Movers du jour (triés par amplitude de variation)
|
||||||
|
const movers = useMemo(() => {
|
||||||
|
return sorted
|
||||||
|
.map(([ccy]) => ({ ccy, d: dayDeltas[ccy] ?? 0 }))
|
||||||
|
.filter(x => Math.abs(x.d) >= 0.001)
|
||||||
|
.sort((a, b) => Math.abs(b.d) - Math.abs(a.d));
|
||||||
|
}, [sorted, dayDeltas]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800">
|
||||||
|
Rendements Souverains 10Y
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
|
Source : Trading Economics (données du jour) · EUR = Bund allemand
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[10px] text-gray-400">
|
||||||
|
<span className="font-semibold text-green-600">{sorted[0][0]} {fmt(maxYield)} ↑ max</span>
|
||||||
|
<span className="mx-2 text-gray-300">|</span>
|
||||||
|
<span className="font-semibold text-blue-600">{sorted.at(-1)![0]} {fmt(minYield)} ↓ min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Classement barres horizontales ──────────────────────────────────── */}
|
||||||
|
<div className="bg-white border border-gray-100 rounded-xl p-4">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
|
Classement Yield 10Y
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sorted.map(([ccy, yld], i) => {
|
||||||
|
const pct = maxYield > 0 ? (yld / maxYield) * 100 : 0;
|
||||||
|
const delta = dayDeltas[ccy] ?? null;
|
||||||
|
const isMax = i === 0;
|
||||||
|
const isMin = i === sorted.length - 1;
|
||||||
|
const barColor = isMax
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: isMin
|
||||||
|
? "bg-blue-400"
|
||||||
|
: yld > 3.5
|
||||||
|
? "bg-green-400"
|
||||||
|
: yld > 2
|
||||||
|
? "bg-yellow-400"
|
||||||
|
: "bg-blue-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ccy} className="flex items-center gap-3">
|
||||||
|
{/* Devise */}
|
||||||
|
<div className="w-24 flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className="text-sm">{CCY_FLAGS[ccy]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700 w-7">{ccy}</span>
|
||||||
|
{isMax && (
|
||||||
|
<span className="text-[9px] font-medium bg-emerald-100 text-emerald-700 rounded-full px-1">MAX</span>
|
||||||
|
)}
|
||||||
|
{isMin && (
|
||||||
|
<span className="text-[9px] font-medium bg-blue-100 text-blue-700 rounded-full px-1">MIN</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre */}
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-2.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Yield */}
|
||||||
|
<div className="w-16 text-right text-xs font-mono font-semibold text-gray-800">
|
||||||
|
{fmt(yld)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delta jour */}
|
||||||
|
<div className={`w-16 text-right text-[10px] font-mono flex items-center justify-end gap-0.5 ${deltaColor(delta)}`}>
|
||||||
|
{deltaIcon(delta)}
|
||||||
|
{delta !== null ? (delta >= 0 ? "+" : "") + delta.toFixed(3) : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Deux colonnes : Top Carry + Spreads Étroits ─────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* Top carry */}
|
||||||
|
<div className="bg-white border border-gray-100 rounded-xl p-4">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
|
🚀 Top Carry — Écarts les plus larges
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topCarry.map(({ long, short, bps }) => {
|
||||||
|
const { label, color } = carryLabel(bps);
|
||||||
|
return (
|
||||||
|
<div key={`${long}${short}`} className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs">{CCY_FLAGS[long]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{long}</span>
|
||||||
|
<ArrowRight size={10} className="text-gray-300" />
|
||||||
|
<span className="text-xs">{CCY_FLAGS[short]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{short}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono font-semibold text-gray-800">
|
||||||
|
+{bps} bps
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] font-medium border rounded-full px-1.5 py-0.5 ${color}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-gray-400 mt-3 border-t border-gray-50 pt-2">
|
||||||
|
Long = devise avec yield le plus élevé · Court = yield le plus faible.
|
||||||
|
Carry = emprunter la devise bon marché pour acheter la devise chère.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spreads étroits */}
|
||||||
|
<div className="bg-white border border-gray-100 rounded-xl p-4">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
|
🔄 Convergence — Spreads les plus étroits
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tightPairs.map(({ long, short, bps }) => (
|
||||||
|
<div key={`${long}${short}`} className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs">{CCY_FLAGS[long]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{long}</span>
|
||||||
|
<span className="text-[9px] text-gray-400">/</span>
|
||||||
|
<span className="text-xs">{CCY_FLAGS[short]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{short}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono font-semibold text-gray-600">
|
||||||
|
{bps} bps
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-medium border rounded-full px-1.5 py-0.5 bg-purple-50 text-purple-700 border-purple-200">
|
||||||
|
Range
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-gray-400 mt-3 border-t border-gray-50 pt-2">
|
||||||
|
Spreads étroits = peu d'avantage de taux entre les deux devises.
|
||||||
|
La paire est davantage guidée par d'autres facteurs (sentiment, données macro).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Divergences + Movers du jour ────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* Divergences */}
|
||||||
|
<div className="bg-white border border-gray-100 rounded-xl p-4">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
|
⚡ Divergences du jour
|
||||||
|
</p>
|
||||||
|
{divergences.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 italic">
|
||||||
|
Aucune divergence notable — tous les yields bougent dans la même direction.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{divergences.map(({ ccy, d }) => (
|
||||||
|
<div key={ccy} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs">{CCY_FLAGS[ccy]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{ccy}</span>
|
||||||
|
<span className="text-[10px] text-gray-400">{CCY_COUNTRY[ccy]}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 text-xs font-mono font-semibold ${deltaColor(d)}`}>
|
||||||
|
{deltaIcon(d)}
|
||||||
|
{d >= 0 ? "+" : ""}{d.toFixed(3)}%
|
||||||
|
<span className={`ml-1 text-[9px] border rounded-full px-1.5 py-0.5 ${
|
||||||
|
d > 0
|
||||||
|
? "bg-green-50 text-green-700 border-green-200"
|
||||||
|
: "bg-red-50 text-red-700 border-red-200"
|
||||||
|
}`}>
|
||||||
|
{d > 0 ? "↑ contre tendance" : "↓ contre tendance"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-gray-400 mt-3 border-t border-gray-50 pt-2">
|
||||||
|
Ces devises bougent à contre-courant des autres ce jour.
|
||||||
|
Signal potentiel de réévaluation ou d'actualité macro spécifique.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Movers du jour */}
|
||||||
|
<div className="bg-white border border-gray-100 rounded-xl p-4">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
|
📊 Movers du jour (variation yield)
|
||||||
|
</p>
|
||||||
|
{movers.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 italic">Pas de mouvement significatif ce jour.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{movers.map(({ ccy, d }, i) => {
|
||||||
|
const maxDelta = Math.max(...movers.map(m => Math.abs(m.d)));
|
||||||
|
const barW = maxDelta > 0 ? (Math.abs(d) / maxDelta) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={ccy} className="flex items-center gap-3">
|
||||||
|
<div className="w-20 flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className="text-xs">{CCY_FLAGS[ccy]}</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{ccy}</span>
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="text-[9px] bg-orange-100 text-orange-700 rounded-full px-1">1er</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${d > 0 ? "bg-green-400" : "bg-red-400"}`}
|
||||||
|
style={{ width: `${barW}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`w-16 text-right text-[10px] font-mono font-semibold ${deltaColor(d)}`}>
|
||||||
|
{d >= 0 ? "+" : ""}{d.toFixed(3)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-gray-400 mt-3 border-t border-gray-50 pt-2">
|
||||||
|
Variation du rendement 10Y par rapport à la clôture de la veille.
|
||||||
|
Un yield qui monte = signal hawkish / pression vendeuse sur l'obligataire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
// lib/tecpi.ts
|
||||||
|
// Scrape tradingeconomics.com/country-list/core-inflation-rate pour les 8 devises.
|
||||||
|
// Une seule requête HTTP (cache 6h via Next.js) — remplace les séries FRED mensuelles
|
||||||
|
// qui avaient de 1 mois à 1 an de retard (GBP GBRCPIALLMINMEI, JPY PCPI total, etc.)
|
||||||
|
|
||||||
|
import type { Currency } from "./types";
|
||||||
|
|
||||||
|
const TE_COUNTRY_CPI: Record<Currency, string> = {
|
||||||
|
USD: "United States",
|
||||||
|
EUR: "Euro Area",
|
||||||
|
GBP: "United Kingdom",
|
||||||
|
JPY: "Japan",
|
||||||
|
CHF: "Switzerland",
|
||||||
|
CAD: "Canada",
|
||||||
|
AUD: "Australia",
|
||||||
|
NZD: "New Zealand",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CoreCPIEntry {
|
||||||
|
value: number; // YoY %
|
||||||
|
prev: number; // valeur précédente
|
||||||
|
refMonth: string; // "2026-04-01"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CoreCPIMap = Partial<Record<Currency, CoreCPIEntry>>;
|
||||||
|
|
||||||
|
// Convertit "Apr/26" → "2026-04-01"
|
||||||
|
function parseRefDate(raw: string): string {
|
||||||
|
const MONTHS: Record<string, string> = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||||
|
};
|
||||||
|
const m = raw.match(/([A-Za-z]+)\/(\d{2})/);
|
||||||
|
if (!m) return "";
|
||||||
|
const month = MONTHS[m[1]] ?? "01";
|
||||||
|
const year = `20${m[2]}`;
|
||||||
|
return `${year}-${month}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTECoreInflation(): Promise<CoreCPIMap> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
"https://tradingeconomics.com/country-list/core-inflation-rate?continent=world",
|
||||||
|
{
|
||||||
|
next: { revalidate: 21600 }, // 6h — données mensuelles, inutile de re-fetcher trop souvent
|
||||||
|
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] HTTP", res.status);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const html = await res.text();
|
||||||
|
return parseCoreInflationHTML(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tecpi] error:", err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCoreInflationHTML(html: string): CoreCPIMap {
|
||||||
|
const result: CoreCPIMap = {};
|
||||||
|
const seen = new Set<Currency>();
|
||||||
|
|
||||||
|
const rowPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = rowPattern.exec(html)) !== null) {
|
||||||
|
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
for (const [ccy, country] of Object.entries(TE_COUNTRY_CPI) as [Currency, string][]) {
|
||||||
|
if (seen.has(ccy)) continue;
|
||||||
|
if (!text.startsWith(country)) continue;
|
||||||
|
|
||||||
|
// Format : "Country Last Previous Mon/YY %"
|
||||||
|
// Last / Previous peuvent être négatifs (ex : Morocco -0.3)
|
||||||
|
const nums = text.match(/-?\d+\.?\d*/g);
|
||||||
|
// Chercher la date de référence (format Mon/YY)
|
||||||
|
const dateMatch = text.match(/([A-Za-z]{3}\/\d{2})/);
|
||||||
|
|
||||||
|
if (nums && nums.length >= 2) {
|
||||||
|
result[ccy] = {
|
||||||
|
value: parseFloat(nums[0]),
|
||||||
|
prev: parseFloat(nums[1]),
|
||||||
|
refMonth: dateMatch ? parseRefDate(dateMatch[1]) : "",
|
||||||
|
};
|
||||||
|
seen.add(ccy);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.size === 8) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user