mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(economic): add FAO Food Price Index panel (#2682)
* feat(economic): add FAO Food Price Index panel Adds a new panel tracking the FAO Global Food Price Index (FFPI) for the past 12 months, complementing existing consumer prices, fuel prices, and Big Mac Index trackers. - proto: GetFaoFoodPriceIndex RPC with 6-series response (Food, Cereals, Meat, Dairy, Oils, Sugar + MoM/YoY pct) - seeder: seed-fao-food-price-index.mjs with 90-day TTL (3× monthly), isMain guard, parseVal NaN safety, correct 13-point slice - handler/gateway: static tier RPC wired into economicHandler - bootstrap/health: bootstrapped as SLOW_KEY; maxStaleMin=86400 (60 days) - panel: SVG multi-line chart with 6 series, auto-scaled Y axis, headline with MoM/YoY indicators, info tooltip, bootstrap hydration - CMD+K: panel:fao-food-price-index with fao/ffpi/food keywords - Railway: fao-ffpi cron seeder service (0.5 vCPU, 0.5 GB, daily 08:45) - locales: full en.json keys for panel UI strings - ais-relay: faoFoodPriceIndex added to economic bootstrap context * fix(economic): add faoFoodPriceIndex to cache-keys.ts BOOTSTRAP_CACHE_KEYS and BOOTSTRAP_TIERS * fix(economic): correct cron comment in fao seeder to reflect daily schedule
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -71,6 +71,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
groceryBasket: 'economic:grocery-basket:v1',
|
||||
bigmac: 'economic:bigmac:v1',
|
||||
fuelPrices: 'economic:fuel-prices:v1',
|
||||
faoFoodPriceIndex: 'economic:fao-ffpi:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
euGasStorage: 'economic:eu-gas-storage:v1',
|
||||
eurostatCountryData: 'economic:eurostat-country-data:v1',
|
||||
@@ -103,6 +104,7 @@ const SLOW_KEYS = new Set([
|
||||
'groceryBasket',
|
||||
'bigmac',
|
||||
'fuelPrices',
|
||||
'faoFoodPriceIndex',
|
||||
'nationalDebt',
|
||||
'euGasStorage',
|
||||
'eurostatCountryData',
|
||||
|
||||
@@ -54,6 +54,7 @@ const BOOTSTRAP_KEYS = {
|
||||
groceryBasket: 'economic:grocery-basket:v1',
|
||||
bigmac: 'economic:bigmac:v1',
|
||||
fuelPrices: 'economic:fuel-prices:v1',
|
||||
faoFoodPriceIndex: 'economic:fao-ffpi:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
defiTokens: 'market:defi-tokens:v1',
|
||||
aiTokens: 'market:ai-tokens:v1',
|
||||
@@ -198,6 +199,7 @@ const SEED_META = {
|
||||
groceryBasket: { key: 'seed-meta:economic:grocery-basket', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
|
||||
bigmac: { key: 'seed-meta:economic:bigmac', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
|
||||
fuelPrices: { key: 'seed-meta:economic:fuel-prices', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
|
||||
faoFoodPriceIndex: { key: 'seed-meta:economic:fao-ffpi', maxStaleMin: 86400 }, // monthly seed; 86400 = 60 days (2x interval)
|
||||
thermalEscalation: { key: 'seed-meta:thermal:escalation', maxStaleMin: 360 }, // cron every 2h; 360 = 3x interval (was 240 = 2x)
|
||||
nationalDebt: { key: 'seed-meta:economic:national-debt', maxStaleMin: 10080 }, // 7 days — monthly seed
|
||||
tariffTrendsUs: { key: 'seed-meta:trade:tariffs:v1:840:all:10', maxStaleMin: 900 },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -702,6 +702,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/economic/v1/get-fao-food-price-index:
|
||||
get:
|
||||
tags:
|
||||
- EconomicService
|
||||
summary: GetFaoFoodPriceIndex
|
||||
description: GetFaoFoodPriceIndex retrieves the FAO Food Price Index for the past 12 months.
|
||||
operationId: GetFaoFoodPriceIndex
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetFaoFoodPriceIndexResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
@@ -1919,3 +1945,46 @@ components:
|
||||
format: double
|
||||
missing:
|
||||
type: boolean
|
||||
GetFaoFoodPriceIndexRequest:
|
||||
type: object
|
||||
GetFaoFoodPriceIndexResponse:
|
||||
type: object
|
||||
properties:
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FaoFoodPricePoint'
|
||||
fetchedAt:
|
||||
type: string
|
||||
currentFfpi:
|
||||
type: number
|
||||
format: double
|
||||
momPct:
|
||||
type: number
|
||||
format: double
|
||||
yoyPct:
|
||||
type: number
|
||||
format: double
|
||||
FaoFoodPricePoint:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
ffpi:
|
||||
type: number
|
||||
format: double
|
||||
meat:
|
||||
type: number
|
||||
format: double
|
||||
dairy:
|
||||
type: number
|
||||
format: double
|
||||
cereals:
|
||||
type: number
|
||||
format: double
|
||||
oils:
|
||||
type: number
|
||||
format: double
|
||||
sugar:
|
||||
type: number
|
||||
format: double
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.economic.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message FaoFoodPricePoint {
|
||||
string date = 1; // YYYY-MM
|
||||
double ffpi = 2; // FAO Food Price Index (composite)
|
||||
double meat = 3;
|
||||
double dairy = 4;
|
||||
double cereals = 5;
|
||||
double oils = 6;
|
||||
double sugar = 7;
|
||||
}
|
||||
|
||||
message GetFaoFoodPriceIndexRequest {}
|
||||
|
||||
message GetFaoFoodPriceIndexResponse {
|
||||
repeated FaoFoodPricePoint points = 1; // last 12 months, ascending
|
||||
string fetched_at = 2;
|
||||
double current_ffpi = 3;
|
||||
double mom_pct = 4; // month-over-month % change
|
||||
double yoy_pct = 5; // year-over-year % change
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import "worldmonitor/economic/v1/get_eu_gas_storage.proto";
|
||||
import "worldmonitor/economic/v1/get_eu_yield_curve.proto";
|
||||
import "worldmonitor/economic/v1/get_eu_fsi.proto";
|
||||
import "worldmonitor/economic/v1/get_economic_stress.proto";
|
||||
import "worldmonitor/economic/v1/get_fao_food_price_index.proto";
|
||||
|
||||
// EconomicService provides APIs for macroeconomic data from FRED, World Bank, and EIA.
|
||||
service EconomicService {
|
||||
@@ -145,4 +146,9 @@ service EconomicService {
|
||||
rpc GetEconomicStress(GetEconomicStressRequest) returns (GetEconomicStressResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-economic-stress", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// GetFaoFoodPriceIndex retrieves the FAO Food Price Index for the past 12 months.
|
||||
rpc GetFaoFoodPriceIndex(GetFaoFoodPriceIndexRequest) returns (GetFaoFoodPriceIndexResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-fao-food-price-index", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9414,7 +9414,8 @@ Market & Crypto:
|
||||
Economic & Energy:
|
||||
macroSignals, bisPolicy, bisExchange, bisCredit, nationalDebt, bigmac, fuelPrices,
|
||||
euGasStorage, natGasStorage, crudeInventories, ecbFxRates, euFsi, groceryBasket,
|
||||
eurostatCountryData, progressData, renewableEnergy, spending, correlationCards
|
||||
eurostatCountryData, progressData, renewableEnergy, spending, correlationCards,
|
||||
faoFoodPriceIndex
|
||||
|
||||
Tech & Intelligence:
|
||||
techReadiness, techEvents, riskScores, crossSourceSignals, securityAdvisories,
|
||||
@@ -9994,7 +9995,8 @@ Market & Crypto:
|
||||
Economic & Energy:
|
||||
macroSignals, bisPolicy, bisExchange, bisCredit, nationalDebt, bigmac, fuelPrices,
|
||||
euGasStorage, natGasStorage, crudeInventories, ecbFxRates, euFsi, groceryBasket,
|
||||
eurostatCountryData, progressData, renewableEnergy, spending, correlationCards
|
||||
eurostatCountryData, progressData, renewableEnergy, spending, correlationCards,
|
||||
faoFoodPriceIndex
|
||||
|
||||
Tech & Intelligence:
|
||||
techReadiness, techEvents, riskScores, crossSourceSignals, securityAdvisories,
|
||||
|
||||
100
scripts/seed-fao-food-price-index.mjs
Normal file
100
scripts/seed-fao-food-price-index.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Seed script: fetches the FAO Food Price Index (FFPI) CSV and writes
|
||||
* the past 12 months to Upstash Redis.
|
||||
*
|
||||
* Source: https://www.fao.org/media/docs/worldfoodsituationlibraries/default-document-library/food_price_indices_data.csv
|
||||
* Released: first Friday of each month ~08:30 UTC
|
||||
*
|
||||
* Railway cron: 45 8 * * * (daily at 08:45 UTC — over-seeds safely; FAO releases ~first Friday of month)
|
||||
*/
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'economic:fao-ffpi:v1';
|
||||
const CACHE_TTL = 90 * 24 * 60 * 60; // 90 days — monthly seed, 3x interval per gold standard
|
||||
const CSV_URL = 'https://www.fao.org/media/docs/worldfoodsituationlibraries/default-document-library/food_price_indices_data.csv';
|
||||
const MONTHS_TO_KEEP = 12;
|
||||
|
||||
async function fetchFaoFfpi() {
|
||||
const resp = await globalThis.fetch(CSV_URL, {
|
||||
headers: { 'User-Agent': CHROME_UA, 'Accept': 'text/csv,text/plain,*/*' },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`FAO CSV HTTP ${resp.status}`);
|
||||
|
||||
const raw = await resp.text();
|
||||
// Strip BOM if present
|
||||
const text = raw.startsWith('\ufeff') ? raw.slice(1) : raw;
|
||||
|
||||
// CSV structure:
|
||||
// Row 0: "FAO Food Price Index" (title)
|
||||
// Row 1: "2014-2016=100" (base note)
|
||||
// Row 2: "Date,Food Price Index,Meat,Dairy,Cereals,Oils,Sugar" (header)
|
||||
// Row 3: blank
|
||||
// Row 4+: YYYY-MM,value,...
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
|
||||
const dataLines = lines.filter(l => /^\d{4}-\d{2},/.test(l));
|
||||
if (dataLines.length === 0) throw new Error('FAO CSV: no data rows found');
|
||||
|
||||
function parseVal(s) {
|
||||
const v = parseFloat(s);
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
const allPoints = dataLines.map(line => {
|
||||
const [date, ffpi, meat, dairy, cereals, oils, sugar] = line.split(',').map(s => s.trim());
|
||||
return {
|
||||
date,
|
||||
ffpi: parseVal(ffpi),
|
||||
meat: parseVal(meat),
|
||||
dairy: parseVal(dairy),
|
||||
cereals: parseVal(cereals),
|
||||
oils: parseVal(oils),
|
||||
sugar: parseVal(sugar),
|
||||
};
|
||||
});
|
||||
|
||||
// Need MONTHS_TO_KEEP + 1 points: 12 for display + 1 year-ago for YoY
|
||||
const recentPoints = allPoints.slice(-(MONTHS_TO_KEEP + 1));
|
||||
|
||||
if (recentPoints.length < 2) throw new Error('FAO CSV: insufficient data rows');
|
||||
|
||||
const last = recentPoints[recentPoints.length - 1];
|
||||
const prev = recentPoints[recentPoints.length - 2];
|
||||
const yearAgo = recentPoints.length >= 13 ? recentPoints[recentPoints.length - 13] : null;
|
||||
|
||||
const momPct = prev.ffpi > 0
|
||||
? +((last.ffpi - prev.ffpi) / prev.ffpi * 100).toFixed(2)
|
||||
: 0;
|
||||
|
||||
const yoyPct = yearAgo && yearAgo.ffpi > 0
|
||||
? +((last.ffpi - yearAgo.ffpi) / yearAgo.ffpi * 100).toFixed(2)
|
||||
: 0;
|
||||
|
||||
// Store only the last 12 months in the response
|
||||
const points = recentPoints.slice(-MONTHS_TO_KEEP);
|
||||
|
||||
console.log(` Latest: ${last.date} FFPI=${last.ffpi} MoM=${momPct}% YoY=${yoyPct}%`);
|
||||
|
||||
return {
|
||||
points,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
currentFfpi: last.ffpi,
|
||||
momPct,
|
||||
yoyPct,
|
||||
};
|
||||
}
|
||||
|
||||
const isMain = process.argv[1]?.endsWith('seed-fao-food-price-index.mjs');
|
||||
if (isMain) {
|
||||
await runSeed('economic', 'fao-ffpi', CANONICAL_KEY, fetchFaoFfpi, {
|
||||
ttlSeconds: CACHE_TTL,
|
||||
validateFn: (data) => data?.points?.length > 0,
|
||||
recordCount: (data) => data?.points?.length || 0,
|
||||
});
|
||||
}
|
||||
@@ -126,6 +126,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
pizzint: 'intelligence:pizzint:seed:v1',
|
||||
diseaseOutbreaks: 'health:disease-outbreaks:v1',
|
||||
economicStress: 'economic:stress-index:v1',
|
||||
faoFoodPriceIndex: 'economic:fao-ffpi:v1',
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
@@ -169,4 +170,5 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
pizzint: 'slow',
|
||||
diseaseOutbreaks: 'slow',
|
||||
economicStress: 'slow',
|
||||
faoFoodPriceIndex: 'slow',
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/economic/v1/list-grocery-basket-prices': 'static',
|
||||
'/api/economic/v1/list-bigmac-prices': 'static',
|
||||
'/api/economic/v1/list-fuel-prices': 'static',
|
||||
'/api/economic/v1/get-fao-food-price-index': 'static',
|
||||
'/api/economic/v1/get-crude-inventories': 'static',
|
||||
'/api/economic/v1/get-nat-gas-storage': 'static',
|
||||
'/api/economic/v1/get-eu-yield-curve': 'daily',
|
||||
|
||||
35
server/worldmonitor/economic/v1/get-fao-food-price-index.ts
Normal file
35
server/worldmonitor/economic/v1/get-fao-food-price-index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* RPC: getFaoFoodPriceIndex -- reads seeded FAO FFPI data from Railway seed cache.
|
||||
* All data fetching happens in seed-fao-food-price-index.mjs on Railway.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ServerContext,
|
||||
GetFaoFoodPriceIndexRequest,
|
||||
GetFaoFoodPriceIndexResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server';
|
||||
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const SEED_CACHE_KEY = 'economic:fao-ffpi:v1';
|
||||
|
||||
const EMPTY: GetFaoFoodPriceIndexResponse = {
|
||||
points: [],
|
||||
fetchedAt: '',
|
||||
currentFfpi: 0,
|
||||
momPct: 0,
|
||||
yoyPct: 0,
|
||||
};
|
||||
|
||||
export async function getFaoFoodPriceIndex(
|
||||
_ctx: ServerContext,
|
||||
_req: GetFaoFoodPriceIndexRequest,
|
||||
): Promise<GetFaoFoodPriceIndexResponse> {
|
||||
try {
|
||||
const result = await getCachedJson(SEED_CACHE_KEY, true) as GetFaoFoodPriceIndexResponse | null;
|
||||
if (!result?.points?.length) return EMPTY;
|
||||
return result;
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getEuGasStorage } from './get-eu-gas-storage';
|
||||
import { getEuYieldCurve } from './get-eu-yield-curve';
|
||||
import { getEuFsi } from './get-eu-fsi';
|
||||
import { getEconomicStress } from './get-economic-stress';
|
||||
import { getFaoFoodPriceIndex } from './get-fao-food-price-index';
|
||||
|
||||
export const economicHandler: EconomicServiceHandler = {
|
||||
getFredSeries,
|
||||
@@ -48,4 +49,5 @@ export const economicHandler: EconomicServiceHandler = {
|
||||
getEuYieldCurve,
|
||||
getEuFsi,
|
||||
getEconomicStress,
|
||||
getFaoFoodPriceIndex,
|
||||
};
|
||||
|
||||
12
src/App.ts
12
src/App.ts
@@ -36,6 +36,7 @@ import type { GulfEconomiesPanel } from '@/components/GulfEconomiesPanel';
|
||||
import type { GroceryBasketPanel } from '@/components/GroceryBasketPanel';
|
||||
import type { BigMacPanel } from '@/components/BigMacPanel';
|
||||
import type { FuelPricesPanel } from '@/components/FuelPricesPanel';
|
||||
import type { FaoFoodPriceIndexPanel } from '@/components/FaoFoodPriceIndexPanel';
|
||||
import type { ConsumerPricesPanel } from '@/components/ConsumerPricesPanel';
|
||||
import type { DefensePatentsPanel } from '@/components/DefensePatentsPanel';
|
||||
import type { MacroTilesPanel } from '@/components/MacroTilesPanel';
|
||||
@@ -286,6 +287,10 @@ export class App {
|
||||
const panel = this.state.panels['fuel-prices'] as FuelPricesPanel | undefined;
|
||||
if (panel) primeTask('fuel-prices', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('fao-food-price-index')) {
|
||||
const panel = this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel | undefined;
|
||||
if (panel) primeTask('fao-food-price-index', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('consumer-prices')) {
|
||||
const panel = this.state.panels['consumer-prices'] as ConsumerPricesPanel | undefined;
|
||||
if (panel) primeTask('consumer-prices', () => panel.fetchData());
|
||||
@@ -1301,6 +1306,13 @@ export class App {
|
||||
() => this.isPanelNearViewport('fuel-prices')
|
||||
);
|
||||
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'fao-food-price-index',
|
||||
() => (this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel).fetchData(),
|
||||
REFRESH_INTERVALS.faoFoodPriceIndex,
|
||||
() => this.isPanelNearViewport('fao-food-price-index')
|
||||
);
|
||||
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'macro-tiles',
|
||||
() => (this.state.panels['macro-tiles'] as MacroTilesPanel).fetchData(),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
GroceryBasketPanel,
|
||||
BigMacPanel,
|
||||
FuelPricesPanel,
|
||||
FaoFoodPriceIndexPanel,
|
||||
WorldClockPanel,
|
||||
AirlineIntelPanel,
|
||||
AviationCommandBar,
|
||||
@@ -991,6 +992,10 @@ export class PanelLayoutManager implements AppModule {
|
||||
this.ctx.panels['fuel-prices'] = new FuelPricesPanel();
|
||||
}
|
||||
|
||||
if (this.shouldCreatePanel('fao-food-price-index') && !this.ctx.panels['fao-food-price-index']) {
|
||||
this.ctx.panels['fao-food-price-index'] = new FaoFoodPriceIndexPanel();
|
||||
}
|
||||
|
||||
if (this.shouldCreatePanel('live-news') &&
|
||||
(getDefaultLiveChannels().length > 0 || loadChannelsFromStorage().length > 0)) {
|
||||
this.ctx.panels['live-news'] = new LiveNewsPanel();
|
||||
|
||||
156
src/components/FaoFoodPriceIndexPanel.ts
Normal file
156
src/components/FaoFoodPriceIndexPanel.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Panel } from './Panel';
|
||||
import { t } from '@/services/i18n';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
import { getRpcBaseUrl } from '@/services/rpc-client';
|
||||
import { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client';
|
||||
import type { GetFaoFoodPriceIndexResponse, FaoFoodPricePoint } from '@/generated/client/worldmonitor/economic/v1/service_client';
|
||||
|
||||
const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
|
||||
|
||||
const SVG_W = 480;
|
||||
const SVG_H = 140;
|
||||
const ML = 36;
|
||||
const MR = 12;
|
||||
const MT = 8;
|
||||
const MB = 20;
|
||||
const CW = SVG_W - ML - MR;
|
||||
const CH = SVG_H - MT - MB;
|
||||
|
||||
const SERIES: { key: keyof FaoFoodPricePoint; color: string; label: string }[] = [
|
||||
{ key: 'ffpi', color: '#f5a623', label: 'Food' },
|
||||
{ key: 'cereals', color: '#7ed321', label: 'Cereals' },
|
||||
{ key: 'meat', color: '#e86c6c', label: 'Meat' },
|
||||
{ key: 'dairy', color: '#74c8e8', label: 'Dairy' },
|
||||
{ key: 'oils', color: '#b57ce8', label: 'Oils' },
|
||||
{ key: 'sugar', color: '#f0c36a', label: 'Sugar' },
|
||||
];
|
||||
|
||||
function xPos(i: number, total: number): number {
|
||||
if (total <= 1) return ML + CW / 2;
|
||||
return ML + (i / (total - 1)) * CW;
|
||||
}
|
||||
|
||||
function yPos(v: number, yMin: number, yMax: number): number {
|
||||
const range = yMax - yMin || 1;
|
||||
return MT + CH - ((v - yMin) / range) * CH;
|
||||
}
|
||||
|
||||
function buildLine(points: FaoFoodPricePoint[], key: keyof FaoFoodPricePoint, yMin: number, yMax: number): string {
|
||||
const coords = points
|
||||
.map((p, i) => {
|
||||
const v = p[key] as number;
|
||||
if (!Number.isFinite(v) || v <= 0) return null;
|
||||
return `${xPos(i, points.length).toFixed(1)},${yPos(v, yMin, yMax).toFixed(1)}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return coords;
|
||||
}
|
||||
|
||||
function buildChart(points: FaoFoodPricePoint[]): string {
|
||||
if (!points.length) return '';
|
||||
|
||||
// Collect all values to compute y range
|
||||
const vals: number[] = [];
|
||||
for (const p of points) {
|
||||
for (const s of SERIES) {
|
||||
const v = p[s.key] as number;
|
||||
if (Number.isFinite(v) && v > 0) vals.push(v);
|
||||
}
|
||||
}
|
||||
const yMin = Math.floor(Math.min(...vals) * 0.96);
|
||||
const yMax = Math.ceil(Math.max(...vals) * 1.02);
|
||||
|
||||
// Y-axis labels (4 ticks: bottom, 1/3, 2/3, top)
|
||||
const yAxis = [0, 1, 2, 3].map(i => {
|
||||
const v = yMin + ((yMax - yMin) / 3) * i;
|
||||
const y = yPos(v, yMin, yMax);
|
||||
return `
|
||||
<line x1="${ML}" y1="${y.toFixed(1)}" x2="${SVG_W - MR}" y2="${y.toFixed(1)}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
|
||||
<text x="${(ML - 3).toFixed(0)}" y="${y.toFixed(1)}" text-anchor="end" fill="rgba(255,255,255,0.35)" font-size="8" dominant-baseline="middle">${v.toFixed(0)}</text>`;
|
||||
}).join('');
|
||||
|
||||
// X-axis labels (show every 3rd month to avoid crowding)
|
||||
const xAxis = points.map((p, i) => {
|
||||
if (i % 3 !== 0 && i !== points.length - 1) return '';
|
||||
const x = xPos(i, points.length);
|
||||
const label = p.date;
|
||||
return `<text x="${x.toFixed(1)}" y="${SVG_H - MB + 12}" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-size="7">${escapeHtml(label)}</text>`;
|
||||
}).join('');
|
||||
|
||||
// Series lines
|
||||
const lines = SERIES.map(s => {
|
||||
const coords = buildLine(points, s.key, yMin, yMax);
|
||||
if (!coords) return '';
|
||||
return `<polyline points="${coords}" fill="none" stroke="${s.color}" stroke-width="${s.key === 'ffpi' ? 2 : 1.2}" opacity="${s.key === 'ffpi' ? 1 : 0.7}"/>`;
|
||||
}).join('');
|
||||
|
||||
return `<svg viewBox="0 0 ${SVG_W} ${SVG_H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;display:block">${yAxis}${xAxis}${lines}</svg>`;
|
||||
}
|
||||
|
||||
function buildLegend(): string {
|
||||
return SERIES.map(s =>
|
||||
`<span class="fao-legend-item"><span class="fao-legend-dot" style="background:${s.color}"></span>${escapeHtml(t(`components.faoFoodPriceIndex.${s.key}`))}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
export class FaoFoodPriceIndexPanel extends Panel {
|
||||
constructor() {
|
||||
super({ id: 'fao-food-price-index', title: t('panels.faoFoodPriceIndex'), infoTooltip: t('components.faoFoodPriceIndex.infoTooltip') });
|
||||
}
|
||||
|
||||
public async fetchData(): Promise<void> {
|
||||
try {
|
||||
const hydrated = getHydratedData('faoFoodPriceIndex') as GetFaoFoodPriceIndexResponse | undefined;
|
||||
if (hydrated?.points?.length) {
|
||||
if (!this.element?.isConnected) return;
|
||||
this.renderChart(hydrated);
|
||||
void client.getFaoFoodPriceIndex({}).then(data => {
|
||||
if (!this.element?.isConnected || !data.points?.length) return;
|
||||
this.renderChart(data);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const data = await client.getFaoFoodPriceIndex({});
|
||||
if (!this.element?.isConnected) return;
|
||||
this.renderChart(data);
|
||||
} catch (err) {
|
||||
if (this.isAbortError(err)) return;
|
||||
if (!this.element?.isConnected) return;
|
||||
this.showError(t('common.failedMarketData'), () => void this.fetchData());
|
||||
}
|
||||
}
|
||||
|
||||
private renderChart(data: GetFaoFoodPriceIndexResponse): void {
|
||||
if (!data.points?.length) {
|
||||
this.showError(t('common.failedMarketData'), () => void this.fetchData());
|
||||
return;
|
||||
}
|
||||
|
||||
const momSign = data.momPct >= 0 ? '+' : '';
|
||||
const yoySign = data.yoyPct >= 0 ? '+' : '';
|
||||
const momCls = data.momPct >= 0 ? 'fao-up' : 'fao-down';
|
||||
const yoyCls = data.yoyPct >= 0 ? 'fao-up' : 'fao-down';
|
||||
const latest = data.points[data.points.length - 1];
|
||||
|
||||
const headline = `
|
||||
<div class="fao-headline">
|
||||
<div class="fao-headline-primary">
|
||||
<span class="fao-index-value">${data.currentFfpi.toFixed(1)}</span>
|
||||
<span class="fao-index-label">${escapeHtml(t('components.faoFoodPriceIndex.indexLabel'))}</span>
|
||||
</div>
|
||||
<div class="fao-headline-changes">
|
||||
<span class="fao-change ${momCls}">${momSign}${data.momPct.toFixed(1)}% ${escapeHtml(t('components.faoFoodPriceIndex.mom'))}</span>
|
||||
<span class="fao-change ${yoyCls}">${yoySign}${data.yoyPct.toFixed(1)}% ${escapeHtml(t('components.faoFoodPriceIndex.yoy'))}</span>
|
||||
</div>
|
||||
<div class="fao-as-of">${escapeHtml(t('components.faoFoodPriceIndex.asOf'))} ${escapeHtml(latest?.date ?? '')}</div>
|
||||
</div>`;
|
||||
|
||||
const chart = buildChart(data.points);
|
||||
const legend = `<div class="fao-legend">${buildLegend()}</div>`;
|
||||
const base = `<div class="fao-base-note">${escapeHtml(t('components.faoFoodPriceIndex.baseNote'))}</div>`;
|
||||
|
||||
this.setContent(`<div class="fao-food-price-index-panel">${headline}${chart}${legend}${base}</div>`);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export * from './GulfEconomiesPanel';
|
||||
export * from './GroceryBasketPanel';
|
||||
export * from './BigMacPanel';
|
||||
export * from './FuelPricesPanel';
|
||||
export * from './FaoFoodPriceIndexPanel';
|
||||
export * from './WorldClockPanel';
|
||||
export { AirlineIntelPanel } from './AirlineIntelPanel';
|
||||
export { AviationCommandBar } from './AviationCommandBar';
|
||||
|
||||
@@ -163,6 +163,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:grocery-basket', keywords: ['grocery', 'grocery basket', 'grocery index', 'food prices', 'supermarket'], label: 'Panel: Grocery Index', icon: '\u{1F96C}', category: 'panels' },
|
||||
{ id: 'panel:bigmac', keywords: ['bigmac', 'big mac', 'big mac index', 'purchasing power parity', 'ppp'], label: 'Panel: Big Mac Index', icon: '\u{1F354}', category: 'panels' },
|
||||
{ id: 'panel:fuel-prices', keywords: ['fuel prices', 'gas prices', 'gasoline', 'diesel', 'petrol', 'fuel cost', 'pump prices'], label: 'Panel: Fuel Prices', icon: '\u26FD', category: 'panels' },
|
||||
{ id: 'panel:fao-food-price-index', keywords: ['fao', 'food price index', 'ffpi', 'food prices', 'cereals', 'fao food', 'global food prices', 'food inflation'], label: 'Panel: FAO Food Price Index', icon: '\u{1F33E}', category: 'panels' },
|
||||
{ id: 'panel:national-debt', keywords: ['national debt', 'debt clock', 'government debt', 'deficit'], label: 'Panel: National Debt Clock', icon: '\u{1F4B8}', category: 'panels' },
|
||||
{ id: 'panel:fsi', keywords: ['fsi', 'financial stress', 'financial stress indicator', 'systemic risk'], label: 'Panel: Financial Stress Indicator', icon: '\u{1F4C9}', category: 'panels' },
|
||||
{ id: 'panel:yield-curve', keywords: ['yield curve', 'rates', 'treasury', 'ecb rates', 'bond yield', 'inversion'], label: 'Panel: Yield Curve & Rates', icon: '\u{1F4C8}', category: 'panels' },
|
||||
|
||||
@@ -74,6 +74,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
'grocery-basket': { name: 'Grocery Index', enabled: false, priority: 2 },
|
||||
'bigmac': { name: 'Big Mac Index', enabled: false, priority: 2 },
|
||||
'fuel-prices': { name: 'Fuel Prices', enabled: false, priority: 2 },
|
||||
'fao-food-price-index': { name: 'FAO Food Price Index', enabled: false, priority: 2 },
|
||||
'etf-flows': { name: 'BTC ETF Tracker', enabled: true, priority: 2 },
|
||||
stablecoins: { name: 'Stablecoins', enabled: true, priority: 2 },
|
||||
'ucdp-events': { name: 'UCDP Conflict Events', enabled: true, priority: 2 },
|
||||
|
||||
@@ -47,6 +47,7 @@ export const REFRESH_INTERVALS = {
|
||||
gulfEconomies: 10 * 60 * 1000,
|
||||
groceryBasket: 6 * 60 * 60 * 1000,
|
||||
fuelPrices: 6 * 60 * 60 * 1000,
|
||||
faoFoodPriceIndex: 24 * 60 * 60 * 1000, // monthly data; refresh daily is sufficient
|
||||
intelligence: 15 * 60 * 1000,
|
||||
correlationEngine: 5 * 60 * 1000,
|
||||
defensePatents: 24 * 60 * 60 * 1000, // 24h — data is weekly, daily poll is sufficient
|
||||
|
||||
@@ -527,6 +527,27 @@ export interface EconomicStressComponent {
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface GetFaoFoodPriceIndexRequest {
|
||||
}
|
||||
|
||||
export interface GetFaoFoodPriceIndexResponse {
|
||||
points: FaoFoodPricePoint[];
|
||||
fetchedAt: string;
|
||||
currentFfpi: number;
|
||||
momPct: number;
|
||||
yoyPct: number;
|
||||
}
|
||||
|
||||
export interface FaoFoodPricePoint {
|
||||
date: string;
|
||||
ffpi: number;
|
||||
meat: number;
|
||||
dairy: number;
|
||||
cereals: number;
|
||||
oils: number;
|
||||
sugar: number;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -1125,6 +1146,29 @@ export class EconomicServiceClient {
|
||||
return await resp.json() as GetEconomicStressResponse;
|
||||
}
|
||||
|
||||
async getFaoFoodPriceIndex(req: GetFaoFoodPriceIndexRequest, options?: EconomicServiceCallOptions): Promise<GetFaoFoodPriceIndexResponse> {
|
||||
let path = "/api/economic/v1/get-fao-food-price-index";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetFaoFoodPriceIndexResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -527,6 +527,27 @@ export interface EconomicStressComponent {
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface GetFaoFoodPriceIndexRequest {
|
||||
}
|
||||
|
||||
export interface GetFaoFoodPriceIndexResponse {
|
||||
points: FaoFoodPricePoint[];
|
||||
fetchedAt: string;
|
||||
currentFfpi: number;
|
||||
momPct: number;
|
||||
yoyPct: number;
|
||||
}
|
||||
|
||||
export interface FaoFoodPricePoint {
|
||||
date: string;
|
||||
ffpi: number;
|
||||
meat: number;
|
||||
dairy: number;
|
||||
cereals: number;
|
||||
oils: number;
|
||||
sugar: number;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -595,6 +616,7 @@ export interface EconomicServiceHandler {
|
||||
getEuYieldCurve(ctx: ServerContext, req: GetEuYieldCurveRequest): Promise<GetEuYieldCurveResponse>;
|
||||
getEuFsi(ctx: ServerContext, req: GetEuFsiRequest): Promise<GetEuFsiResponse>;
|
||||
getEconomicStress(ctx: ServerContext, req: GetEconomicStressRequest): Promise<GetEconomicStressResponse>;
|
||||
getFaoFoodPriceIndex(ctx: ServerContext, req: GetFaoFoodPriceIndexRequest): Promise<GetFaoFoodPriceIndexResponse>;
|
||||
}
|
||||
|
||||
export function createEconomicServiceRoutes(
|
||||
@@ -1527,6 +1549,43 @@ export function createEconomicServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/economic/v1/get-fao-food-price-index",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = {} as GetFaoFoodPriceIndexRequest;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getFaoFoodPriceIndex(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetFaoFoodPriceIndexResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
"bigmacDesc": "Big Mac prices across Middle East countries (Big Mac Index)",
|
||||
"bigmacWow": "WoW",
|
||||
"bigmacCountry": "Country",
|
||||
"faoFoodPriceIndex": "FAO Food Price Index",
|
||||
"fuelPrices": "Fuel Prices",
|
||||
"fuelPricesDesc": "Retail gasoline and diesel prices across 30+ countries worldwide",
|
||||
"fuelPricesCountry": "Country",
|
||||
@@ -1937,6 +1938,20 @@
|
||||
"infoTooltip": "<strong>Fuel Prices</strong> Retail pump prices for gasoline and diesel across 30+ countries, normalized to USD/liter for comparison. Prices are sourced from official government open-data programs and updated weekly. Cheapest and most expensive countries are highlighted. WoW change shown when prior week data is available.",
|
||||
"countries": "countries"
|
||||
},
|
||||
"faoFoodPriceIndex": {
|
||||
"infoTooltip": "<strong>FAO Food Price Index</strong> The UN Food and Agriculture Organization's FFPI tracks international export prices for a basket of food commodities (Cereals, Dairy, Meat, Oils, Sugar), base 2014-2016=100. Updated monthly on the first Friday of each month.",
|
||||
"indexLabel": "FFPI (2014-16=100)",
|
||||
"mom": "MoM",
|
||||
"yoy": "YoY",
|
||||
"asOf": "As of",
|
||||
"baseNote": "Base: 2014-2016 = 100 | Source: UN FAO",
|
||||
"ffpi": "Food",
|
||||
"cereals": "Cereals",
|
||||
"meat": "Meat",
|
||||
"dairy": "Dairy",
|
||||
"oils": "Oils",
|
||||
"sugar": "Sugar"
|
||||
},
|
||||
"panel": {
|
||||
"showMethodologyInfo": "Show methodology info",
|
||||
"dragToResize": "Drag to resize (double-click to reset)",
|
||||
|
||||
@@ -22096,6 +22096,22 @@ body.map-width-resizing {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* FAO Food Price Index panel */
|
||||
.fao-food-price-index-panel { padding: 4px 0; }
|
||||
.fao-headline { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 4px 12px 8px; }
|
||||
.fao-headline-primary { display: flex; align-items: baseline; gap: 6px; }
|
||||
.fao-index-value { font-size: 1.6rem; font-weight: 700; color: #f5a623; line-height: 1; }
|
||||
.fao-index-label { font-size: 0.65rem; opacity: 0.55; }
|
||||
.fao-headline-changes { display: flex; gap: 8px; }
|
||||
.fao-change { font-size: 0.75rem; font-weight: 600; }
|
||||
.fao-up { color: #f87171; }
|
||||
.fao-down { color: #4ade80; }
|
||||
.fao-as-of { font-size: 0.65rem; opacity: 0.4; margin-left: auto; }
|
||||
.fao-legend { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 12px 2px; }
|
||||
.fao-legend-item { display: flex; align-items: center; gap: 4px; font-size: 0.65rem; opacity: 0.7; }
|
||||
.fao-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.fao-base-note { font-size: 0.6rem; opacity: 0.35; padding: 2px 12px 6px; text-align: right; }
|
||||
|
||||
|
||||
/* ── Risk Score Badge on News Cards ──────────────────────────── */
|
||||
.risk-score-badge {
|
||||
|
||||
Reference in New Issue
Block a user