Forex Dashboard v8.0

This commit is contained in:
Capucine Gest
2026-05-27 13:18:45 +02:00
commit 236afa3087
31 changed files with 9292 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "forex-dashboard",
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+5
View File
@@ -0,0 +1,5 @@
FRED_API_KEY=your_fred_key_here
BYTEZ_API_KEY=your_bytez_key_here
ALPHA_VANTAGE_KEY=your_alphavantage_key_here
NEXT_PUBLIC_REFRESH_INTERVAL_MS=3600000
+5
View File
@@ -0,0 +1,5 @@
.env.local
.next/
node_modules/
*.log
+105
View File
@@ -0,0 +1,105 @@
# Forex Macro Dashboard — v8.0
Tableau de bord macroéconomique pour 8 devises majeures (USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD).
## Installation
### 1. Installer Node.js
Télécharger **Node.js LTS** sur [nodejs.org](https://nodejs.org) (inclut npm).
### 2. Installer les dépendances
```bash
cd forex-dashboard
npm install
```
### 3. Configurer les clés API
Le fichier `.env.local` est déjà créé avec tes clés FRED et Bytez.
Ajouter ta clé OANDA quand disponible :
```
OANDA_API_KEY=ta_clé_oanda_ici
```
**Clé FRED gratuite** : [fred.stlouisfed.org](https://fred.stlouisfed.org) → My Account → API Keys
**Compte OANDA gratuit** : [oanda.com](https://oanda.com) → demo → API Access → Generate token
### 4. Lancer en local
```bash
npm run dev
```
Ouvrir **http://localhost:3000**
## Données rate expectations (banques centrales non-USD)
```bash
pip install requests beautifulsoup4
python scripts/investinglive_scraper.py --n 1 --output json --save data/rate_expectations.json
```
Un snapshot de démonstration (mai 2026) est inclus dans `data/rate_expectations.json`.
## Structure
```
forex-dashboard/
├── app/
│ ├── page.tsx ← dashboard principal
│ ├── layout.tsx
│ ├── globals.css
│ └── api/
│ ├── fred/route.ts ← proxy FRED (cache 1h)
│ ├── fx/route.ts ← Frankfurter ECB (taux spot)
│ ├── cot/route.ts ← CFTC COT parser
│ ├── sentiment/route.ts ← OANDA retail sentiment
│ ├── expectations/route.ts ← rate_expectations.json
│ ├── narrative/route.ts ← Bytez LLM (Llama 3.1)
│ ├── yields/route.ts ← obligations 10Y (FRED, ECB, BoE, BoC)
│ └── drivers/route.ts ← Gold, Brent, VIX, HY/IG spreads
├── components/
│ ├── CurrencyCard.tsx ← card par devise
│ ├── DriversBar.tsx ← barre globale des drivers
│ └── NarrativeButton.tsx ← bouton analyse IA Bytez
├── lib/
│ ├── types.ts ← types TypeScript
│ ├── constants.ts ← séries FRED corrigées, COT codes
│ └── scoring.ts ← algorithme §4 + §6 (divergences)
├── scripts/
│ └── investinglive_scraper.py ← scraper rate expectations
├── data/
│ └── rate_expectations.json ← snapshot mensuel BC
└── .env.local ← clés API (gitignored)
```
## Sources de données intégrées
| Catégorie | Source | Clé |
|-----------|--------|-----|
| Taux directeurs, CPI, PIB, Retail Sales, Emploi | FRED | oui |
| FX Spot | Frankfurter (ECB) | non |
| Obligations 10Y USD | FRED DGS10 | oui |
| Obligations 10Y EUR | ECB Data Portal | non |
| Obligations 10Y GBP | BoE API IUDMNPY | non |
| Obligations 10Y CAD | BoC API | non |
| COT Positionnement | CFTC CSV public | non |
| Sentiment retail | OANDA v20 API | oui (OANDA) |
| Rate expectations BC | investinglive.com scraper | non |
| Analyse narrative | Bytez (Llama 3.1) | oui (Bytez) |
## Déploiement Vercel
```bash
git init && git add . && git commit -m "init forex dashboard"
# Créer repo privé sur github.com puis :
git remote add origin https://github.com/TONUSER/forex-dashboard.git
git push -u origin main
```
Sur [vercel.com](https://vercel.com) : New Project → importer le repo → Settings → Environment Variables → copier les variables de `.env.local`.
**Note** : le scraper Python ne tourne pas sur Vercel. Lancer localement puis committer `data/rate_expectations.json`.
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { COT_CODES } from "@/lib/constants";
import type { Currency } from "@/lib/types";
// CFTC CSV URL — updated weekly on Fridays
const CFTC_URL =
"https://www.cftc.gov/files/dea/history/fut_fin_txt_2024.zip";
// Current year CSV (plain text, no zip)
const CFTC_CURRENT =
"https://www.cftc.gov/sites/default/files/files/dea/cotarchives/2024/futures/FinFutWk062824.txt";
// In-memory cache (server lifetime)
let cotCache: { data: Record<string, unknown>; ts: number } | null = null;
const TTL = 7 * 24 * 3600_000; // 1 week
export async function GET() {
if (cotCache && Date.now() - cotCache.ts < TTL) {
return NextResponse.json(cotCache.data);
}
try {
// Fetch latest COT "Disaggregated" or "Financial" futures CSV
// The public URL pattern for the most recent weekly file:
const now = new Date();
const year = now.getFullYear();
const csvUrl = `https://www.cftc.gov/files/dea/history/fut_fin_txt_${year}.zip`;
// Simpler approach: use the non-compressed annual file (available for current year)
const res = await fetch(
`https://www.cftc.gov/dea/newcot/FinFutWk.txt`,
{ next: { revalidate: 86400 * 7 } }
);
if (!res.ok) {
return NextResponse.json(
{ error: `CFTC fetch failed: ${res.status}`, note: "COT data may be unavailable temporarily." },
{ status: 502 }
);
}
const text = await res.text();
const result = parseCOT(text);
cotCache = { data: result, ts: Date.now() };
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
}
}
function parseCOT(csv: string): Record<string, unknown> {
const lines = csv.split("\n");
if (lines.length < 2) return {};
const header = lines[0].split(",").map((h) => h.replace(/"/g, "").trim());
const result: Record<string, { net: number; longPct: number; shortPct: number }> = {};
const targetCodes = new Set(Object.values(COT_CODES));
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(",").map((v) => v.replace(/"/g, "").trim());
if (row.length < 10) continue;
const codeIdx = header.indexOf("CFTC_Contract_Market_Code");
const longIdx = header.indexOf("NonComm_Positions_Long_All");
const shortIdx = header.indexOf("NonComm_Positions_Short_All");
if (codeIdx < 0 || longIdx < 0 || shortIdx < 0) continue;
const code = row[codeIdx];
if (!targetCodes.has(code)) continue;
const longs = parseInt(row[longIdx] ?? "0", 10);
const shorts = parseInt(row[shortIdx] ?? "0", 10);
const total = longs + shorts;
const net = longs - shorts;
const currency = (Object.entries(COT_CODES) as [Currency, string][]).find(
([, c]) => c === code
)?.[0];
if (!currency) continue;
result[currency] = {
net,
longPct: total > 0 ? Math.round((longs / total) * 100) : 50,
shortPct: total > 0 ? Math.round((shorts / total) * 100) : 50,
};
}
return result;
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse } from "next/server";
// ── Yahoo Finance v7/quote ────────────────────────────────────────────────────
// Un seul appel pour tous les prix marchés : VIX, S&P, commodités.
// Pas de clé API. Gratuit. regularMarketChange = delta vs clôture j-1.
type YQuote = {
symbol: string;
regularMarketPrice: number;
regularMarketChange: number;
regularMarketChangePercent: number;
regularMarketPreviousClose: number;
};
async function yahooQuotes(symbols: string[]): Promise<Record<string, YQuote>> {
try {
const url = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${symbols.join(",")}&fields=regularMarketPrice,regularMarketChange,regularMarketChangePercent,regularMarketPreviousClose`;
const res = await fetch(url, {
next: { revalidate: 3600 }, // 1h — prix de marché
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/json",
},
});
if (!res.ok) return {};
const results: YQuote[] = (await res.json())?.quoteResponse?.result ?? [];
return Object.fromEntries(results.map((q) => [q.symbol, q]));
} catch { return {}; }
}
function yVal(q: YQuote | undefined): number | null {
return q?.regularMarketPrice ?? null;
}
function yDelta(q: YQuote | undefined): number | null {
const d = q?.regularMarketChange;
return d != null ? parseFloat(d.toFixed(2)) : null;
}
function yDeltaPct(q: YQuote | undefined): number | null {
const p = q?.regularMarketChangePercent;
return p != null ? parseFloat(p.toFixed(2)) : null;
}
// ── FRED (spreads crédit + taux — 24h cache) ──────────────────────────────────
async function fredObs(series: string, apiKey: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=1`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const obs = ((await res.json())?.observations ?? []).find(
(o: { value: string }) => o.value !== "."
);
return obs ? parseFloat(obs.value) : null;
} catch { return null; }
}
// ── CoinGecko (Bitcoin — gratuit, sans clé, cache 1h) ────────────────────────
async function coingeckoBTC() {
try {
const url = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true";
const res = await fetch(url, { next: { revalidate: 3600 } });
if (!res.ok) return { value: null, change24h: null };
const d = await res.json();
return { value: d?.bitcoin?.usd ?? null, change24h: d?.bitcoin?.usd_24h_change ?? null };
} catch { return { value: null, change24h: null }; }
}
// ── Alpha Vantage (S&P 500 via SPY — fallback si Yahoo échoue) ───────────────
async function avGlobalQuote(symbol: string, avKey: string) {
try {
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${symbol}&apikey=${avKey}`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return { value: null, changePct: null };
const q = (await res.json())?.["Global Quote"];
if (!q) return { value: null, changePct: null };
const value = parseFloat(q["05. price"]);
const changePct = parseFloat((q["10. change percent"] ?? "0%").replace("%", ""));
return {
value: isNaN(value) ? null : value,
changePct: isNaN(changePct) ? null : changePct,
};
} catch { return { value: null, changePct: null }; }
}
// ── GET ───────────────────────────────────────────────────────────────────────
export async function GET() {
const fredKey = process.env.FRED_API_KEY;
const avKey = process.env.ALPHA_VANTAGE_KEY;
if (!fredKey) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// 1. Yahoo Finance — tout en un seul appel (VIX + S&P + commodités + BTC)
const [quotes, btcCgRes] = await Promise.all([
yahooQuotes(["^VIX", "^GSPC", "GC=F", "SI=F", "BZ=F", "CL=F", "BTC-USD"]),
coingeckoBTC(), // fallback si Yahoo échoue pour BTC
]);
const vix = quotes["^VIX"];
const sp500 = quotes["^GSPC"];
const gold = quotes["GC=F"];
const silver = quotes["SI=F"];
const brent = quotes["BZ=F"];
const wti = quotes["CL=F"];
const btcYQ = quotes["BTC-USD"]; // Yahoo primary pour BTC
// Fallback S&P via AV si Yahoo a échoué (rare)
const sp500Fallback = !sp500 && avKey ? await avGlobalQuote("SPY", avKey) : null;
// 2. FRED — spreads crédit + taux (24h cache, données macro)
const [hyRaw, igRaw, us10y, us2y] = await Promise.all([
fredObs("BAMLH0A0HYM2", fredKey),
fredObs("BAMLC0A0CM", fredKey),
fredObs("DGS10", fredKey),
fredObs("DGS2", fredKey),
]);
return NextResponse.json({
// Sentiment / Risk-On
vix: yVal(vix),
vixDelta: yDelta(vix),
sp500: yVal(sp500) ?? sp500Fallback?.value,
sp500Change: yDelta(sp500),
sp500ChangePct: yDeltaPct(sp500) ?? sp500Fallback?.changePct,
btc: yVal(btcYQ) ?? btcCgRes.value,
btcChange24h: yDeltaPct(btcYQ) ?? btcCgRes.change24h,
// Crédit (FRED, fraction × 100 = bps)
hySpread: hyRaw != null ? Math.round(hyRaw * 100) : null,
igSpread: igRaw != null ? Math.round(igRaw * 100) : null,
// Taux (dxy injecté par page.tsx depuis /api/fx)
us10y,
us2y,
curveSlope: us10y !== null && us2y !== null ? Math.round((us10y - us2y) * 100) : null,
// Commodités — Yahoo Finance (quasi temps-réel, delta vs clôture j-1)
gold: yVal(gold),
goldDelta: yDelta(gold),
silver: yVal(silver),
silverDelta: yDelta(silver),
brent: yVal(brent),
brentDelta: yDelta(brent),
wti: yVal(wti),
wtiDelta: yDelta(wti),
// Compat
copper: null,
timestamp: Date.now(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { readFileSync } from "fs";
import { join } from "path";
export async function GET() {
try {
const filePath = join(process.cwd(), "data", "rate_expectations.json");
const raw = readFileSync(filePath, "utf-8");
const data = JSON.parse(raw);
// Return the most recent snapshot (first item)
const latest = Array.isArray(data) ? data[0] : data;
return NextResponse.json(latest);
} catch {
return NextResponse.json({ error: "rate_expectations.json not found. Run the scraper first." }, { status: 404 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
// AV primary (real-time) → Frankfurter fallback (ECB daily)
// AV free plan: 25 req/day, 5 req/min
// With revalidate:86400, server fetches each URL at most once per day → 7 calls/day total
const CURRENCIES = ["EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
const AV_BASE = "https://www.alphavantage.co/query";
export async function GET() {
const avKey = process.env.ALPHA_VANTAGE_KEY;
if (avKey) {
const result = await fetchAV(avKey);
if (result) return NextResponse.json(result);
}
return fetchFrankfurter();
}
async function fetchAV(apiKey: string) {
// Sequential (not parallel) to respect AV's 5 req/min limit
const rates: Record<string, number> = {};
for (const ccy of CURRENCIES) {
try {
const url = `${AV_BASE}?function=CURRENCY_EXCHANGE_RATE&from_currency=USD&to_currency=${ccy}&apikey=${apiKey}`;
const res = await fetch(url, { next: { revalidate: 86400 } }); // 24h server cache
if (!res.ok) continue;
const json = await res.json();
const rate = json?.["Realtime Currency Exchange Rate"]?.["5. Exchange Rate"];
if (rate) rates[ccy] = parseFloat(rate);
} catch { /* skip on error */ }
}
if (Object.keys(rates).length < 4) return null; // too many failures → fall back
// Approximate DXY from fetched pairs (no extra AV call needed)
// DXY = 50.14 × EUR^0.576 × (1/JPY)^0.136 × GBP^0.119 × (1/CAD)^0.091 × (1/CHF)^0.036
const e = rates.EUR, g = rates.GBP, j = rates.JPY, c = rates.CAD, ch = rates.CHF;
const dxy = (e && g && j && c && ch)
? parseFloat((50.14348112 * Math.pow(e,0.576) * Math.pow(1/j,0.136) * Math.pow(g,0.119) * Math.pow(1/c,0.091) * Math.pow(1/ch,0.036)).toFixed(3))
: null;
return { rates, dxy, base: "USD", source: "alphavantage", timestamp: Date.now() };
}
async function fetchFrankfurter() {
try {
const res = await fetch("https://api.frankfurter.app/latest?from=USD", { next: { revalidate: 86400 } });
if (!res.ok) throw new Error(`Frankfurter ${res.status}`);
const data = await res.json();
return NextResponse.json({ rates: data.rates, dxy: null, base: "USD", source: "frankfurter", date: data.date });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 502 });
}
}
+195
View File
@@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from "next/server";
import { FRED_SERIES } from "@/lib/constants";
import type { Currency } from "@/lib/types";
const FRED_BASE = "https://api.stlouisfed.org/fred/series/observations";
// Macro data changes monthly/quarterly — cache 24h
const REVALIDATE = 86400;
// ── FRED ─────────────────────────────────────────────────────────────────────
// Note: we use original index/level URLs (no units= param) so Next.js fetch cache
// stays warm. MoM%/QoQ% are computed locally via toIndicatorPct.
async function fredObs(seriesId: string, apiKey: string, limit = 5) {
const url = `${FRED_BASE}?series_id=${seriesId}&api_key=${apiKey}&file_type=json&sort_order=desc&limit=${limit}`;
try {
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const json = await res.json();
return (json.observations ?? [])
.filter((o: { value: string }) => o.value !== ".")
.map((o: { date: string; value: string }) => ({ date: o.date, value: parseFloat(o.value) }));
} catch { return []; }
}
// ── Eurostat SDMX-JSON API ────────────────────────────────────────────────────
async function eurostatObs(datasetCode: string, params: Record<string, string>) {
try {
const qs = new URLSearchParams({ ...params, format: "JSON" }).toString();
const url = `https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/${datasetCode}?${qs}`;
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const json = await res.json();
const timeIndex = json?.dimension?.time?.category?.index ?? {};
const values = json?.value ?? {};
return Object.entries(timeIndex)
.map(([period, idx]) => ({ date: period, value: values[idx as number] as number | null }))
.filter((o) => o.value !== null && o.value !== undefined) as { date: string; value: number }[];
} catch { return []; }
}
async function eurostatObsSorted(datasetCode: string, params: Record<string, string>, limit = 5) {
const obs = await eurostatObs(datasetCode, params);
return obs.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
}
// ── BoE API (GBP policy rate) ─────────────────────────────────────────────────
async function boeRate() {
try {
// URL dynamique : fenêtre glissante de 3 ans → évite la date hardcodée
const now = new Date();
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const td = now.getDate();
const tm = MONTHS[now.getMonth()];
const ty = now.getFullYear();
const fy = ty - 3; // 3 ans d'historique suffisent
const url = [
"https://www.bankofengland.co.uk/boeapps/database/fromshowcolumns.asp",
`?Travel=NIxIRx&FromSeries=1&ToSeries=50&DAT=RNG`,
`&FD=1&FM=Jan&FY=${fy}`,
`&TD=${td}&TM=${tm}&TY=${ty}`,
`&VPD=Y&html.x=66&html.y=26&SeriesCodes=IUDBEDR&UnitId=GBP&CSVF=TT&csv.x=47&csv.y=26`,
].join("");
const res = await fetch(url, { next: { revalidate: REVALIDATE } });
if (!res.ok) return [];
const text = await res.text();
// Le CSV BoE peut avoir des guillemets et des en-têtes variables — on filtre proprement
const lines = text.trim().split(/\r?\n/).filter((l) => l.trim() && !l.startsWith('"DATE"') && !l.startsWith('DATE'));
return lines
.reverse()
.slice(0, 5)
.map((line) => {
const cols = line.split(",").map((c) => c.replace(/"/g, "").trim());
const val = parseFloat(cols[1] ?? "NaN");
return { date: cols[0] ?? "", value: val };
})
.filter((o) => o.date && !isNaN(o.value));
} catch { return []; }
}
// ── Shared helpers ────────────────────────────────────────────────────────────
type Obs = { date: string; value: number };
/** Direct levels/rates (already in %) */
function toIndicator(obs: Obs[]) {
if (!obs.length) return null;
const value = obs[0].value;
const prev = obs[1]?.value ?? null;
return {
value,
prev,
surprise: prev !== null ? parseFloat((value - prev).toFixed(4)) : null,
trend: prev !== null ? (value > prev ? "up" : value < prev ? "down" : "flat") : null,
lastUpdated: obs[0].date,
};
}
/**
* Converts raw level/index observations → period-over-period % change.
* MoM% for monthly series, QoQ% for quarterly.
* Requires ≥2 observations (newest first).
*/
function toIndicatorPct(obs: Obs[]) {
if (obs.length < 2) return null;
const pctObs: Obs[] = obs.slice(0, -1).map((cur, i) => ({
date: cur.date,
value: parseFloat(((cur.value / obs[i + 1].value - 1) * 100).toFixed(3)),
}));
return toIndicator(pctObs);
}
// ── Server-side cache ─────────────────────────────────────────────────────────
const _cache = new Map<string, { data: unknown; ts: number }>();
export async function GET(req: NextRequest) {
const currency = (new URL(req.url).searchParams.get("currency") ?? "").toUpperCase() as Currency;
const series = FRED_SERIES[currency];
if (!series) return NextResponse.json({ error: "Unknown currency" }, { status: 400 });
const cached = _cache.get(currency);
if (cached && Date.now() - cached.ts < 86_400_000) return NextResponse.json(cached.data);
const key = process.env.FRED_API_KEY;
if (!key) return NextResponse.json({ error: "FRED_API_KEY missing" }, { status: 500 });
// Fields and which need period-over-period % conversion
// policyRate / unemployment → already in % → toIndicator
// cpiCore / gdp / retailSales / employment → index/level → toIndicatorPct (MoM% or QoQ%)
const PCT_FIELDS = new Set(["cpiCore", "gdp", "retailSales", "employment"]);
const fieldMap: Record<string, string | null> = {
policyRate: series.policyRate,
cpiCore: series.cpiCore,
gdp: series.gdp,
retailSales: series.retailSales,
unemployment: series.unemployment,
employment: series.employment,
};
const fredFields = Object.entries(fieldMap).filter(([, id]) => id !== null) as [string, string][];
const fredResults = await Promise.all(fredFields.map(([, id]) => fredObs(id, key)));
const indicators: Record<string, ReturnType<typeof toIndicator>> = {};
fredFields.forEach(([field], i) => {
indicators[field] = PCT_FIELDS.has(field)
? toIndicatorPct(fredResults[i])
: toIndicator(fredResults[i]);
});
// ── EUR alternative sources ────────────────────────────────────────────────
if (currency === "EUR") {
// CPI: Eurostat HICP monthly rate of change (MoM%) — CP00 = all items
if (!indicators.cpiCore) {
const hicp = await eurostatObsSorted("prc_hicp_mmr", { geo: "EA20", coicop: "CP00" });
indicators.cpiCore = toIndicator(hicp);
}
// GDP: Eurostat chained volumes → compute QoQ%
if (!indicators.gdp) {
const gdpRaw = await eurostatObsSorted("namq_10_gdp", { geo: "EA20", unit: "CLV10_MEUR", s_adj: "SCA", na_item: "B1GQ" }, 6);
indicators.gdp = toIndicatorPct(gdpRaw);
}
// Unemployment: Eurostat monthly SA rate
if (!indicators.unemployment) {
const unObs = await eurostatObsSorted("une_rt_m", { geo: "EA20", s_adj: "SA", age: "TOTAL", sex: "T", unit: "PC_ACT" });
indicators.unemployment = toIndicator(unObs);
}
// Retail sales for EUR: FRED uses German proxy index → apply MoM% if available
// (already handled above via toIndicatorPct if FRED returned data)
}
// ── GBP alternative sources ───────────────────────────────────────────────
if (currency === "GBP" && !indicators.policyRate) {
const boe = await boeRate();
indicators.policyRate = toIndicator(boe);
}
// Ensure all keys exist (null for missing)
for (const field of Object.keys(fieldMap)) {
if (!(field in indicators)) indicators[field] = null;
}
indicators.pmiMfg = null;
indicators.pmiServices = null;
const data = { currency, indicators, fetchedAt: new Date().toISOString() };
_cache.set(currency, { data, ts: Date.now() });
return NextResponse.json(data);
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
// Bytez uses an OpenAI-compatible API
const bytez = new OpenAI({
apiKey: process.env.BYTEZ_API_KEY ?? "",
baseURL: "https://api.bytez.com/models/openai",
});
const SYSTEM_PROMPT = `Tu es un analyste macro Forex senior. Tu analyses les données macroéconomiques de 8 devises majeures (USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD) et tu fournis des synthèses concises et actionnables pour un trader particulier.
Tes analyses sont :
- Directes et factuelles — pas de conditionnel excessif
- Structurées en 3-4 points maximum
- Focalisées sur les divergences et signaux de trading
- En français
- Sans disclaimers légaux
Format de réponse : texte court, 80-120 mots maximum par devise.`;
export async function POST(req: NextRequest) {
const apiKey = process.env.BYTEZ_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "BYTEZ_API_KEY not configured" }, { status: 503 });
}
let body: {
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence";
currency?: string;
data?: unknown;
userInput?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { mode, currency, data, userInput } = body;
let userMessage = "";
switch (mode) {
case "cb_analysis":
userMessage = `Analyse le communiqué de la banque centrale pour ${currency}.
Données contextuelles : ${JSON.stringify(data, null, 2)}
Fournis une analyse en 4 points :
1. Changement de ton (hawkish/dovish/neutre)
2. Évolution des projections de taux
3. Phrases clés ajoutées ou supprimées vs précédent
4. Impact suggéré sur ${currency} (+1 haussier / 0 neutre / -1 baissier)`;
break;
case "expert_opinion":
userMessage = `Point de Vérité — Confrontation IA :
Avis expert injecté : "${userInput}"
Données actuelles du dashboard pour ${currency} : ${JSON.stringify(data, null, 2)}
Analyse :
- Convergences entre l'avis expert et les données quantitatives
- Divergences et points de tension
- Score avant/après ajustement suggéré
- Validation ou rejet de l'avis par devise`;
break;
case "divergence":
userMessage = `Analyse les divergences de positionnement détectées pour ${currency} :
${JSON.stringify(data, null, 2)}
Explique en 3 phrases : pourquoi cette configuration est significative et quelle action de trading elle suggère.`;
break;
case "summary":
default:
userMessage = `Génère une synthèse macro hebdomadaire pour ${currency} basée sur ces données :
${JSON.stringify(data, null, 2)}
Résumé en 3 points : situation actuelle, signal directionnel, risque principal.`;
}
try {
const completion = await bytez.chat.completions.create({
model: "meta-llama/Llama-3.1-8B-Instruct",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 300,
temperature: 0.3,
});
const text = completion.choices[0]?.message?.content ?? "";
return NextResponse.json({ analysis: text, model: completion.model, mode });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Bytez error: ${message}` }, { status: 502 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
// OANDA v20 API — position book (% long/short by pair)
const OANDA_BASE = "https://api-fxtrade.oanda.com/v3";
const MAJOR_PAIRS = [
"EUR_USD", "GBP_USD", "USD_JPY", "USD_CHF",
"USD_CAD", "AUD_USD", "NZD_USD",
"EUR_GBP", "EUR_JPY", "GBP_JPY",
"AUD_JPY", "CAD_JPY", "NZD_JPY",
];
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const pair = searchParams.get("pair");
const apiKey = process.env.OANDA_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "OANDA_API_KEY not configured. Add it to .env.local." },
{ status: 503 }
);
}
const pairsToFetch = pair ? [pair] : MAJOR_PAIRS;
const results: Record<string, { longPct: number; shortPct: number; pair: string }> = {};
await Promise.allSettled(
pairsToFetch.map(async (p) => {
try {
const res = await fetch(
`${OANDA_BASE}/instruments/${p}/positionBook?time=current`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 },
}
);
if (!res.ok) return;
const data = await res.json();
const buckets: { price: string; longCountPercent: string; shortCountPercent: string }[] =
data?.positionBook?.buckets ?? [];
let totalLong = 0;
let totalShort = 0;
for (const b of buckets) {
totalLong += parseFloat(b.longCountPercent ?? "0");
totalShort += parseFloat(b.shortCountPercent ?? "0");
}
const total = totalLong + totalShort;
if (total === 0) return;
results[p] = {
pair: p,
longPct: Math.round((totalLong / total) * 100),
shortPct: Math.round((totalShort / total) * 100),
};
} catch {
// silently skip unavailable pairs
}
})
);
return NextResponse.json({ pairs: results, source: "OANDA", timestamp: Date.now() });
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
// 10Y sovereign yields — mixed sources per CDC §6.4
// USD: FRED DGS10 (daily)
// EUR: ECB API (daily Bund)
// GBP: BoE API IUDMNPY (daily)
// Others: FRED monthly as fallback
const FRED_KEY = () => process.env.FRED_API_KEY ?? "";
async function fredObs(series: string): Promise<number | null> {
try {
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${series}&api_key=${FRED_KEY()}&file_type=json&sort_order=desc&limit=3`;
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const val = (data?.observations ?? []).find((o: { value: string }) => o.value !== ".")?.value;
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function ecbBund10Y(): Promise<number | null> {
try {
const url = "https://data-api.ecb.europa.eu/service/data/YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y?format=jsondata&lastNObservations=1";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs = data?.dataSets?.[0]?.series?.["0:0:0:0:0:0:0"]?.observations;
if (!obs) return null;
const last = Object.values(obs).at(-1) as number[] | undefined;
return last?.[0] ?? null;
} catch {
return null;
}
}
async function boeGilt10Y(): Promise<number | null> {
try {
// BoE API series IUDMNPY = UK Nominal Par Yield 10Y
const url = "https://www.bankofengland.co.uk/boeapps/database/_iadb-FromShowColumns.asp?csv.x=yes&Datefrom=01/Jan/2024&Dateto=now&SeriesCodes=IUDMNPY&CSVF=TN&UsingCodes=Y";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const text = await res.text();
const lines = text.trim().split("\n").filter((l) => l.trim());
const last = lines.at(-1)?.split(",");
const val = last?.at(-1)?.trim();
return val ? parseFloat(val) : null;
} catch {
return null;
}
}
async function bocYield10Y(): Promise<number | null> {
try {
const url = "https://www.bankofcanada.ca/valet/observations/BD.CDN.10YR.DQ.YLD/json?recent=5";
const res = await fetch(url, { next: { revalidate: 86400 } });
if (!res.ok) return null;
const data = await res.json();
const obs: { d: string; "BD.CDN.10YR.DQ.YLD": { v: string } }[] =
data?.observations ?? [];
const last = obs.findLast((o) => o["BD.CDN.10YR.DQ.YLD"]?.v);
return last ? parseFloat(last["BD.CDN.10YR.DQ.YLD"].v) : null;
} catch {
return null;
}
}
export async function GET() {
const [usd, eur, gbp, jpy, chf, cad, aud, nzd] = await Promise.all([
fredObs("DGS10"),
ecbBund10Y(),
boeGilt10Y(),
fredObs("IRLTLT01JPM156N"),
fredObs("IRLTLT01CHM156N"),
bocYield10Y(),
fredObs("IRLTLT01AUM156N"),
fredObs("IRLTLT01NZM156N"),
]);
const yields = { USD: usd, EUR: eur, GBP: gbp, JPY: jpy, CHF: chf, CAD: cad, AUD: aud, NZD: nzd };
// Compute spreads vs USD
const spreads: Record<string, number | null> = {};
for (const [ccy, yld] of Object.entries(yields)) {
if (ccy === "USD" || yld === null || usd === null) {
spreads[ccy] = null;
} else {
spreads[ccy] = Math.round((yld - usd) * 100); // bps
}
}
return NextResponse.json({ yields, spreads, timestamp: Date.now() });
}
+19
View File
@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: #111827;
--background: #f9fafb;
}
body {
background: var(--background);
color: var(--foreground);
font-family: system-ui, -apple-system, sans-serif;
}
/* Bias badges */
.badge-bull { @apply bg-green-100 text-green-800 border border-green-200; }
.badge-bear { @apply bg-red-100 text-red-800 border border-red-200; }
.badge-neutral { @apply bg-gray-100 text-gray-700 border border-gray-200; }
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Forex Macro Dashboard",
description: "Tableau de bord macroéconomique Forex — 8 devises majeures",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className="min-h-screen bg-gray-50">{children}</body>
</html>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { RefreshCw, TrendingUp, AlertTriangle, Zap } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency, DriverData } from "@/lib/types";
import CurrencyCard from "@/components/CurrencyCard";
import DriversBar from "@/components/DriversBar";
const REFRESH_MS = parseInt(process.env.NEXT_PUBLIC_REFRESH_INTERVAL_MS ?? "3600000");
export default function Dashboard() {
const [drivers, setDrivers] = useState<DriverData | 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 [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [driversRes, expectRes, yieldsRes, fxRes] = await Promise.allSettled([
fetch("/api/drivers").then((r) => r.json()),
fetch("/api/expectations").then((r) => r.json()),
fetch("/api/yields").then((r) => r.json()),
fetch("/api/fx").then((r) => r.json()),
]);
if (driversRes.status === "fulfilled") {
const driversData = driversRes.value;
// Merge DXY from /api/fx into drivers
if (fxRes.status === "fulfilled" && fxRes.value?.dxy != null) {
driversData.dxy = fxRes.value.dxy;
}
setDrivers(driversData);
}
if (expectRes.status === "fulfilled") setExpectations(expectRes.value);
if (yieldsRes.status === "fulfilled") setYields(yieldsRes.value);
setLastRefresh(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, REFRESH_MS);
return () => clearInterval(id);
}, [refresh]);
const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => {
setActiveDivergences((prev) => {
const filtered = prev.filter((d) => d.currency !== currency);
if (Math.abs(score) >= 2) return [...filtered, { currency, score }];
return filtered;
});
}, []);
const divergenceCount = activeDivergences.filter((d) => Math.abs(d.score) >= 2).length;
return (
<div className="max-w-[1600px] mx-auto px-4 py-4">
{/* Header */}
<header className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">
Forex Macro Dashboard
</h1>
<p className="text-xs text-gray-500 mt-0.5">
USD · EUR · GBP · JPY · CHF · CAD · AUD · NZD v8.0
</p>
</div>
<div className="flex items-center gap-3">
{divergenceCount > 0 && (
<div className="flex items-center gap-1.5 bg-amber-50 border border-amber-200 rounded-full px-3 py-1.5">
<Zap size={13} className="text-amber-600" />
<span className="text-xs font-medium text-amber-700">
{divergenceCount} divergence{divergenceCount > 1 ? "s" : ""} active{divergenceCount > 1 ? "s" : ""}
</span>
</div>
)}
<div className="text-xs text-gray-400">
{lastRefresh.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
</div>
<button
onClick={refresh}
disabled={loading}
className="flex items-center gap-1.5 text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
>
<RefreshCw size={13} className={loading ? "animate-spin" : ""} />
{loading ? "Chargement…" : "Rafraîchir"}
</button>
</div>
</header>
{/* Global drivers bar */}
{drivers && <DriversBar drivers={drivers} />}
{/* Active divergences summary */}
{activeDivergences.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{activeDivergences
.sort((a, b) => Math.abs(b.score) - Math.abs(a.score))
.map(({ currency, score }) => (
<div
key={currency}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-full border font-medium ${
score < 0
? "bg-red-50 border-red-200 text-red-700"
: "bg-green-50 border-green-200 text-green-700"
}`}
>
<Zap size={10} />
{CURRENCY_META[currency].flag} {currency} SD:{score > 0 ? "+" : ""}{score}
</div>
))}
</div>
)}
{/* Currency cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{CURRENCIES.map((currency) => (
<CurrencyCard
key={currency}
currency={currency}
expectations={expectations}
yields={yields}
onDivergenceUpdate={handleDivergenceUpdate}
/>
))}
</div>
{/* Footer */}
<footer className="mt-6 text-center text-xs text-gray-400 space-y-1">
<p>
Sources: FRED · ECB · BoE · BoC · CFTC · Frankfurter · OANDA · investinglive.com
</p>
<p>
LLM: Bytez (Llama 3.1) · Données à titre informatif uniquement pas de conseil financier
</p>
</footer>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { TrendingUp, TrendingDown, Minus, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import { CURRENCY_META } from "@/lib/constants";
import { biasLabel, biasColor, calcMacroScore } from "@/lib/scoring";
import type { Currency, BiasPhase, RateExpectation } from "@/lib/types";
import NarrativeButton from "./NarrativeButton";
interface Ind { value: number | null; prev: number | null; surprise: number | null; trend: "up"|"down"|"flat"|null; lastUpdated: string | null }
interface MacroData { currency: string; indicators: Record<string, Ind | null>; fetchedAt: string }
interface Props {
currency: Currency;
expectations: Record<string, unknown> | null;
yields: { yields: Record<string, number | null>; spreads: Record<string, number | null> } | null;
onDivergenceUpdate: (currency: Currency, score: number) => void;
}
const PHASES: Record<BiasPhase, { label: string; color: string }> = {
tightening: { label: "🔴 Resserrement", color: "text-red-600" },
hawkish_pause: { label: "🟡 Pause Hawkish", color: "text-amber-600" },
easing: { label: "🟢 Assouplissement", color: "text-green-600" },
dovish_pause: { label: "🔵 Pause Dovish", color: "text-blue-600" },
transition: { label: "🟠 Transition", color: "text-orange-500" },
};
function TrendIcon({ trend }: { trend: "up"|"down"|"flat"|null }) {
if (trend === "up") return <TrendingUp size={11} className="text-green-500 flex-shrink-0" />;
if (trend === "down") return <TrendingDown size={11} className="text-red-500 flex-shrink-0" />;
return <Minus size={11} className="text-gray-300 flex-shrink-0" />;
}
function Row({ label, ind, unit = "", invertSurprise = false, warn = false, consensus = null }: {
label: string; ind: Ind | null; unit?: string; invertSurprise?: boolean; warn?: boolean; consensus?: number | null;
}) {
const value = ind?.value ?? null;
const prev = ind?.prev ?? null;
// Colorer la valeur actuelle selon la direction du mouvement
const s = ind?.surprise ?? null;
const effectiveS = invertSurprise && s !== null ? -s : s;
const valCls =
effectiveS === null ? "text-gray-800"
: effectiveS > 0 ? "text-green-700"
: effectiveS < 0 ? "text-red-700"
: "text-gray-500";
const fmt = (v: number | null) =>
v !== null ? `${v.toFixed(2)}${unit}` : "—";
return (
<div className="py-1.5 border-b border-gray-50 last:border-0">
{/* Ligne 1 : label + valeur actuelle publiée */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<TrendIcon trend={ind?.trend ?? null} />
<span className="text-xs text-gray-500 truncate">
{label}{warn && <span className="text-amber-400 ml-0.5"></span>}
</span>
</div>
<span className={`text-xs font-semibold tabular-nums flex-shrink-0 ${valCls}`}>
{fmt(value)}
</span>
</div>
{/* Ligne 2 : précédent + consensus marché */}
<div className="flex items-center justify-between pl-5 mt-0.5">
<span className="text-[10px] text-gray-400 tabular-nums">
Préc.&nbsp;<span className="text-gray-500 font-medium">{fmt(prev)}</span>
</span>
<span className="text-[10px] text-gray-400 tabular-nums">
Cons.&nbsp;
{consensus !== null
? <span className="text-blue-500 font-medium">{fmt(consensus)}</span>
: <span className="text-gray-300"></span>}
</span>
</div>
</div>
);
}
export default function CurrencyCard({ currency, expectations, yields, onDivergenceUpdate }: Props) {
const meta = CURRENCY_META[currency];
const [data, setData] = useState<MacroData | null>(null);
const [phase, setPhase] = useState<BiasPhase>("hawkish_pause");
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [rateExp, setRateExp] = useState<RateExpectation | null>(null);
// Single fetch — server batches all FRED calls
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/macro?currency=${currency}`);
if (!res.ok) return;
const json: MacroData = await res.json();
setData(json);
// Infer phase from policy rate trend
const rateInd = json.indicators.policyRate;
if (rateInd?.trend === "up") setPhase("tightening");
else if (rateInd?.trend === "down") setPhase("easing");
else setPhase("hawkish_pause");
} finally {
setLoading(false);
}
}, [currency]);
useEffect(() => { load(); }, [load]);
// Match rate expectation for this currency
useEffect(() => {
if (!expectations) return;
const all = [
...((expectations.rate_hikes ?? []) as RateExpectation[]),
...((expectations.rate_cuts ?? []) as RateExpectation[]),
];
const cbShort = meta.cbShort.toLowerCase();
setRateExp(all.find((e) => e.cb.toLowerCase().includes(cbShort) || e.cb.toLowerCase().includes(currency.toLowerCase())) ?? null);
}, [expectations, currency, meta.cbShort]);
const inds = data?.indicators;
// Build a minimal indicator object for scoring
const forScoring = {
policyRate: { value: inds?.policyRate?.value ?? null, prev: inds?.policyRate?.prev ?? null, consensus: null, surprise: inds?.policyRate?.surprise ?? null, trend: inds?.policyRate?.trend ?? null, lastUpdated: "" },
cpiCore: { value: inds?.cpiCore?.value ?? null, prev: inds?.cpiCore?.prev ?? null, consensus: null, surprise: inds?.cpiCore?.surprise ?? null, trend: inds?.cpiCore?.trend ?? null, lastUpdated: "" },
pmiMfg: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
pmiServices: { value: null, prev: null, consensus: null, surprise: null, trend: null, lastUpdated: "" },
gdp: { value: inds?.gdp?.value ?? null, prev: inds?.gdp?.prev ?? null, consensus: null, surprise: inds?.gdp?.surprise ?? null, trend: inds?.gdp?.trend ?? null, lastUpdated: "" },
retailSales: { value: inds?.retailSales?.value ?? null, prev: inds?.retailSales?.prev ?? null, consensus: null, surprise: inds?.retailSales?.surprise ?? null, trend: inds?.retailSales?.trend ?? null, lastUpdated: "" },
unemployment: { value: inds?.unemployment?.value ?? null, prev: inds?.unemployment?.prev ?? null, consensus: null, surprise: inds?.unemployment?.surprise ?? null, trend: inds?.unemployment?.trend ?? null, lastUpdated: "" },
employment: { value: inds?.employment?.value ?? null, prev: inds?.employment?.prev ?? null, consensus: null, surprise: inds?.employment?.surprise ?? null, trend: inds?.employment?.trend ?? null, lastUpdated: "" },
};
const macroScore = calcMacroScore(forScoring, phase);
const biasText = biasLabel(macroScore);
const biasCls = biasColor(macroScore);
const phaseInfo = PHASES[phase];
const yield10Y = yields?.yields[currency] ?? null;
const spread10Y = yields?.spreads[currency] ?? null;
const borderCls = macroScore >= 4 ? "border-green-200" : macroScore <= -4 ? "border-red-200" : "border-gray-200";
return (
<div className={`bg-white border rounded-xl overflow-hidden ${borderCls}`}>
{/* Header */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-2xl leading-none">{meta.flag}</span>
<div>
<div className="font-semibold text-sm text-gray-900">{currency}</div>
<div className="text-[10px] text-gray-400">{meta.cbShort} · {meta.name}</div>
</div>
</div>
<div className="text-right">
{loading
? <Loader2 size={15} className="animate-spin text-gray-300" />
: <>
<div className={`text-sm font-bold ${biasCls}`}>{biasText}</div>
<div className="text-[10px] text-gray-400">Score : {macroScore > 0 ? "+" : ""}{macroScore}</div>
</>
}
</div>
</div>
<div className={`text-[10px] font-medium ${phaseInfo.color}`}>{phaseInfo.label}</div>
</div>
{/* Rate expectation pill */}
{rateExp && (
<div className="mx-4 mb-2 px-3 py-1.5 bg-gray-50 rounded-lg text-xs leading-snug">
<span className="font-semibold">{rateExp.direction === "hike" ? "▲" : "▼"} {rateExp.bps} bps</span>
<span className="relative group inline-flex items-center ml-0.5 cursor-help align-middle">
<span className="text-[9px] text-gray-400 border border-gray-300 rounded-full w-3 h-3 flex items-center justify-center leading-none select-none">i</span>
<span className="pointer-events-none absolute bottom-full left-0 mb-1.5 hidden group-hover:block bg-gray-800 text-white text-[10px] rounded px-2 py-1.5 w-56 z-50 leading-snug shadow-lg whitespace-normal">
Variation cumulée attendue sur les ~12 prochains mois selon les marchés (OIS / futures de taux). Distinct de la probabilité à la prochaine réunion ci-dessous.
</span>
</span>
<span className="text-gray-500"> · {rateExp.prob_pct}% prob. </span>
<span className={`font-medium ${rateExp.prob_desc.includes("no change") || rateExp.prob_desc.includes("sans") ? "text-gray-600" : rateExp.direction === "hike" ? "text-red-600" : "text-green-600"}`}>
{rateExp.prob_desc}
</span>
</div>
)}
{/* Core indicators (always visible) */}
<div className="px-4 pb-2">
<Row label="Taux directeur" ind={inds?.policyRate ?? null} unit="%" />
<Row label="CPI (MoM%)" ind={inds?.cpiCore ?? null} unit="%" />
<Row label="PIB (QoQ %)" ind={inds?.gdp ?? null} unit="%" />
<Row label="Chômage" ind={inds?.unemployment ?? null} unit="%" invertSurprise />
{/* Expanded indicators */}
{expanded && (
<>
<Row label="PMI Mfg" ind={null} warn />
<Row label="PMI Services" ind={null} warn />
<Row label="Retail Sales" ind={inds?.retailSales ?? null} unit="%" warn={!inds?.retailSales} />
<Row label="Emploi (MoM%)" ind={inds?.employment ?? null} unit="%" warn={!inds?.employment} />
{/* 10Y yield */}
<div className="flex items-center justify-between py-1.5 text-xs">
<span className="text-gray-500">10Y Yield</span>
<span className="font-semibold text-gray-800 tabular-nums">
{yield10Y !== null ? `${yield10Y.toFixed(2)}%` : "—"}
{spread10Y !== null && (
<span className={`ml-1 text-[10px] ${spread10Y > 0 ? "text-green-600" : "text-red-600"}`}>
({spread10Y > 0 ? "+" : ""}{spread10Y}bps)
</span>
)}
</span>
</div>
</>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-2 flex items-center justify-between">
<NarrativeButton currency={currency} indicators={forScoring} macroScore={macroScore} />
<button
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1 text-[10px] text-gray-400 hover:text-gray-600"
>
{expanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
{expanded ? "Moins" : "Détails"}
</button>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import type { DriverData } from "@/lib/types";
interface Props { drivers: DriverData }
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(v: number | null, dec: number, unit = "") {
if (v === null) return "—";
return `${v.toLocaleString("fr-FR", { minimumFractionDigits: dec, maximumFractionDigits: dec })}${unit}`;
}
function DeltaTag({ delta, pct = false, dec = 2 }: { delta: number | null; pct?: boolean; dec?: number }) {
if (delta === null) return null;
const pos = delta > 0;
const neg = delta < 0;
const color = pos ? "text-emerald-600" : neg ? "text-red-500" : "text-gray-400";
const arrow = pos ? "▲" : neg ? "▼" : "▬";
return (
<span className={`text-[10px] font-medium tabular-nums ml-0.5 ${color}`}>
{arrow}{Math.abs(delta).toFixed(dec)}{pct ? "%" : ""}
</span>
);
}
/** Un indicateur : label · valeur · delta — hauteur fixe garantie */
function M({
label, value, dec = 2, unit = "", delta, deltaPct, deltaDec, tooltip,
}: {
label: string;
value: number | null;
dec?: number;
unit?: string;
delta?: number | null;
deltaPct?: boolean;
deltaDec?: number;
tooltip?: string;
}) {
const labelNode = tooltip ? (
<span className="relative group inline-block">
<span className="text-[9px] text-gray-400 border-b border-dotted border-gray-300 cursor-help whitespace-nowrap leading-tight">
{label}
</span>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-800 text-white text-[10px] rounded px-2 py-1.5 w-56 z-50 leading-snug shadow-lg whitespace-normal text-left">
{tooltip}
</span>
</span>
) : (
<span className="text-[9px] text-gray-400 whitespace-nowrap leading-tight">{label}</span>
);
return (
<div className="flex flex-col items-center text-center flex-shrink-0">
{/* Ligne 1 — label */}
<div className="h-4 flex items-center justify-center">{labelNode}</div>
{/* Ligne 2 — valeur */}
<div className="h-5 flex items-center justify-center">
<span className={`text-sm font-semibold tabular-nums ${value === null ? "text-gray-300" : "text-gray-800"}`}>
{fmt(value, dec, unit)}
</span>
</div>
{/* Ligne 3 — delta (slot réservé = alignement garanti) */}
<div className="h-4 flex items-center justify-center">
{delta !== undefined && delta !== null && (
<DeltaTag delta={delta} pct={deltaPct} dec={deltaDec ?? dec} />
)}
</div>
</div>
);
}
/** Séparateur vertical */
function VSep() {
return <div className="w-px bg-gray-100 self-stretch mx-2 flex-shrink-0" />;
}
// ── Composant principal ───────────────────────────────────────────────────────
export default function DriversBar({ drivers }: Props) {
const {
vix, vixDelta,
sp500, sp500ChangePct,
btc, btcChange24h,
hySpread, igSpread,
us10y, curveSlope,
gold, goldDelta,
silver, silverDelta,
brent, brentDelta,
wti, wtiDelta,
} = drivers as DriverData & { dxy?: number | null };
const dxy = (drivers as DriverData & { dxy?: number | null }).dxy ?? null;
const riskOff = (vix ?? 0) > 25 || (hySpread ?? 0) > 500;
return (
<div className="mb-4 bg-white border border-gray-200 rounded-xl px-5 py-3">
{/* En-tête */}
<div className="flex items-center justify-between mb-2">
<span className="text-[9px] font-semibold text-gray-400 uppercase tracking-widest">
Drivers globaux
</span>
{riskOff && (
<span className="text-[9px] font-medium bg-red-50 text-red-600 border border-red-200 rounded-full px-2 py-0.5">
Risk-Off
</span>
)}
</div>
{/* ── Ligne unique — tous les indicateurs ── */}
<div className="flex items-start gap-4 overflow-x-auto pb-0.5">
{/* Sentiment / Risk-On */}
<M label="VIX" value={vix} dec={1} delta={vixDelta} deltaDec={1} />
<M label="S&P 500" value={sp500} dec={0} delta={sp500ChangePct} deltaPct deltaDec={2} />
<M label="Bitcoin" value={btc} dec={0} unit=" $" delta={btcChange24h} deltaPct deltaDec={2} />
<VSep />
{/* Crédit */}
<M
label="HY Spread" value={hySpread} dec={0} unit=" bps"
tooltip="High Yield spread : écart entre les obligations d'entreprises à haut risque (< BBB) et les Treasuries US. >500 bps = signal risk-off fort."
/>
<M
label="IG Spread" value={igSpread} dec={0} unit=" bps"
tooltip="Investment Grade spread : écart entre les obligations bien notées (BBB+) et les Treasuries US. Mesure le coût du crédit des grandes entreprises."
/>
<VSep />
{/* Taux & FX */}
<M label="DXY" value={dxy} dec={2} />
<M label="US 10Y" value={us10y} dec={2} unit="%" />
<M
label="Crb 2-10" value={curveSlope} dec={0} unit=" bps"
tooltip="Spread US 10Y US 2Y. Positif = courbe normale. Négatif = courbe inversée (signal récessif historiquement, se matérialise ~1218 mois après)."
/>
<VSep />
{/* Commodités */}
<M label="Or $/oz" value={gold} dec={0} delta={goldDelta} deltaDec={1} />
<M label="Argent $/oz" value={silver} dec={2} delta={silverDelta} deltaDec={2} />
<M label="Brent $/b" value={brent} dec={1} delta={brentDelta} deltaDec={1} />
<M label="WTI $/b" value={wti} dec={1} delta={wtiDelta} deltaDec={1} />
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Sparkles, X } from "lucide-react";
import type { Currency, CurrencyIndicators } from "@/lib/types";
interface Props {
currency: Currency;
indicators: CurrencyIndicators;
macroScore: number;
}
export default function NarrativeButton({ currency, indicators, macroScore }: Props) {
const [open, setOpen] = useState(false);
const [analysis, setAnalysis] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/narrative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "summary",
currency,
data: { indicators, macroScore },
}),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
setAnalysis(data.analysis);
setOpen(true);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={run}
disabled={loading}
className="flex items-center gap-1 text-[10px] text-indigo-600 hover:text-indigo-800 disabled:opacity-50"
>
<Sparkles size={11} />
{loading ? "Analyse…" : "Résumé IA"}
</button>
{error && (
<span className="text-[10px] text-red-500 truncate max-w-[140px]" title={error}>
Erreur Bytez
</span>
)}
{open && analysis && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" onClick={() => setOpen(false)}>
<div
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles size={15} className="text-indigo-500" />
<span className="font-medium text-sm">Analyse {currency} Bytez AI</span>
</div>
<button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-700">
<X size={15} />
</button>
</div>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{analysis}</p>
<div className="mt-3 text-[10px] text-gray-400 text-right">
Powered by Bytez · Llama 3.1 8B
</div>
</div>
</div>
)}
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
[
{
"url": "https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-this-weeks-events-20260522/",
"title": "How have interest rate expectations changed after this week's events?",
"date": "2026-05-22",
"scraped_at": "2026-05-26T11:11:02.377436Z",
"rate_cuts": [],
"rate_hikes": [
{
"cb": "RBNZ (NZD)",
"bps": 76,
"prob_pct": 70,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "ECB (EUR)",
"bps": 64,
"prob_pct": 88,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoE (GBP)",
"bps": 48,
"prob_pct": 83,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "BoJ (JPY)",
"bps": 46,
"prob_pct": 75,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoC (CAD)",
"bps": 41,
"prob_pct": 99,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "RBA (AUD)",
"bps": 25,
"prob_pct": 88,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "Fed (USD)",
"bps": 20,
"prob_pct": 99,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "SNB (CHF)",
"bps": 19,
"prob_pct": 98,
"prob_desc": "no change at the next meeting",
"direction": "hike"
}
]
},
{
"url": "https://investinglive.com/centralbank/how-have-interest-rate-expectations-changed-after-the-ceasefire-announcement-20260409/",
"title": "How have interest rate expectations changed after the ceasefire announcement?",
"date": "2026-04-09",
"scraped_at": "2026-05-26T11:11:02.377537Z",
"rate_cuts": [
{
"cb": "Fed (USD)",
"bps": 7,
"prob_pct": 98,
"prob_desc": "no change at the next meeting",
"direction": "cut"
}
],
"rate_hikes": [
{
"cb": "RBNZ (NZD)",
"bps": 74,
"prob_pct": 70,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "ECB (EUR)",
"bps": 59,
"prob_pct": 60,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "RBA (AUD)",
"bps": 50,
"prob_pct": 60,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoJ (JPY)",
"bps": 50,
"prob_pct": 51,
"prob_desc": "rate hike at the next meeting",
"direction": "hike"
},
{
"cb": "BoE (GBP)",
"bps": 40,
"prob_pct": 78,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "BoC (CAD)",
"bps": 35,
"prob_pct": 93,
"prob_desc": "no change at the next meeting",
"direction": "hike"
},
{
"cb": "SNB (CHF)",
"bps": 21,
"prob_pct": 83,
"prob_desc": "no change at the next meeting",
"direction": "hike"
}
]
}
]
+123
View File
@@ -0,0 +1,123 @@
import type { Currency } from "./types";
export const CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"];
export const CURRENCY_META: Record<Currency, { name: string; flag: string; cb: string; cbShort: string }> = {
USD: { name: "États-Unis", flag: "🇺🇸", cb: "Federal Reserve", cbShort: "Fed" },
EUR: { name: "Zone Euro", flag: "🇪🇺", cb: "Banque Centrale Européenne", cbShort: "BCE" },
GBP: { name: "Royaume-Uni", flag: "🇬🇧", cb: "Bank of England", cbShort: "BoE" },
JPY: { name: "Japon", flag: "🇯🇵", cb: "Bank of Japan", cbShort: "BoJ" },
CHF: { name: "Suisse", flag: "🇨🇭", cb: "Banque Nationale Suisse", cbShort: "BNS" },
CAD: { name: "Canada", flag: "🇨🇦", cb: "Bank of Canada", cbShort: "BoC" },
AUD: { name: "Australie", flag: "🇦🇺", cb: "Reserve Bank of Australia", cbShort: "RBA" },
NZD: { name: "Nouvelle-Zélande", flag: "🇳🇿", cb: "Reserve Bank of New Zealand", cbShort: "RBNZ" },
};
// FRED series IDs — corrections audit 2026-05-26
// null = série inexistante ou stale sur FRED → source alternative dans /api/macro
export const FRED_SERIES: Record<Currency, {
policyRate: string | null;
cpiCore: string | null;
gdp: string | null;
retailSales: string | null;
unemployment: string | null;
employment: string | null;
}> = {
USD: {
policyRate: "FEDFUNDS",
cpiCore: "CPILFESL",
gdp: "GDPC1",
retailSales: "MARTSSM44W72USS", // correction : ex-RSXFS (discontinuée)
unemployment: "UNRATE",
employment: "PAYEMS",
},
EUR: {
policyRate: "ECBDFR", // ECB deposit rate — disponible sur FRED
cpiCore: null, // CP0000EZ20M086NEST n'existe pas sur FRED → ECB API
gdp: null, // CLVMNACSCAB1GQEA20 n'existe pas sur FRED → Eurostat
retailSales: "SLRTTO01DEM189S", // proxy Allemagne — OK sur FRED
unemployment: null, // LRHUTTTTEZM156S arrêtée 2023 → Eurostat
employment: null,
},
GBP: {
policyRate: null, // BoE API (IUDBEDR) — séries OECD FRED stale
cpiCore: "GBRCPIALLMINMEI", // OK — mars 2025
gdp: "CLVMNACSCAB1GQGB",
retailSales: "SLRTTO01GBM189S",
unemployment: "LRHUTTTTGBM156S",
employment: "LNEMNACSCAB1GQGB",
},
JPY: {
policyRate: "IRSTCB01JPM156N",
cpiCore: "JPNCPIALLMINMEI", // correction : JPNCPICORMINMEI arrêtée 2021 → All Items
gdp: "JPNRGDPEXP",
retailSales: null, // METI Japan CSV — pas sur FRED
unemployment: "LRHUTTTTJPM156S",
employment: null,
},
CHF: {
policyRate: "IRSTCB01CHM156N",
cpiCore: "CHECPICORMINMEI", // OK — avr 2025
gdp: "CHNGDPNQDSMEI",
retailSales: null, // OFS Suisse CSV
unemployment: "LRHUTTTTCHM156S",
employment: null,
},
CAD: {
policyRate: "IRSTCB01CAM156N",
cpiCore: "CANCPICORMINMEI", // OK — mars 2025
gdp: "CLVMNACSCAB1GQCA",
retailSales: "SLRTTO01CAM189S",
unemployment: "LRHUTTTTCAM156S",
employment: null,
},
AUD: {
policyRate: "IRSTCB01AUM156N",
cpiCore: "AUSCPIALLMINMEI", // trimestriel — pas d'alternative mensuelle FRED
gdp: "CLVMNACSCAB1GQAU",
retailSales: "SLRTTO01AUM189S",
unemployment: "LRHUTTTTAUM156S",
employment: "LNEMNACSCAB1GQAU",
},
NZD: {
policyRate: "IRSTCB01NZM156N",
cpiCore: "NZLCPIALLMINMEI", // trimestriel
gdp: "CLVMNACSCAB1GQNZ",
retailSales: null, // Stats NZ API trimestriel
unemployment: "LRUNTTTTNZQ156S", // correction : ex-LRHUTTTTNZM156S (inexistante)
employment: null,
},
};
// COT CFTC contract codes per currency
export const COT_CODES: Record<Currency, string> = {
USD: "098662", // USD Index DX
EUR: "099741", // Euro FX EC
GBP: "096742", // British Pound BP
JPY: "097741", // Japanese Yen JY
CHF: "092741", // Swiss Franc SF
CAD: "090741", // Canadian Dollar CD
AUD: "232741", // Australian Dollar AD
NZD: "112741", // New Zealand Dollar NE
};
// Scoring weights per indicator (§4 CDC)
export const INDICATOR_WEIGHTS = {
policyRate: 3,
cpiCore: 2.5,
pmiMfg: 1.5,
pmiServices: 1.5,
gdp: 2,
retailSales: 1.5,
unemployment: 1.5,
employment: 2,
} as const;
// Cycle phase multipliers (§4c CDC)
export const PHASE_MULTIPLIERS: Record<string, { bull: number; bear: number }> = {
tightening: { bull: 1.5, bear: 1.0 },
hawkish_pause: { bull: 1.3, bear: 1.5 },
easing: { bull: 1.0, bear: 1.5 },
dovish_pause: { bull: 1.5, bear: 1.0 },
transition: { bull: 1.0, bear: 1.0 },
};
+102
View File
@@ -0,0 +1,102 @@
import type { CurrencyIndicators, BiasPhase, COTData, STIRData, Bond10YData } from "./types";
import { INDICATOR_WEIGHTS, PHASE_MULTIPLIERS } from "./constants";
function signalFromSurprise(surprise: number | null): number {
if (surprise === null) return 0;
if (surprise > 0.3) return 1;
if (surprise < -0.3) return -1;
return 0;
}
// §4 macro score: -16 to +16
export function calcMacroScore(
indicators: CurrencyIndicators,
phase: BiasPhase
): number {
const mult = PHASE_MULTIPLIERS[phase] ?? PHASE_MULTIPLIERS.transition;
const signals: { key: keyof typeof INDICATOR_WEIGHTS; signal: number }[] = [
{ key: "policyRate", signal: signalFromSurprise(indicators.policyRate.surprise) },
{ key: "cpiCore", signal: signalFromSurprise(indicators.cpiCore.surprise) },
{ key: "pmiMfg", signal: indicators.pmiMfg.value !== null ? (indicators.pmiMfg.value > 50 ? 1 : -1) : 0 },
{ key: "pmiServices", signal: indicators.pmiServices.value !== null ? (indicators.pmiServices.value > 50 ? 1 : -1) : 0 },
{ key: "gdp", signal: signalFromSurprise(indicators.gdp.surprise) },
{ key: "retailSales", signal: signalFromSurprise(indicators.retailSales.surprise) },
{ key: "unemployment", signal: signalFromSurprise(indicators.unemployment.surprise) * -1 }, // inversion : chômage bas = haussier
{ key: "employment", signal: signalFromSurprise(indicators.employment.surprise) },
];
let total = 0;
for (const { key, signal } of signals) {
const weight = INDICATOR_WEIGHTS[key];
const multiplier = signal > 0 ? mult.bull : signal < 0 ? mult.bear : 1;
total += signal * weight * multiplier;
}
return Math.round(Math.max(-16, Math.min(16, total)));
}
// §6.5 divergence score: -5 to +5
export function calcDivergenceScore(params: {
retailLongPct: number | null;
cotPercentile: number | null;
cotDeltaWoW: number | null;
stirDeltaWoW: number | null;
bondSpreadDeltaWoW: number | null;
macroScore: number;
}): number {
const { retailLongPct, cotPercentile, cotDeltaWoW, stirDeltaWoW, bondSpreadDeltaWoW, macroScore } = params;
let sd = 0;
// Retail vs STIR
if (retailLongPct !== null && stirDeltaWoW !== null) {
if (retailLongPct > 70 && stirDeltaWoW < -0.5) sd -= 1; // retail long + STIR dovish
if (retailLongPct < 30 && stirDeltaWoW > 0.5) sd += 1; // retail short + STIR hawkish
}
// Retail vs Bonds 10Y
if (retailLongPct !== null && bondSpreadDeltaWoW !== null) {
if (retailLongPct > 70 && bondSpreadDeltaWoW < -5) sd -= 1;
if (retailLongPct < 30 && bondSpreadDeltaWoW > 5) sd += 1;
}
// COT vs Retail
if (cotPercentile !== null && cotDeltaWoW !== null && retailLongPct !== null) {
const cotBearish = cotPercentile < 50 || cotDeltaWoW < 0;
const cotBullish = cotPercentile > 50 && cotDeltaWoW > 0;
if (cotBearish && retailLongPct > 60) sd -= 1;
if (cotBullish && retailLongPct < 40) sd += 1;
}
// COT vs STIR
if (cotDeltaWoW !== null && stirDeltaWoW !== null) {
if (cotDeltaWoW > 0 && stirDeltaWoW < -0.5) sd -= 0.5;
if (cotDeltaWoW < 0 && stirDeltaWoW > 0.5) sd += 0.5;
}
// STIR vs Bonds (yield curve coherence)
if (stirDeltaWoW !== null && bondSpreadDeltaWoW !== null) {
if (stirDeltaWoW < -0.5 && bondSpreadDeltaWoW > 5) sd -= 0.5; // dovish short, hawkish long
if (stirDeltaWoW > 0.5 && bondSpreadDeltaWoW < -5) sd += 0.5;
}
// Retail extreme vs macro
if (retailLongPct !== null) {
if (retailLongPct > 80 && macroScore <= -2) sd -= 1;
if (retailLongPct < 20 && macroScore >= 2) sd += 1;
}
return Math.round(Math.max(-5, Math.min(5, sd)));
}
export function biasLabel(score: number): "ACHETEUR" | "NEUTRE" | "VENDEUR" {
if (score >= 4) return "ACHETEUR";
if (score <= -4) return "VENDEUR";
return "NEUTRE";
}
export function biasColor(score: number): string {
if (score >= 4) return "text-green-600";
if (score <= -4) return "text-red-600";
return "text-gray-500";
}
+138
View File
@@ -0,0 +1,138 @@
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CHF" | "CAD" | "AUD" | "NZD";
export type BiasPhase = "tightening" | "hawkish_pause" | "easing" | "dovish_pause" | "transition";
export interface Indicator {
value: number | null;
prev: number | null;
consensus: number | null;
surprise: number | null;
trend: "up" | "down" | "flat" | null;
lastUpdated: string;
}
export interface RateExpectation {
cb: string;
bps: number;
prob_pct: number;
prob_desc: string;
direction: "cut" | "hike";
}
export interface CurrencyIndicators {
policyRate: Indicator;
cpiCore: Indicator;
pmiMfg: Indicator;
pmiServices: Indicator;
gdp: Indicator;
retailSales: Indicator;
unemployment: Indicator;
employment: Indicator;
}
export interface COTData {
netContracts: number;
deltaWoW: number;
percentile52w: number;
signal: "bullish" | "bearish" | "contrarian_bullish" | "contrarian_bearish" | "neutral";
history: { weekEnding: string; net: number }[];
}
export interface RetailSentimentPair {
pair: string;
longPct: number;
shortPct: number;
change24h: number;
source: string;
signal: "contrarian_bearish" | "contrarian_bullish" | "neutral";
}
export interface STIRData {
instrument: string;
impliedRates: { tenor: string; rate: number }[];
cutsHikes12M: number;
deltaWoW: number;
signal: "bullish" | "bearish" | "neutral";
lastUpdated: string;
}
export interface Bond10YData {
yield: number;
deltaWoW_bps: number;
spreadVsUST_bps: number | null;
deltaSpreadWoW_bps: number | null;
inverted: boolean;
signal: "bullish" | "bearish" | "neutral";
lastUpdated: string;
}
export interface DivergenceEvent {
type: string;
intensity: 1 | 2 | 3;
detectedAt: string;
persisting: boolean;
persistingDays: number;
}
export interface CurrencyData {
currency: Currency;
name: string;
flag: string;
centralBank: string;
phase: BiasPhase;
indicators: CurrencyIndicators;
score: {
macro: number;
drivers: number;
divergence: number;
};
cot: COTData | null;
retailSentiment: {
pairs: RetailSentimentPair[];
aggregatedLongPct: number;
aggregatedSignal: RetailSentimentPair["signal"];
} | null;
stir: STIRData | null;
bond10Y: Bond10YData | null;
divergences: {
score: number;
active: DivergenceEvent[];
};
rateExpectations: RateExpectation | null;
lastUpdated: string;
}
export interface DriverData {
// Sentiment / Risk-On
vix: number | null;
vixDelta: number | null; // pts vs séance précédente
sp500: number | null; // prix SPY (ETF S&P 500)
sp500Change: number | null; // pts vs clôture j-1
sp500ChangePct: number | null; // % vs clôture j-1
btc: number | null; // BTC/USD
btcChange24h: number | null; // % variation 24h (CoinGecko)
// Crédit
hySpread: number | null;
igSpread: number | null;
// Taux & FX
dxy: number | null;
us10y: number | null;
us2y: number | null;
curveSlope: number | null;
// Commodités (avec delta vs session précédente)
gold: number | null;
goldDelta: number | null;
silver: number | null;
silverDelta: number | null;
brent: number | null;
brentDelta: number | null;
wti: number | null;
wtiDelta: number | null;
// Compat
copper: number | null;
}
export interface FXRates {
[pair: string]: number;
timestamp: number;
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["papaparse"],
},
};
export default nextConfig;
+6823
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "forex-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.5",
"react": "^18",
"react-dom": "^18",
"openai": "^4.52.7",
"recharts": "^2.12.7",
"lucide-react": "^0.400.0",
"papaparse": "^5.4.1",
"date-fns": "^3.6.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/papaparse": "^5.3.14",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.2.5"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+344
View File
@@ -0,0 +1,344 @@
"""
Scraper Giuseppe Dellamotta / investinglive.com
Cible : articles "Rate hikes by year-end" publiés périodiquement (~mensuel)
Auteur : Claude (Anthropic) usage personnel
Usage :
python investinglive_scraper.py # scrape les N derniers articles détectés
python investinglive_scraper.py --url <URL> # scrape un article spécifique
python investinglive_scraper.py --output json # sortie JSON (défaut : tableau console)
python investinglive_scraper.py --output json --save expectations.json
Stratégie de découverte d'articles :
1. Page auteur Giuseppe Dellamotta
2. Catégorie CentralBanks
3. Google Search comme fallback
Les articles contenant "Rate hikes by year-end" dans le corps sont sélectionnés.
"""
import requests
from bs4 import BeautifulSoup
import re
import json
import argparse
import time
import sys
from datetime import datetime
from urllib.parse import urljoin
# ── Configuration ──────────────────────────────────────────────────────────────
BASE_URL = "https://investinglive.com"
AUTHOR_URL = f"{BASE_URL}/author/giuseppe-dellamotta/"
CATEGORY_URL = f"{BASE_URL}/CentralBanks"
KEYWORD = "Rate hikes by year-end" # mot-clé discriminant
MAX_ARTICLES = 10 # nb max d'articles à scanner pour trouver les N derniers
# Headers navigateur réaliste (Chrome 124, Windows)
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,application/xml;"
"q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
}
REQUEST_DELAY = 1.5 # secondes entre requêtes (politesse)
# ── Banques centrales reconnues ─────────────────────────────────────────────────
CB_NAMES = {
"fed": "Fed (USD)",
"ecb": "ECB (EUR)",
"boe": "BoE (GBP)",
"boj": "BoJ (JPY)",
"snb": "SNB (CHF)",
"boc": "BoC (CAD)",
"rba": "RBA (AUD)",
"rbnz": "RBNZ (NZD)",
}
def normalize_cb(raw: str) -> str:
key = raw.lower().strip(" *:")
for k, v in CB_NAMES.items():
if k in key:
return v
return raw.strip(" *:")
# ── HTTP helpers ────────────────────────────────────────────────────────────────
_session = requests.Session()
_session.headers.update(HEADERS)
def get_page(url: str, retries: int = 3) -> BeautifulSoup | None:
"""Fetch une page HTML et retourne un BeautifulSoup. None si échec."""
for attempt in range(retries):
try:
if attempt > 0:
time.sleep(REQUEST_DELAY * (attempt + 1))
resp = _session.get(url, timeout=20)
resp.raise_for_status()
return BeautifulSoup(resp.text, "html.parser")
except requests.HTTPError as e:
print(f" [HTTP {e.response.status_code}] {url}", file=sys.stderr)
if e.response.status_code in (403, 404):
return None # inutile de retenter
except requests.RequestException as e:
print(f" [Réseau] {e}", file=sys.stderr)
return None
# ── Extraction de la date depuis l'URL ─────────────────────────────────────────
def date_from_url(url: str) -> str:
"""Extrait YYYY-MM-DD depuis le slug YYYYMMDD en fin d'URL."""
m = re.search(r"(\d{8})/?$", url)
if m:
d = m.group(1)
return f"{d[:4]}-{d[4:6]}-{d[6:8]}"
return "?"
# ── Parsing du bloc de données ──────────────────────────────────────────────────
# Pattern : **RBNZ:** 76 bps (70% probability of no change at the next meeting)
_ENTRY_RE = re.compile(
r"\*?\*?\s*([A-Za-z/]+)\s*:+\s*\*?\*?\s*"
r"(\d+)\s*bps\s*"
r"\((\d+)%\s+probability\s+of\s+([^)]+)\)",
re.IGNORECASE,
)
def parse_rate_entries(text: str) -> list[dict]:
return [
{
"cb": normalize_cb(m.group(1)),
"bps": int(m.group(2)),
"prob_pct": int(m.group(3)),
"prob_desc": m.group(4).strip(),
}
for m in _ENTRY_RE.finditer(text)
]
def extract_rate_data(soup: BeautifulSoup, url: str) -> dict | None:
"""
Extrait les blocs 'Rate cuts by year-end' et 'Rate hikes by year-end'
depuis le HTML d'un article.
Retourne None si le keyword n'est pas trouvé.
"""
# Titre
h1 = soup.find("h1")
title = h1.get_text(strip=True) if h1 else "N/A"
# Texte complet de l'article
article = (
soup.find("article")
or soup.find("div", class_=re.compile(r"article|content|entry|post", re.I))
or soup.body
)
text = article.get_text(separator="\n") if article else soup.get_text(separator="\n")
# Vérifier la présence du keyword
if KEYWORD.lower() not in text.lower():
return None
# Parser les sections
result = {
"url": url,
"title": title,
"date": date_from_url(url),
"scraped_at": datetime.utcnow().isoformat() + "Z",
"rate_cuts": [],
"rate_hikes": [],
}
lines = [l.strip() for l in text.splitlines() if l.strip()]
current_section = None
for line in lines:
ll = line.lower()
if "rate cuts by year-end" in ll:
current_section = "cuts"
continue
if "rate hikes by year-end" in ll:
current_section = "hikes"
continue
if current_section and len(line) < 250:
entries = parse_rate_entries(line)
for e in entries:
if current_section == "cuts":
e["direction"] = "cut"
result["rate_cuts"].append(e)
else:
e["direction"] = "hike"
result["rate_hikes"].append(e)
# Stop si on sort manifestement du bloc (paragraphe long)
if current_section and len(line) > 300:
current_section = None
return result
# ── Découverte des articles ─────────────────────────────────────────────────────
def discover_article_urls(limit: int = MAX_ARTICLES) -> list[str]:
"""
Découvre les URLs d'articles de Giuseppe Dellamotta depuis :
1. Sa page auteur
2. La catégorie CentralBanks
Retourne une liste dédupliquée, triée du plus récent au plus ancien.
"""
found = []
# Source 1 : page auteur
soup = get_page(AUTHOR_URL)
if soup:
for a in soup.find_all("a", href=True):
href = a["href"]
# Articles de la catégorie centralbank avec slug YYYYMMDD
if "/centralbank/" in href and re.search(r"\d{8}/?$", href):
full = urljoin(BASE_URL, href)
if full not in found:
found.append(full)
time.sleep(REQUEST_DELAY)
# Source 2 : catégorie (si page auteur insuffisante)
if len(found) < limit:
soup2 = get_page(CATEGORY_URL)
if soup2:
for a in soup2.find_all("a", href=True):
href = a["href"]
if "/centralbank/" in href and re.search(r"\d{8}/?$", href):
full = urljoin(BASE_URL, href)
if full not in found:
found.append(full)
time.sleep(REQUEST_DELAY)
# Trier du plus récent (date dans l'URL) au plus ancien
def url_date_key(url):
m = re.search(r"(\d{8})/?$", url)
return m.group(1) if m else "00000000"
found.sort(key=url_date_key, reverse=True)
return found[:limit]
# ── Scraping principal ──────────────────────────────────────────────────────────
def scrape_recent(n: int = 2, specific_url: str = None) -> list[dict]:
"""
Scrape les n derniers articles contenant KEYWORD.
Si specific_url est fourni, scrape uniquement cet article.
"""
if specific_url:
urls_to_check = [specific_url]
else:
print(f"[Découverte] Recherche des articles de Giuseppe Dellamotta...", file=sys.stderr)
urls_to_check = discover_article_urls(limit=MAX_ARTICLES)
print(f" {len(urls_to_check)} articles candidats trouvés.", file=sys.stderr)
results = []
for url in urls_to_check:
if len(results) >= n and not specific_url:
break
print(f" → Scanning: {url}", file=sys.stderr)
soup = get_page(url)
if not soup:
continue
data = extract_rate_data(soup, url)
if data:
print(f"'{KEYWORD}' trouvé — {len(data['rate_hikes'])} BC hike / {len(data['rate_cuts'])} BC cut", file=sys.stderr)
results.append(data)
else:
print(f" ⏭ Keyword absent — article ignoré", file=sys.stderr)
time.sleep(REQUEST_DELAY)
return results
# ── Formatage console ───────────────────────────────────────────────────────────
def format_table(results: list[dict]) -> str:
lines = []
for r in results:
lines.append("=" * 65)
lines.append(f"📅 {r['date']} | {r['title'][:50]}")
lines.append(f"🔗 {r['url']}")
lines.append("")
all_entries = []
for e in r["rate_cuts"]:
all_entries.append((e["cb"], -e["bps"], e["prob_pct"], e["prob_desc"], "cut"))
for e in r["rate_hikes"]:
all_entries.append((e["cb"], e["bps"], e["prob_pct"], e["prob_desc"], "hike"))
# Trier par |bps| décroissant
all_entries.sort(key=lambda x: -abs(x[1]))
lines.append(f" {'Banque Centrale':<16} {'Attentes fin d\'année':>14} Prochain meeting")
lines.append(f" {'-'*16} {'-'*14} {'-'*30}")
for cb, bps, prob, desc, direction in all_entries:
arrow = "▲ +" if direction == "hike" else "▼ -"
lines.append(
f" {cb:<16} {arrow}{abs(bps):>3} bps {prob}% prob. of {desc}"
)
lines.append("")
lines.append("=" * 65)
lines.append(f"Scraped at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
return "\n".join(lines)
# ── CLI ─────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Scraper rate expectations — investinglive.com")
parser.add_argument("--url", help="URL spécifique à scraper (optionnel)")
parser.add_argument("--n", type=int, default=2, help="Nombre de derniers articles (défaut: 2)")
parser.add_argument("--output", choices=["table", "json"], default="table", help="Format de sortie")
parser.add_argument("--save", help="Fichier de sauvegarde JSON (optionnel)")
args = parser.parse_args()
results = scrape_recent(n=args.n, specific_url=args.url)
if not results:
print("❌ Aucun article trouvé avec le keyword.", file=sys.stderr)
sys.exit(1)
if args.output == "json" or args.save:
json_str = json.dumps(results, ensure_ascii=False, indent=2)
if args.save:
with open(args.save, "w", encoding="utf-8") as f:
f.write(json_str)
print(f"💾 Sauvegardé dans {args.save}", file=sys.stderr)
if args.output == "json":
print(json_str)
else:
print(format_table(results))
else:
print(format_table(results))
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
bull: "#16a34a",
bear: "#dc2626",
neutral: "#6b7280",
},
},
},
plugins: [],
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long