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:
Elie Habib
2026-04-04 17:33:54 +04:00
committed by GitHub
parent 9de81ecb2c
commit 4e9f25631c
23 changed files with 560 additions and 3 deletions

2
api/bootstrap.js vendored
View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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};
}
}

View File

@@ -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,

View 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,
});
}

View File

@@ -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',
};

View File

@@ -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',

View 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;
}
}

View File

@@ -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,
};

View File

@@ -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(),

View File

@@ -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();

View 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>`);
}
}

View File

@@ -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';

View File

@@ -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' },

View File

@@ -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 },

View File

@@ -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

View File

@@ -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) {

View File

@@ -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" },
});
}
},
},
];
}

View File

@@ -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)",

View File

@@ -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 {