mirror of
https://github.com/caty21/forex-dashboard.git
synced 2026-07-27 20:37:45 +00:00
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:
@@ -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
@@ -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() });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user