feat: WTI/Brent via Stooq, PMI scraping (TE), Groq replace Bytez

- drivers: switch Brent (cb.f) and WTI (cl.f) from stale FRED to Stooq
  near-real-time futures — same stooqMetal() already used for gold/silver
- macro: add scrapePMI() to scrape Trading Economics meta description for
  manufacturing-pmi and services-pmi for all 8 currencies (no API key needed)
- narrative: replace dead Bytez API with Groq (OpenAI-compatible, free tier)
  env var renamed BYTEZ_API_KEY → GROQ_API_KEY, model llama-3.1-8b-instant
- NarrativeButton: rebrand "Erreur Bytez" / "Bytez AI" → "Erreur IA" / "Groq AI"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-05-28 22:52:49 +02:00
co-authored by Claude Sonnet 4.6
parent 0a42e93d01
commit ecb815619b
4 changed files with 99 additions and 20 deletions
+10 -6
View File
@@ -105,12 +105,16 @@ export async function GET() {
// 1. Marchés indices — FRED (données fin de journée, cache 24h) // 1. Marchés indices — FRED (données fin de journée, cache 24h)
// VIXCLS = VIX clôture CBOE | SP500 = S&P 500 // VIXCLS = VIX clôture CBOE | SP500 = S&P 500
// DCOILBRENTEU = Brent | DCOILWTICO = WTI const [vixQ, sp500Q] = await Promise.all([
const [vixQ, sp500Q, brentQ, wtiQ] = await Promise.all([ fredObsDelta("VIXCLS", fredKey),
fredObsDelta("VIXCLS", fredKey), fredObsDelta("SP500", fredKey),
fredObsDelta("SP500", fredKey), ]);
fredObsDelta("DCOILBRENTEU", fredKey),
fredObsDelta("DCOILWTICO", fredKey), // Pétrole — Stooq futures (quasi temps réel, cache 5 min, sans clé API)
// cl.f = WTI NYMEX | cb.f = Brent ICE → delta = variation intraday vs ouverture
const [brentQ, wtiQ] = await Promise.all([
stooqMetal("cb.f"),
stooqMetal("cl.f"),
]); ]);
// 2. Métaux précieux — Stooq (quasi temps réel, cache 5 min, sans clé API) // 2. Métaux précieux — Stooq (quasi temps réel, cache 5 min, sans clé API)
+77 -2
View File
@@ -79,6 +79,63 @@ async function boeRate() {
} catch { return []; } } catch { return []; }
} }
// ── Trading Economics PMI scraping ───────────────────────────────────────────
// URL pattern: https://tradingeconomics.com/{country}/{indicator}
// Source : balise <meta name="description"> dans le HTML
// Ex: "Manufacturing PMI in the United States increased to 55.30 points in May
// from 54.50 points in April of 2026"
const TE_COUNTRY: Record<string, string> = {
USD: "united-states",
EUR: "euro-area",
GBP: "united-kingdom",
JPY: "japan",
CHF: "switzerland",
CAD: "canada",
AUD: "australia",
NZD: "new-zealand",
};
async function scrapePMI(
currency: string,
indicator: "manufacturing-pmi" | "services-pmi"
): Promise<{ value: number | null; prev: number | null }> {
const country = TE_COUNTRY[currency];
if (!country) return { value: null, prev: null };
try {
const url = `https://tradingeconomics.com/${country}/${indicator}`;
const res = await fetch(url, {
next: { revalidate: 3600 }, // cache 1h — données PMI mensuelles
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-Language": "en-US,en;q=0.9",
},
});
if (!res.ok) return { value: null, prev: null };
const html = await res.text();
// Cherche la balise meta description
const metaMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i)
?? html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i);
if (!metaMatch) return { value: null, prev: null };
const desc = metaMatch[1];
// Extraction : "...to XX.XX points in ... from YY.YY points..."
const numRe = /(?:increased|decreased|declined|rose|fell|unchanged)\s+to\s+([\d.]+)\s+points?.+?from\s+([\d.]+)\s+points?/i;
const m = desc.match(numRe);
if (!m) {
// Fallback : premier et deuxième nombre dans la description
const nums = desc.match(/\b(\d{1,3}\.\d{1,2})\b/g);
if (nums && nums.length >= 1) {
return {
value: parseFloat(nums[0]),
prev: nums[1] ? parseFloat(nums[1]) : null,
};
}
return { value: null, prev: null };
}
return { value: parseFloat(m[1]), prev: parseFloat(m[2]) };
} catch { return { value: null, prev: null }; }
}
// ── Shared helpers ──────────────────────────────────────────────────────────── // ── Shared helpers ────────────────────────────────────────────────────────────
type Obs = { date: string; value: number }; type Obs = { date: string; value: number };
@@ -187,8 +244,26 @@ export async function GET(req: NextRequest) {
for (const field of Object.keys(fieldMap)) { for (const field of Object.keys(fieldMap)) {
if (!(field in indicators)) indicators[field] = null; if (!(field in indicators)) indicators[field] = null;
} }
indicators.pmiMfg = null; // ── PMI scraping (Trading Economics) ──────────────────────────────────────
indicators.pmiServices = null; const [pmiMfgRaw, pmiSvcRaw] = await Promise.all([
scrapePMI(currency, "manufacturing-pmi"),
scrapePMI(currency, "services-pmi"),
]);
// Convertit le résultat { value, prev } en format Indicator (surprise = diff)
const toPmiIndicator = (raw: { value: number | null; prev: number | null }) => {
if (raw.value === null) return null;
const surprise = raw.prev !== null ? parseFloat((raw.value - raw.prev).toFixed(2)) : null;
return {
value: raw.value,
prev: raw.prev,
surprise,
trend: surprise !== null ? (surprise > 0 ? "up" : surprise < 0 ? "down" : "flat") as "up"|"down"|"flat" : null,
lastUpdated: null,
};
};
indicators.pmiMfg = toPmiIndicator(pmiMfgRaw);
indicators.pmiServices = toPmiIndicator(pmiSvcRaw);
const data = { currency, indicators, fetchedAt: new Date().toISOString() }; const data = { currency, indicators, fetchedAt: new Date().toISOString() };
_cache.set(currency, { data, ts: Date.now() }); _cache.set(currency, { data, ts: Date.now() });
+9 -9
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai"; import OpenAI from "openai";
// Bytez uses an OpenAI-compatible API // Groq — API OpenAI-compatible, niveau gratuit, modèles Llama
const bytez = new OpenAI({ const groq = new OpenAI({
apiKey: process.env.BYTEZ_API_KEY ?? "", apiKey: process.env.GROQ_API_KEY ?? "",
baseURL: "https://api.bytez.com/models/openai", baseURL: "https://api.groq.com/openai/v1",
}); });
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. 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.
@@ -19,9 +19,9 @@ Tes analyses sont :
Format de réponse : texte court, 80-120 mots maximum par devise.`; Format de réponse : texte court, 80-120 mots maximum par devise.`;
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const apiKey = process.env.BYTEZ_API_KEY; const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) { if (!apiKey) {
return NextResponse.json({ error: "BYTEZ_API_KEY not configured" }, { status: 503 }); return NextResponse.json({ error: "GROQ_API_KEY not configured" }, { status: 503 });
} }
let body: { let body: {
@@ -81,8 +81,8 @@ Résumé en 3 points : situation actuelle, signal directionnel, risque principal
} }
try { try {
const completion = await bytez.chat.completions.create({ const completion = await groq.chat.completions.create({
model: "meta-llama/Llama-3.1-8B-Instruct", model: "llama-3.1-8b-instant",
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage }, { role: "user", content: userMessage },
@@ -95,6 +95,6 @@ Résumé en 3 points : situation actuelle, signal directionnel, risque principal
return NextResponse.json({ analysis: text, model: completion.model, mode }); return NextResponse.json({ analysis: text, model: completion.model, mode });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Bytez error: ${message}` }, { status: 502 }); return NextResponse.json({ error: `Groq error: ${message}` }, { status: 502 });
} }
} }
+3 -3
View File
@@ -53,7 +53,7 @@ export default function NarrativeButton({ currency, indicators, macroScore }: Pr
{error && ( {error && (
<span className="text-[10px] text-red-500 truncate max-w-[140px]" title={error}> <span className="text-[10px] text-red-500 truncate max-w-[140px]" title={error}>
Erreur Bytez Erreur IA
</span> </span>
)} )}
@@ -66,7 +66,7 @@ export default function NarrativeButton({ currency, indicators, macroScore }: Pr
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles size={15} className="text-indigo-500" /> <Sparkles size={15} className="text-indigo-500" />
<span className="font-medium text-sm">Analyse {currency} Bytez AI</span> <span className="font-medium text-sm">Analyse {currency} Groq AI</span>
</div> </div>
<button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-700"> <button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-700">
<X size={15} /> <X size={15} />
@@ -74,7 +74,7 @@ export default function NarrativeButton({ currency, indicators, macroScore }: Pr
</div> </div>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{analysis}</p> <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"> <div className="mt-3 text-[10px] text-gray-400 text-right">
Powered by Bytez · Llama 3.1 8B Powered by Groq · Llama 3.1 8B
</div> </div>
</div> </div>
</div> </div>