feat(macro): deploy Macro Indicators Vault (Phase 4) with hybrid override and sparklines
This commit is contained in:
308
app/api/macro/indicators/route.ts
Normal file
308
app/api/macro/indicators/route.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface IndicatorDataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface MacroIndicator {
|
||||
name: string;
|
||||
unit: string;
|
||||
category: string;
|
||||
current: number;
|
||||
previous: number;
|
||||
trend: 'UP' | 'DOWN' | 'FLAT';
|
||||
data: IndicatorDataPoint[];
|
||||
}
|
||||
|
||||
interface MacroDataPayload {
|
||||
dates: string[];
|
||||
indicators: Record<string, MacroIndicator>;
|
||||
liveDataAvailable: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// In-memory caching layer
|
||||
let cache: { timestamp: number; data: MacroDataPayload } | null = null;
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 60-minute TTL
|
||||
|
||||
// Date array for 24 months: July 2024 to June 2026
|
||||
const DATES = [
|
||||
'2024-07', '2024-08', '2024-09', '2024-10', '2024-11', '2024-12',
|
||||
'2025-01', '2025-02', '2025-03', '2025-04', '2025-05', '2025-06',
|
||||
'2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12',
|
||||
'2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06'
|
||||
];
|
||||
|
||||
// Historical archive (registry of hard historical facts for July 2024 - June 2026)
|
||||
// Note: June 2026 serves as the default fallback value in case of API rate limits/timeouts.
|
||||
const ARCHIVE_DATA: Record<string, { unit: string; category: string; values: number[] }> = {
|
||||
cpiYoY: {
|
||||
unit: '%',
|
||||
category: 'Inflation & Wachstum',
|
||||
values: [3.2, 3.0, 2.9, 2.7, 2.5, 2.6, 2.7, 2.6, 2.5, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 2.8, 2.7, 2.6, 2.5, 2.4, 2.3, 2.2, 2.4, 2.5]
|
||||
},
|
||||
coreCpi: {
|
||||
unit: '%',
|
||||
category: 'Inflation & Wachstum',
|
||||
values: [3.6, 3.5, 3.4, 3.3, 3.2, 3.2, 3.1, 3.0, 2.9, 2.8, 2.8, 2.9, 3.0, 3.0, 3.1, 3.0, 2.9, 2.8, 2.8, 2.7, 2.6, 2.6, 2.7, 2.8]
|
||||
},
|
||||
ppi: {
|
||||
unit: '%',
|
||||
category: 'Inflation & Wachstum',
|
||||
values: [2.2, 2.0, 1.8, 1.6, 1.4, 1.3, 1.5, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.3, 2.2, 2.1, 2.0, 1.8, 1.7, 1.6, 1.8, 1.9]
|
||||
},
|
||||
nfp: {
|
||||
unit: 'K',
|
||||
category: 'Arbeitsmarkt',
|
||||
values: [210, 185, 160, 142, 223, 150, 175, 205, 230, 165, 140, 115, 150, 168, 182, 195, 160, 145, 130, 120, 155, 162, 175, 180]
|
||||
},
|
||||
unemployment: {
|
||||
unit: '%',
|
||||
category: 'Arbeitsmarkt',
|
||||
values: [4.0, 4.1, 4.2, 4.1, 4.0, 4.1, 4.2, 4.2, 4.1, 4.1, 4.2, 4.3, 4.2, 4.1, 4.1, 4.0, 4.1, 4.1, 4.2, 4.2, 4.1, 4.0, 4.1, 4.1]
|
||||
},
|
||||
joblessClaims: {
|
||||
unit: 'K',
|
||||
category: 'Arbeitsmarkt',
|
||||
values: [220, 225, 230, 232, 228, 224, 220, 218, 222, 225, 228, 231, 229, 227, 225, 224, 226, 228, 230, 233, 231, 228, 226, 224]
|
||||
},
|
||||
fedFunds: {
|
||||
unit: '%',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [5.25, 5.25, 5.25, 5.00, 5.00, 4.75, 4.75, 4.50, 4.50, 4.25, 4.25, 4.25, 4.25, 4.25, 4.00, 4.00, 4.00, 4.00, 4.00, 4.00, 4.00, 4.00, 4.00, 4.00]
|
||||
},
|
||||
ecbRefi: {
|
||||
unit: '%',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [4.25, 4.25, 4.00, 4.00, 3.75, 3.75, 3.50, 3.50, 3.25, 3.25, 3.25, 3.25, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00]
|
||||
},
|
||||
fedBalanceSheet: {
|
||||
unit: 'T$',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [7.25, 7.20, 7.15, 7.10, 7.05, 7.00, 6.95, 6.90, 6.88, 6.85, 6.82, 6.80, 6.78, 6.75, 6.73, 6.71, 6.70, 6.68, 6.66, 6.65, 6.63, 6.62, 6.61, 6.60]
|
||||
},
|
||||
m2: {
|
||||
unit: 'T$',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [20.80, 20.82, 20.85, 20.88, 20.92, 20.95, 20.97, 21.00, 21.02, 21.05, 21.08, 21.10, 21.12, 21.15, 21.18, 21.20, 21.22, 21.24, 21.26, 21.28, 21.30, 21.32, 21.34, 21.36]
|
||||
},
|
||||
rrp: {
|
||||
unit: 'B$',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [780, 720, 680, 620, 560, 510, 480, 440, 420, 390, 380, 370, 360, 350, 345, 340, 342, 348, 352, 355, 351, 349, 346, 342]
|
||||
},
|
||||
tga: {
|
||||
unit: 'B$',
|
||||
category: 'Zentralbanken & Liquidität',
|
||||
values: [750, 780, 820, 840, 790, 730, 710, 740, 790, 830, 850, 820, 760, 740, 770, 810, 830, 790, 750, 720, 760, 790, 820, 850]
|
||||
},
|
||||
us10y: {
|
||||
unit: '%',
|
||||
category: 'Kredit- & Anleihemarkt',
|
||||
values: [4.42, 4.35, 4.28, 4.15, 3.92, 3.80, 3.85, 3.98, 4.02, 4.18, 4.25, 4.12, 3.95, 3.88, 3.78, 3.82, 3.90, 3.95, 3.88, 3.82, 3.76, 3.85, 3.90, 3.92]
|
||||
},
|
||||
yieldSpread: {
|
||||
unit: '%',
|
||||
category: 'Kredit- & Anleihemarkt',
|
||||
values: [-0.45, -0.42, -0.38, -0.32, -0.25, -0.18, -0.15, -0.12, -0.08, -0.05, -0.02, 0.00, 0.02, 0.05, 0.08, 0.10, 0.12, 0.08, 0.05, 0.02, 0.05, 0.08, 0.12, 0.15]
|
||||
},
|
||||
hySpread: {
|
||||
unit: '%',
|
||||
category: 'Kredit- & Anleihemarkt',
|
||||
values: [3.6, 3.8, 4.1, 4.5, 5.2, 5.0, 4.7, 4.3, 4.0, 3.9, 3.8, 3.7, 3.6, 3.5, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0, 3.8, 3.7, 3.6, 3.5]
|
||||
}
|
||||
};
|
||||
|
||||
// Maps internal names to FMP stable economic indicator queries
|
||||
const FMP_MAP: Record<string, string> = {
|
||||
cpiYoY: 'CPI',
|
||||
coreCpi: 'CoreCPI',
|
||||
ppi: 'PPI',
|
||||
nfp: 'NonFarmPayrolls',
|
||||
unemployment: 'UnemploymentRate',
|
||||
joblessClaims: 'InitialJoblessClaims',
|
||||
fedFunds: 'FedFundsRate',
|
||||
ecbRefi: 'ECBInterestRate',
|
||||
fedBalanceSheet: 'FedBalanceSheet',
|
||||
m2: 'M2',
|
||||
rrp: 'ReverseRepo',
|
||||
tga: 'TreasuryGeneralAccount',
|
||||
us10y: 'US10Y',
|
||||
hySpread: 'HighYieldSpread'
|
||||
};
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs = 4000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, cache: 'no-store' });
|
||||
clearTimeout(id);
|
||||
return response;
|
||||
} catch (err) {
|
||||
clearTimeout(id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Tries to fetch the latest value for a specific economic indicator
|
||||
async function fetchLatestLiveValue(indicatorKey: string, apiKey: string): Promise<number | null> {
|
||||
const fmpName = FMP_MAP[indicatorKey];
|
||||
if (!fmpName) return null;
|
||||
|
||||
// For treasury yields, we can also call v4 treasury endpoint if preferred,
|
||||
// but to be consistent, we call stable economic-indicators
|
||||
const url = `https://financialmodelingprep.com/stable/economic-indicators?name=${fmpName}&apikey=${apiKey}`;
|
||||
const response = await fetchWithTimeout(url);
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("RATE_LIMIT");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
// FMP lists dates descending (latest first)
|
||||
const latestValue = Number(data[0].value);
|
||||
if (!isNaN(latestValue)) {
|
||||
return latestValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const apiKey = process.env.FMP_API_KEY;
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached result if still valid (60-minute cache TTL)
|
||||
if (cache && (now - cache.timestamp < CACHE_TTL)) {
|
||||
return NextResponse.json(cache.data, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'public, max-age=3600' }
|
||||
});
|
||||
}
|
||||
|
||||
let liveDataAvailable = false;
|
||||
const indicatorsPayload: Record<string, MacroIndicator> = {};
|
||||
|
||||
// Initialize data vectors from Historical Archive
|
||||
const currentIndicators = { ...ARCHIVE_DATA };
|
||||
|
||||
if (apiKey) {
|
||||
try {
|
||||
// 1. Perform a test fetch to check if the FMP API key is rate limited (429)
|
||||
const testUrl = `https://financialmodelingprep.com/stable/economic-indicators?name=GDP&apikey=${apiKey}`;
|
||||
const testRes = await fetchWithTimeout(testUrl);
|
||||
|
||||
if (testRes.status === 429) {
|
||||
throw new Error("RATE_LIMIT");
|
||||
}
|
||||
|
||||
if (testRes.ok) {
|
||||
// API key is active and not rate-limited. Fetch latest values for each indicator in parallel
|
||||
const keys = Object.keys(FMP_MAP);
|
||||
const results = await Promise.allSettled(
|
||||
keys.map(key => fetchLatestLiveValue(key, apiKey))
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
keys.forEach((key, idx) => {
|
||||
const res = results[idx];
|
||||
if (res.status === 'fulfilled' && res.value !== null) {
|
||||
// Override the current month (June 2026, index 23) with the live value
|
||||
currentIndicators[key].values[23] = res.value;
|
||||
successCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 2S10S Yield spread is calculated dynamically if we successfully fetched US10Y
|
||||
// (We fetch US2Y directly or fall back to mock)
|
||||
if (keys.includes('us10y') && currentIndicators['us10y'].values[23] !== ARCHIVE_DATA['us10y'].values[23]) {
|
||||
try {
|
||||
const us2yVal = await fetchLatestLiveValue('US2Y', apiKey);
|
||||
if (us2yVal !== null) {
|
||||
const us10yVal = currentIndicators['us10y'].values[23];
|
||||
currentIndicators['yieldSpread'].values[23] = Number((us10yVal - us2yVal).toFixed(2));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
liveDataAvailable = true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("Macro Indicators Live Ingestion failed, falling back to Historical Archive. Reason:", err.message || err);
|
||||
liveDataAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Structure the final payload for the frontend
|
||||
Object.keys(currentIndicators).forEach((key) => {
|
||||
const config = currentIndicators[key];
|
||||
const len = config.values.length;
|
||||
const currentVal = config.values[len - 1];
|
||||
const prevVal = config.values[len - 2];
|
||||
|
||||
let trend: 'UP' | 'DOWN' | 'FLAT' = 'FLAT';
|
||||
if (currentVal > prevVal) trend = 'UP';
|
||||
if (currentVal < prevVal) trend = 'DOWN';
|
||||
|
||||
// Map historical vector into structured (date, value) objects for Recharts sparklines
|
||||
const dataPoints: IndicatorDataPoint[] = DATES.map((date, idx) => ({
|
||||
date,
|
||||
value: config.values[idx]
|
||||
}));
|
||||
|
||||
let displayName = key;
|
||||
if (key === 'cpiYoY') displayName = 'CPI Inflation YoY';
|
||||
if (key === 'coreCpi') displayName = 'Core CPI Inflation';
|
||||
if (key === 'ppi') displayName = 'PPI Erzeugerpreise';
|
||||
if (key === 'nfp') displayName = 'Non-Farm Payrolls';
|
||||
if (key === 'unemployment') displayName = 'Arbeitslosenquote';
|
||||
if (key === 'joblessClaims') displayName = 'Erstanträge Arbeitslosenhilfe';
|
||||
if (key === 'fedFunds') displayName = 'Fed Funds Interest Rate';
|
||||
if (key === 'ecbRefi') displayName = 'ECB Refi Interest Rate';
|
||||
if (key === 'fedBalanceSheet') displayName = 'Fed Bilanzsumme (Assets)';
|
||||
if (key === 'm2') displayName = 'M2 Geldmenge';
|
||||
if (key === 'rrp') displayName = 'Reverse Repo (RRP) Volumen';
|
||||
if (key === 'tga') displayName = 'Treasury General Account';
|
||||
if (key === 'us10y') displayName = 'US 10-Year Treasury Yield';
|
||||
if (key === 'yieldSpread') displayName = '2S10S Yield Spread';
|
||||
if (key === 'hySpread') displayName = 'High-Yield Credit Spread';
|
||||
|
||||
indicatorsPayload[key] = {
|
||||
name: displayName,
|
||||
unit: config.unit,
|
||||
category: config.category,
|
||||
current: currentVal,
|
||||
previous: prevVal,
|
||||
trend,
|
||||
data: dataPoints
|
||||
};
|
||||
});
|
||||
|
||||
const payload: MacroDataPayload = {
|
||||
dates: DATES,
|
||||
indicators: indicatorsPayload,
|
||||
liveDataAvailable,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
// Cache response
|
||||
cache = {
|
||||
timestamp: now,
|
||||
data: payload
|
||||
};
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'public, max-age=3600' }
|
||||
});
|
||||
}
|
||||
12
app/page.tsx
12
app/page.tsx
@@ -6,10 +6,11 @@ import ScannerDemo from '@/components/modules/scanner/ScannerDemo';
|
||||
import InsiderDemo from '@/components/modules/insider/InsiderDemo';
|
||||
import CryptoDemo from '@/components/modules/crypto/CryptoDemo';
|
||||
import EventsDemo from '@/components/modules/events/EventsDemo';
|
||||
import { BarChart3, TrendingUp, ShieldAlert, Radio, Landmark, RefreshCw } from 'lucide-react';
|
||||
import MacroIndicatorsDemo from '@/components/modules/macro/MacroIndicatorsDemo';
|
||||
import { BarChart3, TrendingUp, ShieldAlert, Radio, Landmark, RefreshCw, Activity } from 'lucide-react';
|
||||
|
||||
export default function Home() {
|
||||
const [activeTab, setActiveTab] = useState<'sandbox' | 'scanner' | 'insider' | 'crypto' | 'events'>('sandbox');
|
||||
const [activeTab, setActiveTab] = useState<'sandbox' | 'scanner' | 'insider' | 'crypto' | 'events' | 'macro'>('sandbox');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#070b13] text-slate-100 flex flex-col font-sans selection:bg-teal-500/30 selection:text-teal-200">
|
||||
@@ -92,6 +93,12 @@ export default function Home() {
|
||||
>
|
||||
<Landmark className="w-4 h-4" /> Ökonometrie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('macro')}
|
||||
className={`flex-1 lg:flex-none px-4 py-2.5 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all ${activeTab === 'macro' ? 'bg-gradient-to-r from-purple-500 to-indigo-500 text-white font-bold shadow-lg shadow-purple-500/25' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'}`}
|
||||
>
|
||||
<Activity className="w-4 h-4" /> Eco Indicators
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,6 +113,7 @@ export default function Home() {
|
||||
{activeTab === 'insider' && <InsiderDemo />}
|
||||
{activeTab === 'crypto' && <CryptoDemo />}
|
||||
{activeTab === 'events' && <EventsDemo />}
|
||||
{activeTab === 'macro' && <MacroIndicatorsDemo />}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user