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
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)
// VIXCLS = VIX clôture CBOE | SP500 = S&P 500
// DCOILBRENTEU = Brent | DCOILWTICO = WTI
const [vixQ, sp500Q, brentQ, wtiQ] = await Promise.all([
fredObsDelta("VIXCLS", fredKey),
fredObsDelta("SP500", fredKey),
fredObsDelta("DCOILBRENTEU", fredKey),
fredObsDelta("DCOILWTICO", fredKey),
const [vixQ, sp500Q] = await Promise.all([
fredObsDelta("VIXCLS", fredKey),
fredObsDelta("SP500", 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)
+77 -2
View File
@@ -79,6 +79,63 @@ async function boeRate() {
} 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 ────────────────────────────────────────────────────────────
type Obs = { date: string; value: number };
@@ -187,8 +244,26 @@ export async function GET(req: NextRequest) {
for (const field of Object.keys(fieldMap)) {
if (!(field in indicators)) indicators[field] = null;
}
indicators.pmiMfg = null;
indicators.pmiServices = null;
// ── PMI scraping (Trading Economics) ──────────────────────────────────────
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() };
_cache.set(currency, { data, ts: Date.now() });
+9 -9
View File
@@ -1,10 +1,10 @@
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",
// Groq — API OpenAI-compatible, niveau gratuit, modèles Llama
const groq = new OpenAI({
apiKey: process.env.GROQ_API_KEY ?? "",
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.
@@ -19,9 +19,9 @@ Tes analyses sont :
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;
const apiKey = process.env.GROQ_API_KEY;
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: {
@@ -81,8 +81,8 @@ 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",
const completion = await groq.chat.completions.create({
model: "llama-3.1-8b-instant",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ 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 });
} catch (err: unknown) {
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 && (
<span className="text-[10px] text-red-500 truncate max-w-[140px]" title={error}>
Erreur Bytez
Erreur IA
</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 gap-2">
<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>
<button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-700">
<X size={15} />
@@ -74,7 +74,7 @@ export default function NarrativeButton({ currency, indicators, macroScore }: Pr
</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
Powered by Groq · Llama 3.1 8B
</div>
</div>
</div>