mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
Forex Dashboard v8.0
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "forex-dashboard",
|
||||||
|
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env.local
|
||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
|
||||||
@@ -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`.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. <span className="text-gray-500 font-medium">{fmt(prev)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 tabular-nums">
|
||||||
|
Cons.
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ~12–18 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ["papaparse"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6823
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
@@ -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
Reference in New Issue
Block a user