diff --git a/app/api/news/route.ts b/app/api/news/route.ts new file mode 100644 index 0000000..7bab08b --- /dev/null +++ b/app/api/news/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { fetchAllNews } from "@/lib/newsfeed"; +import type { NewsItem } from "@/lib/newsfeed"; + +export type { NewsItem } from "@/lib/newsfeed"; + +let _cache: { data: NewsItem[]; ts: number } | null = null; +const TTL = 30 * 60_000; // 30 min + +export async function GET() { + if (_cache && Date.now() - _cache.ts < TTL) { + return NextResponse.json({ items: _cache.data, fetchedAt: new Date(_cache.ts).toISOString() }); + } + + const items = await fetchAllNews(); + _cache = { data: items, ts: Date.now() }; + + return NextResponse.json( + { items, fetchedAt: new Date().toISOString() }, + { headers: { "Cache-Control": "s-maxage=1800, stale-while-revalidate=3600" } } + ); +} diff --git a/app/page.tsx b/app/page.tsx index b7525d1..a0e9a29 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,7 +11,9 @@ import DriversBar from "@/components/DriversBar"; import CalendarTab from "@/components/CalendarTab"; import SentimentPairsTab from "@/components/SentimentPairsTab"; import YieldsTab from "@/components/YieldsTab"; +import NewsTab from "@/components/NewsTab"; import type { CalendarEvent } from "@/app/api/calendar/route"; +import type { NewsItem } from "@/app/api/news/route"; const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000"); @@ -23,7 +25,9 @@ export default function Dashboard() { const [cot, setCot] = useState | null>(null); const [calEvents, setCalEvents] = useState([]); const [nextWeekAvail, setNextWeekAvail] = useState(false); - const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields">("dashboard"); + const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news">("dashboard"); + const [newsItems, setNewsItems] = useState([]); + const [newsLoading, setNewsLoading] = useState(false); const [rawSymbols, setRawSymbols] = useState | null>(null); const [rateProbabilities, setRateProbabilities] = useState(null); const [lastRefresh, setLastRefresh] = useState(new Date()); @@ -212,6 +216,23 @@ export default function Dashboard() { return () => clearInterval(id); }, [refresh]); + const refreshNews = useCallback(async () => { + setNewsLoading(true); + try { + const res = await fetch("/api/news"); + if (res.ok) { + const json = await res.json(); + setNewsItems(json.items ?? []); + } + } finally { + setNewsLoading(false); + } + }, []); + + useEffect(() => { + if (activeTab === "news" && newsItems.length === 0) refreshNews(); + }, [activeTab, newsItems.length, refreshNews]); + const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => { setActiveDivergences((prev) => { const filtered = prev.filter((d) => d.currency !== currency); @@ -271,7 +292,7 @@ export default function Dashboard() { {/* Tab navigation */}
- {(["dashboard", "calendar", "pairs", "yields"] as const).map((tab) => ( + {(["dashboard", "calendar", "pairs", "yields", "news"] as const).map((tab) => ( ))}
@@ -345,6 +367,10 @@ export default function Dashboard() { )} + {activeTab === "news" && ( + + )} + {/* Legend */}
Haussier / Sous-Γ©valuΓ©
diff --git a/components/NewsTab.tsx b/components/NewsTab.tsx new file mode 100644 index 0000000..7830042 --- /dev/null +++ b/components/NewsTab.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { ExternalLink, RefreshCw, TrendingUp, TrendingDown, Minus, Loader2, Zap } from "lucide-react"; +import type { NewsItem } from "@/app/api/news/route"; +import type { Currency } from "@/lib/types"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const CCY_FLAGS: Record = { + USD: "πŸ‡ΊπŸ‡Έ", EUR: "πŸ‡ͺπŸ‡Ί", GBP: "πŸ‡¬πŸ‡§", JPY: "πŸ‡―πŸ‡΅", + CHF: "πŸ‡¨πŸ‡­", CAD: "πŸ‡¨πŸ‡¦", AUD: "πŸ‡¦πŸ‡Ί", NZD: "πŸ‡³πŸ‡Ώ", +}; + +const CCY_LIST: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]; + +const SOURCE_COLORS: Record = { + "InvestingLive": "bg-amber-500/20 text-amber-400 border-amber-500/30", + "Reuters": "bg-orange-500/20 text-orange-400 border-orange-500/30", + "Bloomberg Economics": "bg-sky-500/20 text-sky-400 border-sky-500/30", + "Bloomberg CB": "bg-sky-500/20 text-sky-400 border-sky-500/30", + "Bloomberg FX": "bg-sky-500/20 text-sky-400 border-sky-500/30", +}; + +const CAT_COLORS: Record = { + "Risk-Off": "bg-red-500/15 text-red-400", + "Risk-On": "bg-emerald-500/15 text-emerald-400", + "GΓ©opolitique": "bg-purple-500/15 text-purple-400", + "Γ‰nergie": "bg-yellow-500/15 text-yellow-400", + "Banque Centrale": "bg-blue-500/15 text-blue-400", + "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", + "Commerce": "bg-indigo-500/15 text-indigo-400", + "Agriculture": "bg-green-500/15 text-green-400", + "Or": "bg-yellow-600/15 text-yellow-400", + "MΓ©taux": "bg-gray-400/15 text-gray-300", +}; + +function formatRelativeTime(isoDate: string): string { + const diff = Date.now() - new Date(isoDate).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "Γ  l'instant"; + if (mins < 60) return `il y a ${mins}min`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `il y a ${hrs}h`; + const days = Math.floor(hrs / 24); + return `il y a ${days}j`; +} + +// ── Composant principal ─────────────────────────────────────────────────────── + +interface Props { + items: NewsItem[]; + loading: boolean; + onRefresh: () => void; +} + +export default function NewsTab({ items, loading, onRefresh }: Props) { + const [filterCcy, setFilterCcy] = useState("ALL"); + const [filterDir, setFilterDir] = useState<"all" | "bullish" | "bearish">("all"); + + const filtered = useMemo(() => { + return items.filter(item => { + if (filterCcy !== "ALL" && !item.impacts.some(i => i.ccy === filterCcy)) return false; + if (filterDir !== "all") { + const hasDir = filterCcy === "ALL" + ? item.impacts.some(i => i.direction === filterDir) + : item.impacts.some(i => i.ccy === filterCcy && i.direction === filterDir); + if (!hasDir) return false; + } + return true; + }); + }, [items, filterCcy, filterDir]); + + // Compter les impacts par devise pour les badges de filtre + const ccyCount = useMemo(() => { + const counts: Partial> = {}; + for (const item of items) { + for (const imp of item.impacts) { + if (!counts[imp.ccy]) counts[imp.ccy] = { bull: 0, bear: 0 }; + if (imp.direction === "bullish") counts[imp.ccy]!.bull++; + if (imp.direction === "bearish") counts[imp.ccy]!.bear++; + } + } + return counts; + }, [items]); + + return ( +
+ {/* Barre de filtres */} +
+
+ + Filtrer par devise + +
+ {(["all", "bullish", "bearish"] as const).map(d => ( + + ))} + +
+
+ +
+ + {CCY_LIST.map(ccy => { + const cnt = ccyCount[ccy]; + if (!cnt) return null; + const isActive = filterCcy === ccy; + return ( + + ); + })} +
+
+ + {/* Liste de news */} + {loading && items.length === 0 ? ( +
+ + Chargement des actualitΓ©s… +
+ ) : filtered.length === 0 ? ( +
+ Aucune actualitΓ© pour ce filtre. +
+ ) : ( +
+ {filtered.map(item => ( + + ))} +
+ )} +
+ ); +} + +// ── NewsCard ────────────────────────────────────────────────────────────────── + +function NewsCard({ item, activeCcy }: { item: NewsItem; activeCcy: Currency | null }) { + const [expanded, setExpanded] = useState(false); + + const visibleImpacts = activeCcy + ? item.impacts.filter(i => i.ccy === activeCcy) + : item.impacts; + + const overallDir = 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 === "bearish") ? "bearish" + : "neutral"; + + const borderColor = + overallDir === "bullish" ? "border-emerald-500/20" : + overallDir === "bearish" ? "border-red-500/20" : + overallDir === "mixed" ? "border-amber-500/20" : + "border-slate-700/30"; + + const bgColor = + overallDir === "bullish" ? "bg-emerald-500/5" : + overallDir === "bearish" ? "bg-red-500/5" : + "bg-slate-800/30"; + + return ( +
+
+ {/* Header : source + date */} +
+
+ + {item.source} + + {item.categories.slice(0, 2).map(cat => ( + + {cat} + + ))} +
+ {formatRelativeTime(item.publishedAt)} +
+ + {/* Titre */} + + + {item.title} + + + + + {/* Summary si dispo */} + {item.summary && ( +

+ {item.summary} +

+ )} + + {/* Impact badges */} + {visibleImpacts.length > 0 && ( +
+ {visibleImpacts.map(imp => ( +
+ {CCY_FLAGS[imp.ccy]} {imp.ccy} + {imp.direction === "bullish" ? : + imp.direction === "bearish" ? : + } +
+ ))} + + {visibleImpacts.length > 0 && ( + + )} +
+ )} + + {/* DΓ©tail des raisons */} + {expanded && visibleImpacts.length > 0 && ( +
+ {visibleImpacts.map(imp => ( +
+ + {CCY_FLAGS[imp.ccy]} {imp.ccy} {imp.direction === "bullish" ? "↑" : imp.direction === "bearish" ? "↓" : "β†’"} + + {imp.reason} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/lib/newsfeed.ts b/lib/newsfeed.ts new file mode 100644 index 0000000..8801271 --- /dev/null +++ b/lib/newsfeed.ts @@ -0,0 +1,581 @@ +// lib/newsfeed.ts +// AgrΓ¨ge les news forex/macro depuis plusieurs sources publiques : +// 1. investinglive.com/forex/ β€” articles forex de Giuseppe Dellamotta +// 2. Reuters RSS β€” flux marchΓ©s publics +// 3. Bloomberg meta tags β€” best-effort (titres publics malgrΓ© paywall) +// Applique un moteur de pertinence devise pour chaque article. + +import type { Currency } from "./types"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface NewsImpact { + ccy: Currency; + direction: "bullish" | "bearish" | "neutral"; + reason: string; +} + +export interface NewsItem { + id: string; + title: string; + url: string; + source: string; + publishedAt: string; // ISO date string + summary?: string; + impacts: NewsImpact[]; + categories: string[]; // "Γ‰nergie", "Banque Centrale", "GΓ©opolitique", etc. +} + +// ── Moteur de pertinence devise ─────────────────────────────────────────────── + +interface ImpactRule { + pattern: RegExp; + impacts: { ccy: Currency; dir: "bullish" | "bearish" | "neutral" }[]; + categories: string[]; + reason: string; +} + +const IMPACT_RULES: ImpactRule[] = [ + // ── Fed / USD ────────────────────────────────────────────────────────────── + { + pattern: /\b(hawkish fed|fed hike|fed rate (up|rise)|us inflation (surge|higher|above)|fomc (hike|tighten))\b/i, + impacts: [{ ccy: "USD", dir: "bullish" }], + categories: ["Banque Centrale", "USD"], + reason: "Fed hawkish β†’ USD haussier", + }, + { + pattern: /\b(fed (cut|ease|pivot|pause)|dovish fed|us inflation (cool|drop|lower|ease)|fomc (cut|ease|pause))\b/i, + impacts: [{ ccy: "USD", dir: "bearish" }], + categories: ["Banque Centrale", "USD"], + reason: "Fed dovish β†’ USD baissier", + }, + { + pattern: /\b(fed|fomc|federal reserve|powell|us monetary policy|us interest rate)\b/i, + impacts: [{ ccy: "USD", dir: "neutral" }], + categories: ["Banque Centrale", "USD"], + reason: "Politique monΓ©taire Fed", + }, + + // ── ECB / EUR ────────────────────────────────────────────────────────────── + { + pattern: /\b(ecb (cut|ease|lower rate|dovish)|lagarde (cut|dovish|ease)|eurozone (recession|weak|slow|deflat))\b/i, + impacts: [{ ccy: "EUR", dir: "bearish" }], + categories: ["Banque Centrale", "EUR"], + reason: "BCE dovish β†’ EUR baissier", + }, + { + pattern: /\b(ecb (hike|hawkish|hold|tighten)|lagarde (hawkish|hike)|eurozone inflation (higher|above|surge))\b/i, + impacts: [{ ccy: "EUR", dir: "bullish" }], + categories: ["Banque Centrale", "EUR"], + reason: "BCE hawkish β†’ EUR haussier", + }, + { + pattern: /\b(ecb|european central bank|lagarde|eurozone (gdp|cpi|inflation|rate|pmi))\b/i, + impacts: [{ ccy: "EUR", dir: "neutral" }], + categories: ["Banque Centrale", "EUR"], + reason: "Politique monΓ©taire BCE", + }, + + // ── BoE / GBP ────────────────────────────────────────────────────────────── + { + pattern: /\b(boe (cut|ease|dovish)|bank of england (cut|ease|pause)|uk inflation (drop|lower|cool))\b/i, + impacts: [{ ccy: "GBP", dir: "bearish" }], + categories: ["Banque Centrale", "GBP"], + reason: "BoE dovish β†’ GBP baissier", + }, + { + pattern: /\b(boe (hike|hawkish)|bank of england (hike|hawkish)|uk inflation (surge|higher|above|sticky))\b/i, + impacts: [{ ccy: "GBP", dir: "bullish" }], + categories: ["Banque Centrale", "GBP"], + reason: "BoE hawkish β†’ GBP haussier", + }, + { + pattern: /\b(bank of england|boe|sterling|uk (gdp|cpi|inflation|jobs|pmi|rate))\b/i, + impacts: [{ ccy: "GBP", dir: "neutral" }], + categories: ["Banque Centrale", "GBP"], + reason: "Politique monΓ©taire BoE", + }, + + // ── BoJ / JPY ────────────────────────────────────────────────────────────── + { + pattern: /\b(boj (hike|tighten|hawkish)|bank of japan (hike|raise|hawkish)|yen (strengthens?|stronger|surge)|japan (rate hike|wage))\b/i, + impacts: [{ ccy: "JPY", dir: "bullish" }], + categories: ["Banque Centrale", "JPY"], + reason: "BoJ hawkish β†’ JPY haussier", + }, + { + pattern: /\b(boj (cut|ease|hold|pause|dovish)|yen (weak|falls?|slide)|japan (rate hold|deflat))\b/i, + impacts: [{ ccy: "JPY", dir: "bearish" }], + categories: ["Banque Centrale", "JPY"], + reason: "BoJ dovish β†’ JPY baissier", + }, + { + pattern: /\b(yen intervention|mof japan|ministry of finance japan|japan (intervene|verbal intervention))\b/i, + impacts: [{ ccy: "JPY", dir: "bullish" }], + categories: ["GΓ©opolitique", "JPY"], + reason: "Intervention MoF β†’ JPY haussier (dΓ©fense du plancher)", + }, + { + pattern: /\b(bank of japan|boj|yen|japan (gdp|cpi|inflation|monetary|pmi))\b/i, + impacts: [{ ccy: "JPY", dir: "neutral" }], + categories: ["Banque Centrale", "JPY"], + reason: "Politique monΓ©taire BoJ", + }, + + // ── SNB / CHF ────────────────────────────────────────────────────────────── + { + pattern: /\b(snb|swiss national bank|swiss franc|switzerland (rate|inflation|gdp))\b/i, + impacts: [{ ccy: "CHF", dir: "neutral" }], + categories: ["Banque Centrale", "CHF"], + reason: "Politique monΓ©taire SNB", + }, + + // ── BoC / CAD ────────────────────────────────────────────────────────────── + { + pattern: /\b(bank of canada|boc|canada (rate|gdp|inflation|jobs)|loonie)\b/i, + impacts: [{ ccy: "CAD", dir: "neutral" }], + categories: ["Banque Centrale", "CAD"], + reason: "Politique monΓ©taire BoC", + }, + + // ── RBA / AUD ────────────────────────────────────────────────────────────── + { + pattern: /\b(rba|reserve bank of australia|australia (rate|gdp|inflation|jobs)|aussie)\b/i, + impacts: [{ ccy: "AUD", dir: "neutral" }], + categories: ["Banque Centrale", "AUD"], + reason: "Politique monΓ©taire RBA", + }, + + // ── RBNZ / NZD ──────────────────────────────────────────────────────────── + { + pattern: /\b(rbnz|reserve bank of new zealand|new zealand (rate|gdp|inflation)|kiwi)\b/i, + impacts: [{ ccy: "NZD", dir: "neutral" }], + categories: ["Banque Centrale", "NZD"], + reason: "Politique monΓ©taire RBNZ", + }, + + // ── PΓ©trole β†’ CAD haussier / JPY NZD baissiers ──────────────────────────── + { + pattern: /\b(oil (price(s)?|surge|rise|higher|rally|soar|spike|record)|crude (oil )?(up|higher|rally|surge)|opec (cut|supply cut|restrict)|energy (price(s)? )?(surge|spike|higher))\b/i, + impacts: [ + { ccy: "CAD", dir: "bullish" }, + { ccy: "JPY", dir: "bearish" }, + { ccy: "NZD", dir: "bearish" }, + ], + categories: ["Γ‰nergie", "CommoditΓ©s"], + reason: "PΓ©trole en hausse β†’ CAD haussier (exportateur), JPY/NZD baissiers (importateurs ~90% Γ©nergie)", + }, + { + pattern: /\b(oil (price(s)?|fall|drop|plunge|lower|crash|collapse|weak)|crude (oil )?(down|lower|fall|plunge)|opec (output|increase|supply up)|oil demand (weak|drop|slow))\b/i, + impacts: [ + { ccy: "CAD", dir: "bearish" }, + { ccy: "JPY", dir: "bullish" }, + { ccy: "NZD", dir: "bullish" }, + ], + categories: ["Γ‰nergie", "CommoditΓ©s"], + reason: "PΓ©trole en baisse β†’ CAD baissier (exportateur), JPY/NZD haussiers (importateurs bΓ©nΓ©ficient)", + }, + + // ── Or β†’ AUD haussier / CHF ──────────────────────────────────────────────── + { + pattern: /\b(gold (price(s)?|rise|rally|surge|record|higher|all.time)|gold (up|soar)|precious metal(s)? (up|rally|higher))\b/i, + impacts: [ + { ccy: "AUD", dir: "bullish" }, + { ccy: "CHF", dir: "bullish" }, + ], + categories: ["CommoditΓ©s", "Or"], + reason: "Or en hausse β†’ AUD haussier (3Γ¨me producteur mondial), CHF haussier (valeur refuge liΓ©e Γ  l'or)", + }, + { + pattern: /\b(gold (fall|drop|plunge|lower|crash)|gold (down|weak)|precious metal(s)? (down|fall|lower))\b/i, + impacts: [ + { ccy: "AUD", dir: "bearish" }, + ], + categories: ["CommoditΓ©s", "Or"], + reason: "Or en baisse β†’ AUD baissier", + }, + + // ── Minerai de fer / MΓ©taux β†’ AUD ───────────────────────────────────────── + { + pattern: /\b(iron ore (price|rise|higher|surge|rally)|steel demand (up|higher|strong)|base metal(s)? (up|rally|higher)|copper (rise|surge|rally|higher))\b/i, + impacts: [{ ccy: "AUD", dir: "bullish" }], + categories: ["CommoditΓ©s", "MΓ©taux"], + reason: "Minerai de fer / mΓ©taux en hausse β†’ AUD haussier (1er exportateur mondial minerai de fer)", + }, + { + pattern: /\b(iron ore (fall|drop|lower|plunge|weak)|steel demand (weak|drop|slow|fall)|base metal(s)? (down|fall|lower))\b/i, + impacts: [{ ccy: "AUD", dir: "bearish" }], + categories: ["CommoditΓ©s", "MΓ©taux"], + reason: "Minerai de fer en baisse β†’ AUD baissier", + }, + + // ── Chine / Demande asiatique β†’ AUD / NZD ───────────────────────────────── + { + pattern: /\b(china (growth|gdp|stimulus|pmi|recovery|demand|boom|strong)|chinese (economy|manufacturing|demand) (up|strong|better|recover|boom|grow)|china stimulus|china (fiscal|monetary) (stimulus|ease|boost))\b/i, + impacts: [ + { ccy: "AUD", dir: "bullish" }, + { ccy: "NZD", dir: "bullish" }, + ], + categories: ["Chine", "GΓ©opolitique"], + reason: "Croissance chinoise β†’ AUD/NZD haussiers (principaux exportateurs vers la Chine : fer, LNG, produits laitiers)", + }, + { + pattern: /\b(china (slowdown|weak|contraction|deflat|property crisis|debt|evergrande|recession)|chinese (economy|demand|pmi) (weak|slow|contract|drop|fall)|china (trade war|tariff))\b/i, + impacts: [ + { ccy: "AUD", dir: "bearish" }, + { ccy: "NZD", dir: "bearish" }, + ], + categories: ["Chine", "GΓ©opolitique"], + reason: "Ralentissement Chine β†’ AUD/NZD baissiers (demande commoditΓ©s en baisse)", + }, + + // ── BlΓ© / Agriculture β†’ USD / AUD / CAD ─────────────────────────────────── + { + pattern: /\b(wheat (price|supply|shortage|disruption|export block|higher)|grain (crisis|supply|shortage)|ukraine (wheat|grain|export)|food (inflation|crisis|price surge))\b/i, + impacts: [ + { ccy: "USD", dir: "bullish" }, + { ccy: "AUD", dir: "bullish" }, + { ccy: "CAD", dir: "bullish" }, + ], + categories: ["Agriculture", "GΓ©opolitique"], + reason: "Disruption blΓ©/cΓ©rΓ©ales β†’ USD/AUD/CAD haussiers (grands exportateurs mondiaux)", + }, + + // ── Produits laitiers β†’ NZD ──────────────────────────────────────────────── + { + pattern: /\b(dairy (price(s)?|index|gdt|auction|higher|surge)|fonterra|milk (price|higher)|new zealand export(s)? (up|higher|strong))\b/i, + impacts: [{ ccy: "NZD", dir: "bullish" }], + categories: ["Agriculture", "NZD"], + reason: "Produits laitiers en hausse β†’ NZD haussier (principal exportateur mondial lait/beurre)", + }, + { + pattern: /\b(dairy (price(s)?|index|gdt|auction) (lower|fall|drop|weak)|fonterra (cut|lower|reduce))\b/i, + impacts: [{ ccy: "NZD", dir: "bearish" }], + categories: ["Agriculture", "NZD"], + reason: "Produits laitiers en baisse β†’ NZD baissier", + }, + + // ── Risk-Off β†’ JPY / CHF haussiers, AUD / NZD baissiers ─────────────────── + { + pattern: /\b(risk[- ]off|safe[- ]haven (demand|flow|bid|surge)|geopolit(ical)? (tension|risk|crisis|escalat)|war|military (conflict|strike|attack|tension)|sanction(s)?|nuclear (threat|risk)|market (crash|sell.off|panic|rout|turmoil)|recession (fear|risk|warning)|stock(s)? (crash|plunge|sell.off))\b/i, + impacts: [ + { ccy: "JPY", dir: "bullish" }, + { ccy: "CHF", dir: "bullish" }, + { ccy: "USD", dir: "bullish" }, + { ccy: "AUD", dir: "bearish" }, + { ccy: "NZD", dir: "bearish" }, + ], + categories: ["Risk-Off", "GΓ©opolitique"], + reason: "Risk-off β†’ JPY/CHF/USD haussiers (valeurs refuges), AUD/NZD baissiers (devises risquΓ©es)", + }, + { + pattern: /\b(risk[- ]on|risk appetite (return|improve|recover)|market (rally|surge|boom|exuberance)|stock(s)? (rally|surge|soar|record)|optimism (return|grow)|peace (deal|agreement|ceasefire))\b/i, + impacts: [ + { ccy: "AUD", dir: "bullish" }, + { ccy: "NZD", dir: "bullish" }, + { ccy: "JPY", dir: "bearish" }, + { ccy: "CHF", dir: "bearish" }, + ], + categories: ["Risk-On"], + reason: "Risk-on β†’ AUD/NZD haussiers, JPY/CHF baissiers (refuges vendus)", + }, + + // ── Tarifs douaniers / Guerre commerciale ────────────────────────────────── + { + pattern: /\b(tariff(s)?|trade war|trade barrier|import duty|import tax|protectionism|trade (sanction|restriction)|export ban)\b/i, + impacts: [ + { ccy: "CAD", dir: "bearish" }, + { ccy: "EUR", dir: "bearish" }, + { ccy: "CNY", dir: "neutral" } as never, + ].filter(i => ["CAD", "EUR"].includes(i.ccy)) as { ccy: Currency; dir: "bullish" | "bearish" | "neutral" }[], + categories: ["GΓ©opolitique", "Commerce"], + reason: "Tarifs douaniers β†’ EUR/CAD baissiers (forte dΓ©pendance export vers US)", + }, + + // ── Inflation gΓ©nΓ©rale ───────────────────────────────────────────────────── + { + pattern: /\b(global inflation (surge|spike|higher|accelerat|above)|inflation (surprise|beat|above expect))\b/i, + impacts: [ + { ccy: "USD", dir: "bullish" }, + { ccy: "GBP", dir: "bullish" }, + ], + categories: ["DonnΓ©es Macro", "Inflation"], + reason: "Inflation surprend β†’ pression sur les banques centrales pour maintenir des taux Γ©levΓ©s", + }, + + // ── Croissance mondiale ──────────────────────────────────────────────────── + { + pattern: /\b(global (recession|slowdown|contraction|downturn)|world (growth|gdp) (slow|drop|contract|negative)|imf (cut|lower|downgrade) (forecast|outlook|gdp))\b/i, + impacts: [ + { ccy: "AUD", dir: "bearish" }, + { ccy: "NZD", dir: "bearish" }, + { ccy: "CAD", dir: "bearish" }, + { ccy: "JPY", dir: "bullish" }, + { ccy: "CHF", dir: "bullish" }, + ], + categories: ["DonnΓ©es Macro", "GΓ©opolitique"], + reason: "RΓ©cession mondiale β†’ refuges (JPY/CHF) haussiers, commoditΓ©s (AUD/NZD/CAD) baissiers", + }, +]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function applyRules(text: string): { impacts: NewsImpact[]; categories: string[] } { + const impacts: NewsImpact[] = []; + const categories = new Set(); + const seenCcy = new Set(); + + for (const rule of IMPACT_RULES) { + if (!rule.pattern.test(text)) continue; + + for (const cat of rule.categories) categories.add(cat); + + for (const { ccy, dir } of rule.impacts) { + if (seenCcy.has(ccy)) continue; // premiΓ¨re rΓ¨gle matchΓ©e par devise gagne + seenCcy.add(ccy); + impacts.push({ ccy, direction: dir, reason: rule.reason }); + } + + if (seenCcy.size >= 8) break; // toutes les devises couvertes + } + + return { impacts, categories: [...categories] }; +} + +function parseDate(raw: string): string { + try { + return new Date(raw).toISOString(); + } catch { + return new Date().toISOString(); + } +} + +function extractMeta(html: string, property: string): string { + const m = html.match(new RegExp(`]*(?:name|property)=["']${property}["'][^>]*content=["']([^"']+)["']`, "i")) + ?? html.match(new RegExp(`]*content=["']([^"']+)["'][^>]*(?:name|property)=["']${property}["']`, "i")); + return m?.[1] ?? ""; +} + +const TE_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", +}; + +// ── Source 1 : InvestingLive /forex/ ───────────────────────────────────────── + +async function fetchInvestingLiveNews(): Promise { + try { + const res = await fetch("https://investinglive.com/forex/", { + next: { revalidate: 1800 }, + headers: TE_HEADERS, + }); + if (!res.ok) return []; + const html = await res.text(); + + // Articles listΓ©s sous forme
ou
+ // On cherche les liens + titres + dates dans le HTML + const items: NewsItem[] = []; + + // Pattern :

TITLE

+ datetime="DATE" + const articlePattern = /]*>([\s\S]*?)<\/article>/gi; + let m: RegExpExecArray | null; + + while ((m = articlePattern.exec(html)) !== null && items.length < 15) { + const block = m[1]; + + const linkM = block.match(/href=["'](https?:\/\/investinglive\.com\/[^"']+)["']/i); + const titleM = block.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/a>[\s\S]*?<\/h[1-4]>/i) + ?? block.match(/]*class=["'][^"']*title[^"']*["'][^>]*>([\s\S]*?)<\/a>/i); + const dateM = block.match(/datetime=["']([^"']+)["']/i); + + if (!linkM || !titleM) continue; + + const url = linkM[1]; + const title = titleM[1].replace(/<[^>]+>/g, "").trim(); + const dateStr = dateM ? dateM[1] : ""; + const { impacts, categories } = applyRules(title); + + items.push({ + id: `il-${Buffer.from(url).toString("base64").slice(0, 12)}`, + title, + url, + source: "InvestingLive", + publishedAt: parseDate(dateStr), + impacts, + categories, + }); + } + + return items; + } catch { return []; } +} + +// ── Source 2 : Reuters RSS (marchΓ©s) ───────────────────────────────────────── + +const REUTERS_FEEDS = [ + "https://feeds.reuters.com/reuters/businessNews", + "https://feeds.reuters.com/reuters/topNews", +]; + +function parseRssItems(xml: string, source: string): NewsItem[] { + const items: NewsItem[] = []; + const itemBlocks = xml.match(/([\s\S]*?)<\/item>/g) ?? []; + + for (const block of itemBlocks.slice(0, 20)) { + const titleM = block.match(/(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i); + const linkM = block.match(/<link>([\s\S]*?)<\/link>/i) + ?? block.match(/<guid[^>]*>([\s\S]*?)<\/guid>/i); + const dateM = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i); + const descM = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); + + if (!titleM || !linkM) continue; + + const title = titleM[1].replace(/<[^>]+>/g, "").trim(); + const url = linkM[1].trim(); + const dateStr = dateM?.[1] ?? ""; + const summary = descM?.[1].replace(/<[^>]+>/g, "").trim().slice(0, 200); + + const combined = `${title} ${summary ?? ""}`; + const { impacts, categories } = applyRules(combined); + + items.push({ + id: `rss-${Buffer.from(url).toString("base64").slice(0, 12)}`, + title, + url, + source, + publishedAt: parseDate(dateStr), + summary, + impacts, + categories, + }); + } + + return items; +} + +async function fetchReutersNews(): Promise<NewsItem[]> { + const results = await Promise.allSettled( + REUTERS_FEEDS.map(async (feedUrl) => { + const res = await fetch(feedUrl, { + next: { revalidate: 1800 }, + headers: { ...TE_HEADERS, "Accept": "application/rss+xml, application/xml, text/xml, */*" }, + }); + if (!res.ok) return []; + const xml = await res.text(); + return parseRssItems(xml, "Reuters"); + }) + ); + + const all: NewsItem[] = []; + const seenUrls = new Set<string>(); + + for (const r of results) { + if (r.status !== "fulfilled") continue; + for (const item of r.value) { + if (!seenUrls.has(item.url)) { + seenUrls.add(item.url); + all.push(item); + } + } + } + + return all; +} + +// ── Source 3 : Bloomberg (meta tags publics) ───────────────────────────────── + +const BLOOMBERG_PAGES = [ + { url: "https://www.bloomberg.com/economics", source: "Bloomberg Economics" }, + { url: "https://www.bloomberg.com/economics/central-banks", source: "Bloomberg CB" }, + { url: "https://www.bloomberg.com/fx-center", source: "Bloomberg FX" }, +]; + +async function fetchBloombergMeta(): Promise<NewsItem[]> { + const items: NewsItem[] = []; + + for (const { url, source } of BLOOMBERG_PAGES) { + try { + const res = await fetch(url, { + next: { revalidate: 3600 }, + headers: TE_HEADERS, + }); + if (!res.ok) continue; + const html = await res.text(); + + // Extraire les articles depuis les meta og:title / article JSON-LD + const ldBlocks = html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) ?? []; + for (const block of ldBlocks.slice(0, 5)) { + try { + const json = JSON.parse(block.replace(/<script[^>]*>|<\/script>/gi, "").trim()); + const articles = Array.isArray(json) ? json : [json]; + for (const art of articles) { + if (!art.headline || !art.url) continue; + const title = String(art.headline); + const artUrl = String(art.url); + const dateStr = String(art.datePublished ?? art.dateModified ?? ""); + const { impacts, categories } = applyRules(title); + items.push({ + id: `bb-${Buffer.from(artUrl).toString("base64").slice(0, 12)}`, + title, + url: artUrl, + source, + publishedAt: parseDate(dateStr), + impacts, + categories, + }); + } + } catch { /* skip malformed JSON-LD */ } + } + + // Fallback: og:title de la page elle-mΓͺme + const ogTitle = extractMeta(html, "og:title"); + const ogDesc = extractMeta(html, "og:description"); + if (ogTitle && ogTitle.length > 10) { + const { impacts, categories } = applyRules(`${ogTitle} ${ogDesc}`); + if (impacts.length > 0) { + items.push({ + id: `bb-page-${Buffer.from(url).toString("base64").slice(0, 12)}`, + title: ogTitle, + url, + source, + publishedAt: new Date().toISOString(), + summary: ogDesc || undefined, + impacts, + categories, + }); + } + } + } catch { /* Bloomberg peut bloquer, on continue */ } + } + + return items; +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +export async function fetchAllNews(): Promise<NewsItem[]> { + const [ilNews, reutersNews, bbNews] = await Promise.allSettled([ + fetchInvestingLiveNews(), + fetchReutersNews(), + fetchBloombergMeta(), + ]); + + const all: NewsItem[] = [ + ...(ilNews.status === "fulfilled" ? ilNews.value : []), + ...(reutersNews.status === "fulfilled" ? reutersNews.value : []), + ...(bbNews.status === "fulfilled" ? bbNews.value : []), + ]; + + // DΓ©dupliquer sur l'URL, trier par date dΓ©croissante + const seenUrls = new Set<string>(); + const deduped = all.filter(item => { + if (seenUrls.has(item.url)) return false; + seenUrls.add(item.url); + return true; + }); + + deduped.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + + return deduped; +}