feat: banques centrales (vote+dot plot+previsions), calendrier elargi, fix InvestingLive, M3 auto

Central bank governance (nouvel onglet Banques centrales) :
- lib/centralBankGovernance.ts + app/api/central-bank-sources : scraping live du
  vote de la derniere reunion, dot plot Fed (SEP), et desormais les previsions
  macro (PIB + inflation) publiees par chaque BC elle-meme (Fed SEP, Eurosystem
  staff projections, BoJ Outlook Report PDF, SNB conditional forecast, BoC MPR,
  RBA SMP). GBP/NZD laisses honnetement vides quand aucune source chiffree
  fiable n'est accessible (RBNZ bloque par Cloudflare).
- components/CentralBankSourcesTab.tsx : nouvel onglet avec cards par banque.

Taux directeurs + Money Supply M3 :
- data/rate_decisions.json corrige (JPY, EUR, NZD etc. etaient perimes d'1-2
  decisions) et desormais auto-maintenu : .github/workflows/update-rate-decisions.yml
  (horaire) detecte les changements de taux via Trading Economics et fait
  glisser current -> prev sans perte de donnee.
- data/money-supply-m3.json (nouveau) + .github/workflows/fetch-money-supply.yml
  (hebdo) : M3 par devise (proxy M2 pour l'USD, la Fed ne publiant plus M3
  depuis 2006), affiche dans CurrencyCard.

Calendrier economique elargi :
- lib/calendar-countries.ts, lib/calendar-taxonomy.ts, lib/fxstreetCalendar.ts :
  couverture pays elargie + classification des evenements + source FXStreet.

Fix InvestingLive :
- lib/investinglive.ts : l'ancienne API WordPress (wp-json) renvoyait 404 depuis
  leur migration Nuxt.js -> reecrit vers api.investinglive.com/api/homepage/articles,
  + fix crash silencieux (Tldr pas toujours un tableau).

Divers :
- .vercel/ ajoute au .gitignore (ne doit jamais etre commite, cf. son propre README).
- scripts/ (lancement PWA, push env Vercel), captures d'ecran, cache InvestingLive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
caty21
2026-07-08 16:46:05 +02:00
parent 82a732fbdf
commit 0657b50c07
38 changed files with 3257 additions and 443 deletions
+64 -40
View File
@@ -6,8 +6,9 @@ export const dynamic = "force-dynamic";
import type { FFEvent } from "@/lib/forexfactory";
import type { Currency } from "@/lib/types";
import { fetchAllCBPaths, extractMeetingEvents } from "@/lib/rateprobability";
import { fetchTECalendarHTML } from "@/lib/tradingeconomics";
import { fetchInvestingCalendar } from "@/lib/investing";
import { fetchTECalendarWide } from "@/lib/tradingeconomics";
import { fetchFXStreetCalendar } from "@/lib/fxstreetCalendar";
import { isExcludedEventTitle, applyImpactFloor } from "@/lib/calendar-taxonomy";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -20,12 +21,21 @@ export type EventCategory =
| "gdp"
| "retail_sales"
| "trade_balance"
| "sentiment"
| "housing"
| "money_supply"
| "trade_detail"
| "regional_fed"
| "portfolio_flows"
| "public_finance"
| "holiday"
| "other";
export interface CalendarEvent {
id: string;
date: string; // ISO string from FF
currency: Currency;
currency: string; // ISO 4217 — univers élargi (45 pays), pas seulement les 8 majeures
countryCode: string; // code pays (plusieurs pays peuvent partager une devise, ex. EUR)
category: EventCategory;
title: string; // display-friendly
rawTitle: string; // original FF title
@@ -52,6 +62,12 @@ export interface CalendarResponse {
const CURRENCIES = new Set<string>(["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"]);
// Devise majeure → code pays CALENDAR_COUNTRIES (pour aligner les réunions BC,
// qui sont par nature au niveau devise, avec le dédupe par pays de TE/investingLive).
const MAJOR_CCY_TO_COUNTRY: Record<string, string> = {
USD: "US", EUR: "EMU", GBP: "UK", JPY: "JP", CHF: "CH", CAD: "CA", AUD: "AU", NZD: "NZ",
};
// ── Category detection ─────────────────────────────────────────────────────────
function detectCategory(title: string): EventCategory {
@@ -199,6 +215,7 @@ function mapEvent(ff: FFEvent): CalendarEvent | null {
id: `${ff.country}_${ff.title}_${ff.date}`.replace(/\s+/g, "_"),
date: ff.date,
currency: ff.country as Currency,
countryCode: ff.country,
category,
title: displayTitle(ff.title, ff.country),
rawTitle: ff.title,
@@ -285,6 +302,7 @@ async function fetchFREDCalendar(
id: `fred_${rd.release_id}_${rd.date}`,
date: isoDate,
currency: def.currency,
countryCode: def.currency,
category: def.category,
title: def.title,
rawTitle: def.title,
@@ -305,16 +323,18 @@ async function fetchFREDCalendar(
}
// ── Dedup key ─────────────────────────────────────────────────────────────────
// Clé déterministe pour identifier un doublon entre TE et Investing.
// Clé déterministe pour identifier un doublon entre TE et investingLive.
// Clé par PAYS (pas devise) : plusieurs pays partagent l'EUR (France, Allemagne,
// Italie...) et publient chacun leurs propres indicateurs le même jour — dédupliquer
// par devise fusionnerait à tort des events distincts (ex. CPI FR ≠ CPI DE).
// PMI et discours BC peuvent avoir plusieurs events dans la même journée →
// on affine à l'heure UTC pour les distinguer.
// Toutes les autres catégories sont uniques par (devise, catégorie, jour).
function dedupeKey(currency: string, category: EventCategory, isoDate: string): string {
function dedupeKey(countryCode: string, category: EventCategory, isoDate: string): string {
if (category === "pmi" || category === "cb_speech") {
return `${currency}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
return `${countryCode}_${category}_${isoDate.slice(0, 13)}`; // YYYY-MM-DDTHH
}
return `${currency}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
return `${countryCode}_${category}_${isoDate.slice(0, 10)}`; // YYYY-MM-DD
}
// ── GET ────────────────────────────────────────────────────────────────────────
@@ -351,15 +371,15 @@ export async function GET() {
toDateObj.setDate(toDateObj.getDate() + 14);
const toDate = toDateObj.toISOString().slice(0, 10);
// Fetch TE HTML + Investing + CB paths toujours en parallèle
// Fetch TE (45 pays) + investingLive (widget FXStreet, 45 pays) + CB paths en parallèle
// FF+FRED uniquement si les deux scraping tombent à vide
const [teEvents, invEvents, cbPaths] = await Promise.all([
fetchTECalendarHTML(fromDate, toDate),
fetchInvestingCalendar(fromDate, toDate),
const [teEvents, ilEvents, cbPaths] = await Promise.all([
fetchTECalendarWide(fromDate, toDate),
fetchFXStreetCalendar(fromDate, toDate),
fetchAllCBPaths(),
]);
const useScraping = teEvents.length > 0 || invEvents.length > 0;
const useScraping = teEvents.length > 0 || ilEvents.length > 0;
// Fetch FF+FRED en secours seulement si les deux scrapers ont échoué
const [ffEvents, fredEvents] = useScraping
@@ -377,23 +397,24 @@ export async function GET() {
date >= thisMonday ? "current" : "prev";
if (useScraping) {
// ── BASE : TE HTML ────────────────────────────────────────────────────────
// ── BASE : Trading Economics (45 pays) ─────────────────────────────────────
// Index de dédupe : clé → index dans events[]
const dedupeIndex = new Map<string, number>();
for (const te of teEvents) {
if (te.category === "other") continue; // filtre les events sans catégorie pertinente
if (isExcludedEventTitle(te.title)) continue; // adjudications, prod. industrielle, énergie/hypothécaire hebdo US, CPI infranational, réunions institutionnelles
const evDate = new Date(te.date);
const key = dedupeKey(te.currency, te.category, te.date);
const key = dedupeKey(te.countryCode, te.category, te.date);
dedupeIndex.set(key, events.length);
events.push({
id: te.id,
date: te.date,
currency: te.currency,
countryCode: te.countryCode,
category: te.category,
title: te.title,
rawTitle: te.title,
impact: te.impact,
impact: applyImpactFloor(te.title, te.impact),
actual: te.actual,
forecast: te.forecast,
previous: te.previous,
@@ -406,35 +427,37 @@ export async function GET() {
});
}
// ── COMPLÉMENT : Investing.com ────────────────────────────────────────────
// ── COMPLÉMENT : investingLive (widget FXStreet) ───────────────────────────
// Dédupe immédiat via dedupeIndex : même clé = doublon, on enrichit seulement.
// Absent de TE = on ajoute l'event Investing directement.
for (const inv of invEvents) {
if (inv.category === "other") continue;
const key = dedupeKey(inv.currency, inv.category, inv.date);
// Absent de TE = on ajoute l'event investingLive directement — c'est ce qui
// permet aux deux sources de se compléter l'une l'autre.
for (const il of ilEvents) {
if (isExcludedEventTitle(il.title)) continue;
const key = dedupeKey(il.countryCode, il.category, il.date);
const existingIdx = dedupeIndex.get(key);
if (existingIdx !== undefined) {
// Doublon — enrichir avec les valeurs manquantes d'Investing
// Doublon — enrichir avec les valeurs manquantes d'investingLive
const ev = events[existingIdx];
if (!ev.actual && inv.actual) ev.actual = inv.actual;
if (!ev.forecast && inv.forecast) ev.forecast = inv.forecast;
if (!ev.previous && inv.previous) ev.previous = inv.previous;
if (!ev.actual && il.actual) ev.actual = il.actual;
if (!ev.forecast && il.forecast) ev.forecast = il.forecast;
if (!ev.previous && il.previous) ev.previous = il.previous;
} else {
// Présent sur Investing mais absent de TE — on l'ajoute
const evDate = new Date(inv.date);
// Présent sur investingLive mais absent de TE — on l'ajoute
const evDate = new Date(il.date);
dedupeIndex.set(key, events.length);
events.push({
id: inv.id,
date: inv.date,
currency: inv.currency,
category: inv.category,
title: inv.title,
rawTitle: inv.title,
impact: inv.impact,
actual: inv.actual,
forecast: inv.forecast,
previous: inv.previous,
isPublished: inv.isPublished,
id: il.id,
date: il.date,
currency: il.currency,
countryCode: il.countryCode,
category: il.category,
title: il.title,
rawTitle: il.title,
impact: applyImpactFloor(il.title, il.impact),
actual: il.actual,
forecast: il.forecast,
previous: il.previous,
isPublished: il.isPublished,
week: weekOf(evDate),
source: "fred",
groupKey: null,
@@ -508,6 +531,7 @@ export async function GET() {
id: `cb_${meeting.currency}_${meeting.dateIso}`,
date: isoDate,
currency: meeting.currency,
countryCode: MAJOR_CCY_TO_COUNTRY[meeting.currency] ?? meeting.currency,
category: "policy_rate",
title: meeting.title + probLabel,
rawTitle: meeting.title,
@@ -545,7 +569,7 @@ export async function GET() {
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const sourceLabel = useScraping
? `tradingeconomics-html(${teEvents.length})+investing(${invEvents.length})+rateprobability`
? `tradingeconomics-html(${teEvents.length})+investinglive(${ilEvents.length})+rateprobability`
: (fredKey ? "forexfactory+fred+rateprobability" : "forexfactory+rateprobability");
const result: CalendarResponse = {
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { fetchAllCBGovernance } from "@/lib/centralBankGovernance";
import type { CBGovernance } from "@/lib/centralBankGovernance";
export const dynamic = "force-dynamic";
export type { CBGovernance, FedDotPlot, FedDot, FedSepHistoryPoint } from "@/lib/centralBankGovernance";
export interface CentralBankSourcesResponse {
data: Record<string, CBGovernance>;
fetchedAt: string;
}
export async function GET() {
const data = await fetchAllCBGovernance();
return NextResponse.json(
{ data, fetchedAt: new Date().toISOString() } satisfies CentralBankSourcesResponse,
{ headers: { "Cache-Control": "s-maxage=3600, stale-while-revalidate=21600" } }
);
}
+105 -17
View File
@@ -5,6 +5,7 @@ import type { Currency } from "@/lib/types";
export const dynamic = "force-dynamic";
import cpiOverridesRaw from "@/data/cpi_overrides.json";
import rateDecisionsRaw from "@/data/rate_decisions.json";
import moneySupplyM3Raw from "@/data/money-supply-m3.json";
import { fetchFFThisWeek, fetchFFEvents } from "@/lib/forexfactory";
import type { FFEvent } from "@/lib/forexfactory";
import { fetchTECoreInflation, fetchTEMoMInflation, fetchTEInflationYoY, fetchTECoreCPIMoM, fetchTECoreConsumerPricesIndex, fetchTEPPIMoM, fetchTECoreInflationPages, fetchTEInflationYoYPages, fetchTEAUDCommodityYoY, fetchTEGDPGrowthRate, fetchTEUnemploymentRate, fetchTESTIRRate, fetchTEEmploymentChange } from "@/lib/tecpi";
@@ -129,6 +130,78 @@ async function boeRate(): Promise<Obs[]> {
} catch { return []; }
}
// ── SNB officiel (CHF policy rate) ─────────────────────────────────────────────
// snb.ch publie ses décisions sous /press-releases-restricted/pre_YYYYMMDD.
// On découvre la plus récente depuis la page listing (1er lien du pattern),
// puis on lit le taux dans le titre : "leaves ... unchanged at X%" / "lowers/raises ... to X%".
async function scrapeSnbRate(): Promise<number | null> {
const headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
};
try {
const listRes = await fetch("https://www.snb.ch/en/the-snb/mandates-goals/monetary-policy/decisions", {
next: { revalidate: REVALIDATE }, headers,
});
if (!listRes.ok) return null;
const listHtml = await listRes.text();
const linkMatch = listHtml.match(/href="(\/en\/publications\/communication\/press-releases-restricted\/pre_\d{8}[^"]*)"/);
if (!linkMatch) return null;
const prRes = await fetch(`https://www.snb.ch${linkMatch[1]}`, {
next: { revalidate: REVALIDATE }, headers,
});
if (!prRes.ok) return null;
const prHtml = await prRes.text();
const rateMatch = prHtml.match(/SNB policy rate\s+(?:unchanged at|to)\s+(-?[\d.]+)%/i);
return rateMatch ? parseFloat(rateMatch[1]) : null;
} catch { return null; }
}
// ── Fed officiel (USD policy rate) ──────────────────────────────────────────────
// fomccalendars.htm liste, pour chaque réunion, un lien de statement
// /newsevents/pressreleases/monetary(YYYYMMDD)a.htm. On prend la réunion passée
// la plus récente puis on lit la fourchette officielle : "target range for the
// federal funds rate at/to X to Y percent" (X/Y en fractions type "3-1/2").
// On retourne le haut de fourchette (convention "upper bound" déjà utilisée ici).
function parseFedFraction(s: string): number {
const m = s.match(/^(\d+)(?:-(\d+)\/(\d+))?$/);
if (!m) return NaN;
const whole = parseFloat(m[1]);
return m[2] ? whole + parseFloat(m[2]) / parseFloat(m[3]) : whole;
}
async function scrapeFedRate(): Promise<number | null> {
const headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
};
try {
const calRes = await fetch("https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", {
next: { revalidate: REVALIDATE }, headers,
});
if (!calRes.ok) return null;
const calHtml = await calRes.text();
const todayCompact = new Date().toISOString().slice(0,10).replace(/-/g,"");
const dates = new Set<string>();
for (const m of Array.from(calHtml.matchAll(/\/newsevents\/pressreleases\/monetary(\d{8})a\.htm/g))) dates.add(m[1]);
const latestPast = Array.from(dates).filter(d => d <= todayCompact).sort().reverse()[0];
if (!latestPast) return null;
const stRes = await fetch(`https://www.federalreserve.gov/newsevents/pressreleases/monetary${latestPast}a.htm`, {
next: { revalidate: REVALIDATE }, headers,
});
if (!stRes.ok) return null;
const stHtml = await stRes.text();
const rateMatch = stHtml.match(/target range for the federal funds rate[\s\S]{0,60}?\d+(?:-\d\/\d)?\s*to\s*(\d+(?:-\d\/\d)?)\s*percent/i);
if (!rateMatch) return null;
const upper = parseFedFraction(rateMatch[1]);
return isNaN(upper) ? null : upper;
} catch { return null; }
}
// ── DBnomics API (agrégateur IMF/IFS, BIS, OECD…) ────────────────────────────
// Format : https://api.db.nomics.world/v22/series/{provider}/{dataset}/{code}?observations=1
// Utilisé pour les séries absentes de FRED : JPY CPI, AUD/NZD CPI fallback
@@ -530,6 +603,19 @@ function getRateDecision(ccy: string): RateDecision | null {
return entry?.decisions?.[ccy] ?? null;
}
// ── money-supply-m3.json — masse monétaire M3 (niveau, statique) ─────────────
type MoneySupplyM3 = {
value: number; unit: string; period: string; isProxy: boolean;
proxyLabel?: string; source: string;
};
function getMoneySupplyM3(ccy: string): MoneySupplyM3 | null {
type MsRaw = [{ series: Record<string, MoneySupplyM3> }];
const entry = (moneySupplyM3Raw as unknown as MsRaw)[0];
return entry?.series?.[ccy] ?? null;
}
/** Construit un IndicatorResult à partir de la valeur TE + prev de l'override */
function buildRateIndicator(current: number, prev: number, today: string): IndicatorResult {
const surprise = parseFloat((current - prev).toFixed(4));
@@ -846,11 +932,13 @@ export async function GET(req: NextRequest) {
// USD — midpoint de la fourchette cible (DFEDTARU + DFEDTARL) / 2
// Rateprobability.com / marchés quotent le midpoint (3.625%) pas l'upper bound (3.75%)
// USD — TE scraping (taux Fed upper bound officiel = 3.75%)
// USD — Fed officiel en primaire (source directe federalreserve.gov, cf. scrapeFedRate)
// Repli TE scraping puis FRED upper bound si federalreserve.gov indisponible.
// Ancien calcul midpoint FRED (DFEDTARU+DFEDTARL)/2 = 3.625% → pas le taux annoncé
if (currency === "USD") {
const te = await scrapeTeRate(TE_COUNTRY.USD);
const fed = await scrapeFedRate();
const ovr = getRateDecision("USD");
const te = fed ?? await scrapeTeRate(TE_COUNTRY.USD);
if (te !== null && ovr) {
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
} else if (te !== null) {
@@ -930,15 +1018,17 @@ export async function GET(req: NextRequest) {
}
}
// CHF — TE scraping (taux SNB officiel exact = 0.00%)
// CHF — SNB officiel en primaire (source directe snb.ch, cf. scrapeSnbRate)
// Repli TE scraping si snb.ch indisponible.
// IR3TIB01CHM156N = SARON 3M (~-0.04%) ≠ taux SNB officiel
if (currency === "CHF") {
const te = await scrapeTeRate(TE_COUNTRY.CHF);
const snb = await scrapeSnbRate();
const ovr = getRateDecision("CHF");
if (te !== null && ovr) {
indicators.policyRate = buildRateIndicator(te, ovr.prev, today);
} else if (te !== null) {
indicators.policyRate = { value: te, prev: null, surprise: null, trend: null, lastUpdated: today };
const rate = snb ?? await scrapeTeRate(TE_COUNTRY.CHF);
if (rate !== null && ovr) {
indicators.policyRate = buildRateIndicator(rate, ovr.prev, today);
} else if (rate !== null) {
indicators.policyRate = { value: rate, prev: null, surprise: null, trend: null, lastUpdated: today };
}
}
@@ -1047,18 +1137,16 @@ export async function GET(req: NextRequest) {
toDateObj.setDate(toDateObj.getDate() + 21);
const toDate = toDateObj.toISOString().slice(0, 10);
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts, teForecastMap, invForecastMap] = await Promise.all([
const [ffPMI, pmiMfgRaw, pmiSvcRaw, pmiCompositeRaw, ffForecasts, teForecastMap] = await Promise.all([
fetchFFPMI(currency),
scrapePMI(currency, "manufacturing-pmi"),
scrapePMI(currency, "services-pmi"),
scrapePMI(currency, "composite-pmi"),
fetchFFForecasts(currency),
fetchTEInflationForecasts(today, toDate),
(await import("@/lib/investing")).fetchInvestingInflationForecasts(today, toDate),
]);
const teCpiForecast = teForecastMap[currency];
const invForecast = invForecastMap[currency];
// FF en priorité (forecast + actual) ; TE en fallback
indicators.pmiMfg = ffPMI.mfg ? toPmiIndicator(ffPMI.mfg) : toPmiIndicator(pmiMfgRaw);
indicators.pmiServices = ffPMI.svc ? toPmiIndicator(ffPMI.svc) : toPmiIndicator(pmiSvcRaw);
@@ -1337,14 +1425,14 @@ export async function GET(req: NextRequest) {
const data = {
currency, indicators,
moneySupplyM3: getMoneySupplyM3(currency),
forecasts: {
// CPI — TE calendar forecast (priorité) puis ForexFactory
// Les forecasts TE sont des strings "2.8%" → parseFloat les convertit en number
cpi: parseTeF(teCpiForecast?.cpiYoY) ?? parseTeF(invForecast?.cpiYoY) ?? ffForecasts.cpi ?? null,
cpiCore: parseTeF(teCpiForecast?.cpiCore) ?? parseTeF(invForecast?.cpiCore) ?? null,
cpiMoM: parseTeF(teCpiForecast?.cpiMoM) ?? parseTeF(invForecast?.cpiMoM) ?? null,
cpiCoreMoM: parseTeF(teCpiForecast?.cpiCoreMoM) ?? parseTeF(invForecast?.cpiCoreMoM) ?? null,
ppiMoM: parseTeF(teCpiForecast?.ppiMoM) ?? parseTeF(invForecast?.ppiMoM) ?? null,
cpi: parseTeF(teCpiForecast?.cpiYoY) ?? ffForecasts.cpi ?? null,
cpiCore: parseTeF(teCpiForecast?.cpiCore) ?? null,
cpiMoM: parseTeF(teCpiForecast?.cpiMoM) ?? null,
cpiCoreMoM: parseTeF(teCpiForecast?.cpiCoreMoM) ?? null,
ppiMoM: parseTeF(teCpiForecast?.ppiMoM) ?? null,
cpiSurprise: ffForecasts.cpiSurprise,
unemployment: ffForecasts.unemployment,
unemploymentSurprise: ffForecasts.unemploymentSurprise,
+6 -22
View File
@@ -9,12 +9,10 @@ import { saveCache, loadCache, formatCacheDate } from "@/lib/localCache";
import CurrencyCard from "@/components/CurrencyCard";
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 CotTab from "@/components/CotTab";
import ReportTab from "@/components/ReportTab";
import IdeesTab from "@/components/IdeesTab";
import CentralBankSourcesTab from "@/components/CentralBankSourcesTab";
import { TvAdvancedChart } from "@/components/TvChart";
import type { CalendarEvent } from "@/app/api/calendar/route";
import type { NewsItem } from "@/app/api/news/route";
@@ -30,12 +28,11 @@ export default function Dashboard() {
const [cot, setCot] = useState<Record<string, CotEntry> | null>(null);
const [calEvents, setCalEvents] = useState<CalendarEvent[]>([]);
const [nextWeekAvail, setNextWeekAvail] = useState(false);
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "pairs" | "yields" | "news" | "cot" | "report" | "markets" | "idees">("dashboard");
const [activeTab, setActiveTab] = useState<"dashboard" | "calendar" | "news" | "cot" | "markets" | "idees" | "cbsources">("dashboard");
const [newsItems, setNewsItems] = useState<NewsItem[]>([]);
const [newsLoading, setNewsLoading] = useState(false);
const [cotHistory, setCotHistory] = useState<CotHistory | null>(null);
const [cotLoading, setCotLoading] = useState(false);
const [rawSymbols, setRawSymbols] = useState<Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }> | null>(null);
const [rateProbabilities, setRateProbabilities] = useState<RateProbData | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
@@ -200,7 +197,6 @@ export default function Dashboard() {
// ── Sentiment Myfxbook ────────────────────────────────────────────────
if (sentimentRes.status === "fulfilled" && !sentimentRes.value?.error && sentimentRes.value?.symbols) {
const syms = sentimentRes.value.symbols as Array<{ name: string; longPercentage: number; shortPercentage: number; longVolume: number; shortVolume: number; longPositions: number; shortPositions: number; totalPositions: number; avgLongPrice?: number; avgShortPrice?: number }>;
setRawSymbols(syms);
const mapped = parseSentimentSymbols(syms);
setSentiment(mapped);
saveCache("sentiment", mapped);
@@ -347,7 +343,7 @@ export default function Dashboard() {
{/* Tab navigation */}
<div className="flex gap-0 border-b border-slate-800 mb-4">
{(["dashboard", "markets", "idees", "calendar", "pairs", "yields", "news", "cot", "report"] as const).map((tab) => (
{(["dashboard", "markets", "idees", "calendar", "cbsources", "news", "cot"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -361,11 +357,9 @@ export default function Dashboard() {
: tab === "markets" ? "🌍 Marchés"
: tab === "idees" ? "💡 Idées"
: tab === "calendar" ? "📅 Calendrier"
: tab === "pairs" ? "↕ Paires"
: tab === "yields" ? "📈 Yields 10Y"
: tab === "cbsources" ? "🏛️ Banques centrales"
: tab === "news" ? "📰 Actualités"
: tab === "cot" ? "📊 COT"
: "📋 Rapport"}
: "📊 COT"}
</button>
))}
</div>
@@ -619,13 +613,7 @@ export default function Dashboard() {
<CalendarTab events={calEvents} loading={loading} nextWeekAvail={nextWeekAvail} />
)}
{activeTab === "pairs" && (
<SentimentPairsTab symbols={rawSymbols} />
)}
{activeTab === "yields" && (
<YieldsTab yieldsData={yields} fxDayPct={yields?.fxDayPct ?? null} />
)}
{activeTab === "cbsources" && <CentralBankSourcesTab />}
{activeTab === "news" && (
<NewsTab items={newsItems} loading={newsLoading} onRefresh={refreshNews} />
@@ -637,10 +625,6 @@ export default function Dashboard() {
{activeTab === "idees" && <IdeesTab />}
{activeTab === "report" && (
<ReportTab calEvents={calEvents} drivers={drivers} cotHistory={cotHistory} />
)}
{/* Legend */}
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center gap-5 flex-wrap text-[10px] text-slate-600">
<div className="flex items-center gap-1.5"><span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" /> Haussier / Sous-évalué</div>